@happyvertical/smrt-svelte 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/dist/Provider.svelte +12 -2
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/components/admin/AgentSettingsShell.svelte +72 -4
- package/dist/components/admin/AgentSettingsShell.svelte.d.ts.map +1 -1
- package/dist/components/admin/__tests__/AgentSettingsShell.tablist.test.js +93 -0
- package/dist/components/forms/DateRangeInput.svelte +29 -5
- package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/DateTimeInput.svelte +34 -7
- package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/FileUpload.svelte +3 -1
- package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
- package/dist/components/forms/Form.svelte +72 -36
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/FormMicButton.svelte +14 -11
- package/dist/components/forms/FormMicButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/PhoneInput.svelte +29 -5
- package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextInput.svelte +35 -7
- package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextareaInput.svelte +29 -5
- package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/FileUpload.error-alert.test.js +37 -0
- package/dist/components/forms/__tests__/Form.stt-error.test.js +57 -0
- package/dist/components/forms/__tests__/FormMicButton.test.js +7 -0
- package/dist/components/forms/__tests__/mic-keyboard-a11y.test.js +74 -0
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte +25 -0
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts +15 -0
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts.map +1 -0
- package/dist/hooks/__tests__/stt-ownership-harness.svelte +42 -0
- package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts +20 -0
- package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts.map +1 -0
- package/dist/hooks/__tests__/useSTT-ownership.test.js +102 -0
- package/dist/hooks/useSTT.svelte.d.ts.map +1 -1
- package/dist/hooks/useSTT.svelte.js +20 -6
- package/dist/hooks/useTTS.svelte.d.ts.map +1 -1
- package/dist/hooks/useTTS.svelte.js +20 -6
- package/dist/i18n/server.d.ts +5 -5
- package/dist/i18n/server.js +5 -5
- package/dist/internal/logger.d.ts +13 -0
- package/dist/internal/logger.d.ts.map +1 -0
- package/dist/internal/logger.js +12 -0
- package/dist/state/__tests__/app-state-ai-lifecycle.test.js +240 -0
- package/dist/state/app-state.svelte.d.ts +40 -8
- package/dist/state/app-state.svelte.d.ts.map +1 -1
- package/dist/state/app-state.svelte.js +224 -54
- package/package.json +6 -5
|
@@ -4,6 +4,7 @@ import { onDestroy, onMount } from 'svelte';
|
|
|
4
4
|
import { useAppState } from '../../hooks/useAppState.svelte.js';
|
|
5
5
|
import { useSTT } from '../../hooks/useSTT.svelte.js';
|
|
6
6
|
import { M } from '../../i18n/strings.forms.js';
|
|
7
|
+
import { logger } from '../../internal/logger.js';
|
|
7
8
|
import {
|
|
8
9
|
type FieldDefinition,
|
|
9
10
|
tryGetFormContext,
|
|
@@ -162,15 +163,26 @@ onDestroy(() => {
|
|
|
162
163
|
async function startHoldRecording() {
|
|
163
164
|
if (!isSmrt || disabled || isProcessing) return;
|
|
164
165
|
|
|
165
|
-
if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
|
|
166
|
-
await stt.initialize({ type: 'whisper-wasm' });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
166
|
processError = null;
|
|
170
167
|
recordingStartTime = Date.now();
|
|
171
168
|
isHolding = true;
|
|
172
169
|
|
|
173
|
-
|
|
170
|
+
// Guard STT init / mic acquisition so a rejection can't leave the field
|
|
171
|
+
// wedged in a permanent "Recording..." state with no visible error (C2).
|
|
172
|
+
try {
|
|
173
|
+
if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
|
|
174
|
+
await stt.initialize({ type: 'whisper-wasm' });
|
|
175
|
+
}
|
|
176
|
+
await stt.start({ continuous: true, interimResults: false });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
isHolding = false;
|
|
179
|
+
processError =
|
|
180
|
+
err instanceof Error ? err.message : 'Could not start voice input';
|
|
181
|
+
logger.error('PhoneInput: failed to start voice recording', {
|
|
182
|
+
field: name,
|
|
183
|
+
error: err,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
async function stopHoldRecording() {
|
|
@@ -232,6 +244,17 @@ function handleTouchEnd() {
|
|
|
232
244
|
stopHoldRecording();
|
|
233
245
|
}
|
|
234
246
|
|
|
247
|
+
// Keyboard activation for the mic (WCAG 2.1.1): Enter/Space toggles recording.
|
|
248
|
+
function handleMicKeydown(e: KeyboardEvent) {
|
|
249
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
if (isHolding) {
|
|
252
|
+
stopHoldRecording();
|
|
253
|
+
} else {
|
|
254
|
+
startHoldRecording();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
function handleInput(e: Event) {
|
|
236
259
|
const target = e.target as HTMLInputElement;
|
|
237
260
|
const formatted = formatPhoneNumber(target.value);
|
|
@@ -281,6 +304,7 @@ function handleInput(e: Event) {
|
|
|
281
304
|
class="mic-btn"
|
|
282
305
|
class:active={isHolding}
|
|
283
306
|
{disabled}
|
|
307
|
+
onkeydown={handleMicKeydown}
|
|
284
308
|
onmousedown={handleMouseDown}
|
|
285
309
|
onmouseup={handleMouseUp}
|
|
286
310
|
onmouseleave={handleMouseLeave}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PhoneInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/PhoneInput.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"PhoneInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/PhoneInput.svelte.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA4SD,QAAA,MAAM,UAAU,gDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|
|
@@ -9,6 +9,7 @@ import { onDestroy, onMount } from 'svelte';
|
|
|
9
9
|
import { useAppState } from '../../hooks/useAppState.svelte.js';
|
|
10
10
|
import { useSTT } from '../../hooks/useSTT.svelte.js';
|
|
11
11
|
import { M } from '../../i18n/strings.forms.js';
|
|
12
|
+
import { logger } from '../../internal/logger.js';
|
|
12
13
|
import {
|
|
13
14
|
type FieldDefinition,
|
|
14
15
|
tryGetFormContext,
|
|
@@ -115,19 +116,31 @@ onDestroy(() => {
|
|
|
115
116
|
async function startHoldRecording() {
|
|
116
117
|
if (!isSmrt || disabled || isProcessing) return;
|
|
117
118
|
|
|
118
|
-
// Initialize STT with Whisper v2 for speed + accuracy
|
|
119
|
-
if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
|
|
120
|
-
await stt.initialize({ type: 'whisper-wasm' });
|
|
121
|
-
}
|
|
122
|
-
|
|
123
119
|
// Store current value for append mode
|
|
124
120
|
valueBeforeRecording = value;
|
|
125
121
|
processError = null;
|
|
126
122
|
recordingStartTime = Date.now();
|
|
127
123
|
isHolding = true;
|
|
128
124
|
|
|
129
|
-
//
|
|
130
|
-
|
|
125
|
+
// STT init / mic acquisition can reject (model fetch failure, mic-permission
|
|
126
|
+
// denial). Without this guard `isHolding` would stay true and no error would
|
|
127
|
+
// surface, wedging the field in a permanent "Recording..." state (C2).
|
|
128
|
+
try {
|
|
129
|
+
// Initialize STT with Whisper v2 for speed + accuracy
|
|
130
|
+
if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
|
|
131
|
+
await stt.initialize({ type: 'whisper-wasm' });
|
|
132
|
+
}
|
|
133
|
+
// Use continuous mode to capture all speech while holding
|
|
134
|
+
await stt.start({ continuous: true, interimResults: false });
|
|
135
|
+
} catch (err) {
|
|
136
|
+
isHolding = false;
|
|
137
|
+
processError =
|
|
138
|
+
err instanceof Error ? err.message : 'Could not start voice input';
|
|
139
|
+
logger.error('TextInput: failed to start voice recording', {
|
|
140
|
+
field: name,
|
|
141
|
+
error: err,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
async function stopHoldRecording() {
|
|
@@ -202,6 +215,20 @@ function handleMicClick(e: MouseEvent) {
|
|
|
202
215
|
e.stopPropagation();
|
|
203
216
|
}
|
|
204
217
|
|
|
218
|
+
// Keyboard activation for the mic (WCAG 2.1.1). The pointer flow is
|
|
219
|
+
// hold-to-record; keyboard maps to a toggle — Enter/Space starts recording,
|
|
220
|
+
// pressing again stops. preventDefault stops Space from also firing the
|
|
221
|
+
// button's synthetic click and from scrolling the page.
|
|
222
|
+
function handleMicKeydown(e: KeyboardEvent) {
|
|
223
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
if (isHolding) {
|
|
226
|
+
stopHoldRecording();
|
|
227
|
+
} else {
|
|
228
|
+
startHoldRecording();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
205
232
|
function handleInput(e: Event) {
|
|
206
233
|
const target = e.target as HTMLInputElement;
|
|
207
234
|
updateValue(target.value);
|
|
@@ -253,6 +280,7 @@ function handleInput(e: Event) {
|
|
|
253
280
|
{disabled}
|
|
254
281
|
use:ripple
|
|
255
282
|
onclick={handleMicClick}
|
|
283
|
+
onkeydown={handleMicKeydown}
|
|
256
284
|
onmousedown={(e) => { e.stopPropagation(); if (e.button === 0) startHoldRecording(); }}
|
|
257
285
|
onmouseup={handleMouseUp}
|
|
258
286
|
onmouseleave={handleMouseLeave}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TextInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextInput.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TextInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextInput.svelte.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACxB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAwPD,QAAA,MAAM,SAAS,gDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -6,6 +6,7 @@ import { onDestroy, onMount } from 'svelte';
|
|
|
6
6
|
import { useAppState } from '../../hooks/useAppState.svelte.js';
|
|
7
7
|
import { useSTT } from '../../hooks/useSTT.svelte.js';
|
|
8
8
|
import { M } from '../../i18n/strings.forms.js';
|
|
9
|
+
import { logger } from '../../internal/logger.js';
|
|
9
10
|
import {
|
|
10
11
|
type FieldDefinition,
|
|
11
12
|
tryGetFormContext,
|
|
@@ -103,16 +104,27 @@ onDestroy(() => {
|
|
|
103
104
|
async function startHoldRecording() {
|
|
104
105
|
if (!isSmrt || disabled || isProcessing) return;
|
|
105
106
|
|
|
106
|
-
if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
|
|
107
|
-
await stt.initialize({ type: 'whisper-wasm' });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
107
|
valueBeforeRecording = value;
|
|
111
108
|
processError = null;
|
|
112
109
|
recordingStartTime = Date.now();
|
|
113
110
|
isHolding = true;
|
|
114
111
|
|
|
115
|
-
|
|
112
|
+
// Guard STT init / mic acquisition so a rejection can't leave the field
|
|
113
|
+
// wedged in a permanent "Recording..." state with no visible error (C2).
|
|
114
|
+
try {
|
|
115
|
+
if (!stt.isReady || stt.adapterType !== 'whisper-wasm') {
|
|
116
|
+
await stt.initialize({ type: 'whisper-wasm' });
|
|
117
|
+
}
|
|
118
|
+
await stt.start({ continuous: true, interimResults: false });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
isHolding = false;
|
|
121
|
+
processError =
|
|
122
|
+
err instanceof Error ? err.message : 'Could not start voice input';
|
|
123
|
+
logger.error('TextareaInput: failed to start voice recording', {
|
|
124
|
+
field: name,
|
|
125
|
+
error: err,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
async function stopHoldRecording() {
|
|
@@ -179,6 +191,17 @@ function handleTouchEnd() {
|
|
|
179
191
|
stopHoldRecording();
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
// Keyboard activation for the mic (WCAG 2.1.1): Enter/Space toggles recording.
|
|
195
|
+
function handleMicKeydown(e: KeyboardEvent) {
|
|
196
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
if (isHolding) {
|
|
199
|
+
stopHoldRecording();
|
|
200
|
+
} else {
|
|
201
|
+
startHoldRecording();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
182
205
|
function handleInput(e: Event) {
|
|
183
206
|
const target = e.target as HTMLTextAreaElement;
|
|
184
207
|
updateValue(target.value);
|
|
@@ -228,6 +251,7 @@ function handleInput(e: Event) {
|
|
|
228
251
|
class:active={isHolding}
|
|
229
252
|
{disabled}
|
|
230
253
|
use:ripple
|
|
254
|
+
onkeydown={handleMicKeydown}
|
|
231
255
|
onmousedown={(e) => { e.stopPropagation(); if (e.button === 0) startHoldRecording(); }}
|
|
232
256
|
onmouseup={handleMouseUp}
|
|
233
257
|
onmouseleave={handleMouseLeave}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TextareaInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextareaInput.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TextareaInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/TextareaInput.svelte.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA8ND,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: FileUpload validation errors are announced (C7).
|
|
3
|
+
*
|
|
4
|
+
* The error `<p>` was a plain element, so async reject errors (wrong type /
|
|
5
|
+
* oversize) were not surfaced to screen readers — unlike Form/TextInput which
|
|
6
|
+
* announce. The fix makes it a live region (role="alert" + aria-live).
|
|
7
|
+
*/
|
|
8
|
+
import { render, screen, waitFor } from '@testing-library/svelte';
|
|
9
|
+
import userEvent from '@testing-library/user-event';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import FileUpload from '../FileUpload.svelte';
|
|
12
|
+
function fileInput(container) {
|
|
13
|
+
const input = container.querySelector('input[type="file"]');
|
|
14
|
+
if (!input)
|
|
15
|
+
throw new Error('file input not found');
|
|
16
|
+
return input;
|
|
17
|
+
}
|
|
18
|
+
describe('FileUpload — error live region (C7)', () => {
|
|
19
|
+
it('announces a max-size error via role="alert"', async () => {
|
|
20
|
+
const { container } = render(FileUpload, {
|
|
21
|
+
// maxSize forces a validation error without an `accept` filter (which
|
|
22
|
+
// userEvent.upload would otherwise apply before the file reaches the
|
|
23
|
+
// component, suppressing the error path under test).
|
|
24
|
+
props: { label: 'Upload', maxSize: 4 },
|
|
25
|
+
});
|
|
26
|
+
const big = new File(['way too many bytes'], 'big.bin', {
|
|
27
|
+
type: 'application/octet-stream',
|
|
28
|
+
});
|
|
29
|
+
await userEvent.upload(fileInput(container), big);
|
|
30
|
+
await waitFor(() => {
|
|
31
|
+
const alert = screen.getByRole('alert');
|
|
32
|
+
expect(alert).toBeInTheDocument();
|
|
33
|
+
expect(alert).toHaveAttribute('aria-live');
|
|
34
|
+
expect(alert.textContent).toMatch(/maximum size/i);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: Form voice-listening init failures don't wedge the form (C2/C3).
|
|
3
|
+
*
|
|
4
|
+
* `startFormListening` awaited `stt.initialize()`/`stt.start()` with no
|
|
5
|
+
* try/catch. On rejection the listening flags stayed set, the auto-stop $effect
|
|
6
|
+
* (gated on !isStarting) was suppressed, and the form was stuck in a permanent
|
|
7
|
+
* "listening" state with no surfaced error. The fix wraps the flow in
|
|
8
|
+
* try/catch/finally: reset the flags and show the error.
|
|
9
|
+
*/
|
|
10
|
+
import { render, screen, waitFor } from '@testing-library/svelte';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import { createRawSnippet } from 'svelte';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
const sttStub = {
|
|
15
|
+
isListening: false,
|
|
16
|
+
isInitializing: false,
|
|
17
|
+
isReady: false,
|
|
18
|
+
adapterType: null,
|
|
19
|
+
downloadProgress: 0,
|
|
20
|
+
lastResult: '',
|
|
21
|
+
initialize: vi.fn(async () => {
|
|
22
|
+
throw new Error('model download failed');
|
|
23
|
+
}),
|
|
24
|
+
start: vi.fn(async () => { }),
|
|
25
|
+
stop: vi.fn(async () => { }),
|
|
26
|
+
};
|
|
27
|
+
vi.mock('../../../hooks/useAppState.svelte.js', () => ({
|
|
28
|
+
useAppState: () => ({ state: { mode: 'smrt' }, setMode: vi.fn() }),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('../../../hooks/useSTT.svelte.js', () => ({
|
|
31
|
+
useSTT: () => sttStub,
|
|
32
|
+
}));
|
|
33
|
+
import Form from '../Form.svelte';
|
|
34
|
+
function child() {
|
|
35
|
+
return createRawSnippet(() => ({
|
|
36
|
+
render: () => '<span>fields</span>',
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
describe('Form — voice-listening init failure (C2)', () => {
|
|
40
|
+
it('surfaces an error and does not wedge when STT init rejects', async () => {
|
|
41
|
+
render(Form, {
|
|
42
|
+
props: { children: child(), showFormListen: true },
|
|
43
|
+
});
|
|
44
|
+
// The form-listen button is shown in smrt mode; clicking it starts listening.
|
|
45
|
+
const listenBtn = screen.getByRole('button');
|
|
46
|
+
await userEvent.click(listenBtn);
|
|
47
|
+
// initialize() rejected — an error must be surfaced (role=alert region).
|
|
48
|
+
await waitFor(() => {
|
|
49
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
expect(screen.getByRole('alert').textContent).toMatch(/model download failed/i);
|
|
52
|
+
// And the form is NOT wedged: the polite status region is not stuck on the
|
|
53
|
+
// "listening" message (listening flag was reset).
|
|
54
|
+
const status = screen.getByRole('status');
|
|
55
|
+
expect(status.textContent?.trim()).toBe('');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -68,6 +68,13 @@ describe('FormMicButton', () => {
|
|
|
68
68
|
await userEvent.keyboard('{Enter}');
|
|
69
69
|
expect(toggleListening).toHaveBeenCalledTimes(1);
|
|
70
70
|
});
|
|
71
|
+
it('toggles listening when activated with Space (CS2)', async () => {
|
|
72
|
+
render(FormMicButton);
|
|
73
|
+
const button = screen.getByRole('button', { name: 'Click to speak' });
|
|
74
|
+
button.focus();
|
|
75
|
+
await userEvent.keyboard(' ');
|
|
76
|
+
expect(toggleListening).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
71
78
|
it('relabels to "Stop listening" once the form is listening', async () => {
|
|
72
79
|
formState = { isFormListening: true, isExtracting: false };
|
|
73
80
|
render(FormMicButton);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: per-field voice mics are keyboard-operable (C1, WCAG 2.1.1).
|
|
3
|
+
*
|
|
4
|
+
* Before the fix the mic <button>s were bound only to mousedown/up + touch
|
|
5
|
+
* handlers (onclick was a no-op preventDefault), so a keyboard user could focus
|
|
6
|
+
* the mic but never record. The fix adds Enter/Space toggle activation, mirror-
|
|
7
|
+
* ing FormMicButton. TextInput is the representative editable field;
|
|
8
|
+
* DateTimeInput is covered too because its SMRT-mode text input is read-only —
|
|
9
|
+
* the mic is the ONLY keyboard entry path there.
|
|
10
|
+
*
|
|
11
|
+
* useAppState/useSTT throw outside a <Provider>, so both are mocked. The STT
|
|
12
|
+
* stub starts `isReady: true` so the hold flow skips initialize() and the
|
|
13
|
+
* start()/stop() spies fire synchronously enough to assert.
|
|
14
|
+
*/
|
|
15
|
+
import { render, screen } from '@testing-library/svelte';
|
|
16
|
+
import userEvent from '@testing-library/user-event';
|
|
17
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
18
|
+
const sttStub = {
|
|
19
|
+
isListening: false,
|
|
20
|
+
isInitializing: false,
|
|
21
|
+
isReady: true,
|
|
22
|
+
adapterType: 'whisper-wasm',
|
|
23
|
+
downloadProgress: 0,
|
|
24
|
+
lastResult: '',
|
|
25
|
+
initialize: vi.fn(async () => { }),
|
|
26
|
+
start: vi.fn(async () => { }),
|
|
27
|
+
stop: vi.fn(async () => { }),
|
|
28
|
+
};
|
|
29
|
+
vi.mock('../../../hooks/useAppState.svelte.js', () => ({
|
|
30
|
+
useAppState: () => ({ state: { mode: 'smrt' }, setMode: vi.fn() }),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('../../../hooks/useSTT.svelte.js', () => ({
|
|
33
|
+
useSTT: () => sttStub,
|
|
34
|
+
}));
|
|
35
|
+
import DateTimeInput from '../DateTimeInput.svelte';
|
|
36
|
+
import TextInput from '../TextInput.svelte';
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
sttStub.adapterType = 'whisper-wasm';
|
|
40
|
+
});
|
|
41
|
+
describe('TextInput mic — keyboard activation (C1)', () => {
|
|
42
|
+
it('exposes the mic as a focusable button with an accessible name', () => {
|
|
43
|
+
render(TextInput, { props: { name: 'note', label: 'Note' } });
|
|
44
|
+
const mic = screen.getByRole('button', { name: /speak|mic|voice/i });
|
|
45
|
+
expect(mic).toBeInTheDocument();
|
|
46
|
+
// Native <button> => in the tab order (not an empty/dead tab stop).
|
|
47
|
+
expect(mic).not.toHaveAttribute('tabindex', '-1');
|
|
48
|
+
});
|
|
49
|
+
it('starts recording on Enter and stops on the next Enter (toggle)', async () => {
|
|
50
|
+
render(TextInput, { props: { name: 'note', label: 'Note' } });
|
|
51
|
+
const mic = screen.getByRole('button', { name: /speak|mic|voice/i });
|
|
52
|
+
mic.focus();
|
|
53
|
+
await userEvent.keyboard('{Enter}');
|
|
54
|
+
expect(sttStub.start).toHaveBeenCalledTimes(1);
|
|
55
|
+
await userEvent.keyboard('{Enter}');
|
|
56
|
+
expect(sttStub.stop).toHaveBeenCalledTimes(1);
|
|
57
|
+
});
|
|
58
|
+
it('starts recording on Space', async () => {
|
|
59
|
+
render(TextInput, { props: { name: 'note', label: 'Note' } });
|
|
60
|
+
const mic = screen.getByRole('button', { name: /speak|mic|voice/i });
|
|
61
|
+
mic.focus();
|
|
62
|
+
await userEvent.keyboard(' ');
|
|
63
|
+
expect(sttStub.start).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('DateTimeInput mic — keyboard activation (C1)', () => {
|
|
67
|
+
it('records via keyboard even though the text field is read-only', async () => {
|
|
68
|
+
render(DateTimeInput, { props: { name: 'when', label: 'When' } });
|
|
69
|
+
const mic = screen.getByRole('button', { name: /speak|mic|voice|date/i });
|
|
70
|
+
mic.focus();
|
|
71
|
+
await userEvent.keyboard('{Enter}');
|
|
72
|
+
expect(sttStub.start).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Test consumer that calls the real `useSTT()` hook so its `onDestroy`
|
|
4
|
+
* per-hook ownership logic (R4) runs when this component unmounts.
|
|
5
|
+
*/
|
|
6
|
+
import { type UseSTTReturn, useSTT } from '../useSTT.svelte.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Hand the live hook back to the test so it can call start()/stop(). */
|
|
10
|
+
onReady?: (stt: UseSTTReturn) => void;
|
|
11
|
+
/** If true, start a listening session as soon as this consumer mounts. */
|
|
12
|
+
autoStart?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { onReady, autoStart = false }: Props = $props();
|
|
16
|
+
|
|
17
|
+
const stt = useSTT();
|
|
18
|
+
// This fixture intentionally snapshots props during setup (test-only).
|
|
19
|
+
// svelte-ignore state_referenced_locally
|
|
20
|
+
onReady?.(stt);
|
|
21
|
+
// svelte-ignore state_referenced_locally
|
|
22
|
+
if (autoStart) {
|
|
23
|
+
void stt.start();
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test consumer that calls the real `useSTT()` hook so its `onDestroy`
|
|
3
|
+
* per-hook ownership logic (R4) runs when this component unmounts.
|
|
4
|
+
*/
|
|
5
|
+
import { type UseSTTReturn } from '../useSTT.svelte.js';
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Hand the live hook back to the test so it can call start()/stop(). */
|
|
8
|
+
onReady?: (stt: UseSTTReturn) => void;
|
|
9
|
+
/** If true, start a listening session as soon as this consumer mounts. */
|
|
10
|
+
autoStart?: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare const SttConsumer: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type SttConsumer = ReturnType<typeof SttConsumer>;
|
|
14
|
+
export default SttConsumer;
|
|
15
|
+
//# sourceMappingURL=stt-consumer.fixture.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stt-consumer.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/hooks/__tests__/stt-consumer.fixture.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,EAAE,KAAK,YAAY,EAAU,MAAM,qBAAqB,CAAC;AAGhE,UAAU,KAAK;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;IACtC,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAoBD,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* R4 harness: sets a fake app-state manager on context, then mounts/unmounts
|
|
4
|
+
* `useSTT()` consumers so the test can prove per-hook ownership — unmounting a
|
|
5
|
+
* consumer that did NOT start listening must not stop the shared singleton.
|
|
6
|
+
*/
|
|
7
|
+
import type { SmrtAppStateManager } from '../../state/app-state.svelte.js';
|
|
8
|
+
import { setAppStateContext } from '../../state/context.js';
|
|
9
|
+
import type { UseSTTReturn } from '../useSTT.svelte.js';
|
|
10
|
+
import SttConsumer from './stt-consumer.fixture.svelte';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
manager: SmrtAppStateManager;
|
|
14
|
+
showA?: boolean;
|
|
15
|
+
showB?: boolean;
|
|
16
|
+
onReadyA?: (stt: UseSTTReturn) => void;
|
|
17
|
+
onReadyB?: (stt: UseSTTReturn) => void;
|
|
18
|
+
autoStartA?: boolean;
|
|
19
|
+
autoStartB?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
manager,
|
|
24
|
+
showA = true,
|
|
25
|
+
showB = true,
|
|
26
|
+
onReadyA,
|
|
27
|
+
onReadyB,
|
|
28
|
+
autoStartA = false,
|
|
29
|
+
autoStartB = false,
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
// Context is set once during setup with the initial manager (test-only).
|
|
33
|
+
// svelte-ignore state_referenced_locally
|
|
34
|
+
setAppStateContext(manager);
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
{#if showA}
|
|
38
|
+
<SttConsumer onReady={onReadyA} autoStart={autoStartA} />
|
|
39
|
+
{/if}
|
|
40
|
+
{#if showB}
|
|
41
|
+
<SttConsumer onReady={onReadyB} autoStart={autoStartB} />
|
|
42
|
+
{/if}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4 harness: sets a fake app-state manager on context, then mounts/unmounts
|
|
3
|
+
* `useSTT()` consumers so the test can prove per-hook ownership — unmounting a
|
|
4
|
+
* consumer that did NOT start listening must not stop the shared singleton.
|
|
5
|
+
*/
|
|
6
|
+
import type { SmrtAppStateManager } from '../../state/app-state.svelte.js';
|
|
7
|
+
import type { UseSTTReturn } from '../useSTT.svelte.js';
|
|
8
|
+
interface Props {
|
|
9
|
+
manager: SmrtAppStateManager;
|
|
10
|
+
showA?: boolean;
|
|
11
|
+
showB?: boolean;
|
|
12
|
+
onReadyA?: (stt: UseSTTReturn) => void;
|
|
13
|
+
onReadyB?: (stt: UseSTTReturn) => void;
|
|
14
|
+
autoStartA?: boolean;
|
|
15
|
+
autoStartB?: boolean;
|
|
16
|
+
}
|
|
17
|
+
declare const SttOwnershipHarness: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type SttOwnershipHarness = ReturnType<typeof SttOwnershipHarness>;
|
|
19
|
+
export default SttOwnershipHarness;
|
|
20
|
+
//# sourceMappingURL=stt-ownership-harness.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stt-ownership-harness.svelte.d.ts","sourceRoot":"","sources":["../../../src/hooks/__tests__/stt-ownership-harness.svelte.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAE3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAIxD,UAAU,KAAK;IACb,OAAO,EAAE,mBAAmB,CAAC;IAC7B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;IACvC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;IACvC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAiCD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4 regression (T2 #1403): useSTT()/useTTS() attach onDestroy to the
|
|
3
|
+
* Provider-level *singleton* manager. Before the fix, unmounting ANY hook
|
|
4
|
+
* consumer that found the manager listening would call stopListening() —
|
|
5
|
+
* aborting a recording another consumer had started. The fix tracks per-hook
|
|
6
|
+
* ownership: a consumer only stops the singleton if it started the session.
|
|
7
|
+
*/
|
|
8
|
+
import { render } from '@testing-library/svelte';
|
|
9
|
+
import { tick } from 'svelte';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import Harness from './stt-ownership-harness.svelte';
|
|
12
|
+
/**
|
|
13
|
+
* Minimal fake manager exposing only what useSTT touches. `isListening`
|
|
14
|
+
* flips true once any consumer calls startListening().
|
|
15
|
+
*/
|
|
16
|
+
function makeFakeManager() {
|
|
17
|
+
const listening = { value: false };
|
|
18
|
+
const startListening = vi.fn(async () => {
|
|
19
|
+
listening.value = true;
|
|
20
|
+
});
|
|
21
|
+
const stopListening = vi.fn(async () => {
|
|
22
|
+
listening.value = false;
|
|
23
|
+
});
|
|
24
|
+
const manager = {
|
|
25
|
+
startListening,
|
|
26
|
+
stopListening,
|
|
27
|
+
initializeSTT: vi.fn(async () => ({})),
|
|
28
|
+
get state() {
|
|
29
|
+
return {
|
|
30
|
+
ai: {
|
|
31
|
+
stt: {
|
|
32
|
+
get isListening() {
|
|
33
|
+
return listening.value;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return { manager, startListening, stopListening, listening };
|
|
41
|
+
}
|
|
42
|
+
describe('useSTT per-hook ownership (R4)', () => {
|
|
43
|
+
it("unmounting a non-owning consumer does not stop another consumer's recording", async () => {
|
|
44
|
+
const { manager, startListening, stopListening } = makeFakeManager();
|
|
45
|
+
let sttA;
|
|
46
|
+
const { rerender, unmount } = render(Harness, {
|
|
47
|
+
props: {
|
|
48
|
+
manager,
|
|
49
|
+
showA: true,
|
|
50
|
+
showB: true,
|
|
51
|
+
onReadyA: (s) => {
|
|
52
|
+
sttA = s;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
// Consumer A starts the shared listening session.
|
|
57
|
+
await sttA?.start();
|
|
58
|
+
await tick();
|
|
59
|
+
expect(startListening).toHaveBeenCalledTimes(1);
|
|
60
|
+
// Unmount consumer B (which never started). It must NOT stop A's session.
|
|
61
|
+
await rerender({ manager, showA: true, showB: false });
|
|
62
|
+
await tick();
|
|
63
|
+
expect(stopListening).not.toHaveBeenCalled();
|
|
64
|
+
// Now unmount everything: the owning consumer A stops its own session.
|
|
65
|
+
unmount();
|
|
66
|
+
await tick();
|
|
67
|
+
expect(stopListening).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
it('an owning consumer stops the session it started on unmount', async () => {
|
|
70
|
+
const { manager, startListening, stopListening } = makeFakeManager();
|
|
71
|
+
let sttA;
|
|
72
|
+
const { unmount } = render(Harness, {
|
|
73
|
+
props: {
|
|
74
|
+
manager,
|
|
75
|
+
showA: true,
|
|
76
|
+
showB: false,
|
|
77
|
+
onReadyA: (s) => {
|
|
78
|
+
sttA = s;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
await sttA?.start();
|
|
83
|
+
await tick();
|
|
84
|
+
expect(startListening).toHaveBeenCalledTimes(1);
|
|
85
|
+
unmount();
|
|
86
|
+
await tick();
|
|
87
|
+
expect(stopListening).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
it('a consumer that never started does not stop a session on unmount', async () => {
|
|
90
|
+
const { manager, stopListening, listening } = makeFakeManager();
|
|
91
|
+
// Simulate the singleton already listening (started elsewhere).
|
|
92
|
+
listening.value = true;
|
|
93
|
+
const { unmount } = render(Harness, {
|
|
94
|
+
props: { manager, showA: true, showB: false },
|
|
95
|
+
});
|
|
96
|
+
await tick();
|
|
97
|
+
// Consumer A never called start(); unmounting must leave the session alone.
|
|
98
|
+
unmount();
|
|
99
|
+
await tick();
|
|
100
|
+
expect(stopListening).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSTT.svelte.d.ts","sourceRoot":"","sources":["../../src/hooks/useSTT.svelte.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGxE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,sBAAsB;IACtB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,qBAAqB;IACrB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,gEAAgE;IAChE,UAAU,EAAE,CAAC,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,+BAA+B;IAC/B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,4DAA4D;IAC5D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,
|
|
1
|
+
{"version":3,"file":"useSTT.svelte.d.ts","sourceRoot":"","sources":["../../src/hooks/useSTT.svelte.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGxE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,sBAAsB;IACtB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,qBAAqB;IACrB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,gEAAgE;IAChE,UAAU,EAAE,CAAC,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,+BAA+B;IAC/B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,4DAA4D;IAC5D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,CAmFhE"}
|