diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index 23e67e34..c4651a54 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -52,6 +52,14 @@ the tests mock. Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` + `IHostConnectivityProbe` (via the Secure Channel exchange). +### In-process wire tests (no opc-plc) + +`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests` drives the real driver +against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential): +read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert), +subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need +no Docker. The HistoryRead wire leg is deferred (needs an `IHistorianDataSource` stub on the server). + ### Unit The surface is broad because `OpcUaClientDriver` is the richest-capability diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj index fa00ef1c..03aa382a 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj @@ -4,6 +4,7 @@ Exe net10.0 enable + true enable ZB.MOM.WW.OtOpcUa.Client.CLI diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index fc9ee3b7..b64741ff 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -237,10 +237,8 @@ public sealed class OpcUaClientService : IOpcUaClientService hasChildren)); } - if (continuationPoint != null && continuationPoint.Length > 0) - (continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct); - else - break; + if (continuationPoint is not { Length: > 0 } pending) break; + (continuationPoint, references) = await _session.BrowseNextAsync(pending, ct); } return results; diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj index 4ad02026..a2e8e203 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj @@ -3,6 +3,7 @@ net10.0 enable + true enable ZB.MOM.WW.OtOpcUa.Client.Shared diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj index 5e5a5e0e..db6dcb97 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj @@ -4,6 +4,7 @@ WinExe net10.0 enable + true enable ZB.MOM.WW.OtOpcUa.Client.UI diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index 9a9d822e..af7c2ac0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -124,17 +124,13 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser /// Cancellation token for the configuration build. private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) { - // The default ctor is obsolete in favour of the ITelemetryContext overload; suppress - // locally rather than plumbing a telemetry context all the way through — the browser - // emits no per-request telemetry of its own and the SDK's internal fallback is fine - // for a transient picker session (mirrors the runtime driver's same suppression). -#pragma warning disable CS0618 - var app = new ApplicationInstance + // The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK + // internals off any logging provider. + var app = new ApplicationInstance(BrowseTelemetry.Context) { ApplicationName = "OtOpcUa AdminUI Browse", ApplicationType = ApplicationType.Client, }; -#pragma warning restore CS0618 var pkiRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -245,3 +241,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser private static string ShortName(string uri) => uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)"; } + +internal static class BrowseTelemetry +{ + private static readonly Lazy Instance = new(() => Opc.Ua.DefaultTelemetry.Create(_ => { })); + + /// Gets the shared telemetry context. + public static Opc.Ua.ITelemetryContext Context => Instance.Value; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 80ddbd8e..967111cf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -294,17 +294,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// private async Task BuildApplicationConfigurationAsync(CancellationToken ct) { - // The default ctor is obsolete in favour of the ITelemetryContext overload; suppress - // locally rather than plumbing a telemetry context all the way through the driver - // surface — the driver emits no per-request telemetry of its own and the SDK's - // internal fallback is fine for a gateway use case. -#pragma warning disable CS0618 - var app = new ApplicationInstance + // The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK + // internals off any logging provider (mirrors Client.Shared's ClientTelemetry). + var app = new ApplicationInstance(SdkTelemetry.Context) { ApplicationName = _options.SessionName, ApplicationType = ApplicationType.Client, }; -#pragma warning restore CS0618 var pkiRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -472,7 +468,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig); - var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync( + var session = await new DefaultSessionFactory(SdkTelemetry.Context).CreateAsync( appConfig, endpoint, false, // updateBeforeConnect diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/SdkTelemetry.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/SdkTelemetry.cs new file mode 100644 index 00000000..02d2b8f5 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/SdkTelemetry.cs @@ -0,0 +1,16 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// The one the driver hands the SDK's non-obsolete (telemetry-taking) +/// overloads. An empty builder keeps SDK internals off any logging provider (mirrors Client.Shared's +/// ClientTelemetry). +/// +internal static class SdkTelemetry +{ + private static readonly Lazy Instance = new(() => DefaultTelemetry.Create(_ => { })); + + /// Gets the shared telemetry context. + public static ITelemetryContext Context => Instance.Value; +} diff --git a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj index 4cb98585..0718be02 100644 --- a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj +++ b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj @@ -5,6 +5,7 @@ (which on .NET Framework 4.7.2 and .NET 6+ equally resolves netstandard2.0). --> netstandard2.0 enable + true latest false true diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs index 3abc109d..e4fcc323 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs @@ -37,7 +37,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests private static Mock BuildCancellingSessionMock() { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext()); + mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // BrowseAsync — return one Object child plus a non-empty continuation point so @@ -148,7 +148,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease"); var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext()); + mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // BrowseAsync — return a result with a continuation point so the loop is entered. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs index 23db27b2..bfcd4328 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs @@ -5,9 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// -/// Scaffold tests for 's -/// surface that don't require a live remote server. Live-browse coverage lands in a -/// follow-up PR once the in-process OPC UA server fixture is scaffolded. +/// Unit tests for 's +/// surface that don't require a live remote server. Live-browse coverage lives in +/// OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDiscoveryTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs index 6307a393..78cc3d26 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs @@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Scaffold-level tests for that don't require a live -/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable -/// tests against a local in-process OPC UA server fixture. +/// remote OPC UA server. The IReadable/IWritable/ITagDiscovery/ISubscribable wire-level +/// tests live in OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDriverScaffoldTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs index 01b132a4..1a3eba1d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs @@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit tests for the IReadable/IWritable surface that don't need a live remote OPC UA -/// server. Wire-level round-trips against a local in-process server fixture land in a -/// follow-up PR once we have one scaffolded. +/// server (the uninitialised-guard contract). The wire-level read/write round-trips live in +/// OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests, against the in-process server. /// [Trait("Category", "Unit")] public sealed class OpcUaClientReadWriteTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs index 23dc9ac1..07a76e7d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs @@ -53,7 +53,7 @@ public sealed class OpcUaClientStaleSessionRaceTests // MessageContext. This getter is read in the driver's synchronous prologue (before the // gate), so signalling here proves the pre-gate capture path has run. mock.SetupGet(s => s.MessageContext) - .Returns(new ServiceMessageContext()) + .Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)) .Callback(() => prologueReached?.TrySetResult()); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); mock.Setup(s => s.HistoryReadAsync( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs index bfde6c6a..8183a907 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs @@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// -/// Scaffold tests for ISubscribable + IHostConnectivityProbe that don't -/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip, -/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server -/// fixture is scaffolded. +/// Unit tests for ISubscribable + IHostConnectivityProbe that don't need a +/// live remote server. The live-session tests (subscribe/unsubscribe round-trip, +/// keep-alive transitions) live in OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests. /// [Trait("Category", "Unit")] public sealed class OpcUaClientSubscribeAndProbeTests diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs new file mode 100644 index 00000000..63cc8b0b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs @@ -0,0 +1,282 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Wire-level coverage of the real against the in-process OtOpcUa server +/// (), replacing the "lands in a follow-up PR once we have an in-process server +/// fixture" scaffolds in Driver.OpcUaClient.Tests (Gitea #622). No opc-plc and no Docker: every test +/// here runs a real secure-channel + session + subscription exchange over loopback. The driver authenticates +/// with the rig's API-key credential so writes carry the WriteOperate role. +/// +/// HistoryRead is NOT covered here (deferred): the server-side leg needs an IHistorianDataSource +/// stub plus a historized-variable rig seam, and the driver's passthrough shapes are unit-tested already. +/// +/// +[Trait("Category", "Integration")] +public sealed class OpcUaClientDriverWireTests +{ + private const string SpeedRef = "Sim/dev1/Speed"; + private const string RoRef = "Sim/dev1/Counter"; + private const string MissingRef = "Sim/dev1/Missing"; + private const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed"; + private const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter"; + + private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway + { + public List<(string NodeId, object? Value)> Writes { get; } = []; + + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) + { + lock (Writes) Writes.Add((nodeId, value)); + return Task.FromResult(new NodeWriteOutcome(succeed, succeed ? null : "device refused")); + } + } + + private static RawTagEntry Tag(string rawPath, string nodeId) => + new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true); + + private static OpcUaClientDriver NewDriver(ServerEventRig rig, string id, string? browseRoot = null) => + new(new OpcUaClientDriverOptions + { + EndpointUrl = rig.EndpointUrl, + RawTags = + [ + Tag(SpeedRef, ServerEventRig.RawNsu(SpeedId)), + Tag(RoRef, ServerEventRig.RawNsu(CounterId)), + Tag(MissingRef, ServerEventRig.RawNsu($"{ServerEventRig.RawDeviceFolder}/NoSuchNode")), + ], + SecurityPolicy = OpcUaSecurityPolicy.None, + SecurityMode = OpcUaSecurityMode.None, + AuthType = OpcUaAuthType.Username, + Username = "apikey", + Password = ServerEventRig.KeyPassword, + AutoAcceptCertificates = true, + TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform, + Timeout = TimeSpan.FromSeconds(10), + SessionTimeout = TimeSpan.FromSeconds(30), + KeepAliveInterval = TimeSpan.FromSeconds(1), + BrowseRoot = browseRoot, + }, id); + + private static async Task StartRigAsync(string name, CancellationToken ct) + { + var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct); + rig.EnsureRawVariable("Speed", "Float", writable: true); + rig.EnsureRawVariable("Counter", "Int32", writable: false); + return rig; + } + + private static void Publish(ServerEventRig rig, string id, object value) => + rig.Sink.WriteValue(id, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + /// Reads round-trip through the real wire; a node the server lacks does not read Good. + [Fact] + public async Task Read_returns_the_published_value_and_a_bad_status_for_a_missing_node() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-read", ct); + Publish(rig, SpeedId, 42.5f); + + await using var drv = NewDriver(rig, "wire-read"); + await drv.InitializeAsync("{}", ct); + + var snaps = await drv.ReadAsync([SpeedRef, MissingRef], ct); + + snaps.Count.ShouldBe(2); + snaps[0].StatusCode.ShouldBe(0u); + Convert.ToSingle(snaps[0].Value).ShouldBe(42.5f); + snaps[1].StatusCode.ShouldNotBe(0u, "a node the server does not have must not read Good"); + } + + /// A write the device accepts lands on the node and reads back. + [Fact] + public async Task Write_roundtrips_through_the_node_write_gateway_and_reads_back() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-write", ct); + var gateway = new RecordingWriteGateway(succeed: true); + rig.Nm.NodeWriteGateway = gateway; + Publish(rig, SpeedId, 1.0f); + + await using var drv = NewDriver(rig, "wire-write"); + await drv.InitializeAsync("{}", ct); + + var results = await drv.WriteAsync([new WriteRequest(SpeedRef, 7.25f)], ct); + + results.Single().StatusCode.ShouldBe(0u); + lock (gateway.Writes) Convert.ToSingle(gateway.Writes.Single().Value).ShouldBe(7.25f); + var back = await drv.ReadAsync([SpeedRef], ct); + Convert.ToSingle(back[0].Value).ShouldBe(7.25f); + } + + /// A write to a read-only node is refused by the server and never reaches the gateway. + [Fact] + public async Task Write_to_a_read_only_node_is_refused_and_never_reaches_the_gateway() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-write-ro", ct); + var gateway = new RecordingWriteGateway(succeed: true); + rig.Nm.NodeWriteGateway = gateway; + Publish(rig, CounterId, 5); + + await using var drv = NewDriver(rig, "wire-write-ro"); + await drv.InitializeAsync("{}", ct); + + var results = await drv.WriteAsync([new WriteRequest(RoRef, 9)], ct); + + results.Single().StatusCode.ShouldNotBe(0u); + lock (gateway.Writes) gateway.Writes.ShouldBeEmpty(); + // Positive control: the same session CAN write the writable node, so the refusal above is the node's. + (await drv.WriteAsync([new WriteRequest(SpeedRef, 2.0f)], ct)).Single().StatusCode.ShouldBe(0u); + lock (gateway.Writes) gateway.Writes.Count.ShouldBe(1); + } + + /// The node manager's write-outcome self-correction is visible to the driver as a revert. + [Fact] + public async Task A_write_the_device_refuses_is_reverted_and_the_driver_reads_the_original_value() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-write-fail", ct); + rig.Nm.NodeWriteGateway = new RecordingWriteGateway(succeed: false); + Publish(rig, SpeedId, 3.0f); + + await using var drv = NewDriver(rig, "wire-write-fail"); + await drv.InitializeAsync("{}", ct); + + await drv.WriteAsync([new WriteRequest(SpeedRef, 99.0f)], ct); + + var reverted = false; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline && !reverted) + { + var snap = (await drv.ReadAsync([SpeedRef], ct))[0]; + reverted = Math.Abs(Convert.ToSingle(snap.Value) - 3.0f) < 0.001f; + if (!reverted) await Task.Delay(50, ct); + } + reverted.ShouldBeTrue("the refused write must revert the node to its pre-write value"); + } + + /// A subscription delivers the initial value and then later server-side changes, and stops after unsubscribe. + [Fact] + public async Task Subscribe_delivers_the_initial_value_and_subsequent_changes_until_unsubscribed() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-sub", ct); + Publish(rig, SpeedId, 10.0f); + + await using var drv = NewDriver(rig, "wire-sub"); + await drv.InitializeAsync("{}", ct); + + var seen = new List(); + drv.OnDataChange += (_, e) => + { + if (e.FullReference == SpeedRef && e.Snapshot.StatusCode == 0u) + lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value)); + }; + + var handle = await drv.SubscribeAsync([SpeedRef], TimeSpan.FromMilliseconds(100), ct); + await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(10.0f); }); + + Publish(rig, SpeedId, 20.0f); + await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(20.0f); }); + + await drv.UnsubscribeAsync(handle, ct); + + // Absence needs a positive control: after unsubscribing, publish 30.0 and prove a FRESH subscription + // delivers it exactly once, i.e. the dead first subscription contributed nothing. + Publish(rig, SpeedId, 30.0f); + var control = await drv.SubscribeAsync([SpeedRef], TimeSpan.FromMilliseconds(100), ct); + await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(30.0f); }); + await Task.Delay(500, ct); + await drv.UnsubscribeAsync(control, ct); + lock (seen) seen.Count(v => v == 30.0f).ShouldBe(1, "only the control subscription delivers 30.0"); + } + + /// The host probe reports Running while the server is up and leaves Running when it goes away. + [Fact] + public async Task Host_status_is_Running_then_leaves_Running_when_the_server_stops() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartRigAsync("drv-probe", ct); + await using var drv = NewDriver(rig, "wire-probe"); + try + { + await drv.InitializeAsync("{}", ct); + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + + var transitioned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + drv.OnHostStatusChanged += (_, e) => + { + if (e.NewState != HostState.Running) transitioned.TrySetResult(e.NewState); + }; + + await rig.DisposeAsync(); + rig = null; + + var state = await transitioned.Task.WaitAsync(TimeSpan.FromSeconds(30), ct); + state.ShouldNotBe(HostState.Running); + drv.GetHostStatuses().Single().State.ShouldNotBe(HostState.Running); + } + finally + { + if (rig is not null) await rig.DisposeAsync(); + } + } + + /// Discovery browses the live server's variables into the builder with their real data types. + [Fact] + public async Task Discovery_browses_the_servers_variables_into_the_builder() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartRigAsync("drv-disco", ct); + Publish(rig, SpeedId, 1.0f); + + await using var drv = NewDriver(rig, "wire-disco", browseRoot: ServerEventRig.RawNsu(ServerEventRig.RawDeviceFolder)); + await drv.InitializeAsync("{}", ct); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, ct); + + // The server's browse name for a raw variable is its full RawPath, so key on the stable nsu= reference. + var byRef = builder.Variables.ToDictionary(v => v.Info.FullName); + var speed = byRef[ServerEventRig.RawNsu(SpeedId)].Info; + var counter = byRef[ServerEventRig.RawNsu(CounterId)].Info; + speed.DriverDataType.ShouldBe(DriverDataType.Float32); + counter.DriverDataType.ShouldBe(DriverDataType.Int32); + speed.SecurityClass.ShouldNotBe(counter.SecurityClass, + "the writable variable and the read-only one must classify differently"); + } + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new Handle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullReference) : IVariableHandle + { + public string FullReference { get; } = fullReference; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); + } + + private sealed class Sink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs index 46a90c35..e0bf4ae1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -138,6 +138,24 @@ internal sealed class ServerEventRig : IAsyncDisposable ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false); } + /// + /// Adds a raw value variable under (the folder is ensured), for tests that + /// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers. + /// + public string EnsureRawVariable(string name, string dataType, bool writable) + { + Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + var id = $"{RawDeviceFolder}/{name}"; + Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw); + return id; + } + + /// The server-stable nsu= form of a raw-realm string NodeId. + public static string RawNsu(string id) => $"nsu={V3NodeIds.RawNamespaceUri};s={id}"; + + /// The API-key credential admits (username is always apikey). + public static string KeyPassword => $"otua_{KeyId}_secret"; + public void EnsureEquipment() { Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj index 544f524c..c2b3266f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj @@ -7,6 +7,12 @@ true + + + $(NoWarn);OTOPCUA0001 + + @@ -31,6 +37,8 @@ + +