test: migrate SDK 2.0 wire contracts and pin baseline aggregate vectors
This commit is contained in:
+13
-18
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+9
-14
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+14
-14
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
+8
-14
@@ -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.
|
||||
|
||||
+15
-15
@@ -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);
|
||||
}
|
||||
|
||||
+27
-27
@@ -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();
|
||||
}
|
||||
|
||||
+26
-26
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -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
|
||||
{
|
||||
|
||||
+16
-16
@@ -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)
|
||||
|
||||
+9
-9
@@ -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();
|
||||
|
||||
+22
-22
@@ -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
|
||||
|
||||
+4146
File diff suppressed because it is too large
Load Diff
+36
-36
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -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");
|
||||
|
||||
|
||||
+31
-31
@@ -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)
|
||||
|
||||
+8
-8
@@ -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)
|
||||
|
||||
+6
-6
@@ -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);
|
||||
}
|
||||
|
||||
+66
@@ -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)
|
||||
{
|
||||
|
||||
+6
-6
@@ -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
|
||||
{
|
||||
|
||||
+114
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -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)
|
||||
|
||||
+12
-12
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user