using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; /// /// Pure state-machine tests — no engine, no I/O, no async. Every transition rule /// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions /// surface as clear failures rather than subtle alarm-behavior drift. /// [Trait("Category", "Unit")] public sealed class Part9StateMachineTests { private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0); [Fact] public void Predicate_true_on_inactive_becomes_active_and_emits_Activated() { var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1)); r.State.Active.ShouldBe(AlarmActiveState.Active); r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged); r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); r.Emission.ShouldBe(EmissionKind.Activated); r.State.LastActiveUtc.ShouldNotBeNull(); } [Fact] public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared() { var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State; var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2)); r.State.Active.ShouldBe(AlarmActiveState.Inactive); r.Emission.ShouldBe(EmissionKind.Cleared); r.State.LastClearedUtc.ShouldNotBeNull(); } [Fact] public void Predicate_unchanged_state_emits_None() { var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0); r.Emission.ShouldBe(EmissionKind.None); } [Fact] public void Disabled_alarm_ignores_predicate() { var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State; var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2)); r.State.Active.ShouldBe(AlarmActiveState.Inactive); r.Emission.ShouldBe(EmissionKind.None); } [Fact] public void Acknowledge_from_unacked_records_user_and_emits() { var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State; var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2)); r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged); r.State.LastAckUser.ShouldBe("alice"); r.State.LastAckComment.ShouldBe("looking into it"); r.State.Comments.Count.ShouldBe(1); r.Emission.ShouldBe(EmissionKind.Acknowledged); } [Fact] public void Acknowledge_when_already_acked_is_noop() { var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State; var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State; var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3)); r.Emission.ShouldBe(EmissionKind.None); } [Fact] public void Acknowledge_without_user_throws() { Should.Throw(() => Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0)); } [Fact] public void Confirm_after_clear_records_user_and_emits() { // Walk: activate -> ack -> clear -> confirm var s = Fresh(); s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State; s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State; s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State; var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4)); r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed); r.State.LastConfirmUser.ShouldBe("bob"); r.Emission.ShouldBe(EmissionKind.Confirmed); } [Fact] public void OneShotShelve_suppresses_next_activation_emission() { var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State; var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)); r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances"); r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it"); } [Fact] public void OneShotShelve_expires_on_clear() { var s = Fresh(); s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State; s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State; var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)); r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear"); } [Fact] public void TimedShelve_requires_future_unshelve_time() { Should.Throw(() => Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5))); } [Fact] public void TimedShelve_expires_via_shelving_check() { var until = T0.AddMinutes(5); var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State; shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed); // Before expiry — still shelved. var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3)); earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed); earlier.Emission.ShouldBe(EmissionKind.None); // After expiry — auto-unshelved + emission. var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6)); after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); after.Emission.ShouldBe(EmissionKind.Unshelved); after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue(); } [Fact] public void Unshelve_from_unshelved_is_noop() { var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0); r.Emission.ShouldBe(EmissionKind.None); } [Fact] public void Explicit_Unshelve_emits_event() { var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State; var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30)); r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); r.Emission.ShouldBe(EmissionKind.Unshelved); } [Fact] public void AddComment_appends_to_audit_trail_with_event() { var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5)); r.State.Comments.Count.ShouldBe(1); r.State.Comments[0].Kind.ShouldBe("AddComment"); r.State.Comments[0].User.ShouldBe("alice"); r.State.Comments[0].Text.ShouldBe("investigating"); r.Emission.ShouldBe(EmissionKind.CommentAdded); } [Fact] public void Comments_are_append_only_never_rewritten() { var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State; s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State; s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State; s.Comments.Count.ShouldBe(3); s.Comments[0].User.ShouldBe("alice"); s.Comments[1].User.ShouldBe("bob"); s.Comments[2].User.ShouldBe("carol"); } [Fact] public void Full_lifecycle_walk_produces_every_expected_emission() { // Walk a condition through its whole lifecycle and make sure emissions line up. var emissions = new List(); var s = Fresh(); s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1))); s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2))); s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3))); s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4))); s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5))); emissions.ShouldBe(new[] { EmissionKind.Activated, EmissionKind.Acknowledged, EmissionKind.CommentAdded, EmissionKind.Cleared, EmissionKind.Confirmed, }); AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; } } }