test(client): over-the-wire alarm method calls against the in-process server
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The shared client's alarm APIs (<see cref="OpcUaClientService" />, the code behind Client.CLI's
|
||||
/// <c>ack</c>/<c>confirm</c>/<c>shelve</c>) against the real in-process server, over the wire, with the real
|
||||
/// session adapter. Commit 85f00327 moved the client onto the Part 9 method ids invoked on the condition
|
||||
/// node itself (it used to target nonexistent <c><id>.Condition</c> / <c><id>.ShelvingState</c>
|
||||
/// node ids); until this class it was only exercised against a fake session adapter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The condition id is passed exactly as a CLI user passes <c>--node</c>: the condition node's own id.
|
||||
/// Each call must return Good <b>and</b> move the condition — read back from the condition events the
|
||||
/// server reports, not from the call result alone. The client exposes no AddComment API, so it is not covered.
|
||||
/// </remarks>
|
||||
public sealed class ClientAlarmMethodTests
|
||||
{
|
||||
private const NodePermissions All =
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
||||
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
|
||||
| NodePermissions.MethodCall;
|
||||
|
||||
[Fact]
|
||||
public async Task The_client_acknowledges_confirms_and_shelves_a_scripted_condition()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("ClientAlarmScripted", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseScripted();
|
||||
var current = ConditionMethodEventTests.PreState("Acknowledge", native: false) with { Message = "base" };
|
||||
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
|
||||
|
||||
using var observer = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
rig.Project(native: false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
using var client = await ConnectClientAsync(rig, ct);
|
||||
var conditionId = ConditionNodeId(observer, native: false).ToString();
|
||||
|
||||
async Task<VariantCollection> Next(int expectedCount)
|
||||
{
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= expectedCount);
|
||||
return events.ConditionEvents(item, "base")[expectedCount - 1];
|
||||
}
|
||||
|
||||
var n = 1;
|
||||
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(
|
||||
conditionId, events.LatestEventId(item, "base"), "ack", ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
|
||||
StatusCode.IsGood(await client.ConfirmAlarmAsync(
|
||||
conditionId, events.LatestEventId(item, "base"), "confirm", ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
|
||||
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].Value.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);
|
||||
|
||||
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].Value.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 subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_client_acknowledges_a_native_condition_and_is_refused_confirm_and_shelve()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("ClientAlarmNative", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
var current = ConditionMethodEventTests.PreState("Acknowledge", native: true) with { Message = "base" };
|
||||
ConditionMethodEventTests.WireSimulatedOwners(rig, native: true, () => current, s => current = s);
|
||||
|
||||
using var observer = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
rig.Project(native: true, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
using var client = await ConnectClientAsync(rig, ct);
|
||||
var conditionId = ConditionNodeId(observer, native: true).ToString();
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private static async Task<OpcUaClientService> ConnectClientAsync(ServerEventRig rig, CancellationToken ct)
|
||||
{
|
||||
var client = new OpcUaClientService();
|
||||
await client.ConnectAsync(new ConnectionSettings
|
||||
{
|
||||
EndpointUrl = rig.EndpointUrl,
|
||||
Username = "apikey",
|
||||
Password = $"otua_{KeyId}_secret",
|
||||
CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"),
|
||||
}, ct);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -394,7 +394,7 @@ public sealed class ConditionMethodEventTests
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
|
||||
|
||||
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
|
||||
private static AlarmConditionSnapshot PreState(string method, bool native)
|
||||
internal static AlarmConditionSnapshot PreState(string method, bool native)
|
||||
{
|
||||
var s = new AlarmConditionSnapshot(
|
||||
Active: true, Acknowledged: false, Confirmed: native, Enabled: true,
|
||||
@@ -414,7 +414,7 @@ public sealed class ConditionMethodEventTests
|
||||
/// Stands in for the scripted engine and the native driver: each routed command comes back as a
|
||||
/// re-projection of the state it produces, from a pool thread.
|
||||
/// </summary>
|
||||
private static void WireSimulatedOwners(
|
||||
internal static void WireSimulatedOwners(
|
||||
ServerEventRig rig, bool native, Func<AlarmConditionSnapshot> current, Action<AlarmConditionSnapshot> set)
|
||||
{
|
||||
rig.Nm.NativeAlarmAckRouter = _ =>
|
||||
|
||||
@@ -41,6 +41,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
|
||||
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||
|
||||
public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
|
||||
|
||||
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||
|
||||
public SdkAddressSpaceSink Sink { get; private set; } = null!;
|
||||
|
||||
+2
@@ -29,6 +29,8 @@
|
||||
over-the-wire denial is produced by the same component the hosted service arms rather than by
|
||||
a direct call to the node manager's pass. -->
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||
<!-- ClientAlarmMethodTests drives the real shared client against this in-process server. -->
|
||||
<ProjectReference Include="..\..\..\src\Client\ZB.MOM.WW.OtOpcUa.Client.Shared\ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user