From 6600994ef17d4e163a420637e4a4889b837566e6 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 1 Jun 2026 16:37:42 +0530 Subject: [PATCH] test(spec): cover the WEB Host surface and seed precision Replace the deleted-picker tests with Host coverage: platform()==web, seedHi() parsing a 64-bit seed without Number precision loss, seedLo()==0, reportUnsupported warning, the installed next-action/extractor globals, and queryCandidates verb routing + per-tick caching over a querySelectorAll stub. --- pkg/spec/test/web-runtime.test.ts | 171 +++++++++++++++++++----------- 1 file changed, 111 insertions(+), 60 deletions(-) diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 4c65158..1a99641 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -1,69 +1,120 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { __testing__ } from "../src/web-runtime.ts"; +// The web runtime is the WEB Host: it parses the injected seed, reports its +// platform, and delegates action generation to the shared picker. These tests +// guard the Host surface and the seed-precision contract. DOM candidate +// enumeration is exercised against a minimal querySelectorAll stub. -const { mulberry32, deriveSeed32, pickWeighted, WEB_PRESS_KEYS } = __testing__; +// A 64-bit seed that loses precision as a JS Number must survive as a BigInt. +process.env.SANDERLING_SEED = "9007199254740993"; -function entry(weight: number, kind: string) { - return [weight, { __sanderlingActionGenerator: true, __sanderlingKind: kind }] as const; +const { __testing__ } = await import("../src/web-runtime.ts"); +const { host } = __testing__; + +test("platform is web", () => { + assert.equal(host.platform(), "web"); +}); + +test("seedHi parses the injected 64-bit seed without Number precision loss", () => { + assert.equal(host.seedHi(), 9007199254740993n); +}); + +test("seedLo is 0 to match goja rand.NewPCG(seed, 0)", () => { + assert.equal(host.seedLo(), 0n); +}); + +test("reportUnsupported warns once via console.warn", () => { + const original = console.warn; + const messages: string[] = []; + console.warn = (m: string) => messages.push(m); + try { + host.reportUnsupported("swipes"); + } finally { + console.warn = original; + } + assert.equal(messages.length, 1); + assert.match(messages[0]!, /swipes/); +}); + +// installRuntime locked the next-action and extractor globals at import. +test("installRuntime defined the host-invoked globals", () => { + const g = globalThis as Record; + assert.equal(typeof g.__sanderlingNextAction__, "function"); + assert.equal(typeof g.__sanderlingExtractors__, "function"); + assert.equal(typeof g.__sanderling__, "object"); +}); + +// A button and a text input, each with a deterministic bounding box, exercise +// the per-verb selector routing without a full DOM. +function fakeElement(tag: string, rect: { x: number; y: number; w: number; h: number }) { + return { + tagName: tag.toUpperCase(), + disabled: false, + isContentEditable: false, + type: tag === "input" ? "text" : "", + scrollHeight: 0, + clientHeight: 0, + scrollWidth: 0, + clientWidth: 0, + getBoundingClientRect: () => ({ + left: rect.x, + top: rect.y, + width: rect.w, + height: rect.h, + right: rect.x + rect.w, + bottom: rect.y + rect.h, + }), + }; } -test("mulberry32 is deterministic for the same seed", () => { - const a = mulberry32(deriveSeed32("12345")); - const b = mulberry32(deriveSeed32("12345")); - const first = Array.from({ length: 8 }, () => a()); - const second = Array.from({ length: 8 }, () => b()); - assert.deepEqual(first, second); -}); - -test("mulberry32 diverges for different seeds", () => { - const a = Array.from({ length: 8 }, mulberry32(deriveSeed32("1"))); - const b = Array.from({ length: 8 }, mulberry32(deriveSeed32("2"))); - assert.notDeepEqual(a, b); -}); - -test("mulberry32 stays in [0, 1)", () => { - const next = mulberry32(deriveSeed32("seed")); - for (let i = 0; i < 1000; i++) { - const value = next(); - assert.ok(value >= 0 && value < 1, `value out of range: ${value}`); - } -}); - -test("deriveSeed32 folds a 64-bit seed without precision loss", () => { - // Two distinct 64-bit values that collide as a JS Number must not collide here. - const a = deriveSeed32("9007199254740993"); - const b = deriveSeed32("9007199254740992"); - assert.notEqual(a, b); - assert.equal(deriveSeed32(undefined), 0); -}); - -test("pickWeighted selects by cumulative ascending scan", () => { - const handle = { __sanderlingActionGenerator: true, __sanderlingKind: "weighted", entries: [entry(1, "a"), entry(3, "b")] } as const; - // total = 4. pick = 0.1 -> 0.4 lands in [0,1) -> first; pick = 0.5 -> 2.0 lands in [1,4) -> second. - assert.equal(pickWeighted(handle as never, () => 0.1)?.__sanderlingKind, "a"); - assert.equal(pickWeighted(handle as never, () => 0.5)?.__sanderlingKind, "b"); -}); - -test("pickWeighted never selects a zero-weight entry when pick lands before it", () => { - const handle = { __sanderlingActionGenerator: true, __sanderlingKind: "weighted", entries: [entry(1, "a"), entry(0, "zero"), entry(1, "b")] } as const; - // total = 2. Any pick < 0.5 lands strictly inside the first entry's [0,1) band. - for (const r of [0, 0.1, 0.25, 0.49]) { - assert.equal(pickWeighted(handle as never, () => r)?.__sanderlingKind, "a"); - } - // A zero-weight entry adds 0 to the cumulative, so it can never win. - for (let i = 0; i < 100; i++) { - const r = i / 100; - assert.notEqual(pickWeighted(handle as never, () => r)?.__sanderlingKind, "zero"); - } -}); - -test("seeded press-key draw is reproducible", () => { - const draw = (seed: string) => { - const next = mulberry32(deriveSeed32(seed)); - return Array.from({ length: 12 }, () => WEB_PRESS_KEYS[Math.floor(next() * WEB_PRESS_KEYS.length)]); +function withFakeDocument(map: Record, run: () => void) { + const g = globalThis as Record; + const original = g.document; + g.document = { + querySelectorAll: (selector: string) => map[selector] ?? [], + scrollingElement: null, + documentElement: null, }; - assert.deepEqual(draw("777"), draw("777")); - assert.notDeepEqual(draw("777"), draw("778")); + try { + run(); + } finally { + g.document = original; + } +} + +test("queryCandidates routes taps to the tappable selector set", () => { + const button = fakeElement("button", { x: 10, y: 20, w: 40, h: 8 }); + withFakeDocument( + { 'a, button, input, select, textarea, [role="button"], [onclick]': [button] }, + () => { + __testing__.resetCandidateCache(); + const candidates = host.queryCandidates("taps"); + assert.equal(candidates.length, 1); + assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 30, y: 24 }); + }, + ); +}); + +test("queryCandidates routes typing to editable inputs only", () => { + const input = fakeElement("input", { x: 0, y: 0, w: 100, h: 20 }); + withFakeDocument({ "input, textarea, [contenteditable]": [input] }, () => { + __testing__.resetCandidateCache(); + const candidates = host.queryCandidates("typing"); + assert.equal(candidates.length, 1); + assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 50, y: 10 }); + }); +}); + +test("queryCandidates caches within a tick until reset", () => { + const first = fakeElement("button", { x: 0, y: 0, w: 10, h: 10 }); + withFakeDocument( + { 'a, button, input, select, textarea, [role="button"], [onclick]': [first] }, + () => { + __testing__.resetCandidateCache(); + const a = host.queryCandidates("taps"); + const b = host.queryCandidates("taps"); + assert.equal(a, b); + }, + ); });