Testing¶
gorge has a large test suite (over 12,000 top-level Test functions on
2026-10-21). The full run happens in CI and after merge. Day to day you run
focused tests and learn to read the handful of ratchet and golden tests
that guard the whole engine. This page is for contributors writing or
debugging tests; setup and the worktree workflow are on
Contributing, and the reasons behind each guard on
Invariants.
Test names and paths re-checked on develop @ 513a2a0c3, 2026-10-21.
Focused runs¶
go test -run 'TestOmniscienceInHandGrantsNothing' ./engine/rules/
go test -run 'TestOracleRunnerContract' ./engine/rules/
go test -v -run '^TestHeads$/^4seats$' ./engine/rules/
- One package per invocation, with a
-runpattern. Do not usego test ./...(ormake test, which is the same with-p=2) as an inner loop. - On a shared machine, cap the run at the per-test budget:
systemd-run --user --scope -q -p MemoryMax=4G -p CPUQuota=400% env GOMAXPROCS=4 GOMEMLIMIT=3GiB go test -timeout 2m -run X ./pkg. - Never
-count=1. It defeats the test cache. Go re-runs a test whose inputs changed. The one input it cannot see is the corpus (it is outside the module), somake fetch-cardsclears the test cache whenever the pin moves. - Prove the corpus ran. Tests load cards through
testutil.CorpusRegistry(t)(testkit/testutil/decks.go), which callst.Skipwhen.cards/is missing. A package that printsokin about 2 ms has run almost nothing. Use-vand look forSKIPif in doubt. In a git worktree, link the main checkout's.cards/into it. - Load the corpus only through
testutil.CorpusRegistry, nevercards.LoadRegistry: the shared registry is opened once per test binary, while a fresh one costs hundreds of MB.
The per-test budget¶
Every single test must fit 4 GB RSS, 4 vCPU and 1 minute of wall time.
cmd/testbudget enforces it in the post-merge full suite
(go test -exec "testbudget exec", then testbudget check). It names every test over 60 s and every
test binary whose peak RSS is over 4 GiB, and fails the batch on any not
already listed in the shrink-only files
testkit/testutil/testdata/wall_exceptions.txt and rss_exceptions.txt.
Fix an offender by splitting it into chunk tests that share fixtures. Never
cut its coverage, and never add a row.
Writing a test¶
Engine tests live in the package they test (package rules), so they can use
unexported helpers. The common ones (handEngine, card, corpusCard,
onBoard, onBoardCard, castAndResolve, passUntil) are listed with
their locations in
Your first card fix.
- Name cards; do not embed scripts. Use corpus cards by name, or a
minimal synthetic script you wrote (
card(t, "Name:Mountain\nTypes:Basic Land Mountain\nOracle:x\n")). Forge's script text is GPL-3.0 and never enters the repository. - Fixtures may set state directly (
e.G.AddObject,e.G.SetZone) to build a position. Engine code may not. Once the game is running, move things withe.emit(events.Event{...})so the log stays truthful. - Assert a control. Show the card still works where it should.
t.Parallel()unless the test mutates package state.
Oracle scenarios¶
An oracle scenario is a JSON file that states what a card does according to
its printed text and the CR; gorge's runner (engine/rules/oracle_run.go)
sets up a real game, drives it through player actions only, and asserts
observable state. The scenario corpora (3,227 hand-authored audit
scenarios in 1,001 files and 543 XMage-ported ones, counted at
gorge-compliance ae5a17f, 2026-10-21) live in
gorge-compliance
(scenarios/oracle/, scenarios/xmageport/), whose audit/ drivers
TestOracleAudit and TestXMagePortScenarios play them against a pinned
gorge. Run one card there with make oracle-audit ORACLE_RUN=<Card_Name>.
gorge keeps the runner and the few scenario files its own card tests load
(engine/rules/testdata/oracle/<family>/); TestOracleRunnerContract
(engine/rules/oracle_contract_test.go) plays them through the exported API
gorge-compliance uses, so a runner change that would break it fails here.
Two per-card ratchets sit beside the scenarios in gorge-compliance:
<family>/known-divergent/<slug>.json: scenarios known to fail. A listed scenario that now passes is stale and fails the build until you delete its row. An unlisted failure fails the build.<family>/known-unconsumed/<slug>.json: scenarios with a scripted answer no decision used. Rows are only ever deleted.
gorge-compliance's TestOracleScenarioFilesWellFormed checks the schema and
needs no corpus; in gorge, TestOracleScenarioShapeCheck does the same for
the residual files.
Ratchets and goldens: what a failure means¶
A ratchet is a test whose expected value is a committed table, and which fails in both directions. A new gap is a regression. A listed gap that your change closed is a stale entry, and you delete it in the same commit. A branch never adds an entry or raises a ceiling. The maintainers' merge gate rejects that.
| Test | Where | When it fails |
|---|---|---|
TestHeads |
engine/rules/heads_test.go; goldens in engine/rules/testdata/heads/<seats>.txt |
The deterministic bot games over the 12 Legacy repo decks, at 2/4/6/8 seats, finished with a different hash-chain head. Your change altered what happens in those games. If intended, re-pin with GORGE_UPDATE_HEADS=1 go test ./engine/rules/ -run '^TestHeads$' and name the first diverging event and its cause in the commit message (go doc ./cmd/headdiff shows how to find it). If not intended, you have a bug, often nondeterminism. |
TestRepoDeckGamesReplayExactly |
engine/rules/acceptance_test.go |
A game replayed from its log diverged from the live run: something mutated state outside events.Apply, or something nondeterministic reached an event. |
TestEveryRepoDeckIsFullySupported |
engine/rules/acceptance_test.go; table in engine/rules/testdata/known-unsupported/ (one JSON file per card) |
A card in the repo decks (testkit/testutil/decks/*.json) needs a primitive this build does not register (a regression: fix it), or a listed card is now fully supported (delete its file). |
TestEveryRepoDeckParamsAreRead |
engine/rules/paramcensus_test.go; table in engine/rules/testdata/paramcensus/ |
The same, for script parameters no code reads and cost tokens no code models. |
TestValueHeadRegistryMatchesEvaluator |
engine/rules/value_heads_test.go |
A count: value head was added to the evaluator but not to modelledValueHeads in engine/effects/value_heads.go, or the reverse. |
internal/archtest (TestDependencyOrderHolds, TestTimeIsImportedOnlyByTheHost, TestRootModuleHasNoRequires, the layering_* tests, …) |
internal/archtest/ |
You added a forbidden import edge (for example effects → rules, view → rules), imported time outside the host tier and its allowlist, or added a require to go.mod. Restructure the code; do not edit the allowlists. |
TestClassificationIsComplete |
cmd/pkgmap/main_test.go |
A package has no tier entry in docs/site/architecture/packages.tiers.json. Also run by the pre-commit hook (go run ./cmd/pkgmap -check). |
TestKnownApproximationsOnlyShrinks |
testkit/testutil/agentsdoc_test.go |
AGENTS.md's Known approximations table grew. It only shrinks. |
internal/codeshape (TestCodeShapeOnlyShrinks, TestLongFunctionsOnlyShrink) |
internal/codeshape/ratchet_test.go |
Shrink-only ceilings on function length, interface width and string-keyed dispatch in engine/rules and engine/effects (go run ./cmd/codeshape -table measures them). Both tests are currently skipped by the maintainers (since 2026-10-06), but the ceilings still describe the direction: do not grow a function past 300 lines. |
TestNoForgeScriptsTracked |
engine/cards/boundary_test.go |
A Forge script is tracked in git. Remove it. |
Before you open a PR¶
The checklist is on
Contributing:
gofmt, go vet on the touched packages, go run ./cmd/gentypes -check if
protocol types moved, focused tests with the corpus present, make sim
(20/20 replay OK), and TestHeads.