Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs
T
Joseph Doherty 82a0c298ce 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
2026-10-07 19:50:41 -04:00

400 lines
22 KiB
C#

using System.Buffers.Binary;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>Runs the differential alone: no other suite may hold a session to the target meanwhile.</summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DifferentialCollection : ICollectionFixture<DifferentialRunFixture>
{
public const string Name = "AbCip transport differential";
}
/// <summary>Performs the one run all differential tests read from, on first use.</summary>
public sealed class DifferentialRunFixture
{
private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(15);
private readonly Lazy<Task<DifferentialResult>> _run = new(RunAsync);
internal Task<DifferentialResult> GetAsync() => _run.Value;
private static async Task<DifferentialResult> RunAsync()
{
var target = DifferentialTarget.FromEnvironment()
?? throw new InvalidOperationException(DifferentialTarget.SkipReason);
using var cts = new CancellationTokenSource(RunBudget);
return await DifferentialRun.ExecuteAsync(target, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Read-only differential (Task 18): the same tags through libplctag and the native transport against one
/// endpoint — browse listings, UDT templates and tag reads must agree byte for byte and value for value.
/// Task 21 runs this against a production press, so the suite is read-only by construction (see
/// <see cref="The_harness_is_read_only_by_construction"/>), libplctag and native are never connected at the
/// same time, and the native side runs on one connection held open by an anchor lease.
/// </summary>
[Collection(DifferentialCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
{
[Fact]
public async Task Browse_listings_decode_to_identical_tag_sets_through_both_enumerators()
{
var r = await RunOrSkipAsync();
Log($"## Browse — {r.Target}");
Log($"libplctag: {r.Libplctag.Browse.Count} tag(s){ErrorSuffix(r.Libplctag.BrowseError)}; " +
$"native: {r.Native.Browse.Count} tag(s){ErrorSuffix(r.Native.BrowseError)}");
var onlyLib = DifferentialRun.Missing(r.Libplctag.Browse, r.Native.Browse);
var onlyNative = DifferentialRun.Missing(r.Native.Browse, r.Libplctag.Browse);
foreach (var t in onlyLib) Log($"- only libplctag: {t}");
foreach (var t in onlyNative) Log($"- only native: {t}");
r.Libplctag.BrowseError.ShouldBeNull();
r.Native.BrowseError.ShouldBeNull();
r.Libplctag.Browse.ShouldNotBeEmpty("a browse that returns nothing proves nothing");
onlyLib.ShouldBeEmpty();
onlyNative.ShouldBeEmpty();
}
[Fact]
public async Task Every_template_decodes_to_an_identical_shape_through_both_readers()
{
var r = await RunOrSkipAsync();
var ids = r.Libplctag.Shapes.Keys.Concat(r.Libplctag.TemplateErrors.Keys)
.Concat(r.Native.Shapes.Keys).Concat(r.Native.TemplateErrors.Keys)
.Distinct().Order().ToList();
Log($"## Templates — {ids.Count} id(s); truncated by {DifferentialTarget.MaxTemplatesVar}: " +
$"libplctag {r.Libplctag.TemplatesTruncated}, native {r.Native.TemplatesTruncated}");
if (ids.Count > 0)
{
Log("| Template | libplctag | native | Equal? |");
Log("|---|---|---|---|");
}
foreach (var id in ids)
{
var equal = DifferentialRun.ShapesEqual(r.Libplctag.Shapes.GetValueOrDefault(id), r.Native.Shapes.GetValueOrDefault(id));
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {(equal ? "yes" : "**NO**")} |");
}
var problems = DifferentialRun.TemplateProblems(r.Libplctag, r.Native);
foreach (var p in problems) Log($"- PROBLEM: {p}");
problems.ShouldBeEmpty();
}
[Fact]
public async Task Every_selected_tag_reads_identically_through_both_transports()
{
var r = await RunOrSkipAsync();
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 | 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 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)} | {ProbeColumn(lib, probe)} | {verdict} |");
}
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}.");
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);
foreach (var c in coverage) Log($"- COVERAGE: {c}");
r.Selection.ShouldNotBeEmpty("a run that reads nothing proves nothing");
unexpected.ShouldBeEmpty();
coverage.ShouldBeEmpty();
}
[Fact]
public async Task Libplctag_and_native_are_never_connected_at_the_same_time()
{
var r = await RunOrSkipAsync();
Log($"TCP connections to the target: baseline {r.ConnectionsBaseline?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " +
$"before native {r.ConnectionsBeforeNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " +
$"libplctag drain wait {r.LibplctagDrainWait.TotalMilliseconds:0} ms.");
if (r.ConnectionsBaseline is null)
Assert.Skip("This OS cannot enumerate TCP connections; the run fell back to a fixed settle before the native phase.");
// The run itself refuses to start the native phase while libplctag's session is open; this pins it.
r.ConnectionsBeforeNative.ShouldNotBeNull();
r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value);
}
/// <summary>
/// T21 press pass 1 ended in "A callback was made on a garbage collected delegate": libplctag tags whose
/// initialisation failed (NotFound tags, refused latency setups) were left native-side with a live callback.
/// Every libplctag tag this harness creates goes through the driver's adapters, which now destroy such a tag
/// (<see cref="LibplctagOrphanRelease"/>); this pins that none had to fall back to being rooted instead.
/// </summary>
[Fact]
public async Task No_failed_libplctag_tag_is_left_with_a_live_native_callback()
{
await RunOrSkipAsync();
Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
LibplctagOrphanRelease.RootedCount.ShouldBe(0);
}
[Fact]
public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run()
{
var r = await RunOrSkipAsync();
if (!r.Target.CountOpens)
Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Open counter is only " +
"trusted when the differential runs alone.");
Log($"Native Forward Opens over the run: {r.NativeForwardOpens}.");
r.NativeForwardOpens.ShouldBe(1);
}
/// <summary>
/// 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,
/// if any compiled method of a harness type calls one.
/// </summary>
[Fact]
public void The_harness_is_read_only_by_construction()
{
// Built by concatenation so this file's own text never matches itself.
string[] forbidden =
[
"Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Send" + "Async", "AbCip" + "Driver",
];
// IL half (review r1 M1): method names match by prefix; the driver type matches by declaring type,
// since the driver type's own members are named .ctor / ReadAsync / ..., never the type name.
var forbiddenType = "AbCip" + "Driver";
var dir = Path.GetDirectoryName(ThisFile())!;
var sources = Directory.GetFiles(dir, "*.cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs");
// Task 21's latency comparison and its run helper are part of the harness the press sees: pinned in scope,
// so moving either out of this folder (or namespace) fails the guard instead of silently escaping it.
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyComparisonTests) + ".cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyRun) + ".cs");
var textHits = (from f in sources
let text = File.ReadAllText(f)
from token in forbidden
where text.Contains(token, StringComparison.Ordinal)
select $"{Path.GetFileName(f)} names {token}").ToList();
textHits.ShouldBeEmpty();
var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList();
harnessTypes.ShouldContain(typeof(TransportDifferentialTests));
harnessTypes.ShouldContain(typeof(PollLatencyComparisonTests));
harnessTypes.ShouldContain(typeof(PollLatencyRun));
var callHits = (from t in harnessTypes
from m in MethodsOf(t)
from callee in CalleesOf(m)
where forbidden.Any(f => callee.Name.StartsWith(f, StringComparison.Ordinal))
|| callee.DeclaringType?.Name.StartsWith(forbiddenType, StringComparison.Ordinal) == true
select $"{t.Name}.{m.Name} calls {callee.DeclaringType?.Name}.{callee.Name}").ToList();
callHits.ShouldBeEmpty();
}
/// <summary>
/// Offline falsifiability check of the verdicts: a harness whose comparison could never say NO would
/// pass every live run vacuously.
/// </summary>
[Fact]
public void The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch()
{
var dint = new SelectedTag("T", AbCipDataType.DInt, 1, false, "scalar");
var ok = new ReadObservation((int)AbStatus.Ok, null, [1, 0, 0, 0], 1);
Compare(dint, ok, ok with { }).ShouldBe("yes");
Compare(dint, ok, ok with { Raw = [2, 0, 0, 0] }).ShouldBe("**NO: raw**");
Compare(dint, ok, ok with { Raw = null }).ShouldBe("**NO: raw**");
Compare(dint, ok, ok with { Decoded = 2 }).ShouldBe("**NO: value**");
Compare(dint, ok, new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null)).ShouldBe("**NO: status**");
Compare(dint, ok, ok with { Error = "boom" }).ShouldBe("**NO: error**");
var notFound = new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null);
Compare(dint, notFound, notFound with { }).ShouldBe("yes");
Compare(dint, ok, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null))
.ShouldStartWith("expected");
// Only that direction is excused: libplctag refusing what native accepts is a real divergence.
Compare(dint, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null), ok)
.ShouldBe("**NO: status**");
var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array");
var arr = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { float.NaN, 1f });
Compare(array, arr, arr with { Decoded = new[] { float.NaN, 1f } }).ShouldBe("yes");
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 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)
return "expected (native TypeMismatch, libplctag Ok)";
if (lib.Status != nat.Status) return "**NO: status**";
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";
}
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 ? "…" : "");
private static string Show(object? v) => v switch
{
null => "null",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};
private static string Shape(TransportObservations o, uint id) =>
o.TemplateErrors.TryGetValue(id, out var err) ? $"error {err}"
: o.Shapes.TryGetValue(id, out var s) ? s is null ? "undecodable" : $"{s.TypeName} ({s.Members.Count} members, {s.TotalSize} B)"
: "not read";
private static string StatusName(int status) =>
Enum.IsDefined(typeof(AbStatus), status) ? ((AbStatus)status).ToString() : status.ToString(CultureInfo.InvariantCulture);
private static string ErrorSuffix(string? error) => error is null ? "" : $" (error: {error})";
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
private static string ThisFile([CallerFilePath] string path = "") => path;
private static IEnumerable<MethodBase> MethodsOf(Type t)
{
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance
| BindingFlags.Static | BindingFlags.DeclaredOnly;
return t.GetMethods(all).Cast<MethodBase>().Concat(t.GetConstructors(all));
}
/// <summary>
/// Every method a body's IL references by token (call / callvirt / newobj / ldftn). Scans every offset
/// for a MethodDef / MemberRef / MethodSpec token — over-inclusive by design: a misaligned hit that
/// happens to resolve can only add a false alarm, never hide a real call.
/// </summary>
private static IEnumerable<MethodBase> CalleesOf(MethodBase m)
{
var il = m.GetMethodBody()?.GetILAsByteArray();
if (il is null) yield break;
var typeArgs = m.DeclaringType is { IsGenericType: true } dt ? dt.GetGenericArguments() : null;
var methodArgs = m.IsGenericMethod ? m.GetGenericArguments() : null;
for (var i = 0; i + 4 <= il.Length; i++)
{
var token = BinaryPrimitives.ReadInt32LittleEndian(il.AsSpan(i, 4));
if ((token >> 24) is not (0x06 or 0x0A or 0x2B)) continue;
MethodBase? callee;
try { callee = m.Module.ResolveMethod(token, typeArgs, methodArgs); }
catch (Exception) { continue; }
if (callee is not null) yield return callee;
}
}
}