RINGby Ringwood

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:

  1. [assembly: CollectionBehavior(DisableTestParallelization = true)]Ring.Tests/Properties/AssemblyInfo.cs:10
  2. -parallel none on 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 is RingSourceRoot([CallerFilePath] string thisFile = null) in Ring.Tests/WriteSurfaceRegisterTests.cs:203-218 and Ring.Tests/ProjectMembershipGuardTests.cs:76-90, both with a walk-up-from-BaseDirectory fallback.
  • Test seams are marked and policed. PlcTagWriter.WireWriteHookForTests and PlcHeartbeatConnectionTracker.MonotonicNowTicks are public only because Ring.Tests has no InternalsVisibleTo against Ring; source-scan tripwires in Ring.Tests/MomentaryPulseClearGateTests.cs fail if anything under Ring/ 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, *.L5Xtext eol=crlfnever 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:

  1. packages/ in a worktree is a symlink/junction back to the primary checkout's packages/, 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).
  2. Never modify another lane's worktree.
  3. Never leave a "perturb it to prove the test fails" experiment in a shared checkout. One such experiment flipped a safety default. git status before 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

Generated from the docs/manual/engineering book in the Ring repository — the markdown there is the source of truth. Paths shown in code like this point into the Ring source repository, which is private to Ringwood — they are not links.