using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Gitea #626 — the pure halves of the CONDITION → Part 9 path: /// (MTConnect's multi-condition semantics) and (state mapping and /// transition sequencing). The driver wiring is covered by MTConnectAlarmSourceTests. /// public sealed class MTConnectConditionAlarmMappingTests { private const string Id = "dev1_system_cond"; private const string RawPath = "Plant/MT/dev1/SystemCond"; private const uint Good = 0x00000000u; private const uint BadNoCommunication = 0x80310000u; private static readonly DateTime T0 = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc); private static readonly IAlarmSubscriptionHandle Handle = new MTConnectAlarmSubscriptionHandle(1); // ---- tracker ---- [Fact] public void A_never_observed_item_has_no_state() { new MTConnectConditionTracker().Get(Id).ShouldBeNull(); } [Fact] public void Samples_and_events_never_reach_the_tracker() { var tracker = new MTConnectConditionTracker(); var touched = tracker.Apply(Doc(new MTConnectObservation(Id, "Fault", T0)), baseline: false); touched.ShouldBeEmpty("an observation without Condition detail did not come from a "); tracker.Get(Id).ShouldBeNull(); } [Fact] public void The_most_severe_of_several_simultaneous_conditions_wins() { var tracker = new MTConnectConditionTracker(); tracker.Apply(Doc(Cond("Warning", "W1", at: 0), Cond("Fault", "F1", at: 1), Cond("Warning", "W2", at: 2)), baseline: false); var state = tracker.Get(Id).ShouldNotBeNull(); state.Level.ShouldBe(MTConnectConditionLevel.Fault); state.Detail.NativeCode.ShouldBe("F1"); state.ActiveCount.ShouldBe(3); } [Fact] public void A_normal_for_one_native_code_in_a_later_sample_clears_only_that_code() { // The index's per-document reconcile would read this chunk as "Normal"; the alarm path must not. var tracker = new MTConnectConditionTracker(); tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false); tracker.Apply(Doc(Cond("Warning", "B", at: 1)), baseline: false); tracker.Apply(Doc(Cond("Normal", "A", at: 2)), baseline: false); var state = tracker.Get(Id).ShouldNotBeNull(); state.Level.ShouldBe(MTConnectConditionLevel.Warning); state.Detail.NativeCode.ShouldBe("B"); } [Fact] public void A_normal_without_a_native_code_clears_every_code() { var tracker = new MTConnectConditionTracker(); tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false); tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false); tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal); } [Fact] public void Unavailable_clears_every_code_until_any_other_level_arrives() { var tracker = new MTConnectConditionTracker(); tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false); tracker.Apply(Doc(Cond("UNAVAILABLE", null, at: 1)), baseline: false); tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Unavailable); tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false); tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal); } [Fact] public void A_baseline_drops_a_code_the_current_document_no_longer_lists() { // A code cleared while the driver was not listening must not survive a /current re-baseline. var tracker = new MTConnectConditionTracker(); tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false); tracker.Apply(Doc(Cond("Warning", "B", at: 2)), baseline: true); var state = tracker.Get(Id).ShouldNotBeNull(); state.Level.ShouldBe(MTConnectConditionLevel.Warning); state.ActiveCount.ShouldBe(1); } [Fact] public void An_unrecognised_vendor_state_is_surfaced_as_a_warning() { MTConnectConditionTracker.LevelOf("Alarm").ShouldBe(MTConnectConditionLevel.Warning); MTConnectConditionTracker.LevelOf("UNAVAILABLE").ShouldBe(MTConnectConditionLevel.Unavailable); MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault); } [Fact] public void Equal_level_conditions_tie_break_deterministically_regardless_of_document_order() { // Review MINOR 2: an Agent that lists the same two Warnings in a different order on a re-baseline must // not change the winner, or every reconnect would retrigger (and un-ack) the condition. var forward = new MTConnectConditionTracker(); forward.Apply(Doc(Cond("Warning", "B", at: 0), Cond("Warning", "A", at: 1)), baseline: true); var reverse = new MTConnectConditionTracker(); reverse.Apply(Doc(Cond("Warning", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: true); forward.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A"); reverse.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A"); } // ---- mapper: state mapping ---- [Fact] public void Fault_raises_active_at_severity_800_with_the_text_as_message() { var evt = Map(null, State(MTConnectConditionLevel.Fault, "Spindle overtemperature", "E17", "2", "HIGH")) .ShouldNotBeNull(); evt.Kind.ShouldBe(AlarmTransitionKind.Raise); evt.ConditionId.ShouldBe(RawPath, "the host routes on ConditionId == RawPath"); evt.SourceNodeId.ShouldBe(Id); evt.OpcUaSeverity.ShouldBe(800); evt.Severity.ShouldBe(AlarmSeverity.High); evt.QualityStatusCode.ShouldBe(Good); evt.Message.ShouldBe("Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]"); evt.AlarmType.ShouldBe("SYSTEM"); evt.SourceTimestampUtc.ShouldBe(T0); } [Fact] public void Warning_raises_active_at_severity_500() { var evt = Map(null, State(MTConnectConditionLevel.Warning)).ShouldNotBeNull(); evt.Kind.ShouldBe(AlarmTransitionKind.Raise); evt.OpcUaSeverity.ShouldBe(500); evt.Severity.ShouldBe(AlarmSeverity.Medium); } [Fact] public void A_condition_without_text_falls_back_to_its_type_level_and_native_code() { MTConnectConditionAlarmMapper.MessageOf(State(MTConnectConditionLevel.Fault, text: null, nativeCode: "1001")) .ShouldBe("SYSTEM Fault [nativeCode=1001]"); } // ---- mapper: transition sequencing ---- [Fact] public void Normal_then_fault_then_normal_is_raise_then_clear() { var events = Sequence( State(MTConnectConditionLevel.Normal), State(MTConnectConditionLevel.Fault, "overtemp", "E17"), State(MTConnectConditionLevel.Normal)); events[0].ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive"); events[1].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise); var clear = events[2].ShouldNotBeNull(); clear.Kind.ShouldBe(AlarmTransitionKind.Clear); clear.QualityStatusCode.ShouldBe(Good); clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears"); } [Fact] public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity() { var events = Sequence( State(MTConnectConditionLevel.Warning, "coolant low", "W1"), State(MTConnectConditionLevel.Fault, "coolant empty", "F1")); var evt = events[1].ShouldNotBeNull(); evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger); evt.OpcUaSeverity.ShouldBe(800); evt.Message.ShouldStartWith("coolant empty"); } [Fact] public void A_different_active_native_code_at_the_same_level_is_a_retrigger() { // Includes the documented "clearing the worst code re-raises the next one down, un-acked" case. Sequence(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))[1] .ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Retrigger); } [Fact] public void An_unchanged_active_statement_emits_nothing_even_with_a_new_timestamp_or_count() { // The reconnect replay: /current re-reports the same Fault under a new sequence. Re-raising it would // un-acknowledge an operator's ack. var before = State(MTConnectConditionLevel.Fault, "overtemp", "E17"); var replayed = before with { TimestampUtc = T0.AddMinutes(5), ActiveCount = 2 }; Map(before, replayed).ShouldBeNull(); Map(before, replayed, resync: true).ShouldBeNull(); } [Fact] public void A_text_only_change_updates_nothing_and_the_first_text_is_kept() { // Review MINOR 4 / ruling 5: an adapter embedding a live value in the text must not re-raise (un-ack). var first = State(MTConnectConditionLevel.Fault, "Spindle temp 82C", "E17"); var (evt, told) = MTConnectConditionAlarmMapper.Transition( new MTConnectToldCondition(first, false), State(MTConnectConditionLevel.Fault, "Spindle temp 83C", "E17"), RawPath, Id, Handle, resync: false); evt.ShouldBeNull(); told.Statement.ShouldNotBeNull().Detail.Text.ShouldBe("Spindle temp 82C"); } [Fact] public void A_repeated_normal_with_different_text_does_not_re_clear() { Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull(); } // ---- mapper: Unavailable (comms loss one hop out) ---- [Fact] public void An_active_condition_going_unavailable_stays_active_with_a_bad_quality_annotation() { var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable)) .ShouldNotBeNull(); evt.IsQualityOnly.ShouldBeTrue("not a Clear: the state is unknown, not normal (#477/#481 posture)"); evt.QualityStatusCode.ShouldBe(BadNoCommunication); evt.Message.ShouldBe("x [nativeCode=E1]", "the annotation describes the statement the server still holds"); } [Fact] public void An_ack_survives_an_unavailable_blip_when_the_same_fault_returns() { // Fault → UNAVAILABLE → the same Fault: two quality-only annotations, no Clear, no Raise — so the // operator's acknowledgement is never reset. var events = Sequence( State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1")); events[0].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise); events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue(); events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication); events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue(); events[2]!.QualityStatusCode.ShouldBe(Good); } [Fact] public void An_active_condition_returning_normal_from_unavailable_is_a_clear() { var events = Sequence( State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Normal)); var clear = events[2].ShouldNotBeNull(); clear.Kind.ShouldBe(AlarmTransitionKind.Clear); clear.QualityStatusCode.ShouldBe(Good); } [Fact] public void An_active_condition_returning_with_a_different_statement_is_a_retrigger() { var events = Sequence( State(MTConnectConditionLevel.Warning, "w", "W1"), State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "f", "F1")); var evt = events[2].ShouldNotBeNull(); evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger); evt.QualityStatusCode.ShouldBe(Good); evt.OpcUaSeverity.ShouldBe(800); } [Fact] public void An_inactive_condition_going_unavailable_and_back_is_quality_only() { var events = Sequence( State(MTConnectConditionLevel.Normal), State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Normal)); events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue(); events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication); events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue(); events[2]!.QualityStatusCode.ShouldBe(Good); Map(null, State(MTConnectConditionLevel.Unavailable)).ShouldNotBeNull().IsQualityOnly .ShouldBeTrue("first-seen Unavailable must reach the node"); } [Fact] public void An_unchanged_unavailable_is_reasserted_only_on_a_resync() { // The host re-annotates every condition Good on reconnect (#477); only the resync puts the Bad back. var told = new MTConnectToldCondition(State(MTConnectConditionLevel.Fault, "x", "E1"), QualityBad: true); var unavailable = State(MTConnectConditionLevel.Unavailable); MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: false).Event.ShouldBeNull(); MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: true).Event .ShouldNotBeNull().IsQualityOnly.ShouldBeTrue(); } [Fact] public void A_first_seen_unavailable_recovering_straight_to_fault_raises_with_good_quality() { var events = Sequence(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1")); var evt = events[1].ShouldNotBeNull(); evt.Kind.ShouldBe(AlarmTransitionKind.Raise); evt.QualityStatusCode.ShouldBe(Good); } // ---- helpers ---- /// Maps one step from a told state of (Good quality). private static AlarmEventArgs? Map(MTConnectConditionState? statement, MTConnectConditionState current, bool resync = false) => MTConnectConditionAlarmMapper.Transition( statement is null ? null : new MTConnectToldCondition(statement, false), current, RawPath, Id, Handle, resync).Event; /// Threads the told record through a sequence of Agent statements, returning each step's event. private static AlarmEventArgs?[] Sequence(params MTConnectConditionState[] states) { MTConnectToldCondition? told = null; var events = new AlarmEventArgs?[states.Length]; for (var i = 0; i < states.Length; i++) { (events[i], told) = MTConnectConditionAlarmMapper.Transition(told, states[i], RawPath, Id, Handle, resync: false); } return events; } private static MTConnectConditionState State( MTConnectConditionLevel level, string? text = null, string? nativeCode = null, string? nativeSeverity = null, string? qualifier = null) => new(level, new MTConnectConditionDetail(text, nativeCode, nativeSeverity, qualifier, "SYSTEM"), T0, level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault ? 1 : 0); private static MTConnectObservation Cond(string state, string? nativeCode, int at) => new(Id, state, T0.AddSeconds(at), Condition: new MTConnectConditionDetail($"{state} {nativeCode}", nativeCode, null, null, "SYSTEM")); private static MTConnectStreamsResult Doc(params MTConnectObservation[] observations) => new(1, 2, 1, observations); }