Skip to content

Testing

sswelm edited this page Aug 20, 2026 · 33 revisions

Auto-generated from the repo docs by tools/sync_wiki.sh — edit the source Markdown in the repo, not this wiki page.

Testing

The plugin has a focused unit-test suite (383 tests as of 2026-08-20) over the pure logic that can run outside the game — the registry/parse/era layer, the reflection compatibility report (GameBinding), and the in-game smoke harness's verdict (SmokeVerdict). It is a deliberate, bounded suite, not a coverage target: it guards the functions where bugs have actually hidden, and stops there on purpose.

dotnet test Tests/HumankindAssetFramework.Tests.csproj -c Release

The pre-push gate (Tools/check.sh)

The fast guards used to be separate scripts you had to remember to run. They're now one command per repo, wired as a pre-push hook so a push can't land a broken build, a failing test, or a drifted schema:

Repo the check.sh gate runs ~time
HumankindAssetFramework (plugin) dotnet build · dotnet test (383) · docs guard · registry schema parity seconds
ENCReload (editor) Roslyn editor compile-check · registry schema parity ~30 s

The docs guard (tools/check-docs.sh)

The docs publish three ways — the repo, the Pages site (which rewrites relative .md links via jekyll-relative-links), and the wiki (tools/sync_wiki.sh) — and all three resolve relative links. So one moved page breaks three surfaces at once, silently. The guard checks:

  1. every relative Markdown link resolves to a file that exists (anchors are not checked, only the path);
  2. every page in docs/notes/ opens with the ARCHIVED NOTE banner — the convention that makes the maintained-vs-archived split mean something rather than being a folder name;
  3. no basename collides across docs/ and docs/notes/ — the wiki page namespace is flat, so a collision would have one page silently overwrite the other.

Fault-injected on the day it was written (2026-08-20): a planted dead link, a banner-less note, and a planted docs/notes/Textures.md collision were each caught with a named failure, and the baseline returned to green.

Run it any time by hand: bash tools/check.sh. Enable the hook once per clone:

git config core.hooksPath tools/git-hooks

(Casing matters on case-sensitive filesystems: this repo's folder is lowercase tools/; ENCReload's is Tools/ — the mismatch has already eaten files once, commit db40e73.) The hook (tools/git-hooks/pre-push, version-controlled) then blocks a failing push; bypass only in a real emergency with git push --no-verify. Deliberately not in the gate (too slow / need Unity, Blender, or the game): the Blender golden-master deploy_regression.sh, the in-editor bake tests, and the in-game binding report — those stay manual. The in-editor tests all run from one windowTools ▸ HAF ▸ Bake Tests… (Smoke / Features / Conversion rows, each with a plain-language explanation, live per-row PASS/FAIL, and a durable Logs/haf_bake_tests_report.txt per run; see Factory-Manual.md §11). The gate earned its keep on day one: standing it up surfaced three latent schema drifts (a wrapper field the plugin read but the baker never wrote, two runtime-only keys, and a float?-cast the parity script mis-classified), all fixed to green.

Headless binding drift check (Tools/check-bindings.sh — for game updates)

A different trigger from the push gate: that guards HAF code changes; this guards game changes. After a Humankind update, run:

bash tools/check-bindings.sh [<…/Humankind_Data/Managed>]

The bindcheck tool (net8, System.Reflection.MetadataLoadContext) validates every GameBinding catalog binding against the build's assemblies without launching the game — it reads Patches/GameBinding.cs directly (always in sync, no manifest to stale) and inspects the game DLLs reflection-only (Unity's native deps don't matter). It prints bindcheck: N/N types | M member(s) missing and exits non-zero on any drift, so a game patch's binding breakage is named headlessly (CI-able on a version bump) instead of found by launching and reading haf_bindings_report.txt. Verified both ways: 50/50 clean on the pinned build, and it correctly flags an injected fake binding. It's the headless twin of the in-game report — same catalog, no game needed.

What it covers

Function Lives in What's asserted
ParseModels UniversalInjectPatch.cs JSON→ModelEntry mapping via the generic ToObject<ModelEntry>(); omitted keys fall to the shared HafModelSchema initializers (idleAltInterval 25, turretAxis -1, scale/brightness 1); the position Vector3 parses (Newtonsoft chokes on raw Vector3 — the strip-then-repin path is what's under test); signed GUID components; per-object isolation (an omitted field doesn't shift onto another model); robustness — garbage/empty input → empty without throwing; the regex fallback recovery when JObject.Parse rejects the document (keys entry count on Min(pawnDescription, skel, atlas))
ResolvePacks UniversalInjectPatch.cs duplicate-modId reject (first file kept); dependsOn/loadAfter ordering; missing-dep skip + transitive strand (fixpoint); cycle → file-order + note; soft loadAfter to an absent modId; stable seed order (the invariant that keeps today's single-pack setup byte-identical)
LongestMatch UniversalInjectPatch.cs most-specific substring wins (not first-in-order); single-match fallback; no-match → null
RegexStrArray UniversalInjectPatch.cs wrapper string-array extraction; empty-item filtering; missing field → empty
CoreDesc UniversalInject.Combat.cs trailing _NN variant-suffix strip
GuidToLong UniversalInject.Combat.cs null / non-numeric → 0; numeric string parses
EraFromName UniversalInject.ScaleEra.cs extract EraN (case-insensitive, multi-digit); none/null → −1
EraAnchorFor UniversalInject.ScaleEra.cs the Global Era Lab anchor rule — a unit stays at 1.0 unless an authored grid cell says otherwise (own-age-or-earlier → 1.0; later-but-unauthored → 1.0; non-positive eras clamp cleanly)
GameBinding.Validate / Cached Patches/GameBinding.cs the startup reflection compatibility report — resolves the catalog (~124 type + member bindings across the load-bearing injection path) incl. the simple-name (Type.Name) fallback scan, and writes a diffable haf_bindings_report.txt every launch; a game-update rename is reported (one [MISSING] line, headless-checkable), not silently absorbed. The report is self-validating: an added binding that isn't a real game member shows [MISSING] on the known-good build.
The four live dials Patches/DialConfig.cs haf_rotortrim / haf_turnease / haf_hugterrain / haf_battleturn — every known key, the shipped defaults (lookahead 3, ease 4, cliff 1 — not zero), the airhover legacy alias, the order-independent hoverbankbank fallback, the CSV name filters read before any numeric parse, CRLF, and one bad line never costing the rest of the file. Plus the reason the parse was extracted: every unrecognised line now yields a named problem (line number, the offending token, and the valid keys) instead of being silently dropped. See below.
The per-frame pose decisions Patches/PoseMath.cs which clip a pawn plays and where in it — the thing the player actually sees. The proximity-weighted state vote (PickState) and why it is not a headcount or a nearest-pick; the representative coming from the winning side; the attack window (first match, not nearest) and its unclamped repeats passes; the after-move / pre-move one-shots and the never-quite-1.0 clamp that stops a held frame wrapping to the folded pose; the nearest-fire match; the deploy ramp; the recoil sweep. And the invariant a tidy-up would break: the three match radii differ (state 4u, fire 4u, deploy 3u).
SmokeVerdict Patches/UniversalInject.SmokeTest.cs the in-game smoke harness's PASS/FAIL rule — PASS iff every catalogued binding resolved, zero injection errors, the registry loaded ≥1 model, the deep per-entry checks are clean, and the live seam write-back self-test did not FAIL (the boxed-struct chain every runtime offset uses — the combatZ died-in-the-box class, machine-caught since 2026-08-19); each fail reason surfaced; repointed-zero still passes but is NOTED (vacuous coverage announces itself), uninjected entries are named with a diagnosis, and the verdict is written to haf_smoke_report.txt next to the load/bindings reports

These map directly to the registry bugs this codebase has actually hit — the ParseGuidCsv sign bug, LongestMatch ambiguity, "wrapper-parse drops overrides", the substring pawn-match — so the suite is a regression net, not coverage theatre.

Extracting logic so it can be tested

Most of the plugin cannot be unit-tested: it is reflection against a live game inside Unity. But the decisions buried in that code usually can be, once they are lifted out of the method that does the I/O. SmokeVerdict was the first extraction of this shape; DialConfig (2026-08-20) is the second, and the pattern is now the standard move:

Find a method that mixes I/O, engine access and a decision. Move the decision to a pure static that takes plain data and returns plain data. Leave the I/O where it is. Test the pure half.

The dials are the clearest case. Four haf_*.txt files each inlined their own key=value loop inside a Poll* method, wedged between File.ReadAllText, UnityEngine.Time and live-pawn reflection — untestable, and all four shared one failure: any line the parser did not understand was continued away in silence. radus=6, hoverbanks=12, a European rate=1,5 — each produced a working plugin that quietly ignored the setting, with nothing in the log. That is the "silently disarmed" class the 07-31 audit was written about, sitting in the one part of HAF a user hand-edits mid-session.

The parse is now Patches/DialConfig.cs: text in, typed config + a list of problems out. The Poll* methods keep the I/O and log whatever problems come back, so a typo now names its own line number.

Guarding a refactor of shipped behaviour

Extracting live code risks changing it. Tests written after the extraction only pin what the code does now — they would pass just as happily over a subtly wrong parser. So two extra things were done, and both are worth repeating on the next extraction:

  1. A legacy parity oracle (Tests/DialLegacyParityTests.cs). The original inline loops are kept verbatim as oracles and compared against the new parser over a 39-case corpus — valid input, half-typed input, CRLF, comma decimals, repeated keys, stray @. Values must match exactly; diagnostics are excluded, since emitting them is the point of the change. It found and documents the one deliberate divergence: a line like @1=5 used to produce a trim with an empty bone name, and since name.IndexOf("") is 0 for every string, that silently rotated the first bone in the skeleton. It is now dropped with a message.

  2. A mutation drill. Six mutations were planted in the parser and the suite re-run. Five behaviour-changing ones were each caught (dropping the hoverbank fallback → 5 failures; only/skip falling through to the numeric parse → 6; lookahead default 3→0 → 39; re-silencing unknown keys → 4; re-accepting an empty bone name → 4; dropping malformed-line reporting → 4). The sixth — resolving the hoverbank fallback inline rather than after the file — passed, correctly: it is a genuinely equivalent implementation, not a defect. A mutation that does not fail the suite is either a gap or an equivalence, and you have to tell which; assuming "gap" would have added a test asserting an implementation detail.

  3. An in-game drill, because the two above are still only the suite grading itself. Six deliberately broken lines were planted across the live haf_*.txt dials — an unknown key, a comma decimal, a line with no =, a line with two, a transposed key, and a bone-less @1=5 — each chosen to be provably value-neutral, so the dials had to keep working while every fault got named. The log showed all six warnings with correct line numbers, values byte-identical to the pre-change run, reloaded 0 line(s) for the @1=5, and — the negative control that matters — zero warnings once the faults were removed, proving they fire on faults rather than on every poll.

    And the drill found a bug all 323 green tests had missed. The [Hug]/[TurnEase] echo lines used plain string interpolation, so on a comma-decimal machine the log printed lookahead=1,5 — the exact spelling the parser rejects, one line above the new warning saying use '.' for the decimal point. Copy a value out of the log back into the file and it silently dies. Fixed with DialConfig.Inv() and pinned by a round-trip property — whatever the log prints must parse straight back — asserted under nl-NL.

The rule this follows is the project's own: review, then drill. A suite that has never been shown to fail is not yet evidence of anything — and a suite that has never been checked against a real machine is not yet evidence of much either. The unit tests could not have found the locale bug: they are the code's opinion of itself, and both halves shared the same blind spot.

The second extraction, and what it taught about the guard rails (PoseMath, 2026-08-20)

The per-frame pose decisions went the same way — PickState, the attack/after/pre-move windows, the nearest-fire match, the deploy ramp and the recoil sweep, out of StatePose/DeployPoseTime/FireOncePoseTime and into the pure Patches/PoseMath.cs. Two findings worth carrying forward:

The oracle earns its keep on transcription, not on algorithms. Reading the two nearest-fire call sites had convinced me they were the same loop written twice. They are not: the recoil overlay seeded best with the radius (strictly inside), fire-once seeded with float.MaxValue and range-checked afterwards (inclusive), so they disagree for a fire at a distance of exactly 4.0. The corpus found it in seconds. Unified to strictly-inside — matching what the other two matchers already do — and recorded as the one deliberate behaviour change, with a named test.

A random corpus is the wrong instrument for an algorithm choice. The mutation drill replaced PickState's proximity weight with a constant (turning the vote into a headcount) and the oracle sailed straight past thousands of generated layouts. That is not a corpus-tuning problem: the two rules only disagree on small unbalanced in-range splits, and as the sample count rises the two majorities converge, so a bigger corpus fires less often. Widening the draw and enlarging the formations both failed to catch it; only an adversarial hand-written case does (one sample at the pawn's feet against two at the radius edge). Two tools, two jobs — a generated corpus pins that the code was copied faithfully, hand-written adversarial cases pin that it decides the right thing. Neither substitutes for the other, and a mutation drill is how you find out which one you are missing.

How it's wired

  • Framework: xUnit, net471 (matches the plugin), one test project Tests/HumankindAssetFramework.Tests.csproj.
  • Access: the tested helpers are internal, exposed to the test assembly via [InternalsVisibleTo] (Properties/AssemblyInfo.cs). A few were bumped private→internal purely for this; none were made public.
  • Plugin.Log: null outside the game, so each test class's ctor sets Plugin.Log = new ManualLogSource("test") (a listener-less source → every LogXxx is a safe no-op).
  • Dependencies: needs the same gitignored References\ DLLs as the plugin build; the test project mirrors them into its own bin so the plugin assembly's deps resolve at runtime. Tests\** is excluded from the plugin's compile globs so the xUnit files never leak into the plugin build.

What is deliberately NOT unit-tested — and why

This boundary is intentional. Adding tests past it would be green ceremony that guards nothing real.

  • The runtime/integration seam — inject, pose, muzzle, audio, districts, formations. These reflect into Amplitude types that only exist inside the running game process; they can't be loaded in a test host. Their correctness comes from fail-soft resilience (per-entry try/catch, null-guards), the rebuild → relaunch → verify-log discipline, the editor-side bake smoke/feature tests, and the in-game smoke harness — an F8-triggered runtime integration check (RunSmokeTest) that asserts the plugin came up and injected cleanly and logs one PASS/FAIL line. That's the right instrument for this half: a human loads a game, the harness does the checking. Its verdict logic is pure and unit-tested (SmokeVerdict, above); only the genuinely untestable part — gathering the live numbers via reflection — runs in-game.
  • ParseGuidCsv, MakeGuid, EmitterName — build/consume Amplitude types via reflection, absent in the test host.
  • FindEntryForUnitDefinition — delegates to the already-tested LongestMatch + CoreDesc; testing it would mean exposing the entries global as a test seam for ~zero new coverage.
  • Trivia (StrList, SanitizeFile, one-line accessors) — too trivial to regress meaningfully.

Adding a test

  1. If the target is private, bump it to internal (never public just for tests) — [InternalsVisibleTo] handles the rest.
  2. Only test pure logic (string/JSON/data in → data out). If it reflects into Amplitude/Unity, it belongs to the Phase-5 in-game seam, not here.
  3. Set Plugin.Log in the fixture if the code under test logs.
  4. Prefer tests that pin a real invariant or a historic bug, not line coverage.

See also: docs/Building.md (build/run), docs/Code-Map.md (where the tested functions live), docs/Framework-Review.md (the dated changelog of what each test batch added).

Clone this wiki locally