297 lines
15 KiB
C#
297 lines
15 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 | Equal? |");
|
|
Log("|---|---|---|---|---|");
|
|
|
|
var unexpected = new List<string>();
|
|
var expected = 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)})");
|
|
Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " +
|
|
$"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |");
|
|
}
|
|
|
|
Log("");
|
|
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}.");
|
|
foreach (var e in expected) Log($"- expected: {e}");
|
|
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);
|
|
}
|
|
|
|
[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");
|
|
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));
|
|
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**");
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 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 (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 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;
|
|
}
|
|
}
|
|
}
|