ADR-0017 -- Anti-hollow test strategy (load-time smoke + property-based invariants)

Status: ACCEPTED · Decided: 2026-07-17
These are the actual decision records the game is built from, published unedited except for internal process notes. They describe why the game works the way it does. They are not a strategy guide, and some of them argue with each other.

Context

Two real failures on 2026-07-17 share one root cause -- tests that pass without exercising the thing they claim to protect (call it a "hollow" test):

  1. Hollow CI (fixed as #640). The gate reported GREEN while running ZERO tests: a cold Godot class cache made GUT quit(0) before parsing a single test. Nobody noticed for a while. The suite's pass was uncorrelated with the code's health.
  1. The parse-error-that-broke-the-game (fixed on feat/hiring-phase-b-pipeline, commit a2de3a7). A one-line GDScript parse error in scripts/ui/main_ui.gd made the ENTIRE script fail to load, so the game would not start -- yet 436 unit tests passed, because the fast unit suite exercises core / GameManager logic and never loads the UI script. The thing that broke (script load) was never touched by any test, so no test could fail.

Back in January, Pip's housemate (an ML expert) gave three pieces of advice that today vindicates:

This ADR credits that advice and turns it into standing practice.

What the pre-uplift suite can and cannot catch

CAN catch (well-covered):

CANNOT catch (the gaps this ADR closes):

Where hollow tests can still hide

Decision

Adopt three standing practices, and add the tests that embody them:

  1. A "nothing is hollow at load time" smoke test in the fast gate (tests/unit/test_smoke_load_all.gd). It load()s every project .gd under scripts/ and autoload/, instantiates every .tscn under scenes/, asserts every declared autoload singleton is present, and instantiates the main.tscn chain specifically. A parse/load error turns the gate RED and names the file. This is the direct guard against failure #2. It is cheap (scripts are already imported; instantiate() does not enter the SceneTree, so _ready() side effects stay dormant), so it stays in the required fast gate.
  1. Property-based invariant tests that assert over a deterministically-generated distribution of seeds, not one case: alive, finite/sane resources, at least one affordable action. distribution, (i) the engine is byte-identical across two runs of the same seed, (ii) a recorded replay reproduces the run's hash+score (ADR-0006, READ not weakened), (iii) save/load round-trips to a deep-equal state.
    • tests/unit/test_property_boot_invariants.gd (fast): for any seed (64 generated
      • 8 awkward edge seeds), a fresh GameState boots to an ACTIONABLE state --
    • tests/unit/simulation/test_property_determinism.gd (slow tier): over a seed
  1. Red-first discipline (write-the-test-before-the-code, item b + c). Every new invariant test in this ADR was watched FAIL with its target bug present before being accepted -- see the counterfactual table. New behavioural tests should likewise be demonstrated to fail against the unfixed code, at least once, in the PR that introduces them.

Counterfactual: which practice would have caught today's failures

FailureCaught byHow
#1 hollow CI (zero tests ran)already fixed in #640 (JUnit floor + manifest); this ADR's MIN_* floors extend the same "silence is failure" principle into individual testsa suite that runs nothing now fails; a walk that finds nothing now fails
#2 main_ui.gd parse errorsmoke test test_all_scripts_compileload("res://scripts/ui/main_ui.gd") returns null on parse error -> RED, naming the file

Demonstrated (red-first proof, all reverted after observation):

New testBug reintroducedResult
test_all_scripts_compileone-line parse error appended to main_ui.gdRED. Critically, 419/420 other fast-gate tests still PASSED -- reproducing "the game will not start yet the suite is green." Only the direct script-load() check caught it; scene-instantiation did NOT (a scene loads with a null script attached), which is why the smoke test loads every .gd directly rather than relying on instantiation.
boot-invariant sweepmoney = 0.0 in GameState.reset()RED across the ENTIRE seed distribution ("positive money" failed for every sampled seed).
determinism propertystate.money += Time.get_ticks_usec() % 13 in TurnManager.execute_turn()RED (byte-different final states between two runs of the same seed).
save/load propertydrop money on restore (restore_state)RED (round-trip deep-equal fails for the sampled seeds).

Beacons served / violated

Interaction contract

Rejected alternatives

Consequences / open questions