kempo-ui 0.4.3 → 0.4.5
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/.claude/skills/get-icon/SKILL.md +144 -0
- package/.claude/skills/highlight-code/SKILL.md +62 -0
- package/.claude/skills/new-component/SKILL.md +362 -0
- package/.github/skills/get-icon/SKILL.md +4 -4
- package/.github/skills/highlight-code/SKILL.md +51 -20
- package/.github/skills/new-component/SKILL.md +183 -152
- package/AGENTS.md +2 -2
- package/dist/components/SpeechToText.js +60 -0
- package/dist/components/TextToSpeech.js +56 -0
- package/dist/components/VoiceSelector.js +49 -0
- package/dist/utils/voice.js +1 -0
- package/docs/components/accordion.html +15 -0
- package/docs/components/aside.html +15 -0
- package/docs/components/calendar.html +15 -0
- package/docs/components/card.html +15 -0
- package/docs/components/code-editor.html +15 -0
- package/docs/components/color-picker.html +15 -0
- package/docs/components/combobox.html +15 -0
- package/docs/components/content-slider.html +15 -0
- package/docs/components/context.html +15 -0
- package/docs/components/dialog.html +15 -0
- package/docs/components/dropdown.html +15 -0
- package/docs/components/filter-list.html +15 -0
- package/docs/components/focus-capture.html +15 -0
- package/docs/components/html-editor.html +15 -0
- package/docs/components/hybrid-component.html +15 -0
- package/docs/components/icon.html +15 -0
- package/docs/components/import.html +15 -0
- package/docs/components/light-component.html +15 -0
- package/docs/components/nav-spacer.html +15 -0
- package/docs/components/nav.html +15 -0
- package/docs/components/photo-viewer.html +15 -0
- package/docs/components/progress.html +15 -0
- package/docs/components/resize.html +15 -0
- package/docs/components/shadow-component.html +15 -0
- package/docs/components/show-more.html +15 -0
- package/docs/components/slider.html +15 -0
- package/docs/components/sortable.html +15 -0
- package/docs/components/speech-to-text.html +724 -0
- package/docs/components/spinner.html +15 -0
- package/docs/components/split.html +15 -0
- package/docs/components/table.html +15 -0
- package/docs/components/tableControls.html +15 -0
- package/docs/components/tableCustomFields.html +15 -0
- package/docs/components/tableFetchRecords.html +15 -0
- package/docs/components/tableFieldSortHide.html +15 -0
- package/docs/components/tablePagination.html +15 -0
- package/docs/components/tablePlaceholder.html +15 -0
- package/docs/components/tableRecordEditing.html +15 -0
- package/docs/components/tableRecordFiltering.html +15 -0
- package/docs/components/tableRecordHiding.html +15 -0
- package/docs/components/tableRecordSearching.html +15 -0
- package/docs/components/tableRecordSelection.html +15 -0
- package/docs/components/tableRowControls.html +15 -0
- package/docs/components/tableServerSync.html +15 -0
- package/docs/components/tableSorting.html +15 -0
- package/docs/components/tabs.html +15 -0
- package/docs/components/tags.html +15 -0
- package/docs/components/text-to-speech.html +718 -0
- package/docs/components/theme-select.html +15 -0
- package/docs/components/theme-switcher.html +15 -0
- package/docs/components/time.html +15 -0
- package/docs/components/timestamp.html +15 -0
- package/docs/components/toast.html +15 -0
- package/docs/components/toggle.html +15 -0
- package/docs/components/tree.html +15 -0
- package/docs/components/voice-selector.html +598 -0
- package/docs/icons/mic.svg +1 -0
- package/docs/icons/record_voice_over.svg +1 -0
- package/docs/icons/stop.svg +1 -0
- package/docs/index.html +39 -0
- package/docs/src/components/SpeechToText.js +60 -0
- package/docs/src/components/TextToSpeech.js +56 -0
- package/docs/src/components/VoiceSelector.js +49 -0
- package/docs/src/utils/voice.js +1 -0
- package/docs/utils/context.html +15 -0
- package/docs/utils/cookie.html +15 -0
- package/docs/utils/debounce.html +15 -0
- package/docs/utils/drag.html +15 -0
- package/docs/utils/elevation.html +15 -0
- package/docs/utils/formatTimestamp.html +15 -0
- package/docs/utils/object.html +15 -0
- package/docs/utils/propConverters.html +15 -0
- package/docs/utils/string.html +15 -0
- package/docs/utils/theme.html +15 -0
- package/docs/utils/toTitleCase.html +15 -0
- package/docs/utils/type.html +15 -0
- package/docs/utils/voice.html +536 -0
- package/docs/utils/wait.html +15 -0
- package/docs-src/components/speech-to-text.page.html +251 -0
- package/docs-src/components/text-to-speech.page.html +245 -0
- package/docs-src/components/voice-selector.page.html +125 -0
- package/docs-src/index.page.html +24 -0
- package/docs-src/nav.fragment.html +15 -0
- package/docs-src/utils/voice.page.html +66 -0
- package/icons/mic.svg +1 -0
- package/icons/record_voice_over.svg +1 -0
- package/icons/stop.svg +1 -0
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/src/components/SpeechToText.js +252 -0
- package/src/components/TextToSpeech.js +205 -0
- package/src/components/VoiceSelector.js +271 -0
- package/src/utils/voice.js +103 -0
- package/tests/components/SpeechToText.browser-test.js +530 -0
- package/tests/components/TextToSpeech.browser-test.js +581 -0
- package/tests/components/VoiceSelector.browser-test.js +374 -0
- package/tests/utils/voice.browser-test.js +128 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import VoiceSelector from '../../src/components/VoiceSelector.js';
|
|
2
|
+
import { setVoice } from '../../src/utils/voice.js';
|
|
3
|
+
|
|
4
|
+
const FAKE_VOICES = [
|
|
5
|
+
{ name: 'Samantha', lang: 'en-US' },
|
|
6
|
+
{ name: 'Alex', lang: 'en-US' },
|
|
7
|
+
{ name: 'Daniel', lang: 'en-GB' },
|
|
8
|
+
{ name: 'Amelie', lang: 'fr-FR' },
|
|
9
|
+
{ name: 'Thomas', lang: 'fr-FR' },
|
|
10
|
+
{ name: 'Monica', lang: 'es-ES' }
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const installVoicesStub = (voiceList = FAKE_VOICES) => {
|
|
14
|
+
const synth = window.speechSynthesis;
|
|
15
|
+
const original = synth.getVoices.bind(synth);
|
|
16
|
+
synth.getVoices = () => voiceList;
|
|
17
|
+
return {
|
|
18
|
+
restore: () => { synth.getVoices = original; }
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const createVoiceSelector = async (attrs = {}) => {
|
|
23
|
+
const container = document.createElement('div');
|
|
24
|
+
const parts = [];
|
|
25
|
+
if(attrs.language !== undefined) parts.push(`language="${attrs.language}"`);
|
|
26
|
+
if(attrs.placeholder !== undefined) parts.push(`placeholder="${attrs.placeholder}"`);
|
|
27
|
+
if(attrs.disabled) parts.push('disabled');
|
|
28
|
+
container.innerHTML = `<k-voice-selector ${parts.join(' ')}></k-voice-selector>`;
|
|
29
|
+
document.body.appendChild(container);
|
|
30
|
+
const el = container.querySelector('k-voice-selector');
|
|
31
|
+
await el.updateComplete;
|
|
32
|
+
return { container, el };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const langSelect = (el) => el.shadowRoot.querySelector('select.lang');
|
|
36
|
+
const voiceSelect = (el) => el.shadowRoot.querySelector('select.voice');
|
|
37
|
+
const optionValues = (sel) => [...sel.querySelectorAll('option')].map(o => o.value);
|
|
38
|
+
|
|
39
|
+
const cleanup = (container) => {
|
|
40
|
+
if(container && container.parentNode){
|
|
41
|
+
container.parentNode.removeChild(container);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const reset = () => setVoice('');
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
/*
|
|
49
|
+
Element Creation
|
|
50
|
+
*/
|
|
51
|
+
'should create voice-selector element': async ({pass, fail}) => {
|
|
52
|
+
reset();
|
|
53
|
+
const stub = installVoicesStub();
|
|
54
|
+
const { container, el } = await createVoiceSelector();
|
|
55
|
+
if(!(el instanceof VoiceSelector)){
|
|
56
|
+
stub.restore();
|
|
57
|
+
cleanup(container);
|
|
58
|
+
return fail('Element should be instance of VoiceSelector');
|
|
59
|
+
}
|
|
60
|
+
stub.restore();
|
|
61
|
+
cleanup(container);
|
|
62
|
+
pass('VoiceSelector created');
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
'should render two select elements (language + voice)': async ({pass, fail}) => {
|
|
66
|
+
reset();
|
|
67
|
+
const stub = installVoicesStub();
|
|
68
|
+
const { container, el } = await createVoiceSelector();
|
|
69
|
+
if(!langSelect(el) || !voiceSelect(el)){
|
|
70
|
+
stub.restore();
|
|
71
|
+
cleanup(container);
|
|
72
|
+
return fail('Should render both .lang and .voice selects');
|
|
73
|
+
}
|
|
74
|
+
stub.restore();
|
|
75
|
+
cleanup(container);
|
|
76
|
+
pass('Both selects rendered');
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
Language Dropdown Population
|
|
81
|
+
*/
|
|
82
|
+
'should populate language dropdown with distinct primary languages': async ({pass, fail}) => {
|
|
83
|
+
reset();
|
|
84
|
+
const stub = installVoicesStub();
|
|
85
|
+
const { container, el } = await createVoiceSelector();
|
|
86
|
+
el.refresh();
|
|
87
|
+
await el.updateComplete;
|
|
88
|
+
const langs = optionValues(langSelect(el));
|
|
89
|
+
if(!langs.includes('en') || !langs.includes('fr') || !langs.includes('es')){
|
|
90
|
+
stub.restore();
|
|
91
|
+
cleanup(container);
|
|
92
|
+
return fail(`Expected en/fr/es in language list, got ${JSON.stringify(langs)}`);
|
|
93
|
+
}
|
|
94
|
+
// Should de-dupe en-US + en-GB → "en"
|
|
95
|
+
const enCount = langs.filter(l => l === 'en').length;
|
|
96
|
+
if(enCount !== 1){
|
|
97
|
+
stub.restore();
|
|
98
|
+
cleanup(container);
|
|
99
|
+
return fail(`Expected 'en' only once, got ${enCount}`);
|
|
100
|
+
}
|
|
101
|
+
stub.restore();
|
|
102
|
+
cleanup(container);
|
|
103
|
+
pass('Language dropdown shows distinct primary languages');
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
Language Display (localized + native names)
|
|
108
|
+
*/
|
|
109
|
+
'should display each language name with its native name appended': async ({pass, fail}) => {
|
|
110
|
+
reset();
|
|
111
|
+
const stub = installVoicesStub();
|
|
112
|
+
const { container, el } = await createVoiceSelector();
|
|
113
|
+
el.refresh();
|
|
114
|
+
await el.updateComplete;
|
|
115
|
+
const options = [...langSelect(el).querySelectorAll('option')];
|
|
116
|
+
const spanishOption = options.find(o => o.value === 'es');
|
|
117
|
+
const frenchOption = options.find(o => o.value === 'fr');
|
|
118
|
+
if(!spanishOption || !spanishOption.textContent.includes('Español')){
|
|
119
|
+
stub.restore();
|
|
120
|
+
cleanup(container);
|
|
121
|
+
return fail(`Expected Spanish option to include "Español", got "${spanishOption && spanishOption.textContent}"`);
|
|
122
|
+
}
|
|
123
|
+
if(!frenchOption || !frenchOption.textContent.toLowerCase().includes('français')){
|
|
124
|
+
stub.restore();
|
|
125
|
+
cleanup(container);
|
|
126
|
+
return fail(`Expected French option to include "Français", got "${frenchOption && frenchOption.textContent}"`);
|
|
127
|
+
}
|
|
128
|
+
stub.restore();
|
|
129
|
+
cleanup(container);
|
|
130
|
+
pass('Language options include native names');
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
'should not duplicate when localized and native names match': async ({pass, fail}) => {
|
|
134
|
+
reset();
|
|
135
|
+
const stub = installVoicesStub([{ name: 'EN Voice', lang: 'en-US' }]);
|
|
136
|
+
const { container, el } = await createVoiceSelector();
|
|
137
|
+
el.refresh();
|
|
138
|
+
await el.updateComplete;
|
|
139
|
+
const enOption = [...langSelect(el).querySelectorAll('option')].find(o => o.value === 'en');
|
|
140
|
+
if(!enOption){
|
|
141
|
+
stub.restore();
|
|
142
|
+
cleanup(container);
|
|
143
|
+
return fail('Should have an English option');
|
|
144
|
+
}
|
|
145
|
+
// If user's locale is English, "English (English)" would be silly — verify we drop the duplicate.
|
|
146
|
+
const userLocale = (navigator.language || '').toLowerCase();
|
|
147
|
+
if(userLocale.startsWith('en')){
|
|
148
|
+
if(enOption.textContent.includes('(English)')){
|
|
149
|
+
stub.restore();
|
|
150
|
+
cleanup(container);
|
|
151
|
+
return fail(`Expected "English" without duplicate parenthetical, got "${enOption.textContent}"`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
stub.restore();
|
|
155
|
+
cleanup(container);
|
|
156
|
+
pass('Localized and native names are deduplicated when identical');
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/*
|
|
160
|
+
Voice Dropdown Filtering
|
|
161
|
+
*/
|
|
162
|
+
'should populate voice dropdown for current language': async ({pass, fail}) => {
|
|
163
|
+
reset();
|
|
164
|
+
const stub = installVoicesStub();
|
|
165
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
166
|
+
el.refresh();
|
|
167
|
+
await el.updateComplete;
|
|
168
|
+
const voices = optionValues(voiceSelect(el)).filter(Boolean);
|
|
169
|
+
if(!voices.includes('Samantha') || !voices.includes('Daniel') || voices.includes('Amelie')){
|
|
170
|
+
stub.restore();
|
|
171
|
+
cleanup(container);
|
|
172
|
+
return fail(`Expected en-* voices only, got ${JSON.stringify(voices)}`);
|
|
173
|
+
}
|
|
174
|
+
stub.restore();
|
|
175
|
+
cleanup(container);
|
|
176
|
+
pass('Voice dropdown filtered by current language');
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
'should switch voice list when language dropdown changes': async ({pass, fail}) => {
|
|
180
|
+
reset();
|
|
181
|
+
const stub = installVoicesStub();
|
|
182
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
183
|
+
el.refresh();
|
|
184
|
+
await el.updateComplete;
|
|
185
|
+
const lang = langSelect(el);
|
|
186
|
+
lang.value = 'fr';
|
|
187
|
+
lang.dispatchEvent(new Event('change', { bubbles: true }));
|
|
188
|
+
await el.updateComplete;
|
|
189
|
+
const voices = optionValues(voiceSelect(el)).filter(Boolean);
|
|
190
|
+
if(!voices.includes('Amelie') || !voices.includes('Thomas') || voices.includes('Samantha')){
|
|
191
|
+
stub.restore();
|
|
192
|
+
cleanup(container);
|
|
193
|
+
return fail(`Expected fr-* voices after change, got ${JSON.stringify(voices)}`);
|
|
194
|
+
}
|
|
195
|
+
stub.restore();
|
|
196
|
+
cleanup(container);
|
|
197
|
+
pass('Voice list reflows when language changes');
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
'should include placeholder option in voice dropdown': async ({pass, fail}) => {
|
|
201
|
+
reset();
|
|
202
|
+
const stub = installVoicesStub();
|
|
203
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
204
|
+
el.refresh();
|
|
205
|
+
await el.updateComplete;
|
|
206
|
+
const first = voiceSelect(el).querySelector('option');
|
|
207
|
+
if(first.value !== '' || !first.textContent.includes('Browser default')){
|
|
208
|
+
stub.restore();
|
|
209
|
+
cleanup(container);
|
|
210
|
+
return fail(`First voice option should be the placeholder, got value="${first.value}" text="${first.textContent}"`);
|
|
211
|
+
}
|
|
212
|
+
stub.restore();
|
|
213
|
+
cleanup(container);
|
|
214
|
+
pass('Placeholder option present in voice dropdown');
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/*
|
|
218
|
+
Default language from navigator
|
|
219
|
+
*/
|
|
220
|
+
'should default current language from navigator.language when no attr': async ({pass, fail}) => {
|
|
221
|
+
reset();
|
|
222
|
+
const stub = installVoicesStub();
|
|
223
|
+
// Confine test to languages that exist in the fake list
|
|
224
|
+
const expected = (navigator.language || 'en').split('-')[0].toLowerCase();
|
|
225
|
+
const fallback = ['en', 'fr', 'es'].includes(expected) ? expected : 'en';
|
|
226
|
+
const { container, el } = await createVoiceSelector();
|
|
227
|
+
el.refresh();
|
|
228
|
+
await el.updateComplete;
|
|
229
|
+
if(el.currentLang !== fallback){
|
|
230
|
+
stub.restore();
|
|
231
|
+
cleanup(container);
|
|
232
|
+
return fail(`Expected currentLang "${fallback}" (from navigator), got "${el.currentLang}"`);
|
|
233
|
+
}
|
|
234
|
+
stub.restore();
|
|
235
|
+
cleanup(container);
|
|
236
|
+
pass('Default language picked from navigator');
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/*
|
|
240
|
+
Persistence
|
|
241
|
+
*/
|
|
242
|
+
'should persist voice via voice utility on change': async ({pass, fail}) => {
|
|
243
|
+
reset();
|
|
244
|
+
const stub = installVoicesStub();
|
|
245
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
246
|
+
el.refresh();
|
|
247
|
+
await el.updateComplete;
|
|
248
|
+
const sel = voiceSelect(el);
|
|
249
|
+
sel.value = 'Alex';
|
|
250
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
251
|
+
await el.updateComplete;
|
|
252
|
+
if(localStorage.getItem('k-voice') !== 'Alex'){
|
|
253
|
+
stub.restore();
|
|
254
|
+
cleanup(container);
|
|
255
|
+
reset();
|
|
256
|
+
return fail(`Expected localStorage k-voice="Alex", got "${localStorage.getItem('k-voice')}"`);
|
|
257
|
+
}
|
|
258
|
+
stub.restore();
|
|
259
|
+
cleanup(container);
|
|
260
|
+
reset();
|
|
261
|
+
pass('Selecting a voice persists via voice util');
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
'should clear voice selection when changing to a language without that voice': async ({pass, fail}) => {
|
|
265
|
+
reset();
|
|
266
|
+
const stub = installVoicesStub();
|
|
267
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
268
|
+
el.refresh();
|
|
269
|
+
await el.updateComplete;
|
|
270
|
+
const v = voiceSelect(el);
|
|
271
|
+
v.value = 'Alex';
|
|
272
|
+
v.dispatchEvent(new Event('change', { bubbles: true }));
|
|
273
|
+
await el.updateComplete;
|
|
274
|
+
const lang = langSelect(el);
|
|
275
|
+
lang.value = 'fr';
|
|
276
|
+
lang.dispatchEvent(new Event('change', { bubbles: true }));
|
|
277
|
+
await el.updateComplete;
|
|
278
|
+
if(localStorage.getItem('k-voice') !== null){
|
|
279
|
+
stub.restore();
|
|
280
|
+
cleanup(container);
|
|
281
|
+
reset();
|
|
282
|
+
return fail(`Expected k-voice cleared after switching language, got "${localStorage.getItem('k-voice')}"`);
|
|
283
|
+
}
|
|
284
|
+
stub.restore();
|
|
285
|
+
cleanup(container);
|
|
286
|
+
reset();
|
|
287
|
+
pass('Switching to a language that does not contain the selected voice clears it');
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
'should reflect external voice changes in the voice dropdown': async ({pass, fail}) => {
|
|
291
|
+
reset();
|
|
292
|
+
const stub = installVoicesStub();
|
|
293
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
294
|
+
el.refresh();
|
|
295
|
+
await el.updateComplete;
|
|
296
|
+
setVoice('Daniel');
|
|
297
|
+
await el.updateComplete;
|
|
298
|
+
if(voiceSelect(el).value !== 'Daniel'){
|
|
299
|
+
stub.restore();
|
|
300
|
+
cleanup(container);
|
|
301
|
+
reset();
|
|
302
|
+
return fail(`Expected voice select.value "Daniel", got "${voiceSelect(el).value}"`);
|
|
303
|
+
}
|
|
304
|
+
stub.restore();
|
|
305
|
+
cleanup(container);
|
|
306
|
+
reset();
|
|
307
|
+
pass('External voice updates reflect in voice dropdown');
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
'should sync currentLang when external voice is in a different language': async ({pass, fail}) => {
|
|
311
|
+
reset();
|
|
312
|
+
const stub = installVoicesStub();
|
|
313
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
314
|
+
el.refresh();
|
|
315
|
+
await el.updateComplete;
|
|
316
|
+
setVoice('Amelie');
|
|
317
|
+
await el.updateComplete;
|
|
318
|
+
if(el.currentLang !== 'fr'){
|
|
319
|
+
stub.restore();
|
|
320
|
+
cleanup(container);
|
|
321
|
+
reset();
|
|
322
|
+
return fail(`Expected currentLang to switch to "fr", got "${el.currentLang}"`);
|
|
323
|
+
}
|
|
324
|
+
stub.restore();
|
|
325
|
+
cleanup(container);
|
|
326
|
+
reset();
|
|
327
|
+
pass('External voice in different language updates language dropdown');
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
/*
|
|
331
|
+
Events
|
|
332
|
+
*/
|
|
333
|
+
'should fire change event with detail.voice and detail.language': async ({pass, fail}) => {
|
|
334
|
+
reset();
|
|
335
|
+
const stub = installVoicesStub();
|
|
336
|
+
const { container, el } = await createVoiceSelector({ language: 'en' });
|
|
337
|
+
el.refresh();
|
|
338
|
+
await el.updateComplete;
|
|
339
|
+
let received = null;
|
|
340
|
+
el.addEventListener('change', (e) => { received = e.detail; });
|
|
341
|
+
const sel = voiceSelect(el);
|
|
342
|
+
sel.value = 'Samantha';
|
|
343
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
344
|
+
if(!received || received.voice !== 'Samantha' || received.language !== 'en'){
|
|
345
|
+
stub.restore();
|
|
346
|
+
cleanup(container);
|
|
347
|
+
reset();
|
|
348
|
+
return fail(`Expected detail {voice:"Samantha", language:"en"}, got ${JSON.stringify(received)}`);
|
|
349
|
+
}
|
|
350
|
+
stub.restore();
|
|
351
|
+
cleanup(container);
|
|
352
|
+
reset();
|
|
353
|
+
pass('change event fires with detail.voice and detail.language');
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
/*
|
|
357
|
+
Disabled
|
|
358
|
+
*/
|
|
359
|
+
'should disable both selects when disabled attribute is set': async ({pass, fail}) => {
|
|
360
|
+
reset();
|
|
361
|
+
const stub = installVoicesStub();
|
|
362
|
+
const { container, el } = await createVoiceSelector({ disabled: true, language: 'en' });
|
|
363
|
+
el.refresh();
|
|
364
|
+
await el.updateComplete;
|
|
365
|
+
if(!langSelect(el).disabled || !voiceSelect(el).disabled){
|
|
366
|
+
stub.restore();
|
|
367
|
+
cleanup(container);
|
|
368
|
+
return fail('Both selects should be disabled');
|
|
369
|
+
}
|
|
370
|
+
stub.restore();
|
|
371
|
+
cleanup(container);
|
|
372
|
+
pass('Both selects honor disabled attribute');
|
|
373
|
+
}
|
|
374
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import voice, {
|
|
2
|
+
getVoice,
|
|
3
|
+
setVoice,
|
|
4
|
+
subscribeToVoice
|
|
5
|
+
} from '../../src/utils/voice.js';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'k-voice';
|
|
8
|
+
|
|
9
|
+
const reset = () => {
|
|
10
|
+
try {
|
|
11
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
12
|
+
} catch(e) {}
|
|
13
|
+
setVoice('');
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
/*
|
|
18
|
+
Get/Set
|
|
19
|
+
*/
|
|
20
|
+
'should default to empty string when nothing stored': async ({pass, fail}) => {
|
|
21
|
+
reset();
|
|
22
|
+
if(getVoice() !== ''){
|
|
23
|
+
return fail(`Expected '' default, got "${getVoice()}"`);
|
|
24
|
+
}
|
|
25
|
+
pass('Default voice is empty string');
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
'should set and get a voice name': async ({pass, fail}) => {
|
|
29
|
+
reset();
|
|
30
|
+
setVoice('Samantha');
|
|
31
|
+
if(getVoice() !== 'Samantha'){
|
|
32
|
+
reset();
|
|
33
|
+
return fail(`Expected "Samantha", got "${getVoice()}"`);
|
|
34
|
+
}
|
|
35
|
+
reset();
|
|
36
|
+
pass('Set/get round-trips a voice name');
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
'should persist voice to localStorage on set': async ({pass, fail}) => {
|
|
40
|
+
reset();
|
|
41
|
+
setVoice('Karen');
|
|
42
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
43
|
+
if(stored !== 'Karen'){
|
|
44
|
+
reset();
|
|
45
|
+
return fail(`Expected localStorage "${STORAGE_KEY}" = "Karen", got "${stored}"`);
|
|
46
|
+
}
|
|
47
|
+
reset();
|
|
48
|
+
pass('setVoice persists to localStorage');
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
'should clear localStorage when set to empty': async ({pass, fail}) => {
|
|
52
|
+
reset();
|
|
53
|
+
setVoice('Karen');
|
|
54
|
+
setVoice('');
|
|
55
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
56
|
+
if(stored !== null){
|
|
57
|
+
reset();
|
|
58
|
+
return fail(`Expected localStorage cleared, got "${stored}"`);
|
|
59
|
+
}
|
|
60
|
+
reset();
|
|
61
|
+
pass('Empty setVoice clears localStorage');
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
Subscribe
|
|
66
|
+
*/
|
|
67
|
+
'should fire subscriber immediately with current value': async ({pass, fail}) => {
|
|
68
|
+
reset();
|
|
69
|
+
setVoice('Daniel');
|
|
70
|
+
let received = '__none__';
|
|
71
|
+
const off = subscribeToVoice(name => { received = name; });
|
|
72
|
+
off();
|
|
73
|
+
if(received !== 'Daniel'){
|
|
74
|
+
reset();
|
|
75
|
+
return fail(`Subscriber should fire immediately with "Daniel", got "${received}"`);
|
|
76
|
+
}
|
|
77
|
+
reset();
|
|
78
|
+
pass('Subscriber fires immediately with current value');
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
'should fire subscribers when voice changes': async ({pass, fail}) => {
|
|
82
|
+
reset();
|
|
83
|
+
const events = [];
|
|
84
|
+
const off = subscribeToVoice(name => { events.push(name); });
|
|
85
|
+
setVoice('Alex');
|
|
86
|
+
setVoice('Karen');
|
|
87
|
+
off();
|
|
88
|
+
// First event is the initial fire (empty), then the two changes
|
|
89
|
+
if(events[events.length - 2] !== 'Alex' || events[events.length - 1] !== 'Karen'){
|
|
90
|
+
reset();
|
|
91
|
+
return fail(`Expected last two events "Alex", "Karen", got ${JSON.stringify(events)}`);
|
|
92
|
+
}
|
|
93
|
+
reset();
|
|
94
|
+
pass('Subscribers fire on every change');
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
'should return an unsubscribe function': async ({pass, fail}) => {
|
|
98
|
+
reset();
|
|
99
|
+
let count = 0;
|
|
100
|
+
const off = subscribeToVoice(() => { count++; });
|
|
101
|
+
const initial = count;
|
|
102
|
+
off();
|
|
103
|
+
setVoice('Alex');
|
|
104
|
+
if(count !== initial){
|
|
105
|
+
reset();
|
|
106
|
+
return fail(`Subscriber should not fire after unsubscribe (initial=${initial}, after=${count})`);
|
|
107
|
+
}
|
|
108
|
+
reset();
|
|
109
|
+
pass('Unsubscribe stops further callbacks');
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
Default export
|
|
114
|
+
*/
|
|
115
|
+
'default export exposes get/set/subscribe': async ({pass, fail}) => {
|
|
116
|
+
reset();
|
|
117
|
+
if(typeof voice.get !== 'function' || typeof voice.set !== 'function' || typeof voice.subscribe !== 'function'){
|
|
118
|
+
return fail('Default export missing one of get/set/subscribe');
|
|
119
|
+
}
|
|
120
|
+
voice.set('Tessa');
|
|
121
|
+
if(voice.get() !== 'Tessa'){
|
|
122
|
+
reset();
|
|
123
|
+
return fail(`Default export get/set roundtrip failed, got "${voice.get()}"`);
|
|
124
|
+
}
|
|
125
|
+
reset();
|
|
126
|
+
pass('Default export proxies get/set/subscribe');
|
|
127
|
+
}
|
|
128
|
+
};
|