fix(client): TimedShelve milliseconds + shelve node guard + service-layer tests

Important 1: ShelveAlarmAsync Timed branch now multiplies shelvingTimeSeconds × 1000.0
before passing to CallMethodAsync — OPC UA Part 9 TimedShelve ShelvingTime is a Duration
in milliseconds, not seconds. IOpcUaClientService XML doc and ShelveCommand --duration
description updated to document the seconds-in / ms-out contract.

Important 2: ShelveAlarmAsync builds shelvingStateNodeId with the same
EndsWith(".ShelvingState") guard already used by the .Condition suffix in
AcknowledgeAlarmAsync / ConfirmAlarmAsync, preventing double-append.

Important 3: Add 6 service-layer tests to OpcUaClientServiceTests —
  ConfirmAlarmAsync_OnSuccess_ReturnsGood
  ConfirmAlarmAsync_OnServiceResultException_ReturnsBadStatusCode
  ShelveAlarmAsync_OneShot_CallsMethodWithNoArgs
  ShelveAlarmAsync_Timed_PassesDurationInMilliseconds (regression guard for Important 1)
  ShelveAlarmAsync_Unshelve_CallsMethodWithNoArgs
  ShelveAlarmAsync_OnServiceResultException_ReturnsBadStatusCode
FakeSessionAdapter extended with CallMethodInputArgs list to record per-call input
arguments so the Timed test can assert the ms value.

Minor 4: ShelveCommand output changed from "Shelve (OneShot) successful" to
"{shelveKind} successful/failed" so Unshelve reads "Unshelve successful: …".

Minor 6: ShelveCommand --duration description updated to "(must be > 0; in seconds,
converted to milliseconds for the OPC UA call; required for --kind Timed)".
This commit is contained in:
Joseph Doherty
2026-06-11 05:57:40 -04:00
parent 4b38a9d8c8
commit ac5db0a9f8
5 changed files with 137 additions and 7 deletions
@@ -31,9 +31,10 @@ public class ShelveCommand : CommandBase
public string Kind { get; init; } = default!;
/// <summary>
/// Gets the shelving duration in seconds for Timed shelving (ignored for OneShot and Unshelve).
/// Gets the shelving duration in seconds for Timed shelving (must be &gt; 0; ignored for OneShot and Unshelve).
/// The value is passed in seconds by the operator and converted to milliseconds for the OPC UA TimedShelve call.
/// </summary>
[CommandOption("duration", 'd', Description = "Shelving duration in seconds (required for --kind Timed)")]
[CommandOption("duration", 'd', Description = "Shelving duration in seconds (must be > 0; in seconds, converted to milliseconds for the OPC UA call; required for --kind Timed)")]
public double DurationSeconds { get; init; }
/// <summary>
@@ -61,9 +62,9 @@ public class ShelveCommand : CommandBase
var statusCode = await service.ShelveAlarmAsync(NodeId, shelveKind, DurationSeconds, ct);
if (StatusCode.IsGood(statusCode))
await console.Output.WriteLineAsync($"Shelve ({shelveKind}) successful: {NodeId}");
await console.Output.WriteLineAsync($"{shelveKind} successful: {NodeId}");
else
await console.Output.WriteLineAsync($"Shelve ({shelveKind}) failed: {statusCode}");
await console.Output.WriteLineAsync($"{shelveKind} failed: {statusCode}");
}
finally
{
@@ -123,7 +123,9 @@ public interface IOpcUaClientService : IDisposable
/// <param name="conditionNodeId">The condition node associated with the alarm being shelved.</param>
/// <param name="kind">The shelve operation: <c>OneShot</c>, <c>Timed</c>, or <c>Unshelve</c>.</param>
/// <param name="shelvingTimeSeconds">
/// For <c>Timed</c> shelving: the shelving duration in seconds. Ignored for <c>OneShot</c> and <c>Unshelve</c>.
/// For <c>Timed</c> shelving: the shelving duration in seconds (must be &gt; 0).
/// This value is converted to milliseconds when passed to the OPC UA <c>TimedShelve</c> method
/// (<c>Duration</c> is milliseconds per OPC UA Part 9). Ignored for <c>OneShot</c> and <c>Unshelve</c>.
/// </param>
/// <param name="ct">The cancellation token that aborts the shelve request.</param>
/// <returns>
@@ -417,7 +417,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
// The shelve methods live on the ShelvingState child of the condition node.
// conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node.
var shelvingStateNodeId = NodeId.Parse(conditionNodeId + ".ShelvingState");
var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".ShelvingState");
NodeId methodId;
object[] inputArgs;
@@ -433,7 +435,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds),
"Timed shelving requires a positive shelvingTimeSeconds value.");
methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve;
inputArgs = [shelvingTimeSeconds];
// OPC UA Part 9 TimedShelve ShelvingTime input is a Duration (Double) in milliseconds.
inputArgs = [shelvingTimeSeconds * 1000.0];
break;
case ShelveKind.Unshelve:
methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve;