Merge #622/#621/#606: OpcUaClient driver wire tests against the in-process server; SDK telemetry ctors in driver/browser; TreatWarningsAsErrors on the last 4 src projects

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:10:55 -04:00
18 changed files with 366 additions and 33 deletions
+8
View File
@@ -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
@@ -4,6 +4,7 @@
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Client.CLI</RootNamespace>
</PropertyGroup>
@@ -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;
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Client.Shared</RootNamespace>
</PropertyGroup>
@@ -4,6 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Client.UI</RootNamespace>
</PropertyGroup>
@@ -124,17 +124,13 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
/// <param name="ct">Cancellation token for the configuration build.</param>
private static async Task<ApplicationConfiguration> 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<Opc.Ua.ITelemetryContext> Instance = new(() => Opc.Ua.DefaultTelemetry.Create(_ => { }));
/// <summary>Gets the shared telemetry context.</summary>
public static Opc.Ua.ITelemetryContext Context => Instance.Value;
}
@@ -294,17 +294,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary>
private async Task<ApplicationConfiguration> 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
@@ -0,0 +1,16 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The one <see cref="ITelemetryContext" /> 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
/// <c>ClientTelemetry</c>).
/// </summary>
internal static class SdkTelemetry
{
private static readonly Lazy<ITelemetryContext> Instance = new(() => DefaultTelemetry.Create(_ => { }));
/// <summary>Gets the shared telemetry context.</summary>
public static ITelemetryContext Context => Instance.Value;
}
@@ -5,6 +5,7 @@
(which on .NET Framework 4.7.2 and .NET 6+ equally resolves netstandard2.0). -->
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsRoslynComponent>true</IsRoslynComponent>
@@ -37,7 +37,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
private static Mock<ISession> BuildCancellingSessionMock()
{
var mock = new Mock<ISession>(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<ISession>(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.
@@ -5,9 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Scaffold tests for <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
/// 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 <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
/// surface that don't require a live remote server. Live-browse coverage lives in
/// <c>OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDiscoveryTests
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Scaffold-level tests for <see cref="OpcUaClientDriver"/> 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 <c>OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDriverScaffoldTests
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// 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
/// <c>OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests</c>, against the in-process server.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientReadWriteTests
@@ -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(
@@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Scaffold tests for <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> 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 <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> that don't need a
/// live remote server. The live-session tests (subscribe/unsubscribe round-trip,
/// keep-alive transitions) live in <c>OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientSubscribeAndProbeTests
@@ -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;
/// <summary>
/// Wire-level coverage of the real <see cref="OpcUaClientDriver"/> against the in-process OtOpcUa server
/// (<see cref="ServerEventRig"/>), replacing the "lands in a follow-up PR once we have an in-process server
/// fixture" scaffolds in <c>Driver.OpcUaClient.Tests</c> (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 <c>WriteOperate</c> role.
/// <para>
/// HistoryRead is NOT covered here (deferred): the server-side leg needs an <c>IHistorianDataSource</c>
/// stub plus a historized-variable rig seam, and the driver's passthrough shapes are unit-tested already.
/// </para>
/// </summary>
[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<NodeWriteOutcome> 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<ServerEventRig> 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);
/// <summary>Reads round-trip through the real wire; a node the server lacks does not read Good.</summary>
[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");
}
/// <summary>A write the device accepts lands on the node and reads back.</summary>
[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);
}
/// <summary>A write to a read-only node is refused by the server and never reaches the gateway.</summary>
[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);
}
/// <summary>The node manager's write-outcome self-correction is visible to the driver as a revert.</summary>
[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");
}
/// <summary>A subscription delivers the initial value and then later server-side changes, and stops after unsubscribe.</summary>
[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<float>();
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");
}
/// <summary>The host probe reports Running while the server is up and leaves Running when it goes away.</summary>
[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<HostState>(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();
}
}
/// <summary>Discovery browses the live server's variables into the builder with their real data types.</summary>
[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) { }
}
}
}
@@ -138,6 +138,24 @@ internal sealed class ServerEventRig : IAsyncDisposable
ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false);
}
/// <summary>
/// Adds a raw value variable under <see cref="RawDeviceFolder"/> (the folder is ensured), for tests that
/// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers.
/// </summary>
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;
}
/// <summary>The server-stable <c>nsu=</c> form of a raw-realm string NodeId.</summary>
public static string RawNsu(string id) => $"nsu={V3NodeIds.RawNamespaceUri};s={id}";
/// <summary>The API-key credential <see cref="KeyPresentingAuthenticator"/> admits (username is always <c>apikey</c>).</summary>
public static string KeyPassword => $"otua_{KeyId}_secret";
public void EnsureEquipment()
{
Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns);
@@ -7,6 +7,12 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- OTOPCUA0001 (#595): OpcUaClientDriverWireTests call driver capabilities directly at the wire level on
purpose; the CapabilityInvoker-wrapping rule applies to production code only. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
@@ -31,6 +37,8 @@
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<!-- ClientAlarmMethodTests drives the real shared client against this in-process server. -->
<ProjectReference Include="..\..\..\src\Client\ZB.MOM.WW.OtOpcUa.Client.Shared\ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<!-- OpcUaClientDriverWireTests drive the real OpcUaClient driver against this in-process server (#622). -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
</ItemGroup>
</Project>