Merge pull request 'OpcUaClient integration fixture � opc-plc in Docker (#215)' (#161) from opcuaclient-opc-plc-fixture into v2
This commit was merged in pull request #161.
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
@@ -3,24 +3,55 @@
|
||||
Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
|
||||
driver.
|
||||
|
||||
**TL;DR: there is no integration fixture.** Tests mock the OPC UA SDK's
|
||||
`Session` + `Subscription` types directly; there is no upstream OPC UA
|
||||
server standup in CI. The irony is not lost — this repo *is* an OPC UA
|
||||
server, and the integration fixtures for `OpcUaApplicationHost`
|
||||
(`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` +
|
||||
`OpcUaEquipmentWalkerIntegrationTests.cs`) stand up the server-side stack
|
||||
end-to-end. The client-side driver could in principle wire against one of
|
||||
those, but doesn't today.
|
||||
**TL;DR:** Wire-level coverage now exists via
|
||||
[opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) — Microsoft
|
||||
Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real
|
||||
Secure Channel, real Session, real MonitoredItem exchange against an
|
||||
independent server implementation. Unit tests still carry the exhaustive
|
||||
capability matrix (cert auth / security policies / reconnect / failover /
|
||||
attribute mapping). Gaps remaining: upstream-server-specific quirks
|
||||
(historian aggregates, typed ConditionType events, SDK-publish-queue edge
|
||||
behavior under load) — opc-plc uses the same OPCFoundation stack internally
|
||||
so fully-independent-stack coverage needs `open62541/open62541` as a second
|
||||
image (follow-up).
|
||||
|
||||
## What the fixture is
|
||||
|
||||
Nothing at the integration layer.
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is unit-only. Tests
|
||||
inject fakes through the driver's construction path; the
|
||||
**Integration layer** (task #215):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` stands up
|
||||
`mcr.microsoft.com/iotedge/opc-plc:2.14.10` via `Docker/docker-compose.yml`
|
||||
on `opc.tcp://localhost:50000`. `OpcPlcFixture` probes the port at
|
||||
collection init + skips tests with a clear message when the container's
|
||||
not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern).
|
||||
Docker is the launcher — no PowerShell wrapper needed because opc-plc
|
||||
ships pre-containerized. Compose-file flags: `--ut` (unsecured transport
|
||||
advertised), `--aa` (auto-accept client certs — opc-plc's cert trust store
|
||||
resets on each spin-up), `--alm` (alarm simulation for IAlarmSource
|
||||
follow-up coverage), `--pn=50000` (port).
|
||||
|
||||
**Unit layer**:
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is still the primary
|
||||
coverage. Tests inject fakes through the driver's construction path; the
|
||||
OPCFoundation.NetStandard `Session` surface is wrapped behind an interface
|
||||
the tests mock.
|
||||
|
||||
## What it actually covers (unit only)
|
||||
## What it actually covers
|
||||
|
||||
### Integration (opc-plc Docker, task #215)
|
||||
|
||||
- `OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack` —
|
||||
full Secure Channel + Session + `ns=3;s=StepUp` Read round-trip
|
||||
- `OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator` —
|
||||
batch Read of UInt32 / Int32 / Boolean; asserts `bool`-specific Variant
|
||||
decoding to catch a common attribute-mapping regression
|
||||
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` —
|
||||
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
|
||||
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
|
||||
|
||||
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
|
||||
`IHostConnectivityProbe` (via the Secure Channel exchange).
|
||||
|
||||
### Unit
|
||||
|
||||
The surface is broad because `OpcUaClientDriver` is the richest-capability
|
||||
driver in the fleet (it's a gateway for another OPC UA server, so it
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# opc-plc Docker fixture
|
||||
|
||||
[Microsoft Industrial IoT's opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc)
|
||||
— pinned Docker image that stands up an OPC UA server at
|
||||
`opc.tcp://localhost:50000` with step-up counters, random nodes, alarm
|
||||
simulation, and other canonical simulated shapes. Replaces the PowerShell
|
||||
launcher pattern used by the Modbus / S7 fixtures — Docker is the launcher
|
||||
here since opc-plc ships pre-containerized.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`docker-compose.yml`](docker-compose.yml) | Service definition for `otopcua-opc-plc` — image pin, port map, command flags. |
|
||||
| (this file) | How to run it. |
|
||||
|
||||
## Install
|
||||
|
||||
Docker Desktop (Windows) or the docker CLI + daemon (Linux/macOS). Per
|
||||
`CLAUDE.md` Phase 1 decision #134 the dev box already has Docker Desktop
|
||||
configured with the WSL 2 backend — nothing new to install.
|
||||
|
||||
## Run
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests\Docker\docker-compose.yml up
|
||||
```
|
||||
|
||||
Or from this folder:
|
||||
|
||||
```powershell
|
||||
docker compose up
|
||||
```
|
||||
|
||||
First run pulls the image (~250 MB). Startup takes ~5-10 seconds; the
|
||||
healthcheck in the compose file surfaces ready state in `docker ps`.
|
||||
|
||||
To run detached (CI pattern):
|
||||
|
||||
```powershell
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Stop with `docker compose down` (removes the container) or `docker compose stop`
|
||||
(keeps it for fast restart).
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Default: `opc.tcp://localhost:50000`
|
||||
- Override by setting `OPCUA_SIM_ENDPOINT` before `dotnet test` — e.g. point
|
||||
at a real OPC UA server in the lab, or at a different Docker host.
|
||||
|
||||
## What opc-plc advertises
|
||||
|
||||
Command flags in `docker-compose.yml` enable:
|
||||
|
||||
- `--pn=50000` — OPC UA endpoint on port 50000
|
||||
- `--ut` — unsecured transport endpoint advertised (SecurityPolicy=None).
|
||||
Secured policies are still on the endpoint list; `--ut` just adds an
|
||||
unsecured option.
|
||||
- `--aa` — auto-accept client certs (opc-plc's cert trust store lives
|
||||
inside the container + resets each spin-up, so without this the driver's
|
||||
first contact would be rejected).
|
||||
- `--alm` — alarm simulation enabled; opc-plc publishes
|
||||
`TripAlarmType`, `ExclusiveDeviationAlarmType`,
|
||||
`NonExclusiveLevelAlarmType`, and `DialogConditionType` events.
|
||||
|
||||
Not turned on (but available via compose-file tweaks):
|
||||
|
||||
- `--daa` — disable anonymous auth; forces username or cert tokens. Flip
|
||||
on when username-auth / cert-auth smoke tests land.
|
||||
- `--fn` / `--fr` / `--ft` — fast-node variants (100 / 1 000 / 10 000 Hz
|
||||
update rates) for subscription-stress coverage. Not needed for smoke.
|
||||
- `--sn` / `--sr` — slow-node / special-shape coverage.
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell, with the simulator running:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests
|
||||
```
|
||||
|
||||
Tests auto-skip with a clear `SkipReason` when `localhost:50000` isn't
|
||||
reachable within 2 seconds (`OpcPlcFixture`).
|
||||
|
||||
## Known limitations
|
||||
|
||||
opc-plc uses the OPCFoundation.NetStandard stack internally — same as
|
||||
our driver. That means bugs common to the stack itself are **not** caught
|
||||
by this fixture; the follow-up to add `open62541/open62541` as a second
|
||||
independent-stack image (task tracked in #215's follow-ups) would close
|
||||
that.
|
||||
|
||||
See [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)
|
||||
for the full coverage map + what's still trusted from field deployments.
|
||||
|
||||
## References
|
||||
|
||||
- [opc-plc GitHub](https://github.com/Azure-Samples/iot-edge-opc-plc)
|
||||
- [mcr.microsoft.com/iotedge/opc-plc tags](https://mcr.microsoft.com/v2/iotedge/opc-plc/tags/list)
|
||||
- [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)
|
||||
@@ -0,0 +1,45 @@
|
||||
# opc-plc — OPC UA PLC simulator from Microsoft Industrial IoT.
|
||||
# https://github.com/Azure-Samples/iot-edge-opc-plc
|
||||
#
|
||||
# Why pinned: MCR tags only go forward; keeping the suite reproducible means
|
||||
# we test against a known feature surface. Bump deliberately alongside a
|
||||
# driver-side change that needs the newer image.
|
||||
services:
|
||||
opc-plc:
|
||||
image: mcr.microsoft.com/iotedge/opc-plc:2.14.10
|
||||
container_name: otopcua-opc-plc
|
||||
restart: "no"
|
||||
ports:
|
||||
- "50000:50000"
|
||||
command:
|
||||
# --pn: Bind port 50000 (opc-plc default; matches fixture default)
|
||||
# --ut: Advertise an Unsecured transport endpoint (SecurityPolicy=None).
|
||||
# Tests that need signed/encrypted endpoints pick those off the
|
||||
# negotiated endpoint list separately — opc-plc always advertises
|
||||
# the secure policies even with --ut on.
|
||||
# --aa: Auto-accept client certs. Tests wouldn't otherwise survive the
|
||||
# first contact because opc-plc's cert trust store lives inside
|
||||
# the container + resets each spin-up.
|
||||
# --daa: Disable anonymous auth — forces the driver to go through the
|
||||
# Anonymous user-token policy negotiation rather than opc-plc's
|
||||
# "no auth required" short-circuit. Would flip to username/cert
|
||||
# if we needed that coverage.
|
||||
# Commented out for first-pass smoke; flip on when the cert-auth
|
||||
# and username-auth smoke tests land.
|
||||
# --alm: Turn on alarm simulation (TripAlarm / ExclusiveDeviation /
|
||||
# NonExclusiveLevel / DialogCondition). Closes the IAlarmSource
|
||||
# gap the OpcUaClient-Test-Fixture doc calls out.
|
||||
- "--pn=50000"
|
||||
- "--ut"
|
||||
- "--aa"
|
||||
- "--alm"
|
||||
# - "--daa"
|
||||
healthcheck:
|
||||
# opc-plc doesn't expose an HTTP health endpoint by default; use a TCP
|
||||
# probe via a shell the base image ships with. The fixture does its own
|
||||
# TCP probe but healthcheck surfaces status in `docker ps` for humans.
|
||||
test: ["CMD-SHELL", "netstat -an | grep -q ':50000.*LISTEN' || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for an <c>opc-plc</c> simulator (Microsoft Industrial IoT's
|
||||
/// OPC UA PLC from <c>mcr.microsoft.com/iotedge/opc-plc</c>) or any real OPC UA
|
||||
/// server the <c>OPCUA_SIM_ENDPOINT</c> env var points at. Parses
|
||||
/// <c>OPCUA_SIM_ENDPOINT</c> (default <c>opc.tcp://localhost:50000</c>),
|
||||
/// TCP-connects to the resolved host:port at collection init, and records a
|
||||
/// <see cref="SkipReason"/> on failure. Tests call <c>Assert.Skip</c> on that, so
|
||||
/// `dotnet test` stays green when Docker isn't running the simulator — mirrors the
|
||||
/// <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why opc-plc over loopback against our own server</b> — (1) independent
|
||||
/// cert chain + user-token handling catches interop bugs loopback can't;
|
||||
/// (2) built-in alarm ConditionType + history simulation gives
|
||||
/// <see cref="Core.Abstractions.IAlarmSource"/> +
|
||||
/// <see cref="Core.Abstractions.IHistoryProvider"/> coverage without a custom
|
||||
/// driver fake; (3) pinned image tag fixes the test surface in a way our own
|
||||
/// evolving server wouldn't. Follow-up: add <c>open62541/open62541</c> as a
|
||||
/// second image once this lands, for fully-independent-stack interop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Endpoint URL contract: parser strips the <c>opc.tcp://</c> scheme + resolves
|
||||
/// host + port for the liveness probe only. The real test session always
|
||||
/// dials the full endpoint URL via <see cref="OpcUaClientDriverOptions.EndpointUrl"/>
|
||||
/// so cert negotiation + security-policy selection run end-to-end.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcPlcFixture : IAsyncDisposable
|
||||
{
|
||||
private const string DefaultEndpoint = "opc.tcp://localhost:50000";
|
||||
private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT";
|
||||
|
||||
/// <summary>Full <c>opc.tcp://host:port</c> URL the driver session should connect to.</summary>
|
||||
public string EndpointUrl { get; }
|
||||
public string Host { get; }
|
||||
public int Port { get; }
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public OpcPlcFixture()
|
||||
{
|
||||
EndpointUrl = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
|
||||
|
||||
(Host, Port) = ParseHostPort(EndpointUrl);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||
var task = client.ConnectAsync(
|
||||
System.Net.Dns.GetHostAddresses(Host)
|
||||
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback,
|
||||
Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"opc-plc simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"opc-plc simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse "opc.tcp://host:port[/path]" → (host, port). Defaults to port 4840
|
||||
/// (OPC UA standard) when the URL omits the port, but opc-plc's default is
|
||||
/// 50000 so DefaultEndpoint carries it explicitly.
|
||||
/// </summary>
|
||||
private static (string Host, int Port) ParseHostPort(string endpointUrl)
|
||||
{
|
||||
const string scheme = "opc.tcp://";
|
||||
var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)
|
||||
? endpointUrl[scheme.Length..]
|
||||
: endpointUrl;
|
||||
var slash = body.IndexOf('/');
|
||||
if (slash >= 0) body = body[..slash];
|
||||
var colon = body.IndexOf(':');
|
||||
if (colon < 0) return (body, 4840);
|
||||
var host = body[..colon];
|
||||
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class OpcPlcCollection : Xunit.ICollectionFixture<OpcPlcFixture>
|
||||
{
|
||||
public const string Name = "OpcPlc";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side configuration + well-known opc-plc node identifiers that the smoke
|
||||
/// tests address. Node IDs are stable across opc-plc releases — the simulator
|
||||
/// guarantees the same <c>ns=3;s=...</c> names shipped since v1.0. If a release
|
||||
/// bump breaks these, the fixture's pinned image tag needs a coordinated bump.
|
||||
/// </summary>
|
||||
public static class OpcPlcProfile
|
||||
{
|
||||
/// <summary>opc-plc monotonically-increasing UInt32; ticks once per second under default opts.</summary>
|
||||
public const string StepUp = "ns=3;s=StepUp";
|
||||
|
||||
/// <summary>opc-plc random Int32 node; new value ~every 100ms.</summary>
|
||||
public const string RandomSignedInt32 = "ns=3;s=RandomSignedInt32";
|
||||
|
||||
/// <summary>opc-plc alternating boolean; flips every second.</summary>
|
||||
public const string AlternatingBoolean = "ns=3;s=AlternatingBoolean";
|
||||
|
||||
/// <summary>opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests.</summary>
|
||||
public const string FastUInt1 = "ns=3;s=FastUInt1";
|
||||
|
||||
public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new()
|
||||
{
|
||||
EndpointUrl = endpointUrl,
|
||||
SecurityPolicy = OpcUaSecurityPolicy.None,
|
||||
SecurityMode = OpcUaSecurityMode.None,
|
||||
AuthType = OpcUaAuthType.Anonymous,
|
||||
// opc-plc auto-accepts client certs (--aa) but we still present one; trust the
|
||||
// server's cert back since the simulator regenerates it each container spin-up
|
||||
// and there's no meaningful chain to validate against.
|
||||
AutoAcceptCertificates = true,
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
SessionTimeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
{
|
||||
[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.StepUp], 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");
|
||||
}
|
||||
|
||||
[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.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean],
|
||||
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>();
|
||||
}
|
||||
|
||||
[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.FastUInt1], 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user