fix(client-shared): resolve Medium code-review findings (Client.Shared-001, -002, -007, -008)
Client.Shared-001: lowered the OnAlarmEventNotification early-return guard from <6 to <1; per-index field guards already default missing fields safely. Client.Shared-002: GetRedundancyInfoAsync replaces unguarded unboxing casts with StatusCode.IsGood + Convert.ToInt32/ToByte, defaulting on bad reads. Client.Shared-007: alarm fallback Task.Run guards on ReferenceEquals(session, _session) and drops stale alarms on ObjectDisposedException after failover. Client.Shared-008: WriteValueAsync rejects type inference from bad/null reads; ValueConverter wraps parse failures in a descriptive FormatException. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -293,6 +293,46 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writing a string to a node whose current read returns a bad status
|
||||
/// surfaces a clear error instead of writing a mistyped string value (Client.Shared-008).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteValueAsync_StringValueWithBadReadStatus_ThrowsInvalidOperationException()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
||||
|
||||
ex.Message.ShouldContain("Cannot infer target type");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writing a string to a node whose read returns bad status and null Value
|
||||
/// surfaces a clear error for both the bad-status case (Client.Shared-008).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteValueAsync_StringValueWithBadStatus_MessageMentionsNode()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
||||
|
||||
ex.Message.ShouldContain("ns=2;s=MyNode");
|
||||
}
|
||||
|
||||
// --- Browse tests ---
|
||||
|
||||
/// <summary>
|
||||
@@ -842,6 +882,120 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_service.GetRedundancyInfoAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RedundancySupport boxed as a different numeric type (e.g. short) is handled
|
||||
/// without InvalidCastException — defensive Convert.ToInt32 coercion (Client.Shared-002).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetRedundancyInfoAsync_RedundancySupportBoxedAsShort_DoesNotThrow()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponseFunc = nodeId =>
|
||||
{
|
||||
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
|
||||
// Boxed as short instead of int — simulates a non-conforming server
|
||||
return new DataValue(new Variant((short)RedundancySupport.Warm), StatusCodes.Good);
|
||||
if (nodeId == VariableIds.Server_ServiceLevel)
|
||||
return new DataValue(new Variant((int)200), StatusCodes.Good); // int instead of byte
|
||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var info = await _service.GetRedundancyInfoAsync();
|
||||
|
||||
info.Mode.ShouldBe("Warm");
|
||||
info.ServiceLevel.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a bad-status response for RedundancySupport/ServiceLevel falls back to defaults
|
||||
/// rather than throwing (Client.Shared-002).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetRedundancyInfoAsync_BadStatusOnRequiredReads_ReturnsDefaults()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponseFunc = nodeId =>
|
||||
{
|
||||
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
|
||||
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
|
||||
if (nodeId == VariableIds.Server_ServiceLevel)
|
||||
return new DataValue(StatusCodes.BadNotReadable);
|
||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var info = await _service.GetRedundancyInfoAsync();
|
||||
|
||||
info.Mode.ShouldBe("None");
|
||||
info.ServiceLevel.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
// --- Alarm truncated-fields tests (Client.Shared-001) ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an alarm event with fewer than 6 fields (but at least 1) is still raised
|
||||
/// with available fields — the old hard <6 early return silently dropped it (Client.Shared-001).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnAlarmEvent_TruncatedFields_StillRaisesEvent()
|
||||
{
|
||||
var fakeSub = new FakeSubscriptionAdapter();
|
||||
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
AlarmEventArgs? received = null;
|
||||
_service.AlarmEvent += (_, e) => received = e;
|
||||
|
||||
await _service.SubscribeAlarmsAsync();
|
||||
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
// Only 3 fields — EventId, EventType, SourceName — fewer than the old < 6 threshold
|
||||
var fields = new EventFieldList
|
||||
{
|
||||
EventFields =
|
||||
[
|
||||
new Variant(new byte[] { 9, 8, 7 }), // 0: EventId
|
||||
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
|
||||
new Variant("PartialSource") // 2: SourceName
|
||||
]
|
||||
};
|
||||
fakeSub.SimulateEvent(handle, fields);
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received!.SourceName.ShouldBe("PartialSource");
|
||||
received.Severity.ShouldBe((ushort)0); // default — field 5 not present
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a null or empty event field list is silently ignored (defensive guard).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnAlarmEvent_EmptyFields_DoesNotRaiseEvent()
|
||||
{
|
||||
var fakeSub = new FakeSubscriptionAdapter();
|
||||
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var eventCount = 0;
|
||||
_service.AlarmEvent += (_, _) => eventCount++;
|
||||
|
||||
await _service.SubscribeAlarmsAsync();
|
||||
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
fakeSub.SimulateEvent(handle, new EventFieldList { EventFields = [] });
|
||||
|
||||
eventCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// --- Failover tests ---
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user