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 ? "…" : "");