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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user