test: close the reachable coverage gaps and align SonarQube's denominator - #131
Conversation
…ator SonarQube reported 91.7% coverage against a local gate that was already at ~97%. The bulk of that delta was not a testing gap: SonarQube computes its "lines to cover" denominator from each language analyzer rather than from the coverage report, so `wwwroot/app.js` (197 executable JS lines that no .NET tool can cover) and `Program.cs` (36 lines whose `[ExcludeFromCodeCoverage]` sits on the `partial class Program;` part, which SonarC# does not associate with the top-level statements) were both counted as flat 0%. Both are now in `sonar.coverage.exclusions`, which restates for SonarQube what coverlet and the local gate already do. `app.js` remains a real, documented gap — excluding it stops it distorting a .NET metric, it does not test it. The rest is real coverage: - `CameraController` 94.70/91.30 -> 100/100, via a `FakeRustPlus` double that can throw where the mock server can only return a failed `Response`: zero and negative renewal intervals, a throwing unsubscribe on dispose, the disconnected-client skip, failed renewals with and without a subscriber, cancellation landing inside a renewal, and the loop-condition exit. - `ClientAddress` and `RequestMode` reach 100/100 through their addressless request paths, which `IsLocal` short-circuits past. - `RustPlusSocket` 89.17/90.26 -> 90.68/90.91. An audit of all 42 uncovered lines confirmed testing.md's characterisation: the remainder are concurrent-dispose and teardown-timeout races. Only `Dispose(bool)`'s finalizer arm and the default `ParseNotification` extension point were cleanly reachable; both are now covered. Libraries 96.91/93.55 -> 97.45/94.33, web app 99.26/97.09 -> 99.56/98.54. Three gaps are deliberately left and enumerated in testing.md instead: `CredentialsStore`'s Windows-only branch, `SessionStore`'s CAS-contention arms, and `SessionSweeper`. Reaching them needs contrivances that assert nothing real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new async tests rely on fixed wall-clock delays that can be timing-sensitive and lead to flaky CI failures under contention.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR improves coverage fidelity between the repo’s local Coverlet/ReportGenerator gate and SonarQube’s “lines to cover” calculation by (1) excluding non-.NET or syntactically un-excludable files from Sonar’s coverage denominator, and (2) adding targeted unit tests to cover previously reachable-but-untested branches in the core socket and camera controller logic.
Changes:
- Add new unit tests (including a purpose-built
IRustPlustest double) to close reachable coverage gaps inCameraController,ClientAddress,RequestMode, andRustPlusSocket. - Document why SonarQube’s coverage denominator can diverge from the coverage report and why
sonar.coverage.exclusionsneeds entries beyondsamples/tests/tools. - Update the Sonar workflow to exclude
wwwrootassets andapps/**/Program.csfrom Sonar coverage calculations.
File summaries
| File | Description |
|---|---|
| tests/RustPlusApi.UnitTests/RustPlusSocketBaseTests.cs | Adds tests covering RustPlusSocket.Dispose(bool) finalizer arm and default ParseNotification no-op. |
| tests/RustPlusApi.CredentialsWeb.UnitTests/RequestModeTests.cs | Adds a direct assertion for IsLoopbackAddress(null) to cover the documented “no connection address” case. |
| tests/RustPlusApi.CredentialsWeb.UnitTests/ClientAddressTests.cs | Adds coverage for the "unknown" fallback when RemoteIpAddress is absent. |
| tests/RustPlusApi.Camera.UnitTests/FakeRustPlus.cs | Introduces an IRustPlus test double enabling throw/fault paths not reachable via the mock server. |
| tests/RustPlusApi.Camera.UnitTests/CameraControllerSeamTests.cs | Adds seam tests for keep-alive/dispose edge cases and guard rails (incl. empty movement buttons). |
| docs/development/testing.md | Updates achieved coverage figures and documents SonarQube denominator alignment + accepted residual gaps rationale. |
| .github/workflows/Sonar.yml | Extends sonar.coverage.exclusions to avoid Sonar counting un-coverable wwwroot JS and top-level Program.cs statements as uncovered. |
Review details
Suppressed comments (1)
tests/RustPlusApi.Camera.UnitTests/CameraControllerSeamTests.cs:210
- Using a fixed 200ms delay to "wait for repeated renewals" can be timing-sensitive and lead to flaky failures if the keep-alive loop doesn't get scheduled promptly. Consider waiting (bounded) until SubscribeCount exceeds the threshold instead of assuming a specific wall-clock delay is sufficient.
// Nothing is attached to OnKeepAliveFailed: reporting the failure must be a no-op rather
// than a null dereference, and the loop must keep retrying past it.
await Task.Delay(TimeSpan.FromMilliseconds(200));
Assert.True(client.SubscribeCount > 2, $"expected repeated renewals, saw {client.SubscribeCount}");
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Let the cancelled renewal run and end the loop, then dispose over the already-finished task. | ||
| await Task.Delay(TimeSpan.FromMilliseconds(200)); | ||
| await controller.DisposeAsync(); |
There was a problem hiding this comment.
Correct for these two, fixed in 29f9de7.
Both asserted a renewal count had reached a value inside the window (== 2 at line 142, > 2 at line 210), so a contended runner under-delivers and the test fails. Both now poll via a bounded WaitUntilAsync against the existing 10s Timeout, so contention slows the test rather than failing it.
I did not change the two Task.Delay calls in SubscribeAsync_WithZeroInterval_NeverRenews / WithNegativeInterval_NeverRenews. Those assert the count stayed at 1, so contention can only make the assertion more true — and "wait until the count reaches the expected value" can't express absence. The delay is now a named SettleWindow constant documenting why that direction is contention-safe.
Chasing this did surface a real problem, though: RustPlusSocket's coverage is itself not reproducible. Three runs of the parent commit gave 89.17%, 89.92% and 90.68% line, because its teardown and concurrent-dispose arms are covered or not depending on how the integration tests' WebSocket teardown interleaves. The 89.17 → 90.68 improvement claimed in the PR description was one good run, not a result — the new tests add exactly three lines plus one branch. testing.md now quotes the library figure as a range and warns against reading small movements as regressions.
…cket claim Two of the CameraController seam tests asserted that a renewal count had *reached* a value within a fixed 200ms window, which a contended runner can under-deliver (Copilot review on #131). Both now poll for the condition with a bounded WaitUntilAsync, so contention slows the test instead of failing it. The two "never renews" tests keep their fixed delay, now named SettleWindow: they assert a count *stayed* at 1, so contention can only make the assertion more true, and a wait-for-a-count cannot express absence. Separately, correcting a claim in the previous commit. Three runs of that commit gave RustPlusSocket 89.17%, 89.92% and 90.68% line: its teardown and concurrent-dispose arms are covered or not depending on how the integration tests' WebSocket teardown interleaves. The reported 89.17 -> 90.68 improvement was one good run, not a reproducible result. RustPlusSocketBaseTests adds exactly three lines plus one branch; the rest was noise. testing.md now quotes the library figure as a range, names RustPlusSocket as the source of the variance, and warns against reading small movements as regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why
SonarQube reported 91.7% coverage against a local gate already at ~97%. Most of that delta was not a testing gap.
SonarQube does not take its "lines to cover" denominator from the coverage report — each language analyzer computes executable lines itself, and anything the report is silent about counts as uncovered. Two files were absorbing the difference:
wwwroot/app.jsProgram.cs[ExcludeFromCodeCoverage](hencePairingListener/FcmRegistrationat 100%), but does not associate it with top-level statements — they aren't syntactically inside thepartial class Program;part carrying the attribute. Coverlet excludes them correctly; the file never appears in the local web gap list.Both are now in
sonar.coverage.exclusions. Neither weakens the local gate —tools/coverage/report.shnever saw either file.app.jsis excluded, not fixed. It has no tests. Excluding it stops 400 lines of untested JS from dominating a .NET coverage metric, but the gap is real and is documented as such. Closing it means adding a Node toolchain andsonar.javascript.lcov.reportPaths.Real coverage added
CameraControllerClientAddressRequestModeRustPlusSocketFakeRustPlus— anIRustPlusdouble that can throw where the mock server can only return a failedResponse. Non-camera members throwNotSupportedException, so a controller change that starts calling one fails loudly rather than passing against a permissive stub.CameraControllerSeamTests(10 tests) — zero/negative renewal intervals, a throwing unsubscribe on dispose, the disconnected-client skip, failed renewals with and without a subscriber, cancellation inside a renewal, the loop-condition exit, frame drop with no handler, empty-bitmaskMoveAsync.RustPlusSocketBaseTests— a bare subclass reachingDispose(bool)'s finalizer arm and the defaultParseNotification.Warning
Correction to an earlier version of this description. It claimed
RustPlusSocketwent 89.17 → 90.68 line. That was one good run, not a reproducible result. Three runs of the same commit gave 89.17%, 89.92% and 90.68% — its teardown and concurrent-dispose arms are covered or not depending on how the integration tests' real WebSocket teardown interleaves.RustPlusSocketBaseTestsdeterministically adds three lines plus one branch; the rest was noise. The library aggregate moves with it (97.22–97.45%), so the "before/after" rows above should be read per-class, and only for the classes that are stable.testing.mdnow documents this.ClientAddressTests+ oneRequestModeTestscase — addressless-request fallbacks thatIsLocalshort-circuits past.RustPlusSocketauditI checked all 42 uncovered lines rather than inheriting
testing.md's claim. It holds: the remainder arecatch (ObjectDisposedException)arms around concurrent dispose, close-handshake failures, andTask.WhenAnyteardown-bound races — each needs a dispose to land inside a specific window, or a real socket to break mid-close. Only two were cleanly reachable, and both are now covered.Deliberately not chased
Enumerated in
testing.mdrather than contrived around, matching the standard the doc already sets forSessionSweeper:CredentialsStore.Save's!OperatingSystem.IsWindows()arm — needs a Windows CI leg, not a better test.SessionStore's two CAS-contention arms — need a seam that exists only to be tested; the actual cap contract is pinned bySessionStoreCapsTests.SessionSweeper— already documented as unreachable.Verification
tools/coverage/report.sh— full suite, all projects, both TFM hosts. Both gates green (min 95.0/90.0). ReSharper formatter applied, so the pre-push hook is satisfied.Projected SonarQube: 91.7% → ~97%. That figure is an estimate; the real number lands when the Sonar workflow next runs on
develop.🤖 Generated with Claude Code