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 89e31cbf..ce983425 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 @@ -11,7 +11,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin); /// What one transport observed for one tag. -internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null) +/// The CIP type code the reply carried, where the transport exposes it (native only: libplctag.NET +/// 1.5.2 does not surface it). +internal sealed record ReadObservation( + int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null, ushort? TypeCode = null) { public bool Succeeded => Status == (int)AbStatus.Ok && Error is null; } @@ -27,6 +30,13 @@ internal sealed class TransportObservations /// Template ids that were queued but not read because AB_DIFF_MAX_TEMPLATES was reached. public int TemplatesTruncated { get; set; } public Dictionary Reads { get; } = new(StringComparer.Ordinal); + + /// + /// libplctag only: a SECOND paced read of every selected tag, taken after the first pass and before native + /// starts (T21 press pass 1). A tag whose two reads differ is live — its value moves on its own — and is + /// compared with native structurally, not by value. Empty on the native side. + /// + public Dictionary ProbeReads { get; } = new(StringComparer.Ordinal); } /// The outcome of one whole differential run. @@ -141,7 +151,10 @@ internal static class DifferentialRun await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false); await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false); var selection = Select(target, lib); - await ReadTagsAsync(phase, target, factories.Tags, selection, lib, afterEach: null, ct).ConfigureAwait(false); + await ReadTagsAsync(phase, target, factories.Tags, selection, lib.Reads, afterEach: null, ct).ConfigureAwait(false); + // Volatility probe: read every tag again, paced, so a value that moves on its own (a live analog) is + // recognised as live rather than reported as a transport divergence when native reads it later. + await ReadTagsAsync(phase, target, factories.Tags, selection, lib.ProbeReads, afterEach: null, ct).ConfigureAwait(false); return ((TransportObservations)lib, selection); }, ct); } @@ -179,7 +192,7 @@ internal static class DifferentialRun { 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, checkOpens, ct).ConfigureAwait(false); + await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false); return true; }).ConfigureAwait(false); return (native, forwardOpens); @@ -401,7 +414,7 @@ internal static class DifferentialRun private static async Task ReadTagsAsync( string phase, DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList selection, - TransportObservations into, Action? afterEach, CancellationToken ct) + Dictionary into, Action? afterEach, CancellationToken ct) { foreach (var tag in selection) { @@ -440,7 +453,8 @@ internal static class DifferentialRun decodeError = Describe(ex); // recorded, and a NO verdict (review r1 M3) } } - into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError); + into[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError, + (runtime as NativeAbCipTagRuntime)?.TypeCode); } finally { @@ -560,6 +574,24 @@ internal static class DifferentialRun ? StructuralComparisons.StructuralEqualityComparer.Equals(a, b) : Equals(a, b); + /// + /// True when the libplctag volatility probe saw the value move by itself: both libplctag reads succeeded and + /// their bytes differ. A missing or failed probe is never live, so such a tag keeps the full value comparison. + /// + internal static bool IsLive(ReadObservation lib, ReadObservation? probe) => + 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 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 + { + (null, null) => true, + (Array x, Array y) => x.GetType() == y.GetType() && x.Length == y.Length, + (Array, _) or (_, Array) => false, + (not null, not null) => a.GetType() == b.GetType(), + _ => false, + }; + /// Equal only when BOTH decoded: an undecodable shape is never parity (review r1 I3). internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) => a is not null && b is not null 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 5469f843..f2c86027 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 @@ -95,25 +95,33 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}"); Log("Selection by origin: " + string.Join(", ", r.Selection.GroupBy(t => t.Origin).Select(g => $"{g.Key} {g.Count()}"))); - Log("| Tag | Type | libplctag status | native status | Equal? |"); - Log("|---|---|---|---|---|"); + Log("| Tag | Type | libplctag status | native status | libplctag probe | Equal? |"); + Log("|---|---|---|---|---|---|"); var unexpected = new List(); var expected = new List(); + var live = new List(); foreach (var tag in r.Selection) { var lib = r.Libplctag.Reads[tag.Name]; var nat = r.Native.Reads[tag.Name]; - var verdict = Compare(tag, lib, nat); - if (verdict.StartsWith("expected", StringComparison.Ordinal)) expected.Add($"{tag.Name}: {verdict}"); - else if (verdict != "yes") unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); + var probe = r.Libplctag.ProbeReads.GetValueOrDefault(tag.Name); + var verdict = Compare(tag, lib, nat, probe); + 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.Unexpected: unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); break; + } Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " + - $"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |"); + $"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {ProbeColumn(lib, probe)} | {verdict} |"); } Log(""); - Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}."); + 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}."); foreach (var e in expected) Log($"- expected: {e}"); + foreach (var l in live) Log($"- live: {l}"); foreach (var u in unexpected) Log($"- UNEXPECTED: {u}"); var coverage = DifferentialRun.SelectionCoverageProblems(r.Target, r.Libplctag, r.Selection); @@ -238,13 +246,72 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**"); } + /// + /// Offline pin of the live-drift handling (T21 press pass 1): a value that moved between the two libplctag + /// reads is compared structurally, while a value libplctag read stably and native read differently is still + /// an unexpected divergence. Removing the live handling turns the first assertion into "**NO: raw**". + /// + [Fact] + public void A_live_value_is_compared_structurally_and_a_stable_one_by_value() + { + var real = new SelectedTag("WW_M4ActualTemp", AbCipDataType.Real, 1, false, "scalar"); + ReadObservation Obs(float v) => new((int)AbStatus.Ok, null, BitConverter.GetBytes(v), v); + var lib = Obs(384.88684f); + var probe = Obs(384.95f); + var nat = Obs(385.13782f) with { TypeCode = 0xCA }; + + // Live: the libplctag value moved by itself, so a different native value is not a divergence. + DifferentialRun.IsLive(lib, probe).ShouldBeTrue(); + Compare(real, lib, nat, probe).ShouldBe("live"); + Classify(Compare(real, lib, nat, probe)).ShouldBe(Verdict.Live); + + // ...but everything structural still has to match. + Compare(real, lib, nat with { Status = (int)AbStatus.NotFound, Error = "NotFound" }, probe).ShouldBe("**NO: status**"); + Compare(real, lib, nat with { Raw = new byte[8] }, probe).ShouldBe("**NO: raw length**"); + Compare(real, lib, nat with { Raw = null }, probe).ShouldBe("**NO: raw length**"); + Compare(real, lib with { TypeCode = 0xC4 }, nat, probe).ShouldBe("**NO: type code**"); + Compare(real, lib, nat with { Decoded = 385 }, probe).ShouldBe("**NO: shape**"); + Compare(real, lib, nat with { Decoded = new[] { 385f } }, probe).ShouldBe("**NO: shape**"); + Compare(real, lib, nat with { DecodeError = "boom" }, probe).ShouldBe("**NO: decode**"); + Classify("**NO: raw length**").ShouldBe(Verdict.Unexpected); + + 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"); + Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**"); + + // 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(); + Compare(real, lib, nat, stableProbe).ShouldBe("**NO: raw**"); + Classify(Compare(real, lib, nat, stableProbe)).ShouldBe(Verdict.Unexpected); + Compare(real, lib, lib with { Decoded = 1f }, stableProbe).ShouldBe("**NO: value**"); + Compare(real, lib, lib with { }, stableProbe).ShouldBe("yes"); + Classify("yes").ShouldBe(Verdict.Equal); + + // A missing or failed probe is never live: the full value comparison applies. + Compare(real, lib, nat, null).ShouldBe("**NO: raw**"); + Compare(real, lib, nat, new ReadObservation((int)AbStatus.Timeout, "Timeout", null, null)).ShouldBe("**NO: raw**"); + } + private async Task RunOrSkipAsync() { if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason); return await fixture.GetAsync(); } - internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat) + internal enum Verdict { Equal, Expected, Live, Unexpected } + + /// Which list a verdict string belongs to. Live tags are neither expected nor unexpected divergences. + internal static Verdict Classify(string verdict) => + verdict == "yes" ? Verdict.Equal + : verdict == "live" ? Verdict.Live + : verdict.StartsWith("expected", StringComparison.Ordinal) ? Verdict.Expected + : Verdict.Unexpected; + + /// libplctag's second (volatility-probe) read of the tag; null ⇒ treated as stable. + internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat, ReadObservation? libProbe = null) { // Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway. if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok) @@ -253,6 +320,15 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) if (!lib.Succeeded || !nat.Succeeded) return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**"; if (lib.DecodeError is not null || nat.DecodeError is not null) return "**NO: decode**"; + if (DifferentialRun.IsLive(lib, libProbe)) + { + // The value moved between libplctag's own two reads, so native reading a third value proves nothing. + // 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 (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"; @@ -261,6 +337,12 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) private static string Detail(ReadObservation o) => o.Error ?? o.DecodeError ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}"; + private static string ProbeColumn(ReadObservation lib, ReadObservation? probe) => + probe is null ? "-" + : DifferentialRun.IsLive(lib, probe) ? "**live**" + : 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 ? "…" : "");