diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs index ce983425..5be2d046 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs @@ -50,6 +50,13 @@ internal sealed class DifferentialResult /// Native Forward Opens counted between the counter reset and the anchor release. public required int NativeForwardOpens { get; init; } + /// Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the + /// same window as — the anchor's release included (T21 review r1 M9). + public required NativeCloses NativeForwardCloses { get; init; } + + /// TCP connections to the endpoint once the native session was released and drained (null when unobservable). + public required int? ConnectionsAfterNative { get; init; } + /// TCP connections to the endpoint before libplctag started, and once its handles were disposed. public required int? ConnectionsBaseline { get; init; } public required int? ConnectionsBeforeNative { get; init; } @@ -61,6 +68,9 @@ internal sealed class DifferentialResult public required IReadOnlySet? BaselineEndpoints { get; init; } } +/// Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget. +internal readonly record struct NativeCloses(int Sent, int Acked); + /// /// The run stopped at its first connection-level failure, or when the native side opened more than once. /// Thrown, never swallowed: every live test then fails with this message. @@ -117,7 +127,10 @@ internal static class DifferentialRun drain.Stop(); // ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ---- - var (native, opens) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false); + var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false); + + // ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ---- + var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false); return new DifferentialResult { @@ -126,6 +139,8 @@ internal static class DifferentialRun Native = native, Selection = selection, NativeForwardOpens = opens, + NativeForwardCloses = closes, + ConnectionsAfterNative = afterNative, ConnectionsBaseline = baseline?.Count, ConnectionsBeforeNative = beforeNative, LibplctagDrainWait = drain.Elapsed, @@ -182,20 +197,20 @@ internal static class DifferentialRun } /// The native phase alone; internal so the offline abort test can drive it against a fake endpoint. - internal static async Task<(TransportObservations Observations, int ForwardOpens)> RunNativePhaseAsync( + internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync( DifferentialTarget target, IReadOnlyList selection, CancellationToken ct) { const string phase = "native"; var native = new TransportObservations(); var factories = AbCipTransportFactories.For(AbCipTransport.Native); - var (_, forwardOpens) = await WithNativeAnchorAsync(phase, target, async checkOpens => + var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens => { await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false); await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false); await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false); return true; }).ConfigureAwait(false); - return (native, forwardOpens); + return (native, forwardOpens, forwardCloses); } /// @@ -205,8 +220,9 @@ internal static class DifferentialRun /// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in /// finally. Shared by the differential and the latency comparison. /// - /// The body's result and the Forward Opens counted between the reset and the anchor release. - internal static async Task<(T Result, int ForwardOpens)> WithNativeAnchorAsync( + /// The body's result, the Forward Opens counted between the reset and the anchor release, and the + /// Forward Closes counted between the reset and the END of the anchor release (which awaits the close). + internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync( string phase, DifferentialTarget target, Func, Task> body) { var anchorParams = target.Params("@tags"); @@ -221,9 +237,12 @@ internal static class DifferentialRun } Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0); + Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0); + Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0); Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1); T result; int forwardOpens; + NativeCloses forwardCloses; try { // Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry. @@ -236,13 +255,15 @@ internal static class DifferentialRun { forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount); await anchor.DisposeAsync().ConfigureAwait(false); + forwardCloses = new NativeCloses( + Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked)); } } finally { Volatile.Write(ref CipConnection.OpenAttemptBudget, -1); } - return (result, forwardOpens); + return (result, forwardOpens, forwardCloses); } private static async Task BrowseAsync( @@ -582,6 +603,47 @@ internal static class DifferentialRun probe is not null && lib.Succeeded && probe.Succeeded && lib.Raw is not null && probe.Raw is not null && !lib.Raw.AsSpan().SequenceEqual(probe.Raw); + /// + /// The value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise + /// over the decoded values: + /// + /// an element EQUAL in both libplctag reads did not move, so native must read it equal too — + /// otherwise **NO: stable element i** (a stride, offset or byte-order defect lands here); + /// an element that moved is excused its value, but native must not read a NaN/∞ that neither + /// libplctag read saw — **NO: non-finite element i**. + /// + /// Null means the check passed. Shapes must already agree between libplctag and native. + /// + internal static string? LiveElementMismatch(object? lib, object? probe, object? nat) + { + var l = Elements(lib); + var p = Elements(probe); + var n = Elements(nat); + if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it + if (l.Length != n.Length) return "**NO: shape**"; + for (var i = 0; i < l.Length; i++) + { + if (Equals(l[i], p[i])) + { + if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**"; + } + else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i])) + { + return $"**NO: non-finite element {i}**"; + } + } + return null; + } + + private static object?[] Elements(object? v) => v is Array a ? a.Cast().ToArray() : [v]; + + private static bool IsFinite(object? v) => v switch + { + float f => float.IsFinite(f), + double d => double.IsFinite(d), + _ => true, + }; + /// The same decoded shape: both scalars of one CLR type, or arrays of one element type and length. internal static bool SameShape(object? a, object? b) => (a, b) switch { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs index 3397ce98..46255cbd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs @@ -37,7 +37,10 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) $"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms."); Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}"))); if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}"); - Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}."); + Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}; " + + $"native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " + + $"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}."); + Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}."); Log(""); var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50); @@ -54,14 +57,17 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " + (natP95 <= limit ? "PASS" : "**FAIL**")); Log(""); - Log("| Pass | libplctag ms | native ms | native requests |"); - Log("|---|---|---|---|"); + // Attribution (review r1 M8): GC collections during each pass (gen0/gen1/gen2) on both sides, and the + // round trip of each native CIP exchange — a slow pass with fast exchanges is a client-side stall. + Log("| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |"); + Log("|---|---|---|---|---|---|---|"); for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++) { var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null; var nat = i < r.Native.Count ? r.Native[i] : null; - Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | " + - $"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} |"); + Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {lib?.Gc.ToString() ?? "-"} | " + + $"{(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | {nat?.Gc.ToString() ?? "-"} | " + + $"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} | {Rtt(nat?.Rtt)} |"); } foreach (var p in r.Problems) Log($"- PROBLEM: {p}"); @@ -70,7 +76,15 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) r.Native.Count.ShouldBe(r.Target.LatencyPasses); r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed"); r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed"); - if (r.Target.CountOpens) r.NativeForwardOpens.ShouldBe(1); + if (r.Target.CountOpens) + { + r.NativeForwardOpens.ShouldBe(1); + r.NativeForwardCloses.Sent.ShouldBe(1, "the native phase's one connection must be closed with exactly one Forward Close"); + } + if (r.ConnectionsAfterNative is { } after && differential.ConnectionsBaseline is { } baseline) + after.ShouldBeLessThanOrEqualTo(baseline, "the native session must be gone when the run reports"); + // Review r1 M4: the latency setup's refused libplctag tags are orphan candidates too — checked after them. + LibplctagOrphanRelease.RootedCount.ShouldBe(0); natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}"); } @@ -111,6 +125,9 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) $"{passes.Sum(p => p.TagFailures)} |"; } + private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) => + s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}"; + private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture); private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs index 6a95ecb3..c774cb60 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs @@ -7,7 +7,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; /// One timed burst: every tag read once, concurrently. /// Native only: delta over the pass (keep-alives included). /// Reads that ended with a tag-level status (a connection-level one aborts instead). -internal sealed record LatencyPass(int Index, TimeSpan Wall, int? Requests, int TagFailures); +/// Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here). +/// Native only: round trip of each CIP exchange in the pass, from (a slow +/// controller shows here; a slow pass with fast exchanges points at the client). +internal sealed record LatencyPass( + int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null); + +/// GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports). +internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2) +{ + public static GcDelta Now() => new(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2)); + public static GcDelta operator -(GcDelta a, GcDelta b) => new(a.Gen0 - b.Gen0, a.Gen1 - b.Gen1, a.Gen2 - b.Gen2); + public override string ToString() => $"{Gen0}/{Gen1}/{Gen2}"; +} /// The outcome of the latency comparison. internal sealed class LatencyResult @@ -25,6 +37,12 @@ internal sealed class LatencyResult public required IReadOnlyList Native { get; init; } public required int NativeForwardOpens { get; init; } + /// Forward Closes the native phase sent / got a reply to, its anchor release included (review r1 M9). + public required NativeCloses NativeForwardCloses { get; init; } + + /// TCP connections to the endpoint once the native phase's session was released and drained. + public required int? ConnectionsAfterNative { get; init; } + /// Native requests spent creating the runtimes (one read each, before any timed pass). public required int NativeSetupRequests { get; init; } public required List Problems { get; init; } @@ -114,7 +132,7 @@ internal static class PollLatencyRun // ---- native: the same (post-drop) set, under one anchor lease. ---- var problems = new List(); var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags; - var ((nativePasses, setupRequests), opens) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens => + var ((nativePasses, setupRequests), opens, closes) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens => { var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>(); try @@ -138,6 +156,10 @@ internal static class PollLatencyRun } }).ConfigureAwait(false); + // ---- Barrier: the native session must be gone before the run reports (review r1 M9). ---- + var afterNative = await DifferentialRun.WaitForSessionsToCloseAsync( + target, differential.Addresses, differential.BaselineEndpoints, "latency native", ct).ConfigureAwait(false); + return new LatencyResult { Target = target, @@ -148,6 +170,8 @@ internal static class PollLatencyRun Libplctag = libPasses, Native = nativePasses, NativeForwardOpens = opens, + NativeForwardCloses = closes, + ConnectionsAfterNative = afterNative, NativeSetupRequests = setupRequests, Problems = problems, }; @@ -191,9 +215,13 @@ internal static class PollLatencyRun if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false); var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent); + CipConnection.ResetExchangeStats(); + var gcBefore = GcDelta.Now(); var clock = Stopwatch.StartNew(); var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false); clock.Stop(); + var gc = GcDelta.Now() - gcBefore; + var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null; var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore; var failures = 0; @@ -211,7 +239,7 @@ internal static class PollLatencyRun } } afterEach?.Invoke($"pass {p}"); - passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures)); + passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures, gc, rtt)); } return passes; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs index f2c86027..453cb1f8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs @@ -110,7 +110,10 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) switch (Classify(verdict)) { case Verdict.Expected: expected.Add($"{tag.Name}: {verdict}"); break; - case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib)}; lib probe {Detail(probe!)}; native {Detail(nat)})"); break; + case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); break; + case Verdict.Unexpected when DifferentialRun.IsLive(lib, probe): + unexpected.Add($"{tag.Name}: {verdict} (live; lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); + break; case Verdict.Unexpected: unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); break; } Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " + @@ -119,7 +122,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) Log(""); Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}; " + - $"live tags (value moved between the two libplctag reads; compared by status, length, type code and shape): {live.Count}."); + $"live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every " + + $"element that held still): {live.Count} of {r.Selection.Count}."); foreach (var e in expected) Log($"- expected: {e}"); foreach (var l in live) Log($"- live: {l}"); foreach (var u in unexpected) Log($"- UNEXPECTED: {u}"); @@ -172,6 +176,25 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) r.NativeForwardOpens.ShouldBe(1); } + /// + /// T21 review r1 M9: the native session is closed properly — exactly one Forward Close on the wire (a skipped + /// close, e.g. on a socket already dropped, would otherwise be visible only in a capture), and no connection to + /// the target left once the anchor is released. + /// + [Fact] + public async Task The_native_side_closes_its_one_connection_with_exactly_one_forward_close() + { + var r = await RunOrSkipAsync(); + Log($"Native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " + + $"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}."); + if (r.ConnectionsAfterNative is { } after && r.ConnectionsBaseline is { } baseline) + after.ShouldBeLessThanOrEqualTo(baseline); + if (!r.Target.CountOpens) + Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Close counter is only " + + "trusted when the differential runs alone."); + r.NativeForwardCloses.Sent.ShouldBe(1); + } + /// /// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any /// source file of this harness names a write-capable member, and, independently of the source text, @@ -278,9 +301,26 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array"); var arrLib = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { 1f, 2f }); var arrProbe = arrLib with { Raw = [1, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 1.1f, 2f } }; - Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe).ShouldBe("live"); + // Element 0 moved (1 → 1.1), element 1 held at 2: native may read anything finite for 0, but must read 2 for 1. + Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 2f } }, arrProbe).ShouldBe("live"); + Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe) + .ShouldBe("**NO: stable element 1**"); Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**"); + // Review r1 I1, the pass-1 shape: Real[5] where element 0 (400000) holds still and 1..4 move. A native defect + // that shifts the stable element must FAIL even though the tag is live. + var fout = new SelectedTag("Program:EM01_PowerUnit.__fout9FB1561FA00D73F5", AbCipDataType.Real, 5, true, "udt member"); + ReadObservation Arr(params float[] v) => new((int)AbStatus.Ok, null, v.SelectMany(BitConverter.GetBytes).ToArray(), v); + var foutLib = Arr(400000f, 4.2464733f, 5.2464733f, -6.7535267f, 4.2464733f); + var foutProbe = Arr(400000f, 4.2461f, 5.2461f, -6.7539f, 4.2461f); + Compare(fout, foutLib, Arr(400000f, 4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe).ShouldBe("live"); + var shifted = Compare(fout, foutLib, Arr(4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f, 400000f), foutProbe); + shifted.ShouldBe("**NO: stable element 0**"); + Classify(shifted).ShouldBe(Verdict.Unexpected); + Compare(fout, foutLib, Arr(400000f, float.NaN, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe) + .ShouldBe("**NO: non-finite element 1**"); + Compare(real, lib, nat with { Decoded = float.PositiveInfinity }, probe).ShouldBe("**NO: non-finite element 0**"); + // Stable: the two libplctag reads agree, so native reading anything else is a real decoding divergence. var stableProbe = lib with { }; DifferentialRun.IsLive(lib, stableProbe).ShouldBeFalse(); @@ -326,16 +366,21 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) // Everything that does not depend on the value must still agree. if (nat.Raw is null || nat.Raw.Length != lib.Raw!.Length) return "**NO: raw length**"; if (lib.TypeCode is { } lt && nat.TypeCode is { } nt && lt != nt) return "**NO: type code**"; - if (tag.Type is not null && !DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**"; - return "live"; + if (tag.Type is null) return "live"; // raw-only (type unknown): nothing decoded to compare element-wise + if (!DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**"; + // Only what MOVED between libplctag's two reads is excused (review r1 I1): every element that held still + // must read the same through native, so a native offset/stride/byte-order defect cannot hide as "live". + return DifferentialRun.LiveElementMismatch(lib.Decoded, libProbe!.Decoded, nat.Decoded) ?? "live"; } if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**"; if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**"; return "yes"; } - private static string Detail(ReadObservation o) => - o.Error ?? o.DecodeError ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}"; + /// Every raw byte and every element (live tags: the evidence must be re-checkable by hand). + private static string Detail(ReadObservation o, bool full = false) => + o.Error ?? o.DecodeError ?? + $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw, full)} = {Show(o.Decoded, full)}"; private static string ProbeColumn(ReadObservation lib, ReadObservation? probe) => probe is null ? "-" @@ -343,13 +388,16 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) : probe.Succeeded && lib.Succeeded ? "stable" : StatusName(probe.Status); - private static string Hex(byte[]? raw) => - raw is null ? "-" : Convert.ToHexString(raw.AsSpan(0, Math.Min(raw.Length, 32))) + (raw.Length > 32 ? "…" : ""); + private static string Hex(byte[]? raw, bool full = false) => + raw is null ? "-" + : full || raw.Length <= 32 ? Convert.ToHexString(raw) + : Convert.ToHexString(raw.AsSpan(0, 32)) + "…"; - private static string Show(object? v) => v switch + private static string Show(object? v, bool full = false) => v switch { null => "null", - Array a => "[" + string.Join(",", a.Cast().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]", + Array a => "[" + string.Join(",", a.Cast().Take(full ? int.MaxValue : 8).Select(x => Show(x))) + + (!full && a.Length > 8 ? ",…" : "") + "]", IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => v.ToString() ?? "", };