using System.Collections; using System.Diagnostics; using System.Net; using System.Net.NetworkInformation; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; /// One tag the harness reads, with the type it decodes under (null ⇒ raw comparison only). internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin); /// What one transport observed for one tag. /// 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; } /// What one transport observed over its whole phase. internal sealed class TransportObservations { public List Browse { get; } = []; public string? BrowseError { get; set; } public Dictionary Shapes { get; } = []; public Dictionary TemplateErrors { get; } = []; /// /// The transport status of each refused template, where the refusal was an . /// Lets parity tell a template BOTH transports refuse with the same status (an equal refusal) from a real /// divergence without parsing text. /// public Dictionary TemplateErrorStatuses { get; } = []; /// 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. internal sealed class DifferentialResult { public required DifferentialTarget Target { get; init; } public required TransportObservations Libplctag { get; init; } public required TransportObservations Native { get; init; } public required IReadOnlyList Selection { get; init; } /// Native Forward Opens counted between the counter reset and the anchor release. public required int NativeForwardOpens { get; init; } /// Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the /// same window as — the anchor's release included (T21 review r1 M9). public required NativeCloses NativeForwardCloses { get; init; } /// TCP connections to the endpoint once the native session was released and drained (null when unobservable). public required int? ConnectionsAfterNative { get; init; } /// TCP connections to the endpoint before libplctag started, and once its handles were disposed. public required int? ConnectionsBaseline { get; init; } public required int? ConnectionsBeforeNative { get; init; } public required TimeSpan LibplctagDrainWait { get; init; } /// The endpoint's resolved addresses, and this machine's connections to it before the run (null when /// unobservable). Reused by the latency comparison's barriers, which must not start alongside a session. public required IReadOnlySet Addresses { get; init; } public required IReadOnlySet? BaselineEndpoints { get; init; } } /// Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget. internal readonly record struct NativeCloses(int Sent, int Acked); /// /// The run stopped at its first connection-level failure, or when the native side opened more than once. /// Thrown, never swallowed: every live test then fails with this message. /// internal sealed class DifferentialAbortedException(string message, Exception? inner = null) : Exception(message, inner); /// /// Runs the whole comparison ONCE: every libplctag operation first (handles disposed), then — only /// once libplctag's session is gone — every native operation under one anchor lease. Reads only: /// browse listings, template reads and tag reads. Nothing on this path encodes or sends a write. /// Stop on first failure (review r1 C1). The first connection-level failure on either side — /// connect / RegisterSession / Forward Open refused, timeout, session lost — aborts the whole run with the /// operation and status; nothing is retried. The native side is additionally capped at ONE open attempt /// to the press under test (), so a reconnect cannot reach the wire even /// from inside an enumerator that swallows a per-program failure, and the run aborts the moment /// passes 1. /// internal static class DifferentialRun { private const int MaxArrays = 20; private const int MaxUdtMembers = 20; /// Upper bound on waiting for libplctag's session to close before native work may start. private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30); /// Settle used when the platform cannot enumerate TCP connections. private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5); /// Bound on resolving the endpoint's host name (M5). private static readonly TimeSpan ResolveBudget = TimeSpan.FromSeconds(5); /// /// Statuses that describe ONE tag and leave the session healthy. Everything else — Timeout, BadConnection, /// Abort, BadReply, Pending, any unexpected exception — is connection-level and aborts the run. /// private static readonly HashSet TagLevelStatuses = [ AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds, AbStatus.TooLarge, AbStatus.TooSmall, AbStatus.Unsupported, AbStatus.TypeMismatch, ]; public static async Task ExecuteAsync(DifferentialTarget target, CancellationToken ct) { var addresses = await ResolveAsync(target, ct).ConfigureAwait(false); var baseline = LocalEndpointsTo(target, addresses); // ---- Phase 1: libplctag only. Every handle is disposed before the phase ends. ---- var (lib, selection) = await RunLibplctagPhaseAsync(target, ct).ConfigureAwait(false); // ---- Barrier: libplctag's session must be gone before the native side connects. ---- var drain = Stopwatch.StartNew(); var beforeNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "libplctag", ct).ConfigureAwait(false); drain.Stop(); // ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ---- var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false); // ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ---- var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false); return new DifferentialResult { Target = target, Libplctag = lib, Native = native, Selection = selection, NativeForwardOpens = opens, NativeForwardCloses = closes, ConnectionsAfterNative = afterNative, ConnectionsBaseline = baseline?.Count, ConnectionsBeforeNative = beforeNative, LibplctagDrainWait = drain.Elapsed, Addresses = addresses, BaselineEndpoints = baseline, }; } /// /// libplctag shares one session (and one Forward Open) among the handles alive at the same time, and /// closes it when the last one is destroyed. The per-tag create/read/dispose below would therefore open /// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps /// every libplctag operation on ONE session — the mirror of the native anchor lease. /// internal static Task<(TransportObservations Observations, IReadOnlyList Selection)> RunLibplctagPhaseAsync( DifferentialTarget target, CancellationToken ct) { const string phase = "libplctag"; var lib = new TransportObservations(); var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag); return WithLibplctagAnchorAsync(phase, target, "@tags", async () => { 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.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); } /// /// Holds one libplctag handle on for the whole of , so every /// libplctag operation in it shares ONE session and ONE Forward Open. A failure to create the anchor aborts /// before runs; the anchor is disposed when ends, either way. /// Shared by the differential (anchor @tags) and the latency comparison (anchor = its first tag). /// internal static async Task WithLibplctagAnchorAsync( string phase, DifferentialTarget target, string anchorTag, Func> body, CancellationToken ct) { var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag); using var anchor = factories.Tags.Create(target.Params(anchorTag)); try { await anchor.InitializeAsync(ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { throw Abort(phase, $"anchor session ({anchorTag})", ex); } return await body().ConfigureAwait(false); } /// The native phase alone; internal so the offline abort test can drive it against a fake endpoint. internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync( DifferentialTarget target, IReadOnlyList selection, CancellationToken ct) { const string phase = "native"; var native = new TransportObservations(); var factories = AbCipTransportFactories.For(AbCipTransport.Native); var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens => { 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.Reads, checkOpens, ct).ConfigureAwait(false); return true; }).ConfigureAwait(false); return (native, forwardOpens, forwardCloses); } /// /// Runs with every native operation sharing ONE connection, held open by an anchor /// lease: the shared pool's Forward Open counters are reset, the press is limited to exactly one /// open attempt, and receives a check to call after each operation that aborts the /// moment a second Forward Open reaches the wire. The anchor is released and the limit lifted in /// finally. Shared by the differential and the latency comparison. /// /// The body's result, the Forward Opens counted between the reset and the anchor release, and the /// Forward Closes counted between the reset and the END of the anchor release (which awaits the close). internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync( string phase, DifferentialTarget target, Func, Task> body) { var anchorParams = target.Params("@tags"); var key = NativeAbCipDevice.Key(anchorParams); var diagnostics = DeviceSessionPool.Shared.Diagnostics; void CheckOpens(string operation) { var opens = diagnostics.ForwardOpens; if (opens > 1) throw new DifferentialAbortedException( $"Differential ABORTED in the {phase} phase after {operation}: {opens} Forward Open frames on the wire, " + "expected exactly one (a reconnect, or a Large→regular fallback). Nothing was retried."); } diagnostics.ResetForwardCounts(); T result; int forwardOpens; NativeCloses forwardCloses; // Exactly one open attempt to the press under test; lifted when the scope ends, whatever happens. using (diagnostics.LimitOpenAttempts(key, 1)) { // Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry. var anchor = DeviceSessionPool.Shared.Acquire(key, NativeAbCipDevice.Options(anchorParams)); try { result = await body(CheckOpens).ConfigureAwait(false); } finally { forwardOpens = diagnostics.ForwardOpens; await anchor.DisposeAsync().ConfigureAwait(false); forwardCloses = new NativeCloses(diagnostics.ForwardClosesSent, diagnostics.ForwardClosesAcked); } } return (result, forwardOpens, forwardCloses); } private static async Task BrowseAsync( string phase, DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into, Action? afterEach, CancellationToken ct) { await PaceAsync(target, ct).ConfigureAwait(false); using var enumerator = factory.Create(); try { await foreach (var tag in enumerator.EnumerateAsync(target.Params("@tags"), ct).ConfigureAwait(false)) { into.Browse.Add(tag); afterEach?.Invoke($"browse entry '{tag.Name}'"); } } catch (Exception ex) when (ex is not DifferentialAbortedException && (ex is not OperationCanceledException || !ct.IsCancellationRequested)) { if (IsConnectionLevel(ex)) throw Abort(phase, "browse (@tags)", ex); into.BrowseError = Describe(ex); } afterEach?.Invoke("browse"); } /// Reads every template id the browse surfaced, then every nested id those shapes name. private static async Task ReadTemplatesAsync( string phase, DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, Action? afterEach, CancellationToken ct) { var queue = new Queue(into.Browse .Where(t => t.TemplateInstanceId is not null) .Select(t => t.TemplateInstanceId!.Value) .Distinct() .Order()); var seen = new HashSet(queue); using var reader = factory.Create(); while (queue.Count > 0) { if (into.Shapes.Count + into.TemplateErrors.Count >= target.MaxTemplates) { into.TemplatesTruncated = queue.Count; break; } var id = queue.Dequeue(); await PaceAsync(target, ct).ConfigureAwait(false); try { var shape = CipTemplateObjectDecoder.Decode(await reader.ReadAsync(target.Params($"@udt/{id}"), id, ct).ConfigureAwait(false)); into.Shapes[id] = shape; foreach (var nested in shape?.Members ?? []) if (nested.NestedTemplateId is { } n && seen.Add(n)) queue.Enqueue(n); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { if (IsConnectionLevel(ex)) throw Abort(phase, $"template {id}", ex); into.TemplateErrors[id] = Describe(ex); if (ex is AbTransportException ate) into.TemplateErrorStatuses[id] = ate.Status; } afterEach?.Invoke($"template {id}"); } } /// /// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports /// read exactly the same list. Without AB_DIFF_TAGS the three kinds are taken ROUND-ROBIN /// (array, UDT member, scalar, ...) under their quotas — up to 20 arrays and 20 UDT members — so a /// press with thousands of scalars still exercises arrays and UDT members at any cap (review r1 I2). /// internal static IReadOnlyList Select(DifferentialTarget target, TransportObservations browsed) { var byName = new Dictionary(StringComparer.Ordinal); var scalars = new List(); var arrays = new List(); var members = new List(); foreach (var tag in browsed.Browse.Where(t => !t.IsSystemTag)) { var name = tag.ProgramScope is null ? tag.Name : $"Program:{tag.ProgramScope}.{tag.Name}"; var type = tag.DataType; AbCipUdtShape? shape = null; if (type == AbCipDataType.Structure) { shape = tag.TemplateInstanceId is { } id ? browsed.Shapes.GetValueOrDefault(id) : null; // A string-family struct (STRING, STR0016, ...) is read and presented as one String value. if (shape is { IsStringFamily: true }) type = AbCipDataType.String; } if (type != AbCipDataType.Structure) { var selected = new SelectedTag(name, type, tag.IsArray ? tag.ElementCount : 1, tag.IsArray, tag.IsArray ? "array" : "scalar"); byName.TryAdd(name, selected); (tag.IsArray ? arrays : scalars).Add(selected); continue; } if (tag.IsArray || shape is null) continue; foreach (var member in shape.Members) { var memberType = member.DataType; if (memberType == AbCipDataType.Structure) { var nested = member.NestedTemplateId is { } n ? browsed.Shapes.GetValueOrDefault(n) : null; if (nested is not { IsStringFamily: true }) continue; memberType = AbCipDataType.String; } // Hidden host bytes of packed BOOL members ("ZZZZZZZZZZ..." in Logix) are not addressable. if (member.Name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) continue; var isArray = member.ArrayLength > 1; var selected = new SelectedTag($"{name}.{member.Name}", memberType, isArray ? member.ArrayLength : 1, isArray, "udt member"); byName.TryAdd(selected.Name, selected); members.Add(selected); } } if (target.Tags is { } explicitNames) return explicitNames .Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)")) .DistinctBy(t => t.Name).Take(target.MaxTags).ToList(); var chosen = new List(); var seen = new HashSet(StringComparer.Ordinal); // Reference-type enumerators created ONCE: a boxed List.Enumerator struct would restart per round. IEnumerator[] sources = [ arrays.Take(MaxArrays).GetEnumerator(), members.Take(MaxUdtMembers).GetEnumerator(), scalars.AsEnumerable().Take(int.MaxValue).GetEnumerator(), ]; try { var live = true; while (live && chosen.Count < target.MaxTags) { live = false; foreach (var source in sources) { if (chosen.Count >= target.MaxTags) break; while (source.MoveNext()) { live = true; if (seen.Add(source.Current.Name)) { chosen.Add(source.Current); break; } } } } } finally { foreach (var source in sources) source.Dispose(); } return chosen; } /// /// Coverage problems of a default (browse-driven) selection: browse offered arrays or UDT members but /// none were selected. Empty for an explicit AB_DIFF_TAGS list, which is the operator's choice. /// internal static List SelectionCoverageProblems( DifferentialTarget target, TransportObservations browsed, IReadOnlyList selection) { var problems = new List(); if (target.Tags is not null) return problems; var available = Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed); foreach (var origin in new[] { "array", "udt member" }) if (available.Any(t => t.Origin == origin) && selection.All(t => t.Origin != origin)) problems.Add($"browse offered {origin}s but the selection holds none"); return problems; } private static async Task ReadTagsAsync( string phase, DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList selection, Dictionary into, Action? afterEach, CancellationToken ct) { foreach (var tag in selection) { ct.ThrowIfCancellationRequested(); var runtime = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type)); try { string? error = null; try { await PaceAsync(target, ct).ConfigureAwait(false); await runtime.InitializeAsync(ct).ConfigureAwait(false); await PaceAsync(target, ct).ConfigureAwait(false); await runtime.ReadAsync(ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { if (IsConnectionLevel(ex)) throw Abort(phase, $"tag '{tag.Name}'", ex); error = Describe(ex); } var status = runtime.GetStatus(); byte[]? raw = null; object? decoded = null; string? decodeError = null; if (error is null) { try { raw = RawBufferOf(runtime).ToArray(); if (tag.Type is { } type) decoded = tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null); } catch (Exception ex) { decodeError = Describe(ex); // recorded, and a NO verdict (review r1 M3) } } into[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError, (runtime as NativeAbCipTagRuntime)?.TypeCode); } finally { runtime.Dispose(); } afterEach?.Invoke($"tag '{tag.Name}'"); } } private static ReadOnlyMemory RawBufferOf(IAbCipTagRuntime runtime) => runtime switch { LibplctagTagRuntime l => l.RawBuffer, NativeAbCipTagRuntime n => n.RawBuffer, _ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."), }; /// True when the failure is about the session, not one tag: the run must stop. internal static bool IsConnectionLevel(Exception ex) => ex switch { AbTransportException ate => !TagLevelStatuses.Contains(ate.Status), // Local, pre-I/O refusals of one tag name (malformed path, unsupported type): no session involved. ArgumentException or FormatException or NotSupportedException => false, _ => true, }; internal static DifferentialAbortedException Abort(string phase, string operation, Exception ex) => new($"Differential ABORTED in the {phase} phase at {operation}: {Describe(ex)}. " + "Stopped on the first connection-level failure; nothing was retried.", ex); internal static Task PaceAsync(DifferentialTarget target, CancellationToken ct) => target.PaceMs > 0 ? Task.Delay(target.PaceMs, ct) : Task.CompletedTask; /// Resolves the endpoint once, bounded (an IP literal needs no lookup). private static async Task> ResolveAsync(DifferentialTarget target, CancellationToken ct) { if (IPAddress.TryParse(target.Host, out var literal)) return new HashSet { literal.MapToIPv6() }; using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct); bounded.CancelAfter(ResolveBudget); try { var resolved = await Dns.GetHostAddressesAsync(target.Host, bounded.Token).ConfigureAwait(false); return resolved.Select(x => x.MapToIPv6()).ToHashSet(); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { throw new DifferentialAbortedException( $"Differential ABORTED before any I/O: could not resolve '{target.Host}' within {ResolveBudget.TotalSeconds:0}s ({ex.Message}).", ex); } } /// /// A transport closes its session asynchronously (libplctag on a background thread some time after the last /// handle is destroyed). Wait until no TCP connection to the endpoint remains that was not already there /// before the run (compared by local endpoint, not by count — review r1 M6), so two sessions are never /// connected to the controller at the same time. /// /// Names the session waited for, in the abort message. /// The connection count observed when the next phase may start (null when unobservable). internal static async Task WaitForSessionsToCloseAsync( DifferentialTarget target, IReadOnlySet addresses, IReadOnlySet? baseline, string whose, CancellationToken ct) { if (baseline is null) { await Task.Delay(FallbackSettle, ct).ConfigureAwait(false); return null; } var deadline = Stopwatch.StartNew(); while (true) { var now = LocalEndpointsTo(target, addresses); if (now is null) return null; var added = now.Where(e => !baseline.Contains(e)).ToList(); if (added.Count == 0) return now.Count; if (deadline.Elapsed > LibplctagDrainBudget) throw new DifferentialAbortedException( $"{whose}'s session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after it was " + $"released (local {string.Join(", ", added)}); refusing to start the next phase alongside it."); await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false); } } /// Local endpoints of this machine's open TCP connections to the target, or null when the OS cannot say. private static IReadOnlySet? LocalEndpointsTo(DifferentialTarget target, IReadOnlySet addresses) { try { return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections() .Where(c => c.RemoteEndPoint.Port == target.Port && addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6()) && c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown)) .Select(c => c.LocalEndPoint) .ToHashSet(); } catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException or NotImplementedException) { return null; } } /// Order-insensitive multiset difference: entries of missing from . internal static List Missing(IEnumerable a, IEnumerable b) where T : notnull { var counts = b.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count()); var missing = new List(); foreach (var x in a) { if (counts.TryGetValue(x, out var c) && c > 0) counts[x] = c - 1; else missing.Add(x); } return missing; } /// Structural equality for decoded values: arrays element-wise, NaN equal to NaN. internal static bool ValuesEqual(object? a, object? b) => a is Array || b is Array ? 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 value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise /// over the decoded values: /// /// an element EQUAL in both libplctag reads did not move, so native must read it equal too — /// otherwise **NO: stable element i** (a stride, offset or byte-order defect lands here); /// an element that moved is excused its value, but native must not read a NaN/∞ that neither /// libplctag read saw — **NO: non-finite element i**. /// /// Null means the check passed. Shapes must already agree between libplctag and native. /// internal static string? LiveElementMismatch(object? lib, object? probe, object? nat) { var l = Elements(lib); var p = Elements(probe); var n = Elements(nat); if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it if (l.Length != n.Length) return "**NO: shape**"; for (var i = 0; i < l.Length; i++) { if (Equals(l[i], p[i])) { if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**"; } else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i])) { return $"**NO: non-finite element {i}**"; } } return null; } private static object?[] Elements(object? v) => v is Array a ? a.Cast().ToArray() : [v]; private static bool IsFinite(object? v) => v switch { float f => float.IsFinite(f), double d => double.IsFinite(d), _ => true, }; /// 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 && a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members); /// /// Template ids BOTH transports refused with the SAME and no shape on either side: /// the controller refuses that template and both transports report it identically, which is parity /// (Gitea #575) — not a divergence, and not a shape comparison either. A refusal with differing statuses, /// or one side refusing while the other decoded, is not listed here and stays a problem. /// internal static List EqualRefusals(TransportObservations lib, TransportObservations native) => [.. lib.TemplateErrorStatuses.Keys .Where(id => !lib.Shapes.ContainsKey(id) && !native.Shapes.ContainsKey(id) && native.TemplateErrorStatuses.TryGetValue(id, out var n) && n == lib.TemplateErrorStatuses[id]) .Order()]; /// /// Template-parity problems; empty means parity. Vacuity is a problem: browse found structured tags /// but no template id was compared (review r1 I3). An equal refusal () is /// parity and is not a problem, but is not a comparison either: it does not satisfy the vacuity check. /// internal static List TemplateProblems(TransportObservations lib, TransportObservations native) { var problems = new List(); var ids = lib.Shapes.Keys.Concat(lib.TemplateErrors.Keys) .Concat(native.Shapes.Keys).Concat(native.TemplateErrors.Keys) .Distinct().Order().ToList(); var refused = EqualRefusals(lib, native).ToHashSet(); var structured = lib.Browse.Concat(native.Browse).Count(t => t.DataType == AbCipDataType.Structure); if (structured > 0 && ids.Count(id => !refused.Contains(id)) == 0) problems.Add($"vacuous: browse found {structured} structured tag(s) but no template id was compared"); foreach (var id in ids) { if (refused.Contains(id)) continue; var libShape = lib.Shapes.GetValueOrDefault(id); var nativeShape = native.Shapes.GetValueOrDefault(id); if (!ShapesEqual(libShape, nativeShape)) problems.Add($"template {id}: " + Divergence(lib, native, id, libShape, nativeShape)); } return problems; } private static string Divergence( TransportObservations lib, TransportObservations native, uint id, AbCipUdtShape? libShape, AbCipUdtShape? nativeShape) { if (lib.TemplateErrorStatuses.TryGetValue(id, out var l) && native.TemplateErrorStatuses.TryGetValue(id, out var n) && l != n) return $"refused with different statuses (libplctag {l}, native {n})"; return libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ"; } internal static string Describe(Exception ex) => ex is AbTransportException ate ? $"{ate.Status}: {ate.Message}" : $"{ex.GetType().Name}: {ex.Message}"; }