Skip to content

Testing

sswelm edited this page Aug 20, 2026 · 47 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 (329 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 (329) · 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 window — Tools ▸ 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 air→hover legacy alias, the order-independent hoverbank→bank 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.
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.

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