using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #641 over the wire — a full address-space rebuild that leaves the native alarm set unchanged must not /// leave an active condition reading inactive. A Server-object (i=2253) item, which outlives the rebuild /// (#586), receives exactly ONE condition event carrying the restored state — and an acknowledgement a client /// applied before the rebuild survives it. /// /// /// The rebuild is driven exactly as OpcUaPublishActor drives one: RebuildAddressSpace, the /// re-materialise + notifier wiring, then CompleteAddressSpaceApply. The count is read after a later /// sentinel transition arrives on the same item, so a duplicate would already be queued ahead of it. /// The native ack router echoes nothing back, like MTConnect and FOCAS: the acknowledgement lives only on the node. /// public sealed class NativeAlarmRebuildCarryOverWireTests { private const NodePermissions All = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall; private static AlarmConditionSnapshot Fault(string message) => new( Active: true, Acknowledged: false, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: message); [Theory] [InlineData(false)] [InlineData(true)] public async Task A_rebuild_with_an_unchanged_alarm_set_reports_the_restored_state_once(bool clientAcked) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"Rebuild641{clientAcked}", encrypted: false, ct); try { rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.Nm.NativeAlarmAckRouter = _ => { }; // a driver whose ack is a no-op: nothing echoes back rig.MaterialiseNative(); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(native: true, Fault("fault")); await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "fault")); var expectedBefore = 1; if (clientAcked) { await session.CallAsync( ConditionNodeId(session, native: true), MethodIds.AcknowledgeableConditionType_Acknowledge, ct, events.LatestEventId(serverItem, "fault"), new LocalizedText("ack")); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(serverItem, "fault").Count == 2); expectedBefore = 2; } // The deploy: a rebuild whose composition carries the same alarm (e.g. one UnsTagReference added). rig.Sink.RebuildAddressSpace(); rig.MaterialiseNative(); rig.Sink.CompleteAddressSpaceApply(); rig.Project(native: true, Fault("sentinel") with { Acknowledged = clientAcked }); await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel")); 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); // 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); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// Falsification control: a NORMAL native condition produces no event across the rebuild. [Fact] public async Task A_rebuild_reports_nothing_for_a_normal_condition() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("Rebuild641Normal", encrypted: false, ct); try { rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(native: true, Fault("cleared") with { Active = false, Acknowledged = true }); await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "cleared")); rig.Sink.RebuildAddressSpace(); rig.MaterialiseNative(); rig.Sink.CompleteAddressSpaceApply(); rig.Project(native: true, Fault("sentinel")); await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel")); events.ConditionEvents(serverItem, "cleared").Count.ShouldBe(1); events.ConditionEvents(serverItem, "temp_hi").ShouldBeEmpty("the fresh node is not reported"); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } }