Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
8.4 KiB
C#
206 lines
8.4 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ArgumentException>(() =>
|
|
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<ArgumentOutOfRangeException>(() =>
|
|
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<EmissionKind>();
|
|
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; }
|
|
}
|
|
}
|