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:
@@ -31,9 +31,10 @@ public class ShelveCommand : CommandBase
|
|||||||
public string Kind { get; init; } = default!;
|
public string Kind { get; init; } = default!;
|
||||||
|
|
||||||
/// <summary>
|
/// <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 > 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>
|
/// </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; }
|
public double DurationSeconds { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -61,9 +62,9 @@ public class ShelveCommand : CommandBase
|
|||||||
var statusCode = await service.ShelveAlarmAsync(NodeId, shelveKind, DurationSeconds, ct);
|
var statusCode = await service.ShelveAlarmAsync(NodeId, shelveKind, DurationSeconds, ct);
|
||||||
|
|
||||||
if (StatusCode.IsGood(statusCode))
|
if (StatusCode.IsGood(statusCode))
|
||||||
await console.Output.WriteLineAsync($"Shelve ({shelveKind}) successful: {NodeId}");
|
await console.Output.WriteLineAsync($"{shelveKind} successful: {NodeId}");
|
||||||
else
|
else
|
||||||
await console.Output.WriteLineAsync($"Shelve ({shelveKind}) failed: {statusCode}");
|
await console.Output.WriteLineAsync($"{shelveKind} failed: {statusCode}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// <param name="conditionNodeId">The condition node associated with the alarm being shelved.</param>
|
/// <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="kind">The shelve operation: <c>OneShot</c>, <c>Timed</c>, or <c>Unshelve</c>.</param>
|
||||||
/// <param name="shelvingTimeSeconds">
|
/// <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 > 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>
|
||||||
/// <param name="ct">The cancellation token that aborts the shelve request.</param>
|
/// <param name="ct">The cancellation token that aborts the shelve request.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
|
|||||||
@@ -417,7 +417,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
|
|
||||||
// The shelve methods live on the ShelvingState child of the condition node.
|
// 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.
|
// 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;
|
NodeId methodId;
|
||||||
object[] inputArgs;
|
object[] inputArgs;
|
||||||
@@ -433,7 +435,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds),
|
throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds),
|
||||||
"Timed shelving requires a positive shelvingTimeSeconds value.");
|
"Timed shelving requires a positive shelvingTimeSeconds value.");
|
||||||
methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve;
|
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;
|
break;
|
||||||
case ShelveKind.Unshelve:
|
case ShelveKind.Unshelve:
|
||||||
methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve;
|
methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve;
|
||||||
|
|||||||
@@ -202,11 +202,18 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Exception? CallMethodException { get; set; }
|
public Exception? CallMethodException { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the input arguments passed to each <see cref="CallMethodAsync"/> invocation.
|
||||||
|
/// Index 0 is the first call, index 1 the second, etc.
|
||||||
|
/// </summary>
|
||||||
|
public List<object[]> CallMethodInputArgs { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
|
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
CallMethodCount++;
|
CallMethodCount++;
|
||||||
|
CallMethodInputArgs.Add(inputArguments);
|
||||||
if (CallMethodException != null)
|
if (CallMethodException != null)
|
||||||
throw CallMethodException;
|
throw CallMethodException;
|
||||||
return Task.FromResult<IList<object>?>(null);
|
return Task.FromResult<IList<object>?>(null);
|
||||||
|
|||||||
@@ -1057,6 +1057,123 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
session.CallMethodCount.ShouldBe(1);
|
session.CallMethodCount.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ConfirmAlarm tests ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a successful confirm call returns <see cref="StatusCodes.Good"/>
|
||||||
|
/// and reaches the session adapter's CallMethodAsync exactly once.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmAlarmAsync_OnSuccess_ReturnsGood()
|
||||||
|
{
|
||||||
|
var session = new FakeSessionAdapter();
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed");
|
||||||
|
|
||||||
|
result.ShouldBe(StatusCodes.Good);
|
||||||
|
session.CallMethodCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmAlarmAsync_OnServiceResultException_ReturnsBadStatusCode()
|
||||||
|
{
|
||||||
|
var session = new FakeSessionAdapter
|
||||||
|
{
|
||||||
|
CallMethodException = new ServiceResultException(
|
||||||
|
StatusCodes.BadConditionAlreadyEnabled, "already confirmed")
|
||||||
|
};
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed");
|
||||||
|
|
||||||
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ShelveAlarm tests ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that OneShot shelving calls the session adapter once with no input arguments.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ShelveAlarmAsync_OneShot_CallsMethodWithNoArgs()
|
||||||
|
{
|
||||||
|
var session = new FakeSessionAdapter();
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot);
|
||||||
|
|
||||||
|
result.ShouldBe(StatusCodes.Good);
|
||||||
|
session.CallMethodCount.ShouldBe(1);
|
||||||
|
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that Timed shelving passes the duration converted to milliseconds (seconds × 1000)
|
||||||
|
/// as the first input argument — regression guard for the Important 1 units bug.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ShelveAlarmAsync_Timed_PassesDurationInMilliseconds()
|
||||||
|
{
|
||||||
|
var session = new FakeSessionAdapter();
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
const double durationSeconds = 30.0;
|
||||||
|
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.Timed, durationSeconds);
|
||||||
|
|
||||||
|
result.ShouldBe(StatusCodes.Good);
|
||||||
|
session.CallMethodCount.ShouldBe(1);
|
||||||
|
session.CallMethodInputArgs[0].Length.ShouldBe(1);
|
||||||
|
session.CallMethodInputArgs[0][0].ShouldBe(durationSeconds * 1000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that Unshelve calls the session adapter once with no input arguments.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ShelveAlarmAsync_Unshelve_CallsMethodWithNoArgs()
|
||||||
|
{
|
||||||
|
var session = new FakeSessionAdapter();
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.Unshelve);
|
||||||
|
|
||||||
|
result.ShouldBe(StatusCodes.Good);
|
||||||
|
session.CallMethodCount.ShouldBe(1);
|
||||||
|
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ShelveAlarmAsync_OnServiceResultException_ReturnsBadStatusCode()
|
||||||
|
{
|
||||||
|
var session = new FakeSessionAdapter
|
||||||
|
{
|
||||||
|
CallMethodException = new ServiceResultException(
|
||||||
|
StatusCodes.BadConditionAlreadyShelved, "already shelved")
|
||||||
|
};
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot);
|
||||||
|
|
||||||
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Alarm fallback path (Client.Shared-011) ---
|
// --- Alarm fallback path (Client.Shared-011) ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user