@hyperfixi/testing-framework 4.2.0 → 4.4.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,351 @@
1
+ /**
2
+ * The English-leak scanner (english-leaks.ts) on hand-written renders: each
3
+ * guard is pinned both ways, so a scanner that stopped applying one fails here
4
+ * rather than silently moving the baselines.
5
+ *
6
+ * @vitest-environment node
7
+ */
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import {
10
+ compressLanguages,
11
+ diffLeakBaseline,
12
+ expandLanguages,
13
+ gainedLeaks,
14
+ prunableLeaks,
15
+ findingKey,
16
+ initLeakScanner,
17
+ leakBaselineFrom,
18
+ leakRatesByLanguage,
19
+ type LeakFinding,
20
+ type LeakResults,
21
+ type LeakScanner,
22
+ } from './english-leaks';
23
+
24
+ let scanner: LeakScanner;
25
+
26
+ beforeAll(async () => {
27
+ scanner = await initLeakScanner();
28
+ }, 120_000);
29
+
30
+ /** The finding keys of `render`, a render of English `source` into `language`. */
31
+ function keys(source: string, render: string, language: string): string[] {
32
+ const info = scanner.sourceInfo(source, 'program');
33
+ if (!info) throw new Error(`the engine rejects ${source}`);
34
+ return scanner.scan(render, info, language).map(findingKey).sort();
35
+ }
36
+
37
+ function findings(source: string, render: string, language: string): LeakFinding[] {
38
+ const info = scanner.sourceInfo(source, 'program');
39
+ if (!info) throw new Error(`the engine rejects ${source}`);
40
+ return scanner.scan(render, info, language);
41
+ }
42
+
43
+ describe('english-leaks scanner', () => {
44
+ it('counts an English grammar word the language has its own word for, or not', () => {
45
+ const source = 'on click if I match .a then add .b to me end';
46
+ expect(keys(source, 'al clic si I match .a entonces agregar .b a mí fin', 'es')).toEqual([
47
+ 'I',
48
+ 'match',
49
+ ]);
50
+ });
51
+
52
+ it('never counts a word spelled like the language’s own word (de `in`)', () => {
53
+ const source = 'on click repeat for x in .item add .y to x end';
54
+ // es has no word spelled `in`; de's `in` is its own word.
55
+ expect(keys(source, 'al clic repetir para x in .item agregar .y a x fin', 'es')).toEqual([
56
+ 'in',
57
+ ]);
58
+ expect(
59
+ keys(source, 'bei klick wiederholen für x in .item hinzufügen .y zu x ende', 'de')
60
+ ).toEqual([]);
61
+ });
62
+
63
+ it('counts an English alternative the reader still takes, beside the language’s own word', () => {
64
+ // ru's `forever` is `всегда`; the profile lists English `forever` too, so the
65
+ // reader takes code-switched input, but a render that writes it leaks.
66
+ const source = 'on click repeat forever log 1 end';
67
+ expect(keys(source, 'при клик повторить всегда записать 1 конец', 'ru')).toEqual([]);
68
+ expect(keys(source, 'при клик повторить forever записать 1 конец', 'ru')).toEqual(['forever']);
69
+ });
70
+
71
+ it('counts a reference the engine reads as a name (`me`)', () => {
72
+ expect(keys('on click log 1, me', 'al clic registrar 1, me', 'es')).toEqual(['me']);
73
+ });
74
+
75
+ it('never counts a name, even one spelled like a keyword', () => {
76
+ // `index` is a variable here, a keyword below.
77
+ expect(keys('on click set index to 1', 'al clic establecer index a 1', 'es')).toEqual([]);
78
+ expect(
79
+ keys(
80
+ 'on load repeat for x in .item index i add .v to x end',
81
+ 'al carga repeat x en .item index i agregar .v a x fin',
82
+ 'es'
83
+ )
84
+ ).toEqual(['index', 'repeat']);
85
+ });
86
+
87
+ it('counts a word no more often than the source uses it as grammar', () => {
88
+ // `index` once as a keyword, once as a name: the render's two count once.
89
+ expect(
90
+ keys(
91
+ 'on load repeat for x in .item index index add .v to x end',
92
+ 'al carga repetir para x en .item index index agregar .v a x fin',
93
+ 'es'
94
+ )
95
+ ).toEqual(['index']);
96
+ });
97
+
98
+ it('does not read strings, `js` blocks (with their parameters) or brace interiors', () => {
99
+ expect(
100
+ keys('on click put "repeat in me" into #a', 'al clic poner "repeat in me" en #a', 'es')
101
+ ).toEqual([]);
102
+ expect(
103
+ keys(
104
+ 'on click js(me) return new Date() end then put it into #a',
105
+ 'al clic js (me) return new Date() fin entonces poner ello en #a',
106
+ 'es'
107
+ )
108
+ ).toEqual([]);
109
+ expect(
110
+ keys(
111
+ 'on click fetch /a with {body: it, method: "POST"}',
112
+ 'al clic buscar "/a" con {body: it, method: "POST"}',
113
+ 'es'
114
+ )
115
+ ).toEqual([]);
116
+ });
117
+
118
+ it('does not read a `js` body or its parameter list, even where they repeat a grammar word', () => {
119
+ // `if` is grammar once (the handler's); the body's `if` is JavaScript.
120
+ expect(
121
+ keys(
122
+ 'on click if x then js(x) if (x) return 1 end end',
123
+ 'al clic si x entonces js (x) if (x) return 1 fin fin',
124
+ 'es'
125
+ )
126
+ ).toEqual([]);
127
+ // `me` is a reference once (put's); the parameter list passes it to JavaScript.
128
+ expect(
129
+ keys(
130
+ 'on click put me into #a then js(me) return 1 end',
131
+ 'al clic poner yo en #a entonces js (me) return 1 fin',
132
+ 'es'
133
+ )
134
+ ).toEqual([]);
135
+ });
136
+
137
+ it("reads a word after a possessive quote (`the element's x`) and not as a string", () => {
138
+ expect(
139
+ keys(
140
+ "on click set the element's x to the element's y",
141
+ "al clic establecer the element's x a the element's y",
142
+ 'es'
143
+ )
144
+ ).toEqual(['element', 'element', 'the', 'the']);
145
+ });
146
+
147
+ it('gives each word its context: clause, bracket, call, property, plain', () => {
148
+ const clause = findings(
149
+ 'on click take .foo from .div for #d3',
150
+ 'al clic tomar .foo de .div for #d3',
151
+ 'es'
152
+ );
153
+ expect(clause.map(f => `${f.word}/${f.context}`)).toEqual(['for/clause']);
154
+ const bracket = findings(
155
+ 'on keyup[key is "Escape"] add .x',
156
+ 'al keyup[key is "Escape"] agregar .x',
157
+ 'es'
158
+ );
159
+ expect(bracket.map(f => `${f.word}/${f.context}`)).toEqual(['is/bracket']);
160
+ const property = findings(
161
+ "on click put #a's children into #b",
162
+ 'bei klick setzen children von #a in #b',
163
+ 'de'
164
+ );
165
+ expect(property.map(f => `${f.word}/${f.context}/${f.hasWord}`)).toEqual([
166
+ 'children/property/true',
167
+ ]);
168
+ });
169
+
170
+ it('counts a property name only where the language has a word for it', () => {
171
+ // innerHTML is a DOM name no lexicon translates.
172
+ expect(
173
+ keys("on click put #a's innerHTML into #b", 'bei klick setzen innerHTML von #a in #b', 'de')
174
+ ).toEqual([]);
175
+ });
176
+
177
+ it('counts an English event only where the lexicon has a word and the renderer may use it', () => {
178
+ expect(keys('on click add .x', 'при click добавить .x', 'ru')).toEqual(['event:click']);
179
+ expect(keys('on click add .x', 'при клик добавить .x', 'ru')).toEqual([]);
180
+ // A custom event has no word anywhere.
181
+ expect(keys('on foo add .x', 'при foo добавить .x', 'ru')).toEqual([]);
182
+ });
183
+
184
+ it('never counts an event the renderer is told to keep English (its denylist)', async () => {
185
+ const { getEventLocalizationDenylist } = await import('@lokascript/semantic');
186
+ const [language, events] = Object.entries(getEventLocalizationDenylist()).find(
187
+ ([, e]) => e.size > 0
188
+ )!;
189
+ const event = [...events][0]!;
190
+ const found = keys(`on ${event} add .x`, `${event} add .x`, language).filter(k =>
191
+ k.startsWith('event:')
192
+ );
193
+ expect(found).toEqual([]);
194
+ });
195
+
196
+ it('counts a nominative `me` beside a marker, in the languages where that is wrong', () => {
197
+ expect(keys('on click add .b to me', 'al clic agregar .b a yo', 'es')).toEqual(['case:me']);
198
+ expect(keys('on click add .b to me', 'tıklama da .b i ben e ekle', 'tr')).toEqual(['case:me']);
199
+ // fr writes the stressed form; ja's particle attaches to any noun.
200
+ expect(keys('on click add .b to me', 'sur clic ajouter .b à moi', 'fr')).toEqual([]);
201
+ expect(keys('on click add .b to me', 'クリック で 自分 に .b を 追加', 'ja')).toEqual([]);
202
+ });
203
+ });
204
+
205
+ describe('english-leaks baseline', () => {
206
+ const finding = (word: string): LeakFinding => ({
207
+ kind: 'word',
208
+ word,
209
+ context: 'plain',
210
+ hasWord: false,
211
+ });
212
+ const results = (byLanguage: Record<string, string[] | null>): LeakResults =>
213
+ new Map([
214
+ [
215
+ 'row',
216
+ new Map(Object.entries(byLanguage).map(([l, ws]) => [l, ws ? ws.map(finding) : null])),
217
+ ],
218
+ ]);
219
+
220
+ it('groups languages with the same findings, `*` for all 23', () => {
221
+ const all = expandLanguages('*');
222
+ expect(all).toHaveLength(23);
223
+ expect(compressLanguages(all)).toBe('*');
224
+ const doc = leakBaselineFrom(
225
+ results(Object.fromEntries(all.map(l => [l, l === 'ja' ? [] : ['repeat']]))),
226
+ 'test'
227
+ );
228
+ expect(doc.entries.row).toEqual({ repeat: compressLanguages(all.filter(l => l !== 'ja')) });
229
+ expect([doc.renders, doc.leaky, doc.findings, doc.kinds.word.renders]).toEqual([
230
+ 23, 22, 22, 22,
231
+ ]);
232
+ });
233
+
234
+ it('counts a lane with no render as no render, not as a clean one', () => {
235
+ const doc = leakBaselineFrom(results({ es: ['repeat'], fr: [], de: null }), 'test');
236
+ expect([doc.renders, doc.leaky]).toEqual([2, 1]);
237
+ expect(doc.unrendered).toEqual({ row: 'de' });
238
+ });
239
+
240
+ it('reports a lane that was refused and renders now as one to prune, never as gained English', () => {
241
+ const baseline = { entries: { row: { repeat: 'es' } }, unrendered: { row: 'de' } };
242
+ const changes = diffLeakBaseline(results({ es: ['repeat'], de: ['event:click'] }), baseline);
243
+ expect(changes).toEqual([
244
+ { id: 'row', language: 'de', added: ['event:click'], gone: [], newRender: true },
245
+ ]);
246
+ expect(gainedLeaks(changes)).toEqual([]);
247
+ expect(prunableLeaks(changes)).toHaveLength(1);
248
+ // Still unrendered: nothing to say.
249
+ expect(diffLeakBaseline(results({ de: null }), baseline)).toEqual([]);
250
+ });
251
+
252
+ it('reports a finding a pair gained, and a listed one that is gone (a lane with no render included)', () => {
253
+ const baseline = { entries: { row: { 'match repeat': 'es', repeat: 'de' } } };
254
+ const changes = diffLeakBaseline(
255
+ results({ es: ['repeat'], de: ['repeat', 'as'], fr: null }),
256
+ baseline
257
+ );
258
+ expect(changes).toEqual([
259
+ { id: 'row', language: 'es', added: [], gone: ['match'] },
260
+ { id: 'row', language: 'de', added: ['as'], gone: [] },
261
+ ]);
262
+ const noRender = diffLeakBaseline(results({ es: null }), baseline);
263
+ expect(noRender).toEqual([{ id: 'row', language: 'es', added: [], gone: ['match', 'repeat'] }]);
264
+ });
265
+ });
266
+
267
+ describe('English a recorded decision keeps', () => {
268
+ it('marks a kept phrase and a kept context, and nothing else', () => {
269
+ const debounced = findings(
270
+ 'on input debounced at 300ms log 1',
271
+ 'al entrada debounced at 300ms registrar 1',
272
+ 'es'
273
+ );
274
+ expect(debounced.map(f => `${f.word}:${f.kept ?? false}`)).toEqual([
275
+ 'debounced:true',
276
+ 'at:true',
277
+ ]);
278
+ // `start` is counted; the API name after it is kept.
279
+ const view = findings(
280
+ 'on click start view transition add .a end',
281
+ 'al clic start view transition agregar .a fin',
282
+ 'es'
283
+ );
284
+ expect(view.map(f => `${f.word}:${f.kept ?? false}`)).toEqual([
285
+ 'start:false',
286
+ 'view:true',
287
+ 'transition:true',
288
+ ]);
289
+ const bracket = findings(
290
+ 'on keyup[key is "Escape"] add .x',
291
+ 'al keyup[key is "Escape"] agregar .x',
292
+ 'es'
293
+ );
294
+ expect(bracket.map(f => f.kept)).toEqual([true]);
295
+ });
296
+ });
297
+
298
+ describe('M2 exit targets', () => {
299
+ const f = (kind: LeakFinding['kind'], word = 'x'): LeakFinding => ({
300
+ kind,
301
+ word,
302
+ context: 'plain',
303
+ hasWord: false,
304
+ });
305
+ /** 20 renders per language: `n` of them with each listed kind, the rest clean. */
306
+ const rows = (perLanguage: Record<string, LeakFinding['kind'][] | 'none'>): LeakResults => {
307
+ const out: LeakResults = new Map();
308
+ for (let i = 0; i < 20; i++) {
309
+ const byLanguage = new Map<string, LeakFinding[] | null>();
310
+ for (const [language, kinds] of Object.entries(perLanguage)) {
311
+ byLanguage.set(language, kinds === 'none' ? null : i < kinds.length ? [f(kinds[i])] : []);
312
+ }
313
+ out.set(`row${i}`, byLanguage);
314
+ }
315
+ return out;
316
+ };
317
+
318
+ it('holds each language to the target on its own, by share of renders', () => {
319
+ const rates = leakRatesByLanguage(rows({ es: ['word'], de: ['word', 'word'] }), 'corpus');
320
+ expect(rates.get('es')).toMatchObject({ renders: 20, met: { word: true } });
321
+ expect(rates.get('es')!.rate.word).toBeCloseTo(0.05);
322
+ expect(rates.get('de')!.met.word).toBe(false);
323
+ // The command-shape half allows twice as many.
324
+ expect(leakRatesByLanguage(rows({ de: ['word', 'word'] }), 'shapes').get('de')!.met.word).toBe(
325
+ true
326
+ );
327
+ });
328
+
329
+ it('allows no event and no pronoun case, and skips a lane with no render', () => {
330
+ const rates = leakRatesByLanguage(rows({ ru: ['event'], pl: ['case'], he: 'none' }), 'shapes');
331
+ expect(rates.get('ru')!.met).toEqual({ word: true, event: false, case: true });
332
+ expect(rates.get('pl')!.met).toEqual({ word: true, event: true, case: false });
333
+ expect(rates.has('he')).toBe(false);
334
+ });
335
+
336
+ it('does not count a kept finding', () => {
337
+ const results: LeakResults = new Map([
338
+ ['a', new Map([['es', [{ ...f('word', 'url'), kept: true as const }]]])],
339
+ ['b', new Map([['es', [f('word', 'in')]]])],
340
+ ]);
341
+ expect(leakRatesByLanguage(results, 'corpus').get('es')!.rate.word).toBe(0.5);
342
+ });
343
+
344
+ it('counts a render once per kind, however many findings it holds', () => {
345
+ const results: LeakResults = new Map([
346
+ ['a', new Map([['es', [f('word', 'in'), f('word', 'of')]]])],
347
+ ['b', new Map([['es', []]])],
348
+ ]);
349
+ expect(leakRatesByLanguage(results, 'corpus').get('es')!.rate.word).toBe(0.5);
350
+ });
351
+ });