using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Task #177 — tests covering ALMD projection detection, feature-flag gate, /// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge. /// [Trait("Category", "Unit")] public sealed class AbCipAlarmProjectionTests { private const string Device = "ab://10.0.0.5/1,0"; private static AbCipTagDefinition AlmdTag(string name) => new( name, Device, name, AbCipDataType.Structure, Members: [ new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT new AbCipStructureMember("Acked", AbCipDataType.DInt), new AbCipStructureMember("Severity", AbCipDataType.DInt), new AbCipStructureMember("In", AbCipDataType.DInt), ]); /// Verifies that ALMD structure signature is correctly detected as an alarm. [Fact] public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm() { var almd = AlmdTag("HighTemp"); AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue(); var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members: [new AbCipStructureMember("X", AbCipDataType.DInt)]); AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse(); var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt); AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse(); } /// Verifies that severity values map correctly to OPC UA alarm severity levels. [Fact] public void Severity_Mapping_Matches_OPC_UA_Convention() { // Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing. AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low); AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium); AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High); AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical); } private static AbCipDriverOptions Options(bool enabled) => new() { Devices = [new AbCipDeviceOptions(Device)], RawTags = AbCipRawTags.From(AlmdTag("HighTemp")), EnableAlarmProjection = enabled, AlarmPollInterval = TimeSpan.FromMilliseconds(20), // The ALMD projection here drives the parent-UDT runtime via offset-keyed values, // so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003). EnableDeclarationOnlyUdtGrouping = true, }; /// Verifies that disabled alarm projection returns a valid handle but does not poll. [Fact] public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: false), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); handle.ShouldNotBeNull(); handle.DiagnosticId.ShouldContain("abcip-alarm-sub-"); // Positive control (#583): the same options with the flag ON poll three times — so a loop this // subscription had started would have read (and so created) the ALMD runtime by now. await PolledThreeTimesWithTheFlagOnAsync(); factory.Tags.ShouldNotContainKey("HighTemp"); factory.Tags.ShouldNotContainKey("HighTemp.InFaulted"); factory.Tags.ShouldNotContainKey("HighTemp.Severity"); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } /// Verifies that enabled alarm projection starts polling and fires raise event on 0-to-1 transition. [Fact] public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var events = new List(); drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); }; var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); // The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime // gets created + polled. Set InFaulted offset-value to 0 first (clear), let polls seed the // "last-seen false" state, then flip to 1 (fault) + wait for the raise event. await WaitForTagCreation(factory, "HighTemp"); factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0 factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked) await WaitForMoreReadsAsync(factory, "HighTemp", 2); factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted await WaitForEventAsync(events, e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD" && e.Message.Contains("raised")); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } /// /// Regression for the reconnect poll-loop leak (#399 sibling): the owning /// DriverInstanceActor re-subscribes alarms on every Connected entry without first calling /// Unsubscribe, and the driver object (and its projection) survives the in-place reconnect. /// Each SubscribeAsync used to start a fresh, never-cancelled poll loop — so after N /// reconnects there were N live loops all polling the device and all firing the same /// raise/clear transition, producing DUPLICATE alarm events. /// /// This simulates two re-subscribes (one reconnect) against the same source, then drives /// ONE 0->1 device transition. After the collapse-to-single-loop fix exactly one loop is /// alive so the raise fires exactly once; before the fix both loops fire it ⇒ two events. /// [Fact] public async Task Resubscribe_Collapses_To_Single_Loop_No_Duplicate_Raise() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var raises = new List(); drv.OnAlarmEvent += (_, e) => { if (e.Message.Contains("raised")) lock (raises) raises.Add(e); }; // First subscribe creates + starts polling the HighTemp runtime. Seed InFaulted=false + // a severity so the loop's baseline settles on "not faulted". var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0 factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 await WaitForMoreReadsAsync(factory, "HighTemp", 2); // sub1's loop has seeded its baseline // Second subscribe = the actor re-subscribing across a reconnect. Its loop reads the same // shared HighTemp runtime; let it poll (and so seed its own "last-seen false" baseline) too. var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForMoreReadsAsync(factory, "HighTemp", 3); factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // one 0->1 transition await WaitForEventAsync(raises, _ => true); // Processed-count wait (#583), not a fixed delay: six more reads of the shared runtime after the raise, // so a leaked second loop — which reads it too — has had polls in which to fire its duplicate. await WaitForMoreReadsAsync(factory, "HighTemp", 6); await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None); await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); lock (raises) { raises.Count.ShouldBe(1, "exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise"); } } /// Verifies that alarm clear event fires on 1-to-0 transition. [Fact] public async Task Clear_Event_Fires_On_1_to_0_Transition() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var events = new List(); drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); }; var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); factory.Tags["HighTemp"].ValuesByOffset[0] = 1; factory.Tags["HighTemp"].ValuesByOffset[8] = 500; await WaitForEventAsync(events, e => e.Message.Contains("raised")); factory.Tags["HighTemp"].ValuesByOffset[0] = 0; await WaitForEventAsync(events, e => e.Message.Contains("cleared")); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); // Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and // projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every // ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if // it had routed. AlarmEventArgs raised, cleared; lock (events) { raised = events.First(e => e.Message.Contains("raised")); cleared = events.First(e => e.Message.Contains("cleared")); } raised.ConditionId.ShouldBe("HighTemp"); raised.Kind.ShouldBe(AlarmTransitionKind.Raise); cleared.ConditionId.ShouldBe("HighTemp"); cleared.Kind.ShouldBe(AlarmTransitionKind.Clear); } /// /// Gitea #641 review — the server carries every surviving native condition's state (a client's /// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every /// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so /// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm /// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set. /// [Fact] public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var raises = new List(); drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); }; var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); factory.Tags["HighTemp"].ValuesByOffset[8] = 500; factory.Tags["HighTemp"].ValuesByOffset[0] = 1; await WaitForEventAsync(raises, _ => true); // A reconnect / alarm-set change: the same ref, still faulted. var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForMoreReadsAsync(factory, "HighTemp", 6); lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm"); await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None); await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } /// Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh /// (inactive) when it came back, so it is raised again. [Fact] public async Task A_ref_that_left_the_set_and_came_back_is_raised_again() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var raises = new List(); drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); }; await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); factory.Tags["HighTemp"].ValuesByOffset[8] = 500; factory.Tags["HighTemp"].ValuesByOffset[0] = 1; await WaitForEventAsync(raises, _ => true); await drv.SubscribeAlarmsAsync([], CancellationToken.None); await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForEventAsync(raises, _ => raises.Count >= 2); await drv.ShutdownAsync(CancellationToken.None); } /// Verifies that unsubscribing stops the alarm poll loop. [Fact] public async Task Unsubscribe_Stops_The_Poll_Loop() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); await WaitForMoreReadsAsync(factory, "HighTemp", 1); // the loop is running // Unsubscribe awaits the loop's exit, so no read can land after it returns. await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); var postUnsubReadCount = factory.Tags["HighTemp"].ReadCount; // Positive control (#583): an identical, still-subscribed driver polls three more times on the same // interval — so the unsubscribed loop, had it survived, would have read again by now. await PolledThreeTimesWithTheFlagOnAsync(); factory.Tags["HighTemp"].ReadCount.ShouldBe(postUnsubReadCount); await drv.ShutdownAsync(CancellationToken.None); } /// A control driver with the projection ON polls its ALMD runtime three times. private static async Task PolledThreeTimesWithTheFlagOnAsync() { var factory = new FakeAbCipTagFactory(); var control = new AbCipDriver(Options(enabled: true), "drv-control", factory); await control.InitializeAsync("{}", CancellationToken.None); var handle = await control.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); await WaitForMoreReadsAsync(factory, "HighTemp", 3); await control.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await control.ShutdownAsync(CancellationToken.None); } /// Waits for further reads of the fake runtime (a processed-count wait). /// /// #630 — a re-Initialize on a live driver must dispose the alarm projection's poll loop rather /// than leave it running (it would keep raising duplicate alarm events through this driver). /// [Fact] public async Task Reinitialize_Stops_The_Previous_Projection_Poll_Loop() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); var tag = factory.Tags["HighTemp"]; // Positive control: the loop is demonstrably polling before the re-init. await WaitForMoreReadsAsync(factory, "HighTemp", 3); await drv.InitializeAsync("{}", CancellationToken.None); int Reads() => factory.Tags.Values.Sum(t => t.ReadCount); // the loop may re-create its runtime on the new device state var frozen = Reads(); await Task.Delay(300); // ~15 poll intervals; a live loop would advance the count Reads().ShouldBe(frozen); await drv.ShutdownAsync(CancellationToken.None); } private static async Task WaitForMoreReadsAsync(FakeAbCipTagFactory factory, string tagName, int more) { var tag = factory.Tags[tagName]; var target = tag.ReadCount + more; var deadline = DateTime.UtcNow.AddSeconds(5); while (tag.ReadCount < target && DateTime.UtcNow < deadline) await Task.Delay(5); tag.ReadCount.ShouldBeGreaterThanOrEqualTo(target, $"'{tagName}' should have been read {more} more times"); } private static async Task WaitForEventAsync(List events, Func match) { var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline) { lock (events) if (events.Any(match)) return; await Task.Delay(5); } throw new TimeoutException("The expected alarm event was never raised."); } private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName) { var deadline = DateTime.UtcNow.AddSeconds(2); while (DateTime.UtcNow < deadline) { if (factory.Tags.ContainsKey(tagName)) return; await Task.Delay(10); } throw new TimeoutException($"Tag {tagName} was never created by the fake factory."); } }