using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; /// /// Golden-box-tier ALMD alarm projection tests against Logix Emulate. /// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage /// (AbCipAlarmProjectionTests with faked InFaulted sequences) to end-to-end /// wire-level coverage — Emulate runs the real ALMD instruction, with real /// rising-edge semantics on InFaulted + Ack, so the driver's poll-based /// projection gets validated against the actual behaviour shops running FT View see. /// /// /// Required Emulate project state (see LogixProject/README.md): /// /// Controller-scope ALMD tag HighTempAlarm — a standard ALMD instruction /// with default member set (In, InFaulted, Acked, /// Severity, Cfg_ProgTime, …). /// A periodic task that drives HighTempAlarm.In false→true→false at a /// cadence the operator can script via a one-shot routine (e.g. a /// SimulateAlarm bit the test case pulses through /// IWritable.WriteAsync). /// Operator writes 1 to SimulateAlarm to trigger the rising /// edge on HighTempAlarm.In; ladder uses that as the alarm input. /// /// Runs only when AB_SERVER_PROFILE=emulate. ab_server has no ALMD /// instruction + no alarm subsystem, so this tier-gated class couldn't produce a /// meaningful result against the default simulator. /// [Collection("AbServerEmulate")] [Trait("Category", "Integration")] [Trait("Tier", "Emulate")] public sealed class AbCipEmulateAlmdTests { [AbServerFact] public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection() { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT") ?? throw new InvalidOperationException( "AB_SERVER_ENDPOINT must be set to the Logix Emulate instance when AB_SERVER_PROFILE=emulate."); var options = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")], EnableAlarmProjection = true, AlarmPollInterval = TimeSpan.FromMilliseconds(200), Tags = [ new AbCipTagDefinition( Name: "HighTempAlarm", DeviceHostAddress: $"ab://{endpoint}/1,0", TagPath: "HighTempAlarm", DataType: AbCipDataType.Structure, Members: [ new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt), new AbCipStructureMember("Severity", AbCipDataType.DInt), new AbCipStructureMember("In", AbCipDataType.DInt), ]), // The "simulate the alarm input" bit the ladder watches. new AbCipTagDefinition( Name: "SimulateAlarm", DeviceHostAddress: $"ab://{endpoint}/1,0", TagPath: "SimulateAlarm", DataType: AbCipDataType.Bool, Writable: true), ], }; await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-almd"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var raised = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); drv.OnAlarmEvent += (_, e) => { if (e.Message.Contains("raised")) raised.TrySetResult(e); }; var sub = await drv.SubscribeAlarmsAsync( ["HighTempAlarm"], TestContext.Current.CancellationToken); // Pulse the input bit the ladder watches, then wait for the driver's poll loop // to see InFaulted rise + fire the raise event. _ = await drv.WriteAsync( [new WriteRequest("SimulateAlarm", true)], TestContext.Current.CancellationToken); var got = await Task.WhenAny(raised.Task, Task.Delay(TimeSpan.FromSeconds(5))); got.ShouldBe(raised.Task, "driver must surface the ALMD raise within 5 s of the ladder-driven edge"); var args = await raised.Task; args.SourceNodeId.ShouldBe("HighTempAlarm"); args.AlarmType.ShouldBe("ALMD"); await drv.UnsubscribeAlarmsAsync(sub, TestContext.Current.CancellationToken); // Reset the bit so the next test run starts from a known state. _ = await drv.WriteAsync( [new WriteRequest("SimulateAlarm", false)], TestContext.Current.CancellationToken); } }