feat(server): route OPC UA Part 9 shelve methods to ScriptedAlarmEngine (#24)

OneShotShelve / TimedShelve / Unshelve now reach the ScriptedAlarmEngine.
Scripted-alarm condition nodes get a ShelvedStateMachine subtree created
before alarm.Create so the stack wires each shelve method's dispatch
handler; AlarmConditionState.OnShelve / OnTimedUnshelve route to the
engine and mirror the result onto the OPC UA node via SetShelvingState.

The three per-instance shelve method NodeIds are indexed so the Call gate
resolves them to OpcUaOperation.AlarmShelve instead of falling through to
generic Call. Engine dispatch is split into the node-free InvokeEngineShelve
so the routing decision is unit-testable.

Adds 9 unit tests; updates phase-7-status.md Gap 1 (only AddComment remains
unwired) and the #24 entry in looseends.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 09:31:30 -04:00
parent 56bb1ceaf5
commit c5915700bd
6 changed files with 421 additions and 33 deletions

View File

@@ -410,6 +410,76 @@ public sealed class ScriptedAlarmMethodRoutingTests
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
}
// ---- Shelve routing (Task #24 follow-up) -------------------------------
[Fact]
public void InvokeEngineShelve_oneshot_shelves_engine_state()
{
using var engine = BuildEngine("al-1");
var result = DriverNodeManager.InvokeEngineShelve(
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
ServiceResult.IsBad(result).ShouldBeFalse("OneShotShelve succeeds");
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
}
[Fact]
public void InvokeEngineShelve_timed_shelves_engine_state()
{
using var engine = BuildEngine("al-1");
// shelvingTime is a Duration in ms — InvokeEngineShelve adds it to UtcNow.
var result = DriverNodeManager.InvokeEngineShelve(
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 60_000, logger: null);
ServiceResult.IsBad(result).ShouldBeFalse("TimedShelve succeeds");
var state = engine.GetState("al-1")!;
state.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
state.Shelving.UnshelveAtUtc.ShouldNotBeNull();
}
[Fact]
public void InvokeEngineShelve_unshelve_clears_engine_state()
{
using var engine = BuildEngine("al-1");
DriverNodeManager.InvokeEngineShelve(
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
var result = DriverNodeManager.InvokeEngineShelve(
engine, "al-1", "ops-user", shelving: false, oneShot: false, shelvingTime: 0, logger: null);
ServiceResult.IsBad(result).ShouldBeFalse("Unshelve succeeds");
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
}
[Fact]
public void InvokeEngineShelve_timed_with_non_positive_duration_returns_BadInvalidArgument()
{
using var engine = BuildEngine("al-1");
// A TimedShelve resolving to an unshelve time at-or-before now is rejected by the
// engine's Part 9 state machine (ArgumentOutOfRangeException → BadInvalidArgument).
var result = DriverNodeManager.InvokeEngineShelve(
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 0, logger: null);
ServiceResult.IsBad(result).ShouldBeTrue();
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
}
[Fact]
public void InvokeEngineShelve_unknown_alarm_returns_BadInvalidArgument()
{
using var engine = BuildEngine("al-1");
var result = DriverNodeManager.InvokeEngineShelve(
engine, "not-an-alarm", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
ServiceResult.IsBad(result).ShouldBeTrue("unknown alarm id → error result");
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
}
// ---- Phase7ComposedSources helpers -------------------------------------
private static Script ScriptRow(string id, string source) => new()