Merge #593/#594: Ab.Transport offline/simulator test gaps, libplctag BadParam per-tag path, fixture suites skip when absent
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -36,8 +36,9 @@ supplies a `FakeAbLegacyTag`.
|
||||
|
||||
The `AbLegacy.IntegrationTests` suites now run **once per transport** (`TransportMatrix`: `Libplctag` and
|
||||
`Native`). Set `AB_LEGACY_COMPOSE_PROFILE` to the running profile. On the 2026-10-07 run native passed
|
||||
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the PLC-5 test reads N7 only, so the PLC-5
|
||||
refusal paths and status-bit writes are not exercised by it). Native PCCC is **simulator-only**. Write-capable
|
||||
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the smoke tests read N7 only; the PLC-5
|
||||
refusal paths and status-bit write refusals are exercised by `AbLegacyUnsupportedPathTests`, native only, #593 — the
|
||||
refusals are the driver's own, raised before the wire, so each test carries a positive-control read). Native PCCC is **simulator-only**. Write-capable
|
||||
tests (the SLC500 N7:5 round trip) require `AB_SERVER_ALLOW_WRITES=1`. Golden frames:
|
||||
`scripts/ab-capture/capture.sh`, fixtures under
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/`. Evidence:
|
||||
|
||||
@@ -37,7 +37,7 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
||||
|
||||
Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
|
||||
`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
|
||||
(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
|
||||
(all profiles bind `:44818`, so set it to the running profile; **unset, only the default `controllogix` rows run** — ab_server does not self-identify its `--plc` mode, so crossing every family at one container failed the non-matching rows instead of skipping them, #594). `NativeLeaseReleaseSoakTests` is the ab_server soak of the background lease release (`AB_LEASE_SOAK_CYCLES`, default 100; read-only, skips without the fixture, #593). On the 2026-10-07 run native passed everything
|
||||
libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
|
||||
[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
|
||||
smoke test passes on both transports. Evidence:
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly TcpClient _tcp;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly Stream _stream;
|
||||
|
||||
// Never disposed: an abandoned exchange may release it after DisposeAsync returns, and
|
||||
// SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless
|
||||
@@ -56,12 +56,17 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// <summary>False once the connection has faulted or been disposed.</summary>
|
||||
public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected;
|
||||
|
||||
private EncapsulationClient(TcpClient tcp)
|
||||
private EncapsulationClient(TcpClient tcp, Stream? stream = null)
|
||||
{
|
||||
_tcp = tcp;
|
||||
_stream = tcp.GetStream();
|
||||
_stream = stream ?? tcp.GetStream();
|
||||
}
|
||||
|
||||
/// <summary>Test seam: wraps an already-connected <paramref name="tcp"/> but routes all frame I/O through
|
||||
/// <paramref name="stream"/> (e.g. one whose write never completes), skipping RegisterSession. The TCP
|
||||
/// client is still what <see cref="Fault"/> closes and <see cref="IsConnected"/> consults.</summary>
|
||||
internal static EncapsulationClient ForStream(TcpClient tcp, Stream stream) => new(tcp, stream);
|
||||
|
||||
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
|
||||
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
|
||||
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
|
||||
|
||||
@@ -37,6 +37,32 @@ internal static class LibplctagStatusTranslation
|
||||
? ToAbStatus(s)
|
||||
: AbStatus.BadConnection;
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="ex"/> is libplctag refusing the tag's own attributes (<c>ErrorBadParam</c>:
|
||||
/// the name or a create attribute does not parse), as opposed to a transport failure.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
public static bool IsTagParameterRefusal(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorBadParam;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tag's <b>initialisation</b>: <c>ErrorBadParam</c> there means libplctag rejected the tag's own
|
||||
/// name before any I/O, so it is raised as <see cref="AbCipTagConfigurationException"/> (per-tag
|
||||
/// <c>BadConfigurationError</c>, no backoff, no health signal — Gitea #570/#593) instead of falling through
|
||||
/// to the generic <see cref="AbStatus.BadConnection"/> default that backs the whole device off. Only the
|
||||
/// create phase: a BadParam from a read or write on a live handle is not a name problem.
|
||||
/// </summary>
|
||||
/// <param name="op">The libplctag initialisation.</param>
|
||||
/// <returns>A task completing when the initialisation does.</returns>
|
||||
public static async Task WrapInitialize(Func<Task> op)
|
||||
{
|
||||
try { await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) when (IsTagParameterRefusal(ex))
|
||||
{
|
||||
throw new AbCipTagConfigurationException($"libplctag rejected the tag's name or attributes: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
/// <returns>A task completing when the operation does.</returns>
|
||||
|
||||
@@ -52,7 +52,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
|
||||
RunReleasingOrphanAsync(() => LibplctagStatusTranslation.WrapInitialize(() => _tag.InitializeAsync(cancellationToken)));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
|
||||
|
||||
+68
@@ -295,6 +295,74 @@ public sealed class EncapsulationClientTests
|
||||
client.IsConnected.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A stream whose write never completes until released, standing in for a peer that stopped reading.</summary>
|
||||
private sealed class BlockedWriteStream : Stream
|
||||
{
|
||||
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _writeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task WriteStarted => _writeStarted.Task;
|
||||
public void FailPendingWrite() => _release.TrySetException(new IOException("socket closed"));
|
||||
|
||||
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_writeStarted.TrySetResult();
|
||||
await _release.Task.ConfigureAwait(false); // ignores cancellation, like a stuck socket send
|
||||
}
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("The exchange must never reach the read side while its write is blocked.");
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_blocked_write_times_out_faults_the_session_and_leaves_no_unobserved_exception()
|
||||
{
|
||||
// Gitea #565 / #593: the write ignores cancellation (a half-written frame would desync the stream) but is
|
||||
// bounded. A stream whose write never completes is the deterministic form of "peer stopped reading".
|
||||
var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonABlockedWrite, Guard);
|
||||
unobserved.ShouldNotContain(x => x.Message.Contains("socket closed"));
|
||||
}
|
||||
|
||||
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
||||
private static async Task AbandonABlockedWrite()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
using var tcp = new TcpClient();
|
||||
var accept = listener.AcceptTcpClientAsync(TestCt).AsTask();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, port, TestCt);
|
||||
using var peer = await accept;
|
||||
|
||||
var stream = new BlockedWriteStream();
|
||||
await using var client = EncapsulationClient.ForStream(tcp, stream);
|
||||
client.IsConnected.ShouldBeTrue();
|
||||
|
||||
var call = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(250), TestCt);
|
||||
await stream.WriteStarted.WaitAsync(Guard, TestCt); // positive control: the write really was attempted
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(call.WaitAsync(Guard, TestCt));
|
||||
ex.Status.ShouldBe(AbStatus.Timeout);
|
||||
client.IsConnected.ShouldBeFalse();
|
||||
|
||||
// Faulted: the next call is refused up front rather than queued behind the stuck write.
|
||||
var next = await Should.ThrowAsync<AbTransportException>(
|
||||
client.SendRRDataAsync(new byte[] { 0x01 }, Generous, TestCt));
|
||||
next.Status.ShouldBe(AbStatus.BadConnection);
|
||||
|
||||
// Closing the socket is what fails the pending write in production; the abandoned write task then faults
|
||||
// and must have been observed.
|
||||
stream.FailPendingWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connected_sequence_is_assigned_inside_the_gate()
|
||||
{
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #579 / #593: soak of <c>ConnectionLease.ReleaseInBackground</c> against a real <c>ab_server</c>.
|
||||
/// The offline <c>NativeAbCipLeaseReleaseTests</c> pin the ordering against a fake CIP target for 25 cycles;
|
||||
/// this drives the same release-then-immediately-recreate loop over a real socket and the simulator's own
|
||||
/// connection bookkeeping, where a Forward Open racing the predecessor's still-in-flight Forward Close would
|
||||
/// be refused or would leak a connection slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skips when ab_server is unreachable (<see cref="AbServerFactAttribute"/>). Cycle count:
|
||||
/// <c>AB_LEASE_SOAK_CYCLES</c> (default 100). Read-only against the target (one DINT read per cycle), so it
|
||||
/// needs no write opt-in.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class NativeLeaseReleaseSoakTests
|
||||
{
|
||||
private static readonly TimeSpan Guard = TimeSpan.FromSeconds(60);
|
||||
|
||||
private static int Cycles =>
|
||||
int.TryParse(Environment.GetEnvironmentVariable("AB_LEASE_SOAK_CYCLES"), out var n) && n > 0 ? n : 100;
|
||||
|
||||
/// <summary>
|
||||
/// Each cycle: create a native runtime, initialise, read, then <c>Dispose</c> (the background release) and
|
||||
/// re-create at once. Every read must be Good, exactly one Forward Open and one Forward Close per cycle
|
||||
/// must hit the wire (a re-create that overlapped the close, or a close that never ran, shows as a count
|
||||
/// mismatch), and the pool must end with no live entry.
|
||||
/// </summary>
|
||||
[AbServerFact]
|
||||
public async Task Release_in_background_then_immediate_recreate_never_overlaps_or_leaks_against_ab_server()
|
||||
{
|
||||
var fixture = new AbServerFixture(KnownProfiles.ControlLogix);
|
||||
var pool = new DeviceSessionPool();
|
||||
var factory = new NativeAbCipTagFactory(pool);
|
||||
var cycles = Cycles;
|
||||
var teardowns = new List<Task>(cycles);
|
||||
|
||||
var createParams = new AbCipTagCreateParams(
|
||||
Gateway: fixture.Host,
|
||||
Port: fixture.Port,
|
||||
CipPath: "1,0",
|
||||
LibplctagPlcAttribute: "controllogix",
|
||||
TagName: "TestDINT",
|
||||
Timeout: TimeSpan.FromSeconds(5),
|
||||
AllowPacking: false,
|
||||
ElementCount: 1,
|
||||
IsArray: false,
|
||||
AuthoredType: AbCipDataType.DInt,
|
||||
UseConnectedMessaging: true);
|
||||
|
||||
using var budget = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
budget.CancelAfter(TimeSpan.FromSeconds(120));
|
||||
|
||||
for (var i = 0; i < cycles; i++)
|
||||
{
|
||||
var runtime = (NativeAbCipTagRuntime)factory.Create(createParams);
|
||||
await runtime.InitializeAsync(budget.Token);
|
||||
await runtime.ReadAsync(budget.Token);
|
||||
runtime.GetStatus().ShouldBe(0, $"cycle {i}: read status");
|
||||
runtime.DecodeValue(AbCipDataType.DInt, null).ShouldNotBeNull($"cycle {i}: decoded value");
|
||||
|
||||
runtime.Dispose(); // background release; the next Create must queue behind its close
|
||||
teardowns.Add(runtime.TeardownTask ?? Task.CompletedTask);
|
||||
}
|
||||
|
||||
await Task.WhenAll(teardowns).WaitAsync(Guard, TestContext.Current.CancellationToken);
|
||||
|
||||
pool.EntryCount.ShouldBe(0, "every lease was released, so no pooled connection may remain");
|
||||
pool.Diagnostics.ForwardOpens.ShouldBe(cycles, "one Forward Open per cycle: a re-create must wait for its predecessor's close");
|
||||
pool.Diagnostics.ForwardClosesSent.ShouldBe(cycles, "one Forward Close per cycle: none dropped, none doubled");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
/// Every AbCip compose profile binds host <c>:44818</c>, so only one can run at a time. When
|
||||
/// <c>AB_SERVER_COMPOSE_PROFILE</c> is set, <see cref="Profiles"/> keeps only that profile, so the
|
||||
/// rows match the running container instead of driving, say, Micro800 semantics at a ControlLogix.
|
||||
/// Unset, every profile is crossed (the original behaviour).
|
||||
/// Unset, only the default ControlLogix profile is crossed: ab_server does not self-identify its
|
||||
/// <c>--plc</c> mode, so crossing every family at whichever container holds <c>:44818</c> failed the
|
||||
/// non-matching rows instead of skipping them. Set <c>AB_SERVER_COMPOSE_PROFILE</c> to the running
|
||||
/// profile (<c>compactlogix</c> / <c>micro800</c> / <c>guardlogix</c>) to select another family.
|
||||
/// </remarks>
|
||||
public static class TransportMatrix
|
||||
{
|
||||
@@ -24,8 +27,9 @@ public static class TransportMatrix
|
||||
{
|
||||
var only = Environment.GetEnvironmentVariable("AB_SERVER_COMPOSE_PROFILE");
|
||||
return from p in KnownProfiles.All
|
||||
where string.IsNullOrEmpty(only) ||
|
||||
string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase)
|
||||
where string.Equals(p.ComposeProfile,
|
||||
string.IsNullOrEmpty(only) ? KnownProfiles.ControlLogix.ComposeProfile : only,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
from t in AllTransports
|
||||
select new object[] { p, t };
|
||||
}
|
||||
|
||||
@@ -111,4 +111,41 @@ public sealed class LibplctagStatusTranslationTests
|
||||
ex.Status.ShouldBe(AbStatus.Timeout);
|
||||
ex.InnerException.ShouldBeSameAs(inner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #593 / #570: <c>ErrorBadParam</c> during initialisation is libplctag refusing the tag's own name — a
|
||||
/// per-tag configuration error (the driver's no-backoff, no-health path), not a device connect failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WrapInitialize_maps_ErrorBadParam_to_a_tag_configuration_exception()
|
||||
{
|
||||
var thrown = await Should.ThrowAsync<AbCipTagConfigurationException>(() =>
|
||||
LibplctagStatusTranslation.WrapInitialize(() => throw new LibPlcTagException(Status.ErrorBadParam)));
|
||||
thrown.InnerException.ShouldBeOfType<LibPlcTagException>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification pair for the above: every OTHER initialisation failure is left alone (it still reaches
|
||||
/// <c>Wrap</c> and becomes a transport status that opens the backoff window).
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag status.</param>
|
||||
[Theory]
|
||||
[InlineData(Status.ErrorTimeout)]
|
||||
[InlineData(Status.ErrorBadGateway)]
|
||||
[InlineData(Status.ErrorNotFound)]
|
||||
public async Task WrapInitialize_leaves_other_statuses_untouched(Status status)
|
||||
{
|
||||
var thrown = await Should.ThrowAsync<LibPlcTagException>(() =>
|
||||
LibplctagStatusTranslation.WrapInitialize(() => throw new LibPlcTagException(status)));
|
||||
thrown.ShouldNotBeOfType<AbCipTagConfigurationException>();
|
||||
}
|
||||
|
||||
/// <summary>A BadParam on the read/write path (through <c>Wrap</c>) is not reclassified as configuration.</summary>
|
||||
[Fact]
|
||||
public async Task Wrap_still_maps_ErrorBadParam_to_a_transport_status()
|
||||
{
|
||||
var thrown = await Should.ThrowAsync<AbTransportException>(() =>
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam)));
|
||||
thrown.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-adjacent coverage of the native transport's deliberate refusals (#571): PLC-5 non-N reads, PLC-5
|
||||
/// writes and status-bit sub-element writes (<c>T4:0.DN</c>). Each must surface as
|
||||
/// <see cref="AbLegacyStatusMapper.BadNotSupported"/> and must not count as a comms fault
|
||||
/// (health stays Healthy — the comms-loss watchdog reads it).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Which layer refuses.</b> These refusals are the DRIVER's, raised by <c>NativeAbLegacyTagRuntime</c>
|
||||
/// before anything reaches the wire — the simulator is only needed so the driver has a device to hold a
|
||||
/// handle against and so the positive control (an N-file read on the same driver) can prove the refusal is
|
||||
/// per-tag rather than a dead connection. ab_server's PLC/5 mode covers N/F/B only, so a refusal that
|
||||
/// came from the simulator would look the same from the outside; the positive control and the health
|
||||
/// assertion are what tell the layers apart. Each test skips when its family's simulator is unreachable.
|
||||
/// </remarks>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
private AbLegacyDriver NativeDriver(AbLegacyServerProfile profile, string id, params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
return new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = deviceUri }).ToArray()),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = AbLegacyTransport.Native,
|
||||
}, driverInstanceId: id);
|
||||
}
|
||||
|
||||
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) =>
|
||||
new(Name: name, DeviceHostAddress: "", Address: address, DataType: type, Writable: writable);
|
||||
|
||||
/// <summary>A native PLC-5 non-N read is refused locally; an N read on the same driver still works.</summary>
|
||||
[AbLegacyFact]
|
||||
public async Task Native_Plc5_non_N_read_is_BadNotSupported_and_leaves_health_Healthy()
|
||||
{
|
||||
if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip);
|
||||
await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-read",
|
||||
Tag("Float", "F8:0", AbLegacyDataType.Float),
|
||||
Tag("Int", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Positive control first: the connection and the handle path work, so the refusal below is per-tag.
|
||||
var control = await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken);
|
||||
control.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "PLC-5 N-file read is the verified path");
|
||||
|
||||
var refused = await drv.ReadAsync(["Float"], TestContext.Current.CancellationToken);
|
||||
refused.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a driver-side refusal is not a comms fault");
|
||||
|
||||
// The refusal kept the connection: the N read still succeeds afterwards.
|
||||
(await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken)).Single().StatusCode
|
||||
.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>A native PLC-5 write is refused locally before anything reaches the device.</summary>
|
||||
[AbLegacyFact]
|
||||
public async Task Native_Plc5_write_is_BadNotSupported_and_leaves_health_Healthy()
|
||||
{
|
||||
if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip);
|
||||
IntegrationWriteGate.SkipUnlessWritesAllowed(); // the write is refused, but never aim a write test at an unvetted endpoint
|
||||
await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-write",
|
||||
Tag("Scratch", "N7:5", AbLegacyDataType.Int, writable: true));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Scratch", (short)0x1234)], TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>A native status-bit sub-element write is refused locally on the families that carry T/C/R files.</summary>
|
||||
/// <param name="address">The status-bit address.</param>
|
||||
[AbLegacyTheory]
|
||||
[InlineData("T4:0.DN")]
|
||||
[InlineData("C5:0.CU")]
|
||||
[InlineData("R6:0.EN")]
|
||||
public async Task Native_status_bit_write_is_BadNotSupported_and_leaves_health_Healthy(string address)
|
||||
{
|
||||
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
|
||||
IntegrationWriteGate.SkipUnlessWritesAllowed();
|
||||
await using var drv = NativeDriver(KnownProfiles.Slc500, $"ablegacy-statusbit-{address}",
|
||||
Tag("Scratch", "N7:5", AbLegacyDataType.Int),
|
||||
Tag("Bit", address, AbLegacyDataType.Bit, writable: true));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Positive control: the connection is live, so a BadNotSupported below is the driver's refusal.
|
||||
(await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode
|
||||
.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ families stop the current service + start another.
|
||||
| `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). |
|
||||
| `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/<path>` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. |
|
||||
| `AB_SERVER_ALLOW_WRITES` | *unset* | **Write opt-in.** Every write-capable test (today: the SLC500 N7:5 write-then-read round trip) skips unless this is `1`. Set it for simulator runs; **never** set it when an endpoint variable points at production hardware. Write tests also refuse while `AB_DIFF_ENDPOINT` (the AbCip read-only differential) is set. |
|
||||
| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. |
|
||||
| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. When `AB_LEGACY_ENDPOINT` is set and this is not, only the `slc500` rows run (the family of a pinned device cannot be detected). Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. |
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
/// <remarks>
|
||||
/// Preserves the <c>AB_LEGACY_COMPOSE_PROFILE</c> filter the smoke theory always applied: when set,
|
||||
/// <see cref="KnownProfiles.All"/> is narrowed to that profile <b>before</b> it is crossed with the
|
||||
/// transports, so the rows match the running container.
|
||||
/// transports, so the rows match the running container. Unset, every family is crossed because each
|
||||
/// has its own port and skips itself when unreachable — <b>unless</b> <c>AB_LEGACY_ENDPOINT</c> pins
|
||||
/// one device, in which case the family is a property of that device and cannot be detected, so only
|
||||
/// the default SLC500 profile is crossed (set <c>AB_LEGACY_COMPOSE_PROFILE</c> to pick another).
|
||||
/// </remarks>
|
||||
public static class TransportMatrix
|
||||
{
|
||||
@@ -21,6 +24,9 @@ public static class TransportMatrix
|
||||
get
|
||||
{
|
||||
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
|
||||
if (string.IsNullOrEmpty(only) &&
|
||||
Environment.GetEnvironmentVariable("AB_LEGACY_ENDPOINT") is { Length: > 0 })
|
||||
only = KnownProfiles.Slc500.ComposeProfile;
|
||||
return from p in KnownProfiles.All
|
||||
where string.IsNullOrEmpty(only) ||
|
||||
string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ public class BrowseRoundTripTests
|
||||
[Fact]
|
||||
public async Task Three_level_expand_round_trips_resolve_back()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT")
|
||||
?? "opc.tcp://10.100.0.35:50000";
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var endpoint = OpcPlcProbe.Endpoint;
|
||||
// Numeric ordinals because the production browser uses default System.Text.Json
|
||||
// without a JsonStringEnumConverter (SecurityPolicy.None=0, SecurityMode.None=0,
|
||||
// AuthType.Anonymous=0).
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability gate for the shared opc-plc fixture (<c>OPCUA_SIM_ENDPOINT</c>, default
|
||||
/// <c>opc.tcp://10.100.0.35:50000</c>). A small copy of the probe in
|
||||
/// <c>Driver.OpcUaClient.IntegrationTests/OpcPlcFixture</c> rather than a cross-project reference —
|
||||
/// that project drags the whole driver in. Tests call <see cref="SkipUnlessReachable"/> first, so a
|
||||
/// machine without the simulator reports Skipped instead of a connect failure.
|
||||
/// </summary>
|
||||
internal static class OpcPlcProbe
|
||||
{
|
||||
private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT";
|
||||
private const string DefaultEndpoint = "opc.tcp://10.100.0.35:50000";
|
||||
|
||||
/// <summary>The simulator endpoint the tests should connect to.</summary>
|
||||
public static string Endpoint =>
|
||||
Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } e ? e : DefaultEndpoint;
|
||||
|
||||
private static readonly Lazy<string?> Reason = new(Probe);
|
||||
|
||||
/// <summary>Skips the calling test when opc-plc does not accept a TCP connection (probed once per run).</summary>
|
||||
public static void SkipUnlessReachable()
|
||||
{
|
||||
if (Reason.Value is { } reason) Xunit.Assert.Skip(reason);
|
||||
}
|
||||
|
||||
private static string? Probe()
|
||||
{
|
||||
var body = Endpoint.StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase)
|
||||
? Endpoint["opc.tcp://".Length..]
|
||||
: Endpoint;
|
||||
var slash = body.IndexOf('/');
|
||||
if (slash >= 0) body = body[..slash];
|
||||
var colon = body.LastIndexOf(':');
|
||||
var host = colon >= 0 ? body[..colon] : body;
|
||||
var port = colon >= 0 && int.TryParse(body[(colon + 1)..], out var p) ? p : 4840;
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||
var addr = Dns.GetHostAddresses(host).FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? IPAddress.Loopback;
|
||||
if (client.ConnectAsync(addr, port).Wait(TimeSpan.FromSeconds(2)) && client.Connected) return null;
|
||||
return $"opc-plc simulator at {host}:{port} did not accept a TCP connection within 2s. Start it or override {EndpointEnvVar}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"opc-plc simulator at {host}:{port} unreachable: {ex.GetType().Name}: {ex.Message}. Start it or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -3,21 +3,20 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live-server tests against the opc-plc Docker fixture. Bring up with
|
||||
/// `lmxopcua-fix up opcuaclient` from PowerShell before running. These tests
|
||||
/// drive the internal <c>OpcUaClientBrowseSession</c> through its public factory
|
||||
/// <see cref="OpcUaClientDriverBrowser"/> so no InternalsVisibleTo is needed.
|
||||
/// Filter out with <c>--filter "Category!=RequiresOpcPlc"</c> when the fixture
|
||||
/// is unreachable.
|
||||
/// Each test skips (<see cref="OpcPlcProbe"/>) when the fixture is unreachable. Moved here from the
|
||||
/// unit project, which must hold no live-server tests (#594).
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresOpcPlc")]
|
||||
[Trait("Category", "Integration"), Trait("Fixture", "opc-plc")]
|
||||
public sealed class OpcUaClientBrowseSessionTests
|
||||
{
|
||||
private static string Endpoint =>
|
||||
Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000";
|
||||
private static string Endpoint => OpcPlcProbe.Endpoint;
|
||||
|
||||
// SecurityPolicy.None, SecurityMode.None, OpcUaAuthType.Anonymous are all
|
||||
// index 0 and can also be written as string names now that the browser carries
|
||||
@@ -40,6 +39,7 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
[Fact]
|
||||
public async Task RootAsync_returns_at_least_one_node()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var browser = new OpcUaClientDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(ConfigJson, ct);
|
||||
@@ -52,6 +52,7 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
[Fact]
|
||||
public async Task ExpandAsync_round_trips_stable_NodeId()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var browser = new OpcUaClientDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(ConfigJson, ct);
|
||||
@@ -67,6 +68,7 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
[Fact]
|
||||
public async Task AttributesAsync_is_empty_for_opcuaclient()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var browser = new OpcUaClientDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(ConfigJson, ct);
|
||||
Reference in New Issue
Block a user