@lokascript/domain-voice 2.1.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.
@@ -0,0 +1,1232 @@
1
+ /**
2
+ * Voice/Accessibility Domain Tests
3
+ *
4
+ * Validates the multilingual voice DSL across 8 languages (EN, ES, JA, AR, KO, ZH, TR, FR)
5
+ * covering SVO, SOV, and VSO word orders, with role value verification,
6
+ * compilation output assertions (executable JS), renderer round-trips,
7
+ * alternative keywords, and edge cases.
8
+ *
9
+ * ~180 tests covering:
10
+ * - Language support (8 languages)
11
+ * - Per-language parse tests (8 languages × key commands)
12
+ * - English alternative keywords (go, press, tap, enter, say, find)
13
+ * - Spanish alternative keywords (ir, pulsar, volver)
14
+ * - French alternative keywords (aller, écrire, rechercher)
15
+ * - Cross-language semantic equivalence
16
+ * - Compilation to executable JS
17
+ * - Renderer round-trips (8 languages)
18
+ * - Error handling
19
+ */
20
+
21
+ import { describe, it, expect, beforeAll } from 'vitest';
22
+ import { createVoiceDSL, renderVoice, toVoiceActionSpec, voiceCodeGenerator } from '../index';
23
+ import type { MultilingualDSL } from '@lokascript/framework';
24
+ import { extractRoleValue } from '@lokascript/framework';
25
+
26
+ describe('Voice Domain', () => {
27
+ let voice: MultilingualDSL;
28
+
29
+ beforeAll(() => {
30
+ voice = createVoiceDSL();
31
+ });
32
+
33
+ // ===========================================================================
34
+ // Language Support
35
+ // ===========================================================================
36
+
37
+ describe('Language Support', () => {
38
+ it('should support 8 languages', () => {
39
+ const langs = voice.getSupportedLanguages();
40
+ expect(langs).toContain('en');
41
+ expect(langs).toContain('es');
42
+ expect(langs).toContain('ja');
43
+ expect(langs).toContain('ar');
44
+ expect(langs).toContain('ko');
45
+ expect(langs).toContain('zh');
46
+ expect(langs).toContain('tr');
47
+ expect(langs).toContain('fr');
48
+ expect(langs).toHaveLength(8);
49
+ });
50
+
51
+ it('should reject unsupported language', () => {
52
+ expect(() => voice.parse('click submit', 'de')).toThrow();
53
+ });
54
+ });
55
+
56
+ // ===========================================================================
57
+ // English (SVO)
58
+ // ===========================================================================
59
+
60
+ describe('English (SVO)', () => {
61
+ // navigate
62
+ it('parses "navigate to home"', () => {
63
+ const result = voice.parse('navigate to home', 'en');
64
+ expect(result.action).toBe('navigate');
65
+ expect(extractRoleValue(result, 'destination')).toBe('home');
66
+ });
67
+
68
+ it('parses "navigate to settings"', () => {
69
+ const result = voice.parse('navigate to settings', 'en');
70
+ expect(result.action).toBe('navigate');
71
+ expect(extractRoleValue(result, 'destination')).toBe('settings');
72
+ });
73
+
74
+ // click
75
+ it('parses "click submit"', () => {
76
+ const result = voice.parse('click submit', 'en');
77
+ expect(result.action).toBe('click');
78
+ expect(extractRoleValue(result, 'patient')).toBe('submit');
79
+ });
80
+
81
+ it('parses "click #login-btn"', () => {
82
+ const result = voice.parse('click #login-btn', 'en');
83
+ expect(result.action).toBe('click');
84
+ expect(extractRoleValue(result, 'patient')).toBe('#login-btn');
85
+ });
86
+
87
+ // type
88
+ it('parses "type hello"', () => {
89
+ const result = voice.parse('type hello', 'en');
90
+ expect(result.action).toBe('type');
91
+ expect(extractRoleValue(result, 'patient')).toBe('hello');
92
+ });
93
+
94
+ it('parses "type hello into #search"', () => {
95
+ const result = voice.parse('type hello into #search', 'en');
96
+ expect(result.action).toBe('type');
97
+ expect(extractRoleValue(result, 'patient')).toBe('hello');
98
+ expect(extractRoleValue(result, 'destination')).toBe('#search');
99
+ });
100
+
101
+ // scroll
102
+ it('parses "scroll down"', () => {
103
+ const result = voice.parse('scroll down', 'en');
104
+ expect(result.action).toBe('scroll');
105
+ expect(extractRoleValue(result, 'manner')).toBe('down');
106
+ });
107
+
108
+ it('parses "scroll up"', () => {
109
+ const result = voice.parse('scroll up', 'en');
110
+ expect(result.action).toBe('scroll');
111
+ expect(extractRoleValue(result, 'manner')).toBe('up');
112
+ });
113
+
114
+ // read
115
+ it('parses "read #article"', () => {
116
+ const result = voice.parse('read #article', 'en');
117
+ expect(result.action).toBe('read');
118
+ expect(extractRoleValue(result, 'patient')).toBe('#article');
119
+ });
120
+
121
+ // zoom
122
+ it('parses "zoom in"', () => {
123
+ const result = voice.parse('zoom in', 'en');
124
+ expect(result.action).toBe('zoom');
125
+ expect(extractRoleValue(result, 'manner')).toBe('in');
126
+ });
127
+
128
+ it('parses "zoom out"', () => {
129
+ const result = voice.parse('zoom out', 'en');
130
+ expect(result.action).toBe('zoom');
131
+ expect(extractRoleValue(result, 'manner')).toBe('out');
132
+ });
133
+
134
+ // select
135
+ it('parses "select all"', () => {
136
+ const result = voice.parse('select all', 'en');
137
+ expect(result.action).toBe('select');
138
+ expect(extractRoleValue(result, 'patient')).toBe('all');
139
+ });
140
+
141
+ // back / forward
142
+ it('parses "back"', () => {
143
+ const result = voice.parse('back', 'en');
144
+ expect(result.action).toBe('back');
145
+ });
146
+
147
+ it('parses "forward"', () => {
148
+ const result = voice.parse('forward', 'en');
149
+ expect(result.action).toBe('forward');
150
+ });
151
+
152
+ // focus
153
+ it('parses "focus #username"', () => {
154
+ const result = voice.parse('focus #username', 'en');
155
+ expect(result.action).toBe('focus');
156
+ expect(extractRoleValue(result, 'patient')).toBe('#username');
157
+ });
158
+
159
+ // close
160
+ it('parses "close tab"', () => {
161
+ const result = voice.parse('close tab', 'en');
162
+ expect(result.action).toBe('close');
163
+ expect(extractRoleValue(result, 'patient')).toBe('tab');
164
+ });
165
+
166
+ it('parses "close dialog"', () => {
167
+ const result = voice.parse('close dialog', 'en');
168
+ expect(result.action).toBe('close');
169
+ expect(extractRoleValue(result, 'patient')).toBe('dialog');
170
+ });
171
+
172
+ // open
173
+ it('parses "open settings"', () => {
174
+ const result = voice.parse('open settings', 'en');
175
+ expect(result.action).toBe('open');
176
+ expect(extractRoleValue(result, 'patient')).toBe('settings');
177
+ });
178
+
179
+ // search
180
+ it('parses "search hello"', () => {
181
+ const result = voice.parse('search hello', 'en');
182
+ expect(result.action).toBe('search');
183
+ expect(extractRoleValue(result, 'patient')).toBe('hello');
184
+ });
185
+
186
+ it('parses "search hello in page"', () => {
187
+ const result = voice.parse('search hello in page', 'en');
188
+ expect(result.action).toBe('search');
189
+ expect(extractRoleValue(result, 'patient')).toBe('hello');
190
+ expect(extractRoleValue(result, 'destination')).toBe('page');
191
+ });
192
+
193
+ // help
194
+ it('parses "help"', () => {
195
+ const result = voice.parse('help', 'en');
196
+ expect(result.action).toBe('help');
197
+ });
198
+
199
+ it('parses "help navigate"', () => {
200
+ const result = voice.parse('help navigate', 'en');
201
+ expect(result.action).toBe('help');
202
+ expect(extractRoleValue(result, 'patient')).toBe('navigate');
203
+ });
204
+
205
+ // validation
206
+ it('validates correct input', () => {
207
+ const result = voice.validate('click submit', 'en');
208
+ expect(result.valid).toBe(true);
209
+ });
210
+
211
+ // compilation
212
+ it('compiles "click submit" to JS with click', () => {
213
+ const result = voice.compile('click submit', 'en');
214
+ expect(result.ok).toBe(true);
215
+ expect(result.code).toContain('.click()');
216
+ });
217
+
218
+ it('compiles "navigate to /home" to JS with location', () => {
219
+ const result = voice.compile('navigate to /home', 'en');
220
+ expect(result.ok).toBe(true);
221
+ expect(result.code).toContain('window.location.href');
222
+ });
223
+
224
+ it('compiles "scroll down" to JS with scrollBy', () => {
225
+ const result = voice.compile('scroll down', 'en');
226
+ expect(result.ok).toBe(true);
227
+ expect(result.code).toContain('scrollBy');
228
+ });
229
+
230
+ it('compiles "back" to JS with history.go', () => {
231
+ const result = voice.compile('back', 'en');
232
+ expect(result.ok).toBe(true);
233
+ expect(result.code).toContain('history.go');
234
+ });
235
+
236
+ it('compiles "read #article" to JS with SpeechSynthesis', () => {
237
+ const result = voice.compile('read #article', 'en');
238
+ expect(result.ok).toBe(true);
239
+ expect(result.code).toContain('SpeechSynthesisUtterance');
240
+ });
241
+
242
+ it('compiles "zoom in" to JS with body.style.zoom', () => {
243
+ const result = voice.compile('zoom in', 'en');
244
+ expect(result.ok).toBe(true);
245
+ expect(result.code).toContain('zoom');
246
+ });
247
+
248
+ it('compiles "close tab" to JS with window.close', () => {
249
+ const result = voice.compile('close tab', 'en');
250
+ expect(result.ok).toBe(true);
251
+ expect(result.code).toContain('window.close');
252
+ });
253
+
254
+ it('compiles "open /new-page" to JS with window.open', () => {
255
+ const result = voice.compile('open /new-page', 'en');
256
+ expect(result.ok).toBe(true);
257
+ expect(result.code).toContain('window.open');
258
+ });
259
+
260
+ it('compiles "search hello" to JS with searchInput', () => {
261
+ const result = voice.compile('search hello', 'en');
262
+ expect(result.ok).toBe(true);
263
+ expect(result.code).toContain('searchInput');
264
+ });
265
+
266
+ it('compiles "forward" to JS with history.go', () => {
267
+ const result = voice.compile('forward', 'en');
268
+ expect(result.ok).toBe(true);
269
+ expect(result.code).toContain('history.go');
270
+ });
271
+
272
+ it('compiles "focus #username" to JS with .focus()', () => {
273
+ const result = voice.compile('focus #username', 'en');
274
+ expect(result.ok).toBe(true);
275
+ expect(result.code).toContain('.focus()');
276
+ });
277
+
278
+ it('compiles "select all" to JS with Range selection', () => {
279
+ const result = voice.compile('select all', 'en');
280
+ expect(result.ok).toBe(true);
281
+ expect(result.code).toContain('selectNodeContents');
282
+ });
283
+
284
+ it('compiles "type hello into #search" to JS with value', () => {
285
+ const result = voice.compile('type hello into #search', 'en');
286
+ expect(result.ok).toBe(true);
287
+ expect(result.code).toContain('value');
288
+ expect(result.code).toContain('input');
289
+ });
290
+
291
+ it('compiles "help" to JS with console.log', () => {
292
+ const result = voice.compile('help', 'en');
293
+ expect(result.ok).toBe(true);
294
+ expect(result.code).toContain('console.log');
295
+ });
296
+ });
297
+
298
+ // ===========================================================================
299
+ // English Alternative Keywords
300
+ // ===========================================================================
301
+
302
+ describe('English alternative keywords', () => {
303
+ it('parses "go" as navigate', () => {
304
+ const result = voice.parse('go to home', 'en');
305
+ expect(result.action).toBe('navigate');
306
+ });
307
+
308
+ it('parses "press" as click', () => {
309
+ const result = voice.parse('press submit', 'en');
310
+ expect(result.action).toBe('click');
311
+ });
312
+
313
+ it('parses "tap" as click', () => {
314
+ const result = voice.parse('tap submit', 'en');
315
+ expect(result.action).toBe('click');
316
+ });
317
+
318
+ it('parses "enter" as type', () => {
319
+ const result = voice.parse('enter hello', 'en');
320
+ expect(result.action).toBe('type');
321
+ });
322
+
323
+ it('parses "say" as read', () => {
324
+ const result = voice.parse('say #article', 'en');
325
+ expect(result.action).toBe('read');
326
+ });
327
+
328
+ it('parses "find" as search', () => {
329
+ const result = voice.parse('find hello', 'en');
330
+ expect(result.action).toBe('search');
331
+ });
332
+ });
333
+
334
+ // ===========================================================================
335
+ // Spanish (SVO)
336
+ // ===========================================================================
337
+
338
+ describe('Spanish (SVO)', () => {
339
+ it('parses "navegar a inicio"', () => {
340
+ const result = voice.parse('navegar a inicio', 'es');
341
+ expect(result.action).toBe('navigate');
342
+ expect(extractRoleValue(result, 'destination')).toBe('inicio');
343
+ });
344
+
345
+ it('parses "clic enviar"', () => {
346
+ const result = voice.parse('clic enviar', 'es');
347
+ expect(result.action).toBe('click');
348
+ expect(extractRoleValue(result, 'patient')).toBe('enviar');
349
+ });
350
+
351
+ it('parses "escribir hola"', () => {
352
+ const result = voice.parse('escribir hola', 'es');
353
+ expect(result.action).toBe('type');
354
+ expect(extractRoleValue(result, 'patient')).toBe('hola');
355
+ });
356
+
357
+ it('parses "desplazar abajo"', () => {
358
+ const result = voice.parse('desplazar abajo', 'es');
359
+ expect(result.action).toBe('scroll');
360
+ expect(extractRoleValue(result, 'manner')).toBe('abajo');
361
+ });
362
+
363
+ it('parses "leer #articulo"', () => {
364
+ const result = voice.parse('leer #articulo', 'es');
365
+ expect(result.action).toBe('read');
366
+ expect(extractRoleValue(result, 'patient')).toBe('#articulo');
367
+ });
368
+
369
+ it('parses "seleccionar todo"', () => {
370
+ const result = voice.parse('seleccionar todo', 'es');
371
+ expect(result.action).toBe('select');
372
+ });
373
+
374
+ it('parses "cerrar pestaña"', () => {
375
+ const result = voice.parse('cerrar pestaña', 'es');
376
+ expect(result.action).toBe('close');
377
+ });
378
+
379
+ it('parses "abrir configuración"', () => {
380
+ const result = voice.parse('abrir configuración', 'es');
381
+ expect(result.action).toBe('open');
382
+ });
383
+
384
+ it('parses "buscar hola"', () => {
385
+ const result = voice.parse('buscar hola', 'es');
386
+ expect(result.action).toBe('search');
387
+ });
388
+
389
+ it('parses "atrás"', () => {
390
+ const result = voice.parse('atrás', 'es');
391
+ expect(result.action).toBe('back');
392
+ });
393
+
394
+ it('parses "adelante"', () => {
395
+ const result = voice.parse('adelante', 'es');
396
+ expect(result.action).toBe('forward');
397
+ });
398
+
399
+ it('parses "ayuda"', () => {
400
+ const result = voice.parse('ayuda', 'es');
401
+ expect(result.action).toBe('help');
402
+ });
403
+
404
+ it('compiles "clic enviar" to JS', () => {
405
+ const result = voice.compile('clic enviar', 'es');
406
+ expect(result.ok).toBe(true);
407
+ expect(result.code).toContain('.click()');
408
+ });
409
+ });
410
+
411
+ // ===========================================================================
412
+ // Spanish Alternative Keywords
413
+ // ===========================================================================
414
+
415
+ describe('Spanish alternative keywords', () => {
416
+ it('parses "ir" as navigate', () => {
417
+ const result = voice.parse('ir a inicio', 'es');
418
+ expect(result.action).toBe('navigate');
419
+ });
420
+
421
+ it('parses "pulsar" as click', () => {
422
+ const result = voice.parse('pulsar enviar', 'es');
423
+ expect(result.action).toBe('click');
424
+ });
425
+
426
+ it('parses "volver" as back', () => {
427
+ const result = voice.parse('volver', 'es');
428
+ expect(result.action).toBe('back');
429
+ });
430
+ });
431
+
432
+ // ===========================================================================
433
+ // Japanese (SOV)
434
+ // ===========================================================================
435
+
436
+ describe('Japanese (SOV)', () => {
437
+ it('parses "ホーム に 移動" (navigate to home)', () => {
438
+ const result = voice.parse('ホーム に 移動', 'ja');
439
+ expect(result.action).toBe('navigate');
440
+ });
441
+
442
+ it('parses "送信 を クリック" (click submit)', () => {
443
+ const result = voice.parse('送信 を クリック', 'ja');
444
+ expect(result.action).toBe('click');
445
+ });
446
+
447
+ it('parses "こんにちは を 入力" (type hello)', () => {
448
+ const result = voice.parse('こんにちは を 入力', 'ja');
449
+ expect(result.action).toBe('type');
450
+ });
451
+
452
+ it('parses "下 スクロール" (scroll down)', () => {
453
+ const result = voice.parse('下 スクロール', 'ja');
454
+ expect(result.action).toBe('scroll');
455
+ });
456
+
457
+ it('parses "記事 を 読む" (read article)', () => {
458
+ const result = voice.parse('記事 を 読む', 'ja');
459
+ expect(result.action).toBe('read');
460
+ });
461
+
462
+ it('parses "拡大 ズーム" (zoom in)', () => {
463
+ const result = voice.parse('拡大 ズーム', 'ja');
464
+ expect(result.action).toBe('zoom');
465
+ });
466
+
467
+ it('parses "全て を 選択" (select all)', () => {
468
+ const result = voice.parse('全て を 選択', 'ja');
469
+ expect(result.action).toBe('select');
470
+ });
471
+
472
+ it('parses "戻る" (back)', () => {
473
+ const result = voice.parse('戻る', 'ja');
474
+ expect(result.action).toBe('back');
475
+ });
476
+
477
+ it('parses "進む" (forward)', () => {
478
+ const result = voice.parse('進む', 'ja');
479
+ expect(result.action).toBe('forward');
480
+ });
481
+
482
+ it('parses "タブ を 閉じる" (close tab)', () => {
483
+ const result = voice.parse('タブ を 閉じる', 'ja');
484
+ expect(result.action).toBe('close');
485
+ });
486
+
487
+ it('parses "設定 を 開く" (open settings)', () => {
488
+ const result = voice.parse('設定 を 開く', 'ja');
489
+ expect(result.action).toBe('open');
490
+ });
491
+
492
+ it('parses "こんにちは を 検索" (search hello)', () => {
493
+ const result = voice.parse('こんにちは を 検索', 'ja');
494
+ expect(result.action).toBe('search');
495
+ });
496
+
497
+ it('parses "ヘルプ" (help)', () => {
498
+ const result = voice.parse('ヘルプ', 'ja');
499
+ expect(result.action).toBe('help');
500
+ });
501
+
502
+ it('compiles "送信 を クリック" to JS', () => {
503
+ const result = voice.compile('送信 を クリック', 'ja');
504
+ expect(result.ok).toBe(true);
505
+ expect(result.code).toContain('.click()');
506
+ });
507
+ });
508
+
509
+ // ===========================================================================
510
+ // Arabic (VSO)
511
+ // ===========================================================================
512
+
513
+ describe('Arabic (VSO)', () => {
514
+ it('parses "انتقل إلى الرئيسية" (navigate to home)', () => {
515
+ const result = voice.parse('انتقل إلى الرئيسية', 'ar');
516
+ expect(result.action).toBe('navigate');
517
+ });
518
+
519
+ it('parses "انقر على إرسال" (click submit)', () => {
520
+ const result = voice.parse('انقر على إرسال', 'ar');
521
+ expect(result.action).toBe('click');
522
+ });
523
+
524
+ it('parses "اكتب مرحبا" (type hello)', () => {
525
+ const result = voice.parse('اكتب مرحبا', 'ar');
526
+ expect(result.action).toBe('type');
527
+ });
528
+
529
+ it('parses "تمرير أسفل" (scroll down)', () => {
530
+ const result = voice.parse('تمرير أسفل', 'ar');
531
+ expect(result.action).toBe('scroll');
532
+ });
533
+
534
+ it('parses "اقرأ #مقال" (read article)', () => {
535
+ const result = voice.parse('اقرأ #مقال', 'ar');
536
+ expect(result.action).toBe('read');
537
+ });
538
+
539
+ it('parses "أغلق الحوار" (close dialog)', () => {
540
+ const result = voice.parse('أغلق الحوار', 'ar');
541
+ expect(result.action).toBe('close');
542
+ });
543
+
544
+ it('parses "افتح إعدادات" (open settings)', () => {
545
+ const result = voice.parse('افتح إعدادات', 'ar');
546
+ expect(result.action).toBe('open');
547
+ });
548
+
549
+ it('parses "ابحث عن مرحبا" (search hello)', () => {
550
+ const result = voice.parse('ابحث عن مرحبا', 'ar');
551
+ expect(result.action).toBe('search');
552
+ });
553
+
554
+ it('parses "رجوع" (back)', () => {
555
+ const result = voice.parse('رجوع', 'ar');
556
+ expect(result.action).toBe('back');
557
+ });
558
+
559
+ it('parses "تقدم" (forward)', () => {
560
+ const result = voice.parse('تقدم', 'ar');
561
+ expect(result.action).toBe('forward');
562
+ });
563
+
564
+ it('parses "مساعدة" (help)', () => {
565
+ const result = voice.parse('مساعدة', 'ar');
566
+ expect(result.action).toBe('help');
567
+ });
568
+
569
+ it('compiles "انقر على إرسال" to JS', () => {
570
+ const result = voice.compile('انقر على إرسال', 'ar');
571
+ expect(result.ok).toBe(true);
572
+ expect(result.code).toContain('.click()');
573
+ });
574
+ });
575
+
576
+ // ===========================================================================
577
+ // Korean (SOV)
578
+ // ===========================================================================
579
+
580
+ describe('Korean (SOV)', () => {
581
+ it('parses "홈 로 이동" (navigate to home)', () => {
582
+ const result = voice.parse('홈 로 이동', 'ko');
583
+ expect(result.action).toBe('navigate');
584
+ });
585
+
586
+ it('parses "제출 을 클릭" (click submit)', () => {
587
+ const result = voice.parse('제출 을 클릭', 'ko');
588
+ expect(result.action).toBe('click');
589
+ });
590
+
591
+ it('parses "안녕 을 입력" (type hello)', () => {
592
+ const result = voice.parse('안녕 을 입력', 'ko');
593
+ expect(result.action).toBe('type');
594
+ });
595
+
596
+ it('parses "아래 스크롤" (scroll down)', () => {
597
+ const result = voice.parse('아래 스크롤', 'ko');
598
+ expect(result.action).toBe('scroll');
599
+ });
600
+
601
+ it('parses "기사 을 읽기" (read article)', () => {
602
+ const result = voice.parse('기사 을 읽기', 'ko');
603
+ expect(result.action).toBe('read');
604
+ });
605
+
606
+ it('parses "전체 를 선택" (select all)', () => {
607
+ const result = voice.parse('전체 를 선택', 'ko');
608
+ expect(result.action).toBe('select');
609
+ });
610
+
611
+ it('parses "탭 를 닫기" (close tab)', () => {
612
+ const result = voice.parse('탭 를 닫기', 'ko');
613
+ expect(result.action).toBe('close');
614
+ });
615
+
616
+ it('parses "설정 을 열기" (open settings)', () => {
617
+ const result = voice.parse('설정 을 열기', 'ko');
618
+ expect(result.action).toBe('open');
619
+ });
620
+
621
+ it('parses "뒤로" (back)', () => {
622
+ const result = voice.parse('뒤로', 'ko');
623
+ expect(result.action).toBe('back');
624
+ });
625
+
626
+ it('parses "앞으로" (forward)', () => {
627
+ const result = voice.parse('앞으로', 'ko');
628
+ expect(result.action).toBe('forward');
629
+ });
630
+
631
+ it('parses "도움말" (help)', () => {
632
+ const result = voice.parse('도움말', 'ko');
633
+ expect(result.action).toBe('help');
634
+ });
635
+
636
+ it('compiles "제출 을 클릭" to JS', () => {
637
+ const result = voice.compile('제출 을 클릭', 'ko');
638
+ expect(result.ok).toBe(true);
639
+ expect(result.code).toContain('.click()');
640
+ });
641
+ });
642
+
643
+ // ===========================================================================
644
+ // Chinese (SVO)
645
+ // ===========================================================================
646
+
647
+ describe('Chinese (SVO)', () => {
648
+ it('parses "导航 到 首页" (navigate to home)', () => {
649
+ const result = voice.parse('导航 到 首页', 'zh');
650
+ expect(result.action).toBe('navigate');
651
+ });
652
+
653
+ it('parses "点击 提交" (click submit)', () => {
654
+ const result = voice.parse('点击 提交', 'zh');
655
+ expect(result.action).toBe('click');
656
+ });
657
+
658
+ it('parses "输入 你好" (type hello)', () => {
659
+ const result = voice.parse('输入 你好', 'zh');
660
+ expect(result.action).toBe('type');
661
+ });
662
+
663
+ it('parses "滚动 下" (scroll down)', () => {
664
+ const result = voice.parse('滚动 下', 'zh');
665
+ expect(result.action).toBe('scroll');
666
+ });
667
+
668
+ it('parses "朗读 #文章" (read article)', () => {
669
+ const result = voice.parse('朗读 #文章', 'zh');
670
+ expect(result.action).toBe('read');
671
+ });
672
+
673
+ it('parses "选择 全部" (select all)', () => {
674
+ const result = voice.parse('选择 全部', 'zh');
675
+ expect(result.action).toBe('select');
676
+ });
677
+
678
+ it('parses "关闭 标签" (close tab)', () => {
679
+ const result = voice.parse('关闭 标签', 'zh');
680
+ expect(result.action).toBe('close');
681
+ });
682
+
683
+ it('parses "打开 设置" (open settings)', () => {
684
+ const result = voice.parse('打开 设置', 'zh');
685
+ expect(result.action).toBe('open');
686
+ });
687
+
688
+ it('parses "搜索 你好" (search hello)', () => {
689
+ const result = voice.parse('搜索 你好', 'zh');
690
+ expect(result.action).toBe('search');
691
+ });
692
+
693
+ it('parses "返回" (back)', () => {
694
+ const result = voice.parse('返回', 'zh');
695
+ expect(result.action).toBe('back');
696
+ });
697
+
698
+ it('parses "前进" (forward)', () => {
699
+ const result = voice.parse('前进', 'zh');
700
+ expect(result.action).toBe('forward');
701
+ });
702
+
703
+ it('parses "帮助" (help)', () => {
704
+ const result = voice.parse('帮助', 'zh');
705
+ expect(result.action).toBe('help');
706
+ });
707
+
708
+ it('compiles "点击 提交" to JS', () => {
709
+ const result = voice.compile('点击 提交', 'zh');
710
+ expect(result.ok).toBe(true);
711
+ expect(result.code).toContain('.click()');
712
+ });
713
+ });
714
+
715
+ // ===========================================================================
716
+ // Turkish (SOV)
717
+ // ===========================================================================
718
+
719
+ describe('Turkish (SOV)', () => {
720
+ it('parses "ana-sayfa ya git" (navigate to home)', () => {
721
+ const result = voice.parse('ana-sayfa ya git', 'tr');
722
+ expect(result.action).toBe('navigate');
723
+ });
724
+
725
+ it('parses "gönder tıkla" (click submit)', () => {
726
+ const result = voice.parse('gönder tıkla', 'tr');
727
+ expect(result.action).toBe('click');
728
+ });
729
+
730
+ it('parses "merhaba yaz" (type hello)', () => {
731
+ const result = voice.parse('merhaba yaz', 'tr');
732
+ expect(result.action).toBe('type');
733
+ });
734
+
735
+ it('parses "aşağı kaydır" (scroll down)', () => {
736
+ const result = voice.parse('aşağı kaydır', 'tr');
737
+ expect(result.action).toBe('scroll');
738
+ });
739
+
740
+ it('parses "makale oku" (read article)', () => {
741
+ const result = voice.parse('makale oku', 'tr');
742
+ expect(result.action).toBe('read');
743
+ });
744
+
745
+ it('parses "sekme kapat" (close tab)', () => {
746
+ const result = voice.parse('sekme kapat', 'tr');
747
+ expect(result.action).toBe('close');
748
+ });
749
+
750
+ it('parses "ayarlar aç" (open settings)', () => {
751
+ const result = voice.parse('ayarlar aç', 'tr');
752
+ expect(result.action).toBe('open');
753
+ });
754
+
755
+ it('parses "merhaba ara" (search hello)', () => {
756
+ const result = voice.parse('merhaba ara', 'tr');
757
+ expect(result.action).toBe('search');
758
+ });
759
+
760
+ it('parses "geri" (back)', () => {
761
+ const result = voice.parse('geri', 'tr');
762
+ expect(result.action).toBe('back');
763
+ });
764
+
765
+ it('parses "ileri" (forward)', () => {
766
+ const result = voice.parse('ileri', 'tr');
767
+ expect(result.action).toBe('forward');
768
+ });
769
+
770
+ it('parses "yardım" (help)', () => {
771
+ const result = voice.parse('yardım', 'tr');
772
+ expect(result.action).toBe('help');
773
+ });
774
+
775
+ it('compiles "gönder tıkla" to JS', () => {
776
+ const result = voice.compile('gönder tıkla', 'tr');
777
+ expect(result.ok).toBe(true);
778
+ expect(result.code).toContain('.click()');
779
+ });
780
+ });
781
+
782
+ // ===========================================================================
783
+ // French (SVO)
784
+ // ===========================================================================
785
+
786
+ describe('French (SVO)', () => {
787
+ it('parses "naviguer vers accueil"', () => {
788
+ const result = voice.parse('naviguer vers accueil', 'fr');
789
+ expect(result.action).toBe('navigate');
790
+ expect(extractRoleValue(result, 'destination')).toBe('accueil');
791
+ });
792
+
793
+ it('parses "cliquer sur envoyer"', () => {
794
+ const result = voice.parse('cliquer sur envoyer', 'fr');
795
+ expect(result.action).toBe('click');
796
+ expect(extractRoleValue(result, 'patient')).toBe('envoyer');
797
+ });
798
+
799
+ it('parses "taper bonjour"', () => {
800
+ const result = voice.parse('taper bonjour', 'fr');
801
+ expect(result.action).toBe('type');
802
+ expect(extractRoleValue(result, 'patient')).toBe('bonjour');
803
+ });
804
+
805
+ it('parses "défiler bas"', () => {
806
+ const result = voice.parse('défiler bas', 'fr');
807
+ expect(result.action).toBe('scroll');
808
+ });
809
+
810
+ it('parses "lire #article"', () => {
811
+ const result = voice.parse('lire #article', 'fr');
812
+ expect(result.action).toBe('read');
813
+ });
814
+
815
+ it('parses "fermer onglet"', () => {
816
+ const result = voice.parse('fermer onglet', 'fr');
817
+ expect(result.action).toBe('close');
818
+ });
819
+
820
+ it('parses "ouvrir menu"', () => {
821
+ const result = voice.parse('ouvrir menu', 'fr');
822
+ expect(result.action).toBe('open');
823
+ });
824
+
825
+ it('parses "chercher bonjour"', () => {
826
+ const result = voice.parse('chercher bonjour', 'fr');
827
+ expect(result.action).toBe('search');
828
+ });
829
+
830
+ it('parses "retour"', () => {
831
+ const result = voice.parse('retour', 'fr');
832
+ expect(result.action).toBe('back');
833
+ });
834
+
835
+ it('parses "avancer"', () => {
836
+ const result = voice.parse('avancer', 'fr');
837
+ expect(result.action).toBe('forward');
838
+ });
839
+
840
+ it('parses "aide"', () => {
841
+ const result = voice.parse('aide', 'fr');
842
+ expect(result.action).toBe('help');
843
+ });
844
+
845
+ it('compiles "cliquer sur envoyer" to JS', () => {
846
+ const result = voice.compile('cliquer sur envoyer', 'fr');
847
+ expect(result.ok).toBe(true);
848
+ expect(result.code).toContain('.click()');
849
+ });
850
+ });
851
+
852
+ // ===========================================================================
853
+ // French Alternative Keywords
854
+ // ===========================================================================
855
+
856
+ describe('French alternative keywords', () => {
857
+ it('parses "aller" as navigate', () => {
858
+ const result = voice.parse('aller vers accueil', 'fr');
859
+ expect(result.action).toBe('navigate');
860
+ });
861
+
862
+ it('parses "écrire" as type', () => {
863
+ const result = voice.parse('écrire bonjour', 'fr');
864
+ expect(result.action).toBe('type');
865
+ });
866
+
867
+ it('parses "rechercher" as search', () => {
868
+ const result = voice.parse('rechercher bonjour', 'fr');
869
+ expect(result.action).toBe('search');
870
+ });
871
+ });
872
+
873
+ // ===========================================================================
874
+ // Semantic Equivalence (Cross-Language)
875
+ // ===========================================================================
876
+
877
+ describe('Semantic Equivalence', () => {
878
+ it('"click" produces same action across EN and ES', () => {
879
+ const en = voice.parse('click submit', 'en');
880
+ const es = voice.parse('clic enviar', 'es');
881
+ expect(en.action).toBe(es.action);
882
+ expect(en.action).toBe('click');
883
+ });
884
+
885
+ it('"click" produces same action across EN and JA (SOV)', () => {
886
+ const en = voice.parse('click submit', 'en');
887
+ const ja = voice.parse('送信 を クリック', 'ja');
888
+ expect(en.action).toBe(ja.action);
889
+ expect(en.action).toBe('click');
890
+ });
891
+
892
+ it('"click" produces same action across EN and AR (VSO)', () => {
893
+ const en = voice.parse('click submit', 'en');
894
+ const ar = voice.parse('انقر على إرسال', 'ar');
895
+ expect(en.action).toBe(ar.action);
896
+ expect(en.action).toBe('click');
897
+ });
898
+
899
+ it('"navigate" produces same action across EN, KO, and ZH', () => {
900
+ const en = voice.parse('navigate to home', 'en');
901
+ const ko = voice.parse('홈 로 이동', 'ko');
902
+ const zh = voice.parse('导航 到 首页', 'zh');
903
+ expect(en.action).toBe(ko.action);
904
+ expect(en.action).toBe(zh.action);
905
+ expect(en.action).toBe('navigate');
906
+ });
907
+
908
+ it('"scroll" produces same action across TR and FR', () => {
909
+ const tr = voice.parse('aşağı kaydır', 'tr');
910
+ const fr = voice.parse('défiler bas', 'fr');
911
+ expect(tr.action).toBe(fr.action);
912
+ expect(tr.action).toBe('scroll');
913
+ });
914
+
915
+ it('"back" produces same action across all 8 languages', () => {
916
+ const en = voice.parse('back', 'en');
917
+ const es = voice.parse('atrás', 'es');
918
+ const ja = voice.parse('戻る', 'ja');
919
+ const ar = voice.parse('رجوع', 'ar');
920
+ const ko = voice.parse('뒤로', 'ko');
921
+ const zh = voice.parse('返回', 'zh');
922
+ const tr = voice.parse('geri', 'tr');
923
+ const fr = voice.parse('retour', 'fr');
924
+ const actions = [en, es, ja, ar, ko, zh, tr, fr].map(r => r.action);
925
+ expect(new Set(actions).size).toBe(1);
926
+ expect(actions[0]).toBe('back');
927
+ });
928
+
929
+ it('"help" produces same action across all 8 languages', () => {
930
+ const en = voice.parse('help', 'en');
931
+ const es = voice.parse('ayuda', 'es');
932
+ const ja = voice.parse('ヘルプ', 'ja');
933
+ const ar = voice.parse('مساعدة', 'ar');
934
+ const ko = voice.parse('도움말', 'ko');
935
+ const zh = voice.parse('帮助', 'zh');
936
+ const tr = voice.parse('yardım', 'tr');
937
+ const fr = voice.parse('aide', 'fr');
938
+ const actions = [en, es, ja, ar, ko, zh, tr, fr].map(r => r.action);
939
+ expect(new Set(actions).size).toBe(1);
940
+ expect(actions[0]).toBe('help');
941
+ });
942
+
943
+ it('"close" produces same action across EN, JA, KO', () => {
944
+ const en = voice.parse('close tab', 'en');
945
+ const ja = voice.parse('タブ を 閉じる', 'ja');
946
+ const ko = voice.parse('탭 를 닫기', 'ko');
947
+ expect(en.action).toBe(ja.action);
948
+ expect(en.action).toBe(ko.action);
949
+ expect(en.action).toBe('close');
950
+ });
951
+
952
+ it('"open" produces same action across EN, ES, AR', () => {
953
+ const en = voice.parse('open settings', 'en');
954
+ const es = voice.parse('abrir configuración', 'es');
955
+ const ar = voice.parse('افتح إعدادات', 'ar');
956
+ expect(en.action).toBe(es.action);
957
+ expect(en.action).toBe(ar.action);
958
+ expect(en.action).toBe('open');
959
+ });
960
+ });
961
+
962
+ // ===========================================================================
963
+ // Renderer
964
+ // ===========================================================================
965
+
966
+ describe('Renderer', () => {
967
+ it('renders click to English', () => {
968
+ const node = voice.parse('click submit', 'en');
969
+ const rendered = renderVoice(node, 'en');
970
+ expect(rendered).toContain('click');
971
+ expect(rendered).toContain('submit');
972
+ });
973
+
974
+ it('renders click to Spanish', () => {
975
+ const node = voice.parse('click submit', 'en');
976
+ const rendered = renderVoice(node, 'es');
977
+ expect(rendered).toContain('clic');
978
+ });
979
+
980
+ it('renders click to Japanese (SOV order)', () => {
981
+ const node = voice.parse('click submit', 'en');
982
+ const rendered = renderVoice(node, 'ja');
983
+ expect(rendered).toContain('クリック');
984
+ // SOV: patient before verb
985
+ const patientIdx = rendered.indexOf('submit');
986
+ const verbIdx = rendered.indexOf('クリック');
987
+ if (patientIdx >= 0 && verbIdx >= 0) {
988
+ expect(patientIdx).toBeLessThan(verbIdx);
989
+ }
990
+ });
991
+
992
+ it('renders click to Arabic (with على marker)', () => {
993
+ const node = voice.parse('click submit', 'en');
994
+ const rendered = renderVoice(node, 'ar');
995
+ expect(rendered).toContain('انقر');
996
+ expect(rendered).toContain('على');
997
+ });
998
+
999
+ it('renders click to Korean (SOV order)', () => {
1000
+ const node = voice.parse('click submit', 'en');
1001
+ const rendered = renderVoice(node, 'ko');
1002
+ expect(rendered).toContain('클릭');
1003
+ });
1004
+
1005
+ it('renders click to Chinese', () => {
1006
+ const node = voice.parse('click submit', 'en');
1007
+ const rendered = renderVoice(node, 'zh');
1008
+ expect(rendered).toContain('点击');
1009
+ });
1010
+
1011
+ it('renders click to Turkish (SOV order)', () => {
1012
+ const node = voice.parse('click submit', 'en');
1013
+ const rendered = renderVoice(node, 'tr');
1014
+ expect(rendered).toContain('tıkla');
1015
+ });
1016
+
1017
+ it('renders click to French (with sur marker)', () => {
1018
+ const node = voice.parse('click submit', 'en');
1019
+ const rendered = renderVoice(node, 'fr');
1020
+ expect(rendered).toContain('cliquer');
1021
+ expect(rendered).toContain('sur');
1022
+ });
1023
+
1024
+ it('renders navigate with destination', () => {
1025
+ const node = voice.parse('navigate to home', 'en');
1026
+ const rendered = renderVoice(node, 'en');
1027
+ expect(rendered).toContain('navigate');
1028
+ expect(rendered).toContain('home');
1029
+ });
1030
+
1031
+ it('renders navigate to Japanese (SOV: dest marker verb)', () => {
1032
+ const node = voice.parse('navigate to home', 'en');
1033
+ const rendered = renderVoice(node, 'ja');
1034
+ expect(rendered).toContain('移動');
1035
+ expect(rendered).toContain('home');
1036
+ });
1037
+
1038
+ it('renders scroll with manner', () => {
1039
+ const node = voice.parse('scroll down', 'en');
1040
+ const rendered = renderVoice(node, 'en');
1041
+ expect(rendered).toBe('scroll down');
1042
+ });
1043
+
1044
+ it('renders bare back command', () => {
1045
+ const node = voice.parse('back', 'en');
1046
+ const rendered = renderVoice(node, 'en');
1047
+ expect(rendered).toBe('back');
1048
+ });
1049
+
1050
+ it('renders bare help command', () => {
1051
+ const node = voice.parse('help', 'en');
1052
+ const rendered = renderVoice(node, 'en');
1053
+ expect(rendered).toBe('help');
1054
+ });
1055
+
1056
+ it('renders search with query', () => {
1057
+ const node = voice.parse('search hello', 'en');
1058
+ const rendered = renderVoice(node, 'en');
1059
+ expect(rendered).toContain('search');
1060
+ expect(rendered).toContain('hello');
1061
+ });
1062
+
1063
+ it('renders type with destination', () => {
1064
+ const node = voice.parse('type hello into #search', 'en');
1065
+ const rendered = renderVoice(node, 'en');
1066
+ expect(rendered).toContain('type');
1067
+ expect(rendered).toContain('hello');
1068
+ });
1069
+
1070
+ it('renders zoom', () => {
1071
+ const node = voice.parse('zoom in', 'en');
1072
+ const rendered = renderVoice(node, 'en');
1073
+ expect(rendered).toContain('zoom');
1074
+ expect(rendered).toContain('in');
1075
+ });
1076
+
1077
+ it('renders open', () => {
1078
+ const node = voice.parse('open settings', 'en');
1079
+ const rendered = renderVoice(node, 'en');
1080
+ expect(rendered).toContain('open');
1081
+ expect(rendered).toContain('settings');
1082
+ });
1083
+
1084
+ it('renders focus', () => {
1085
+ const node = voice.parse('focus #username', 'en');
1086
+ const rendered = renderVoice(node, 'en');
1087
+ expect(rendered).toContain('focus');
1088
+ });
1089
+ });
1090
+
1091
+ // ===========================================================================
1092
+ // Error Handling
1093
+ // ===========================================================================
1094
+
1095
+ describe('Error Handling', () => {
1096
+ it('returns errors for invalid input', () => {
1097
+ const result = voice.validate('completely invalid gibberish xyz', 'en');
1098
+ expect(result.valid).toBe(false);
1099
+ expect(result.errors).toBeDefined();
1100
+ });
1101
+
1102
+ it('returns errors for empty input', () => {
1103
+ const result = voice.validate('', 'en');
1104
+ expect(result.valid).toBe(false);
1105
+ });
1106
+
1107
+ it('compile returns ok:false for invalid input', () => {
1108
+ const result = voice.compile('completely invalid gibberish xyz', 'en');
1109
+ expect(result.ok).toBe(false);
1110
+ });
1111
+ });
1112
+
1113
+ // ===========================================================================
1114
+ // Security — XSS Prevention in Code Generator
1115
+ //
1116
+ // The tokenizer strips most special characters before they reach the code
1117
+ // generator, so we test esc() by constructing SemanticNodes manually with
1118
+ // malicious role values and passing them directly to the generator.
1119
+ // ===========================================================================
1120
+
1121
+ describe('Security', () => {
1122
+ it('escapes single quotes in generated JS', () => {
1123
+ // Construct a SemanticNode with a single-quote in the role value
1124
+ const node: import('@lokascript/framework').SemanticNode = {
1125
+ kind: 'command',
1126
+ action: 'click',
1127
+ roles: new Map([['patient', { type: 'literal' as const, value: "test'value" }]]),
1128
+ };
1129
+ const code = voiceCodeGenerator.generate(node);
1130
+ // The single quote must be escaped so it doesn't break out of the string literal
1131
+ expect(code).not.toContain("'test'value'");
1132
+ expect(code).toContain("\\'");
1133
+ });
1134
+
1135
+ it('escapes backticks in generated JS', () => {
1136
+ const node: import('@lokascript/framework').SemanticNode = {
1137
+ kind: 'command',
1138
+ action: 'click',
1139
+ roles: new Map([['patient', { type: 'literal' as const, value: 'test`value' }]]),
1140
+ };
1141
+ const code = voiceCodeGenerator.generate(node);
1142
+ expect(code).toContain('\\`');
1143
+ });
1144
+
1145
+ it('escapes dollar signs in generated JS', () => {
1146
+ const node: import('@lokascript/framework').SemanticNode = {
1147
+ kind: 'command',
1148
+ action: 'click',
1149
+ roles: new Map([['patient', { type: 'literal' as const, value: 'test${alert(1)}' }]]),
1150
+ };
1151
+ const code = voiceCodeGenerator.generate(node);
1152
+ // The $ should be escaped so template literals can't execute
1153
+ expect(code).toContain('\\$');
1154
+ // Should not contain an unescaped $ (i.e., $ not preceded by \)
1155
+ expect(code).not.toMatch(/(?<!\\)\$\{alert/);
1156
+ });
1157
+
1158
+ it('escapes newlines in generated JS', () => {
1159
+ const node: import('@lokascript/framework').SemanticNode = {
1160
+ kind: 'command',
1161
+ action: 'navigate',
1162
+ roles: new Map([['destination', { type: 'literal' as const, value: 'test\nvalue' }]]),
1163
+ };
1164
+ const code = voiceCodeGenerator.generate(node);
1165
+ // The literal newline in the value must be escaped to \\n in the string
1166
+ expect(code).toContain('\\n');
1167
+ // Should not contain an actual unescaped newline inside a string literal
1168
+ expect(code).not.toMatch(/href = 'test\nvalue'/);
1169
+ });
1170
+ });
1171
+
1172
+ // ===========================================================================
1173
+ // Code Generation Quality
1174
+ // ===========================================================================
1175
+
1176
+ describe('Code Generation Quality', () => {
1177
+ it('_findEl uses idempotent guard (no redeclaration)', () => {
1178
+ const result = voice.compile('click submit', 'en');
1179
+ expect(result.ok).toBe(true);
1180
+ // Should use window._findEl guard, not bare function declaration
1181
+ expect(result.code).toContain('window._findEl');
1182
+ });
1183
+
1184
+ it('select all does not use deprecated execCommand', () => {
1185
+ const result = voice.compile('select all', 'en');
1186
+ expect(result.ok).toBe(true);
1187
+ expect(result.code).not.toContain('execCommand');
1188
+ expect(result.code).toContain('selectNodeContents');
1189
+ });
1190
+
1191
+ it('zoom does not use non-standard body.style.zoom', () => {
1192
+ const result = voice.compile('zoom in', 'en');
1193
+ expect(result.ok).toBe(true);
1194
+ expect(result.code).not.toContain('body.style.zoom');
1195
+ expect(result.code).toContain('transform');
1196
+ });
1197
+
1198
+ it('zoom reset clears transform', () => {
1199
+ const result = voice.compile('zoom reset', 'en');
1200
+ expect(result.ok).toBe(true);
1201
+ expect(result.code).toContain("dataset.zoom = '1'");
1202
+ });
1203
+ });
1204
+
1205
+ // ===========================================================================
1206
+ // VoiceActionSpec Converter
1207
+ // ===========================================================================
1208
+
1209
+ describe('toVoiceActionSpec', () => {
1210
+ it('converts click to spec', () => {
1211
+ const node = voice.parse('click submit', 'en');
1212
+ const spec = toVoiceActionSpec(node, 'en');
1213
+ expect(spec.action).toBe('click');
1214
+ expect(spec.target).toBe('submit');
1215
+ expect(spec.metadata.sourceLanguage).toBe('en');
1216
+ });
1217
+
1218
+ it('converts scroll to spec with direction', () => {
1219
+ const node = voice.parse('scroll down', 'en');
1220
+ const spec = toVoiceActionSpec(node, 'en');
1221
+ expect(spec.action).toBe('scroll');
1222
+ expect(spec.direction).toBe('down');
1223
+ });
1224
+
1225
+ it('converts navigate to spec with target', () => {
1226
+ const node = voice.parse('navigate to home', 'en');
1227
+ const spec = toVoiceActionSpec(node, 'en');
1228
+ expect(spec.action).toBe('navigate');
1229
+ expect(spec.target).toBe('home');
1230
+ });
1231
+ });
1232
+ });