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

@@ -41,6 +41,69 @@ public sealed class CallGatingTests
.ShouldBe(OpcUaOperation.Call);
}
[Fact]
public void MapCallOperation_shelve_method_in_index_maps_to_AlarmShelve()
{
// Shelve methods carry per-instance NodeIds; membership in the indexed set
// (built during the address-space build) is how they resolve to AlarmShelve.
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
var index = new HashSet<NodeId> { shelveMethodId };
DriverNodeManager.MapCallOperation(shelveMethodId, index)
.ShouldBe(OpcUaOperation.AlarmShelve);
}
[Fact]
public void MapCallOperation_shelve_method_not_in_index_falls_through_to_Call()
{
// A shelve-shaped NodeId that wasn't indexed (e.g. no scripted alarms) is
// indistinguishable from a generic method node and gates as Call.
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
DriverNodeManager.MapCallOperation(shelveMethodId, new HashSet<NodeId>())
.ShouldBe(OpcUaOperation.Call);
DriverNodeManager.MapCallOperation(shelveMethodId, shelveMethodIds: null)
.ShouldBe(OpcUaOperation.Call);
}
[Fact]
public void Denied_shelve_call_gets_BadUserAccessDenied()
{
var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2);
var calls = new List<CallMethodRequest>
{
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
};
var errors = new List<ServiceResult> { (ServiceResult)null! };
// Operator has AlarmAcknowledge but NOT AlarmShelve — shelve must be denied.
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]);
DriverNodeManager.GateCallMethodRequests(
calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"),
shelveMethodIds: new HashSet<NodeId> { shelveMethodId });
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
}
[Fact]
public void Allowed_shelve_call_passes_through()
{
var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2);
var calls = new List<CallMethodRequest>
{
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
};
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: true, rows: [Row("grp-eng", NodePermissions.AlarmShelve)]);
DriverNodeManager.GateCallMethodRequests(
calls, errors, NewIdentity("alice", "grp-eng"), gate, new NodeScopeResolver("c1"),
shelveMethodIds: new HashSet<NodeId> { shelveMethodId });
errors[0].ShouldBeNull("AlarmShelve grant allows the shelve call");
}
[Fact]
public void Gate_null_leaves_errors_untouched()
{