using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Gitea #626 — 's half against /// : CONDITION transitions off the /sample stream become Part 9 alarm /// events keyed by the alarm tag's RawPath, an unchanged state is never re-raised on a re-subscribe or a /// re-baseline, and a transition nobody was listening for is delivered on the next subscribe. /// /// /// Every "nothing was raised" assertion is followed by a transition that MUST be raised, and asserts that /// one is the first event — an absence proven by order, never by a delay. /// public sealed class MTConnectAlarmSourceTests { private const string ConditionRawPath = "Plant/MT/dev1/SystemCond"; private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText"; private const string ConditionId = "dev1_system_cond"; private const string OtherConditionRawPath = "Plant/MT/dev1/OtherCond"; private const long FixtureInstanceId = 1655000000L; private const long RestartedInstanceId = 1655999999L; private static readonly DateTime ObservedAt = new(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc); private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10); private static CancellationToken Ct => TestContext.Current.CancellationToken; private static MTConnectDriverOptions Opts() => new() { AgentUri = "http://fixture-agent:5000", RequestTimeoutMs = 5000, RawTags = [ // The alarm-bearing raw tag and an ordinary String tag on the SAME DataItem: the alarm path // must not disturb the existing value surface. new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"), new RawTagEntry(ConditionValueRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"), new RawTagEntry(OtherConditionRawPath, """{"address":"dev1_other_cond"}""", false, "dev1"), ], }; private static async Task<(MTConnectDriver Driver, CannedAgentClient Agent, ConcurrentQueue Events)> AlarmDriverAsync(CannedAgentClient? agent = null) { var client = agent ?? CannedAgentClient.FromFixtures(); var driver = new MTConnectDriver(Opts(), "mt1", _ => client); await driver.InitializeAsync("{}", Ct); var events = new ConcurrentQueue(); driver.OnAlarmEvent += (_, e) => events.Enqueue(e); return (driver, client, events); } private static MTConnectStreamsResult Chunk(long first, long next, params MTConnectObservation[] observations) => new(FixtureInstanceId, next, first, observations); private static MTConnectObservation Cond(string state, string? nativeCode = null, string? text = null) => new(ConditionId, state, ObservedAt, Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM")); [Fact] public async Task Normal_then_fault_then_normal_raises_then_clears_keyed_by_the_RawPath() { var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); events.ShouldBeEmpty("the primed /current says Normal, and a first-seen Normal has nothing to say"); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "Spindle overtemperature"))).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct); var seen = events.ToArray(); seen.Length.ShouldBe(2); seen[0].Kind.ShouldBe(AlarmTransitionKind.Raise); seen[0].ConditionId.ShouldBe(ConditionRawPath); seen[0].OpcUaSeverity.ShouldBe(800); seen[0].Message.ShouldBe("Spindle overtemperature [nativeCode=E17]"); seen[1].Kind.ShouldBe(AlarmTransitionKind.Clear); seen[1].ConditionId.ShouldBe(ConditionRawPath); await driver.ShutdownAsync(Ct); } [Fact] public async Task Several_native_codes_show_the_most_severe_and_clear_only_when_the_last_goes() { var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1", "coolant low"))).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(109, 110, Cond("Fault", "F1", "spindle"))).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(110, 111, Cond("Normal", "F1"))).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(111, 112, Cond("Normal", "W1"))).WaitAsync(Watchdog, Ct); var seen = events.ToArray(); seen.Select(e => e.Kind).ShouldBe( [ AlarmTransitionKind.Raise, // Warning W1 AlarmTransitionKind.Retrigger, // escalation to Fault F1 AlarmTransitionKind.Retrigger, // F1 cleared — W1 is still active, NOT a return to normal AlarmTransitionKind.Clear, // the last code cleared ]); seen.Select(e => e.OpcUaSeverity).ShouldBe([500, 800, 500, 500]); seen[2].Message.ShouldStartWith("coolant low"); await driver.ShutdownAsync(Ct); } [Fact] public async Task An_unavailable_condition_reaches_the_server_as_a_bad_quality_annotation() { var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct); var evt = events.ShouldHaveSingleItem(); evt.IsQualityOnly.ShouldBeTrue(); evt.QualityStatusCode.ShouldBe(0x80310000u); await driver.ShutdownAsync(Ct); } [Fact] public async Task An_ack_survives_an_adapter_unavailable_blip() { // A cppagent marks an adapter's items UNAVAILABLE when the adapter socket drops. The Fault must stay // active (no Clear/Raise pair, which would reset the operator's ack); only its quality moves. var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(109, 110, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(110, 111, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct); var seen = events.ToArray(); seen.Select(e => e.Kind).ShouldBe( [AlarmTransitionKind.Raise, AlarmTransitionKind.Unspecified, AlarmTransitionKind.Unspecified]); seen[1].IsQualityOnly.ShouldBeTrue(); seen[1].QualityStatusCode.ShouldBe(0x80310000u); seen[2].IsQualityOnly.ShouldBeTrue(); seen[2].QualityStatusCode.ShouldBe(0x00000000u); await driver.ShutdownAsync(Ct); } [Fact] public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back() { // Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every // surviving condition's state (acknowledgement included) across it. Re-raising a survivor would // UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back // was materialised fresh and must be told again. var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise); await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent events.Count.ShouldBe(1, "a surviving condition is not re-raised"); await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node events.Count.ShouldBe(2, "a condition that left the set and came back is told again"); events.Last().ConditionId.ShouldBe(ConditionRawPath); events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise); // Positive control: the next real transition is the very next event. await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct); events.Select(e => e.Kind).ShouldBe( [AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]); await driver.ShutdownAsync(Ct); } [Fact] public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing() { // The reconnect path: DriverInstanceActor re-subscribes on every Connected entry. Re-raising the // still-active Fault would un-acknowledge an operator's ack. var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); events.Count.ShouldBe(1); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); // Positive control: the next real transition is the very next event. await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct); events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]); await driver.ShutdownAsync(Ct); } [Fact] public async Task A_reinitialize_replaying_the_same_active_state_raises_nothing() { var agent = CannedAgentClient.FromFixtures(); agent.Current = Chunk(1, 108, Cond("Fault", "E17", "overtemp")); var (driver, _, events) = await AlarmDriverAsync(agent); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise, "the subscribe re-establishes an active condition"); // The first pump must own its /sample before the restart, or the count barrier below is off by one. await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct); // A comms-loss recovery re-initializes against the same Agent, which replays the same Fault. await driver.ReinitializeAsync("{}", Ct); // The restarted pump republishes (values AND conditions) before it opens /sample, so its second // /sample call is the barrier that the replay has been diffed. await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Normal"))).WaitAsync(Watchdog, Ct); events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]); await driver.ShutdownAsync(Ct); } [Fact] public async Task A_transition_nobody_was_listening_for_is_delivered_on_the_next_subscribe() { // While the host reconnects it detaches its handler; the fault that clears in that window must not be // recorded as delivered, or the condition stays active on the server forever. var agent = CannedAgentClient.FromFixtures(); var driver = new MTConnectDriver(Opts(), "mt1", _ => agent); await driver.InitializeAsync("{}", Ct); var events = new ConcurrentQueue(); EventHandler handler = (_, e) => events.Enqueue(e); driver.OnAlarmEvent += handler; await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); driver.OnAlarmEvent -= handler; await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct); events.Count.ShouldBe(1); driver.OnAlarmEvent += handler; await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]); await driver.ShutdownAsync(Ct); } [Fact] public async Task An_agent_restart_whose_baseline_no_longer_lists_the_fault_clears_it() { var agent = CannedAgentClient.FromFixtures(); var (driver, _, events) = await AlarmDriverAsync(agent); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); // The restarted Agent's /current reports the condition Normal. agent.CurrentAnswers.Enqueue(new MTConnectStreamsResult(RestartedInstanceId, 5, 1, [Cond("Normal")])); await agent.PumpAsync(new MTConnectStreamsResult(RestartedInstanceId, 4, 1, [])).WaitAsync(Watchdog, Ct); await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct); events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]); await driver.ShutdownAsync(Ct); } [Fact] public async Task A_dropped_stream_raises_no_alarm_event() { // Comms loss is the host's to annotate (#477) — the driver says nothing on its own. var (driver, agent, events) = await AlarmDriverAsync(); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); agent.EndStream(); await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct); // Positive control: after reconnecting, the next transition is the next event. await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct); events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]); await driver.ShutdownAsync(Ct); } [Fact] public async Task An_alarm_only_subscription_starts_the_sample_stream() { var (driver, agent, events) = await AlarmDriverAsync(); driver.SampleStreamTask.ShouldBeNull("no value subscription exists"); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1"))).WaitAsync(Watchdog, Ct); events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise); await driver.ShutdownAsync(Ct); } [Fact] public async Task The_condition_string_value_surface_is_unchanged_beside_the_alarm() { var (driver, agent, events) = await AlarmDriverAsync(); var values = new ConcurrentQueue(); driver.OnDataChange += (_, e) => values.Enqueue(e); await driver.SubscribeAsync([ConditionValueRawPath], TimeSpan.FromMilliseconds(50), Ct); await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); values.Where(v => v.FullReference == ConditionValueRawPath).Select(v => v.Snapshot.Value) .ShouldBe(["Normal", "Fault"]); values.ShouldNotContain(v => v.FullReference == ConditionRawPath, "the alarm RawPath is not a value subscription and gets no data-change"); events.ShouldHaveSingleItem().ConditionId.ShouldBe(ConditionRawPath); await driver.ShutdownAsync(Ct); } [Fact] public async Task Acknowledge_is_a_no_op_success_that_sends_nothing_to_the_agent() { var (driver, agent, _) = await AlarmDriverAsync(); await driver.AcknowledgeAsync([new AlarmAcknowledgeRequest(ConditionRawPath, ConditionRawPath, "seen", "op")], Ct); agent.Writes.ShouldBeEmpty(); await driver.ShutdownAsync(Ct); } [Fact] public async Task Unsubscribing_the_last_alarm_subscription_stops_an_alarm_only_stream() { var (driver, agent, _) = await AlarmDriverAsync(); var handle = await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct); var pump = driver.SampleStreamTask.ShouldNotBeNull(); await driver.UnsubscribeAlarmsAsync(handle, Ct); await pump.WaitAsync(Watchdog, Ct); driver.SampleStreamTask.ShouldBeNull(); await driver.ShutdownAsync(Ct); } }