-
Notifications
You must be signed in to change notification settings - Fork 0
Testing
Auto-generated from the repo docs by
tools/sync_wiki.sh— edit the source Markdown in the repo, not this wiki page.
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 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 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:
- every relative Markdown link resolves to a file that exists (anchors are not checked, only the path);
-
every page in
docs/notes/opens with theARCHIVED NOTEbanner — the convention that makes the maintained-vs-archived split mean something rather than being a folder name; -
no basename collides across
docs/anddocs/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.
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.
| 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. |
| 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.
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.
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:
-
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=5used to produce a trim with an empty bone name, and sincename.IndexOf("")is0for every string, that silently rotated the first bone in the skeleton. It is now dropped with a message. -
A mutation drill. Six mutations were planted in the parser and the suite re-run. Five behaviour-changing ones were each caught (dropping the
hoverbankfallback → 5 failures;only/skipfalling through to the numeric parse → 6;lookaheaddefault 3→0 → 39; re-silencing unknown keys → 4; re-accepting an empty bone name → 4; dropping malformed-line reporting → 4). The sixth — resolving thehoverbankfallback 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. -
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_*.txtdials — 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 printedlookahead=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 withDialConfig.Inv()and pinned by a round-trip property — whatever the log prints must parse straight back — asserted undernl-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 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.
-
Framework: xUnit,
net471(matches the plugin), one test projectTests/HumankindAssetFramework.Tests.csproj. -
Access: the tested helpers are
internal, exposed to the test assembly via[InternalsVisibleTo](Properties/AssemblyInfo.cs). A few were bumpedprivate→internalpurely for this; none were madepublic. -
Plugin.Log: null outside the game, so each test class's ctor setsPlugin.Log = new ManualLogSource("test")(a listener-less source → everyLogXxxis 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.
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-testedLongestMatch+CoreDesc; testing it would mean exposing theentriesglobal as a test seam for ~zero new coverage. -
Trivia (
StrList,SanitizeFile, one-line accessors) — too trivial to regress meaningfully.
- If the target is
private, bump it tointernal(neverpublicjust for tests) —[InternalsVisibleTo]handles the rest. - 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.
- Set
Plugin.Login the fixture if the code under test logs. - 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).
Get started
- Getting Started
- Installation
- Troubleshooting
- Authoring State and Deployment
- Mod Editor version.xml Recovery
- Building
- Backup
Author models and behavior
- Editor Tools
- Factory Manual
- Vehicle Lab Quickstart
- Animated Models
- Animation Pitfalls
- Textures
- Unit Size
- Unit Combat Behavior
- Formations
- Pawn Props
- Projectiles
- Game Sound Lab
- Firing on Attack
- Turn Ease
- Facing Persistence
- Donor Clip Flight
Districts and wonders
Ship and operate
Internals and project