diff --git a/internal/bundler/web_bundle_test.go b/internal/bundler/web_bundle_test.go index 0863101..95070d0 100644 --- a/internal/bundler/web_bundle_test.go +++ b/internal/bundler/web_bundle_test.go @@ -122,6 +122,38 @@ func TestBundleWeb_IsDeterministic(t *testing.T) { } } +// TestBundleWeb_InjectsSeedDefine asserts that a SANDERLING_SEED define is +// inlined into the bundle so --seed reaches the web runtime PRNG. esbuild +// replaces process.env.SANDERLING_SEED with the quoted literal at build time. +func TestBundleWeb_InjectsSeedDefine(t *testing.T) { + directory := t.TempDir() + runtimePath := filepath.Join(directory, "web-runtime.ts") + specPath := filepath.Join(directory, "spec.ts") + runtime := fakeRuntime + "\n(globalThis as Record).__seed = process.env.SANDERLING_SEED;\n" + if err := os.WriteFile(runtimePath, []byte(runtime), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(specPath, []byte(fakeSpec), 0o644); err != nil { + t.Fatal(err) + } + + result, err := BundleWeb(WebOptions{ + EntryFile: specPath, + WebRuntimeFile: runtimePath, + Defines: map[string]string{"SANDERLING_SEED": "8675309"}, + }) + if err != nil { + t.Fatalf("BundleWeb: %v", err) + } + source := string(result.JavaScript) + if !strings.Contains(source, "8675309") { + t.Errorf("seed literal not inlined into bundle\nsource head:\n%s", head(source, 500)) + } + if strings.Contains(source, "process.env.SANDERLING_SEED") { + t.Errorf("define left unsubstituted in bundle") + } +} + func head(text string, max int) string { if len(text) <= max { return text diff --git a/internal/testrun/testrun_test.go b/internal/testrun/testrun_test.go index 9611150..5cd63bf 100644 --- a/internal/testrun/testrun_test.go +++ b/internal/testrun/testrun_test.go @@ -3,9 +3,31 @@ package testrun import ( "os" "path/filepath" + "strconv" "testing" ) +func TestResolveSeed_UsesConfiguredWhenNonZero(t *testing.T) { + if got := resolveSeed(42); got != 42 { + t.Fatalf("got %d, want 42", got) + } +} + +func TestResolveSeed_DerivesWhenZero(t *testing.T) { + if got := resolveSeed(0); got == 0 { + t.Fatal("expected a non-zero time-derived seed") + } +} + +// TestSeedDefineFormatsAsDecimal guards the contract that Execute uses to put +// the seed into the web bundle: strconv.FormatInt base 10, which web-runtime +// folds to a 32-bit PRNG seed. +func TestSeedDefineFormatsAsDecimal(t *testing.T) { + if got := strconv.FormatInt(resolveSeed(8675309), 10); got != "8675309" { + t.Fatalf("got %q, want 8675309", got) + } +} + func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) { root := t.TempDir() apiPath := filepath.Join(root, "pkg", "spec", "src", "index.ts") diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts new file mode 100644 index 0000000..4c65158 --- /dev/null +++ b/pkg/spec/test/web-runtime.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { __testing__ } from "../src/web-runtime.ts"; + +const { mulberry32, deriveSeed32, pickWeighted, WEB_PRESS_KEYS } = __testing__; + +function entry(weight: number, kind: string) { + return [weight, { __sanderlingActionGenerator: true, __sanderlingKind: kind }] as const; +} + +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)]); + }; + assert.deepEqual(draw("777"), draw("777")); + assert.notDeepEqual(draw("777"), draw("778")); +});