@hyperfixi/testing-framework 3.1.1 → 3.3.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 (44) hide show
  1. package/CHANGELOG.md +186 -1
  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 +21 -19
  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/engine-parser-parity.test.ts +67 -0
  16. package/src/multilingual/pattern-loader.test.ts +43 -0
  17. package/src/multilingual/pattern-loader.ts +7 -2
  18. package/src/multilingual/shipped-examples-execution.test.ts +76 -1
  19. package/src/multilingual/shipped-examples-execution.ts +73 -3
  20. package/src/multilingual/shipped-sources-engine.test.ts +106 -0
  21. package/src/multilingual/shipped-sources-validity.ts +65 -0
  22. package/src/multilingual/validators/execution-validator.test.ts +32 -3
  23. package/src/multilingual/validators/execution-validator.ts +22 -5
  24. package/src/multilingual/value-matrix-gate.ts +118 -0
  25. package/src/multilingual/value-matrix.accepted.test.ts +65 -0
  26. package/src/multilingual/value-matrix.assign.test.ts +13 -0
  27. package/src/multilingual/value-matrix.chain-phrases.test.ts +13 -0
  28. package/src/multilingual/value-matrix.chain.test.ts +13 -0
  29. package/src/multilingual/value-matrix.count.test.ts +13 -0
  30. package/src/multilingual/value-matrix.get-phrases.test.ts +13 -0
  31. package/src/multilingual/value-matrix.get.test.ts +13 -0
  32. package/src/multilingual/value-matrix.if-phrases.test.ts +13 -0
  33. package/src/multilingual/value-matrix.if.test.ts +13 -0
  34. package/src/multilingual/value-matrix.increment.test.ts +13 -0
  35. package/src/multilingual/value-matrix.isolation.test.ts +57 -0
  36. package/src/multilingual/value-matrix.names.test.ts +62 -0
  37. package/src/multilingual/value-matrix.put-phrases.test.ts +13 -0
  38. package/src/multilingual/value-matrix.put.test.ts +13 -0
  39. package/src/multilingual/value-matrix.set-phrases.test.ts +13 -0
  40. package/src/multilingual/value-matrix.set.test.ts +13 -0
  41. package/src/multilingual/value-matrix.times.test.ts +13 -0
  42. package/src/multilingual/value-matrix.ts +1208 -0
  43. package/src/multilingual/value-matrix.while.test.ts +13 -0
  44. package/src/runner.ts +7 -1
@@ -17,6 +17,12 @@
17
17
  * 3. no allowlisted key has silently converged (stale entries must be
18
18
  * removed so the list only ever ratchets down).
19
19
  *
20
+ * The same three run for the ENGINE LANE: `@hyperfixi/engine`, the engine meant
21
+ * to replace core's, against the same oracle on every handler upstream accepts
22
+ * (`allowedEngineDivergences` in the same baseline). It is the gate for a
23
+ * handler that parses on both engines and runs differently, which the
24
+ * parse-level list in shipped-sources-engine.test.ts cannot see.
25
+ *
20
26
  * To update after an intentional change: re-run and regenerate
21
27
  * `baselines/shipped-examples-execution.json` (the allowlist key embeds a
22
28
  * source hash, so FIXING a handler changes its key and assertion 3 forces the
@@ -54,6 +60,14 @@ interface AllowlistDoc {
54
60
  excerpt: string;
55
61
  reason: string;
56
62
  }>;
63
+ /** The engine lane: `@hyperfixi/engine` against upstream. */
64
+ allowedEngineDivergences: Array<{
65
+ key: string;
66
+ file: string;
67
+ event: string;
68
+ excerpt: string;
69
+ reason: string;
70
+ }>;
57
71
  }
58
72
 
59
73
  const baselinePath = path.resolve(
@@ -62,12 +76,17 @@ const baselinePath = path.resolve(
62
76
  );
63
77
  const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
64
78
  const allowed = new Set(allowlist.allowedDivergences.map(e => e.key));
79
+ const allowedOnEngine = new Set(allowlist.allowedEngineDivergences.map(e => e.key));
80
+
81
+ /** One sweep, shared by both lanes' tests. */
82
+ let sweeping: Promise<ExecutionParityResult> | undefined;
83
+ const sweep = (): Promise<ExecutionParityResult> => (sweeping ??= runShippedExamplesExecution());
65
84
 
66
85
  describe('shipped-examples execution gate', () => {
67
86
  let result: ExecutionParityResult;
68
87
 
69
88
  beforeAll(async () => {
70
- result = await runShippedExamplesExecution();
89
+ result = await sweep();
71
90
 
72
91
  // Visibility, not assertions: what the sweep could not compare, and why.
73
92
  // A silently shrinking denominator is this gate's own blind spot.
@@ -84,6 +103,12 @@ describe('shipped-examples execution gate', () => {
84
103
  for (const [r, n] of [...reasons].sort((a, b) => b[1] - a[1])) {
85
104
  console.log(`[shipped-examples-execution] skip ×${n}: ${r}`);
86
105
  }
106
+ const onEngine = result.engineCompared;
107
+ console.log(
108
+ `[shipped-examples-execution] engine lane: compared=${onEngine.length} ` +
109
+ `(vacuous=${onEngine.filter(c => c.vacuous).length}) ` +
110
+ `diverging=${onEngine.filter(c => !c.match).length}`
111
+ );
87
112
  }, 240_000);
88
113
 
89
114
  it('walks pages and compares handlers (sanity: extraction and both engines working)', () => {
@@ -142,6 +167,56 @@ describe('shipped-examples execution gate', () => {
142
167
  });
143
168
  });
144
169
 
170
+ describe('shipped-examples execution gate: @hyperfixi/engine against upstream', () => {
171
+ let result: ExecutionParityResult;
172
+ beforeAll(async () => {
173
+ result = await sweep();
174
+ }, 240_000);
175
+
176
+ it('compares handlers on the engine (sanity: the lane ran and mostly agrees)', () => {
177
+ expect(result.engineCompared.length).toBeGreaterThan(90);
178
+ const realMatches = result.engineCompared.filter(c => c.match && !c.vacuous).length;
179
+ expect(realMatches).toBeGreaterThan(60);
180
+ });
181
+
182
+ it('has no NEW divergence from upstream outside the allowlist', () => {
183
+ const unexpected = result.engineCompared.filter(c => !c.match && !allowedOnEngine.has(c.key));
184
+ expect(
185
+ unexpected,
186
+ unexpected.length
187
+ ? `\nShipped handlers whose DOM effect on @hyperfixi/engine DIVERGES from the hyperscript.org ` +
188
+ `engine\n(the engine follows upstream's source: fix the engine, or allowlist with a reason ` +
189
+ `under allowedEngineDivergences):\n` +
190
+ unexpected
191
+ .map(
192
+ f =>
193
+ ` [${f.key}]\n` +
194
+ ` "${f.excerpt}"\n` +
195
+ ` engine : ${JSON.stringify(f.engineEffects).slice(0, 300)}\n` +
196
+ ` upstream: ${JSON.stringify(f.upstreamEffects).slice(0, 300)}`
197
+ )
198
+ .join('\n') +
199
+ `\n\nOne source on both, on one page: npx tsx packages/engine/tools/probe.mts '<source>'`
200
+ : ''
201
+ ).toEqual([]);
202
+ });
203
+
204
+ it('has no stale allowlist entries', () => {
205
+ const stillDiverging = new Set(result.engineCompared.filter(c => !c.match).map(c => c.key));
206
+ const stale = allowlist.allowedEngineDivergences
207
+ .map(e => e.key)
208
+ .filter(k => !stillDiverging.has(k));
209
+ expect(
210
+ stale,
211
+ stale.length
212
+ ? `\nThese handlers no longer diverge on the engine (or were edited: the key embeds a\n` +
213
+ `source hash). Remove them from allowedEngineDivergences in\n` +
214
+ `baselines/shipped-examples-execution.json:\n ${stale.join('\n ')}`
215
+ : ''
216
+ ).toEqual([]);
217
+ });
218
+ });
219
+
145
220
  describe('harness pieces', () => {
146
221
  it('extracts the trigger event from the leading on-clause', () => {
147
222
  expect(triggerEventOf('on click add .a to me')).toBe('click');
@@ -35,6 +35,15 @@
35
35
  * before/after snapshot around it. See runHandlerOnEngine for why isolation is
36
36
  * worth its cost.
37
37
  *
38
+ * ## The engine lane
39
+ * `@hyperfixi/engine` is meant to replace core's engine, so the same sweep
40
+ * runs it against the same oracle: every handler upstream accepts (whether or
41
+ * not core compiles it clean) is executed on upstream and on the engine, and
42
+ * the two effect signatures are compared (`engineCompared`). The parse-level
43
+ * list of what the engine rejects (shipped-sources-engine.test.ts) cannot see
44
+ * a handler that parses on both and runs differently; this can. A handler
45
+ * upstream accepts and the engine rejects is compared too, and diverges.
46
+ *
38
47
  * ## Node-only
39
48
  * Imports the real `hyperscript.org` build off disk and swaps jsdom globals
40
49
  * per handler execution (both engines resolve `document` lazily through
@@ -144,6 +153,18 @@ export interface ComparedHandler extends ShippedHandler {
144
153
  excerpt: string;
145
154
  }
146
155
 
156
+ /** One handler run on upstream and on `@hyperfixi/engine`. */
157
+ export interface EngineComparedHandler extends ShippedHandler {
158
+ key: string;
159
+ event: string;
160
+ engineEffects: string[];
161
+ upstreamEffects: string[];
162
+ match: boolean;
163
+ /** Both signatures empty: not evidence of parity (see ComparedHandler.vacuous). */
164
+ vacuous: boolean;
165
+ excerpt: string;
166
+ }
167
+
147
168
  export interface ExecutionParityResult {
148
169
  /** Pages walked. */
149
170
  pages: number;
@@ -153,6 +174,8 @@ export interface ExecutionParityResult {
153
174
  compared: ComparedHandler[];
154
175
  /** Handlers excluded, each with its reason. */
155
176
  skipped: SkippedHandler[];
177
+ /** Handlers upstream accepts, executed on upstream and on `@hyperfixi/engine`. */
178
+ engineCompared: EngineComparedHandler[];
156
179
  }
157
180
 
158
181
  /** Stable key for one handler execution. */
@@ -219,6 +242,10 @@ export interface Engines {
219
242
  hyperfixiInstall(source: string, el: Element): Promise<void>;
220
243
  /** Install a handler on an element via the upstream engine. */
221
244
  upstreamInstall(el: Element): void;
245
+ /** `@hyperfixi/engine`'s parse errors for a source; [] = it parses. */
246
+ engineErrors(source: string): string[];
247
+ /** Install a handler on an element via `@hyperfixi/engine`. */
248
+ engineInstall(el: Element): void;
222
249
  }
223
250
 
224
251
  /**
@@ -311,7 +338,20 @@ export async function initEngines(): Promise<Engines> {
311
338
  processNode(el: Node): void;
312
339
  };
313
340
 
341
+ const engine = await import('@hyperfixi/engine');
342
+ engine.register(...engine.everything);
343
+
314
344
  return {
345
+ engineErrors(source) {
346
+ try {
347
+ return engine.api.parse(source).errors.map(e => e.message.split('\n')[0] ?? '');
348
+ } catch (e) {
349
+ return ['threw: ' + (e as Error).message.split('\n')[0]];
350
+ }
351
+ },
352
+ engineInstall(el) {
353
+ engine.api.processNode(el);
354
+ },
315
355
  compileClean(source) {
316
356
  try {
317
357
  const r = core.hyperscript.compileSync(source);
@@ -425,6 +465,7 @@ export async function runShippedExamplesExecution(opts?: {
425
465
 
426
466
  const compared: ComparedHandler[] = [];
427
467
  const skipped: SkippedHandler[] = [];
468
+ const engineCompared: EngineComparedHandler[] = [];
428
469
  let pages = 0;
429
470
  let handlers = 0;
430
471
 
@@ -438,6 +479,9 @@ export async function runShippedExamplesExecution(opts?: {
438
479
  handlers += pageHandlers.length;
439
480
 
440
481
  const eligible: Array<ShippedHandler & { event: string }> = [];
482
+ // The engine lane's denominator: dispatchable, deterministic, and upstream
483
+ // accepts it. Core's verdict is not part of it.
484
+ const oracle: Array<ShippedHandler & { event: string }> = [];
441
485
  for (const h of pageHandlers) {
442
486
  if (!h.event) {
443
487
  skipped.push({ ...h, reason: 'not an `on <event>` handler' });
@@ -452,6 +496,8 @@ export async function runShippedExamplesExecution(opts?: {
452
496
  skipped.push({ ...h, reason: disq.reason });
453
497
  continue;
454
498
  }
499
+ const upstreamErrs = engines.upstreamErrors(h.source);
500
+ if (upstreamErrs.length === 0) oracle.push(h as ShippedHandler & { event: string });
455
501
  if (!engines.compileClean(h.source)) {
456
502
  skipped.push({
457
503
  ...h,
@@ -459,14 +505,18 @@ export async function runShippedExamplesExecution(opts?: {
459
505
  });
460
506
  continue;
461
507
  }
462
- const upstreamErrs = engines.upstreamErrors(h.source);
463
508
  if (upstreamErrs.length > 0) {
464
509
  skipped.push({ ...h, reason: `upstream rejects it (no oracle): ${upstreamErrs[0]}` });
465
510
  continue;
466
511
  }
467
512
  eligible.push(h as ShippedHandler & { event: string });
468
513
  }
469
- if (eligible.length === 0) continue;
514
+ if (eligible.length === 0 && oracle.length === 0) continue;
515
+ // Upstream's signatures from the core lane serve the engine lane too when
516
+ // the two lanes install the same handlers on the page.
517
+ const sameHandlers =
518
+ eligible.length === oracle.length && eligible.every((h, i) => h === oracle[i]);
519
+ const upstreamRun = new Map<ShippedHandler, string[]>();
470
520
 
471
521
  // Both engines log runtime errors to the console during dispatch
472
522
  // (COMMAND FAILED etc.). That is expected data here — the effect
@@ -488,6 +538,7 @@ export async function runShippedExamplesExecution(opts?: {
488
538
  const theirs = await runHandlerOnEngine(html, eligible, h, (_hh, el) =>
489
539
  engines.upstreamInstall(el)
490
540
  );
541
+ upstreamRun.set(h, theirs);
491
542
  compared.push({
492
543
  ...h,
493
544
  key: keyFor(h),
@@ -498,6 +549,25 @@ export async function runShippedExamplesExecution(opts?: {
498
549
  excerpt: h.source.replace(/\s+/g, ' ').trim().slice(0, 100),
499
550
  });
500
551
  }
552
+ for (const h of oracle) {
553
+ const rejected = engines.engineErrors(h.source)[0];
554
+ const theirs =
555
+ (sameHandlers && upstreamRun.get(h)) ||
556
+ (await runHandlerOnEngine(html, oracle, h, (_hh, el) => engines.upstreamInstall(el)));
557
+ const ours =
558
+ rejected !== undefined
559
+ ? [`<engine rejects the source: ${rejected}>`]
560
+ : await runHandlerOnEngine(html, oracle, h, (_hh, el) => engines.engineInstall(el));
561
+ engineCompared.push({
562
+ ...h,
563
+ key: keyFor(h),
564
+ engineEffects: ours,
565
+ upstreamEffects: theirs,
566
+ match: JSON.stringify(ours) === JSON.stringify(theirs),
567
+ vacuous: ours.length === 0 && theirs.length === 0,
568
+ excerpt: h.source.replace(/\s+/g, ' ').trim().slice(0, 100),
569
+ });
570
+ }
501
571
  } finally {
502
572
  console.log = saved.log;
503
573
  console.warn = saved.warn;
@@ -507,5 +577,5 @@ export async function runShippedExamplesExecution(opts?: {
507
577
  }
508
578
  }
509
579
 
510
- return { pages, handlers, compared, skipped };
580
+ return { pages, handlers, compared, skipped, engineCompared };
511
581
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shipped sources on `@hyperfixi/engine`: what replacing core's engine would break.
3
+ *
4
+ * `packages/engine` is meant to replace the engine in `packages/core`. Every
5
+ * hyperscript source this repository ships (`examples/` and the doc trees, the
6
+ * validity gate's collection) that core compiles clean is put to the engine's
7
+ * parser. The ones it rejects are listed in
8
+ * `baselines/shipped-sources-engine.json`, each with the reason it is still
9
+ * there. Three assertions, as the sibling gates:
10
+ * 1. sanity: sources were found and the engine reads most of them;
11
+ * 2. no NEW rejected source outside the list. A page or a doc example
12
+ * written in syntax only core has would break when core's engine goes;
13
+ * write upstream's spelling (`packages/engine/README.md` lists the forms
14
+ * that were considered and not kept), or list it with a reason;
15
+ * 3. no stale entry: a listed source the engine now reads, or one that was
16
+ * edited (the key embeds a hash of the source), must be removed, so the
17
+ * list only shrinks. It is empty when core's engine can be replaced
18
+ * without breaking a shipped page.
19
+ *
20
+ * This is a PARSE-level gate. A source can parse on both engines and run
21
+ * differently; the engine lane of `shipped-examples-execution.test.ts` is the
22
+ * gate for that.
23
+ *
24
+ * To see the list by form, and beside upstream's verdict:
25
+ * `npx tsx packages/engine/tools/shipped-sources.mts`.
26
+ */
27
+
28
+ import { readFileSync } from 'node:fs';
29
+ import { fileURLToPath } from 'node:url';
30
+ import path from 'node:path';
31
+ import { JSDOM } from 'jsdom';
32
+ import { describe, it, expect, beforeAll } from 'vitest';
33
+ import {
34
+ checkShippedSourcesOnEngine,
35
+ type CompileForValidity,
36
+ type ShippedSourcesOnEngineResult,
37
+ } from './shipped-sources-validity';
38
+
39
+ interface RejectedDoc {
40
+ rejected: Array<{ key: string; file: string; error: string; excerpt: string; reason: string }>;
41
+ }
42
+
43
+ const baselinePath = path.resolve(
44
+ path.dirname(fileURLToPath(import.meta.url)),
45
+ '../../baselines/shipped-sources-engine.json'
46
+ );
47
+ const listed = JSON.parse(readFileSync(baselinePath, 'utf8')) as RejectedDoc;
48
+ const allowed = new Set(listed.rejected.map(e => e.key));
49
+
50
+ describe('shipped sources on @hyperfixi/engine', () => {
51
+ let result: ShippedSourcesOnEngineResult;
52
+
53
+ beforeAll(async () => {
54
+ const core = (await import('@hyperfixi/core')) as unknown as {
55
+ hyperscript: { compileSync: CompileForValidity };
56
+ };
57
+ const { api, everything, register } = await import('@hyperfixi/engine');
58
+ register(...everything);
59
+ const dom = new JSDOM('<!doctype html><html><body></body></html>');
60
+ result = checkShippedSourcesOnEngine(
61
+ code => core.hyperscript.compileSync(code),
62
+ code => {
63
+ try {
64
+ return api.parse(code).errors.map(error => error.message);
65
+ } catch (e) {
66
+ return ['threw: ' + (e instanceof Error ? e.message : String(e))];
67
+ }
68
+ },
69
+ dom.window.document as unknown as Parameters<typeof checkShippedSourcesOnEngine>[2]
70
+ );
71
+ }, 120_000);
72
+
73
+ it('finds the shipped sources and the engine reads most of them (sanity)', () => {
74
+ expect(result.coreClean).toBeGreaterThan(200);
75
+ expect(result.engineAccepts).toBeGreaterThan(200);
76
+ });
77
+
78
+ it('has no NEW shipped source the engine rejects outside the list', () => {
79
+ const unexpected = result.rejections.filter(r => !allowed.has(r.key));
80
+ expect(
81
+ unexpected,
82
+ unexpected.length
83
+ ? `\nShipped sources core compiles and @hyperfixi/engine rejects (write upstream's spelling,\n` +
84
+ `or list it in baselines/shipped-sources-engine.json with a reason):\n` +
85
+ unexpected
86
+ .map(r => ` [${r.key}]\n "${r.excerpt}"\n -> ${r.error}`)
87
+ .join('\n') +
88
+ `\n\nOne source on the engine and on upstream, side by side:\n` +
89
+ ` npx tsx packages/engine/tools/probe.mts '<source>'`
90
+ : ''
91
+ ).toEqual([]);
92
+ });
93
+
94
+ it('has no stale entries (a source the engine now reads must be removed, so the list only shrinks)', () => {
95
+ const stillRejected = new Set(result.rejections.map(r => r.key));
96
+ const stale = listed.rejected.map(e => e.key).filter(key => !stillRejected.has(key));
97
+ expect(
98
+ stale,
99
+ stale.length
100
+ ? `\nThese listed sources are no longer rejected (read by the engine now, or edited: the key\n` +
101
+ `embeds a hash of the source). Remove them from baselines/shipped-sources-engine.json:\n ` +
102
+ stale.join('\n ')
103
+ : ''
104
+ ).toEqual([]);
105
+ });
106
+ });
@@ -231,3 +231,68 @@ export function checkShippedSourcesValidity(
231
231
 
232
232
  return { checked: sources.length, clean, findings };
233
233
  }
234
+
235
+ /** A shipped source `packages/core` compiles clean and `@hyperfixi/engine` rejects. */
236
+ export interface EngineRejection extends ShippedSource {
237
+ /** Stable key: file plus a hash of the source, as the validity gate's. */
238
+ key: string;
239
+ /** The engine's first parse error, first line. */
240
+ error: string;
241
+ excerpt: string;
242
+ }
243
+
244
+ export interface ShippedSourcesOnEngineResult {
245
+ /** Distinct (file, source) pairs core compiles clean. */
246
+ coreClean: number;
247
+ /** Of those, the ones the engine parses. */
248
+ engineAccepts: number;
249
+ rejections: EngineRejection[];
250
+ }
251
+
252
+ /**
253
+ * What replacing core's engine with `@hyperfixi/engine` would break in the
254
+ * material we ship: every source core compiles clean, put to the engine's
255
+ * parser. `engineErrors` returns the engine's parse errors ([] = it parses).
256
+ *
257
+ * The denominator is core-clean sources on purpose. A source core itself
258
+ * rejects or recovers from is already broken where it is shipped, and is the
259
+ * validity gate's business; the question here is only what would STOP working.
260
+ */
261
+ export function checkShippedSourcesOnEngine(
262
+ compile: CompileForValidity,
263
+ engineErrors: (code: string) => string[],
264
+ doc: Parameters<typeof extractHyperscriptFromMarkup>[0],
265
+ opts?: { roots?: string[]; repoRoot?: string }
266
+ ): ShippedSourcesOnEngineResult {
267
+ const seen = new Set<string>();
268
+ const rejections: EngineRejection[] = [];
269
+ let coreClean = 0;
270
+ let engineAccepts = 0;
271
+
272
+ for (const s of collectShippedSources(doc, opts)) {
273
+ const key = keyFor(s.file, s.source);
274
+ if (seen.has(key)) continue;
275
+ seen.add(key);
276
+ let clean = false;
277
+ try {
278
+ const result = compile(s.source);
279
+ clean = result.ok && (result.errors ?? []).length === 0;
280
+ } catch {
281
+ /* not core-clean */
282
+ }
283
+ if (!clean) continue;
284
+ coreClean++;
285
+ const errors = engineErrors(s.source);
286
+ if (errors.length === 0) {
287
+ engineAccepts++;
288
+ continue;
289
+ }
290
+ rejections.push({
291
+ ...s,
292
+ key,
293
+ error: (errors[0] ?? '').split('\n')[0] ?? '',
294
+ excerpt: s.source.replace(/\s+/g, ' ').trim().slice(0, 100),
295
+ });
296
+ }
297
+ return { coreClean, engineAccepts, rejections };
298
+ }
@@ -19,7 +19,7 @@ import { describe, it, expect, beforeAll } from 'vitest';
19
19
  import { ExecutionValidator, EXECUTION_SUBSET, loadExecutionSubset } from './execution-validator';
20
20
 
21
21
  describe('R2 execution subset (lock)', () => {
22
- it('contains exactly the 48 curated patterns', () => {
22
+ it('contains exactly the 49 curated patterns', () => {
23
23
  // Changing this list recalibrates avgExecutionFidelity for every language.
24
24
  // If you expand the subset, regenerate the baseline (--save-baseline) in
25
25
  // the SAME PR and update this lock.
@@ -128,6 +128,10 @@ describe('R2 execution subset (lock)', () => {
128
128
  // execution-validator.ts.
129
129
  'append-content',
130
130
  'increment-by-amount',
131
+ // Wave 12 (hxi18n Arc 3): the book's counter — a property counter with
132
+ // a positional owner, which ran and changed nothing in every language
133
+ // until the semantic increment mapper desugared it to `set X to X + 1`.
134
+ 'book-counter-increment',
131
135
  ].sort()
132
136
  );
133
137
  });
@@ -231,12 +235,37 @@ describe('R2 execution validator (lock)', () => {
231
235
  expect(clickOnly.effects).toEqual([]);
232
236
  });
233
237
 
238
+ it('the book counter increments its <output> in en and in the clitic languages', async () => {
239
+ // Hypermedia Systems ch. 9. The ja/ko renders are owner-first
240
+ // (`前 <output/> の textContent`), which neither parsed nor executed before
241
+ // hxi18n Arc 3; a property counter built by the semantic path also wrote
242
+ // nowhere until the increment mapper desugared it to `set X to X + 1`.
243
+ const en = await validator.execute(
244
+ 'book-counter-increment',
245
+ 'on click increment the textContent of the previous <output/>',
246
+ 'en'
247
+ );
248
+ expect(en.error, en.error).toBeUndefined();
249
+ expect(en.effects).toHaveLength(1);
250
+ expect(en.effects[0]).toMatch(/^Δoutput.*text\[6\]$/);
251
+ for (const [lang, code] of [
252
+ ['ja', 'クリック を で 前 <output/> の textContent を 増加'],
253
+ ['ko', '클릭 할 때 이전 <output/> 의 textContent 을 증가'],
254
+ ] as const) {
255
+ const res = await validator.execute('book-counter-increment', code, lang);
256
+ expect(res.error, `${lang}: ${res.error}`).toBeUndefined();
257
+ expect(res.effects, lang).toEqual(en.effects);
258
+ }
259
+ });
260
+
234
261
  it('wave-6 en references execute with their locked signatures', async () => {
235
262
  // The six wave-6 additions. Each en reference must produce a non-empty,
236
263
  // deterministic signature against the existing fixture (the foundation every
237
264
  // language is scored against). next/closest positionals fall back to `me`
238
265
  // when no match exists in the fixture; set *opacity/*transform write inline
239
- // style; caret-var-on-target clears #btn text (undefined `^count`).
266
+ // style; caret-var-on-target writes `null` into #btn (the undefined
267
+ // `^count`), as upstream's put writes a null value (PR 54; it used to
268
+ // clear the text).
240
269
  const cases: ReadonlyArray<[string, string, string[]]> = [
241
270
  [
242
271
  'next-element',
@@ -266,7 +295,7 @@ describe('R2 execution validator (lock)', () => {
266
295
  [
267
296
  'caret-var-on-target',
268
297
  'on click put ^count on #host into me',
269
- ['Δ#btn cls[] attr[id=btn] style[] text[]'],
298
+ ['Δ#btn cls[] attr[id=btn] style[] text[null]'],
270
299
  ],
271
300
  ];
272
301
  for (const [id, code, expected] of cases) {
@@ -72,9 +72,11 @@ export const EXECUTION_SUBSET: readonly string[] = [
72
72
  // patterns whose en reference now parses (the if/else conditional fold) and
73
73
  // executes (propertyAccess evaluator; matches/exists/is-empty condition
74
74
  // forms). Probed through this validator: each produces a non-empty,
75
- // deterministic effect signature. `unless-condition` stays out — `unless` is
76
- // deliberately NOT folded (see semantic-parser.tryParseConditionalBlock), so
77
- // its flat parse still errors at runtime.
75
+ // deterministic effect signature. `unless-condition` stayed out while it was
76
+ // core's prefix `unless` (deliberately NOT folded: see
77
+ // semantic-parser.tryParseConditionalBlock; its flat parse errored at
78
+ // runtime). The row is `if I do not match …` since 2026-10-01 and has not
79
+ // been probed for this subset.
78
80
  'if-condition',
79
81
  'if-matches',
80
82
  'if-exists',
@@ -145,8 +147,8 @@ export const EXECUTION_SUBSET: readonly string[] = [
145
147
  // stale. No fixture/setup/trigger change needed; each en reference produces a clean
146
148
  // non-empty signature against the existing fixture (next/closest positionals fall
147
149
  // back to `me` consistently across every language; set *opacity/*transform write
148
- // inline style; caret-var-on-target clears #btn text — the undefined `^count` resolves
149
- // the same way in every language).
150
+ // inline style; caret-var-on-target writes `null` into #btn, as upstream's put writes
151
+ // a null value — the undefined `^count` resolves the same way in every language).
150
152
  'next-element',
151
153
  'toggle-aria-expanded',
152
154
  'set-opacity',
@@ -213,6 +215,14 @@ export const EXECUTION_SUBSET: readonly string[] = [
213
215
  // Fixture adds `#a`/`#b` with distinguishable content (appended last, so
214
216
  // existing snapshot indices are preserved).
215
217
  'swap-content',
218
+ // Expansion wave 12 (hxi18n Arc 3): `increment the textContent of the previous
219
+ // <output/>` — Hypermedia Systems ch. 9, verbatim. It parsed lossily in every
220
+ // language (the owner dropped) until the of-possessive matcher took positional
221
+ // owners, and even then it ran and changed nothing on the semantic path: the
222
+ // AST handed `increment` a property access, which the command evaluated and
223
+ // wrote nowhere. The semantic mapper now desugars a property counter to core's
224
+ // own `set X to (X + 1)`. PATTERN_SETUP puts an <output> before #btn.
225
+ 'book-counter-increment',
216
226
  ];
217
227
 
218
228
  /**
@@ -265,6 +275,13 @@ const PATTERN_SETUP: Record<string, (doc: Document) => void> = {
265
275
  doc.querySelector('.card')!.classList.add('modal');
266
276
  doc.body.classList.add('modal-open');
267
277
  },
278
+ // `increment the textContent of the previous <output/>` needs an <output>
279
+ // before #btn, as in the book's counter (`<output>0</output><button …>`).
280
+ 'book-counter-increment': doc => {
281
+ const out = doc.createElement('output');
282
+ out.textContent = '5';
283
+ doc.getElementById('btn')!.before(out);
284
+ },
268
285
  };
269
286
 
270
287
  /**