fix(hosts): a driver that never built a tag table reports NO ANSWER, not zero skips

Found on the docker-dev live gate, not in tests. Two drivers that faulted
during initialise (rtu-gate, opcua1) each rendered "0 tags" on /hosts against
two authored tags apiece, because a ledger that had never committed returned
SkippedTagReport.Empty.

A zero report is a positive claim -- "this driver skipped nothing" -- and
making it on behalf of a driver that never got as far as looking is exactly
the quiet inaccuracy #541 exists to remove. GetSkippedTags() and
SkippedTagLedger.Snapshot() now return null until the first committed build,
so the column renders the same em-dash it already used for a driver that
cannot report at all. Both are 'no answer'; neither is 'nothing skipped'.

Knock-on: a FOCAS driver whose only init threw now reports null rather than a
zero report, which is strictly more honest than the assertion the test
originally made.
This commit is contained in:
Joseph Doherty
2026-08-05 20:16:31 -04:00
parent 39db75083d
commit dd900f970a
19 changed files with 94 additions and 51 deletions
+4 -2
View File
@@ -43,11 +43,13 @@ Reading the Tags column:
| Cell | Meaning | | Cell | Meaning |
|---|---| |---|---|
| `—` | the driver cannot report skips. **Not** the same as reporting none. | | `—` | **no answer.** Either the driver cannot report skips, or it never got as far as building a tag table (it faulted during initialise). Deliberately *not* the same as reporting none. |
| `12 tags` | the driver reports, and skipped nothing. | | `12 tags` | the driver built a table, and skipped nothing. |
| `20 / 22 skipped` (amber) | partly working — some tags poll, some do not. | | `20 / 22 skipped` (amber) | partly working — some tags poll, some do not. |
| `22 / 22 skipped` (red) | polling **nothing**. The state most likely to look healthy, because it has no failing reads to report. | | `22 / 22 skipped` (red) | polling **nothing**. The state most likely to look healthy, because it has no failing reads to report. |
That first row is a distinction the live rig forced. A pre-fix build rendered `0 tags` for two drivers that had faulted during initialise — each with two authored tags — because a never-built ledger returned a zero report. A zero report is a *positive claim* ("this driver skipped nothing"), and making it on behalf of a driver that never looked is the same quiet inaccuracy the column exists to remove. `GetSkippedTags()` now returns null until the first committed build.
Three properties worth knowing: Three properties worth knowing:
- **A skip never changes driver state.** Latching non-Healthy for a config problem would satisfy both halves of the [#527](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/527) comms-loss conjunction — *90 s without a successful read AND non-Healthy* — and a driver whose tags were all skipped has no successful reads by construction, so it would reconnect-loop forever against a device that is perfectly fine. The signal is additive to the state, not a change of it. - **A skip never changes driver state.** Latching non-Healthy for a config problem would satisfy both halves of the [#527](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/527) comms-loss conjunction — *90 s without a successful read AND non-Healthy* — and a driver whose tags were all skipped has no successful reads by construction, so it would reconnect-loop forever against a device that is perfectly fine. The signal is additive to the state, not a change of it.
@@ -44,8 +44,15 @@ public interface ISkippedTagReporter
/// Must be a pure in-memory read with no I/O — it is called on the health-publish path, is /// Must be a pure in-memory read with no I/O — it is called on the health-publish path, is
/// deliberately NOT routed through the resilience pipeline, and must never block. /// deliberately NOT routed through the resilience pipeline, and must never block.
/// </remarks> /// </remarks>
/// <returns>The current report; never null. An all-zero report means "nothing skipped".</returns> /// <returns>
SkippedTagReport GetSkippedTags(); /// The report from the driver's most recent tag-table build, or <see langword="null"/> when it has
/// not built one yet — a driver that faulted during initialise has <b>no answer</b>, which is not
/// the same as the answer "nothing skipped". Returning a zero report there would state that a
/// driver with a dozen authored tags has none, which is the class of quiet inaccuracy this whole
/// capability exists to remove. (Found on the docker-dev rig, where two faulted drivers each
/// rendered "0 tags" against two authored ones.)
/// </returns>
SkippedTagReport? GetSkippedTags();
} }
/// <summary>One authored tag a driver could not build into its poll table, and why.</summary> /// <summary>One authored tag a driver could not build into its poll table, and why.</summary>
@@ -71,9 +78,6 @@ public sealed record SkippedTag(string RawPath, string Reason);
/// </param> /// </param>
public sealed record SkippedTagReport(int AuthoredCount, int SkippedCount, IReadOnlyList<SkippedTag> Sample) public sealed record SkippedTagReport(int AuthoredCount, int SkippedCount, IReadOnlyList<SkippedTag> Sample)
{ {
/// <summary>A driver that has authored nothing and skipped nothing.</summary>
public static readonly SkippedTagReport Empty = new(0, 0, []);
/// <summary>Whether any authored tag was skipped — i.e. whether this is worth an operator's attention.</summary> /// <summary>Whether any authored tag was skipped — i.e. whether this is worth an operator's attention.</summary>
public bool HasSkips => SkippedCount > 0; public bool HasSkips => SkippedCount > 0;
@@ -113,7 +117,9 @@ public sealed class SkippedTagLedger
public const int MaxSample = 20; public const int MaxSample = 20;
private readonly object _gate = new(); private readonly object _gate = new();
private SkippedTagReport _visible = SkippedTagReport.Empty;
/// <summary>Null until the first <see cref="Commit"/> — "no answer yet", never a zero report.</summary>
private SkippedTagReport? _visible;
private int _stagedAuthored; private int _stagedAuthored;
private int _stagedSkipped; private int _stagedSkipped;
@@ -168,8 +174,9 @@ public sealed class SkippedTagLedger
} }
/// <summary>The current published snapshot.</summary> /// <summary>The current published snapshot.</summary>
/// <returns>The last committed report, or <see cref="SkippedTagReport.Empty"/> before the first commit.</returns> /// <returns>The last committed report, or <see langword="null"/> before the first commit — a driver
public SkippedTagReport Snapshot() /// that has not built a tag table has no answer, and a zero report would be a false one.</returns>
public SkippedTagReport? Snapshot()
{ {
lock (_gate) return _visible; lock (_gate) return _visible;
} }
@@ -31,7 +31,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private AbCipDriverOptions _options; private AbCipDriverOptions _options;
private readonly string _driverInstanceId; private readonly string _driverInstanceId;
@@ -17,7 +17,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize /// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary> /// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
@@ -42,7 +42,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// OPC UA status codes surfaced by the calc driver. // OPC UA status codes surfaced by the calc driver.
private const uint StatusGood = 0u; private const uint StatusGood = 0u;
@@ -27,7 +27,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only /// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary> /// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
@@ -45,7 +45,7 @@ public sealed class GalaxyDriver
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private readonly string _driverInstanceId; private readonly string _driverInstanceId;
private readonly GalaxyDriverOptions _options; private readonly GalaxyDriverOptions _options;
@@ -79,7 +79,7 @@ public sealed class MTConnectDriver
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary> /// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct /// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
@@ -25,7 +25,7 @@ public sealed class ModbusDriver
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// ---- instance fields (grouped at top for auditability) ---- // ---- instance fields (grouped at top for auditability) ----
@@ -70,7 +70,7 @@ public sealed class MqttDriver
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// ---- Write status codes (Gitea #508) ---- // ---- Write status codes (Gitea #508) ----
// //
@@ -37,7 +37,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary> /// <summary>
/// Recomputes the skip report from the freshly-built map. Derived by re-asking the map to resolve /// Recomputes the skip report from the freshly-built map. Derived by re-asking the map to resolve
@@ -36,7 +36,7 @@ public sealed class S7Driver
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private readonly string _driverInstanceId; private readonly string _driverInstanceId;
private readonly ILogger<S7Driver> _logger; private readonly ILogger<S7Driver> _logger;
@@ -46,7 +46,7 @@ public sealed class SqlDriver
private IReadOnlyList<SkippedTag> _blobSkips = []; private IReadOnlyList<SkippedTag> _blobSkips = [];
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary> /// <summary>
/// Republishes the skip ledger as the union of the blob-level skips and the catalog gate's /// Republishes the skip ledger as the union of the blob-level skips and the catalog gate's
@@ -17,7 +17,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot(); public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds // Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds
// it; InitializeAsync re-parses driverConfigJson over the top of it. // it; InitializeAsync re-parses driverConfigJson over the top of it.
@@ -234,8 +234,11 @@ else
Healthy, Errors/5 min stays 0, Last error stays empty, and Last Healthy, Errors/5 min stays 0, Last error stays empty, and Last
read keeps advancing off the tags that DID map. On the Makino in read keeps advancing off the tags that DID map. On the Makino in
#540 that read as a perfectly healthy driver ignoring 20 of its 22 #540 that read as a perfectly healthy driver ignoring 20 of its 22
tags. "—" means the driver cannot report skips, which is distinct tags. "—" means NO ANSWER — either the driver cannot report skips,
from reporting none. *@ or it faulted before building a tag table — which is distinct from
the positive claim "this driver skipped nothing". A pre-fix build
rendered "0 tags" for two faulted drivers on the docker-dev rig
that had two authored tags apiece. *@
@if (d.SkippedTags is null) @if (d.SkippedTags is null)
{ {
<span class="text-muted">—</span> <span class="text-muted">—</span>
@@ -44,9 +44,10 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
/// <param name="HostStatuses">Per-host connectivity for a multi-device driver, ordered by host name; null /// <param name="HostStatuses">Per-host connectivity for a multi-device driver, ordered by host name; null
/// when the driver reports no per-host detail. Lets one unreachable device show even while the driver /// when the driver reports no per-host detail. Lets one unreachable device show even while the driver
/// row itself is Healthy.</param> /// row itself is Healthy.</param>
/// <param name="SkippedTags">Authored tags the driver could not build into its poll table; null when the /// <param name="SkippedTags">Authored tags the driver could not build into its poll table; null when there
/// driver cannot report them. Lets "ignoring 20 of 22 tags" show even while every other column on the /// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
/// row — state, last read, error count, last error — reads perfectly healthy (Gitea #541).</param> /// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
/// count, last error — reads perfectly healthy (Gitea #541).</param>
public sealed record HostsDriverRow( public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State, string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc, DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
@@ -9,17 +9,45 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// </summary> /// </summary>
public sealed class SkippedTagLedgerTests public sealed class SkippedTagLedgerTests
{ {
/// <summary>
/// <b>A ledger that has never built reports NO ANSWER, not a clean bill of health.</b> Caught on
/// the docker-dev rig: two drivers that faulted during initialise each rendered "0 tags" on
/// <c>/hosts</c> against two authored tags apiece. A zero report is a positive claim — "this driver
/// skipped nothing" — and making it on behalf of a driver that never got as far as looking is the
/// same species of quiet inaccuracy this capability exists to remove.
/// </summary>
[Fact] [Fact]
public void A_fresh_ledger_reports_nothing() public void A_ledger_that_has_never_built_reports_no_answer_rather_than_a_clean_one()
{ => new SkippedTagLedger().Snapshot().ShouldBeNull();
var report = new SkippedTagLedger().Snapshot();
report.AuthoredCount.ShouldBe(0); /// <summary>An empty deployment that DID build reports a zero report — distinct from the above.</summary>
[Fact]
public void A_committed_empty_build_reports_zero_rather_than_no_answer()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(0);
ledger.Commit();
var report = ledger.Snapshot();
report.ShouldNotBeNull();
report!.AuthoredCount.ShouldBe(0);
report.SkippedCount.ShouldBe(0); report.SkippedCount.ShouldBe(0);
report.Sample.ShouldBeEmpty();
report.HasSkips.ShouldBeFalse(); report.HasSkips.ShouldBeFalse();
} }
/// <summary>
/// An in-flight first build is still "no answer" — the staging state must not leak as a report.
/// </summary>
[Fact]
public void A_first_build_still_in_progress_reports_no_answer()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(10);
ledger.Skip("a", "r");
ledger.Snapshot().ShouldBeNull();
}
[Fact] [Fact]
public void A_committed_build_reports_its_skips_with_the_authored_denominator() public void A_committed_build_reports_its_skips_with_the_authored_denominator()
{ {
@@ -30,7 +58,7 @@ public sealed class SkippedTagLedgerTests
ledger.Skip("Line1/Makino/Identity/Version", "invalid Address"); ledger.Skip("Line1/Makino/Identity/Version", "invalid Address");
ledger.Commit(); ledger.Commit();
var report = ledger.Snapshot(); var report = ledger.Snapshot()!;
report.AuthoredCount.ShouldBe(22); report.AuthoredCount.ShouldBe(22);
report.SkippedCount.ShouldBe(2); report.SkippedCount.ShouldBe(2);
report.Sample.Select(s => s.RawPath) report.Sample.Select(s => s.RawPath)
@@ -54,7 +82,7 @@ public sealed class SkippedTagLedgerTests
ledger.Skip("c", "r"); ledger.Skip("c", "r");
ledger.Commit(); ledger.Commit();
ledger.Snapshot().SkippedEverything.ShouldBeTrue(); ledger.Snapshot()!.SkippedEverything.ShouldBeTrue();
} }
/// <summary> /// <summary>
@@ -70,7 +98,7 @@ public sealed class SkippedTagLedgerTests
ledger.BeginRebuild(0); ledger.BeginRebuild(0);
ledger.Commit(); ledger.Commit();
ledger.Snapshot().SkippedEverything.ShouldBeFalse(); ledger.Snapshot()!.SkippedEverything.ShouldBeFalse();
} }
/// <summary> /// <summary>
@@ -86,7 +114,7 @@ public sealed class SkippedTagLedgerTests
for (var i = 0; i < 500; i++) ledger.Skip($"tag-{i}", "TagConfig did not map to a definition"); for (var i = 0; i < 500; i++) ledger.Skip($"tag-{i}", "TagConfig did not map to a definition");
ledger.Commit(); ledger.Commit();
var report = ledger.Snapshot(); var report = ledger.Snapshot()!;
report.SkippedCount.ShouldBe(500); report.SkippedCount.ShouldBe(500);
report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample); report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample);
report.Sample[0].RawPath.ShouldBe("tag-0", "the sample keeps the FIRST skips, in encounter order"); report.Sample[0].RawPath.ShouldBe("tag-0", "the sample keeps the FIRST skips, in encounter order");
@@ -108,13 +136,13 @@ public sealed class SkippedTagLedgerTests
ledger.BeginRebuild(10); ledger.BeginRebuild(10);
ledger.Skip("new-1", "r"); ledger.Skip("new-1", "r");
var midRebuild = ledger.Snapshot(); var midRebuild = ledger.Snapshot()!;
midRebuild.SkippedCount.ShouldBe(1); midRebuild.SkippedCount.ShouldBe(1);
midRebuild.Sample.Single().RawPath.ShouldBe("old"); midRebuild.Sample.Single().RawPath.ShouldBe("old");
ledger.Skip("new-2", "r"); ledger.Skip("new-2", "r");
ledger.Commit(); ledger.Commit();
ledger.Snapshot().Sample.Select(s => s.RawPath).ShouldBe(["new-1", "new-2"]); ledger.Snapshot()!.Sample.Select(s => s.RawPath).ShouldBe(["new-1", "new-2"]);
} }
/// <summary> /// <summary>
@@ -133,7 +161,7 @@ public sealed class SkippedTagLedgerTests
ledger.Skip("would-have-been-reported", "invalid Address"); ledger.Skip("would-have-been-reported", "invalid Address");
// …driver throws here; no Commit. // …driver throws here; no Commit.
ledger.Snapshot().Sample.Single().RawPath.ShouldBe("known-bad"); ledger.Snapshot()!.Sample.Single().RawPath.ShouldBe("known-bad");
} }
/// <summary> /// <summary>
@@ -151,7 +179,7 @@ public sealed class SkippedTagLedgerTests
ledger.BeginRebuild(2); ledger.BeginRebuild(2);
ledger.Commit(); ledger.Commit();
var report = ledger.Snapshot(); var report = ledger.Snapshot()!;
report.HasSkips.ShouldBeFalse(); report.HasSkips.ShouldBeFalse();
report.SkippedCount.ShouldBe(0); report.SkippedCount.ShouldBe(0);
report.AuthoredCount.ShouldBe(2); report.AuthoredCount.ShouldBe(2);
@@ -167,7 +195,7 @@ public sealed class SkippedTagLedgerTests
var ledger = new SkippedTagLedger(); var ledger = new SkippedTagLedger();
Should.NotThrow(() => ledger.Skip("orphan", "r")); Should.NotThrow(() => ledger.Skip("orphan", "r"));
ledger.Snapshot().SkippedCount.ShouldBe(0); ledger.Snapshot().ShouldBeNull("a stray Skip must not conjure a report out of nothing");
} }
/// <summary> /// <summary>
@@ -197,7 +225,7 @@ public sealed class SkippedTagLedgerTests
{ {
while (!writer.IsCompleted && !cts.IsCancellationRequested) while (!writer.IsCompleted && !cts.IsCancellationRequested)
{ {
var r = ledger.Snapshot(); var r = ledger.Snapshot()!;
// Every committed snapshot is internally consistent: the sample never exceeds either the // Every committed snapshot is internally consistent: the sample never exceeds either the
// cap or the true count it is drawn from. // cap or the true count it is drawn from.
r.Sample.Count.ShouldBeLessThanOrEqualTo(SkippedTagLedger.MaxSample); r.Sample.Count.ShouldBeLessThanOrEqualTo(SkippedTagLedger.MaxSample);
@@ -42,7 +42,7 @@ public sealed class FocasSkippedTagReportingTests
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
var report = drv.GetSkippedTags(); var report = drv.GetSkippedTags()!;
report.AuthoredCount.ShouldBe(2); report.AuthoredCount.ShouldBe(2);
report.SkippedCount.ShouldBe(0); report.SkippedCount.ShouldBe(0);
report.HasSkips.ShouldBeFalse(); report.HasSkips.ShouldBeFalse();
@@ -75,7 +75,7 @@ public sealed class FocasSkippedTagReportingTests
drv.GetHealth().LastError.ShouldBeNull(); drv.GetHealth().LastError.ShouldBeNull();
// …and the fact that contradicts it. // …and the fact that contradicts it.
var report = drv.GetSkippedTags(); var report = drv.GetSkippedTags()!;
report.AuthoredCount.ShouldBe(22); report.AuthoredCount.ShouldBe(22);
report.SkippedCount.ShouldBe(20); report.SkippedCount.ShouldBe(20);
report.SkippedEverything.ShouldBeFalse("two tags do poll — this driver is partly working"); report.SkippedEverything.ShouldBeFalse("two tags do poll — this driver is partly working");
@@ -97,7 +97,7 @@ public sealed class FocasSkippedTagReportingTests
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
var report = drv.GetSkippedTags(); var report = drv.GetSkippedTags()!;
report.SkippedCount.ShouldBe(25); report.SkippedCount.ShouldBe(25);
report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample); report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample);
report.SkippedEverything.ShouldBeTrue("this driver polls nothing at all"); report.SkippedEverything.ShouldBeTrue("this driver polls nothing at all");
@@ -116,11 +116,11 @@ public sealed class FocasSkippedTagReportingTests
new FocasTagDefinition("raw/Good", Host, "MACRO:500", FocasDataType.Float64)); new FocasTagDefinition("raw/Good", Host, "MACRO:500", FocasDataType.Float64));
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetSkippedTags().SkippedCount.ShouldBe(1); drv.GetSkippedTags()!.SkippedCount.ShouldBe(1);
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
var report = drv.GetSkippedTags(); var report = drv.GetSkippedTags()!;
report.SkippedCount.ShouldBe(1, "the same one bad tag — not two"); report.SkippedCount.ShouldBe(1, "the same one bad tag — not two");
report.AuthoredCount.ShouldBe(2); report.AuthoredCount.ShouldBe(2);
} }
@@ -145,7 +145,7 @@ public sealed class FocasSkippedTagReportingTests
}; };
var drv = new FocasDriver(good, "drv-1", new FakeFocasClientFactory()); var drv = new FocasDriver(good, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetSkippedTags().Sample.Single().RawPath.ShouldBe("raw/Bad"); drv.GetSkippedTags()!.Sample.Single().RawPath.ShouldBe("raw/Bad");
// A second driver over an artifact whose tag names a device that is not configured: the loop skips // A second driver over an artifact whose tag names a device that is not configured: the loop skips
// the first entry, then throws on the second. // the first entry, then throws on the second.
@@ -164,7 +164,9 @@ public sealed class FocasSkippedTagReportingTests
await Should.ThrowAsync<InvalidOperationException>( await Should.ThrowAsync<InvalidOperationException>(
() => drv2.InitializeAsync("{}", CancellationToken.None)); () => drv2.InitializeAsync("{}", CancellationToken.None));
drv2.GetSkippedTags().SkippedCount.ShouldBe(0, // NO ANSWER, not "zero skipped": this driver's only build threw, so it never reached a verdict.
"an abandoned build must not publish; a partial report would read as 'we skip fewer tags now'"); // A zero report here would be the same false reassurance the docker-dev rig showed for two
// faulted drivers, and a PARTIAL one would read as "we skip fewer tags now".
drv2.GetSkippedTags().ShouldBeNull();
} }
} }
@@ -248,7 +248,7 @@ public sealed class DriverInstanceActorSkippedTagTests : RuntimeActorTestBase
} }
/// <inheritdoc /> /// <inheritdoc />
public SkippedTagReport GetSkippedTags() public SkippedTagReport? GetSkippedTags()
{ {
if (ThrowOnGetSkippedTags) throw new InvalidOperationException("reporter is broken"); if (ThrowOnGetSkippedTags) throw new InvalidOperationException("reporter is broken");
return _ledger.Snapshot(); return _ledger.Snapshot();