kempo-ui 0.4.3 → 0.4.4

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.
Files changed (96) hide show
  1. package/.claude/skills/get-icon/SKILL.md +144 -0
  2. package/.claude/skills/highlight-code/SKILL.md +62 -0
  3. package/.claude/skills/new-component/SKILL.md +362 -0
  4. package/.github/skills/get-icon/SKILL.md +4 -4
  5. package/.github/skills/highlight-code/SKILL.md +51 -20
  6. package/.github/skills/new-component/SKILL.md +183 -152
  7. package/AGENTS.md +2 -2
  8. package/dist/components/SpeechToText.js +60 -0
  9. package/dist/components/TextToSpeech.js +56 -0
  10. package/docs/components/accordion.html +8 -0
  11. package/docs/components/aside.html +8 -0
  12. package/docs/components/calendar.html +8 -0
  13. package/docs/components/card.html +8 -0
  14. package/docs/components/code-editor.html +8 -0
  15. package/docs/components/color-picker.html +8 -0
  16. package/docs/components/combobox.html +8 -0
  17. package/docs/components/content-slider.html +8 -0
  18. package/docs/components/context.html +8 -0
  19. package/docs/components/dialog.html +8 -0
  20. package/docs/components/dropdown.html +8 -0
  21. package/docs/components/filter-list.html +8 -0
  22. package/docs/components/focus-capture.html +8 -0
  23. package/docs/components/html-editor.html +8 -0
  24. package/docs/components/hybrid-component.html +8 -0
  25. package/docs/components/icon.html +8 -0
  26. package/docs/components/import.html +8 -0
  27. package/docs/components/light-component.html +8 -0
  28. package/docs/components/nav-spacer.html +8 -0
  29. package/docs/components/nav.html +8 -0
  30. package/docs/components/photo-viewer.html +8 -0
  31. package/docs/components/progress.html +8 -0
  32. package/docs/components/resize.html +8 -0
  33. package/docs/components/shadow-component.html +8 -0
  34. package/docs/components/show-more.html +8 -0
  35. package/docs/components/slider.html +8 -0
  36. package/docs/components/sortable.html +8 -0
  37. package/docs/components/speech-to-text.html +717 -0
  38. package/docs/components/spinner.html +8 -0
  39. package/docs/components/split.html +8 -0
  40. package/docs/components/table.html +8 -0
  41. package/docs/components/tableControls.html +8 -0
  42. package/docs/components/tableCustomFields.html +8 -0
  43. package/docs/components/tableFetchRecords.html +8 -0
  44. package/docs/components/tableFieldSortHide.html +8 -0
  45. package/docs/components/tablePagination.html +8 -0
  46. package/docs/components/tablePlaceholder.html +8 -0
  47. package/docs/components/tableRecordEditing.html +8 -0
  48. package/docs/components/tableRecordFiltering.html +8 -0
  49. package/docs/components/tableRecordHiding.html +8 -0
  50. package/docs/components/tableRecordSearching.html +8 -0
  51. package/docs/components/tableRecordSelection.html +8 -0
  52. package/docs/components/tableRowControls.html +8 -0
  53. package/docs/components/tableServerSync.html +8 -0
  54. package/docs/components/tableSorting.html +8 -0
  55. package/docs/components/tabs.html +8 -0
  56. package/docs/components/tags.html +8 -0
  57. package/docs/components/text-to-speech.html +668 -0
  58. package/docs/components/theme-select.html +8 -0
  59. package/docs/components/theme-switcher.html +8 -0
  60. package/docs/components/time.html +8 -0
  61. package/docs/components/timestamp.html +8 -0
  62. package/docs/components/toast.html +8 -0
  63. package/docs/components/toggle.html +8 -0
  64. package/docs/components/tree.html +8 -0
  65. package/docs/icons/mic.svg +1 -0
  66. package/docs/icons/record_voice_over.svg +1 -0
  67. package/docs/icons/stop.svg +1 -0
  68. package/docs/index.html +20 -0
  69. package/docs/src/components/SpeechToText.js +60 -0
  70. package/docs/src/components/TextToSpeech.js +56 -0
  71. package/docs/utils/context.html +8 -0
  72. package/docs/utils/cookie.html +8 -0
  73. package/docs/utils/debounce.html +8 -0
  74. package/docs/utils/drag.html +8 -0
  75. package/docs/utils/elevation.html +8 -0
  76. package/docs/utils/formatTimestamp.html +8 -0
  77. package/docs/utils/object.html +8 -0
  78. package/docs/utils/propConverters.html +8 -0
  79. package/docs/utils/string.html +8 -0
  80. package/docs/utils/theme.html +8 -0
  81. package/docs/utils/toTitleCase.html +8 -0
  82. package/docs/utils/type.html +8 -0
  83. package/docs/utils/wait.html +8 -0
  84. package/docs-src/components/speech-to-text.page.html +251 -0
  85. package/docs-src/components/text-to-speech.page.html +202 -0
  86. package/docs-src/index.page.html +12 -0
  87. package/docs-src/nav.fragment.html +8 -0
  88. package/icons/mic.svg +1 -0
  89. package/icons/record_voice_over.svg +1 -0
  90. package/icons/stop.svg +1 -0
  91. package/llms.txt +2 -0
  92. package/package.json +1 -1
  93. package/src/components/SpeechToText.js +252 -0
  94. package/src/components/TextToSpeech.js +194 -0
  95. package/tests/components/SpeechToText.browser-test.js +530 -0
  96. package/tests/components/TextToSpeech.browser-test.js +432 -0
@@ -0,0 +1,530 @@
1
+ import SpeechToText from '../../src/components/SpeechToText.js';
2
+
3
+ const createSpeechToText = async (attrs = {}) => {
4
+ const container = document.createElement('div');
5
+ const parts = [];
6
+ if(attrs.language !== undefined) parts.push(`language="${attrs.language}"`);
7
+ if(attrs.continuous) parts.push('continuous');
8
+ if(attrs.interim) parts.push('interim');
9
+ if(attrs.minConfidence !== undefined) parts.push(`min-confidence="${attrs.minConfidence}"`);
10
+ if(attrs.timeout !== undefined) parts.push(`timeout="${attrs.timeout}"`);
11
+ if(attrs.disabled) parts.push('disabled');
12
+ container.innerHTML = `<k-speech-to-text ${parts.join(' ')}></k-speech-to-text>`;
13
+ document.body.appendChild(container);
14
+ const el = container.querySelector('k-speech-to-text');
15
+ await el.updateComplete;
16
+ return { container, el };
17
+ };
18
+
19
+ const cleanup = (container) => {
20
+ if(container && container.parentNode){
21
+ container.parentNode.removeChild(container);
22
+ }
23
+ };
24
+
25
+ export default {
26
+ /*
27
+ Element Creation
28
+ */
29
+ 'should create speech-to-text element': async ({pass, fail}) => {
30
+ const { container, el } = await createSpeechToText();
31
+ if(!el){
32
+ cleanup(container);
33
+ return fail('SpeechToText element should be created');
34
+ }
35
+ if(!(el instanceof SpeechToText)){
36
+ cleanup(container);
37
+ return fail('Element should be instance of SpeechToText');
38
+ }
39
+ cleanup(container);
40
+ pass('SpeechToText element created correctly');
41
+ },
42
+
43
+ 'should have shadow root': async ({pass, fail}) => {
44
+ const { container, el } = await createSpeechToText();
45
+ if(!el.shadowRoot){
46
+ cleanup(container);
47
+ return fail('SpeechToText should have shadow root');
48
+ }
49
+ cleanup(container);
50
+ pass('SpeechToText has shadow root');
51
+ },
52
+
53
+ 'should render a button': async ({pass, fail}) => {
54
+ const { container, el } = await createSpeechToText();
55
+ const btn = el.shadowRoot.querySelector('button');
56
+ if(!btn){
57
+ cleanup(container);
58
+ return fail('SpeechToText should render a button');
59
+ }
60
+ cleanup(container);
61
+ pass('Button rendered');
62
+ },
63
+
64
+ 'should render a mic k-icon when idle': async ({pass, fail}) => {
65
+ const { container, el } = await createSpeechToText();
66
+ const icon = el.shadowRoot.querySelector('button k-icon');
67
+ if(!icon){
68
+ cleanup(container);
69
+ return fail('Button should contain a k-icon');
70
+ }
71
+ if(icon.getAttribute('name') !== 'mic'){
72
+ cleanup(container);
73
+ return fail(`Idle icon should be "mic", got "${icon.getAttribute('name')}"`);
74
+ }
75
+ cleanup(container);
76
+ pass('Idle k-icon is "mic"');
77
+ },
78
+
79
+ 'should render a stop k-icon when listening': async ({pass, fail}) => {
80
+ const { container, el } = await createSpeechToText();
81
+ el.listening = true;
82
+ await el.updateComplete;
83
+ const icon = el.shadowRoot.querySelector('button k-icon');
84
+ if(icon.getAttribute('name') !== 'stop'){
85
+ cleanup(container);
86
+ return fail(`Listening icon should be "stop", got "${icon.getAttribute('name')}"`);
87
+ }
88
+ cleanup(container);
89
+ pass('Listening k-icon is "stop"');
90
+ },
91
+
92
+ /*
93
+ Default Properties
94
+ */
95
+ 'should have default language of en-US': async ({pass, fail}) => {
96
+ const { container, el } = await createSpeechToText();
97
+ if(el.language !== 'en-US'){
98
+ cleanup(container);
99
+ return fail(`Expected language to be "en-US", got "${el.language}"`);
100
+ }
101
+ cleanup(container);
102
+ pass('Default language is en-US');
103
+ },
104
+
105
+ 'should have continuous default of false': async ({pass, fail}) => {
106
+ const { container, el } = await createSpeechToText();
107
+ if(el.continuous !== false){
108
+ cleanup(container);
109
+ return fail(`Expected continuous to be false, got ${el.continuous}`);
110
+ }
111
+ cleanup(container);
112
+ pass('Default continuous is false');
113
+ },
114
+
115
+ 'should have interim default of false': async ({pass, fail}) => {
116
+ const { container, el } = await createSpeechToText();
117
+ if(el.interim !== false){
118
+ cleanup(container);
119
+ return fail(`Expected interim to be false, got ${el.interim}`);
120
+ }
121
+ cleanup(container);
122
+ pass('Default interim is false');
123
+ },
124
+
125
+ 'should have disabled default of false': async ({pass, fail}) => {
126
+ const { container, el } = await createSpeechToText();
127
+ if(el.disabled !== false){
128
+ cleanup(container);
129
+ return fail(`Expected disabled to be false, got ${el.disabled}`);
130
+ }
131
+ cleanup(container);
132
+ pass('Default disabled is false');
133
+ },
134
+
135
+ 'should have listening default of false': async ({pass, fail}) => {
136
+ const { container, el } = await createSpeechToText();
137
+ if(el.listening !== false){
138
+ cleanup(container);
139
+ return fail(`Expected listening to be false, got ${el.listening}`);
140
+ }
141
+ cleanup(container);
142
+ pass('Default listening is false');
143
+ },
144
+
145
+ /*
146
+ Attribute Reflection
147
+ */
148
+ 'should reflect language attribute': async ({pass, fail}) => {
149
+ const { container, el } = await createSpeechToText({ language: 'es-ES' });
150
+ if(el.language !== 'es-ES'){
151
+ cleanup(container);
152
+ return fail(`Expected language property to be "es-ES", got "${el.language}"`);
153
+ }
154
+ cleanup(container);
155
+ pass('Language attribute reflects to property');
156
+ },
157
+
158
+ 'should reflect continuous attribute': async ({pass, fail}) => {
159
+ const { container, el } = await createSpeechToText({ continuous: true });
160
+ if(el.continuous !== true){
161
+ cleanup(container);
162
+ return fail(`Expected continuous to be true, got ${el.continuous}`);
163
+ }
164
+ cleanup(container);
165
+ pass('Continuous attribute reflects to property');
166
+ },
167
+
168
+ 'should reflect interim attribute': async ({pass, fail}) => {
169
+ const { container, el } = await createSpeechToText({ interim: true });
170
+ if(el.interim !== true){
171
+ cleanup(container);
172
+ return fail(`Expected interim to be true, got ${el.interim}`);
173
+ }
174
+ cleanup(container);
175
+ pass('Interim attribute reflects to property');
176
+ },
177
+
178
+ /*
179
+ Disabled
180
+ */
181
+ 'should disable the button when disabled attribute is set': async ({pass, fail}) => {
182
+ const { container, el } = await createSpeechToText({ disabled: true });
183
+ const btn = el.shadowRoot.querySelector('button');
184
+ if(!btn.disabled){
185
+ cleanup(container);
186
+ return fail('Button should be disabled when disabled attribute is set');
187
+ }
188
+ cleanup(container);
189
+ pass('Button is disabled when disabled attribute is set');
190
+ },
191
+
192
+ /*
193
+ Public Methods
194
+ */
195
+ 'should expose start, stop, and toggle methods': async ({pass, fail}) => {
196
+ const { container, el } = await createSpeechToText();
197
+ for(const m of ['start', 'stop', 'toggle']){
198
+ if(typeof el[m] !== 'function'){
199
+ cleanup(container);
200
+ return fail(`SpeechToText should have a ${m}() method`);
201
+ }
202
+ }
203
+ cleanup(container);
204
+ pass('start, stop, and toggle methods exist');
205
+ },
206
+
207
+ /*
208
+ Browser Support Detection
209
+ */
210
+ 'should disable button when SpeechRecognition is unsupported': async ({pass, fail}) => {
211
+ const original = {
212
+ SpeechRecognition: window.SpeechRecognition,
213
+ webkitSpeechRecognition: window.webkitSpeechRecognition
214
+ };
215
+ delete window.SpeechRecognition;
216
+ delete window.webkitSpeechRecognition;
217
+ const { container, el } = await createSpeechToText();
218
+ const btn = el.shadowRoot.querySelector('button');
219
+ const wasDisabled = btn.disabled;
220
+ if(original.SpeechRecognition) window.SpeechRecognition = original.SpeechRecognition;
221
+ if(original.webkitSpeechRecognition) window.webkitSpeechRecognition = original.webkitSpeechRecognition;
222
+ if(!wasDisabled){
223
+ cleanup(container);
224
+ return fail('Button should be disabled when SpeechRecognition is unsupported');
225
+ }
226
+ cleanup(container);
227
+ pass('Button auto-disables when SpeechRecognition API is missing');
228
+ },
229
+
230
+ /*
231
+ Listening State Class
232
+ */
233
+ 'should apply listening class when listening property is true': async ({pass, fail}) => {
234
+ const { container, el } = await createSpeechToText();
235
+ el.listening = true;
236
+ await el.updateComplete;
237
+ const btn = el.shadowRoot.querySelector('button');
238
+ if(!btn.classList.contains('listening')){
239
+ cleanup(container);
240
+ return fail('Button should have "listening" class when listening is true');
241
+ }
242
+ cleanup(container);
243
+ pass('Listening class applied when listening is true');
244
+ },
245
+
246
+ /*
247
+ Events
248
+ */
249
+ 'should fire end event with detail.text on handleEnd': async ({pass, fail}) => {
250
+ const { container, el } = await createSpeechToText();
251
+ let received = null;
252
+ el.addEventListener('end', (e) => { received = e.detail; });
253
+ el.handleEnd();
254
+ if(!received){
255
+ cleanup(container);
256
+ return fail('end event should fire');
257
+ }
258
+ if(typeof received.text !== 'string'){
259
+ cleanup(container);
260
+ return fail(`detail.text should be a string, got ${typeof received.text}`);
261
+ }
262
+ cleanup(container);
263
+ pass('end event fires with detail.text string');
264
+ },
265
+
266
+ 'should fire start event on handleStart': async ({pass, fail}) => {
267
+ const { container, el } = await createSpeechToText();
268
+ let fired = false;
269
+ el.addEventListener('start', () => { fired = true; });
270
+ el.handleStart();
271
+ await el.updateComplete;
272
+ if(!fired){
273
+ cleanup(container);
274
+ return fail('start event should fire');
275
+ }
276
+ if(el.listening !== true){
277
+ cleanup(container);
278
+ return fail('listening should be true after handleStart');
279
+ }
280
+ cleanup(container);
281
+ pass('start event fires and listening becomes true');
282
+ },
283
+
284
+ 'should fire result event with detail text and isFinal': async ({pass, fail}) => {
285
+ const { container, el } = await createSpeechToText();
286
+ let received = null;
287
+ el.addEventListener('result', (e) => { received = e.detail; });
288
+ const fakeEvent = {
289
+ resultIndex: 0,
290
+ results: [
291
+ Object.assign([{ transcript: 'hello world' }], { isFinal: true })
292
+ ]
293
+ };
294
+ el.handleResult(fakeEvent);
295
+ if(!received){
296
+ cleanup(container);
297
+ return fail('result event should fire');
298
+ }
299
+ if(received.text !== 'hello world'){
300
+ cleanup(container);
301
+ return fail(`detail.text should be "hello world", got "${received.text}"`);
302
+ }
303
+ if(received.isFinal !== true){
304
+ cleanup(container);
305
+ return fail(`detail.isFinal should be true, got ${received.isFinal}`);
306
+ }
307
+ cleanup(container);
308
+ pass('result event fires with correct detail');
309
+ },
310
+
311
+ 'should NOT fire result event for interim chunks when interim is off': async ({pass, fail}) => {
312
+ const { container, el } = await createSpeechToText();
313
+ let count = 0;
314
+ el.addEventListener('result', () => { count++; });
315
+ const interimEvent = {
316
+ resultIndex: 0,
317
+ results: [
318
+ Object.assign([{ transcript: 'hel' }], { isFinal: false })
319
+ ]
320
+ };
321
+ el.handleResult(interimEvent);
322
+ if(count !== 0){
323
+ cleanup(container);
324
+ return fail(`Should not dispatch result for interim when interim=false, got ${count} events`);
325
+ }
326
+ cleanup(container);
327
+ pass('Interim result events suppressed when interim=false');
328
+ },
329
+
330
+ 'should fire result event for interim chunks when interim is on': async ({pass, fail}) => {
331
+ const { container, el } = await createSpeechToText({ interim: true });
332
+ let received = null;
333
+ el.addEventListener('result', (e) => { received = e.detail; });
334
+ el.handleResult({
335
+ resultIndex: 0,
336
+ results: [
337
+ Object.assign([{ transcript: 'hel' }], { isFinal: false })
338
+ ]
339
+ });
340
+ if(!received){
341
+ cleanup(container);
342
+ return fail('result event should fire on interim when interim=true');
343
+ }
344
+ if(received.text !== 'hel' || received.isFinal !== false){
345
+ cleanup(container);
346
+ return fail(`Expected text "hel" and isFinal false, got "${received.text}" / ${received.isFinal}`);
347
+ }
348
+ cleanup(container);
349
+ pass('Interim result events fire when interim=true');
350
+ },
351
+
352
+ 'should include unfinalized interim text in end event': async ({pass, fail}) => {
353
+ const { container, el } = await createSpeechToText();
354
+ let endText = null;
355
+ el.addEventListener('end', (e) => { endText = e.detail.text; });
356
+ el.handleStart();
357
+ // Simulate Chrome firing only an interim result and then ending without finalizing
358
+ el.handleResult({
359
+ resultIndex: 0,
360
+ results: [
361
+ Object.assign([{ transcript: 'hello there' }], { isFinal: false })
362
+ ]
363
+ });
364
+ el.handleEnd();
365
+ if(endText !== 'hello there'){
366
+ cleanup(container);
367
+ return fail(`end event should include interim text, got "${endText}"`);
368
+ }
369
+ cleanup(container);
370
+ pass('end event includes unfinalized interim text');
371
+ },
372
+
373
+ /*
374
+ Min Confidence
375
+ */
376
+ 'should default minConfidence to 0': async ({pass, fail}) => {
377
+ const { container, el } = await createSpeechToText();
378
+ if(el.minConfidence !== 0){
379
+ cleanup(container);
380
+ return fail(`Expected minConfidence to default to 0, got ${el.minConfidence}`);
381
+ }
382
+ cleanup(container);
383
+ pass('Default minConfidence is 0');
384
+ },
385
+
386
+ 'should reflect min-confidence attribute': async ({pass, fail}) => {
387
+ const { container, el } = await createSpeechToText({ minConfidence: 0.7 });
388
+ if(el.minConfidence !== 0.7){
389
+ cleanup(container);
390
+ return fail(`Expected minConfidence to be 0.7, got ${el.minConfidence}`);
391
+ }
392
+ cleanup(container);
393
+ pass('min-confidence attribute reflects to minConfidence property');
394
+ },
395
+
396
+ 'should drop final results below minConfidence': async ({pass, fail}) => {
397
+ const { container, el } = await createSpeechToText({ minConfidence: 0.7 });
398
+ let endText = null;
399
+ el.addEventListener('end', (e) => { endText = e.detail.text; });
400
+ el.handleStart();
401
+ el.handleResult({
402
+ resultIndex: 0,
403
+ results: [
404
+ Object.assign([{ transcript: 'maybe noise', confidence: 0.3 }], { isFinal: true })
405
+ ]
406
+ });
407
+ el.handleEnd();
408
+ if(endText !== ''){
409
+ cleanup(container);
410
+ return fail(`Low-confidence result should be dropped, got "${endText}"`);
411
+ }
412
+ cleanup(container);
413
+ pass('Low-confidence finals are filtered out');
414
+ },
415
+
416
+ 'should keep final results at or above minConfidence': async ({pass, fail}) => {
417
+ const { container, el } = await createSpeechToText({ minConfidence: 0.7 });
418
+ let endText = null;
419
+ el.addEventListener('end', (e) => { endText = e.detail.text; });
420
+ el.handleStart();
421
+ el.handleResult({
422
+ resultIndex: 0,
423
+ results: [
424
+ Object.assign([{ transcript: 'clear speech', confidence: 0.9 }], { isFinal: true })
425
+ ]
426
+ });
427
+ el.handleEnd();
428
+ if(endText !== 'clear speech'){
429
+ cleanup(container);
430
+ return fail(`High-confidence result should pass, got "${endText}"`);
431
+ }
432
+ cleanup(container);
433
+ pass('High-confidence finals pass through');
434
+ },
435
+
436
+ /*
437
+ Timeout
438
+ */
439
+ 'should default timeout to 0': async ({pass, fail}) => {
440
+ const { container, el } = await createSpeechToText();
441
+ if(el.timeout !== 0){
442
+ cleanup(container);
443
+ return fail(`Expected timeout to default to 0, got ${el.timeout}`);
444
+ }
445
+ cleanup(container);
446
+ pass('Default timeout is 0');
447
+ },
448
+
449
+ 'should reflect timeout attribute': async ({pass, fail}) => {
450
+ const { container, el } = await createSpeechToText({ timeout: 5 });
451
+ if(el.timeout !== 5){
452
+ cleanup(container);
453
+ return fail(`Expected timeout to be 5, got ${el.timeout}`);
454
+ }
455
+ cleanup(container);
456
+ pass('timeout attribute reflects to property');
457
+ },
458
+
459
+ 'should auto-stop after timeout elapses': async ({pass, fail}) => {
460
+ const { container, el } = await createSpeechToText({ timeout: 0.05 });
461
+ let stopCalled = false;
462
+ el.stop = () => { stopCalled = true; };
463
+ el.handleStart();
464
+ await new Promise(r => setTimeout(r, 100));
465
+ if(!stopCalled){
466
+ cleanup(container);
467
+ return fail('stop() should be called automatically after timeout elapses');
468
+ }
469
+ cleanup(container);
470
+ pass('Auto-stop fires after timeout');
471
+ },
472
+
473
+ 'should not auto-stop when timeout is 0': async ({pass, fail}) => {
474
+ const { container, el } = await createSpeechToText();
475
+ let stopCalled = false;
476
+ el.stop = () => { stopCalled = true; };
477
+ el.handleStart();
478
+ await new Promise(r => setTimeout(r, 100));
479
+ if(stopCalled){
480
+ cleanup(container);
481
+ return fail('stop() should NOT be called when timeout is 0');
482
+ }
483
+ cleanup(container);
484
+ pass('No auto-stop when timeout is 0');
485
+ },
486
+
487
+ 'should clear pending timeout when end fires before timeout': async ({pass, fail}) => {
488
+ const { container, el } = await createSpeechToText({ timeout: 0.2 });
489
+ let stopCalled = false;
490
+ el.stop = () => { stopCalled = true; };
491
+ el.handleStart();
492
+ el.handleEnd();
493
+ await new Promise(r => setTimeout(r, 250));
494
+ if(stopCalled){
495
+ cleanup(container);
496
+ return fail('Timeout should be cleared when end fires early');
497
+ }
498
+ cleanup(container);
499
+ pass('Pending timeout cleared on early end');
500
+ },
501
+
502
+ 'should keep last interim when a later empty result event fires': async ({pass, fail}) => {
503
+ const { container, el } = await createSpeechToText();
504
+ let endText = null;
505
+ el.addEventListener('end', (e) => { endText = e.detail.text; });
506
+ el.handleStart();
507
+ // First, an interim transcript
508
+ el.handleResult({
509
+ resultIndex: 0,
510
+ results: [
511
+ Object.assign([{ transcript: 'hello world' }], { isFinal: false })
512
+ ]
513
+ });
514
+ // Then Chrome fires a settling event whose iteration produces nothing
515
+ // (resultIndex past the end of the array — no new content)
516
+ el.handleResult({
517
+ resultIndex: 1,
518
+ results: [
519
+ Object.assign([{ transcript: 'hello world' }], { isFinal: false })
520
+ ]
521
+ });
522
+ el.handleEnd();
523
+ if(endText !== 'hello world'){
524
+ cleanup(container);
525
+ return fail(`end event should preserve last interim across empty events, got "${endText}"`);
526
+ }
527
+ cleanup(container);
528
+ pass('Last interim preserved across empty result events');
529
+ }
530
+ };