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), ]); [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(); } [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); } [Fact] public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls() { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [AlmdTag("HighTemp")], EnableAlarmProjection = false, // explicit; also the default }; var drv = new AbCipDriver(opts, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); handle.ShouldNotBeNull(); handle.DiagnosticId.ShouldContain("abcip-alarm-sub-"); // Wait a touch — if polling were active, a fake member-read would be triggered. await Task.Delay(100); factory.Tags.ShouldNotContainKey("HighTemp.InFaulted"); factory.Tags.ShouldNotContainKey("HighTemp.Severity"); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1() { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [AlmdTag("HighTemp")], EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), }; var drv = new AbCipDriver(opts, "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), wait a tick, // 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 Task.Delay(80); // let a tick seed the "last-seen false" state factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted await Task.Delay(200); // allow several polls to be safe lock (events) { events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD" && e.Message.Contains("raised")); } await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Clear_Event_Fires_On_1_to_0_Transition() { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [AlmdTag("HighTemp")], EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), }; var drv = new AbCipDriver(opts, "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 Task.Delay(80); // observe raise factory.Tags["HighTemp"].ValuesByOffset[0] = 0; await Task.Delay(200); lock (events) { events.ShouldContain(e => e.Message.Contains("raised")); events.ShouldContain(e => e.Message.Contains("cleared")); } await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Unsubscribe_Stops_The_Poll_Loop() { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [AlmdTag("HighTemp")], EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(20), }; var drv = new AbCipDriver(opts, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); await WaitForTagCreation(factory, "HighTemp"); var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount; await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await Task.Delay(100); // well past several poll intervals if the loop were still alive var postDelayReadCount = factory.Tags["HighTemp"].ReadCount; // Allow at most one straggler read between the unsubscribe-cancel + the loop exit. (postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1); await drv.ShutdownAsync(CancellationToken.None); } 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."); } }