f51055b509
Against opc-plc (opc.tcp://10.100.0.35:50000): a shutdown with three live subscriptions is bounded and the same instance re-initialises and reads (O-9 + lifecycle generation through the real SDK); a server-refused write (BadTypeMismatch) passes through and keeps the driver Healthy with its stamp advanced (health contract); the browse side-panel reports a live variable's type, shape and access (O-5). Against the MTConnect fixture Agent: the headers-only ping reaches it and fails on a dead port (T-7); a delivering /sample stream serves reads and reports its lag and effective count on the diagnostics bag (T-4/T-5). Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
153 lines
7.0 KiB
C#
153 lines
7.0 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// End-to-end smoke against a live <c>opc-plc</c> (task #215). Drives the real
|
|
/// OPC UA Secure Channel + Session + MonitoredItem exchange — no mocks. Every
|
|
/// test here proves a capability surface that loopback against our own server
|
|
/// couldn't exercise cleanly: real cert negotiation, real endpoint descriptions,
|
|
/// real simulated nodes that change without a write.
|
|
/// </summary>
|
|
[Collection(OpcPlcCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Simulator", "opc-plc")]
|
|
public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
|
|
{
|
|
/// <summary>Verifies that the client can connect and read a node through the real OPC UA stack.</summary>
|
|
[Fact]
|
|
public async Task Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
|
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-read");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var snapshots = await drv.ReadAsync(
|
|
[OpcPlcProfile.StepUpRef], TestContext.Current.CancellationToken);
|
|
|
|
snapshots.Count.ShouldBe(1);
|
|
snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end");
|
|
snapshots[0].Value.ShouldNotBeNull("StepUp always has a current value");
|
|
}
|
|
|
|
/// <summary>Verifies that the client can read a batch of varied types from the simulator.</summary>
|
|
[Fact]
|
|
public async Task Client_reads_batch_of_varied_types_from_live_simulator()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
|
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-batch");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var snapshots = await drv.ReadAsync(
|
|
[OpcPlcProfile.StepUpRef, OpcPlcProfile.RandomSignedInt32Ref, OpcPlcProfile.AlternatingBooleanRef],
|
|
TestContext.Current.CancellationToken);
|
|
|
|
snapshots.Count.ShouldBe(3);
|
|
foreach (var s in snapshots)
|
|
{
|
|
s.StatusCode.ShouldBe(0u);
|
|
s.Value.ShouldNotBeNull();
|
|
}
|
|
// AlternatingBoolean should decode as a bool specifically — catches a common
|
|
// attribute-mapping regression where the driver stringifies variant values.
|
|
snapshots[2].Value.ShouldBeOfType<bool>();
|
|
}
|
|
|
|
/// <summary>Verifies that the client can subscribe to data changes from the live server.</summary>
|
|
[Fact]
|
|
public async Task Client_subscribe_receives_StepUp_data_changes_from_live_server()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
|
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-sub");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var observed = new List<DataChangeEventArgs>();
|
|
var gate = new SemaphoreSlim(0);
|
|
drv.OnDataChange += (_, e) =>
|
|
{
|
|
lock (observed) observed.Add(e);
|
|
gate.Release();
|
|
};
|
|
|
|
var handle = await drv.SubscribeAsync(
|
|
[OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
// FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver.
|
|
// Wait up to 3 s to tolerate container warm-up + first-publish delay.
|
|
var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
got.ShouldBeTrue("opc-plc FastUInt1 must publish at least one data change within 3s");
|
|
|
|
int observedCount;
|
|
lock (observed) observedCount = observed.Count;
|
|
observedCount.ShouldBeGreaterThan(0);
|
|
|
|
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 O-9 + lifecycle generation, through the real SDK: a shutdown with several live
|
|
/// subscriptions completes inside the one teardown budget, lands Unknown, and a re-initialise of the same
|
|
/// instance reconnects and reads.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Shutdown_with_live_subscriptions_is_bounded_and_the_instance_reinitialises()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
var ct = TestContext.Current.CancellationToken;
|
|
|
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-lifecycle");
|
|
await drv.InitializeAsync("{}", ct);
|
|
for (var i = 0; i < 3; i++)
|
|
await drv.SubscribeAsync([OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), ct);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
await drv.ShutdownAsync(ct);
|
|
sw.Stop();
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "one 4 s budget bounds the whole teardown");
|
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
|
|
await drv.InitializeAsync("{}", ct);
|
|
var read = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
|
|
read.Single().StatusCode.ShouldBe(0u, "the re-initialised instance must be fully usable");
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The health contract against a real server: a write the server REFUSES (a string into the UInt32 StepUp —
|
|
/// BadTypeMismatch) is a reply — the refusal status passes through and the driver stays Healthy with its stamp
|
|
/// advanced.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_server_refused_write_is_a_reply_and_keeps_the_driver_healthy()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
var ct = TestContext.Current.CancellationToken;
|
|
|
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-refused-write");
|
|
await drv.InitializeAsync("{}", ct);
|
|
var before = drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
|
while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield();
|
|
|
|
var result = await drv.WriteAsync([new WriteRequest(OpcPlcProfile.StepUpRef, "not-a-number")], ct);
|
|
|
|
(result.Single().StatusCode & 0x80000000u).ShouldNotBe(0u, "opc-plc refuses a string written into a UInt32");
|
|
result.Single().StatusCode.ShouldNotBe(0x80050000u, "a server refusal is not a local comms error");
|
|
var h = drv.GetHealth();
|
|
h.State.ShouldBe(DriverState.Healthy);
|
|
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
|
}
|
|
}
|