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

@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <item>Lax-mode fall-through for all four deferred gates</item>
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
/// <item>AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine
/// per-instance NodeId limitation noted in the MapCallOperation implementation)</item>
/// <item>AlarmShelve resolves via the indexed shelve-method NodeId set (Task #24
/// follow-up); an unindexed shelve-shaped NodeId still falls through to Call</item>
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
/// </list>
/// </summary>
@@ -203,29 +203,38 @@ public sealed class DeferredGateHardeningTests
}
// ======================================================================
// 5. AlarmShelve falls through to Call in MapCallOperation
// Documents the ShelvedStateMachine per-instance NodeId limitation.
// 5. AlarmShelve resolution in MapCallOperation (Task #24 follow-up)
// Shelve methods carry per-instance NodeIds, so they resolve to AlarmShelve
// via membership in the indexed shelve-method set rather than a constant match.
// ======================================================================
[Fact]
public void MapCallOperation_AlarmShelve_falls_through_to_Call()
public void MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve()
{
// AlarmShelve methods on ShelvedStateMachine arrive with per-instance NodeIds
// (not well-known type NodeIds), so they can't be reliably constant-matched.
// MapCallOperation returns OpcUaOperation.Call for any unrecognised method NodeId;
// operators who can Shelve must therefore have NodePermissions.MethodCall granted.
// (This is an intentional design decision documented in the MapCallOperation
// implementation remarks — finer-grained AlarmShelve gating is deferred until
// the method-invocation path also carries a "method-role" annotation.)
// The address-space build indexes each scripted alarm's three ShelvedStateMachine
// method NodeIds. A call whose MethodId is in that set gates as AlarmShelve, so
// operators can be granted shelve rights independently of generic MethodCall.
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_unindexed_shelve_method_falls_through_to_Call()
{
// Without the index (e.g. a deployment with no scripted alarms) a shelve-shaped
// NodeId is indistinguishable from a generic driver method and gates as Call.
var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0);
DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call);
}
[Fact]
public void MethodCall_grant_allows_generic_Call_including_shelve_path()
public void MethodCall_grant_allows_generic_Call()
{
// Users with MethodCall permission can invoke shelve methods because the gate
// maps AlarmShelve back to Call (see MapCallOperation_AlarmShelve_falls_through_to_Call).
// Users with MethodCall permission can invoke generic (non-alarm) driver methods.
// Shelve methods now gate as AlarmShelve when indexed (see
// MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve).
var gate = MakeGate(strict: true, rows:
[
Row("grp-eng", NodePermissions.MethodCall),