fix(client-shared): resolve Low code-review findings (Client.Shared-003,004,009,010,011)
- Client.Shared-003: DefaultSessionAdapter.WriteValueAsync / CallMethodAsync guard against null/empty Results and throw ServiceResultException with the response's ServiceResult code instead of indexing into a missing list. - Client.Shared-004: DefaultSessionAdapter.CloseAsync / HistoryReadRawAsync / HistoryReadAggregateAsync use the Session.*Async overloads and honour the caller's CancellationToken. - Client.Shared-009: AcknowledgeAlarmAsync returns the underlying ServiceResultException.StatusCode on failure instead of always Good; IOpcUaClientService doc updated to describe the new contract. - Client.Shared-010: ConnectionSettings.CertificateStorePath defaults to empty; DefaultApplicationConfigurationFactory resolves the canonical PKI path lazily, so per-failover ConnectionSettings copies don't hit the filesystem. - Client.Shared-011: added the alarm-fallback regression test, extracted EndpointSelector as a pure static, and added EndpointSelectorTests covering security-mode match, Basic256Sha256 preference, fallback, diagnostics, hostname rewrite, and null/empty guards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -996,6 +996,143 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
eventCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// --- AcknowledgeAlarm tests (Client.Shared-009) ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a successful acknowledge call returns <see cref="StatusCodes.Good"/>
|
||||
/// and reaches the session adapter's CallMethodAsync (Client.Shared-009).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_OnSuccess_ReturnsGood()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked");
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-009: a bad call result must surface as the returned
|
||||
/// <see cref="StatusCode"/> rather than escape as an uncaught
|
||||
/// <see cref="ServiceResultException"/>, so callers using
|
||||
/// <c>if (StatusCode.IsBad(result))</c> actually see the failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_OnServiceResultException_ReturnsBadStatusCode()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
CallMethodException = new ServiceResultException(
|
||||
StatusCodes.BadConditionAlreadyEnabled, "already acked")
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked");
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_LeavesConditionSuffixAlone()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
await _service.AcknowledgeAlarmAsync("ns=2;s=Cond.Condition", new byte[] { 1, 2 }, "acked");
|
||||
|
||||
// Both call shapes reach the adapter once.
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// --- Alarm fallback path (Client.Shared-011) ---
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-011: when standard AckedState/Id and ActiveState/Id
|
||||
/// fields are missing (null Value) but the SourceNode (ConditionId) field at index 12
|
||||
/// is populated, the client launches the Task.Run fallback that reads
|
||||
/// <c>InAlarm</c>/<c>Acked</c> from the condition node's Galaxy attributes. Verify
|
||||
/// the alarm event is delivered with the values from the supplemental reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnAlarmEvent_MissingAckedActiveButHasConditionNode_FallbackReadsAndRaisesEvent()
|
||||
{
|
||||
var fakeSub = new FakeSubscriptionAdapter();
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
NextSubscription = fakeSub,
|
||||
ReadResponseFunc = nodeId =>
|
||||
{
|
||||
var key = nodeId.ToString();
|
||||
if (key.EndsWith(".InAlarm"))
|
||||
return new DataValue(new Variant(true), StatusCodes.Good);
|
||||
if (key.EndsWith(".Acked"))
|
||||
return new DataValue(new Variant(false), StatusCodes.Good);
|
||||
if (key.EndsWith(".TimeAlarmOn"))
|
||||
return new DataValue(new Variant(new DateTime(2026, 1, 1, 12, 0, 0)), StatusCodes.Good);
|
||||
if (key.EndsWith(".DescAttrName"))
|
||||
return new DataValue(new Variant("Fallback message"), StatusCodes.Good);
|
||||
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
AlarmEventArgs? received = null;
|
||||
var raised = new TaskCompletionSource();
|
||||
_service.AlarmEvent += (_, e) =>
|
||||
{
|
||||
received = e;
|
||||
raised.TrySetResult();
|
||||
};
|
||||
|
||||
await _service.SubscribeAlarmsAsync();
|
||||
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
// AckedState/Id (8) and ActiveState/Id (9) are present but Variant.Value is null,
|
||||
// which triggers the supplemental Galaxy-attribute fallback; SourceNode (12) is set.
|
||||
var fields = new EventFieldList
|
||||
{
|
||||
EventFields =
|
||||
[
|
||||
new Variant(new byte[] { 1, 2, 3 }), // 0: EventId
|
||||
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
|
||||
new Variant("Source1"), // 2: SourceName
|
||||
new Variant(DateTime.MinValue), // 3: Time
|
||||
new Variant(new LocalizedText("Initial")), // 4: Message
|
||||
new Variant((ushort)400), // 5: Severity
|
||||
new Variant("CondName"), // 6: ConditionName
|
||||
new Variant(true), // 7: Retain
|
||||
Variant.Null, // 8: AckedState/Id — missing
|
||||
Variant.Null, // 9: ActiveState/Id — missing
|
||||
new Variant(true), // 10: EnabledState/Id
|
||||
new Variant(false), // 11: SuppressedOrShelved
|
||||
new Variant("ns=2;s=ConditionId") // 12: SourceNode
|
||||
]
|
||||
};
|
||||
fakeSub.SimulateEvent(handle, fields);
|
||||
|
||||
// The fallback runs on a background Task.Run continuation — wait briefly for it.
|
||||
await Task.WhenAny(raised.Task, Task.Delay(500));
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received!.ActiveState.ShouldBeTrue(); // from InAlarm read
|
||||
received.AckedState.ShouldBeFalse(); // from Acked read
|
||||
received.ConditionNodeId.ShouldBe("ns=2;s=ConditionId");
|
||||
received.Message.ShouldBe("Fallback message"); // from DescAttrName read
|
||||
}
|
||||
|
||||
// --- Failover tests ---
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user