Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs
T

453 lines
26 KiB
C#

using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
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("|---|---|---|---|");
}
var refused = DifferentialRun.EqualRefusals(r.Libplctag, r.Native).ToHashSet();
foreach (var id in ids)
{
var equal = DifferentialRun.ShapesEqual(r.Libplctag.Shapes.GetValueOrDefault(id), r.Native.Shapes.GetValueOrDefault(id));
var verdict = refused.Contains(id) ? "equal refusal" : equal ? "yes" : "**NO**";
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {verdict} |");
}
if (refused.Count > 0)
Log($"- EQUAL REFUSAL (not a divergence; both transports refuse with the same status): {string.Join(", ", refused)}");
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, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); break;
case Verdict.Unexpected when DifferentialRun.IsLive(lib, probe):
unexpected.Add($"{tag.Name}: {verdict} (live; lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})");
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, shape and every " +
$"element that held still): {live.Count} of {r.Selection.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>
/// T21 review r1 M9: the native session is closed properly — exactly one Forward Close on the wire (a skipped
/// close, e.g. on a socket already dropped, would otherwise be visible only in a capture), and no connection to
/// the target left once the anchor is released.
/// </summary>
[Fact]
public async Task The_native_side_closes_its_one_connection_with_exactly_one_forward_close()
{
var r = await RunOrSkipAsync();
Log($"Native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
if (r.ConnectionsAfterNative is { } after && r.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline);
if (!r.Target.CountOpens)
Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Close counter is only " +
"trusted when the differential runs alone.");
r.NativeForwardCloses.Sent.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 } };
// Element 0 moved (1 → 1.1), element 1 held at 2: native may read anything finite for 0, but must read 2 for 1.
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 2f } }, arrProbe).ShouldBe("live");
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe)
.ShouldBe("**NO: stable element 1**");
Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**");
// Review r1 I1, the pass-1 shape: Real[5] where element 0 (400000) holds still and 1..4 move. A native defect
// that shifts the stable element must FAIL even though the tag is live.
var fout = new SelectedTag("Program:EM01_PowerUnit.__fout9FB1561FA00D73F5", AbCipDataType.Real, 5, true, "udt member");
ReadObservation Arr(params float[] v) => new((int)AbStatus.Ok, null, v.SelectMany(BitConverter.GetBytes).ToArray(), v);
var foutLib = Arr(400000f, 4.2464733f, 5.2464733f, -6.7535267f, 4.2464733f);
var foutProbe = Arr(400000f, 4.2461f, 5.2461f, -6.7539f, 4.2461f);
Compare(fout, foutLib, Arr(400000f, 4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe).ShouldBe("live");
var shifted = Compare(fout, foutLib, Arr(4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f, 400000f), foutProbe);
shifted.ShouldBe("**NO: stable element 0**");
Classify(shifted).ShouldBe(Verdict.Unexpected);
Compare(fout, foutLib, Arr(400000f, float.NaN, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe)
.ShouldBe("**NO: non-finite element 1**");
Compare(real, lib, nat with { Decoded = float.PositiveInfinity }, probe).ShouldBe("**NO: non-finite element 0**");
// 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 null) return "live"; // raw-only (type unknown): nothing decoded to compare element-wise
if (!DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**";
// Only what MOVED between libplctag's two reads is excused (review r1 I1): every element that held still
// must read the same through native, so a native offset/stride/byte-order defect cannot hide as "live".
return DifferentialRun.LiveElementMismatch(lib.Decoded, libProbe!.Decoded, nat.Decoded) ?? "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";
}
/// <param name="full">Every raw byte and every element (live tags: the evidence must be re-checkable by hand).</param>
private static string Detail(ReadObservation o, bool full = false) =>
o.Error ?? o.DecodeError ??
$"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw, full)} = {Show(o.Decoded, full)}";
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, bool full = false) =>
raw is null ? "-"
: full || raw.Length <= 32 ? Convert.ToHexString(raw)
: Convert.ToHexString(raw.AsSpan(0, 32)) + "…";
private static string Show(object? v, bool full = false) => v switch
{
null => "null",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(full ? int.MaxValue : 8).Select(x => Show(x))) +
(!full && 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;
}
}
}