Files
Joseph Doherty ac5db0a9f8 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)".
2026-06-11 05:57:40 -04:00

1454 lines
53 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests;
/// <summary>
/// Verifies the shared OPC UA client service behaviors for connection management, browsing, subscriptions, history, alarms, and redundancy.
/// </summary>
public class OpcUaClientServiceTests : IDisposable
{
private readonly FakeApplicationConfigurationFactory _configFactory = new();
private readonly FakeEndpointDiscovery _endpointDiscovery = new();
private readonly OpcUaClientService _service;
private readonly FakeSessionFactory _sessionFactory = new();
/// <summary>Initializes a new test instance with fake dependencies.</summary>
public OpcUaClientServiceTests()
{
_service = new OpcUaClientService(_configFactory, _endpointDiscovery, _sessionFactory);
}
/// <summary>
/// Releases the shared client service after each test so session and subscription state do not leak between scenarios.
/// </summary>
public void Dispose()
{
_service.Dispose();
}
private ConnectionSettings ValidSettings(string url = "opc.tcp://localhost:4840")
{
return new ConnectionSettings
{
EndpointUrl = url,
SessionTimeoutSeconds = 60
};
}
// --- Connection tests ---
/// <summary>
/// Verifies that a valid connection request returns populated connection metadata and marks the client as connected.
/// </summary>
[Fact]
public async Task ConnectAsync_ValidSettings_ReturnsConnectionInfo()
{
var info = await _service.ConnectAsync(ValidSettings());
info.ShouldNotBeNull();
info.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
_service.IsConnected.ShouldBeTrue();
_service.CurrentConnectionInfo.ShouldBe(info);
}
/// <summary>
/// Verifies that invalid connection settings fail validation before any OPC UA session is created.
/// </summary>
[Fact]
public async Task ConnectAsync_InvalidSettings_ThrowsBeforeCreatingSession()
{
var settings = new ConnectionSettings { EndpointUrl = "" };
await Should.ThrowAsync<ArgumentException>(() => _service.ConnectAsync(settings));
_sessionFactory.CreateCallCount.ShouldBe(0);
_service.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that server and security details from the session are copied into the exposed connection info.
/// </summary>
[Fact]
public async Task ConnectAsync_PopulatesConnectionInfo()
{
var session = new FakeSessionAdapter
{
ServerName = "MyServer",
SecurityMode = "Sign",
SecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256",
SessionId = "ns=0;i=999",
SessionName = "TestSession"
};
_sessionFactory.EnqueueSession(session);
var info = await _service.ConnectAsync(ValidSettings());
info.ServerName.ShouldBe("MyServer");
info.SecurityMode.ShouldBe("Sign");
info.SecurityPolicyUri.ShouldBe("http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256");
info.SessionId.ShouldBe("ns=0;i=999");
info.SessionName.ShouldBe("TestSession");
}
/// <summary>
/// Verifies that connection-state transitions are raised for the connecting and connected phases.
/// </summary>
[Fact]
public async Task ConnectAsync_RaisesConnectionStateChangedEvents()
{
var events = new List<ConnectionStateChangedEventArgs>();
_service.ConnectionStateChanged += (_, e) => events.Add(e);
await _service.ConnectAsync(ValidSettings());
events.Count.ShouldBe(2);
events[0].OldState.ShouldBe(ConnectionState.Disconnected);
events[0].NewState.ShouldBe(ConnectionState.Connecting);
events[1].OldState.ShouldBe(ConnectionState.Connecting);
events[1].NewState.ShouldBe(ConnectionState.Connected);
}
/// <summary>
/// Verifies that a failed session creation leaves the client in the disconnected state.
/// </summary>
[Fact]
public async Task ConnectAsync_SessionFactoryFails_TransitionsToDisconnected()
{
_sessionFactory.ThrowOnCreate = true;
var events = new List<ConnectionStateChangedEventArgs>();
_service.ConnectionStateChanged += (_, e) => events.Add(e);
await Should.ThrowAsync<InvalidOperationException>(() => _service.ConnectAsync(ValidSettings()));
_service.IsConnected.ShouldBeFalse();
events.Last().NewState.ShouldBe(ConnectionState.Disconnected);
}
/// <summary>
/// Verifies that username and password settings are passed through to the session-creation pipeline.
/// </summary>
[Fact]
public async Task ConnectAsync_WithUsername_PassesThroughToFactory()
{
var settings = ValidSettings();
settings.Username = "admin";
settings.Password = "secret";
await _service.ConnectAsync(settings);
_configFactory.LastSettings!.Username.ShouldBe("admin");
_configFactory.LastSettings!.Password.ShouldBe("secret");
}
// --- Disconnect tests ---
/// <summary>
/// Verifies that disconnect closes the active session and clears exposed connection state.
/// </summary>
[Fact]
public async Task DisconnectAsync_WhenConnected_ClosesSession()
{
await _service.ConnectAsync(ValidSettings());
var session = _sessionFactory.CreatedSessions[0];
await _service.DisconnectAsync();
session.Closed.ShouldBeTrue();
_service.IsConnected.ShouldBeFalse();
_service.CurrentConnectionInfo.ShouldBeNull();
}
/// <summary>
/// Verifies that disconnect is safe to call when no server session is active.
/// </summary>
[Fact]
public async Task DisconnectAsync_WhenNotConnected_IsIdempotent()
{
await _service.DisconnectAsync(); // Should not throw
_service.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that repeated disconnect calls do not throw after cleanup has already run.
/// </summary>
[Fact]
public async Task DisconnectAsync_CalledTwice_IsIdempotent()
{
await _service.ConnectAsync(ValidSettings());
await _service.DisconnectAsync();
await _service.DisconnectAsync(); // Should not throw
}
// --- Read tests ---
/// <summary>
/// Verifies that a connected client can read the current value of a node through the session adapter.
/// </summary>
[Fact]
public async Task ReadValueAsync_WhenConnected_ReturnsValue()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(new Variant(42), StatusCodes.Good)
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"));
result.Value.ShouldBe(42);
session.ReadCount.ShouldBe(1);
}
/// <summary>
/// Verifies that reads are rejected when the client is not connected to a server.
/// </summary>
[Fact]
public async Task ReadValueAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
}
/// <summary>
/// Verifies that session-level read failures are surfaced to callers instead of being swallowed.
/// </summary>
[Fact]
public async Task ReadValueAsync_SessionThrows_PropagatesException()
{
var session = new FakeSessionAdapter { ThrowOnRead = true };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await Should.ThrowAsync<ServiceResultException>(() =>
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
}
// --- Write tests ---
/// <summary>
/// Verifies that writes succeed through the session adapter when the client is connected.
/// </summary>
[Fact]
public async Task WriteValueAsync_WhenConnected_WritesValue()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(new Variant(0), StatusCodes.Good),
WriteResponse = StatusCodes.Good
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42);
result.ShouldBe(StatusCodes.Good);
session.WriteCount.ShouldBe(1);
}
/// <summary>
/// Verifies that string inputs are coerced to the node's current data type before writing.
/// </summary>
[Fact]
public async Task WriteValueAsync_StringValue_CoercesToTargetType()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(new Variant(0), StatusCodes.Good) // int type
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42");
session.WriteCount.ShouldBe(1);
session.ReadCount.ShouldBe(1); // Read for type inference
}
/// <summary>
/// Verifies that non-string values are written directly without an extra type-inference read.
/// </summary>
[Fact]
public async Task WriteValueAsync_NonStringValue_WritesDirectly()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42);
session.WriteCount.ShouldBe(1);
session.ReadCount.ShouldBe(0); // No read for non-string values
}
/// <summary>
/// Verifies that writes are rejected when the client is disconnected.
/// </summary>
[Fact]
public async Task WriteValueAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_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>
/// Verifies that browse results are mapped into the client browse model used by CLI and UI consumers.
/// </summary>
[Fact]
public async Task BrowseAsync_WhenConnected_ReturnsMappedResults()
{
var session = new FakeSessionAdapter
{
BrowseResponse =
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId("ns=2;s=Child1"),
DisplayName = new LocalizedText("Child1"),
NodeClass = NodeClass.Variable
}
]
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var results = await _service.BrowseAsync();
results.Count.ShouldBe(1);
results[0].DisplayName.ShouldBe("Child1");
results[0].NodeClass.ShouldBe("Variable");
results[0].HasChildren.ShouldBeFalse(); // Variable nodes don't check HasChildren
}
/// <summary>
/// Verifies that a null browse root defaults to the OPC UA Objects folder.
/// </summary>
[Fact]
public async Task BrowseAsync_NullParent_UsesObjectsFolder()
{
var session = new FakeSessionAdapter
{
BrowseResponse = []
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.BrowseAsync();
session.BrowseCount.ShouldBe(1);
}
/// <summary>
/// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches.
/// </summary>
[Fact]
public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
{
var session = new FakeSessionAdapter
{
BrowseResponse =
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
DisplayName = new LocalizedText("Folder1"),
NodeClass = NodeClass.Object
}
],
HasChildrenResponse = true
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var results = await _service.BrowseAsync();
results[0].HasChildren.ShouldBeTrue();
session.HasChildrenCount.ShouldBe(1);
}
/// <summary>
/// Verifies that browse continuation points are followed so multi-page address-space branches are fully returned.
/// </summary>
[Fact]
public async Task BrowseAsync_WithContinuationPoint_FollowsIt()
{
var session = new FakeSessionAdapter
{
BrowseResponse =
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId("ns=2;s=A"),
DisplayName = new LocalizedText("A"),
NodeClass = NodeClass.Variable
}
],
BrowseContinuationPoint = [1, 2, 3],
BrowseNextResponse =
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId("ns=2;s=B"),
DisplayName = new LocalizedText("B"),
NodeClass = NodeClass.Variable
}
],
BrowseNextContinuationPoint = null
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var results = await _service.BrowseAsync();
results.Count.ShouldBe(2);
session.BrowseNextCount.ShouldBe(1);
}
/// <summary>
/// Verifies that browse requests are rejected when the client is disconnected.
/// </summary>
[Fact]
public async Task BrowseAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() => _service.BrowseAsync());
}
// --- Subscribe tests ---
/// <summary>
/// Verifies that subscribing to a node creates a monitored item on a data-change subscription.
/// </summary>
[Fact]
public async Task SubscribeAsync_CreatesSubscription()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500);
session.CreatedSubscriptions.Count.ShouldBe(1);
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
}
/// <summary>
/// Verifies that duplicate subscribe requests for the same node do not create duplicate monitored items.
/// </summary>
[Fact]
public async Task SubscribeAsync_DuplicateNode_IsIdempotent()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"));
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode")); // duplicate
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
}
/// <summary>
/// Verifies that data-change notifications from the subscription are raised through the shared client event.
/// </summary>
[Fact]
public async Task SubscribeAsync_RaisesDataChangedEvent()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
DataChangedEventArgs? received = null;
_service.DataChanged += (_, e) => received = e;
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500);
// Simulate data change
var handle = fakeSub.ActiveHandles.First();
fakeSub.SimulateDataChange(handle, new DataValue(new Variant(99), StatusCodes.Good));
received.ShouldNotBeNull();
received!.NodeId.ShouldBe("ns=2;s=MyNode");
received.Value.Value.ShouldBe(99);
}
/// <summary>
/// Verifies that unsubscribing removes the corresponding monitored item from the active subscription.
/// </summary>
[Fact]
public async Task UnsubscribeAsync_RemovesMonitoredItem()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"));
await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode"));
fakeSub.RemoveCount.ShouldBe(1);
}
/// <summary>
/// Verifies that unsubscribing an unknown node is treated as a safe no-op.
/// </summary>
[Fact]
public async Task UnsubscribeAsync_WhenNotSubscribed_DoesNotThrow()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"));
// Should not throw
}
/// <summary>
/// Verifies that data subscriptions cannot be created while the client is disconnected.
/// </summary>
[Fact]
public async Task SubscribeAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.SubscribeAsync(new NodeId("ns=2;s=MyNode")));
}
// --- Alarm subscription tests ---
/// <summary>
/// Verifies that alarm subscription requests create an event monitored item on the session.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_CreatesEventSubscription()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAlarmsAsync();
session.CreatedSubscriptions.Count.ShouldBe(1);
session.CreatedSubscriptions[0].AddEventCount.ShouldBe(1);
}
/// <summary>
/// Verifies that duplicate alarm-subscription requests do not create duplicate event subscriptions.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_Duplicate_IsIdempotent()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAlarmsAsync();
await _service.SubscribeAlarmsAsync(); // duplicate
session.CreatedSubscriptions.Count.ShouldBe(1);
}
/// <summary>
/// Verifies that OPC UA event notifications are mapped into the shared client alarm event model.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_RaisesAlarmEvent()
{
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();
// Simulate alarm event with proper field count
var handle = fakeSub.ActiveHandles.First();
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.UtcNow), // 3: Time
new Variant(new LocalizedText("High temp")), // 4: Message
new Variant((ushort)500), // 5: Severity
new Variant("HighTemp"), // 6: ConditionName
new Variant(true), // 7: Retain
new Variant(false), // 8: AckedState
new Variant(true), // 9: ActiveState
new Variant(true), // 10: EnabledState
new Variant(false)
]
};
fakeSub.SimulateEvent(handle, fields);
received.ShouldNotBeNull();
received!.SourceName.ShouldBe("Source1");
received.ConditionName.ShouldBe("HighTemp");
received.Severity.ShouldBe((ushort)500);
received.Message.ShouldBe("High temp");
received.Retain.ShouldBeTrue();
received.ActiveState.ShouldBeTrue();
received.AckedState.ShouldBeFalse();
}
/// <summary>
/// Verifies that removing alarm monitoring deletes the underlying event subscription.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_DeletesSubscription()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAlarmsAsync();
await _service.UnsubscribeAlarmsAsync();
fakeSub.Deleted.ShouldBeTrue();
}
/// <summary>
/// Verifies that removing alarms is safe even when no alarm subscription exists.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_WhenNoSubscription_DoesNotThrow()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.UnsubscribeAlarmsAsync(); // Should not throw
}
/// <summary>
/// Verifies that condition refresh requests are forwarded to the active alarm subscription.
/// </summary>
[Fact]
public async Task RequestConditionRefreshAsync_CallsAdapter()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAlarmsAsync();
await _service.RequestConditionRefreshAsync();
fakeSub.ConditionRefreshCalled.ShouldBeTrue();
}
/// <summary>
/// Verifies that condition refresh fails fast when no alarm subscription is active.
/// </summary>
[Fact]
public async Task RequestConditionRefreshAsync_NoAlarmSubscription_Throws()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.RequestConditionRefreshAsync());
}
/// <summary>
/// Verifies that alarm subscriptions cannot be created while disconnected.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.SubscribeAlarmsAsync());
}
// --- History read tests ---
/// <summary>
/// Verifies that raw history reads return the session-provided values.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_ReturnsValues()
{
var expectedValues = new List<DataValue>
{
new(new Variant(1.0), StatusCodes.Good),
new(new Variant(2.0), StatusCodes.Good)
};
var session = new FakeSessionAdapter { HistoryReadRawResponse = expectedValues };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var results = await _service.HistoryReadRawAsync(
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow);
results.Count.ShouldBe(2);
session.HistoryReadRawCount.ShouldBe(1);
}
/// <summary>
/// Verifies that raw history reads are rejected while disconnected.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
}
/// <summary>
/// Verifies that raw-history failures from the session are propagated to callers.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_SessionThrows_PropagatesException()
{
var session = new FakeSessionAdapter { ThrowOnHistoryReadRaw = true };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await Should.ThrowAsync<ServiceResultException>(() =>
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
}
/// <summary>
/// Verifies that aggregate history reads return the processed values from the session adapter.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_ReturnsValues()
{
var expectedValues = new List<DataValue>
{
new(new Variant(1.5), StatusCodes.Good)
};
var session = new FakeSessionAdapter { HistoryReadAggregateResponse = expectedValues };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var results = await _service.HistoryReadAggregateAsync(
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
AggregateType.Average);
results.Count.ShouldBe(1);
session.HistoryReadAggregateCount.ShouldBe(1);
}
/// <summary>
/// Verifies that aggregate history reads are rejected while disconnected.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.HistoryReadAggregateAsync(
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
AggregateType.Average));
}
/// <summary>
/// Verifies that aggregate-history failures from the session are propagated to callers.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_SessionThrows_PropagatesException()
{
var session = new FakeSessionAdapter { ThrowOnHistoryReadAggregate = true };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await Should.ThrowAsync<ServiceResultException>(() =>
_service.HistoryReadAggregateAsync(
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
AggregateType.Average));
}
// --- Redundancy tests ---
/// <summary>
/// Verifies that redundancy mode, service level, and server URIs are read from the standard OPC UA redundancy nodes.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_ReturnsInfo()
{
var session = new FakeSessionAdapter
{
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
return new DataValue(new Variant((int)RedundancySupport.Warm), StatusCodes.Good);
if (nodeId == VariableIds.Server_ServiceLevel)
return new DataValue(new Variant((byte)200), StatusCodes.Good);
if (nodeId == VariableIds.Server_ServerRedundancy_ServerUriArray)
return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good);
if (nodeId == VariableIds.Server_ServerArray)
return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good);
return new DataValue(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var info = await _service.GetRedundancyInfoAsync();
info.Mode.ShouldBe("Warm");
info.ServiceLevel.ShouldBe((byte)200);
info.ServerUris.ShouldBe(["urn:server1", "urn:server2"]);
info.ApplicationUri.ShouldBe("urn:server1");
}
/// <summary>
/// Verifies that missing optional redundancy arrays do not prevent a redundancy snapshot from being returned.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_MissingOptionalArrays_ReturnsGracefully()
{
var readCallIndex = 0;
var session = new FakeSessionAdapter
{
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
return new DataValue(new Variant((int)RedundancySupport.None), StatusCodes.Good);
if (nodeId == VariableIds.Server_ServiceLevel)
return new DataValue(new Variant((byte)100), StatusCodes.Good);
// Throw for optional reads
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)100);
info.ServerUris.ShouldBeEmpty();
info.ApplicationUri.ShouldBeEmpty();
}
/// <summary>
/// Verifies that redundancy inspection is rejected while disconnected.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_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 &lt;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);
}
// --- 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);
}
// --- 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) ---
/// <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>
/// Verifies that a keep-alive failure moves the client to a configured failover endpoint.
/// </summary>
[Fact]
public async Task KeepAliveFailure_TriggersFailover()
{
var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840" };
_sessionFactory.EnqueueSession(session1);
_sessionFactory.EnqueueSession(session2);
var settings = ValidSettings("opc.tcp://primary:4840");
settings.FailoverUrls = ["opc.tcp://backup:4840"];
var stateChanges = new List<ConnectionStateChangedEventArgs>();
_service.ConnectionStateChanged += (_, e) => stateChanges.Add(e);
await _service.ConnectAsync(settings);
// Simulate keep-alive failure
session1.SimulateKeepAlive(false);
// Give async failover time to complete
await Task.Delay(200);
// Should have reconnected
stateChanges.ShouldContain(e => e.NewState == ConnectionState.Reconnecting);
stateChanges.ShouldContain(e => e.NewState == ConnectionState.Connected &&
e.EndpointUrl == "opc.tcp://backup:4840");
}
/// <summary>
/// Verifies that connection metadata is refreshed to reflect the newly active failover endpoint.
/// </summary>
[Fact]
public async Task KeepAliveFailure_UpdatesConnectionInfo()
{
var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
var session2 = new FakeSessionAdapter
{
EndpointUrl = "opc.tcp://backup:4840",
ServerName = "BackupServer"
};
_sessionFactory.EnqueueSession(session1);
_sessionFactory.EnqueueSession(session2);
var settings = ValidSettings("opc.tcp://primary:4840");
settings.FailoverUrls = ["opc.tcp://backup:4840"];
await _service.ConnectAsync(settings);
session1.SimulateKeepAlive(false);
await Task.Delay(200);
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
_service.CurrentConnectionInfo.ServerName.ShouldBe("BackupServer");
}
/// <summary>
/// Verifies that the client falls back to disconnected when every failover endpoint is unreachable.
/// </summary>
[Fact]
public async Task KeepAliveFailure_AllEndpointsFail_TransitionsToDisconnected()
{
var session1 = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session1);
await _service.ConnectAsync(ValidSettings());
// After the first session, make factory fail
_sessionFactory.ThrowOnCreate = true;
session1.SimulateKeepAlive(false);
await Task.Delay(200);
_service.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Regression for Client.Shared-006: a re-entrant keep-alive failure that fires while a
/// failover loop is still in-flight must be ignored, so only one failover runs and only
/// one replacement session is created.
/// </summary>
[Fact]
public async Task KeepAliveFailure_ReentrantWhileFailoverInFlight_RunsFailoverOnce()
{
var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840" };
_sessionFactory.EnqueueSession(session1);
_sessionFactory.EnqueueSession(session2);
var settings = ValidSettings("opc.tcp://primary:4840");
settings.FailoverUrls = ["opc.tcp://backup:4840"];
await _service.ConnectAsync(settings);
var createCountAfterConnect = _sessionFactory.CreateCallCount; // 1
// Hold the failover's session creation open so it stays in-flight.
var gate = new TaskCompletionSource();
_sessionFactory.CreateGate = gate;
// First bad keep-alive starts the failover loop (now blocked on the gate).
session1.SimulateKeepAlive(false);
// Re-entrant bad keep-alives while failover is still running must be ignored.
session1.SimulateKeepAlive(false);
session1.SimulateKeepAlive(false);
// Release the gate so the in-flight failover completes.
gate.SetResult();
await Task.Delay(200);
// Exactly one extra session created by the single failover loop.
_sessionFactory.CreateCallCount.ShouldBe(createCountAfterConnect + 1);
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
}
/// <summary>
/// Regression for Client.Shared-005: concurrent subscribe/unsubscribe calls mutating the
/// active-subscription bookkeeping must not corrupt the dictionary or throw.
/// </summary>
[Fact]
public async Task SubscribeAndUnsubscribe_ConcurrentCalls_DoNotCorruptState()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var tasks = new List<Task>();
for (var i = 0; i < 50; i++)
{
var nodeId = new NodeId($"ns=2;s=Node{i}");
tasks.Add(Task.Run(async () =>
{
await _service.SubscribeAsync(nodeId);
await _service.UnsubscribeAsync(nodeId);
}));
}
// No InvalidOperationException from concurrent Dictionary mutation.
await Should.NotThrowAsync(() => Task.WhenAll(tasks));
}
// --- Dispose tests ---
/// <summary>
/// Verifies that dispose releases the underlying session and clears exposed connection state.
/// </summary>
[Fact]
public async Task Dispose_CleansUpResources()
{
var session = new FakeSessionAdapter();
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
_service.Dispose();
session.Disposed.ShouldBeTrue();
_service.CurrentConnectionInfo.ShouldBeNull();
}
/// <summary>
/// Verifies that dispose is safe to call even when no connection was established.
/// </summary>
[Fact]
public void Dispose_WhenNotConnected_DoesNotThrow()
{
_service.Dispose(); // Should not throw
}
/// <summary>
/// Verifies that public operations reject use after the shared client has been disposed.
/// </summary>
[Fact]
public async Task OperationsAfterDispose_Throw()
{
_service.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(() =>
_service.ConnectAsync(ValidSettings()));
await Should.ThrowAsync<ObjectDisposedException>(() =>
_service.ReadValueAsync(new NodeId("ns=2;s=X")));
}
// --- Factory tests ---
/// <summary>
/// Verifies that the factory creates a usable shared OPC UA client service instance.
/// </summary>
[Fact]
public void OpcUaClientServiceFactory_CreatesService()
{
var factory = new OpcUaClientServiceFactory();
var service = factory.Create();
service.ShouldNotBeNull();
service.ShouldBeAssignableTo<IOpcUaClientService>();
service.Dispose();
}
}