test(abcip): read-parity volatility probe; live tags compared structurally (T21 press pass 1)
In T21 press pass 1, two REAL tags diverged only because their live values moved between the sequential libplctag and native phases (WW_M4ActualTemp 384.887 -> 385.138). The libplctag phase now reads every selected tag a second time, paced, before native starts (TransportObservations.ProbeReads). A tag whose two libplctag reads both succeed and differ is classed live. For a live tag, parity still requires: - the same status (and the native-TypeMismatch rule); - no decode error; - the same raw length; - the same CIP type code when both sides expose one (native records it now; libplctag.NET 1.5.2 does not); - the same decoded shape. Value equality is not required. Live tags get their own list and a "libplctag probe" table column, and are counted as neither expected nor unexpected divergences. A tag that reads stable on libplctag but differs on native is still an unexpected divergence, and a missing or failed probe never makes a tag live. The offline pin A_live_value_is_compared_structurally_and_a_stable_one_by_value goes red with the live branch disabled. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+37
-5
@@ -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);
|
||||
|
||||
/// <summary>What one transport observed for one tag.</summary>
|
||||
internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null)
|
||||
/// <param name="TypeCode">The CIP type code the reply carried, where the transport exposes it (native only: libplctag.NET
|
||||
/// 1.5.2 does not surface it).</param>
|
||||
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
|
||||
/// <summary>Template ids that were queued but not read because <c>AB_DIFF_MAX_TEMPLATES</c> was reached.</summary>
|
||||
public int TemplatesTruncated { get; set; }
|
||||
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Dictionary<string, ReadObservation> ProbeReads { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The outcome of one whole differential run.</summary>
|
||||
@@ -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<SelectedTag> selection,
|
||||
TransportObservations into, Action<string>? afterEach, CancellationToken ct)
|
||||
Dictionary<string, ReadObservation> into, Action<string>? 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>The same decoded shape: both scalars of one CLR type, or arrays of one element type and length.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Equal only when BOTH decoded: an undecodable shape is never parity (review r1 I3).</summary>
|
||||
internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
|
||||
a is not null && b is not null
|
||||
|
||||
+90
-8
@@ -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<string>();
|
||||
var expected = new List<string>();
|
||||
var live = new List<string>();
|
||||
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**");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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**".
|
||||
/// </summary>
|
||||
[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<DifferentialResult> 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 }
|
||||
|
||||
/// <summary>Which list a verdict string belongs to. Live tags are neither expected nor unexpected divergences.</summary>
|
||||
internal static Verdict Classify(string verdict) =>
|
||||
verdict == "yes" ? Verdict.Equal
|
||||
: verdict == "live" ? Verdict.Live
|
||||
: verdict.StartsWith("expected", StringComparison.Ordinal) ? Verdict.Expected
|
||||
: Verdict.Unexpected;
|
||||
|
||||
/// <param name="libProbe">libplctag's second (volatility-probe) read of the tag; null ⇒ treated as stable.</param>
|
||||
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 ? "…" : "");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user