08 — Testing and Quality#
Who this is for. Anyone running the suite, adding a test, or trying to work out why a build that "should" be green is not.
What you'll learn. The exact mechanics of running this suite (they are not the .NET defaults); why parallelism is disabled and must stay that way; the non-SDK project-membership guard; the production gate and what it actually asserts; the encoding rules; and the worktree conventions that keep concurrent sessions from corrupting each other's work.
8.1 Suite mechanics — read this before you run anything#
There is no dotnet test here. Both projects are classic non-SDK MSBuild
projects (Ring/Ring.csproj:11, Ring.Tests/Ring.Tests.csproj:11, both
v4.7.2), and the SDK test host cannot run them.
MSBuild is normally not on PATH. Use the full path, and use PowerShell,
not Git Bash — Git Bash mangles switches like /m into path-looking arguments:
$msbuild = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe"
nuget restore Ring.sln
& $msbuild Ring.sln /m /t:Build /p:Configuration=Debug "/p:Platform=Any CPU"
Run the suite with the xUnit console runner, restored into packages/:
& packages\xunit.runner.console.2.9.3\tools\net472\xunit.console.exe `
Ring.Tests\bin\Debug\Ring.Tests.dll -nologo -parallel none
(The runner version is pinned in Ring.Tests/packages.config:
xunit.runner.console 2.9.3.)
Log long output to a file and read the tail. The full transcript is noise
(CLAUDE.md).
Check free disk space before diagnosing a mass failure. Parallel build lanes
filling C: have produced thousands of phantom failures in this repository.
This is the first thing to check when a failure appears out of nowhere, not the
last.
-parallel none is required, not optional#
Parallelization is disabled in two places, deliberately:
[assembly: CollectionBehavior(DisableTestParallelization = true)]—Ring.Tests/Properties/AssemblyInfo.cs:10-parallel noneon the runner command line, including in the gate
The reason is process-global state. Several tests flip the static
PlcWriteGuard in try/finally, and any test class exercising a write path
in parallel would see its writes silently suppressed
(CONTRIBUTING.md). The same applies to
PlcHeartbeatConnectionTracker, TankInstalledWriteGate,
PlcTagWriter.WireWriteHookForTests and the snapshot holders.
Do not re-enable it, and do not introduce a test that depends on parallel
execution. A test that pulls the ForceReadOnlyForSession latch must release
it with PlcWriteGuard.ResetForcedReadOnlyForTests() followed by
PlcWriteGuard.Configure(false) in a finally. The reset alone is not
enough: ForceReadOnlyForSession() sets both _forcedReadOnly = true and
_readOnly = true (PlcWriteGuard.cs:51-52), but
ResetForcedReadOnlyForTests() clears only _forcedReadOnly
(PlcWriteGuard.cs:62-65) — _readOnly stays true until a later Configure
call recomputes it. Call the reset without the Configure(false) that follows
it and read-only still leaks into every later test in the run, silently
passing every later PLC-writer test because its writes are suppressed. See
Ring.Tests/StartupWriteGateAndPreMigrationBackupTests.cs:43-44 for the
canonical pair, repeated at TankTempModeWriteAuthorizationTests.cs:60-61,
TankTempModeWriteTargetTests.cs:70-71 and
BenchPlcWriteRoundTripTests.cs:79-80.
House conventions for writing tests#
- Prefer WPF-free logic objects over constructing
UserControls. This is why so much of the codebase is factored into pure calculators, pure decision functions (FormulaBankWriteInterlock.Decide,PlcPollingCoordinator.DecideStartupReconcile,PlcHeartbeatConnectionTracker.ClassifyHeldHeartbeat,FitHost.ComputeChildArrange) and pure projections. - Tests that need a repo path use a
[CallerFilePath]-anchored root, not the CWD. The canonical implementation isRingSourceRoot([CallerFilePath] string thisFile = null)inRing.Tests/WriteSurfaceRegisterTests.cs:203-218andRing.Tests/ProjectMembershipGuardTests.cs:76-90, both with a walk-up-from-BaseDirectoryfallback. - Test seams are marked and policed.
PlcTagWriter.WireWriteHookForTestsandPlcHeartbeatConnectionTracker.MonotonicNowTicksarepubliconly becauseRing.Testshas noInternalsVisibleToagainstRing; source-scan tripwires inRing.Tests/MomentaryPulseClearGateTests.csfail if anything underRing/assigns them.
Bench-gated tests pass vacuously by default#
Ring.Tests/BenchPlcIntegrationTests.cs and
Ring.Tests/BenchPlcWriteRoundTripTests.cs early-return a pass on every
[Fact]/[Theory] when BENCH_PLC_AVAILABLE is unset — i.e. on every normal
laptop or CI run. A green suite therefore does not mean bench hardware
verified anything.
The gate is explicit about this rather than hiding it: it counts the gated
methods mechanically from source and records both
benchGatedTestCount and benchHardwareWasAvailable in its summary
(scripts/Invoke-ProductionGate.ps1:157-186). When you cite "tests passed",
cite those two fields alongside it.
8.2 The non-SDK csproj rule and its guard#
Both projects are classic MSBuild projects. Nothing is globbed. A new .cs,
.xaml or image is invisible to the compiler and to the shipped EXE until you
hand-add a row:
<Compile Include="Services\PLC\MyNewService.cs" />
<Page Include="Views\Setup\MyNewScreen.xaml"><Generator>MSBuild:Compile</Generator><SubType>Designer</SubType></Page>
<Compile Include="Views\Setup\MyNewScreen.xaml.cs"><DependentUpon>MyNewScreen.xaml</DependentUpon></Compile>
This used to fail silently. Five orphan test files and a set of image variants lived in the tree for months looking like product code. It now fails loudly.
ProjectMembershipGuardTests#
Ring.Tests/ProjectMembershipGuardTests.cs closes both directions of the
failure:
| Failure mode | Description |
|---|---|
| Orphan source | a .cs / .xaml / image lands under Ring/ and is never registered. It compiles nowhere, ships nowhere, and reads to every later reviewer as live product code |
| Phantom include | a registered path that no longer exists on disk. MSBuild fails loudly for Compile, but a stale Resource/Content row can survive a rename and only surface as a missing image at runtime, on the plant floor |
Three tests:
Every_source_and_asset_under_Ring_is_registered_in_the_project(:179)Every_project_include_points_at_a_file_that_exists(:199)The_membership_scan_is_not_vacuous(:222) — the anti-blindness pin, the same pattern the write-surface census uses
It recognises registration through any of
Compile | Page | Resource | Content | ApplicationDefinition | None | EmbeddedResource
(:40-42), because the question it asks is "does the project know about this
file", not "how is it packaged".
Scope is deliberately the shipping project only (Ring/). The test
project's own membership is proven by the fact that a missing row means the test
simply does not run.
The allow-list is not a mute button#
AllowList (:58-72) maps a glob to a truthful reason. Every entry is a
standing claim that the file is not product code. The current entries are the
opaque-white vessel BMPs superseded by transparent-PNG siblings, kept on disk
because the repository is under a rename freeze and because they are the
conversion script's only input — deleting them would make
scripts/Convert-VesselArtToAlpha.ps1 unreproducible.
"Keep the reason column truthful — it is the only thing separating this list from a mute button."
8.3 The production gate#
scripts/Invoke-ProductionGate.ps1 is the gate. It is what CI runs
(.github/workflows/production-gate.yml) and what you should run before asking
anyone to merge.
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Invoke-ProductionGate.ps1
What it actually does, in order (verified against the script):
| Step | Detail |
|---|---|
| Resolve MSBuild | Get-Command msbuild.exe, else vswhere -latest … -find 'MSBuild\**\Bin\MSBuild.exe'; throws with an install instruction if neither works (:18-30) |
| Guard the artifacts path | ArtifactsRoot must stay inside the repository (:40-44) |
| NuGet restore | throws if nuget.exe is missing; -SkipRestore is only for an already-verified restore (:59-66) |
| Isolated rebuild | /t:Rebuild into a unique OutDir — "Shared bin\Debug/bin\Release directories can be stale or concurrently cleaned by another build/test process, producing misleading assembly-load failures" (:67-73) |
| Output completeness check | asserts twelve required files exist, including x64\SQLite.Interop.dll, libplctag.dll, libplctag.NativeImport.dll and Config\appsettings.json (:76-92) |
| Full xUnit run | -nologo -parallel none -xml, then parses the result XML and throws unless failures == 0 and skips == 0 (:99-110) |
| Regenerate the PLC manifests | runs Parse-L5kLiveTags.ps1 and Audit-CodeTags.ps1 -SkipHistoryAppend, then git diff --exit-code over scripts/live-tags.{md,json} and scripts/code-tag-audit.{md,json} — fails on drift (:111-127) |
| Release verifier contract tests | scripts/tests/ReleasePackageContract.Tests.ps1 (:130-132) |
| Build the release package | New-ReleasePackage.ps1 -IncludeKiosk (:134-138) |
| Re-verify the built package | expands the produced ZIP and runs Test-ReleasePackage.ps1 against the expanded copy (:145-151) |
| Write the summary | artifacts/production-gate/production-gate-summary.json (:172-189) |
The summary carries commit, configuration, tests_total, tests_passed,
tests_failed, tests_skipped, test_seconds, benchGatedTestCount,
benchHardwareWasAvailable and generated_utc.
That file is the authoritative test count. Do not hardcode the number into any document — including this one. The last document that did was wrong by an order of magnitude for months (
README.md).
The gate closes with an explicit non-claim, worth quoting because it bounds what a green run means:
"Hardware-in-the-loop and signed factory commissioning evidence remain separate mandatory gates."
The second CI workflow#
.github/workflows/plc-tag-audit.yml regenerates the tag manifests and fails
if either (1) the regenerated files differ from the committed copies, or (2) the
dead-read count went up versus the PR's base branch — meaning a new C# read
was added against a tag the parsed export shows no ladder writer for.
It runs on windows-latest with shell: powershell (Windows PowerShell 5.1)
deliberately: ConvertTo-Json formats differently between PS 5.1 and pwsh 7+
(indent width, colon spacing), so an ubuntu+pwsh runner would always diff against
locally regenerated commits.
Remember what a "dead read" means before you act on one:
docs/PLC_FACTS.md — it is a statement about the
parsed 2024 export, not proof the value is dead in the plant. A rising count
still deserves attention, because it usually means a read was added against a
tag nobody has confirmed is fed.
8.4 Encoding, line endings and BOMs#
.gitattributes at the repository root is deliberate and every line is
commented. The rules that bite:
| Rule | Why |
|---|---|
*.csproj text eol=crlf, *.sln text eol=crlf |
MSBuild and Visual Studio are happiest with CRLF; an LF .sln can confuse older VS tooling into a rewrite |
| csproj and sln stay BOM-free | CLAUDE.md |
*.jsonl, *.L5K, *.L5X → text eol=crlf — never binary, never -text |
Those files were committed under * text=auto, so the index holds LF and core.autocrlf puts CRLF in the working tree. Turning conversion off does not preserve that; it freezes whatever bytes are on disk and hands the next clone LF files. For the L5K that is a byte-level rewrite of the v21 export that is the math authority for the plant |
| No renormalization pass was run, on purpose | git add --renormalize would produce a whole-repo diff that buries real changes for months. The rules govern files as they are added or modified from here on |
Two working rules follow:
- Preserve CRLF. A bulk find-and-replace across a file can collapse CRLF to
LF. Prefer a targeted edit, and diff before committing
(
CLAUDE.md). - Localization dictionaries are UTF-8 without BOM, and if the mojibake test fires, repair the encoding rather than retyping the character (chapter 06 §6.5).
8.5 Working alongside other sessions#
Parallel work happens in git worktrees under ..\worktrees\<lane>, one
branch per lane.
Why a worktree and not just a branch: concurrent sessions sharing one
checkout race .git/index and .git/HEAD; only a worktree prevents both
(CLAUDE.md). Verify your branch after committing.
Three practical rules:
packages/in a worktree is a symlink/junction back to the primary checkout'spackages/, and it is not created for you. Provision the worktree and the junction before building, or restore will appear to succeed and the build will fail on missing references (CONTRIBUTING.md).- Never modify another lane's worktree.
- Never leave a "perturb it to prove the test fails" experiment in a shared
checkout. One such experiment flipped a safety default.
git statusbefore you trust a result.
Branch naming in use: fix/<slug>, cleanup/lane-<x>, feature/<slug>; see
docs/BRANCHES.md. PRs go through gh pr create, and both
CI workflows must be green.
Commit bodies should explain why — and, in this repository especially, what you verified rather than assumed.
8.6 The quality tests that are really safety artifacts#
Four tests in this suite are load-bearing beyond their own assertions. Treat a change that touches them as a change to the safety model, not to test hygiene.
| Test file | What it protects |
|---|---|
WriteSurfaceRegisterTests.cs |
The whole PLC write surface stays enumerated, classified, gated and reachable. §4.10 |
MomentaryPulseClearGateTests.cs |
WriteClear (a heartbeat-gate bypass) stays de-assert-only; the wire test hook is never assigned by production; No_production_code_releases_the_one_way_read_only_latch (:858-869) pins that ResetForcedReadOnlyForTests is never called from Ring/ itself |
ProjectMembershipGuardTests.cs |
No orphan source, no phantom include |
DatabaseExpectedTablesDriftTests.cs |
The restore contract cannot drift from the real schema in either direction |
All four share the same defensive shape and it is worth copying when you write something similar: a "nothing matches X" assertion passes vacuously the moment the detector stops matching. Each therefore carries an anti-blindness pin — synthetic positives, asserted negatives, and a floor on the live population — so a narrowed regex looks like a red build rather than a safer codebase.
Next: 09 — Build, Deploy, Release.
Verified against#
Every claim in this chapter was read out of these files on 2026-09-01:
Ring.Tests/Properties/AssemblyInfo.cs ·
Ring.Tests/ProjectMembershipGuardTests.cs ·
Ring.Tests/WriteSurfaceRegisterTests.cs ·
Ring.Tests/MomentaryPulseClearGateTests.cs ·
Ring.Tests/StartupWriteGateAndPreMigrationBackupTests.cs,
TankTempModeWriteAuthorizationTests.cs, TankTempModeWriteTargetTests.cs,
BenchPlcWriteRoundTripTests.cs ·
Ring.Tests/DatabaseExpectedTablesDriftTests.cs ·
Ring.Tests/packages.config · Ring/Ring.csproj ·
Ring.Tests/Ring.Tests.csproj · scripts/Invoke-ProductionGate.ps1 ·
.github/workflows/production-gate.yml ·
.github/workflows/plc-tag-audit.yml · .gitattributes ·
Ring/Services/PLC/PlcWriteGuard.cs · docs/PLC_FACTS.md ·
CONTRIBUTING.md · CLAUDE.md · docs/BRANCHES.md