3,745 words · 2 diagrams
What to test here, which style to reach for, and how to write an assertion that means something.
Tests live beside the code and run in plain Node. That is not a convenience — it is the check that the core stays free of DOM, React and WebGL. Nothing registers a browser environment.
That now includes the client.
vitest.config.tscoversapps/*as well aspackages/*, andapps/game/src/engine/gameEngine.test.tsdrives the real frame loop, lens resolution, observatory framing, save/load and derived-state invalidation under Node — becauseGameEnginetakes its worker factory and save store as arguments instead of constructing a browserWorkerand IndexedDB itself. The dedicated terrain-descent slow test runs throughpnpm test:slow, alongside galaxy convergence and population checks; both regular and slow suites are inpnpm check.
The regular suite runs at most four files concurrently, or fewer when the host
has fewer available cores. This bounds contention between procedural
generation and CPU rendering tests while preserving the 20-second timeout.
Use VITEST_MAX_WORKERS=2 pnpm check for a more constrained verification run;
do not raise timeouts before investigating contention.
Verify the committed change
Commit each coherent step before lengthy verification, as described in the working guide. When another task's untracked files make a repository-wide check fail, preserve them. Verify the committed change in a temporary worktree instead of formatting, hiding or deleting someone else's work:
git worktree add --detach /tmp/inertialref-verify HEAD
cd /tmp/inertialref-verify
pnpm install --frozen-lockfile --prefer-offline
pnpm checkUse an unused temporary path and remove only your own verification worktree
when finished. Report the shared checkout's failure and the isolated result
separately. The isolated tree must contain the same changes being claimed as
verified. Fresh worktrees still need the locked install; --prefer-offline
can require network access when the local store lacks a package.
Choosing a style
flowchart TB
Q{"what are you testing?"}
Q -->|"a mathematical relationship"| PROP["<b>property test</b><br/>round trips, invariants, ordering"]
Q -->|"a specific known value"| GOLD["<b>golden vector</b><br/>PRNG output, wire formats"]
Q -->|"determinism"| HASH["<b>state hash equality</b>"]
Q -->|"physics"| ANA["<b>compare to the analytic result</b>"]
Q -->|"a boundary or a failure"| EX["<b>example test</b><br/>malformed input, refusals"]
style PROP fill:#0369a1,stroke:#0c4a6e,color:#fff
style HASH fill:#0369a1,stroke:#0c4a6e,color:#fffSeveral real bugs in this repository were found by a property test and would
not have been found by an example. Reach for fast-check whenever the thing
under test is mathematical.
The five patterns
1. Property tests
it('round-trips translate/difference (property)', () => {
fc.assert(
fc.property(anyPosition, anyDisplacement, (uv, d) => {
const back = UV.difference(UV.translate(uv, d), uv)
// bounded by the representation's ULP, not by the absolute magnitude
expect(Math.abs(back.x - d.x)).toBeLessThanOrEqual(
UV.POSITION_RESOLUTION * 4,
)
}),
)
})Good candidates: coordinate round trips, frame re-expression, quaternion composition, orbital element recovery, address parse/format, wire codecs, cube-sphere direction mapping.
A flaky property test is usually the code telling you something. The depth- compression monotonicity test failed intermittently because the mapping really is only non-decreasing past 1e17 m. The fix was to state the true property in two tests, not to loosen the tolerance.
2. Golden vectors
expect(formatSeed(ROOT)).toBe('0df87e57180611d601f6e442eb5fc374')Not testing that the value is right — any stream would do. Testing that it never changes, because a silent change regenerates every player's universe. Changing one is deliberate and comes with an algorithm version bump in the same commit.
3. State-hash equality
The canonical comparison for anything about determinism:
const jittery = build(),
steady = build()
while (jittery.clock.tick < 512) jittery.advance(randomFrameTime())
steady.runTicks(jittery.clock.tick)
expect(jittery.stateHash()).toBe(steady.stateHash())4. Compare to the analytic result
const fallen = radius - now
const predicted = 0.5 * gravity * seconds * seconds
expect(Math.abs(fallen - predicted) / predicted).toBeLessThan(0.02)Not "the number went down". Capability check 5 once passed while reporting
"fell from 57287 km to 57287 km" — the ship had fallen 19 m out of 57,000 km
and the assertion was now <= start, which equality satisfies.
A test that cannot fail informatively converts an unknown into a false assurance.
5. Example tests for boundaries
Malformed JSON, a save from a newer schema, a frame that cannot be rebuilt, an unknown worker task, a version mismatch. These are the paths where behavior is a decision (refuse? default? migrate?) and the test documents the decision.
Writing an honest bound
When a tolerance is loose because of a real limit, name the limit:
// says where the number came from
expect(error).toBeLessThan(UV.POSITION_RESOLUTION * 2)
// says nothing; will be "fixed" by loosening when it fails
expect(value).toBeCloseTo(expected, 3)The first survives a refactor with its meaning intact. The second is a magic number that the next person will adjust rather than investigate.
Related: prefer relative comparisons when magnitudes span orders of
magnitude. expect(Math.abs(a / b - 1)).toBeLessThan(1e-9) means something at
both 1e-3 and 1e16; an absolute epsilon does not.
A wall-clock bound has one honest job: catching a collapse. The catalog's
search bound allows half a second for six queries that cost 1.9 ms, and the
margin is the whole usable range rather than generosity — the same queries cost
an order of magnitude more under vitest than under bare Node, and anything close
enough to the real figure to be interesting is measuring how busy the machine
is. It catches search decoding the catalog per query or going quadratic. It
does not catch the scan it was written against, and no bound can: a naive
scan of 7,123 stars is 2.9 ms against the index's 1.9, so the index buys a
factor of one and a half. Say what a clock assertion cannot see, in the
assertion's own comment.
Prove a regression test can fail
Before keeping a regression test, temporarily reintroduce the defect and watch that test fail for the intended reason. Then restore the fix and watch it pass. Three ways a test has failed that check here, each different:
- The assertion did not distinguish the cases. A terrain-normal regression asserted only that normals were unit length; a radial normal is also unit length, so it passed both before and after the bug it claimed to guard.
- The bound was looser than the claim beside it. A shape-generator property
test asserted that a body could not exceed
exp(0.6)— 1.82 times — its published extent, while its own comment said "30% larger would be inventing a size". The measured inflation topped out at 1.7, so the assertion could not fail for any input in its own range and the 37% volume error it was written to catch sailed through. - The runtime was not the one with the bug. A module cycle threw
ReferenceError: Cannot access 'X' before initializationunder native Node ESM. Written as animport()inside a vitest test it passed with the defect deliberately reintroduced, because vitest evaluates modules through its own runner and does not enforce the temporal dead zone Node's linker does. The working version spawns a Node process per entry point fromapps/headless. If a bug lives in a boundary — a loader, a bundler, a host — the test has to cross that boundary too.
A timeout is a guard against a hang, not a performance budget
testTimeout is 20 s in vitest.config.ts, not vitest's default 5, and the
reason is written there. Several tests legitimately take a second or more of
pure CPU — a 129-body Solar System stepped for thousands of ticks, a fast-check
property over a quarter of a million noise samples — and the runner puts
eighty-odd files across every core at once. At 5 s the suite failed
intermittently, and the tests it killed were mostly not the ones that had
grown: an Rng uniformity property, an atmosphere sweep, the catalog's own
search bound, all pre-existing and green standalone.
The diagnosis is worth remembering because the symptom points somewhere else
entirely: when unrelated tests start failing together and pass on a re-run, the
timeout has stopped measuring the code and started measuring how busy the
machine is. A test that spawns processes is the usual culprit — one here began
as ten parallel node invocations under it.each and starved everything.
The other culprit is a browser you started yourself and left running.
scripts/drive.mjs leaves Chrome up between invocations on purpose — boot is
about five seconds and attaching is under one — so a session that has been
verifying in the browser is a session running a GPU process, up to eight worker
threads and a Vite dev server beside the suite. Measured here across two consecutive full runs with
that rig up: one file timed out each time, and it was a different file each
time — a terrain descent once, a crater-field property the next — with both
passing clean the moment pnpm drive --down had run. Neither was a regression
and both looked exactly like one. --down before the gate, and treat a single
timeout that moves between runs as a machine reading rather than a code one.
If contention remains after closing your own rig, rerun the failing files with fewer workers before changing a test or its timeout:
pnpm vitest run packages/universe/src/geology.test.ts apps/game/src/engine/terrainStreamer.test.ts --maxWorkers 2
VITEST_MAX_WORKERS=2 pnpm checkVitest reads VITEST_MAX_WORKERS for both test projects, so the second command
keeps the full gate and every assertion while limiting concurrency. A green
focused rerun supports a contention diagnosis; it does not replace the full
run. Keep performance measurements separate from a contended gate.
A test that needs more says so at the call site.
gameEngine.descent.slow.test.ts streams a landing on Proxima Centauri b
through the CPU source. Its disk archive can skip repeated heightfield
generation, but a cold run still pays the field serially through an inline
worker. The five-minute timeout guards the cold path under contention; a warm
run does not justify lowering it. A shared beforeAll performs the descent,
and four assertions inspect its terrain, geometry and contact behavior.
The descent and galaxy count use the slow suite. *.slow.test.ts is a second vitest
project — apps/game/vitest.slow.config.ts, behind pnpm test:slow — selected
by suffix the way the GPU suite is and excluded from the root config for the
opposite reason: it makes the same plain-Node claim the root suite does, while
cold terrain generation can dominate that suite's wall time. The Stop gate
runs the root suite after every turn; pnpm check and CI run both, which is
where "the ship
lands on the ground it drew" has to be proved before a merge. Anything else
that streams a landing takes the suffix. A change under
engine/terrainStreamer.ts or the terrain path is therefore proved by
pnpm check and CI rather than by the Stop gate, and pnpm test:slow by hand
is how to prove it sooner. Test speed carries the dated
measurements and remaining cold-run work.
galaxy/field.slow.test.ts also takes the suffix. Its count-convergence check
samples 9,953,280 positions across two quadratures: 8.4 s in isolation locally,
but beyond the regular suite’s 20 s timeout on CI. It keeps both grids and
all count assertions, with a two-minute timeout at the call site.
The figure moves with the field and cache state. Every level the detail floor gains requests another ring of heightfields. A cold CPU descent pays their generation serially; a warm descent validates and reads retained records. Report which path the measured run took.
Check a distribution when the claim is about one
irregularFigure claims its numbers come from twenty-five measured shape
models. That is a claim about a population, and a test that generated one body
and looked at it could not evaluate it. The test generates eighty across the
parameter range and asserts the median and both tails — which is how the
generator was caught delivering 0.03 when asked for 0.18, because an fBm's
standard deviation is a sixth of its range and nothing divided it out.
The same shape appears wherever a generator has a statistical claim: the spin
barrier (nothing may rotate faster than sqrt(3π/Gρ)), the retrograde fraction,
the size ladder. Each is one assertion over a population and none of them is
expressible about a single draw.
Compare derived quantities, not stored ones
apps/headless/src/solarSystem.test.ts checks the engine's Solar System against
a JPL reference, and the strongest assertions in it are the ones about numbers
the engine does not store. It has no orbital period field — it computes one
from G(M+m) and the semi-major axis — and no surface gravity or escape velocity
at all. Matching JPL's published period to four figures says the axis is right,
the star's mass is right, the body's mass is right and orbitalPeriod is right,
in one assertion, because there is no way for two of those to be wrong and still
produce it.
It also converts units in only one direction: the reference keeps the kilometers and days JPL publishes, and the test does the arithmetic, so a check and the thing it checks cannot share a factor of 86,400 and agree with each other about it.
Testing across the boundary
Two mechanisms make otherwise-hard things testable in Node:
flowchart LR
subgraph PORTS["ports"]
WP["WorkerPort → in-process fake"]
SP["SaveStore → in-memory store"]
end
subgraph RESULT["what it buys"]
R1["the pool has real tests<br/>with no worker environment"]
R2["persistence has real tests<br/>with no IndexedDB"]
end
WP --> R1
SP --> R2The inline worker is not a mock — it runs the real host loop through the real envelopes, so a value that is not structured-cloneable still fails.
Shader behavior runs on the real GPU, from Node
A TSL node graph is compiled and run on the physical GPU by pnpm test:gpu,
without a browser. webgpu on npm is Dawn — the WebGPU implementation Chrome
ships — built as a Node addon; apps/game/src/render/gpuSetup.ts installs
the three globals three/webgpu reads at import time, and
apps/game/src/render/gpuHarness.ts hands a test a WebGPURenderer and five
verbs: compile, shader (the generated WGSL), drawGraph and draw
(pixels back from a render target), and compute with readBuffer (a
storage buffer back from a kernel). The suite is *.gpu.test.ts, its config
is apps/game/vitest.gpu.config.ts. It covers material compilation, WGSL
structure, pixel and bake readback, terrain agreement, star projection and dust,
physical galaxy light, temporal history and sensor behavior.
Name a focused file when investigating one mechanism:
pnpm vitest run --config apps/game/vitest.gpu.config.ts materials.gpu.
Suite size and wall time change as coverage grows, and cost depends on the
adapter and selected tests. The early eight-file, 41-test suite took about
18 seconds on an M5; that is a historical measurement, not the current suite's
budget. The config explains why the physical-GPU suite sits outside
pnpm check; the roadmap carries the open
hosted-adapter question. Two limits travel with every answer the suite gives:
Dawn tracks its own release while Chrome ships its own, so a graph that
compiles under one and not the other is possible, which is why the browser
rig stays the arbiter; and navigator.gpu is undefined on about:blank and
data: URLs because isSecureContext is false there, so a browser-side probe
has to be served from localhost or a real origin to mean anything.
It is a separate command rather than part of pnpm test because it makes a
different portability claim: the rest of the suite runs on any Node, and this
needs a Metal, Vulkan or D3D adapter. It is not in pnpm check for the same
reason. Run it during shader work, and before shipping any change under
render/.
A sandbox can prevent Dawn from finding the physical adapter even when this host has one. An adapter-initialization failure is not shader evidence. Run the same focused command with adapter access before changing rendering code; an unavailable or skipped GPU does not count as a passing shader check.
Lifecycle and pixel behavior need different observations. Deferred source jobs expose replacement, cancellation and recovery without drawing. A recording renderer exposes warm targets, state restoration and disposal. Keep a real GPU integration test alongside those checks to compile the actual graph and read its output; a fake renderer cannot detect a black bake or an invalid pipeline. Browser checks cover the host boundary, including R3F remounts and agreement between the actual canvas and output encoder.
Do not write a scalar mirror of a shader and test that instead. That rule
matters more now, not less: the mirror can pass while the graph it claims to
describe drifts, and the remedy is to test the graph itself.
terrainKernel.gpu.test.ts is the whole of it: the GPU tile producer's kernel
against generateHeightfield on every zoo body and three Sol bodies at seven
levels each, held to a bound stated as arithmetic about the kernel's two
limits, and terrainBands.gpu.test.ts holds each of the eight bands to its own
figure so a red whole names its band. terrainKernels.gpu.test.ts is the
seed those grew from — a TSL port of faceToDirection
and of the pcg3d lattice hash, each run on the GPU and compared with the CPU
function, the float one to a named f32 bound and the integer one bit for bit.
Four things the harness owns, because each cost a round trip to learn:
- A readback is padded.
readRenderTargetPixelsAsyncreturns the mapped staging buffer whole, with every row aligned to 256 bytes — an 8-wide RGBA8 target reads back with its second row at element 256.Pixelsunpacks it, and puts row 0 at the top, which is where WebGPU's texture origin is. - A pipeline that will not build does not reject. A draw builds it inside
the backend's own validation scope and reports through three's console
sink;
compileAsyncbuilds it withcreateRenderPipelineAsync, whose rejection carries the failure, and the backend discards that rejection. The harness brackets every verb in a validation scope of its own and listens on the sink, so a shader Tint refuses is a red test with the compiler's message in it. - A compute kernel must guard its own index. A compute node dispatches whole workgroups of 64, and WGSL clamps an out-of-range write onto the last element, so a 1,547-cell kernel runs 1,600 times and the last cell holds whichever excess invocation ran last. A count that happens to divide by 64 never shows it.
- A stand-in texture must be filtered like the map it stands in for.
DataTexturedefaults to nearest, the WGSL builder reads a nearest texture withtextureLoadand no sampler, and the gradient sample has no such path — so the ground's stand-in referenced a sampler that was never declared, and every mapless body's ground was a black frame.materials.gpu.test.tsholds each stand-in and a real map to the same program.
A headless GPU check is still not a real one. Nothing here observes
presentation: one renderer failure reproduced only at devicePixelRatio 2,
terrain selection is measured in display pixels, and a strobe is a property of
what the compositor presented. Rendering work still ends at the browser
procedure in Driving. The two layers are ordered, not
alternative — the fast one answers whether a graph is valid and correct, the
browser answers whether a frame is right.
Reconstruction and persistent terrain
The reconstruction policy and preference migration run in Node. Actual shader behavior belongs to the physical-GPU gate:
pnpm vitest run apps/game/src/render/picture.test.ts apps/game/src/state/preferences.test.ts apps/game/src/hud/hud.test.ts
pnpm vitest run --config apps/game/vitest.gpu.config.ts upscale.gpu radianceReactivity.gpuThe GPU fixtures exercise constant HDR preservation, repeated dispatch, attachment warm-up, resize, jitter cleanup after a throw, reactive coverage, rigid motion across an origin rebase and distant reversed depth. They do not prove compositor behavior or the appearance of a moving camera. Browser acceptance uses generated worlds outside Sol, with native, spatial, bilinear and temporal pictures at DPR 1 and 2. Hold the camera, photographic instant, surface settings and display size fixed. Let temporal history converge and record its phase; compare two boots of the same build before interpreting a plate's error. Also verify that changing anti-aliasing rebuilds the sensor without announcing a new renderer.
Persistent terrain tests cover complete key separation, corrupt payloads, cancellation, storage failures, bounded eviction and reopen. The external-world replay test reopens a real SQLite store, serves the same Proxima Centauri b requests without another generator call, and holds the world's state hash unchanged.
pnpm vitest run packages/workers/src/heightfieldCache.test.ts apps/headless/src/heightfieldStore.test.ts apps/headless/src/terrainCacheBaseline.test.ts apps/game/src/engine/terrainStore.test.ts
pnpm sim --ticks 0 --terrain-cache-clear --terrain-cache-descent 'g:milky-way/s:HIP70890/b:0'
pnpm sim --ticks 0 --terrain-cache-descent 'g:milky-way/s:HIP70890/b:0'
pnpm sim --ticks 0 --terrain-cache --terrain-baseline
IR_TERRAIN_CACHE_REPORT=1 pnpm vitest run --config apps/game/vitest.slow.config.ts gameEngine.descent.slowThe first two simulation commands are a cold/warm pair over the same requested
regions; the third measures the zoo through the source. The disk store is
.data/heightfields/tiles.sqlite. Cache hits avoid field generation, so retain
direct generator tests. A drawn-only arithmetic change spends a version of
generateHeightfieldTask, which heightfieldFingerprint.test.ts enforces by
failing until it is spent. A warm descent cannot prove a generator
implementation it did not execute.
Browser retention needs a real IndexedDB check as well. After a generated-world
visit, inspect await engine.terrainCache(), reload through the driver with
--keep-storage, revisit the same stance and compare hits and pending writes.
await engine.clearTerrainCache() clears the archive independently of saves.
Run storage-denied and quota-failure fixtures to prove regeneration still
completes. Measure cold and warm runs separately, with the browser closed during
CPU benchmarks. ADR-0045
states the invalidation contract.
The pictures are a fixture
A preset is an address, a framing, a lens and a held instant, so it is the one frame the renderer can be asked for twice. The thirteen shipped pictures are therefore the regression fixture for anything visible, in two halves.
pnpm presets:compare is the browser half. It serves the tree on a port of its
own, opens each picture's public URL with the chrome cleared and standard
output, waits until the sky and the ground have stopped arriving, and
differences the frame against the committed plate — the frame a reviewer last
accepted — printing the pixels over a 3% per-pixel threshold, the peak, and a
verdict against a floor measured from two captures of one build. By default it
compares the lit pictures only: a frame that is mostly sky or shadow compares
its noise, so a plate has to be a fifth lit and the ledger's verdict has to
agree, and the dark ones are named as skipped (--all compares them).
--base <ref> serves that commit from a worktree of its own and photographs
it instead, for plates known to be behind or for a same-machine control;
--twice photographs the tree a second time and is the rig against itself.
Each result has a
reference | tree | difference pair under .data/presets/compare/, which is
what a pull request attaches. A move is accepted with pnpm presets:plates <id>.
apps/headless/src/pictureLedger.test.ts is the Node half, in pnpm test.
It takes the thirteen against the real catalog and snapshots what the camera
decided — position, lens, altitude, a digest of the body, how lit the view is
(the sun's elevation over a stance, the phase angle from orbit), and for a
stance on the ground the terrain the plate's 480×320 asks the streamer for —
into pictureLedger.json. Five of the pictures stand on generated bodies outside
Sol, so a change to the procedural terrain or the detail floor moves a number
there before a plate is taken. It also holds the world's state hash across all
thirteen, which is rule 37 executed rather than described.
pnpm presets:ledger rewrites the snapshot after a deliberate move.
The capability checks
Twelve executable assertions about the architecture, in
packages/devtools/src/capabilities.ts. They run in the test suite, in
pnpm sim --self-test, and in the browser via ir.selfTest().
They differ from unit tests in two ways:
- They run against a whole assembled world, not a unit.
- They report measurements, so a passing run is still informative.
Add one when a new claim about the architecture becomes something you would otherwise assert in prose.
What is not covered yet
| Gap | Roadmap |
|---|---|
| A fixture captured from a released build, for real compatibility testing (the v0 shape is covered, but from an inline literal) | roadmap |
| Recorded input replay | roadmap |
| Performance regression benchmarks | roadmap |
Shader behavior in CI — pnpm test:gpu needs a physical adapter, and whether a hosted macOS runner gives Dawn one is unmeasured |
roadmap |
Running them
pnpm test # regular Node suite
pnpm test:slow # terrain descent and galaxy calibration/population
pnpm test:gpu # the shader suite, on the real GPU — not in check
pnpm test:coverage # the same suite, plus coverage/coverage-final.json
pnpm vitest run world.test # one file
# One GPU file. The root config excludes the suffix, so the plain form above
# answers "No test files found" for anything named *.gpu.test.ts.
pnpm vitest run --config apps/game/vitest.gpu.config.ts materials.gpu
pnpm vitest # watch
pnpm check # every stage as one graph; --only gate is the Stop hook's fourRelated
- Observability — the structures tests assert on
- Determinism — what the golden vectors protect
- AGENTS.md — the rules a test is defending
- Agent handbook