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:
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 9 |
|
||||
| Open findings | 5 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `OpcUaClientService.cs:552` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `OnAlarmEventNotification` returns early when `eventFields.EventFields` has fewer than 6 entries. The event filter built by `CreateAlarmEventFilter` always registers 13 select clauses, so a conforming server returns 13 fields. The `< 6` threshold is arbitrary and inconsistent: SourceName is index 2 and Severity index 5, but ConditionName (6), Retain (7), Acked/Active (8/9) and ConditionNodeId (12) are all needed for a usable alarm and are each guarded individually with `fields.Count > N`. A non-conforming server that returns a truncated list (or fewer fields than requested) makes the `< 6` early return silently drop the entire notification, including the EventId/SourceName/Severity that are present.
|
||||
|
||||
**Recommendation:** Drop the `< 6` early return (or lower it to `< 1`) and rely on the existing per-index `fields.Count > N` guards, which already default missing fields safely. If a hard floor is wanted, document why 6 and not 13.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — lowered the early-return threshold to `< 1` (null or empty guard only); per-index `fields.Count > N` guards already default missing fields safely for all higher indices.
|
||||
|
||||
### Client.Shared-002
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `OpcUaClientService.cs:351-355`, `OpcUaClientService.cs:373` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `GetRedundancyInfoAsync` performs unguarded unboxing casts on values read from the server: `(int)redundancySupportValue.Value` and `(byte)serviceLevelValue.Value`. Unlike the `ServerUriArray`/`ServerArray` reads below them, the `RedundancySupport` and `ServiceLevel` reads are not wrapped in try/catch. If the server returns the value boxed as a different numeric type than expected (e.g. `ServiceLevel` boxed as `int` instead of `byte`), or returns a null `Value` on a `Bad` DataValue, the cast throws `InvalidCastException`/`NullReferenceException` and the whole call fails instead of returning a sensible default.
|
||||
|
||||
**Recommendation:** Wrap the `RedundancySupport` and `ServiceLevel` reads in the same defensive pattern used for the array reads, using `Convert.ToInt32`/`Convert.ToByte` on the boxed value and falling back to `None`/`0` when the read status is bad or the value is null.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — replaced direct casts with `StatusCode.IsGood` guard + `Convert.ToInt32`/`Convert.ToByte` coercion; falls back to `None`/`0` when status is bad or value is null.
|
||||
|
||||
### Client.Shared-003
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `OpcUaClientService.cs:581-622` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** In the alarm fallback path, the `Task.Run` closure mutates the captured locals `activeState`, `ackedState`, `time`, and `capturedMessage`, then reads them when invoking `AlarmEvent`. Because the captured `_session` reference can be replaced by a concurrent failover (see Client.Shared-006), the supplemental `ReadValueAsync` calls may run against a session being disposed, throwing `ObjectDisposedException` — caught by the bare `catch`, after which the alarm is delivered with default (false/MinValue) states, silently misreporting it as inactive/unacknowledged. The notification callback also has no back-pressure: a burst of alarm events spawns an unbounded number of `Task.Run` continuations each doing 3-4 server round-trips.
|
||||
|
||||
**Recommendation:** Capture the session under the same lock proposed in Client.Shared-005 and skip the supplemental read if the session has changed or is disposed. Consider batching the four sequential `ReadValueAsync` calls into one `Read` request.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — added a `ReferenceEquals(session, _session)` guard at the top of the `Task.Run` body to skip reads if the session was replaced by failover; separated `ObjectDisposedException` from the general catch to drop rather than deliver the stale alarm.
|
||||
|
||||
### Client.Shared-008
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `OpcUaClientService.cs:170-180`, `Helpers/ValueConverter.cs:15-31` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `WriteValueAsync` coerces a string input to the target type by reading the node's current value and inferring the type from `currentDataValue.Value`. When the node has never been written, or the read returns a `Bad` status with a null `Value`, `ValueConverter.ConvertValue` falls through to the `_ => rawValue` default and writes a raw `string` into, for example, an `Int32` node — the server then rejects it with `BadTypeMismatch`, surfacing as a confusing failure unrelated to the operator's input. Separately, `ConvertValue` uses `bool.Parse`, which accepts only `true`/`false` — operator input of `1`/`0` throws `FormatException` that propagates raw to the caller. The read-before-write also doubles the round-trip cost of every string write.
|
||||
|
||||
**Recommendation:** Inspect `currentDataValue.StatusCode` before trusting `Value`; when the type cannot be inferred, surface a clear error rather than writing a mistyped value. Make boolean parsing accept `1`/`0`/`yes`/`no`, and wrap parse failures in a descriptive exception naming the node and target type.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `WriteValueAsync` now checks `StatusCode.IsGood` and non-null `Value` before calling `ConvertValue`, throwing a descriptive `InvalidOperationException` on bad reads; `ValueConverter` now uses a `ParseBool` helper accepting `true/false/1/0/yes/no` (case-insensitive) and wraps all parse/overflow failures in a `FormatException` with the target type and input value in the message.
|
||||
|
||||
### Client.Shared-009
|
||||
|
||||
|
||||
@@ -10,23 +10,53 @@ public static class ValueConverter
|
||||
/// Converts a raw string value into the runtime type expected by the target node.
|
||||
/// </summary>
|
||||
/// <param name="rawValue">The raw string supplied by the user.</param>
|
||||
/// <param name="currentValue">The current node value used to infer the target type. May be null.</param>
|
||||
/// <param name="currentValue">
|
||||
/// The current node value used to infer the target type. When <c>null</c> the raw string
|
||||
/// is returned unchanged; callers should validate this case before writing.
|
||||
/// </param>
|
||||
/// <returns>A typed value suitable for an OPC UA write request.</returns>
|
||||
/// <exception cref="FormatException">
|
||||
/// Thrown with a descriptive message when <paramref name="rawValue"/> cannot be parsed
|
||||
/// into the type inferred from <paramref name="currentValue"/>.
|
||||
/// </exception>
|
||||
public static object ConvertValue(string rawValue, object? currentValue)
|
||||
{
|
||||
return currentValue switch
|
||||
try
|
||||
{
|
||||
bool => bool.Parse(rawValue),
|
||||
byte => byte.Parse(rawValue),
|
||||
short => short.Parse(rawValue),
|
||||
ushort => ushort.Parse(rawValue),
|
||||
int => int.Parse(rawValue),
|
||||
uint => uint.Parse(rawValue),
|
||||
long => long.Parse(rawValue),
|
||||
ulong => ulong.Parse(rawValue),
|
||||
float => float.Parse(rawValue),
|
||||
double => double.Parse(rawValue),
|
||||
_ => rawValue
|
||||
return currentValue switch
|
||||
{
|
||||
bool => ParseBool(rawValue),
|
||||
byte => byte.Parse(rawValue),
|
||||
short => short.Parse(rawValue),
|
||||
ushort => ushort.Parse(rawValue),
|
||||
int => int.Parse(rawValue),
|
||||
uint => uint.Parse(rawValue),
|
||||
long => long.Parse(rawValue),
|
||||
ulong => ulong.Parse(rawValue),
|
||||
float => float.Parse(rawValue),
|
||||
double => double.Parse(rawValue),
|
||||
_ => rawValue
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||
{
|
||||
var targetType = currentValue?.GetType().Name ?? "unknown";
|
||||
throw new FormatException(
|
||||
$"Cannot convert value \"{rawValue}\" to target type {targetType}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a boolean from common string representations including numeric and word forms.
|
||||
/// Accepts: <c>true</c>/<c>false</c>, <c>1</c>/<c>0</c>, <c>yes</c>/<c>no</c> (case-insensitive).
|
||||
/// </summary>
|
||||
private static bool ParseBool(string rawValue)
|
||||
{
|
||||
return rawValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "yes" => true,
|
||||
"false" or "0" or "no" => false,
|
||||
_ => throw new FormatException($"String '{rawValue}' was not recognized as a valid Boolean.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
if (value is string rawString)
|
||||
{
|
||||
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
|
||||
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
|
||||
}
|
||||
|
||||
@@ -388,10 +391,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
var redundancySupportValue =
|
||||
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
|
||||
var redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString();
|
||||
RedundancySupport redundancySupport;
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
|
||||
else
|
||||
redundancySupport = RedundancySupport.None;
|
||||
var redundancyMode = redundancySupport.ToString();
|
||||
|
||||
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
||||
var serviceLevel = (byte)serviceLevelValue.Value;
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
|
||||
? Convert.ToByte(serviceLevelValue.Value)
|
||||
: (byte)0;
|
||||
|
||||
string[] serverUris = [];
|
||||
try
|
||||
@@ -627,7 +637,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
private void OnAlarmEventNotification(EventFieldList eventFields)
|
||||
{
|
||||
var fields = eventFields.EventFields;
|
||||
if (fields == null || fields.Count < 6)
|
||||
if (fields == null || fields.Count < 1)
|
||||
return;
|
||||
|
||||
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
|
||||
@@ -656,6 +666,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
// Fallback: read InAlarm/Acked from condition node Galaxy attributes
|
||||
// when the server doesn't populate standard event fields.
|
||||
// Must run on a background thread to avoid deadlocking the notification thread.
|
||||
// Capture the session reference now; skip supplemental reads if the session has
|
||||
// been replaced by a concurrent failover before the Task.Run body executes.
|
||||
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
|
||||
{
|
||||
var session = _session;
|
||||
@@ -663,6 +675,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var capturedMessage = message;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
// If the session was replaced by a failover before we started reading,
|
||||
// skip the supplemental reads to avoid hitting a disposed session.
|
||||
if (!ReferenceEquals(session, _session))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
|
||||
@@ -687,9 +704,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
catch { /* DescAttrName may not exist */ }
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Session was disposed during supplemental reads (concurrent failover);
|
||||
// skip the event rather than delivering stale/default states.
|
||||
Logger.Debug("Supplemental alarm read skipped — session disposed during failover for {ConditionNodeId}", capturedConditionNodeId);
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Supplemental read failed; use defaults
|
||||
// Other supplemental read failure; deliver event with defaults
|
||||
}
|
||||
|
||||
AlarmEvent?.Invoke(this, new AlarmEventArgs(
|
||||
|
||||
@@ -97,14 +97,43 @@ public class ValueConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidInt_Throws()
|
||||
public void ConvertValue_InvalidInt_ThrowsWithDescription()
|
||||
{
|
||||
Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notanint", 0));
|
||||
var ex = Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notanint", 0));
|
||||
ex.Message.ShouldContain("Int32");
|
||||
ex.Message.ShouldContain("notanint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Overflow_Throws()
|
||||
public void ConvertValue_Overflow_ThrowsFormatException()
|
||||
{
|
||||
Should.Throw<OverflowException>(() => ValueConverter.ConvertValue("256", (byte)0));
|
||||
// OverflowException is now wrapped in a descriptive FormatException
|
||||
var ex = Should.Throw<FormatException>(() => ValueConverter.ConvertValue("256", (byte)0));
|
||||
ex.InnerException.ShouldBeOfType<OverflowException>();
|
||||
}
|
||||
|
||||
// --- Client.Shared-008: Boolean aliases ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("yes", true)]
|
||||
[InlineData("no", false)]
|
||||
[InlineData("YES", true)]
|
||||
[InlineData("NO", false)]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("True", true)]
|
||||
[InlineData("False", false)]
|
||||
public void ConvertValue_Bool_AcceptsNumericAndWordAliases(string input, bool expected)
|
||||
{
|
||||
ValueConverter.ConvertValue(input, true).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidBool_ThrowsDescriptiveFormatException()
|
||||
{
|
||||
var ex = Should.Throw<FormatException>(() => ValueConverter.ConvertValue("maybe", false));
|
||||
ex.Message.ShouldContain("Boolean");
|
||||
}
|
||||
}
|
||||
@@ -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