diff --git a/go/tests/fixtures.rs b/go/tests/fixtures.rs index 8107ed31e..a949eb3fa 100644 --- a/go/tests/fixtures.rs +++ b/go/tests/fixtures.rs @@ -163,9 +163,8 @@ impl Rng { } } -/// The inputs rust2-ts's differential test runs: every op with every short tail, each -/// u64 boundary through the first `ops` ops with each of `selectors` second bytes, and -/// `count` random ones. +/// Every op with every short tail, each u64 boundary through the first `ops` ops with each +/// of `selectors` second bytes, and `count` random ones from one stream. fn cases(ops: u8, selectors: u8, count: usize) -> Vec> { let mut cases = vec![Vec::new()]; for op in 0..=255u8 { diff --git a/quest/m0/checks/README.md b/quest/m0/checks/README.md index 08d457f95..474272ec7 100644 --- a/quest/m0/checks/README.md +++ b/quest/m0/checks/README.md @@ -19,5 +19,3 @@ Each child reports its CPU saved, cold and warm, against `origin/main`. ## Required - [A warm gate](/quest/m0/checks/cache.md) - the land gate builds through mbx, as moq's does -- [Fixtures that merge](/quest/m0/checks/fixture-layout.md) - `lang` splits by module, and `reject`'s snapshots key by item, not line -- [A case's inputs stay as cases are added](/quest/m0/checks/case-inputs.md) - each differential case's generated inputs are seeded by its name diff --git a/quest/m0/checks/case-inputs.md b/quest/m0/checks/case-inputs.md deleted file mode 100644 index 097115c19..000000000 --- a/quest/m0/checks/case-inputs.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] A case's inputs stay as cases are added - -## Goal - -Each case of a differential harness gets generated inputs of its own, seeded from the -case's name, so adding, removing, or reordering a case changes no other case's inputs, and -a new case fails only on its own inputs. - -## Plan - -Decided with the maintainer on 2026-11-02: m0, since every PR adding a case can now fail on -another case's inputs, costing a fixer run and another gate run. - -`ts/tests/differential.ts` draws its random inputs from one seeded stream, and a harness -picks the case by leading bytes modulo its case count: `stock`'s first byte, `lang`'s first -two, and each of `task`'s steps' first. Adding a case shifts which case each random input -reaches, and the full-width values reach only the first eight ops. In -[#436](https://github.com/moq-dev/rust2/pull/436), adding a `stock` case sent the input -`421f00ff...` to `buf::write_all`, which hit the `rt.BytesMut` reserve refusal on a -zero-capacity `take` that no input had reached before. Generate each case's inputs from a -stream seeded by its name, behind the `ops` a harness exports, as `lang`'s caller already -exports one per case. - -Public API: none. diff --git a/quest/m0/checks/fixture-layout.md b/quest/m0/checks/fixture-layout.md deleted file mode 100644 index 58e7928bc..000000000 --- a/quest/m0/checks/fixture-layout.md +++ /dev/null @@ -1,16 +0,0 @@ -# [M] Fixtures that merge - -## Goal - -`lang` splits into fixtures by its modules, sharing one wasm target dir, and `reject`'s -snapshots key each refusal by its item rather than its line number, so two PRs adding cases -rarely conflict and a targeted run builds only its module. - -## Plan - -Decided with the maintainer on 2026-11-01. Of 71 merge-conflict ejections in four days, 43 -were in `lang` (31.7k lines, 108k lines of TS) and 28 in `reject`, and gate failure #374 was -`reject`'s line numbers shifting inside a batch. Each conflict costs a fixer run, a local -`just ci`, and another gate run. - -Public API: none. diff --git a/ts/README.md b/ts/README.md index 3718b8073..eb0df64e4 100644 --- a/ts/README.md +++ b/ts/README.md @@ -997,7 +997,9 @@ just ts census # census moq-net and kio at the same revision Each fixture under `fixtures/` is translated and compared with its `expected/` snapshot, then type-checked with `tsc`. A fixture with a `harness::run(&[u8]) -> Vec` is also built natively for wasm32 and run beside its translation on the same inputs by -`tests/differential.ts`, which fails on any difference in output or panic messages. +`tests/differential.ts`, which fails on any difference in output or panic messages. A +harness that names its cases draws each case's inputs from a stream seeded by its name, +so adding, removing, or reordering a case changes no other case's inputs. - `codec` vendors moq-net's sync codec, which a harness outside the crate could not reach, and writes its errors' `Display` and `Debug`. It is also translated without diff --git a/ts/fixtures/codec/caller.ts b/ts/fixtures/codec/caller.ts new file mode 100644 index 000000000..c9e26c3e6 --- /dev/null +++ b/ts/fixtures/codec/caller.ts @@ -0,0 +1,11 @@ +// The harness, with its cases by name for `differential.ts`. + +import { cases, run } from "./harness.ts"; + +export { run }; + +/** Each case's name and the byte that picks it, its place in `cases`. */ +export const ops = new Map(cases().map(([name], i): [string, Uint8Array] => [name, Uint8Array.of(i)])); + +/** Its cases read full-width values. */ +export const wide = true; diff --git a/ts/fixtures/codec/expected/harness.ts b/ts/fixtures/codec/expected/harness.ts index 71c827d4f..deeb64c81 100644 --- a/ts/fixtures/codec/expected/harness.ts +++ b/ts/fixtures/codec/expected/harness.ts @@ -18,25 +18,25 @@ import * as Probe from "./lite/probe.Probe.ts"; import type * as lite_version_Version from "./lite/version.Version.ts"; /** - * Each case, which the first byte picks modulo their count, so a case added at the end - * moves no other. + * Each case's name and what it runs. The first byte picks one modulo their count, so a + * case added at the end moves no other. */ -export function cases(): Array<(a0: rt.ByteSlice, a1: rt.ByteVec) => undefined> { - return [(a0: rt.ByteSlice, a1: rt.ByteVec): undefined => { +export function cases(): [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined][] { + return [["decode_script", (a0: rt.ByteSlice, a1: rt.ByteVec): undefined => { decodeScript(a0.data, a0.start, a0.length, a1); - }, (a02: rt.ByteSlice, a12: rt.ByteVec): undefined => { + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["encode_script", (a02: rt.ByteSlice, a12: rt.ByteVec): undefined => { encodeScript(a02.data, a02.start, a02.length, a12); - }, zigzag as (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined, sizes as (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined, (a03: rt.ByteSlice, a13: rt.ByteVec): undefined => { + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["zigzag", zigzag as (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["sizes", sizes as (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["roundtrip", (a03: rt.ByteSlice, a13: rt.ByteVec): undefined => { roundtrip$coding_version_Versions(a03.data, a03.start, a03.length, a13); - }, (a04: rt.ByteSlice, a14: rt.ByteVec): undefined => { + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["roundtrip_lite::", (a04: rt.ByteSlice, a14: rt.ByteVec): undefined => { roundtripLite$lite_group_Group(a04.data, a04.start, a04.length, a14); - }, (a05: rt.ByteSlice, a15: rt.ByteVec): undefined => { + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["roundtrip_lite::", (a05: rt.ByteSlice, a15: rt.ByteVec): undefined => { roundtripLite$lite_probe_Probe(a05.data, a05.start, a05.length, a15); - }, (a06: rt.ByteSlice, a16: rt.ByteVec): undefined => { + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["encode_messages", (a06: rt.ByteSlice, a16: rt.ByteVec): undefined => { encodeMessages(a06.data, a06.start, a06.length, a16); - }, (a07: rt.ByteSlice, a17: rt.ByteVec): undefined => { + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined], ["errors", (a07: rt.ByteSlice, a17: rt.ByteVec): undefined => { errors(a07.data, a07.start, a07.length, a17); - }]; + }] as [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined]]; } /** Run one case. */ @@ -44,8 +44,8 @@ export function run(input: rt.ByteSlice): rt.ByteVec { const out: rt.ByteVec = new rt.ByteVec(); if (input.length !== 0) { const op: rt.u8 = input.data[input.start]; - const cases_: Array<(a0: rt.ByteSlice, a1: rt.ByteVec) => undefined> = cases(); - cases_[rt.idx(cases_, rt.usizeRem(op as rt.usize, cases_.length))](new rt.ByteSlice(input.data, input.start + 1, input.length - 1), out); + const cases_: [string, (a0: rt.ByteSlice, a1: rt.ByteVec) => undefined][] = cases(); + cases_[rt.idx(cases_, rt.usizeRem(op as rt.usize, cases_.length))][1](new rt.ByteSlice(input.data, input.start + 1, input.length - 1), out); return out; } return out; diff --git a/ts/fixtures/codec/src/harness.rs b/ts/fixtures/codec/src/harness.rs index 71d3fe991..8f8dbee82 100644 --- a/ts/fixtures/codec/src/harness.rs +++ b/ts/fixtures/codec/src/harness.rs @@ -11,19 +11,19 @@ use crate::{SessionError, lite}; /// What a case runs on the rest of the input. type Case = fn(&[u8], &mut Vec); -/// Each case, which the first byte picks modulo their count, so a case added at the end -/// moves no other. -fn cases() -> Vec { +/// Each case's name and what it runs. The first byte picks one modulo their count, so a +/// case added at the end moves no other. +fn cases() -> Vec<(&'static str, Case)> { vec![ - decode_script, - encode_script, - zigzag, - sizes, - roundtrip::, - roundtrip_lite::, - roundtrip_lite::, - encode_messages, - errors, + ("decode_script", decode_script), + ("encode_script", encode_script), + ("zigzag", zigzag), + ("sizes", sizes), + ("roundtrip", roundtrip::), + ("roundtrip_lite::", roundtrip_lite::), + ("roundtrip_lite::", roundtrip_lite::), + ("encode_messages", encode_messages), + ("errors", errors), ] } @@ -34,7 +34,8 @@ pub fn run(input: &[u8]) -> Vec { return out; }; let cases = cases(); - cases[usize::from(op) % cases.len()](data, &mut out); + let (_, case) = cases[usize::from(op) % cases.len()]; + case(data, &mut out); out } diff --git a/ts/fixtures/lang/lang.ts b/ts/fixtures/lang/lang.ts index 44828259f..cef79189d 100644 --- a/ts/fixtures/lang/lang.ts +++ b/ts/fixtures/lang/lang.ts @@ -14,12 +14,12 @@ export function op(name: string): number[] { return [at >> 8, at & 0xff]; } -/** The bytes that pick each case, through `op`, which panics for one past what two bytes pick. */ -export const ops: Uint8Array[] = cases().map(([name]) => Uint8Array.from(op(name))); +/** Each case's name and the bytes that pick it, through `op`, which panics for one past what two bytes pick. */ +export const ops = new Map(cases().map(([name]): [string, Uint8Array] => [name, Uint8Array.from(op(name))])); /** Which case the first two bytes of `input` pick. */ export function picked(input: Uint8Array): number { - return ((input[0] << 8) | input[1]) % ops.length; + return ((input[0] << 8) | input[1]) % ops.size; } /** A double's bits, big-endian, as `floats` takes them. */ diff --git a/ts/fixtures/lang/shapes/caller.ts b/ts/fixtures/lang/shapes/caller.ts index 8d8ea71cd..1854135d6 100644 --- a/ts/fixtures/lang/shapes/caller.ts +++ b/ts/fixtures/lang/shapes/caller.ts @@ -7,7 +7,7 @@ import { be, edges, op, ops as picks, run as harness } from "./lang.ts"; import * as rt from "./rt.ts"; /** The bytes that pick each case, and a `Fault`'s first byte. */ -export const ops: Uint8Array[] = [...picks, Uint8Array.of(0xff)]; +export const ops = new Map([...picks, ["fmt::Fault", Uint8Array.of(0xff)]]); export function run(input: rt.ByteSlice): rt.ByteVec { if (rt.byteSliceFirst(input) !== 0xff) return harness(input); diff --git a/ts/fixtures/stock/caller.ts b/ts/fixtures/stock/caller.ts index 08db2ee47..d545db53c 100644 --- a/ts/fixtures/stock/caller.ts +++ b/ts/fixtures/stock/caller.ts @@ -1,13 +1,19 @@ -// The harness, with inputs it names for `differential.ts`: `buf::reserves`' edges of -// `rt.BytesMut`'s limit, which only these reach, as each allocates 512 MiB, each of -// `hashlink`'s iterators read through every pick of steps from either end, each -// `buf::chains` chain copied by each pick of a length, split at either end and between, -// and every pair of steps over `hashlink`'s maps of what drops. +// The harness, with its cases by name and the inputs it names for `differential.ts`: +// `buf::reserves`' edges of `rt.BytesMut`'s limit, which only these reach, as each +// allocates 512 MiB, each of `hashlink`'s iterators read through every pick of steps from +// either end, each `buf::chains` chain copied by each pick of a length, split at either +// end and between, and every pair of steps over `hashlink`'s maps of what drops. -import { op, run } from "./harness.ts"; +import { cases as named, op, run } from "./harness.ts"; export { run }; +/** Each case's name and the byte that picks it. */ +export const ops = new Map(named().map(([name]): [string, Uint8Array] => [name, Uint8Array.of(op(name))])); + +/** Its cases read full-width values. */ +export const wide = true; + const reserves = op("buf::reserves"); /** Each edge but shared storage past the limit, which throws, read past by none and by some. */ diff --git a/ts/runtime/rt/collections.ts b/ts/runtime/rt/collections.ts index ffc72453f..8d2dbf090 100644 --- a/ts/runtime/rt/collections.ts +++ b/ts/runtime/rt/collections.ts @@ -15,7 +15,7 @@ // A function that may drop a key, value, or item std's would, rather than return or keep // it, takes what drops each that does, as `k`, `v`, or `f`, and drops it where std's does. -import { ByteSlice, ByteVec, byteVecPush, byteVecReserve, type Capped, capOf, capReserve, capReserveExact, capSet, type Debug, Err, type FmtResult, type Formatter, fmtDebugTupleFields, fmtList, type None, Ok, type Place, panic, type RangeBound, type Result, rangeCheck, sliceSort, sliceView, vecExact, vecMax, vecReserve, vecWithCapacity } from "../rt.ts"; +import { ByteSlice, ByteVec, byteVecPush, byteVecReserve, type Capped, capHold, capOf, capReserve, capReserveExact, capSet, type Debug, Err, type FmtResult, type Formatter, fmtDebugTupleFields, fmtList, type None, Ok, type Place, panic, type RangeBound, type Result, rangeCheck, sliceSort, sliceView, vecExact, vecMax, vecReserve, vecWithCapacity } from "../rt.ts"; import type { Hashing } from "./hash.ts"; import { type Limbs, lt, type u64, type usize } from "./int.ts"; import { @@ -3515,11 +3515,11 @@ export function dequeSplitOff(q: VecDeque, i: number): VecDeque { return other; } -/** `Vec::from` of a deque: its items in order, with its capacity, as std's makes its buffer contiguous and keeps it. */ +/** `Vec::from` of a deque: its items in order, with its capacity, as std's makes its buffer contiguous and keeps it, however the length then drops. */ export function dequeToVec(q: VecDeque): T[] { const v: T[] & Capped = []; for (let i = 0; i < q.n; i++) v.push(q.b[at(q, i)] as T); - capSet(v, v.length, q.b.length, q.s); + capHold(v, q.b.length, q.s); return v; } diff --git a/ts/tests/differential.ts b/ts/tests/differential.ts index 13871b6a1..fd85d2051 100644 --- a/ts/tests/differential.ts +++ b/ts/tests/differential.ts @@ -4,11 +4,10 @@ // usage: node differential.ts [cases] // // A harness, or a hand-written caller wrapping one, exports `run(input: rt.ByteSlice): -// Vec`, and may export `cases`, inputs it names, which run with the generated ones, -// `ops`, the bytes that pick each of its cases, if not the first byte alone, and -// `within(input, native, translated)`, which accepts outputs that differ only where Rust -// leaves the result to the platform, as libm's are. [cases] random inputs run, or that -// many per op of a harness that exports `ops`. +// Vec`, what `inputs.ts` reads to pick its inputs, and may export `within(input, +// native, translated)`, which accepts outputs that differ only where Rust leaves the +// result to the platform, as libm's are. [cases] random inputs run, or that many per case +// of a harness that names its cases. // The wasm build exports `input`, `run`, and `output` around the crate's entry, which // `wasm.ts` calls. A panic traps natively and throws `Panic` in // TypeScript, and the two agree only if their messages do. An abort traps natively @@ -19,6 +18,7 @@ import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; +import { inputs } from "./inputs.ts"; import { type Outcome, Wasm } from "./wasm.ts"; const [wasmPath, harnessPath, countArg] = process.argv.slice(2); @@ -41,33 +41,6 @@ function translated(input: Uint8Array): Outcome { } } -// xorshift64*: the same cases on every run. -let state = 0x9e3779b97f4a7c15n; -function next(): bigint { - state ^= state >> 12n; - state ^= (state << 25n) & 0xffffffffffffffffn; - state ^= state >> 27n; - return (state * 0x2545f4914f6cdd1dn) & 0xffffffffffffffffn; -} -function byte(): number { - return Number(next() & 0xffn); -} - -/** Bytes biased toward the values codecs care about: zero, all ones, and boundaries. */ -function interesting(): number { - const pick = Number(next() % 8n); - switch (pick) { - case 0: - return 0; - case 1: - return 0xff; - case 2: - return [0x3f, 0x40, 0x7f, 0x80, 0xbf, 0xc0, 0xdf, 0xe0, 0xef, 0xf0, 0xf7, 0xf8, 0xfb, 0xfc, 0xfd, 0xfe][Number(next() % 16n)]; - default: - return byte(); - } -} - function show(outcome: Outcome): string { if (outcome === "abort") return outcome; return outcome instanceof Uint8Array ? Buffer.from(outcome).toString("hex") : `panic ${JSON.stringify(outcome.panic)}`; @@ -81,38 +54,7 @@ function display(outcome: Outcome): string { return show(outcome); } -const cases: Uint8Array[] = [new Uint8Array(0)]; -const named: Uint8Array[] | undefined = harness.ops; -const ops: Uint8Array[] = named ?? Array.from({ length: 256 }, (_, op) => Uint8Array.of(op)); -if (new Set(ops.map((op) => Buffer.from(op).toString("hex"))).size !== ops.length) throw new Error("two ops are the same bytes, so one of them never runs"); -// Every op with every short tail, so each path's edges are covered exhaustively. -for (const op of ops) { - cases.push(op); - for (let b = 0; b < 256; b += 17) cases.push(Uint8Array.of(...op, b)); -} -// Full-width values through the first ops: each u64 boundary 2^k - 1, 2^k, 2^k + 1. A -// harness that names its ops names these inputs itself, for the ops that take them. -for (const op of named ? [] : ops.slice(0, 8)) { - for (let k = 0n; k <= 64n; k++) { - for (const delta of [-1n, 0n, 1n]) { - const value = ((1n << k) + delta) & 0xffffffffffffffffn; - const bytes = new Uint8Array(8); - new DataView(bytes.buffer).setBigUint64(0, value); - for (const selector of [0, 1, 2, 3, 4, 5, 6]) { - cases.push(Uint8Array.of(...op, selector, ...bytes, ...bytes, ...bytes)); - } - } - } -} -// A harness's own cases, which name what they cover. -if (Array.isArray(harness.cases)) for (const input of harness.cases) cases.push(input); -for (let i = 0; i < count * (named?.length ?? 1); i++) { - const len = Number(next() % 48n); - const input = new Uint8Array(len + 1); - input[0] = byte(); - for (let j = 1; j <= len; j++) input[j] = interesting(); - cases.push(input); -} +const cases = inputs(harness, count); let failures = 0; let panics = 0; diff --git a/ts/tests/fixtures.rs b/ts/tests/fixtures.rs index c7eaf8359..3e2922556 100644 --- a/ts/tests/fixtures.rs +++ b/ts/tests/fixtures.rs @@ -205,8 +205,10 @@ fn codec() { let out = translated("codec"); snapshot("codec", &out); std::fs::write(out.join("shake.ts"), SHAKE).unwrap(); + std::fs::copy(fixture("codec").join("caller.ts"), out.join("caller.ts")).unwrap(); typecheck(&out); - differential("codec-fixture", &[], &out, "harness.ts", 20_000); + // As many random inputs per case as each had of 20,000 picked by their first byte. + differential("codec-fixture", &[], &out, "caller.ts", 2_300); shake(&out); } @@ -501,6 +503,17 @@ fn share() { } } +/// Each case's generated inputs are its own, which `stable.ts` checks: adding, removing, +/// or reordering a case changes no other case's. +#[test] +fn case_inputs() { + let output = Command::new("node") + .arg(manifest_dir().join("tests").join("stable.ts")) + .output() + .expect("running node, which the dev shell provides"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + /// Futures and drops: the harness is hand-written TS around the translation, whose FIFO /// executor logs every poll, wake, and drop as the fixture's native one does. #[test] @@ -523,7 +536,7 @@ import * as futures from "./futures.ts"; import * as closures from "./closures.ts"; import {{ harness }} from {harness:?}; -export {{ cases, steps }} from {harness:?}; +export {{ cases, input, ops }} from {harness:?}; const fixture = {{ Pending: rt.Pending, u64: {{ of: rt.u64Of }}, Log, Channel, Guard, sum, drain, blocks, watch, front, drops, futures, closures }}; const script = harness(fixture); export const run = (input: rt.ByteSlice) => script(rt.byteSliceView(input)); @@ -531,7 +544,8 @@ export const run = (input: rt.ByteSlice) => script(rt.byteSliceView(input)); ); std::fs::write(out.join("caller.ts"), text).unwrap(); typecheck(out); - differential("task-fixture", &[], out, "caller.ts", 20_000); + // Each step as often in random scripts as its share of 20,000 that mixed every step. + differential("task-fixture", &[], out, "caller.ts", 800); } /// Each construct outside the subset fails with its span, and nothing is written. @@ -888,8 +902,9 @@ fn stock() { String::from_utf8_lossy(&output.stderr) ); snapshot("stock", &out); - // A hand-written caller, which names inputs the generated ones never reach. + // A hand-written caller, which names the cases and inputs the generated ones never reach. std::fs::copy(fixture("stock").join("caller.ts"), out.join("caller.ts")).unwrap(); typecheck(&out); - differential("stock-fixture", &["--config", GETRANDOM], &out, "caller.ts", 5_000); + // As many random inputs per case as each had of 5,000 picked by their first byte. + differential("stock-fixture", &["--config", GETRANDOM], &out, "caller.ts", 320); } diff --git a/ts/tests/inputs.ts b/ts/tests/inputs.ts new file mode 100644 index 000000000..07a620947 --- /dev/null +++ b/ts/tests/inputs.ts @@ -0,0 +1,126 @@ +// The inputs `differential.ts` runs a harness on. A harness that names its cases draws +// each case's from a stream seeded by its name, so adding, removing, or reordering a case +// changes no other case's inputs. + +/** What a harness, or a hand-written caller wrapping one, exports to pick its inputs. */ +export interface Harness { + /** Each case's name and the bytes that pick it, if it has cases. */ + ops?: Map; + /** Whether its cases read full-width values, which each u64 boundary then runs through. */ + wide?: boolean; + /** Case `name`'s input from the bytes generated for it, if not its op then those bytes. */ + input?(name: string, tail: Uint8Array): Uint8Array; + /** Inputs it names, which run with the generated ones. */ + cases?: Uint8Array[]; +} + +const MASK = 0xffffffffffffffffn; +const SEED = 0x9e3779b97f4a7c15n; + +/** xorshift64*: the same bytes on every run from the same seed. */ +class Stream { + #state: bigint; + + constructor(seed: bigint) { + this.#state = seed; + } + + next(): bigint { + this.#state ^= this.#state >> 12n; + this.#state ^= (this.#state << 25n) & MASK; + this.#state ^= this.#state >> 27n; + return (this.#state * 0x2545f4914f6cdd1dn) & MASK; + } + + byte(): number { + return Number(this.next() & 0xffn); + } + + /** A byte biased toward the values codecs care about: zero, all ones, and boundaries. */ + interesting(): number { + switch (Number(this.next() % 8n)) { + case 0: + return 0; + case 1: + return 0xff; + case 2: + return [0x3f, 0x40, 0x7f, 0x80, 0xbf, 0xc0, 0xdf, 0xe0, 0xef, 0xf0, 0xf7, 0xf8, 0xfb, 0xfc, 0xfd, 0xfe][Number(this.next() % 16n)]; + default: + return this.byte(); + } + } + + /** Up to 47 interesting bytes. */ + tail(): Uint8Array { + return Uint8Array.from({ length: Number(this.next() % 48n) }, () => this.interesting()); + } +} + +/** A stream of `name`'s own: FNV-1a of its UTF-8, odd so that xorshift never sticks at zero. */ +function seeded(name: string): Stream { + let hash = 0xcbf29ce484222325n; + for (const b of new TextEncoder().encode(name)) hash = ((hash ^ BigInt(b)) * 0x100000001b3n) & MASK; + return new Stream(hash | 1n); +} + +/** A selector byte, then three of each u64 boundary 2^k - 1, 2^k, 2^k + 1, big-endian. */ +function boundaries(): Uint8Array[] { + const tails: Uint8Array[] = []; + for (let k = 0n; k <= 64n; k++) { + for (const delta of [-1n, 0n, 1n]) { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, ((1n << k) + delta) & MASK); + for (const selector of [0, 1, 2, 3, 4, 5, 6]) tails.push(Uint8Array.of(selector, ...bytes, ...bytes, ...bytes)); + } + } + return tails; +} + +/** + * Each case's generated inputs, by name: its op alone and with every short tail, so each + * path's edges are covered exhaustively, each boundary if its cases are `wide`, and + * `count` random tails from its own stream. + */ +export function byCase(harness: Harness, count: number): Map { + const ops = harness.ops; + if (!ops) throw new Error("the harness names no cases"); + if (new Set([...ops.values()].map((op) => Buffer.from(op).toString("hex"))).size !== ops.size) { + throw new Error("two ops are the same bytes, so one of them never runs"); + } + const wide = harness.wide ? boundaries() : []; + const inputs = new Map(); + for (const [name, op] of ops) { + const tails: Uint8Array[] = [new Uint8Array(0)]; + for (let b = 0; b < 256; b += 17) tails.push(Uint8Array.of(b)); + tails.push(...wide); + const stream = seeded(name); + for (let i = 0; i < count; i++) tails.push(stream.tail()); + inputs.set(name, tails.map((tail) => harness.input?.(name, tail) ?? Uint8Array.of(...op, ...tail))); + } + return inputs; +} + +/** + * Every input of a harness: of each case, by `byCase`, or of a harness without cases, + * every first byte with every short tail, each boundary through the first eight, and + * `count` random inputs; then the inputs it names. + */ +export function inputs(harness: Harness, count: number): Uint8Array[] { + let inputs: Uint8Array[] = [new Uint8Array(0)]; + if (harness.ops) { + inputs = inputs.concat(...byCase(harness, count).values()); + } else { + for (let op = 0; op < 256; op++) { + inputs.push(Uint8Array.of(op)); + for (let b = 0; b < 256; b += 17) inputs.push(Uint8Array.of(op, b)); + } + const wide = boundaries(); + for (let op = 0; op < 8; op++) for (const tail of wide) inputs.push(Uint8Array.of(op, ...tail)); + const stream = new Stream(SEED); + for (let i = 0; i < count; i++) { + const len = Number(stream.next() % 48n); + inputs.push(Uint8Array.of(stream.byte(), ...Array.from({ length: len }, () => stream.interesting()))); + } + } + return inputs.concat(harness.cases ?? []); +} diff --git a/ts/tests/stable.ts b/ts/tests/stable.ts new file mode 100644 index 000000000..7a0b9a772 --- /dev/null +++ b/ts/tests/stable.ts @@ -0,0 +1,50 @@ +// Each case's generated inputs are its own: adding, removing, or reordering a case +// changes no other case's, and a task step's scripts mix in only the drivers. +// +// usage: node stable.ts + +import { strict as assert } from "node:assert"; + +import { byCase } from "./inputs.ts"; +import * as task from "./task.ts"; + +const COUNT = 50; + +/** Cases picked by their place in `names`, as a harness's `op` picks them. */ +function placed(names: string[]): Map { + return new Map(names.map((name, i) => [name, Uint8Array.of(i >> 8, i & 0xff)])); +} + +const hex = (inputs: Uint8Array[]) => inputs.map((input) => Buffer.from(input).toString("hex")); + +const names = ["slices", "made", "buf::reads", "buf::writes", "maps::map"]; +const base = byCase({ ops: placed(names), wide: true }, COUNT); + +/** What follows each input's two-byte op. */ +const tails = (inputs: Uint8Array[]) => hex(inputs.map((input) => input.subarray(2))); + +// Added at the end, a case moves no other's op either, so every other input stays byte for byte. +const added = byCase({ ops: placed([...names, "buf::write_all"]), wide: true }, COUNT); +for (const name of names) assert.deepEqual(hex(added.get(name)!), hex(base.get(name)!), `adding a case changed ${name}'s inputs`); +for (const name of names) { + assert.notDeepEqual(tails(added.get("buf::write_all")!.slice(-COUNT)), tails(base.get(name)!.slice(-COUNT)), `a new case drew ${name}'s random inputs`); +} + +// Added first, removed, or reordered, a case moves the others' ops, but nothing after them. +for (const moved of [["buf::write_all", ...names], names.filter((name) => name !== "made"), [...names].reverse()]) { + const inputs = byCase({ ops: placed(moved), wide: true }, COUNT); + for (const name of moved.filter((name) => names.includes(name))) { + assert.deepEqual(tails(inputs.get(name)!), tails(base.get(name)!), `ordering cases as ${moved} changed ${name}'s inputs`); + } +} + +// A step's scripts run only it and the drivers, so no other step's existence or place reaches them. +const drivers = task.drivers.map((name) => task.ops.get(name)![0]); +for (const [name, scripts] of byCase(task, COUNT)) { + const own = task.ops.get(name)![0]; + for (const script of scripts) { + for (let i = 0; i < script.length; i += 2) { + assert(script[i] === own || drivers.includes(script[i]), `${name}'s script ${Buffer.from(script).toString("hex")} runs another step`); + } + } +} diff --git a/ts/tests/task.ts b/ts/tests/task.ts index f74703f7f..743d1b97b 100644 --- a/ts/tests/task.ts +++ b/ts/tests/task.ts @@ -214,7 +214,7 @@ const guard = (r: Run) => r.f.Guard.new(r.exec.tasks.length, r.f.Log.clone * Each step and its name, which a script's op picks modulo their count: `harness.rs`'s * `steps`, in the same order. */ -export const steps: [string, (r: Run, arg: number) => void][] = [ +const steps: [string, (r: Run, arg: number) => void][] = [ [ "open", (r) => { @@ -373,6 +373,25 @@ export function harness(f: Fixture): (input: Uint8Array) => Bytes { }; } +/** Each step's name and the byte that picks it. */ +export const ops = new Map(steps.map(([name], i): [string, Uint8Array] => [name, Uint8Array.of(i)])); + +/** The steps that drive what others spawn, the only ones a step's random scripts mix in. */ +export const drivers = ["open", "send", "close", "clone", "run", "step", "poll", "drop"]; + +/** + * Step `name`'s script from the bytes generated for it: the step with the first byte, then + * a step per pair, `name` for an odd first byte or else a driver, with the second. Mixing + * in only drivers keeps a step's scripts as they are when other steps are added. + */ +export function input(name: string, tail: Uint8Array): Uint8Array { + const picks: [string, number][] = [[name, tail[0] ?? 0]]; + for (let i = 1; i < tail.length; i += 2) { + picks.push([tail[i] & 1 ? name : drivers[(tail[i] >> 1) % drivers.length], tail[i + 1] ?? 0]); + } + return script(...picks); +} + /** A script of steps, each by name and with its argument. */ function script(...script: [string, number][]): Uint8Array { return Uint8Array.from(