test: migrate SDK 2.0 wire contracts and pin baseline aggregate vectors

This commit is contained in:
Joseph Doherty
2026-11-10 10:44:23 -05:00
parent 6aaccb332f
commit aac7c88417
29 changed files with 4695 additions and 362 deletions
@@ -847,7 +847,7 @@ public sealed class AclEventSubscriptionEnforcementTests
private static async Task CallIgnoringStatusAsync(
ISession session, NodeId objectId, NodeId methodId, CancellationToken ct, params object[] args)
{
try { await session.CallAsync(objectId, methodId, ct, args); }
try { await session.CallAsync(objectId, methodId, ct, args.Select(SdkTestValues.FromClr).ToArray()); }
catch (ServiceResultException) { /* the outcome is not under test */ }
}
@@ -905,9 +905,9 @@ public sealed class AclEventSubscriptionEnforcementTests
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventType));
var item = new MonitoredItem(subscription.DefaultItem)
{
@@ -940,21 +940,21 @@ public sealed class AclEventSubscriptionEnforcementTests
private readonly Dictionary<(uint Handle, string Message), byte[]> _eventIds = [];
private readonly HashSet<(uint Handle, NodeId Type)> _types = [];
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
public void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > MessageFieldIndex
&& e.EventFields[MessageFieldIndex].Value is LocalizedText { Text: { } text })
&& e.EventFields[MessageFieldIndex].ClrValue()! is LocalizedText { Text: { } text })
{
_seen.Add((e.ClientHandle, text));
if (e.EventFields[0].Value is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId;
if (e.EventFields[0].ClrValue()! is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId;
}
if (e.EventFields.Count > EventTypeFieldIndex
&& e.EventFields[EventTypeFieldIndex].Value is NodeId type)
&& e.EventFields[EventTypeFieldIndex].ClrValue()! is NodeId type)
{
_types.Add((e.ClientHandle, type));
}
@@ -1115,7 +1115,7 @@ public sealed class AclEventSubscriptionEnforcementTests
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.AclEventClient",
ApplicationUri = $"urn:OtOpcUa.AclEventClient.{Guid.NewGuid():N}",
@@ -1124,29 +1124,24 @@ public sealed class AclEventSubscriptionEnforcementTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
var identity = anonymous
? new UserIdentity(new AnonymousIdentityToken())
: new UserIdentity(new UserNameIdentityToken
{
UserName = "apikey",
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
});
: new UserIdentity("apikey", Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"));
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "AclEventSubscriptionEnforcementTests",
sessionTimeout: 60_000,
identity: identity,
preferredLocales: null,
preferredLocales: default,
ct: ct);
}
@@ -63,7 +63,7 @@ public sealed class AclKeyExpiryEnforcementTests
var (subscription, received) = await rig.SubscribeAsync(session, ct);
// Baseline: the key-carrying session's feed is live.
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
// Only the clock moves. No deployment, no new generation, no grant change.
@@ -78,7 +78,7 @@ public sealed class AclKeyExpiryEnforcementTests
received.Clear();
rig.Write(2);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
received.ShouldNotContain(v => Equals(v.Value, 2f));
received.ShouldNotContain(v => Equals(v.ClrValue()!, 2f));
await subscription.DeleteAsync(true, ct);
}
@@ -106,14 +106,14 @@ public sealed class AclKeyExpiryEnforcementTests
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
rig.Advance(TimeSpan.FromMinutes(20));
rig.TickRevalidation();
rig.Write(2);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 2f)), TimeSpan.FromSeconds(10));
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
await subscription.DeleteAsync(true, ct);
@@ -282,7 +282,7 @@ public sealed class AclKeyExpiryEnforcementTests
/// <returns>The connected session.</returns>
public async Task<ISession> ConnectAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.AclKeyClient",
ApplicationUri = $"urn:OtOpcUa.AclKeyClient.{Guid.NewGuid():N}",
@@ -291,25 +291,20 @@ public sealed class AclKeyExpiryEnforcementTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "AclKeyExpiryEnforcementTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new UserNameIdentityToken
{
UserName = "apikey",
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
}),
preferredLocales: null,
identity: new UserIdentity("apikey", Encoding.UTF8.GetBytes($"otua_{KeyId}_secret")),
preferredLocales: default,
ct: ct);
}
@@ -183,13 +183,13 @@ public sealed class AclServerObjectModelChangeTests
return (session, events, item, subscription);
}
private static bool NamesEqA(VariantCollection e) => Names(e, EqA);
private static bool NamesEqA(ArrayOf<Variant> e) => Names(e, EqA);
/// <summary>Whether a model-change event's <c>Changes</c> name the node whose identifier is <paramref name="id"/>.</summary>
private static bool Names(VariantCollection e, string id) =>
e[RecordedEvents.ChangesField].Value is ExtensionObject[] changes
&& changes.Any(c => c.Body is ModelChangeStructureDataType { Affected: { } affected }
&& affected.Identifier as string == id);
private static bool Names(ArrayOf<Variant> e, string id) =>
e[RecordedEvents.ChangesField].ClrValue()! is ExtensionObject[] changes
&& changes.Any(c => ExtensionObject.ToEncodeable(c) is ModelChangeStructureDataType { Affected: { } affected }
&& affected.IdentifierAsString == id);
/// <summary>Every device write fails, so the node manager reverts and audits it.</summary>
private sealed class RejectingGateway : IOpcUaNodeWriteGateway
@@ -63,7 +63,7 @@ public sealed class AclSubscriptionEnforcementTests
var (subscription, received) = await rig.SubscribeAsync(session, ct);
// Baseline: the feed is live.
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
@@ -79,7 +79,7 @@ public sealed class AclSubscriptionEnforcementTests
received.Clear();
rig.Write(2);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
received.ShouldNotContain(v => Equals(v.Value, 2f));
received.ShouldNotContain(v => Equals(v.ClrValue()!, 2f));
await subscription.DeleteAsync(true, ct);
}
@@ -106,13 +106,13 @@ public sealed class AclSubscriptionEnforcementTests
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
state.Apply(Cluster, "rev-2", [], [grant], NoScopes);
rig.Write(2);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 2f)), TimeSpan.FromSeconds(10));
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
await subscription.DeleteAsync(true, ct);
@@ -144,7 +144,7 @@ public sealed class AclSubscriptionEnforcementTests
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
state.Apply(Cluster, "rev-2", [], [], NoScopes);
await WaitUntilAsync(
@@ -156,7 +156,7 @@ public sealed class AclSubscriptionEnforcementTests
state.Apply(Cluster, "rev-3", [], [grant], NoScopes);
rig.Write(3);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 3f)), TimeSpan.FromSeconds(10));
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 3f)), TimeSpan.FromSeconds(10));
await subscription.DeleteAsync(true, ct);
}
@@ -181,11 +181,11 @@ public sealed class AclSubscriptionEnforcementTests
// The read works, proving the session is admitted and granted.
var read = await session.ReadValueAsync(rig.TagNodeId(session), ct);
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
var (subscription, _) = await rig.SubscribeAsync(session, ct, expectFailure: true);
subscription.MonitoredItems.First().Status.Error?.StatusCode.Code
.ShouldBe(StatusCodes.BadUserAccessDenied);
.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
await subscription.DeleteAsync(true, ct);
}
@@ -272,8 +272,8 @@ public sealed class AclSubscriptionEnforcementTests
],
ct: ct);
return response.Results[0].References
.Select(r => r.NodeId.Identifier as string ?? string.Empty)
return response.Results[0].References.ToList()
.Select(r => r.NodeId.IdentifierAsString)
.ToList();
}
@@ -403,7 +403,7 @@ public sealed class AclSubscriptionEnforcementTests
public async Task<ISession> ConnectAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.AclClient",
ApplicationUri = $"urn:OtOpcUa.AclClient.{Guid.NewGuid():N}",
@@ -412,20 +412,20 @@ public sealed class AclSubscriptionEnforcementTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "AclSubscriptionEnforcementTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null,
preferredLocales: default,
ct: ct);
}
@@ -185,17 +185,17 @@ public sealed class AdvertisedAggregateFunctionsWireTests
private static async Task<List<string>> AdvertisedAsync(ISession session, NodeId folder, CancellationToken ct)
{
var refs = await BrowseAsync(session, folder, BrowseDirection.Forward, ct);
return refs.Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
return refs.ToList().Select(r => r.BrowseName.Name ?? throw new InvalidOperationException("Aggregate has no browse name.")).OrderBy(n => n, StringComparer.Ordinal).ToList();
}
/// <summary>The folders an aggregate-function node is organized under (inverse Organizes).</summary>
private static async Task<List<NodeId>> ParentsAsync(ISession session, NodeId aggregate, CancellationToken ct)
{
var refs = await BrowseAsync(session, aggregate, BrowseDirection.Inverse, ct);
return refs.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris)).ToList();
return refs.ToList().Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris)).ToList();
}
private static async Task<ReferenceDescriptionCollection> BrowseAsync(
private static async Task<ArrayOf<ReferenceDescription>> BrowseAsync(
ISession session, NodeId node, BrowseDirection direction, CancellationToken ct)
{
var response = await session.BrowseAsync(
@@ -18,10 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// and the per-client API-key miss budget therefore applies to a real client.
/// </summary>
/// <remarks>
/// The SDK exposes no client address on any public surface; <see cref="SecureChannelRemoteEndpoint"/>
/// reads it from the TCP listener by reflection and degrades to null when that path breaks. These tests are
/// what turn an SDK upgrade that breaks it into a failure instead of a silent fall-back to process-wide
/// accounting.
/// SDK 2 supplies observed PeerAddress during activation. This test verifies that public evidence
/// reaches the authenticator and its per-IP miss budget; no private channel lookup is involved.
/// </remarks>
public sealed class ApiKeyMissBudgetPerEndpointTests
{
@@ -36,7 +34,7 @@ public sealed class ApiKeyMissBudgetPerEndpointTests
recorder.Seen.ShouldNotBeNull("the server must resolve the activating client's endpoint");
IPAddress.IsLoopback(recorder.Seen!.Address).ShouldBeTrue($"connected from loopback, got {recorder.Seen}");
recorder.Seen.Port.ShouldBeGreaterThan(0);
recorder.Seen.Port.ShouldBe(0, "the public SDK evidence contains an IP address, not a source port");
}
/// <summary>
@@ -108,7 +106,7 @@ public sealed class ApiKeyMissBudgetPerEndpointTests
public async Task<ISession> ConnectAsync(string credential, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.MissBudgetClient",
ApplicationUri = $"urn:OtOpcUa.MissBudgetClient.{Guid.NewGuid():N}",
@@ -117,24 +115,20 @@ public sealed class ApiKeyMissBudgetPerEndpointTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "ApiKeyMissBudgetPerEndpointTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new UserNameIdentityToken
{
UserName = "apikey",
DecryptedPassword = Encoding.UTF8.GetBytes(credential),
}),
preferredLocales: null,
identity: new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)),
preferredLocales: default,
ct: ct);
}
@@ -144,23 +144,23 @@ public sealed class ArrayWriteWireTests
private static async Task<StatusCode> WriteAsync(ISession session, NodeId node, object value, CancellationToken ct, string? indexRange = null)
{
var response = await session.WriteAsync(null, new WriteValueCollection
var response = await session.WriteAsync(null, new WriteValue[]
{
new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(new Variant(value)) },
new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(SdkTestValues.FromClr(value)) },
}, ct);
return response.Results.ShouldHaveSingleItem();
return response.Results.ToList().ShouldHaveSingleItem();
}
private static async Task<short[]> ReadShortsAsync(ISession session, NodeId node, CancellationToken ct) =>
(short[])(await session.ReadValueAsync(node, ct)).Value;
(short[])(await session.ReadValueAsync(node, ct)).ClrValue()!;
private static async Task<byte> ReadAccessLevelAsync(ISession session, NodeId node, CancellationToken ct)
{
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[]
{
new ReadValueId { NodeId = node, AttributeId = Attributes.UserAccessLevel },
}, ct);
return (byte)response.Results.ShouldHaveSingleItem().Value;
return (byte)response.Results.ToList().ShouldHaveSingleItem().ClrValue()!;
}
private static async Task WaitForAsync(Func<Task<bool>> condition)
@@ -218,7 +218,7 @@ public sealed class ArrayWriteWireTests
(await ReadAccessLevelAsync(session, ab, ct) & AccessLevels.CurrentWrite).ShouldBe((byte)0);
(await WriteAsync(session, ab, new[] { 50, 60, 70, 80 }, ct)).ShouldBe((StatusCode)StatusCodes.BadNotWritable);
((int[])(await session.ReadValueAsync(ab, ct)).Value).ShouldBe(new[] { 5, 6, 7, 8 });
((int[])(await session.ReadValueAsync(ab, ct)).ClrValue()!).ShouldBe(new[] { 5, 6, 7, 8 });
gateway.Count.ShouldBe(0);
// Positive control: the same session's write to the S7 array is dispatched, so the refusal is the node's.
@@ -286,7 +286,7 @@ public sealed class ArrayWriteWireTests
(await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
((int[])(await session.ReadValueAsync(galaxy, ct)).ClrValue()!).ShouldBe(new[] { 11, 12 });
gateway.Count.ShouldBe(0);
// Positive control: the correctly-typed array on the same Galaxy node is dispatched as-is.
@@ -352,7 +352,7 @@ public sealed class ArrayWriteWireTests
var scalar = Raw(session, S7ScalarRaw);
(await WriteAsync(session, scalar, new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
((short)(await session.ReadValueAsync(scalar, ct)).Value).ShouldBe((short)5);
((short)(await session.ReadValueAsync(scalar, ct)).ClrValue()!).ShouldBe((short)5);
gateway.Count.ShouldBe(0);
// Positive control: a scalar write to the same node is dispatched.
@@ -51,7 +51,7 @@ public sealed class ClientAlarmMethodTests
using var client = await ConnectClientAsync(rig, ct);
var conditionId = ConditionNodeId(observer, native: false).ToString();
async Task<VariantCollection> Next(int expectedCount)
async Task<ArrayOf<Variant>> Next(int expectedCount)
{
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= expectedCount);
@@ -61,27 +61,27 @@ public sealed class ClientAlarmMethodTests
var n = 1;
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(
conditionId, events.LatestEventId(item, "base"), "ack", ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.AckedField].Value.ShouldBe(true);
(await Next(++n))[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
StatusCode.IsGood(await client.ConfirmAlarmAsync(
conditionId, events.LatestEventId(item, "base"), "confirm", ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
(await Next(++n))[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
StatusCode.IsGood(await client.AddCommentAsync(
conditionId, events.LatestEventId(item, "base"), "note", ct)).ShouldBeTrue();
((await Next(++n))[RecordedEvents.CommentField].Value as LocalizedText).ShouldNotBeNull().Text.ShouldBe("note");
((await Next(++n))[RecordedEvents.CommentField].ClrValue()! as LocalizedText?).ShouldNotBeNull().Text.ShouldBe("note");
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved);
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
}
@@ -113,23 +113,23 @@ public sealed class ClientAlarmMethodTests
var eventId = events.LatestEventId(item, "base");
// Refusals first: they must not disturb the condition, so the ack below still lands as event #2.
(await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
(await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported.Code);
(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported.Code);
(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported.Code);
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(conditionId, eventId, "ack", ct)).ShouldBeTrue();
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 2);
var ack = events.ConditionEvents(item, "base")[1];
ack[RecordedEvents.AckedField].Value.ShouldBe(true);
ack[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
ack[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
ack[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
ack[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
ack[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
StatusCode.IsGood(await client.AddCommentAsync(
conditionId, events.LatestEventId(item, "base"), "note", ct)).ShouldBeTrue();
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 3);
(events.ConditionEvents(item, "base")[2][RecordedEvents.CommentField].Value as LocalizedText).ShouldNotBeNull().Text.ShouldBe("note");
(events.ConditionEvents(item, "base")[2][RecordedEvents.CommentField].ClrValue()! as LocalizedText?).ShouldNotBeNull().Text.ShouldBe("note");
await subscription.DeleteAsync(true, ct);
}
@@ -69,14 +69,14 @@ public sealed class ConditionChildNodeIdTests
using var session = await rig.ConnectAsync(anonymous: false, ct);
var condition = ConditionNodeId(session, native);
var conditionId = (string)condition.Identifier;
var conditionId = condition.IdentifierAsString;
var resolved = await TranslateAsync(session, condition, ChildPaths, ct);
for (var i = 0; i < ChildPaths.Length; i++)
{
var path = string.Join('/', ChildPaths[i]);
resolved[i].NamespaceIndex.ShouldBe(condition.NamespaceIndex, $"{path} lives in the condition's namespace");
resolved[i].Identifier.ShouldBe($"{conditionId}/{path}", $"{path} is scoped to the condition instance");
resolved[i].IdentifierAsString.ShouldBe($"{conditionId}/{path}", $"{path} is scoped to the condition instance");
}
var values = await ReadAsync(session, resolved, ct);
@@ -84,25 +84,25 @@ public sealed class ConditionChildNodeIdTests
StatusCode.IsGood(values[i].StatusCode).ShouldBeTrue($"{string.Join('/', ChildPaths[i])}: {values[i].StatusCode}");
var expectedMessage = native ? "native-distinct" : "scripted-distinct";
values[0].Value.ShouldBe(true, "ActiveState/Id — the instance is active");
values[1].Value.ShouldBe(false, "AckedState/Id — the instance is unacknowledged");
values[2].Value.ShouldBe(true, "EnabledState/Id");
values[3].Value.ShouldBe(
values[0].ClrValue()!.ShouldBe(true, "ActiveState/Id — the instance is active");
values[1].ClrValue()!.ShouldBe(false, "AckedState/Id — the instance is unacknowledged");
values[2].ClrValue()!.ShouldBe(true, "EnabledState/Id");
values[3].ClrValue()!.ShouldBe(
rig.Nm.TryGetAlarmCondition(native ? RawAlarmPath : ScriptedAlarmId, native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns)!
.Severity.Value,
.Severity!.Value,
"Severity — the instance's mapped severity");
((ushort)values[3].Value).ShouldBeGreaterThan((ushort)0);
(values[4].Value as LocalizedText)?.Text.ShouldBe(expectedMessage, "Message");
values[5].Value.ShouldBe(true, "Retain — an active unacked condition is retained");
((ushort)values[3].ClrValue()!).ShouldBeGreaterThan((ushort)0);
(values[4].ClrValue()! as LocalizedText?)?.Text.ShouldBe(expectedMessage, "Message");
values[5].ClrValue()!.ShouldBe(true, "Retain — an active unacked condition is retained");
if (native)
{
((double)values[6].Value).ShouldBe(0d, "UnshelveTime — a native condition is never shelved");
values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
((double)values[6].ClrValue()!).ShouldBe(0d, "UnshelveTime — a native condition is never shelved");
values[7].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
}
else
{
((double)values[6].Value).ShouldBeInRange(50_000d, 60_000d, "UnshelveTime — the instance's timed shelve");
values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
((double)values[6].ClrValue()!).ShouldBeInRange(50_000d, 60_000d, "UnshelveTime — the instance's timed shelve");
values[7].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
}
}
finally { await rig.DisposeAsync(); }
@@ -196,8 +196,8 @@ public sealed class ConditionChildNodeIdTests
], ct);
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue($"granted child read: {values[0].StatusCode}");
(values[0].Value as LocalizedText)?.Text.ShouldBe("native-distinct");
values[1].Value.ShouldBe(true);
(values[0].ClrValue()! as LocalizedText?)?.Text.ShouldBe("native-distinct");
values[1].ClrValue()!.ShouldBe(true);
values[2].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
values[3].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
}
@@ -227,7 +227,7 @@ public sealed class ConditionChildNodeIdTests
// Positive control: readable while the condition exists.
var before = await ReadAsync(session, [child, nested], ct);
(before[0].Value as LocalizedText)?.Text.ShouldBe("scripted-distinct");
(before[0].ClrValue()! as LocalizedText?)?.Text.ShouldBe("scripted-distinct");
StatusCode.IsGood(before[1].StatusCode).ShouldBeTrue();
rig.Nm.RemoveAlarmConditionNode(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue();
@@ -283,13 +283,13 @@ public sealed class ConditionChildNodeIdTests
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/TimedShelve", ns), ct, 60_000d);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].Value
events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].ClrValue()!
.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/Unshelve", ns), ct);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3);
events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].Value
events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].ClrValue()!
.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
@@ -354,24 +354,24 @@ public sealed class ConditionChildNodeIdTests
private static async Task<IReadOnlyList<NodeId>> TranslateAsync(
ISession session, NodeId start, IReadOnlyList<string[]> paths, CancellationToken ct)
{
var browsePaths = new BrowsePathCollection();
var browsePaths = new List<BrowsePath>();
foreach (var path in paths)
{
var relative = new RelativePath();
foreach (var name in path)
{
relative.Elements.Add(new RelativePathElement
relative.Elements += new RelativePathElement
{
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IsInverse = false,
IncludeSubtypes = true,
TargetName = new QualifiedName(name),
});
};
}
browsePaths.Add(new BrowsePath { StartingNode = start, RelativePath = relative });
}
var response = await session.TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct);
var response = await session.TranslateBrowsePathsToNodeIdsAsync(null, browsePaths.ToArrayOf(), ct);
var results = new List<NodeId>();
for (var i = 0; i < response.Results.Count; i++)
{
@@ -385,14 +385,14 @@ public sealed class ConditionChildNodeIdTests
private static async Task<IReadOnlyList<DataValue>> ReadAsync(ISession session, IReadOnlyList<NodeId> ids, CancellationToken ct)
{
var request = new ReadValueIdCollection(ids.Select(id => new ReadValueId { NodeId = id, AttributeId = Attributes.Value }));
var request = ids.Select(id => new ReadValueId { NodeId = id, AttributeId = Attributes.Value }).ToArrayOf();
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, request, ct);
return response.Results;
return response.Results.ToList();
}
private static async Task<IReadOnlyList<NodeId>> BrowseChildrenAsync(ISession session, NodeId node, CancellationToken ct)
{
var request = new BrowseDescriptionCollection
var request = new BrowseDescription[]
{
new BrowseDescription
{
@@ -404,7 +404,7 @@ public sealed class ConditionChildNodeIdTests
},
};
var response = await session.BrowseAsync(null, null, 0, request, ct);
return response.Results[0].References
return response.Results[0].References.ToList()
.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris))
.ToList();
}
@@ -172,8 +172,8 @@ public sealed class ConditionMethodEventTests
events.ConditionEvents(folderItem, "base").Count.ShouldBe(1);
events.ConditionEvents(serverItem, "base").Count.ShouldBe(1);
var sentinel = events.ConditionEvents(serverItem, "sentinel")[0];
sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
sentinel[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
sentinel[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
}
@@ -252,9 +252,9 @@ public sealed class ConditionMethodEventTests
var baseEvents = events.ConditionEvents(item, "base");
baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta");
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
baseEvents[1][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated")))
e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
await subscription.DeleteAsync(true, ct);
}
@@ -349,7 +349,7 @@ public sealed class ConditionMethodEventTests
routed.ShouldNotContain("Unshelve", "the SDK timer must not route an operator-style Unshelve");
unshelvedBy.ShouldBe("AutoUnshelve:system", "the engine's own expiry owns the unshelve");
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].ClrValue()!).ToList();
shelving.ShouldBe(
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
ObjectIds.ShelvedStateMachineType_Unshelved],
@@ -396,7 +396,7 @@ public sealed class ConditionMethodEventTests
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
double RemainingMs(string message) =>
(double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].Value;
(double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].ClrValue()!;
rig.Project(false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
@@ -473,7 +473,7 @@ public sealed class ConditionMethodEventTests
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
refused.StatusCode.ShouldBe(StatusCodes.BadInvalidState);
routed.ShouldBe(0, "a refused ack reaches no driver");
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState!.Id!.Value
.ShouldBeFalse("a refused ack applies nothing");
// Positive control: the same call on the Primary is accepted, routed and reported.
@@ -486,7 +486,7 @@ public sealed class ConditionMethodEventTests
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
var baseEvents = events.ConditionEvents(item, "base");
baseEvents.Count.ShouldBe(2, "the base transition plus the Primary's one ack event; the refusal reported nothing");
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
baseEvents[1][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
await subscription.DeleteAsync(true, ct);
}
@@ -530,7 +530,7 @@ public sealed class ConditionMethodEventTests
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
refused.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBe(0, "a refused ack reaches no driver");
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState!.Id!.Value
.ShouldBeFalse("a refused ack applies nothing");
// Positive control: with nothing to object to, the same call is accepted, routed and reported.
@@ -543,7 +543,7 @@ public sealed class ConditionMethodEventTests
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
var baseEvents = events.ConditionEvents(item, "base");
baseEvents.Count.ShouldBe(2, "the base transition plus the accepted ack's event; the refusal reported nothing");
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
baseEvents[1][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
await subscription.DeleteAsync(true, ct);
}
@@ -620,9 +620,9 @@ public sealed class ConditionMethodEventTests
{
baseEvents.Count.ShouldBe(expected + 1, "a commented re-ack reports exactly one comment event");
var commented = baseEvents[^1];
(commented[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe(reAckComment);
commented[RecordedEvents.AckedField].Value.ShouldBe(true);
commented[RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the commented re-ack must not unconfirm the node");
(commented[RecordedEvents.CommentField].ClrValue()! as LocalizedText?)?.Text.ShouldBe(reAckComment);
commented[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
commented[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true, "the commented re-ack must not unconfirm the node");
}
// A native re-ack is still routed to the driver: the route is fire-and-forget, so a node showing
@@ -632,8 +632,8 @@ public sealed class ConditionMethodEventTests
var sentinel = events.ConditionEvents(item, "sentinel");
sentinel.Count.ShouldBe(1);
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node");
sentinel[0][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
sentinel[0][RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true, "the re-ack must not unconfirm the node");
await subscription.DeleteAsync(true, ct);
}
@@ -697,20 +697,20 @@ public sealed class ConditionMethodEventTests
};
}
private static void AssertNewState(string method, bool native, VariantCollection e)
private static void AssertNewState(string method, bool native, ArrayOf<Variant> e)
{
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
if (native) e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
switch (method)
{
case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break;
case "Confirm": e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); break;
case "AddComment": (e[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe("note"); break;
case "OneShotShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break;
case "TimedShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break;
case "Unshelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break;
case "Disable": e[RecordedEvents.EnabledField].Value.ShouldBe(false); break;
case "Enable": e[RecordedEvents.EnabledField].Value.ShouldBe(true); break;
case "Acknowledge": e[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true); break;
case "Confirm": e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true); break;
case "AddComment": (e[RecordedEvents.CommentField].ClrValue()! as LocalizedText?)?.Text.ShouldBe("note"); break;
case "OneShotShelve": e[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break;
case "TimedShelve": e[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break;
case "Unshelve": e[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break;
case "Disable": e[RecordedEvents.EnabledField].ClrValue()!.ShouldBe(false); break;
case "Enable": e[RecordedEvents.EnabledField].ClrValue()!.ShouldBe(true); break;
default: throw new ArgumentOutOfRangeException(nameof(method), method, null);
}
}
@@ -736,6 +736,6 @@ public sealed class ConditionMethodEventTests
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
};
await session.CallAsync(condition, methodId, ct, args);
await session.CallAsync(condition, methodId, ct, args.Select(SdkTestValues.FromClr).ToArray());
}
}
@@ -51,13 +51,13 @@ public sealed class DisabledVirtualTagWireTests
// Raw ReadAsync: ReadValueAsync throws on a Bad status, and a Bad status is exactly what we assert.
var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
new ReadValueIdCollection { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } }, ct);
new ReadValueId[] { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } }, ct);
var read = readResponse.Results[0];
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadNodeIdUnknown, "the disabled VirtualTag's node must exist");
read.StatusCode.Code.ShouldBe(StatusCodes.BadOutOfService);
read.Value.ShouldBeNull();
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadNodeIdUnknown.Code, "the disabled VirtualTag's node must exist");
read.StatusCode.Code.ShouldBe(StatusCodes.BadOutOfService.Code);
read.ClrValue()!.ShouldBeNull();
var write = await session.WriteAsync(null, new WriteValueCollection
var write = await session.WriteAsync(null, new WriteValue[]
{
new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(1.0f)) },
}, ct);
@@ -100,7 +100,7 @@ public sealed class DisabledVirtualTagWireTests
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.DisabledVtClient",
ApplicationUri = $"urn:OtOpcUa.DisabledVtClient.{Guid.NewGuid():N}",
@@ -109,16 +109,16 @@ public sealed class DisabledVirtualTagWireTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "DisabledVirtualTagWireTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
}
}
@@ -70,7 +70,7 @@ public sealed class DualEndpointTests
},
NullLogger<OpcUaApplicationHost>.Instance);
},
() => new StandardServer(), CancellationToken.None);
() => new OtOpcUaSdkServer(), CancellationToken.None);
return (started.Host, boundPort);
}
@@ -82,7 +82,7 @@ public sealed class DualEndpointTests
var clientPki = TestClientSecurity.AllocatePkiRoot();
try
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.DualEndpointClient",
ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
@@ -91,13 +91,13 @@ public sealed class DualEndpointTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, default);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
// 1.5.378: the discovery/session/read client surface moved to async.
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
@@ -107,11 +107,11 @@ public sealed class DualEndpointTests
sessionName: "DualEndpointTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null,
preferredLocales: default,
ct: default);
var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default);
return (string[])value.Value;
return (string[])value.ClrValue()!;
}
finally
{
@@ -84,18 +84,18 @@ public sealed class DualNamespaceAddressSpaceTests
var unsValue = await session.ReadValueAsync(unsNodeId, ct);
StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good");
StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good");
Convert.ToSingle(rawValue.Value).ShouldBe(42.5f);
Convert.ToSingle(unsValue.Value).ShouldBe(42.5f);
Convert.ToSingle(rawValue.ClrValue()!).ShouldBe(42.5f);
Convert.ToSingle(unsValue.ClrValue()!).ShouldBe(42.5f);
// (3) the UNS Variable Organizes-references its backing raw node.
var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes);
var organizesRaw = refs.Any(r =>
var organizesRaw = refs.ToList().Any(r =>
r.ReferenceTypeId == ReferenceTypeIds.Organizes &&
r.NodeId.NamespaceIndex == rawNs &&
r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal));
organizesRaw.ShouldBeTrue(
$"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " +
$"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]");
$"forward Organizes refs = [{string.Join(", ", refs.ToList().Select(r => r.NodeId))}]");
}
finally
{
@@ -129,7 +129,7 @@ public sealed class DualNamespaceAddressSpaceTests
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value));
Convert.ToSingle(unsValue.ClrValue()!).ShouldBe(Convert.ToSingle(rawValue.ClrValue()!));
unsValue.StatusCode.ShouldBe(rawValue.StatusCode);
unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp);
unsValue.SourceTimestamp.ShouldBe(ts);
@@ -219,14 +219,14 @@ public sealed class DualNamespaceAddressSpaceTests
sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes");
}
private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)>
private static async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)>
BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType)
{
// Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct.
var (_, cp, refs) = await session.BrowseAsync(
null, null, node, 0u, BrowseDirection.Forward, referenceType,
includeSubtypes: true, nodeClassMask: 0u);
return (cp, refs ?? new ReferenceDescriptionCollection());
return (cp.IsEmpty ? null : cp.ToArray(), refs);
}
private static async Task<StatusCode> HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct)
@@ -239,22 +239,22 @@ public sealed class DualNamespaceAddressSpaceTests
IsReadModified = false,
ReturnBounds = false,
};
var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } };
var nodesToRead = new HistoryReadValueId[] { new HistoryReadValueId { NodeId = node } };
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct);
response.Results.ShouldNotBeNull();
response.Results.IsNull.ShouldBeFalse();
response.Results.Count.ShouldBe(1);
return response.Results[0].StatusCode;
}
private static async Task<StatusCode> WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct)
{
var writeCollection = new WriteValueCollection
var writeCollection = new WriteValue[]
{
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) },
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(value)) },
};
var response = await session.WriteAsync(null, writeCollection, ct);
response.Results.ShouldNotBeNull();
response.Results.IsNull.ShouldBeFalse();
response.Results.Count.ShouldBe(1);
return response.Results[0];
}
@@ -287,7 +287,7 @@ public sealed class DualNamespaceAddressSpaceTests
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.DualNamespaceClient",
ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}",
@@ -296,17 +296,17 @@ public sealed class DualNamespaceAddressSpaceTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
}
private static void SafeDelete(string dir)
@@ -94,14 +94,14 @@ public sealed class EventHistoryPagingOverTheWireTests
finally { await rig.DisposeAsync(); }
}
private static async Task<(List<string> Ids, byte[]? Cp)> ReadPageAsync(
internal static async Task<(List<string> Ids, byte[]? Cp)> ReadPageAsync(
ISession session, NodeId node, byte[]? cp, CancellationToken ct, bool reverse = false)
{
var filter = new EventFilter();
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
{
AttributeId = Attributes.Value,
});
};
var details = new ReadEventDetails
{
StartTime = reverse ? Epoch.AddDays(1) : Epoch.AddDays(-1),
@@ -111,16 +111,16 @@ public sealed class EventHistoryPagingOverTheWireTests
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct);
var result = response.Results.ShouldHaveSingleItem();
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp is null ? default : ByteString.From(cp) } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
var ids = he.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToList();
return (ids, result.ContinuationPoint is { Length: > 0 } c ? c : null);
var he = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryEvent>();
var ids = he.Events.ToList().Select(e => System.Text.Encoding.UTF8.GetString(e.EventFields[0].GetByteString().Span)).ToList();
return (ids, result.ContinuationPoint.IsEmpty ? null : result.ContinuationPoint.ToArray());
}
/// <summary>(time, id) order, [start, end), cap + truncation flag — the SQL backends' contract.</summary>
private sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
internal sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
{
private readonly List<HistoricalEvent> _all =
events.OrderBy(e => e.EventTimeUtc).ThenBy(e => e.EventId, StringComparer.Ordinal).ToList();
@@ -44,18 +44,18 @@ public sealed class EventHistoryProjectionOverTheWireTests
all.Count.ShouldBe(2);
foreach (var fields in all)
{
fields[1].Value.ShouldNotBeNull("EventType");
fields[2].Value.ShouldNotBeNull("ConditionName");
fields[3].Value.ShouldNotBeNull("SourceNode");
fields[1].IsNull.ShouldBeFalse("EventType");
fields[2].IsNull.ShouldBeFalse("ConditionName");
fields[3].IsNull.ShouldBeFalse("SourceNode");
}
all[0][1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
all[0][2].Value.ShouldBe("temp_hi");
all[0][3].Value.ShouldBe(ConditionNodeId(session, native: true), "the native condition the alarm id names");
all[0][1].GetNodeId().ShouldBe(ObjectTypeIds.LimitAlarmType);
all[0][2].GetString().ShouldBe("temp_hi");
all[0][3].GetNodeId().ShouldBe(ConditionNodeId(session, native: true), "the native condition the alarm id names");
var where = new ContentFilter();
where.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ObjectTypeIds.LimitAlarmType)));
where.Push(FilterOperator.OfType, Variant.From(ObjectTypeIds.LimitAlarmType));
var limits = await ReadAsync(session, node, where, ct);
limits.Select(f => System.Text.Encoding.UTF8.GetString((byte[])f[0].Value)).ShouldBe(["e-limit"]);
limits.Select(f => System.Text.Encoding.UTF8.GetString(f[0].GetByteString().Span)).ShouldBe(["e-limit"]);
}
finally { await rig.DisposeAsync(); }
}
@@ -97,17 +97,17 @@ public sealed class EventHistoryProjectionOverTheWireTests
var all = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), Filter(ofType: null), 100, ct);
all.Count.ShouldBe(2);
foreach (var e in all)
e.Fields.ShouldAllBe(f => f.Value != null, "every default field is projected");
all[0].Fields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
all[0].Fields[4].Value.ShouldBe((ushort)700);
all[0].Fields[6].Value.ShouldBe("temp_hi");
e.Fields.ShouldAllBe(f => !f.IsNull, "every default field is projected");
all[0].Fields[1].GetNodeId().ShouldBe(ObjectTypeIds.LimitAlarmType);
all[0].Fields[4].GetUInt16().ShouldBe((ushort)700);
all[0].Fields[6].GetString().ShouldBe("temp_hi");
var limits = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1),
Filter(ObjectTypeIds.LimitAlarmType), 100, ct);
limits.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value)).ShouldBe(["e-limit"]);
limits.Select(e => System.Text.Encoding.UTF8.GetString(e.Fields[0].GetByteString().Span)).ShouldBe(["e-limit"]);
var invalid = Filter(ofType: null);
invalid.WhereClause.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)));
invalid.WhereClause.Push(FilterOperator.Equals, Variant.From(1));
var refused = await Should.ThrowAsync<ServiceResultException>(() =>
client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), invalid, 100, ct));
refused.StatusCode.ShouldBe(StatusCodes.BadEventFilterInvalid);
@@ -120,20 +120,20 @@ public sealed class EventHistoryProjectionOverTheWireTests
foreach (var leaf in new[] { "EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode" })
{
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value };
}
if (ofType is not null) filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
if (ofType is not null) filter.WhereClause.Push(FilterOperator.OfType, Variant.From(ofType.Value));
return filter;
}
}
private static async Task<List<VariantCollection>> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct)
private static async Task<List<ArrayOf<Variant>>> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct)
{
var filter = new EventFilter { WhereClause = where ?? new ContentFilter() };
foreach (var leaf in new[] { "EventId", "EventType", "ConditionName", "SourceNode" })
{
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value };
}
var details = new ReadEventDetails
{
@@ -144,11 +144,11 @@ public sealed class EventHistoryProjectionOverTheWireTests
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
new HistoryReadValueId[] { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
return he.Events.Select(e => e.EventFields).ToList();
var he = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryEvent>();
return he.Events.ToList().Select(e => e.EventFields).ToList();
}
private sealed class FixedSource(IReadOnlyList<HistoricalEvent> events) : IHistorianDataSource
@@ -53,21 +53,21 @@ public sealed class HistoryUpdateOverTheWireTests
var node = RawNode(session, id);
var insert = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5));
insert.StatusCode.Code.ShouldBe(StatusCodes.Good);
insert.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted, StatusCodes.GoodEntryInserted]);
insert.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
insert.OperationResults.ToList().Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code, StatusCodes.GoodEntryInserted.Code]);
var read = await ReadRawAsync(session, node, ct);
read.Select(v => (v.SourceTimestamp, (double)v.Value)).ShouldBe([(T1, 1.5), (T2, 2.5)]);
read.Select(v => (v.SourceTimestamp, (double)v.ClrValue()!)).ShouldBe([(T1, 1.5), (T2, 2.5)]);
var dup = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 9.9));
dup.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadEntryExists]);
dup.OperationResults.ToList().Select(c => c.Code).ShouldBe([StatusCodes.BadEntryExists.Code]);
(await ReadRawAsync(session, node, ct)).Count.ShouldBe(2, "the refused insert changed nothing");
var del = await DeleteAtTimeAsync(session, node, ct, T1);
del.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good]);
del.OperationResults.ToList().Select(c => c.Code).ShouldBe([StatusCodes.Good.Code]);
var after = await ReadRawAsync(session, node, ct);
after.Select(v => (v.SourceTimestamp, (double)v.Value)).ShouldBe([(T2, 2.5)]);
after.Select(v => (v.SourceTimestamp, (double)v.ClrValue()!)).ShouldBe([(T2, 2.5)]);
}
finally { await rig.DisposeAsync(); }
}
@@ -102,17 +102,17 @@ public sealed class HistoryUpdateOverTheWireTests
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
result.StatusCode.Code.ShouldBe(StatusCodes.Good);
new HistoryReadValueId[] { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
result.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
var data = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryModifiedData>();
data.DataValues.Select(v => v.SourceTimestamp).ShouldBe([T1, T1, T2]);
data.ModificationInfos.Select(m => m.UpdateType)
data.DataValues.ToList().Select(v => v.SourceTimestamp).ShouldBe([T1, T1, T2]);
data.ModificationInfos.ToList().Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Delete, HistoryUpdateType.Insert]);
data.ModificationInfos.ShouldAllBe(m => m.UserName == KeyPrincipal);
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); // an insert had no prior value
((double)data.DataValues[1].Value).ShouldBe(1.5); // the deleted sample's prior value
data.ModificationInfos.ToList().ShouldAllBe(m => m.UserName == KeyPrincipal);
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData.Code); // an insert had no prior value
((double)data.DataValues[1].ClrValue()!).ShouldBe(1.5); // the deleted sample's prior value
}
finally { await rig.DisposeAsync(); }
}
@@ -139,13 +139,13 @@ public sealed class HistoryUpdateOverTheWireTests
using var denied = await rig.ConnectAsync(anonymous: true, ct);
var denial = await UpdateAsync(denied, RawNode(denied, id), PerformUpdateType.Insert, ct, (T1, 1.0));
denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater");
store.Count.ShouldBe(0);
using var allowed = await rig.ConnectAsync(anonymous: false, ct);
var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0));
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
ok.OperationResults.ToList().Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
store.UpdateCalls.ShouldBe(1);
}
finally { await rig.DisposeAsync(); }
@@ -177,7 +177,7 @@ public sealed class HistoryUpdateOverTheWireTests
using (var writer = await rig.ConnectAsync(anonymous: false, ct))
{
var denial = await UpdateAsync(writer, RawNode(writer, id), PerformUpdateType.Insert, ct, (T1, 1.0));
denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
}
store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater");
@@ -186,7 +186,7 @@ public sealed class HistoryUpdateOverTheWireTests
rig.KeyRoles = ApiKeyInterimRoleMapper.Map([editor], KeyPrincipal);
using var allowed = await rig.ConnectAsync(anonymous: false, ct);
var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0));
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
ok.OperationResults.ToList().Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
store.UpdateCalls.ShouldBe(1);
}
finally { await rig.DisposeAsync(); }
@@ -241,7 +241,7 @@ public sealed class HistoryUpdateOverTheWireTests
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
.ShouldBe(AccessLevels.HistoryWrite, "a late updater lights the bit on the existing node");
var ok = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0));
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
ok.OperationResults.ToList().Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
store.UpdateCalls.ShouldBe(1);
rig.Nm.HistorianDataUpdater = null;
@@ -249,7 +249,7 @@ public sealed class HistoryUpdateOverTheWireTests
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
.ShouldBe(0, "a reset updater takes the bit back");
var refused = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T2, 2.0));
refused.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
refused.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
store.UpdateCalls.ShouldBe(1, "the reset updater is never reached");
}
finally { await rig.DisposeAsync(); }
@@ -262,11 +262,11 @@ public sealed class HistoryUpdateOverTheWireTests
private static async Task<byte> ReadByteAsync(ISession session, NodeId node, uint attribute, CancellationToken ct)
{
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[]
{
new ReadValueId { NodeId = node, AttributeId = attribute },
}, ct);
return (byte)response.Results.ShouldHaveSingleItem().Value;
return (byte)response.Results.ToList().ShouldHaveSingleItem().ClrValue()!;
}
private static async Task<HistoryUpdateResult> UpdateAsync(
@@ -275,7 +275,7 @@ public sealed class HistoryUpdateOverTheWireTests
{
var details = new UpdateDataDetails { NodeId = node, PerformInsertReplace = mode };
foreach (var (time, value) in items)
details.UpdateValues.Add(new DataValue(new Variant(value), StatusCodes.Good, time, time));
details.UpdateValues += new DataValue(Variant.From(value), StatusCodes.Good, time, time);
return await SendAsync(session, details, ct);
}
@@ -283,15 +283,15 @@ public sealed class HistoryUpdateOverTheWireTests
ISession session, NodeId node, CancellationToken ct, params DateTime[] times)
{
var details = new DeleteAtTimeDetails { NodeId = node };
foreach (var t in times) details.ReqTimes.Add(t);
foreach (var t in times) details.ReqTimes += (DateTimeUtc)t;
return SendAsync(session, details, ct);
}
private static async Task<HistoryUpdateResult> SendAsync(ISession session, object details, CancellationToken ct)
private static async Task<HistoryUpdateResult> SendAsync(ISession session, IEncodeable details, CancellationToken ct)
{
var response = await session.HistoryUpdateAsync(
null, new ExtensionObjectCollection { new ExtensionObject(details) }, ct);
return response.Results.ShouldHaveSingleItem();
null, new ExtensionObject[] { new ExtensionObject(details) }, ct);
return response.Results.ToList().ShouldHaveSingleItem();
}
private static async Task<List<DataValue>> ReadRawAsync(ISession session, NodeId node, CancellationToken ct)
@@ -306,10 +306,10 @@ public sealed class HistoryUpdateOverTheWireTests
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Source, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
new HistoryReadValueId[] { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
var data = (HistoryData)ExtensionObject.ToEncodeable(result.HistoryData);
var data = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryData>();
return data.DataValues.ToList();
}
@@ -326,7 +326,7 @@ public sealed class HistoryUpdateOverTheWireTests
private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) =>
_trail.Add(new HistoryModifiedValue(
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData : StatusCodes.Good, t, t),
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData.Code : StatusCodes.Good.Code, t, t),
DateTime.UtcNow, type, who.Principal, _trail.Count + 1, t));
public int UpdateCalls => Volatile.Read(ref _updateCalls);
@@ -347,11 +347,11 @@ public sealed class HistoryUpdateOverTheWireTests
foreach (var v in values)
{
var t = v.TimestampUtc!.Value;
if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists); continue; }
if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists.Code); continue; }
v.Value.TryGetDouble(out var d).ShouldBeTrue();
_samples[t] = d;
Record(t, null, HistoryModificationType.Insert, context);
codes.Add(StatusCodes.GoodEntryInserted);
codes.Add(StatusCodes.GoodEntryInserted.Code);
}
}
return codes;
@@ -368,9 +368,9 @@ public sealed class HistoryUpdateOverTheWireTests
lock (_gate)
return timesUtc.Select(t =>
{
if (!_samples.Remove(t, out var prior)) return StatusCodes.BadNoEntryExists;
if (!_samples.Remove(t, out var prior)) return StatusCodes.BadNoEntryExists.Code;
Record(t, prior, HistoryModificationType.Delete, context);
return StatusCodes.Good;
return StatusCodes.Good.Code;
}).ToList();
}
@@ -397,7 +397,7 @@ public sealed class HistoryUpdateOverTheWireTests
lock (_gate)
{
var rows = _samples.Where(kv => kv.Key >= startUtc && kv.Key <= endUtc)
.Select(kv => new DataValueSnapshot(kv.Value, StatusCodes.Good, kv.Key, kv.Key)).ToList();
.Select(kv => new DataValueSnapshot(kv.Value, StatusCodes.Good.Code, kv.Key, kv.Key)).ToList();
return new CoreHistoryReadResult(rows, null);
}
}
@@ -77,10 +77,10 @@ public sealed class MTConnectConditionAlarmWireTests
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, FaultMessage));
var fault = events.ConditionEvents(folderItem, FaultMessage).ShouldHaveSingleItem();
fault[RecordedEvents.AckedField].Value.ShouldBe(false, "a fresh fault is unacknowledged");
fault[RecordedEvents.ActiveField].Value.ShouldBe(true, "a fault is active");
fault[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800, "Fault is the exact Part 9 severity 800, not the 900 bucket");
fault[RecordedEvents.RetainField].Value.ShouldBe(true);
fault[RecordedEvents.AckedField].ClrValue()!.ShouldBe(false, "a fresh fault is unacknowledged");
fault[RecordedEvents.ActiveField].ClrValue()!.ShouldBe(true, "a fault is active");
fault[RecordedEvents.SeverityField].ClrValue()!.ShouldBe((ushort)800, "Fault is the exact Part 9 severity 800, not the 900 bucket");
fault[RecordedEvents.RetainField].ClrValue()!.ShouldBe(true);
// An operator acknowledges it (applied server-side, as the Part 9 method does), then a deploy clears
// the host's projector prior state.
@@ -98,16 +98,16 @@ public sealed class MTConnectConditionAlarmWireTests
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(folderItem, FaultMessage).Count == 4);
foreach (var blip in events.ConditionEvents(folderItem, FaultMessage).Skip(2))
{
blip[RecordedEvents.AckedField].Value.ShouldBe(true, "an Unavailable blip must not reset the ack");
blip[RecordedEvents.ActiveField].Value.ShouldBe(true, "an Unavailable blip must not clear the fault");
blip[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true, "an Unavailable blip must not reset the ack");
blip[RecordedEvents.ActiveField].ClrValue()!.ShouldBe(true, "an Unavailable blip must not clear the fault");
}
agent.Push(Condition("Normal", null, null, 111));
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "SYSTEM Normal"));
var normal = events.ConditionEvents(folderItem, "SYSTEM Normal").ShouldHaveSingleItem();
normal[RecordedEvents.ActiveField].Value.ShouldBe(false, "Normal clears the condition");
normal[RecordedEvents.RetainField].Value.ShouldBe(false, "inactive + acknowledged is no longer retained");
normal[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800, "a clear keeps the severity of the alarm it clears");
normal[RecordedEvents.ActiveField].ClrValue()!.ShouldBe(false, "Normal clears the condition");
normal[RecordedEvents.RetainField].ClrValue()!.ShouldBe(false, "inactive + acknowledged is no longer retained");
normal[RecordedEvents.SeverityField].ClrValue()!.ShouldBe((ushort)800, "a clear keeps the severity of the alarm it clears");
lock (routed) routed.ShouldAllBe(id => id == RawAlarmPath, "ConditionId must be the alarm tag's RawPath");
@@ -98,25 +98,25 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
// --- the three fields this issue is about ---
// EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
fields[EventTypeIndex].ClrValue()!.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
"EventType must carry the concrete condition type, not null");
// SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
fields[SourceNodeIndex].ClrValue()!.ShouldBe(new NodeId(RawAlarmPath, rawNs),
"SourceNode must identify the source condition node, not null");
// SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
// name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
fields[SourceNameIndex].ClrValue()!.ShouldBe(RawAlarmPath,
"SourceName must carry the unique RawPath, not null");
fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
fields[SourceNameIndex].ClrValue()!.ShouldNotBe(LeafName,
"SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
// The rest of the standard envelope still arrives intact (guards against a regression that
// populated identity at the cost of the existing fields).
fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
fields[SeverityIndex].Value.ShouldBe((ushort)700);
fields[TimeIndex].ClrValue()!.ShouldBeOfType<DateTime>();
((LocalizedText)fields[MessageIndex].ClrValue()!).Text.ShouldBe(AlarmMessage);
fields[SeverityIndex].ClrValue()!.ShouldBe((ushort)700);
await subscription.DeleteAsync(true, ct);
}
@@ -160,11 +160,11 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
// ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
// Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
fields[ConditionClassIdIndex].ClrValue()!.ShouldBe(ObjectTypeIds.BaseConditionClassType,
"ConditionClassId must carry a resolvable condition class, not null");
// ConditionClassName — the matching human-readable name, NOT empty text.
var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
var className = fields[ConditionClassNameIndex].ClrValue()!.ShouldBeOfType<LocalizedText>();
className.Text.ShouldBe("BaseConditionClass",
"ConditionClassName must name the reported condition class, not be empty");
@@ -208,20 +208,20 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
// Raise the alarm while the source is healthy → Quality Good.
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].ClrValue()!)
.ShouldBeTrue("a healthy source's condition reports Good quality");
// Device goes comms-lost (connectivity path) → the next condition event carries a Bad Quality, while
// the alarm stays active (annotation only — not asserted here, covered by the node-level test).
sink.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 2, TimeSpan.FromSeconds(5));
StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].Value)
StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].ClrValue()!)
.ShouldBeTrue("a comms-lost source's condition must report non-Good quality on the wire");
// Device recovers → Quality returns to Good.
sink.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 3, TimeSpan.FromSeconds(5));
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].ClrValue()!)
.ShouldBeTrue("a recovered source's condition reports Good quality again");
await subscription.DeleteAsync(true, ct);
@@ -264,12 +264,12 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
private static EventFilter BuildEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventType));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.SourceNode));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.SourceName));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Time));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Severity));
return filter;
}
@@ -279,9 +279,9 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
private static EventFilter BuildConditionClassEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(new NodeId(ObjectTypes.ConditionType), QualifiedName.From(BrowseNames.ConditionClassId));
filter.AddSelectClause(new NodeId(ObjectTypes.ConditionType), QualifiedName.From(BrowseNames.ConditionClassName));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
return filter;
}
@@ -294,8 +294,8 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
// BrowseNames.Quality is the SDK's Part 9 ConditionType.Quality browse name — NOT our snapshot's
// renamed StatusCode member. Selecting the wrong clause here yields a null field and an NRE at the
// cast, rather than a readable assertion failure.
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(new NodeId(ObjectTypes.ConditionType), QualifiedName.From(BrowseNames.Quality));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
return filter;
}
@@ -306,21 +306,21 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
private sealed class EventCollector(int messageIndex)
{
private readonly object _gate = new();
private readonly List<VariantCollection> _fields = new();
private readonly List<ArrayOf<Variant>> _fields = new();
public IReadOnlyList<VariantCollection> Fields
public IReadOnlyList<ArrayOf<Variant>> Fields
{
get { lock (_gate) return _fields.ToList(); }
}
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
public void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > messageIndex &&
e.EventFields[messageIndex].Value is LocalizedText lt &&
e.EventFields[messageIndex].ClrValue()! is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_fields.Add(e.EventFields);
@@ -358,7 +358,7 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}",
@@ -367,17 +367,17 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
@@ -171,8 +171,8 @@ public sealed class NativeAlarmMultiNotifierEventDeliveryTests
private static EventFilter BuildEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
return filter;
}
@@ -184,14 +184,14 @@ public sealed class NativeAlarmMultiNotifierEventDeliveryTests
private readonly object _gate = new();
private readonly Dictionary<uint, int> _byHandle = new();
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
public void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > MessageFieldIndex &&
e.EventFields[MessageFieldIndex].Value is LocalizedText lt &&
e.EventFields[MessageFieldIndex].ClrValue()! is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_byHandle[e.ClientHandle] = _byHandle.GetValueOrDefault(e.ClientHandle) + 1;
@@ -234,7 +234,7 @@ public sealed class NativeAlarmMultiNotifierEventDeliveryTests
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.MultiNotifierEventDeliveryClient",
ApplicationUri = $"urn:OtOpcUa.MultiNotifierEventDeliveryClient.{Guid.NewGuid():N}",
@@ -243,17 +243,17 @@ public sealed class NativeAlarmMultiNotifierEventDeliveryTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "MultiNotifierEventDeliveryTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
@@ -57,7 +57,7 @@ public sealed class NativeAlarmRebuildCarryOverWireTests
{
await session.CallAsync(
ConditionNodeId(session, native: true), MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
events.LatestEventId(serverItem, "fault"), new LocalizedText("ack"));
Variant.From(ByteString.From(events.LatestEventId(serverItem, "fault"))), Variant.From(new LocalizedText("ack")));
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(serverItem, "fault").Count == 2);
expectedBefore = 2;
}
@@ -73,15 +73,15 @@ public sealed class NativeAlarmRebuildCarryOverWireTests
var faultEvents = events.ConditionEvents(serverItem, "fault");
faultEvents.Count.ShouldBe(expectedBefore + 1, "the pre-rebuild event(s) plus exactly one restored event");
var restored = faultEvents[^1];
restored[RecordedEvents.ActiveField].Value.ShouldBe(true);
restored[RecordedEvents.AckedField].Value.ShouldBe(clientAcked);
restored[RecordedEvents.RetainField].Value.ShouldBe(true);
restored[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800);
restored[RecordedEvents.ActiveField].ClrValue()!.ShouldBe(true);
restored[RecordedEvents.AckedField].ClrValue()!.ShouldBe(clientAcked);
restored[RecordedEvents.RetainField].ClrValue()!.ShouldBe(true);
restored[RecordedEvents.SeverityField].ClrValue()!.ShouldBe((ushort)800);
// And a ConditionRefresh-style read of the live node agrees.
var active = await session.ReadValueAsync(
new NodeId($"{RawAlarmPath}/ActiveState/Id", ConditionNodeId(session, native: true).NamespaceIndex), ct);
active.Value.ShouldBe(true);
active.ClrValue()!.ShouldBe(true);
await subscription.DeleteAsync(true, ct);
}
@@ -0,0 +1,66 @@
using System.Globalization;
using System.Text.Json;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors.</summary>
public sealed class Sdk2AggregateCompatibilityTests
{
[Fact]
public void All_37_factories_preserve_the_4144_baseline_vectors()
{
var baseline = JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var telemetry = DefaultTelemetry.Create(_ => { });
var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc);
var failures = new List<string>();
foreach (var vector in baseline.Vectors)
{
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
var config = new AggregateConfiguration { TreatUncertainAsBad = vector.UncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false };
var calculator = CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
var output = new List<Value>(); string? error = null;
try
{
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
{
var time = epoch.AddSeconds(sample.Seconds);
calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time));
if (vector.Incremental) Drain(false);
}
Drain(true);
}
catch (Exception ex) { error = ex.GetType().FullName; }
if (error != vector.Exception || !output.SequenceEqual(vector.Output))
{
failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}");
}
void Drain(bool partial)
{
for (var guard = 0; guard < 100; guard++)
{
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, ((DateTime)value.SourceTimestamp).Ticks, ((DateTime)value.ServerTimestamp).Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
}
throw new InvalidOperationException("Baseline calculator did not terminate.");
}
}
baseline.Vectors.Length.ShouldBe(4144);
baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37);
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing.");
}
static object? Payload(Sample sample) => sample.Type switch { "Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() };
record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary<string,Sample[]> Data, Vector[] Vectors);
record Sample(double Seconds,string Type,string? Payload,uint Status);
record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception);
}
@@ -0,0 +1,24 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>Observes wire payloads using the CLR representations asserted by the baseline contract tests.</summary>
internal static class SdkTestValues
{
internal static object? ClrValue(this DataValue value) => value.WrappedValue.ClrValue();
internal static object? ClrValue(this Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
{
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
DateTimeUtc time => (DateTime)time,
Uuid id => (Guid)id,
var other => other,
};
internal static Variant FromClr(object? value)
{
if (value is null) return Variant.Null;
if (value is byte[] bytes) return Variant.From(ByteString.From(bytes));
if (VariantHelper.TryCastFrom(value, out var variant)) return variant;
throw new ArgumentException("Unsupported test payload.", nameof(value));
}
}
@@ -253,8 +253,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
{
var pki = TestClientSecurity.AllocatePkiRoot();
var security = TestClientSecurity.Build(pki);
security.ApplicationCertificate.SubjectName = "CN=OtOpcUa.ServerEventClient";
var appConfig = new ApplicationConfiguration
security.ApplicationCertificate!.SubjectName = "CN=OtOpcUa.ServerEventClient";
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.ServerEventClient",
ApplicationUri = $"urn:OtOpcUa.ServerEventClient.{Guid.NewGuid():N}",
@@ -263,7 +263,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
// TestClientSecurity explicitly auto-accepts self-signed test peers.
if (encrypted)
{
@@ -278,21 +278,17 @@ internal sealed class ServerEventRig : IAsyncDisposable
var identity = anonymous
? new UserIdentity(new AnonymousIdentityToken())
: new UserIdentity(new UserNameIdentityToken
{
UserName = "apikey",
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
});
: new UserIdentity("apikey", Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"));
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "ServerEventRig",
sessionTimeout: 60_000,
identity: identity,
preferredLocales: null,
preferredLocales: default,
ct: ct);
}
@@ -342,9 +338,9 @@ internal sealed class RecordedEvents
public const int RetainField = 13;
private readonly object _gate = new();
private readonly List<(uint Handle, VariantCollection Fields)> _events = [];
private readonly List<(uint Handle, ArrayOf<Variant> Fields)> _events = [];
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
public void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
{
lock (_gate)
{
@@ -353,23 +349,23 @@ internal sealed class RecordedEvents
}
/// <summary>Every event <paramref name="item"/> received, in arrival order.</summary>
public IReadOnlyList<VariantCollection> Of(MonitoredItem item)
public IReadOnlyList<ArrayOf<Variant>> Of(MonitoredItem item)
{
lock (_gate) return _events.Where(e => e.Handle == item.ClientHandle).Select(e => e.Fields).ToList();
}
/// <summary>The condition events (they carry <c>AckedState/Id</c>; audit and refresh events do not).</summary>
public IReadOnlyList<VariantCollection> ConditionEvents(MonitoredItem item, string message) =>
Of(item).Where(f => f[AckedField].Value is bool && Message(f) == message).ToList();
public IReadOnlyList<ArrayOf<Variant>> ConditionEvents(MonitoredItem item, string message) =>
Of(item).Where(f => f[AckedField].TryGetValue(out bool _) && Message(f) == message).ToList();
public bool Has(MonitoredItem item, string message) => Of(item).Any(f => Message(f) == message);
public bool HasType(MonitoredItem item, NodeId type) => Of(item).Any(f => f[EventTypeField].Value as NodeId == type);
public bool HasType(MonitoredItem item, NodeId type) => Of(item).Any(f => f[EventTypeField].GetNodeId() == type);
public byte[] LatestEventId(MonitoredItem item, string message) =>
(byte[])Of(item).Last(f => Message(f) == message && f[AckedField].Value is bool)[EventIdField].Value;
Of(item).Last(f => Message(f) == message && f[AckedField].TryGetValue(out bool _))[EventIdField].GetByteString().ToArray()!;
public static string? Message(VariantCollection f) => (f[MessageField].Value as LocalizedText)?.Text;
public static string? Message(ArrayOf<Variant> f) => f[MessageField].GetLocalizedText().Text;
public static async Task<Subscription> SubscribeAsync(ISession session, RecordedEvents events, CancellationToken ct)
{
@@ -383,26 +379,26 @@ internal sealed class RecordedEvents
public static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id]));
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.ConfirmedState, BrowseNames.Id]));
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id]));
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.CurrentState, BrowseNames.Id]));
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Comment);
filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType, BrowseNames.Changes);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.UnshelveTime]));
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id]));
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventType));
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AcknowledgeableConditionType, [QualifiedName.From(BrowseNames.AckedState), QualifiedName.From(BrowseNames.Id)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AcknowledgeableConditionType, [QualifiedName.From(BrowseNames.ConfirmedState), QualifiedName.From(BrowseNames.Id)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.ConditionType, [QualifiedName.From(BrowseNames.EnabledState), QualifiedName.From(BrowseNames.Id)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ShelvingState), QualifiedName.From(BrowseNames.CurrentState), QualifiedName.From(BrowseNames.Id)]);
filter.AddSelectClause(ObjectTypeIds.ConditionType, QualifiedName.From(BrowseNames.Comment));
filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType, QualifiedName.From(BrowseNames.Changes));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.SourceNode));
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ShelvingState), QualifiedName.From(BrowseNames.UnshelveTime)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ActiveState), QualifiedName.From(BrowseNames.Id)]);
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Severity));
filter.AddSelectClause(ObjectTypeIds.ConditionType, QualifiedName.From(BrowseNames.Retain));
var item = new MonitoredItem(subscription.DefaultItem)
{
@@ -36,11 +36,11 @@ public sealed class ServiceLevelBindingReplayTests
ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}",
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
}, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new StandardServer(), ct);
}, () => new OtOpcUaSdkServer(), ct);
await using var host = started.Host;
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
var config = new ApplicationConfiguration
var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.ReplayClient",
ApplicationUri = $"urn:OtOpcUa.ReplayClient:{Guid.NewGuid():N}",
@@ -50,17 +50,17 @@ public sealed class ServiceLevelBindingReplayTests
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
};
await config.ValidateAsync(ApplicationType.Client, ct);
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(config,
$"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)),
config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)),
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay",
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null, ct: ct);
preferredLocales: default, ct: ct);
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
read.StatusCode.ShouldBe(StatusCodes.Good);
read.Value.ShouldBe(latest);
read.ClrValue()!.ShouldBe(latest);
}
finally
{
@@ -0,0 +1,114 @@
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>Exercises the production session continuation store through actual HistoryRead requests.</summary>
public sealed class SessionHistoryContinuationLifecycleTests
{
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Tokens_are_single_use_and_cannot_cross_sessions()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartHistoryAsync(ct);
using var owner = await rig.ConnectAsync(anonymous: true, ct);
using var other = await rig.ConnectAsync(anonymous: true, ct);
var point = (await PageAsync(owner, default, false, ct)).ContinuationPoint;
point.IsEmpty.ShouldBeFalse();
(await PageAsync(other, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
var resumed = await PageAsync(owner, point, false, ct);
resumed.StatusCode.ShouldBe(StatusCodes.Good);
resumed.ContinuationPoint.IsEmpty.ShouldBeFalse();
(await PageAsync(owner, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
}
[Fact]
public async Task Release_consumes_a_token_and_malformed_tokens_are_rejected()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartHistoryAsync(ct);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var point = (await PageAsync(session, default, false, ct)).ContinuationPoint;
point.IsEmpty.ShouldBeFalse();
var released = await PageAsync(session, point, true, ct);
released.StatusCode.ShouldBe(StatusCodes.Good);
released.ContinuationPoint.IsEmpty.ShouldBeTrue();
(await PageAsync(session, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
(await PageAsync(session, ByteString.From(new byte[] { 1, 2, 3 }), false, ct))
.StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
}
[Fact]
public async Task Session_capacity_evicts_the_oldest_point_and_keeps_the_newest()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartHistoryAsync(ct);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var first = (await PageAsync(session, default, false, ct)).ContinuationPoint;
ByteString latest = default;
// The production host retains the SDK's cap of 100; issue 101 distinct initial reads.
for (var i = 0; i < 100; i++) latest = (await PageAsync(session, default, false, ct)).ContinuationPoint;
latest.IsEmpty.ShouldBeFalse();
(await PageAsync(session, first, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
(await PageAsync(session, latest, false, ct)).StatusCode.ShouldBe(StatusCodes.Good);
}
[Fact]
public async Task Closed_session_tokens_do_not_resume_on_a_new_session()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartHistoryAsync(ct);
ByteString point;
using (var old = await rig.ConnectAsync(anonymous: true, ct))
{
point = (await PageAsync(old, default, false, ct)).ContinuationPoint;
point.IsEmpty.ShouldBeFalse();
await old.CloseAsync(ct);
}
using var fresh = await rig.ConnectAsync(anonymous: true, ct);
(await PageAsync(fresh, point, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
}
private static async Task<ServerEventRig> StartHistoryAsync(CancellationToken ct)
{
var rig = await ServerEventRig.StartAsync("SessionHistoryLifecycle", encrypted: false, ct);
try
{
var events = Enumerable.Range(0, 25)
.Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500)).ToList();
rig.Nm.HistorianDataSource = new EventHistoryPagingOverTheWireTests.SqlLikeEventSource(events);
rig.MaterialiseNative();
rig.Enforce(false, [Key()]);
return rig;
}
catch
{
await rig.DisposeAsync();
throw;
}
}
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ISession session, ByteString point, bool release, CancellationToken ct)
{
var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
var filter = new EventFilter
{
SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.EventId))],
};
var details = new ReadEventDetails
{
StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 10, Filter = filter,
};
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, release,
[new HistoryReadValueId { NodeId = node, ContinuationPoint = point }], ct);
return response.Results.ToList().ShouldHaveSingleItem();
}
}
@@ -81,7 +81,7 @@ public sealed class StaleMonitoredNodeWireTests
await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5));
nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5),
await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.ClrValue()!, 3)); }, TimeSpan.FromSeconds(5),
"the second session's item must receive a value written after its creation");
lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away");
@@ -113,7 +113,7 @@ public sealed class StaleMonitoredNodeWireTests
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct);
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.ClrValue()!, 41)); }, TimeSpan.FromSeconds(5));
nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node");
// The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node.
@@ -132,10 +132,10 @@ public sealed class StaleMonitoredNodeWireTests
newItem.Status.Error.ShouldBeNull();
// Positive control: the initial value is the NEW node's, and a value written afterwards arrives.
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5),
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.ClrValue()!, 42)); }, TimeSpan.FromSeconds(5),
"the new item's initial read must come from the re-created node");
nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5),
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.ClrValue()!, 43)); }, TimeSpan.FromSeconds(5),
"the new item must receive a value written after its creation");
lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
@@ -160,7 +160,7 @@ public sealed class StaleMonitoredNodeWireTests
// And the new item is unaffected by that delete.
nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.ClrValue()!, 44)); }, TimeSpan.FromSeconds(5));
// Positive control: deleting the live item gives its reference back.
subscription.RemoveItem(newItem);
@@ -246,7 +246,7 @@ public sealed class StaleMonitoredNodeWireTests
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.StaleMonitoredNodeClient",
ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}",
@@ -255,16 +255,16 @@ public sealed class StaleMonitoredNodeWireTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig));
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig));
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string? because = null)
@@ -123,19 +123,19 @@ public sealed class SubscriptionSurvivalTests
lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.ClrValue()!, 42)); }, TimeSpan.FromSeconds(5));
lock (gate)
{
received.ShouldContain(v => Equals(v.Value, 42));
received.ShouldContain(v => Equals(v.ClrValue()!, 42));
received.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
}
// --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId("Plant/dev1/B", ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
sink.WriteValue(nodeIdB.IdentifierAsString, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7);
bValue.ClrValue()!.ShouldBe(7);
}
finally
{
@@ -224,7 +224,7 @@ public sealed class SubscriptionSurvivalTests
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.ClrValue()!, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
// throws ServiceResultException for an unknown node rather than returning a bad DataValue.
@@ -312,7 +312,7 @@ public sealed class SubscriptionSurvivalTests
// A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.ClrValue()!, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId("Plant/dev1/B", ns);
@@ -322,10 +322,10 @@ public sealed class SubscriptionSurvivalTests
// C is browsable + readable.
var nodeIdC = new NodeId("Plant/dev1/C", ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
sink.WriteValue(nodeIdC.IdentifierAsString, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9);
cRead.ClrValue()!.ShouldBe(9);
}
finally
{
@@ -351,7 +351,7 @@ public sealed class SubscriptionSurvivalTests
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.SubscriptionSurvivalClient",
ApplicationUri = $"urn:OtOpcUa.SubscriptionSurvivalClient.{Guid.NewGuid():N}",
@@ -363,12 +363,12 @@ public sealed class SubscriptionSurvivalTests
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
@@ -378,7 +378,7 @@ public sealed class SubscriptionSurvivalTests
sessionName: "SubscriptionSurvivalTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null,
preferredLocales: default,
ct: ct);
}
@@ -45,4 +45,7 @@
<Compile Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests\OpcUaHostStart.cs" Link="OpcUaHostStart.cs"/>
</ItemGroup>
<ItemGroup>
<None Update="Fixtures/opcua-1.5.378.182-aggregates.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>