fix(twincat): read-singly fallback for any non-comms overall sum error, once-per-device warning, wording (#645 re-review)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:33:09 -04:00
parent fbcb521d1b
commit 802781b645
10 changed files with 90 additions and 15 deletions
@@ -85,7 +85,7 @@ internal class FakeTwinCATClient : ITwinCATClient
/// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a
/// <see cref="TwinCATSumReadRefusedException"/> models a target without sum commands.</summary>
public Exception? SumReadException { get; set; }
/// <summary>Gets or sets the ADS state text <see cref="ReadAdsStateAsync"/> returns.</summary>
/// <summary>Gets or sets the ADS state text <see cref="LastAdsState"/> returns.</summary>
public string? AdsStateText { get; set; }
/// <summary>Simulates an ADS sum-read: records the batch size, then answers each item from the same
@@ -99,6 +99,7 @@ internal class FakeTwinCATClient : ITwinCATClient
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
SumReadBatches.Add(reads.Count);
if (SumReadException is { } ex) throw ex;
if (SimulateSumFallback) SumFallbackCount++;
var results = new (object? value, uint status)[reads.Count];
for (var i = 0; i < reads.Count; i++)
{
@@ -109,6 +110,12 @@ internal class FakeTwinCATClient : ITwinCATClient
return Task.FromResult<IReadOnlyList<(object? value, uint status)>>(results);
}
/// <summary>Gets or sets whether each batch counts as a non-latching sum fallback.</summary>
public bool SimulateSumFallback { get; set; }
/// <summary>Gets the simulated count of non-latching sum fallbacks.</summary>
public long SumFallbackCount { get; private set; }
/// <summary>Gets the simulated overall code of the last fallback.</summary>
public uint LastSumFallbackCode => 1795;
/// <summary>Gets the ADS state the "probe" last read.</summary>
public string? LastAdsState => AdsStateText;
@@ -49,7 +49,7 @@ public sealed class TwinCATDiagnosticsTests
diag.GetDiagnostics().ShouldBeEmpty();
}
/// <summary>Verifies a single-device driver publishes flat keys with state, app, cycle, exec and jitter.</summary>
/// <summary>Verifies a single-device driver publishes flat keys with state, app, cycle, exec and headroom.</summary>
[Fact]
public async Task Single_device_publishes_flat_keys()
{
@@ -46,10 +46,12 @@ public sealed class TwinCATSumReadPlannerTests
[InlineData(1792u, false, true)]
[InlineData(1797u, false, true)]
[InlineData(1798u, false, true)]
[InlineData(1795u, false, false)]
[InlineData(6u, false, false)]
[InlineData(1795u, false, true)] // invalid offset: this request rejected, read singly
[InlineData(1796u, false, true)]
[InlineData(1808u, false, true)]
[InlineData(6u, false, false)] // comms: every item goes Bad
[InlineData(7u, false, false)]
[InlineData(1861u, false, false)]
[InlineData(1808u, false, false)]
public void Overall_ads_codes_classify(uint code, bool refusal, bool rejected)
{
TwinCATSumReadPlanner.IsRefusal(code).ShouldBe(refusal);
@@ -156,6 +156,22 @@ public sealed class TwinCATSumReadTests
factory.Clients[0].SumReadBatches.ShouldBe([2, 2]);
}
/// <summary>Verifies a non-latching fallback is surfaced once per device, not once per poll.</summary>
[Fact]
public async Task Non_latching_fallback_warns_once_per_device()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 }, SimulateSumFallback = true };
await drv.ReadAsync(["A", "B"], CancellationToken.None);
var state = drv.GetDeviceState(Host)!;
state.SumFallbackWarned.ShouldBeTrue();
state.SumReadUnsupported.ShouldBeFalse(); // not latched: the next poll tries the sum command again
await drv.ReadAsync(["A", "B"], CancellationToken.None);
factory.Clients[0].SumReadBatches.ShouldBe([2, 2]);
}
/// <summary>Verifies unknown references do not disturb result order.</summary>
[Fact]
public async Task Unknown_reference_keeps_its_slot_in_a_batched_poll()