fix(client-cli): call Part 9 ack/confirm/enable/shelve methods on the condition node itself

The client appended .Condition / .ShelvingState to the condition id; neither child exists on the
OtOpcUa server (conditions are their own SourceNode, ShelvingState is an SDK numeric ns0 node), so
every CLI ack/confirm/shelve/enable/disable returned BadNodeIdUnknown.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 00:57:06 -04:00
parent 15dd75379d
commit fa7efb3b62
3 changed files with 29 additions and 49 deletions
@@ -1040,21 +1040,22 @@ public class OpcUaClientServiceTests : IDisposable
}
/// <summary>
/// Verifies the ".Condition" suffix is appended when the caller supplies the
/// source node, but left alone when the caller already passes the condition node —
/// matches the documented contract.
/// Regression: OtOpcUa conditions are their own event source, so Acknowledge/Confirm must be invoked on
/// the supplied condition node itself — no ".Condition" child exists on the server to append.
/// </summary>
[Fact]
public async Task AcknowledgeAlarmAsync_LeavesConditionSuffixAlone()
public async Task AcknowledgeAndConfirm_InvokeMethodsOnTheConditionNodeItself()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.AcknowledgeAlarmAsync("ns=2;s=Cond.Condition", new byte[] { 1, 2 }, "acked");
await _service.AcknowledgeAlarmAsync("ns=2;s=Plant/Alarm1", new byte[] { 1, 2 }, "acked");
await _service.ConfirmAlarmAsync("ns=2;s=Plant/Alarm1", new byte[] { 1, 2 }, "confirmed");
// Both call shapes reach the adapter once.
session.CallMethodCount.ShouldBe(1);
session.CallMethodObjectIds.ShouldAllBe(id => id == NodeId.Parse("ns=2;s=Plant/Alarm1"));
session.CallMethodMethodIds[0].ShouldBe(MethodIds.AcknowledgeableConditionType_Acknowledge);
session.CallMethodMethodIds[1].ShouldBe(MethodIds.AcknowledgeableConditionType_Confirm);
}
// --- ConfirmAlarm tests ---
@@ -1113,6 +1114,8 @@ public class OpcUaClientServiceTests : IDisposable
result.ShouldBe(StatusCodes.Good);
session.CallMethodCount.ShouldBe(1);
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
session.CallMethodMethodIds[0].ShouldBe(MethodIds.AlarmConditionType_ShelvingState_OneShotShelve);
session.CallMethodInputArgs[0].ShouldBeEmpty();
}
@@ -1150,6 +1153,8 @@ public class OpcUaClientServiceTests : IDisposable
result.ShouldBe(StatusCodes.Good);
session.CallMethodCount.ShouldBe(1);
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
session.CallMethodMethodIds[0].ShouldBe(MethodIds.AlarmConditionType_ShelvingState_Unshelve);
session.CallMethodInputArgs[0].ShouldBeEmpty();
}
@@ -1191,7 +1196,7 @@ public class OpcUaClientServiceTests : IDisposable
result.ShouldBe(StatusCodes.Good);
session.CallMethodCount.ShouldBe(1);
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond.Condition"));
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
session.CallMethodMethodIds[0].ShouldBe(MethodIds.ConditionType_Enable);
session.CallMethodInputArgs[0].ShouldBeEmpty();
}
@@ -1211,31 +1216,11 @@ public class OpcUaClientServiceTests : IDisposable
result.ShouldBe(StatusCodes.Good);
session.CallMethodCount.ShouldBe(1);
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond.Condition"));
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
session.CallMethodMethodIds[0].ShouldBe(MethodIds.ConditionType_Disable);
session.CallMethodInputArgs[0].ShouldBeEmpty();
}
/// <summary>
/// Verifies the ".Condition" suffix is appended when the caller supplies the source node,
/// but left alone when the caller already passes the condition node — mirrors
/// <see cref="AcknowledgeAlarmAsync_LeavesConditionSuffixAlone"/> for the Enable path so the
/// object-node derivation in CallEnableDisableAsync is regression-covered.
/// </summary>
[Fact]
public async Task EnableAsync_WhenConditionSuffixAlreadyPresent_DoesNotDoubleSuffix()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var result = await _service.EnableAsync("ns=2;s=Cond.Condition");
result.ShouldBe(StatusCodes.Good);
session.CallMethodCount.ShouldBe(1);
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond.Condition"));
}
/// <summary>
/// Verifies that a <see cref="ServiceResultException"/> from the session is captured and
/// returned as a bad <see cref="StatusCode"/> rather than propagating to the caller.