chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAlarmTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((ushort)1, AlarmSeverity.Low)]
|
||||
[InlineData((ushort)200, AlarmSeverity.Low)]
|
||||
[InlineData((ushort)201, AlarmSeverity.Medium)]
|
||||
[InlineData((ushort)500, AlarmSeverity.Medium)]
|
||||
[InlineData((ushort)501, AlarmSeverity.High)]
|
||||
[InlineData((ushort)800, AlarmSeverity.High)]
|
||||
[InlineData((ushort)801, AlarmSeverity.Critical)]
|
||||
[InlineData((ushort)1000, AlarmSeverity.Critical)]
|
||||
public void MapSeverity_buckets_per_OPC_UA_Part_9_guidance(ushort opcSev, AlarmSeverity expected)
|
||||
{
|
||||
OpcUaClientDriver.MapSeverity(opcSev).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSeverity_zero_maps_to_Low()
|
||||
{
|
||||
// 0 isn't in OPC UA's 1-1000 range but we handle it gracefully as Low.
|
||||
OpcUaClientDriver.MapSeverity(0).ShouldBe(AlarmSeverity.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAlarmsAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAlarmsAsync_with_unknown_handle_is_noop()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-unknown");
|
||||
// Parallels the subscribe handle path — session-drop races shouldn't crash the caller.
|
||||
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("ns=2;s=Src", "ns=2;s=Cond", "operator ack")],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_with_empty_batch_is_noop_even_without_init()
|
||||
{
|
||||
// Empty batch short-circuits before touching the session, so it's safe pre-init. This
|
||||
// keeps batch-ack callers from needing to guard the list size themselves.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-empty");
|
||||
await drv.AcknowledgeAsync([], TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "fake-alarm";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAttributeMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((uint)DataTypes.Boolean, DriverDataType.Boolean)]
|
||||
[InlineData((uint)DataTypes.Int16, DriverDataType.Int16)]
|
||||
[InlineData((uint)DataTypes.UInt16, DriverDataType.UInt16)]
|
||||
[InlineData((uint)DataTypes.Int32, DriverDataType.Int32)]
|
||||
[InlineData((uint)DataTypes.UInt32, DriverDataType.UInt32)]
|
||||
[InlineData((uint)DataTypes.Int64, DriverDataType.Int64)]
|
||||
[InlineData((uint)DataTypes.UInt64, DriverDataType.UInt64)]
|
||||
[InlineData((uint)DataTypes.Float, DriverDataType.Float32)]
|
||||
[InlineData((uint)DataTypes.Double, DriverDataType.Float64)]
|
||||
[InlineData((uint)DataTypes.String, DriverDataType.String)]
|
||||
[InlineData((uint)DataTypes.DateTime, DriverDataType.DateTime)]
|
||||
public void MapUpstreamDataType_recognizes_standard_builtin_types(uint typeId, DriverDataType expected)
|
||||
{
|
||||
var nodeId = new NodeId(typeId);
|
||||
OpcUaClientDriver.MapUpstreamDataType(nodeId).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapUpstreamDataType_maps_SByte_and_Byte_to_Int16_since_DriverDataType_lacks_8bit()
|
||||
{
|
||||
// DriverDataType has no 8-bit type; conservative widen to Int16. Documented so a
|
||||
// future Core.Abstractions PR that adds Int8/Byte can find this call site.
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.SByte)).ShouldBe(DriverDataType.Int16);
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.Byte)).ShouldBe(DriverDataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapUpstreamDataType_falls_back_to_String_for_unknown_custom_types()
|
||||
{
|
||||
// Custom vendor extension object — NodeId in namespace 2 that isn't a standard type.
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId("CustomStruct", 2)).ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapUpstreamDataType_handles_UtcTime_as_DateTime()
|
||||
{
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.UtcTime)).ShouldBe(DriverDataType.DateTime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0, SecurityClassification.ViewOnly)] // no access flags set
|
||||
[InlineData((byte)1, SecurityClassification.ViewOnly)] // CurrentRead only
|
||||
[InlineData((byte)2, SecurityClassification.Operate)] // CurrentWrite only
|
||||
[InlineData((byte)3, SecurityClassification.Operate)] // CurrentRead + CurrentWrite
|
||||
[InlineData((byte)0x0F, SecurityClassification.Operate)] // read+write+historyRead+historyWrite
|
||||
[InlineData((byte)0x04, SecurityClassification.ViewOnly)] // HistoryRead only — no Write bit
|
||||
public void MapAccessLevelToSecurityClass_respects_CurrentWrite_bit(byte accessLevel, SecurityClassification expected)
|
||||
{
|
||||
OpcUaClientDriver.MapAccessLevelToSecurityClass(accessLevel).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCertAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_missing_path()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
|
||||
.Message.ShouldContain("UserCertificatePath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_nonexistent_file()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
|
||||
};
|
||||
Should.Throw<FileNotFoundException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
|
||||
{
|
||||
// Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
|
||||
// The driver doesn't care about the issuer — just needs a cert with a private key.
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = tmpPath,
|
||||
UserCertificatePassword = "testpw",
|
||||
};
|
||||
var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
|
||||
identity.ShouldNotBeNull();
|
||||
identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||
var builder = new NullAddressSpaceBuilder();
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverAsync_rejects_null_builder()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await drv.DiscoverAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discovery_caps_are_sensible_defaults()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.MaxDiscoveredNodes.ShouldBe(10_000, "bounds memory on runaway servers without clipping normal models");
|
||||
opts.MaxBrowseDepth.ShouldBe(10, "deep enough for realistic info models; shallow enough for cycle safety");
|
||||
opts.BrowseRoot.ShouldBeNull("null = default to ObjectsFolder i=85");
|
||||
}
|
||||
|
||||
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDriverScaffoldTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_options_target_standard_opcua_port_and_anonymous_auth()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
||||
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
||||
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
|
||||
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
||||
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeouts_match_driver_specs_section_8()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(120));
|
||||
opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_reports_type_and_id_before_connect()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-test");
|
||||
drv.DriverType.ShouldBe("OpcUaClient");
|
||||
drv.DriverInstanceId.ShouldBe("opcua-test");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws()
|
||||
{
|
||||
// RFC 5737 reserved-for-documentation IP; won't route anywhere. Pick opc.tcp:// so
|
||||
// endpoint selection hits the transport-layer connection rather than a DNS lookup.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
|
||||
// with TCP RST immediately instead of hanging on connect, which keeps the
|
||||
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
|
||||
// routed to a black-hole + time out only after the SDK's internal retry/backoff
|
||||
// fully elapses (~60s even with Options.Timeout=500ms).
|
||||
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true, // dev-mode to bypass cert validation in the test
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-unreach");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
|
||||
// with TCP RST immediately instead of hanging on connect, which keeps the
|
||||
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
|
||||
// routed to a black-hole + time out only after the SDK's internal retry/backoff
|
||||
// fully elapses (~60s even with Options.Timeout=500ms).
|
||||
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true,
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-reinit");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.ReinitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientFailoverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_prefers_EndpointUrls_when_provided()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://fallback:4840",
|
||||
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||
};
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||
list.Count.ShouldBe(2);
|
||||
list[0].ShouldBe("opc.tcp://primary:4840");
|
||||
list[1].ShouldBe("opc.tcp://backup:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_falls_back_to_single_EndpointUrl_when_list_empty()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||
list.Count.ShouldBe(1);
|
||||
list[0].ShouldBe("opc.tcp://only:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_empty_list_treated_as_fallback_to_EndpointUrl()
|
||||
{
|
||||
// Explicit empty list should still fall back to the single-URL shortcut rather than
|
||||
// producing a zero-candidate sweep that would immediately throw with no URLs tried.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://single:4840",
|
||||
EndpointUrls = [],
|
||||
};
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(opts).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostName_uses_first_candidate_before_connect()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-host");
|
||||
drv.HostName.ShouldBe("opc.tcp://primary:4840",
|
||||
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
||||
{
|
||||
// Port 1 + port 2 + port 3 on loopback are all guaranteed closed (TCP RST immediate).
|
||||
// Failover sweep should attempt all three and throw AggregateException naming each URL
|
||||
// so operators see exactly which candidates were tried.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://127.0.0.1:1", "opc.tcp://127.0.0.1:2", "opc.tcp://127.0.0.1:3"],
|
||||
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true,
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
|
||||
|
||||
var ex = await Should.ThrowAsync<AggregateException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain("127.0.0.1:1");
|
||||
ex.Message.ShouldContain("127.0.0.1:2");
|
||||
ex.Message.ShouldContain("127.0.0.1:3");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientHistoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average)]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Total)]
|
||||
[InlineData(HistoryAggregateType.Count)]
|
||||
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
|
||||
{
|
||||
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
|
||||
NodeId.IsNull(nodeId).ShouldBeFalse();
|
||||
// Every mapping should resolve to an AggregateFunction_* NodeId (namespace 0, numeric id).
|
||||
nodeId.NamespaceIndex.ShouldBe((ushort)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToNodeId_rejects_invalid_enum_value()
|
||||
{
|
||||
// Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through.
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadRawAsync("ns=2;s=Counter",
|
||||
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000,
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_with_malformed_NodeId_returns_empty_result_not_throw()
|
||||
{
|
||||
// Same defensive pattern as ReadAsync / WriteAsync — malformed NodeId short-circuits
|
||||
// to an empty result rather than crashing a batch history call. Needs init via the
|
||||
// throw path first, then we pass "" to trigger the parse-fail branch inside
|
||||
// ExecuteHistoryReadAsync. The init itself fails against 127.0.0.1:1 so we stop there.
|
||||
// Not runnable without init — keep as placeholder for when the in-process fixture
|
||||
// PR lands.
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadProcessedAsync("ns=2;s=Counter",
|
||||
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow,
|
||||
TimeSpan.FromSeconds(10), HistoryAggregateType.Average,
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAtTimeAsync("ns=2;s=Counter",
|
||||
[DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_throws_NotSupportedException_as_documented()
|
||||
{
|
||||
// The IHistoryProvider default implementation throws; the OPC UA Client driver
|
||||
// deliberately inherits that default (see PR 76 commit body) because the OPC UA
|
||||
// client call path needs an EventFilter SelectClauses spec the interface doesn't carry.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-default");
|
||||
await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await ((IHistoryProvider)drv).ReadEventsAsync(
|
||||
sourceName: null,
|
||||
startUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
endUtc: DateTime.UtcNow,
|
||||
maxEvents: 100,
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAsync(["ns=2;s=Demo"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.WriteAsync(
|
||||
[new WriteRequest("ns=2;s=Demo", 42)],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <see cref="SessionReconnectHandler"/> wiring. Wire-level
|
||||
/// disconnect-reconnect-resume coverage against a live upstream server lands with the
|
||||
/// in-process fixture — too much machinery for a unit-test-only lane.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReconnectTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_ReconnectPeriod_matches_driver_specs_5_seconds()
|
||||
{
|
||||
new OpcUaClientDriverOptions().ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_ReconnectPeriod_is_configurable_for_aggressive_or_relaxed_retry()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { ReconnectPeriod = TimeSpan.FromMilliseconds(500) };
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_starts_with_no_reconnect_handler_active_pre_init()
|
||||
{
|
||||
// The reconnect handler is lazy — spun up only when a bad keep-alive fires. Pre-init
|
||||
// there's no session to reconnect, so the field must be null (indirectly verified by
|
||||
// the lifecycle-shape test suite catching any accidental construction).
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-reconnect");
|
||||
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSecurityPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityPolicy.None)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic128Rsa15)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256Sha256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)]
|
||||
public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy)
|
||||
{
|
||||
var uri = OpcUaClientDriver.MapSecurityPolicy(policy);
|
||||
uri.ShouldNotBeNullOrEmpty();
|
||||
// Each URI should end in the enum name (for the non-None policies) so a driver
|
||||
// operator reading logs can correlate the URI back to the config value.
|
||||
if (policy != OpcUaSecurityPolicy.None)
|
||||
uri.ShouldContain(policy.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_None_matches_SDK_None_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None)
|
||||
.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256)
|
||||
.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)
|
||||
.ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_enum_value_has_a_mapping()
|
||||
{
|
||||
foreach (OpcUaSecurityPolicy p in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSubscribeAndProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.SubscribeAsync(["ns=2;s=Demo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_with_unknown_handle_is_noop()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-unknown");
|
||||
// UnsubscribeAsync returns cleanly for handles it doesn't recognise — protects against
|
||||
// the caller's race with server-side cleanup after a session drop.
|
||||
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_returns_endpoint_url_row_pre_init()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(
|
||||
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.example:4840" },
|
||||
"opcua-hosts");
|
||||
var rows = drv.GetHostStatuses();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].HostName.ShouldBe("opc.tcp://plc.example:4840",
|
||||
"host identity mirrors the endpoint URL so the Admin /hosts dashboard can link back to the remote server");
|
||||
rows[0].State.ShouldBe(HostState.Unknown);
|
||||
}
|
||||
|
||||
private sealed class FakeHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "fake";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<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.Tests</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\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
</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