test(abcip): read-only differential harness comparing native and libplctag on one target
This commit is contained in:
+356
@@ -0,0 +1,356 @@
|
||||
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;
|
||||
|
||||
/// <summary>One tag the harness reads, with the type it decodes under (null ⇒ raw comparison only).</summary>
|
||||
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)
|
||||
{
|
||||
public bool Succeeded => Status == (int)AbStatus.Ok && Error is null;
|
||||
}
|
||||
|
||||
/// <summary>What one transport observed over its whole phase.</summary>
|
||||
internal sealed class TransportObservations
|
||||
{
|
||||
public List<AbCipDiscoveredTag> Browse { get; } = [];
|
||||
public string? BrowseError { get; set; }
|
||||
public Dictionary<uint, AbCipUdtShape?> Shapes { get; } = [];
|
||||
public Dictionary<uint, string> TemplateErrors { get; } = [];
|
||||
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The outcome of one whole differential run.</summary>
|
||||
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<SelectedTag> Selection { get; init; }
|
||||
|
||||
/// <summary>Native Forward Opens counted between the counter reset and the anchor release.</summary>
|
||||
public required int NativeForwardOpens { get; init; }
|
||||
|
||||
/// <summary>TCP connections to the endpoint before libplctag started, and once its handles were disposed.</summary>
|
||||
public required int? ConnectionsBaseline { get; init; }
|
||||
public required int? ConnectionsBeforeNative { get; init; }
|
||||
public required TimeSpan LibplctagDrainWait { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class DifferentialRun
|
||||
{
|
||||
private const int MaxArrays = 20;
|
||||
private const int MaxUdtMembers = 20;
|
||||
private const int MaxTemplates = 256;
|
||||
|
||||
/// <summary>Upper bound on waiting for libplctag's session to close before native work may start.</summary>
|
||||
private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Settle used when the platform cannot enumerate TCP connections.</summary>
|
||||
private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5);
|
||||
|
||||
public static async Task<DifferentialResult> ExecuteAsync(DifferentialTarget target, CancellationToken ct)
|
||||
{
|
||||
var baseline = CountConnections(target);
|
||||
|
||||
// ---- Phase 1: libplctag only. Every handle is disposed before the phase ends. ----
|
||||
// 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.
|
||||
var lib = new TransportObservations();
|
||||
var libFactories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
|
||||
IReadOnlyList<SelectedTag> selection;
|
||||
using (var libAnchor = libFactories.Tags.Create(target.Params("@tags")))
|
||||
{
|
||||
try
|
||||
{
|
||||
await libAnchor.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"libplctag could not open its anchor session to {target}: {Describe(ex)}", ex);
|
||||
}
|
||||
await BrowseAsync(target, libFactories.Enumerators, lib, ct).ConfigureAwait(false);
|
||||
await ReadTemplatesAsync(target, libFactories.Templates, lib, ct).ConfigureAwait(false);
|
||||
selection = Select(target, lib);
|
||||
await ReadTagsAsync(target, libFactories.Tags, selection, lib, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
|
||||
var drain = Stopwatch.StartNew();
|
||||
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, baseline, ct).ConfigureAwait(false);
|
||||
drain.Stop();
|
||||
|
||||
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
|
||||
var native = new TransportObservations();
|
||||
var nativeFactories = AbCipTransportFactories.For(AbCipTransport.Native);
|
||||
var anchorParams = target.Params("@tags");
|
||||
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
|
||||
int opens;
|
||||
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
|
||||
try
|
||||
{
|
||||
await BrowseAsync(target, nativeFactories.Enumerators, native, ct).ConfigureAwait(false);
|
||||
await ReadTemplatesAsync(target, nativeFactories.Templates, native, ct).ConfigureAwait(false);
|
||||
await ReadTagsAsync(target, nativeFactories.Tags, selection, native, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
opens = Volatile.Read(ref CipConnection.ForwardOpenCount);
|
||||
await anchor.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new DifferentialResult
|
||||
{
|
||||
Target = target,
|
||||
Libplctag = lib,
|
||||
Native = native,
|
||||
Selection = selection,
|
||||
NativeForwardOpens = opens,
|
||||
ConnectionsBaseline = baseline,
|
||||
ConnectionsBeforeNative = beforeNative,
|
||||
LibplctagDrainWait = drain.Elapsed,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task BrowseAsync(
|
||||
DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into, CancellationToken ct)
|
||||
{
|
||||
using var enumerator = factory.Create();
|
||||
try
|
||||
{
|
||||
await foreach (var tag in enumerator.EnumerateAsync(target.Params("@tags"), ct).ConfigureAwait(false))
|
||||
into.Browse.Add(tag);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
into.BrowseError = Describe(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads every template id the browse surfaced, then every nested id those shapes name.</summary>
|
||||
private static async Task ReadTemplatesAsync(
|
||||
DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, CancellationToken ct)
|
||||
{
|
||||
var queue = new Queue<uint>(into.Browse
|
||||
.Where(t => t.TemplateInstanceId is not null)
|
||||
.Select(t => t.TemplateInstanceId!.Value)
|
||||
.Distinct()
|
||||
.Order());
|
||||
using var reader = factory.Create();
|
||||
while (queue.Count > 0 && into.Shapes.Count + into.TemplateErrors.Count < MaxTemplates)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
if (into.Shapes.ContainsKey(id) || into.TemplateErrors.ContainsKey(id)) continue;
|
||||
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) queue.Enqueue(n);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
into.TemplateErrors[id] = Describe(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports
|
||||
/// read exactly the same list.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<SelectedTag> Select(DifferentialTarget target, TransportObservations browsed)
|
||||
{
|
||||
var byName = new Dictionary<string, SelectedTag>(StringComparer.Ordinal);
|
||||
var scalars = new List<SelectedTag>();
|
||||
var arrays = new List<SelectedTag>();
|
||||
var members = new List<SelectedTag>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<SelectedTag> chosen = target.Tags is { } explicitNames
|
||||
? explicitNames.Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)"))
|
||||
: scalars.Concat(arrays.Take(MaxArrays)).Concat(members.Take(MaxUdtMembers));
|
||||
return chosen.DistinctBy(t => t.Name).Take(target.MaxTags).ToList();
|
||||
}
|
||||
|
||||
private static async Task ReadTagsAsync(
|
||||
DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList<SelectedTag> selection,
|
||||
TransportObservations into, 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 runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = Describe(ex);
|
||||
}
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
byte[]? raw = null;
|
||||
object? decoded = null;
|
||||
if (error is null)
|
||||
{
|
||||
raw = Try(() => RawBufferOf(runtime).ToArray());
|
||||
if (tag.Type is { } type)
|
||||
decoded = Try(() => tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null));
|
||||
}
|
||||
into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ReadOnlyMemory<byte> RawBufferOf(IAbCipTagRuntime runtime) => runtime switch
|
||||
{
|
||||
LibplctagTagRuntime l => l.RawBuffer,
|
||||
NativeAbCipTagRuntime n => n.RawBuffer,
|
||||
_ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// libplctag closes its session on a background thread some time after the last handle is
|
||||
/// destroyed. Wait until the endpoint's TCP connection count is back at the pre-run baseline, so
|
||||
/// the two transports are never connected to the controller at the same time.
|
||||
/// </summary>
|
||||
/// <returns>The connection count observed when native work may start (null when unobservable).</returns>
|
||||
private static async Task<int?> WaitForLibplctagSessionToCloseAsync(
|
||||
DifferentialTarget target, int? baseline, CancellationToken ct)
|
||||
{
|
||||
if (baseline is null)
|
||||
{
|
||||
await Task.Delay(FallbackSettle, ct).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
var deadline = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
var now = CountConnections(target);
|
||||
if (now is null || now <= baseline) return now;
|
||||
if (deadline.Elapsed > LibplctagDrainBudget)
|
||||
throw new InvalidOperationException(
|
||||
$"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
|
||||
$"handles were disposed ({now} connection(s), baseline {baseline}); refusing to start the native phase alongside it.");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Open TCP connections from this machine to the target, or null when the OS cannot say.</summary>
|
||||
internal static int? CountConnections(DifferentialTarget target)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addresses = Dns.GetHostAddresses(target.Host).Select(a => a.MapToIPv6()).ToHashSet();
|
||||
return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections().Count(c =>
|
||||
c.RemoteEndPoint.Port == target.Port
|
||||
&& addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6())
|
||||
&& c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown));
|
||||
}
|
||||
catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException or NotImplementedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Order-insensitive multiset difference: entries of <paramref name="a"/> missing from <paramref name="b"/>.</summary>
|
||||
internal static List<T> Missing<T>(IEnumerable<T> a, IEnumerable<T> b) where T : notnull
|
||||
{
|
||||
var counts = b.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count());
|
||||
var missing = new List<T>();
|
||||
foreach (var x in a)
|
||||
{
|
||||
if (counts.TryGetValue(x, out var c) && c > 0) counts[x] = c - 1;
|
||||
else missing.Add(x);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <summary>Structural equality for decoded values: arrays element-wise, NaN equal to NaN.</summary>
|
||||
internal static bool ValuesEqual(object? a, object? b) =>
|
||||
a is Array || b is Array
|
||||
? StructuralComparisons.StructuralEqualityComparer.Equals(a, b)
|
||||
: Equals(a, b);
|
||||
|
||||
internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
|
||||
a is null || b is null
|
||||
? a is null && b is null
|
||||
: a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
|
||||
|
||||
private static T? Try<T>(Func<T> f)
|
||||
{
|
||||
try { return f(); }
|
||||
catch (Exception) { return default; }
|
||||
}
|
||||
|
||||
private static string Describe(Exception ex) => ex is AbTransportException ate
|
||||
? $"{ate.Status}: {ate.Message}"
|
||||
: $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
|
||||
/// <summary>
|
||||
/// The one endpoint the read-only differential harness targets, read from the environment.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AB_DIFF_ENDPOINT</c> — <c>host:port</c> (port defaults to 44818). Absent ⇒ every live test skips.</item>
|
||||
/// <item><c>AB_DIFF_PATH</c> — CIP path, default <c>1,0</c>.</item>
|
||||
/// <item><c>AB_DIFF_FAMILY</c> — an <see cref="AbCipPlcFamily"/> name, default <c>CompactLogix</c>.</item>
|
||||
/// <item><c>AB_DIFF_TAGS</c> — optional comma list of tag names; absent ⇒ selected from browse.</item>
|
||||
/// <item><c>AB_DIFF_MAX_TAGS</c> — cap on the selected tags, default 200.</item>
|
||||
/// <item><c>AB_DIFF_COUNT_OPENS</c> — <c>1</c> ⇒ assert exactly one native Forward Open over the run.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed record DifferentialTarget(
|
||||
string Host,
|
||||
int Port,
|
||||
string CipPath,
|
||||
AbCipPlcFamily Family,
|
||||
IReadOnlyList<string>? Tags,
|
||||
int MaxTags,
|
||||
bool CountOpens)
|
||||
{
|
||||
public const string EndpointVar = "AB_DIFF_ENDPOINT";
|
||||
public const string PathVar = "AB_DIFF_PATH";
|
||||
public const string FamilyVar = "AB_DIFF_FAMILY";
|
||||
public const string TagsVar = "AB_DIFF_TAGS";
|
||||
public const string MaxTagsVar = "AB_DIFF_MAX_TAGS";
|
||||
public const string CountOpensVar = "AB_DIFF_COUNT_OPENS";
|
||||
|
||||
private const int DefaultPort = 44818;
|
||||
private const string DefaultPath = "1,0";
|
||||
private const AbCipPlcFamily DefaultFamily = AbCipPlcFamily.CompactLogix;
|
||||
private const int DefaultMaxTags = 200;
|
||||
|
||||
/// <summary>Per-operation timeout for both transports.</summary>
|
||||
public static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Skip reason used by every live test when the endpoint is not configured.</summary>
|
||||
public static string SkipReason =>
|
||||
$"{EndpointVar} is not set — point it at host:port of the one controller to compare (e.g. 10.100.0.35:44818 for ab_server).";
|
||||
|
||||
/// <summary>The configured target, or <c>null</c> when <c>AB_DIFF_ENDPOINT</c> is absent.</summary>
|
||||
/// <exception cref="InvalidOperationException">An env var is present but malformed.</exception>
|
||||
public static DifferentialTarget? FromEnvironment()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable(EndpointVar)?.Trim();
|
||||
if (string.IsNullOrEmpty(endpoint)) return null;
|
||||
|
||||
var parts = endpoint.Split(':', 2);
|
||||
var host = parts[0];
|
||||
var port = DefaultPort;
|
||||
if (parts.Length == 2 && (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535))
|
||||
throw new InvalidOperationException($"{EndpointVar}='{endpoint}' is not host:port.");
|
||||
if (host.Length == 0) throw new InvalidOperationException($"{EndpointVar}='{endpoint}' has no host.");
|
||||
|
||||
// An explicitly empty AB_DIFF_PATH is honoured (direct / no-backplane targets).
|
||||
var path = Environment.GetEnvironmentVariable(PathVar) ?? DefaultPath;
|
||||
|
||||
var family = DefaultFamily;
|
||||
if (Environment.GetEnvironmentVariable(FamilyVar) is { Length: > 0 } rawFamily
|
||||
&& !Enum.TryParse(rawFamily.Trim(), ignoreCase: true, out family))
|
||||
throw new InvalidOperationException(
|
||||
$"{FamilyVar}='{rawFamily}' is not one of {string.Join(", ", Enum.GetNames<AbCipPlcFamily>())}.");
|
||||
|
||||
IReadOnlyList<string>? tags = null;
|
||||
if (Environment.GetEnvironmentVariable(TagsVar) is { Length: > 0 } rawTags)
|
||||
tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var max = DefaultMaxTags;
|
||||
if (Environment.GetEnvironmentVariable(MaxTagsVar) is { Length: > 0 } rawMax
|
||||
&& (!int.TryParse(rawMax, NumberStyles.None, CultureInfo.InvariantCulture, out max) || max < 1))
|
||||
throw new InvalidOperationException($"{MaxTagsVar}='{rawMax}' is not a positive integer.");
|
||||
|
||||
var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1";
|
||||
return new DifferentialTarget(host, port, path, family, tags, max, countOpens);
|
||||
}
|
||||
|
||||
/// <summary>The family profile the driver would use for this target.</summary>
|
||||
public AbCipPlcFamilyProfile Profile => AbCipPlcFamilyProfile.ForFamily(Family);
|
||||
|
||||
/// <summary>Create params for one tag (or listing pseudo-tag), built the way the driver builds them.</summary>
|
||||
public AbCipTagCreateParams Params(
|
||||
string tagName, int elementCount = 1, bool isArray = false, AbCipDataType? authoredType = null) => new(
|
||||
Gateway: Host,
|
||||
Port: Port,
|
||||
CipPath: CipPath,
|
||||
LibplctagPlcAttribute: Profile.LibplctagPlcAttribute,
|
||||
TagName: tagName,
|
||||
Timeout: OperationTimeout,
|
||||
AllowPacking: Profile.SupportsRequestPacking,
|
||||
ConnectionSize: Profile.DefaultConnectionSize,
|
||||
ElementCount: elementCount < 1 ? 1 : elementCount,
|
||||
IsArray: isArray,
|
||||
AuthoredType: authoredType,
|
||||
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() =>
|
||||
$"{Host}:{Port} path '{CipPath}' family {Family} ({Profile.LibplctagPlcAttribute}, connection size {Profile.DefaultConnectionSize})";
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
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)");
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
Log("| Template | libplctag | native | Equal? |");
|
||||
Log("|---|---|---|---|");
|
||||
}
|
||||
|
||||
var unequal = new List<uint>();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var lib = Shape(r.Libplctag, id);
|
||||
var nat = Shape(r.Native, id);
|
||||
var equal = r.Libplctag.Shapes.ContainsKey(id) && r.Native.Shapes.ContainsKey(id)
|
||||
&& DifferentialRun.ShapesEqual(r.Libplctag.Shapes[id], r.Native.Shapes[id]);
|
||||
if (!equal) unequal.Add(id);
|
||||
Log($"| {id} | {lib} | {nat} | {(equal ? "yes" : "**NO**")} |");
|
||||
}
|
||||
unequal.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("| 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}");
|
||||
|
||||
r.Selection.ShouldNotBeEmpty("a run that reads nothing proves nothing");
|
||||
unexpected.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",
|
||||
];
|
||||
|
||||
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.Contains(callee.Name, StringComparer.Ordinal)
|
||||
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.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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user