@hyperfixi/testing-framework 3.1.1 → 3.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/dist/index.js +5 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +5 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/runner.js +5 -1
  7. package/dist/runner.js.map +1 -1
  8. package/dist/runner.mjs +5 -1
  9. package/dist/runner.mjs.map +1 -1
  10. package/package.json +19 -18
  11. package/src/multilingual/README.md +39 -0
  12. package/src/multilingual/en-reference-equivalences.test.ts +321 -0
  13. package/src/multilingual/en-reference-preservation.test.ts +124 -0
  14. package/src/multilingual/en-reference-preservation.ts +495 -0
  15. package/src/multilingual/pattern-loader.test.ts +43 -0
  16. package/src/multilingual/pattern-loader.ts +7 -2
  17. package/src/multilingual/validators/execution-validator.test.ts +32 -3
  18. package/src/multilingual/validators/execution-validator.ts +17 -2
  19. package/src/multilingual/value-matrix-gate.ts +118 -0
  20. package/src/multilingual/value-matrix.accepted.test.ts +62 -0
  21. package/src/multilingual/value-matrix.assign.test.ts +13 -0
  22. package/src/multilingual/value-matrix.chain-phrases.test.ts +13 -0
  23. package/src/multilingual/value-matrix.chain.test.ts +13 -0
  24. package/src/multilingual/value-matrix.count.test.ts +13 -0
  25. package/src/multilingual/value-matrix.get-phrases.test.ts +13 -0
  26. package/src/multilingual/value-matrix.get.test.ts +13 -0
  27. package/src/multilingual/value-matrix.if-phrases.test.ts +13 -0
  28. package/src/multilingual/value-matrix.if.test.ts +13 -0
  29. package/src/multilingual/value-matrix.increment.test.ts +13 -0
  30. package/src/multilingual/value-matrix.isolation.test.ts +57 -0
  31. package/src/multilingual/value-matrix.names.test.ts +62 -0
  32. package/src/multilingual/value-matrix.put-phrases.test.ts +13 -0
  33. package/src/multilingual/value-matrix.put.test.ts +13 -0
  34. package/src/multilingual/value-matrix.set-phrases.test.ts +13 -0
  35. package/src/multilingual/value-matrix.set.test.ts +13 -0
  36. package/src/multilingual/value-matrix.times.test.ts +13 -0
  37. package/src/multilingual/value-matrix.ts +1170 -0
  38. package/src/multilingual/value-matrix.while.test.ts +13 -0
  39. package/src/runner.ts +7 -1
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Pins for the en-reference-preservation gate's NAMED EQUIVALENCES.
3
+ *
4
+ * The gate lets the renderer respell a construct only when the two spellings
5
+ * are the same program. That claim is checked here, on the real
6
+ * `hyperscript.org` engine, one pin per equivalence:
7
+ * - `tree` — both spellings parse without error to the same tree (token
8
+ * positions and parent links excluded; the command chain via
9
+ * `next` INCLUDED — a control pair proves later commands count);
10
+ * - `effect` — the node types differ (naked vs quoted URL, `my.x` vs `my x`,
11
+ * …), so both spellings run in jsdom and must leave the same,
12
+ * non-empty observation.
13
+ * Every entry of EQUIVALENCES must have a pin, so a new rule cannot land
14
+ * without evidence. The negative pins record why the list is narrow: dropping
15
+ * `the` is not an equivalence (`halt event` does not parse).
16
+ *
17
+ * @vitest-environment node
18
+ * Required: under the suite default (happy-dom) the DOM constructors already
19
+ * exist on globalThis and `installGlobals` will not replace them, so upstream
20
+ * would bind happy-dom's constructors against jsdom pages (see
21
+ * shipped-examples-execution.test.ts, which measured exactly that).
22
+ */
23
+ import { createRequire } from 'node:module';
24
+ import path from 'node:path';
25
+ import { pathToFileURL } from 'node:url';
26
+ import { JSDOM } from 'jsdom';
27
+ import { beforeAll, describe, expect, it } from 'vitest';
28
+ import {
29
+ EQUIVALENCES,
30
+ describeDifference,
31
+ normalizeForComparison,
32
+ preservesContent,
33
+ tokenize,
34
+ } from './en-reference-preservation';
35
+ import { installGlobals } from './shipped-examples-execution';
36
+
37
+ interface Upstream {
38
+ parse(src: string): { errors?: unknown[] };
39
+ processNode(el: Node): void;
40
+ }
41
+
42
+ type Pin =
43
+ | { kind: 'tree'; pair: readonly [string, string] }
44
+ | { kind: 'effect'; pair: readonly [string, string] };
45
+
46
+ /**
47
+ * One pin per equivalence id. `effect` pairs are written so the observation is
48
+ * non-empty: they put a value into #o, set a style on #t, or record a fetch.
49
+ */
50
+ const PINS: Record<string, Pin> = {
51
+ 'then-separator': {
52
+ kind: 'tree',
53
+ pair: ['on click toggle .a add .b to me', 'on click toggle .a then add .b to me'],
54
+ },
55
+ 'quote-style': {
56
+ kind: 'tree',
57
+ pair: ["on click put 'Saved!' into me", 'on click put "Saved!" into me'],
58
+ },
59
+ 'quoted-url': { kind: 'effect', pair: ['on click fetch /api/x', 'on click fetch "/api/x"'] },
60
+ 'article-before-query': {
61
+ kind: 'tree',
62
+ pair: [
63
+ 'on click make a <div.card/> then put it into #c',
64
+ 'on click make <div.card/> then put it into #c',
65
+ ],
66
+ },
67
+ 'the-before-positional': {
68
+ kind: 'tree',
69
+ pair: [
70
+ 'on click increment the textContent of the previous <output/>',
71
+ 'on click increment the textContent of previous <output/>',
72
+ ],
73
+ },
74
+ // behavior-sortable's `set item to the target.closest("li")`. By effect: the
75
+ // trees differ only in a raw token's offsets, which `the ` shifts.
76
+ 'the-before-target': {
77
+ kind: 'effect',
78
+ pair: ['on click put the target.id into #o', 'on click put target.id into #o'],
79
+ },
80
+ 'dotted-possessive': {
81
+ kind: 'effect',
82
+ pair: [
83
+ "on click get {name:'Q'} then put it.name into #o",
84
+ "on click get {name:'Q'} then put its name into #o",
85
+ ],
86
+ },
87
+ 'of-possessive': {
88
+ kind: 'effect',
89
+ pair: ['on click set the *color of #t to "red"', 'on click set #t\'s *color to "red"'],
90
+ },
91
+ 'go-to-url': { kind: 'tree', pair: ['on click go to url "/page"', 'on click go url "/page"'] },
92
+ 'with-object-braces': {
93
+ kind: 'effect',
94
+ pair: [
95
+ 'on click fetch /x with method:"POST", body:"a"',
96
+ 'on click fetch /x with {method:"POST", body:"a"}',
97
+ ],
98
+ },
99
+ 'trailing-end': {
100
+ kind: 'tree',
101
+ pair: ['on click repeat 3 times log 1', 'on click repeat 3 times log 1 end'],
102
+ },
103
+ 'settle-me': {
104
+ kind: 'effect',
105
+ pair: [
106
+ 'on click put "x" into #o then settle then put "y" into #o',
107
+ 'on click put "x" into #o then settle me then put "y" into #o',
108
+ ],
109
+ },
110
+ // `click` is not an upstream command keyword (`focus`/`reset` are, and upstream
111
+ // rejects `focus() me`). Triggered by `poke` so the click it causes cannot
112
+ // re-enter the handler; #b's native click listener counts it.
113
+ 'pseudo-command-me': {
114
+ kind: 'effect',
115
+ pair: ['on poke click() me', 'on poke call me.click()'],
116
+ },
117
+ // Both install on the button and hear the click dispatched on it: `from me` is
118
+ // where a handler listens by default.
119
+ 'handler-from-me': {
120
+ kind: 'effect',
121
+ pair: ['on click from me put "x" into #o', 'on click put "x" into #o'],
122
+ },
123
+ // The button sends and triggers to itself and records what arrived: upstream
124
+ // reads a STRING or a dotted/colon path as the same eventName (both commands).
125
+ 'quoted-event-name': {
126
+ kind: 'effect',
127
+ pair: [
128
+ 'on click send "hello" to me then trigger "bye" on me end ' +
129
+ 'on hello put "got" into #o end on bye set #t\'s *color to "red"',
130
+ 'on click send hello to me then trigger bye on me end ' +
131
+ 'on hello put "got" into #o end on bye set #t\'s *color to "red"',
132
+ ],
133
+ },
134
+ };
135
+
136
+ let hs: Upstream;
137
+
138
+ beforeAll(async () => {
139
+ installGlobals(new JSDOM('<!doctype html><html><body></body></html>'));
140
+ const require = createRequire(import.meta.url);
141
+ const esm = path.join(path.dirname(require.resolve('hyperscript.org')), '_hyperscript.esm.js');
142
+ hs = (await import(pathToFileURL(esm).href)).default as Upstream;
143
+ });
144
+
145
+ /** Parse tree without positions or parent links; `next` (the command chain) is kept. */
146
+ function tree(src: string): string {
147
+ const skip = new Set([
148
+ 'parent',
149
+ 'token',
150
+ 'startToken',
151
+ 'endToken',
152
+ 'programSource',
153
+ 'sourceFor',
154
+ 'lineFor',
155
+ ]);
156
+ const seen = new WeakSet<object>();
157
+ const walk = (n: unknown): unknown => {
158
+ if (n === null || typeof n !== 'object') return n;
159
+ if (seen.has(n)) return '<cycle>';
160
+ seen.add(n);
161
+ if (Array.isArray(n)) return n.map(walk);
162
+ const out: Record<string, unknown> = { $: n.constructor?.name };
163
+ for (const [key, value] of Object.entries(n).sort(([x], [y]) => x.localeCompare(y))) {
164
+ if (!skip.has(key) && typeof value !== 'function') out[key] = walk(value);
165
+ }
166
+ return out;
167
+ };
168
+ return JSON.stringify(walk(hs.parse(src)));
169
+ }
170
+
171
+ /** Run one handler on upstream in a fresh page; return what it observably did. */
172
+ async function effect(src: string): Promise<string> {
173
+ const dom = new JSDOM(
174
+ '<!doctype html><html><body><button id="b"></button><div id="o"></div><div id="t"></div></body></html>'
175
+ );
176
+ try {
177
+ installGlobals(dom);
178
+ const fetches: string[] = [];
179
+ const stub = async (url: unknown, init?: { method?: string }) => {
180
+ fetches.push(`${String(url)} ${init?.method ?? 'GET'}`);
181
+ return {
182
+ ok: true,
183
+ status: 200,
184
+ headers: new Map(),
185
+ text: async () => '',
186
+ json: async () => ({}),
187
+ };
188
+ };
189
+ Object.assign(dom.window, { fetch: stub });
190
+ Object.assign(globalThis, { fetch: stub });
191
+ const doc = dom.window.document;
192
+ const button = doc.getElementById('b')!;
193
+ let clicks = 0;
194
+ button.addEventListener('click', () => clicks++);
195
+ button.setAttribute('_', src);
196
+ hs.processNode(button);
197
+ const event = /^on (\w+)/.exec(src)?.[1] ?? 'click';
198
+ button.dispatchEvent(new dom.window.Event(event, { bubbles: true }));
199
+ // `settle` with no transition resolves after upstream's 500ms fallback.
200
+ await new Promise(resolve => setTimeout(resolve, src.includes('settle') ? 700 : 50));
201
+ return JSON.stringify({
202
+ o: doc.getElementById('o')!.textContent,
203
+ color: (doc.getElementById('t') as HTMLElement).style.color,
204
+ fetches,
205
+ // The dispatch itself counts when the trigger IS a click; only extra clicks matter.
206
+ clicks: event === 'click' ? clicks - 1 : clicks,
207
+ });
208
+ } finally {
209
+ dom.window.close();
210
+ }
211
+ }
212
+
213
+ const EMPTY_EFFECT = JSON.stringify({ o: '', color: '', fetches: [], clicks: 0 });
214
+
215
+ describe('en-reference equivalences', () => {
216
+ it('pins every equivalence (a new rule cannot land without engine evidence)', () => {
217
+ expect(EQUIVALENCES.map(e => e.id).filter(id => !PINS[id])).toEqual([]);
218
+ expect(Object.keys(PINS).filter(id => !EQUIVALENCES.some(e => e.id === id))).toEqual([]);
219
+ });
220
+
221
+ describe.each(EQUIVALENCES.map(e => [e.id, e] as const))('%s', (id, equivalence) => {
222
+ it('the normalizer treats its example as the same program', () => {
223
+ expect(preservesContent(...equivalence.example)).toBe(true);
224
+ });
225
+
226
+ it('the normalizer treats its pin pair as the same program', () => {
227
+ expect(preservesContent(...PINS[id]!.pair)).toBe(true);
228
+ });
229
+
230
+ it('upstream agrees: both spellings are the same program', async () => {
231
+ const pin = PINS[id]!;
232
+ const [a, b] = pin.pair;
233
+ if (pin.kind === 'tree') {
234
+ expect(hs.parse(a).errors ?? []).toEqual([]);
235
+ expect(hs.parse(b).errors ?? []).toEqual([]);
236
+ expect(tree(a)).toEqual(tree(b));
237
+ } else {
238
+ const [ea, eb] = [await effect(a), await effect(b)];
239
+ expect(ea, 'the pin observes nothing, so it proves nothing').not.toEqual(EMPTY_EFFECT);
240
+ expect(ea).toEqual(eb);
241
+ }
242
+ }, 10_000);
243
+ });
244
+
245
+ describe('what is NOT an equivalence', () => {
246
+ it('`the` is not droppable in general: `halt event` does not parse, `halt the event` does', () => {
247
+ expect(hs.parse('on click halt the event').errors ?? []).toEqual([]);
248
+ expect((hs.parse('on click halt event').errors ?? []).length).toBeGreaterThan(0);
249
+ expect(preservesContent('on click halt the event', 'on click halt event')).toBe(false);
250
+ });
251
+
252
+ it('the tree comparison sees commands after the first one (control pair)', () => {
253
+ expect(tree('on click toggle .a add .b to me')).not.toEqual(
254
+ tree('on click toggle .a then add .c to me')
255
+ );
256
+ });
257
+
258
+ it('a dropped qualifier is a loss', () => {
259
+ expect(preservesContent('on click hide me with *opacity', 'on click hide me')).toBe(false);
260
+ expect(describeDifference('on click hide me with *opacity', 'on click hide me')).toEqual({
261
+ lost: ['with *opacity'],
262
+ added: [],
263
+ });
264
+ });
265
+
266
+ it('a string literal is not the identifier with the same text', () => {
267
+ // A value, not an event name: `put hello` reads a variable named hello.
268
+ expect(preservesContent('on click put "hello" into #o', 'on click put hello into #o')).toBe(
269
+ false
270
+ );
271
+ // An event name that is not a plain name cannot be written bare.
272
+ expect(
273
+ preservesContent('on click send "my event" to #t', 'on click send my event to #t')
274
+ ).toBe(false);
275
+ });
276
+
277
+ it('a URL carrying `${…}` is not treated as quote-insensitive', () => {
278
+ expect(
279
+ preservesContent('on input fetch /s?q=${my value}', 'on input fetch "/s?q=${my value}"')
280
+ ).toBe(false);
281
+ });
282
+
283
+ it('a changed operand is a loss even when the command survives', () => {
284
+ expect(
285
+ describeDifference(
286
+ 'on click repeat while #c.innerText < 10 increment #c end',
287
+ 'on click repeat while #c.innerText increment #c end'
288
+ )
289
+ ).toEqual({ lost: ['< 10'], added: [] });
290
+ });
291
+
292
+ it('`go back` and `go url back` are different programs', () => {
293
+ expect(preservesContent('on click go back', 'on click go url back')).toBe(false);
294
+ });
295
+
296
+ // handler-from-me is a HEAD rule: a command's `from me` is its source.
297
+ it('a command’s `from me` is not a handler head’s', () => {
298
+ expect(preservesContent('on click take .a from me', 'on click take .a')).toBe(false);
299
+ expect(preservesContent('on click remove .a from me', 'on click remove .a')).toBe(false);
300
+ expect(preservesContent('on click from #b log 1', 'on click log 1')).toBe(false);
301
+ });
302
+ });
303
+
304
+ describe('tokenizer', () => {
305
+ it('reads a possessive apostrophe as part of a word, not a string opener', () => {
306
+ expect(tokenize("set #price's value to 'x'")).toEqual([
307
+ { kind: 'word', text: 'set' },
308
+ { kind: 'word', text: "#price's" },
309
+ { kind: 'word', text: 'value' },
310
+ { kind: 'word', text: 'to' },
311
+ { kind: 'string', quote: "'", body: 'x' },
312
+ ]);
313
+ });
314
+
315
+ it('ignores whitespace, including a re-spaced CSS block', () => {
316
+ expect(normalizeForComparison('add { left: ${x}px; }')).toEqual(
317
+ normalizeForComparison('add { left : $ { x } px ; }')
318
+ );
319
+ });
320
+ });
321
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * En-reference preservation gate (see en-reference-preservation.ts for why).
3
+ *
4
+ * Renders every translatable corpus unit (plain row, or markup `_` body) back
5
+ * to English through its own English parse, and requires the render to carry
6
+ * the source's content under the named equivalences. The committed allowlist
7
+ * records the units that do not, one entry per unit, with the render it was
8
+ * triaged against. Assertions:
9
+ * 1. sanity — the corpus loaded and most units pass (guards a silent zero);
10
+ * 2. no NEW unit loses content outside the allowlist;
11
+ * 3. no allowlisted unit's render has CHANGED (a different render is a
12
+ * different defect, or a partial fix — re-triage and regenerate);
13
+ * 4. no stale entry: a unit that now preserves its content, or no longer
14
+ * exists, must be pruned (the list only shrinks);
15
+ * 5. every entry is triaged (has a family), so the list stays a worklist.
16
+ *
17
+ * DB DEPENDENCY: the unit set comes from `getAllPatterns()`, so this runs only
18
+ * when the caller asserts a freshly populated DB — the same contract as the
19
+ * foreign and render-fidelity gates. `npm run test:canonical` and CI's
20
+ * multilingual job set it, after populating.
21
+ *
22
+ * Regenerate after an intentional parser/renderer change:
23
+ * `npm run populate --prefix packages/patterns-reference`, then
24
+ * `npx tsx tools/regen-en-reference-baseline.ts`, then fill in the family of any
25
+ * entry it marks UNTRIAGED, and commit the result with the change.
26
+ */
27
+ import { readFileSync } from 'node:fs';
28
+ import { fileURLToPath } from 'node:url';
29
+ import path from 'node:path';
30
+ import { beforeAll, describe, expect, it } from 'vitest';
31
+ import {
32
+ checkEnReferencePreservation,
33
+ type PreservationFailure,
34
+ type PreservationResult,
35
+ } from './en-reference-preservation';
36
+
37
+ interface AllowlistEntry {
38
+ /** A short worklist label: the construct family, and MEANING/TRIAGE/BY DESIGN tags. */
39
+ family: string;
40
+ /** The English re-render this entry was triaged against (null = parse/render failed). */
41
+ rendered: string | null;
42
+ lost: string[];
43
+ added: string[];
44
+ }
45
+
46
+ interface AllowlistDoc {
47
+ checked: number;
48
+ preserved: number;
49
+ allowedLosses: Record<string, AllowlistEntry>;
50
+ }
51
+
52
+ const baselinePath = path.resolve(
53
+ path.dirname(fileURLToPath(import.meta.url)),
54
+ '../../baselines/en-reference-preservation.json'
55
+ );
56
+ const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
57
+
58
+ const DB_FRESHLY_POPULATED = process.env.FOREIGN_CANONICAL_VALIDITY === '1';
59
+
60
+ const show = (f: PreservationFailure) =>
61
+ ` [${f.key}]\n source: ${f.source.replace(/\s+/g, ' ').trim()}\n` +
62
+ ` render: ${f.rendered === null ? '<parse or render failed>' : f.rendered.replace(/\s+/g, ' ').trim()}\n` +
63
+ ` lost: ${JSON.stringify(f.lost)} added: ${JSON.stringify(f.added)}`;
64
+
65
+ describe.skipIf(!DB_FRESHLY_POPULATED)('en-reference preservation gate', () => {
66
+ let result: PreservationResult;
67
+
68
+ beforeAll(async () => {
69
+ result = await checkEnReferencePreservation();
70
+ }, 120_000);
71
+
72
+ it('checks a non-empty corpus and most of it passes (sanity: guards a false green)', () => {
73
+ expect(result.checked).toBeGreaterThan(150);
74
+ expect(result.preserved).toBeGreaterThan(100);
75
+ });
76
+
77
+ it('loses no content in a unit outside the allowlist', () => {
78
+ const unexpected = result.failures.filter(f => !(f.key in allowlist.allowedLosses));
79
+ expect(
80
+ unexpected,
81
+ unexpected.length
82
+ ? '\nThe English parse of these units drops or changes content — every one of the ' +
83
+ '23 translations inherits it, and no other gate can see it. Fix the parser/renderer, ' +
84
+ 'or allowlist the unit with a family (tools/regen-en-reference-baseline.ts):\n' +
85
+ unexpected.map(show).join('\n')
86
+ : ''
87
+ ).toEqual([]);
88
+ });
89
+
90
+ it('has not changed the render of an allowlisted unit (re-triage and regenerate)', () => {
91
+ const changed = result.failures.filter(f => {
92
+ const entry = allowlist.allowedLosses[f.key];
93
+ return entry !== undefined && entry.rendered !== f.rendered;
94
+ });
95
+ expect(
96
+ changed,
97
+ changed.length
98
+ ? '\nThese allowlisted units now render differently — still lossy, but not the loss ' +
99
+ 'that was triaged. Check what changed, then regenerate the baseline:\n' +
100
+ changed.map(show).join('\n')
101
+ : ''
102
+ ).toEqual([]);
103
+ });
104
+
105
+ it('has no stale allowlist entries (a fixed or deleted unit must be pruned)', () => {
106
+ const stillFailing = new Set(result.failures.map(f => f.key));
107
+ const stale = Object.keys(allowlist.allowedLosses).filter(key => !stillFailing.has(key));
108
+ expect(
109
+ stale,
110
+ stale.length
111
+ ? '\nThese allowlisted units now preserve their content (or no longer exist) — ' +
112
+ 'remove them from baselines/en-reference-preservation.json (regenerate with ' +
113
+ `tools/regen-en-reference-baseline.ts):\n ${stale.join('\n ')}`
114
+ : ''
115
+ ).toEqual([]);
116
+ });
117
+
118
+ it('has every allowlisted loss triaged into a family', () => {
119
+ const untriaged = Object.entries(allowlist.allowedLosses)
120
+ .filter(([, entry]) => !entry.family || entry.family === 'UNTRIAGED')
121
+ .map(([key]) => key);
122
+ expect(untriaged).toEqual([]);
123
+ });
124
+ });