Document client stack XML docs progress

This commit is contained in:
Joseph Doherty
2026-04-01 08:58:17 -04:00
parent b2be438d33
commit 5c89a44255
27 changed files with 14809 additions and 28 deletions

View File

@@ -69,14 +69,22 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public Exception? WriteException { get; set; }
public Exception? ConditionRefreshException { get; set; }
// IOpcUaClientService implementation
/// <inheritdoc />
public bool IsConnected => ConnectCalled && !DisconnectCalled;
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo => ConnectCalled ? ConnectionInfoResult : null;
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCalled = true;
@@ -85,12 +93,14 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ConnectionInfoResult);
}
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken ct = default)
{
DisconnectCalled = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ReadNodeIds.Add(nodeId);
@@ -98,6 +108,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ReadValueResult);
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
WriteValues.Add((nodeId, value));
@@ -105,36 +116,42 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(WriteStatusCodeResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
{
BrowseNodeIds.Add(parentNodeId);
return Task.FromResult(BrowseResults);
}
/// <inheritdoc />
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCalls.Add((nodeId, intervalMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
UnsubscribeCalls.Add(nodeId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCalls.Add((sourceNodeId, intervalMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
UnsubscribeAlarmsCalled = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
RequestConditionRefreshCalled = true;
@@ -142,12 +159,14 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
return Task.FromResult(new StatusCode(StatusCodes.Good));
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
@@ -155,6 +174,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
double intervalMs = 3600000, CancellationToken ct = default)
@@ -163,12 +183,16 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
/// <inheritdoc />
public Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
GetRedundancyInfoCalled = true;
return Task.FromResult(RedundancyInfoResult);
}
/// <summary>
/// Marks the fake client as disposed so CLI command tests can assert cleanup behavior.
/// </summary>
public void Dispose()
{
DisposeCalled = true;
@@ -191,4 +215,4 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
{
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
}
}
}

View File

@@ -3,12 +3,22 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
/// <summary>
/// Test double for <see cref="ISessionAdapter" /> used to simulate reads, writes, browsing, history, and failover callbacks.
/// </summary>
internal sealed class FakeSessionAdapter : ISessionAdapter
{
private readonly List<FakeSubscriptionAdapter> _createdSubscriptions = [];
private Action<bool>? _keepAliveCallback;
/// <summary>
/// Gets a value indicating whether the fake session has been closed through the client disconnect path.
/// </summary>
public bool Closed { get; private set; }
/// <summary>
/// Gets a value indicating whether the fake session has been disposed.
/// </summary>
public bool Disposed { get; private set; }
public int ReadCount { get; private set; }
public int WriteCount { get; private set; }
@@ -38,27 +48,47 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
public bool ThrowOnHistoryReadAggregate { get; set; }
/// <summary>
/// The next FakeSubscriptionAdapter to return from CreateSubscriptionAsync.
/// If null, a new one is created automatically.
/// Gets or sets the next fake subscription returned when the client creates a monitored-item subscription.
/// If unset, the fake builds a new subscription automatically.
/// </summary>
public FakeSubscriptionAdapter? NextSubscription { get; set; }
/// <summary>
/// Gets the fake subscriptions created by this session so tests can inspect replay and cleanup behavior.
/// </summary>
public IReadOnlyList<FakeSubscriptionAdapter> CreatedSubscriptions => _createdSubscriptions;
/// <inheritdoc />
public bool Connected { get; set; } = true;
/// <inheritdoc />
public string SessionId { get; set; } = "ns=0;i=12345";
/// <inheritdoc />
public string SessionName { get; set; } = "FakeSession";
/// <inheritdoc />
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
/// <inheritdoc />
public string ServerName { get; set; } = "FakeServer";
/// <inheritdoc />
public string SecurityMode { get; set; } = "None";
/// <inheritdoc />
public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#None";
/// <inheritdoc />
public NamespaceTable NamespaceUris { get; set; } = new();
/// <inheritdoc />
public void RegisterKeepAliveHandler(Action<bool> callback)
{
_keepAliveCallback = callback;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct)
{
ReadCount++;
@@ -71,6 +101,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult(ReadResponse ?? new DataValue(new Variant(0), StatusCodes.Good));
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct)
{
WriteCount++;
@@ -79,6 +110,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult(WriteResponse);
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
@@ -88,6 +120,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult((BrowseContinuationPoint, BrowseResponse));
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
@@ -95,12 +128,14 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
}
/// <inheritdoc />
public Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
{
HasChildrenCount++;
return Task.FromResult(HasChildrenResponse);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
{
@@ -110,6 +145,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadRawResponse);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs,
CancellationToken ct)
@@ -120,6 +156,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadAggregateResponse);
}
/// <inheritdoc />
public Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
{
var sub = NextSubscription ?? new FakeSubscriptionAdapter();
@@ -128,12 +165,14 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<ISubscriptionAdapter>(sub);
}
/// <inheritdoc />
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
return Task.FromResult<IList<object>?>(null);
}
/// <inheritdoc />
public Task CloseAsync(CancellationToken ct)
{
Closed = true;
@@ -141,6 +180,9 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.CompletedTask;
}
/// <summary>
/// Marks the fake session as disposed so tests can verify cleanup after disconnect or failover.
/// </summary>
public void Dispose()
{
Disposed = true;
@@ -154,4 +196,4 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
{
_keepAliveCallback?.Invoke(isGood);
}
}
}

View File

@@ -3,6 +3,9 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
/// <summary>
/// Test double for <see cref="ISubscriptionAdapter" /> used to drive monitored-item behavior in shared-client tests.
/// </summary>
internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
{
private readonly
@@ -10,8 +13,19 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
)> _items = new();
private uint _nextHandle = 100;
/// <summary>
/// Gets a value indicating whether the fake subscription has been deleted.
/// </summary>
public bool Deleted { get; private set; }
/// <summary>
/// Gets a value indicating whether a condition refresh was requested by the client under test.
/// </summary>
public bool ConditionRefreshCalled { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether condition refresh should throw to simulate unsupported servers.
/// </summary>
public bool ThrowOnConditionRefresh { get; set; }
public int AddDataChangeCount { get; private set; }
public int AddEventCount { get; private set; }
@@ -22,8 +36,10 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
/// </summary>
public IReadOnlyCollection<uint> ActiveHandles => _items.Keys.ToList();
/// <inheritdoc />
public uint SubscriptionId { get; set; } = 42;
/// <inheritdoc />
public Task<uint> AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs,
Action<string, DataValue> onDataChange, CancellationToken ct)
{
@@ -33,6 +49,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
{
RemoveCount++;
@@ -40,6 +57,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<uint> AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter,
Action<EventFieldList> onEvent, CancellationToken ct)
{
@@ -49,6 +67,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task ConditionRefreshAsync(CancellationToken ct)
{
ConditionRefreshCalled = true;
@@ -57,6 +76,7 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(CancellationToken ct)
{
Deleted = true;
@@ -64,6 +84,9 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <summary>
/// Clears tracked monitored items when the fake subscription is disposed by the client under test.
/// </summary>
public void Dispose()
{
_items.Clear();
@@ -85,4 +108,4 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
{
if (_items.TryGetValue(handle, out var item) && item.EventCallback != null) item.EventCallback(eventFields);
}
}
}

View File

@@ -6,6 +6,9 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
namespace ZB.MOM.WW.LmxOpcUa.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();
@@ -18,6 +21,9 @@ public class OpcUaClientServiceTests : IDisposable
_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();
@@ -34,6 +40,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- 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()
{
@@ -45,6 +54,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -55,6 +67,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -77,6 +92,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -92,6 +110,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -105,6 +126,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -120,6 +144,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Disconnect tests ---
/// <summary>
/// Verifies that disconnect closes the active session and clears exposed connection state.
/// </summary>
[Fact]
public async Task DisconnectAsync_WhenConnected_ClosesSession()
{
@@ -133,6 +160,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -140,6 +170,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -150,6 +183,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- 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()
{
@@ -166,6 +202,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -173,6 +212,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -186,6 +228,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Write tests ---
/// <summary>
/// Verifies that writes succeed through the session adapter when the client is connected.
/// </summary>
[Fact]
public async Task WriteValueAsync_WhenConnected_WritesValue()
{
@@ -203,6 +248,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -219,6 +267,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -232,6 +283,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -241,6 +295,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- 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()
{
@@ -267,6 +324,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -282,6 +342,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -307,6 +370,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -342,6 +408,9 @@ public class OpcUaClientServiceTests : IDisposable
session.BrowseNextCount.ShouldBe(1);
}
/// <summary>
/// Verifies that browse requests are rejected when the client is disconnected.
/// </summary>
[Fact]
public async Task BrowseAsync_WhenDisconnected_Throws()
{
@@ -350,6 +419,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- 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()
{
@@ -363,6 +435,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -376,6 +451,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -398,6 +476,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -412,6 +493,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -423,6 +507,9 @@ public class OpcUaClientServiceTests : IDisposable
// Should not throw
}
/// <summary>
/// Verifies that data subscriptions cannot be created while the client is disconnected.
/// </summary>
[Fact]
public async Task SubscribeAsync_WhenDisconnected_Throws()
{
@@ -432,6 +519,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Alarm subscription tests ---
/// <summary>
/// Verifies that alarm subscription requests create an event monitored item on the session.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_CreatesEventSubscription()
{
@@ -445,6 +535,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -458,6 +551,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -503,6 +599,9 @@ public class OpcUaClientServiceTests : IDisposable
received.AckedState.ShouldBeFalse();
}
/// <summary>
/// Verifies that removing alarm monitoring deletes the underlying event subscription.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_DeletesSubscription()
{
@@ -517,6 +616,9 @@ public class OpcUaClientServiceTests : IDisposable
fakeSub.Deleted.ShouldBeTrue();
}
/// <summary>
/// Verifies that removing alarms is safe even when no alarm subscription exists.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_WhenNoSubscription_DoesNotThrow()
{
@@ -527,6 +629,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -541,6 +646,9 @@ public class OpcUaClientServiceTests : IDisposable
fakeSub.ConditionRefreshCalled.ShouldBeTrue();
}
/// <summary>
/// Verifies that condition refresh fails fast when no alarm subscription is active.
/// </summary>
[Fact]
public async Task RequestConditionRefreshAsync_NoAlarmSubscription_Throws()
{
@@ -552,6 +660,9 @@ public class OpcUaClientServiceTests : IDisposable
_service.RequestConditionRefreshAsync());
}
/// <summary>
/// Verifies that alarm subscriptions cannot be created while disconnected.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_WhenDisconnected_Throws()
{
@@ -561,6 +672,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- History read tests ---
/// <summary>
/// Verifies that raw history reads return the session-provided values.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_ReturnsValues()
{
@@ -580,6 +694,9 @@ public class OpcUaClientServiceTests : IDisposable
session.HistoryReadRawCount.ShouldBe(1);
}
/// <summary>
/// Verifies that raw history reads are rejected while disconnected.
/// </summary>
[Fact]
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
{
@@ -587,6 +704,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -598,6 +718,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -617,6 +740,9 @@ public class OpcUaClientServiceTests : IDisposable
session.HistoryReadAggregateCount.ShouldBe(1);
}
/// <summary>
/// Verifies that aggregate history reads are rejected while disconnected.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_WhenDisconnected_Throws()
{
@@ -626,6 +752,9 @@ public class OpcUaClientServiceTests : IDisposable
AggregateType.Average));
}
/// <summary>
/// Verifies that aggregate-history failures from the session are propagated to callers.
/// </summary>
[Fact]
public async Task HistoryReadAggregateAsync_SessionThrows_PropagatesException()
{
@@ -641,6 +770,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- 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()
{
@@ -670,6 +802,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -697,6 +832,9 @@ public class OpcUaClientServiceTests : IDisposable
info.ApplicationUri.ShouldBeEmpty();
}
/// <summary>
/// Verifies that redundancy inspection is rejected while disconnected.
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_WhenDisconnected_Throws()
{
@@ -706,6 +844,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Failover tests ---
/// <summary>
/// Verifies that a keep-alive failure moves the client to a configured failover endpoint.
/// </summary>
[Fact]
public async Task KeepAliveFailure_TriggersFailover()
{
@@ -734,6 +875,9 @@ public class OpcUaClientServiceTests : IDisposable
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()
{
@@ -757,6 +901,9 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -775,6 +922,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Dispose tests ---
/// <summary>
/// Verifies that dispose releases the underlying session and clears exposed connection state.
/// </summary>
[Fact]
public async Task Dispose_CleansUpResources()
{
@@ -788,12 +938,18 @@ public class OpcUaClientServiceTests : IDisposable
_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()
{
@@ -807,6 +963,9 @@ public class OpcUaClientServiceTests : IDisposable
// --- Factory tests ---
/// <summary>
/// Verifies that the factory creates a usable shared OPC UA client service instance.
/// </summary>
[Fact]
public void OpcUaClientServiceFactory_CreatesService()
{
@@ -816,4 +975,4 @@ public class OpcUaClientServiceTests : IDisposable
service.ShouldBeAssignableTo<IOpcUaClientService>();
service.Dispose();
}
}
}

View File

@@ -6,27 +6,81 @@ using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
/// <summary>
/// Fake IOpcUaClientService for unit testing.
/// Test double for the shared OPC UA client service used by UI view-model tests.
/// It lets tests script connection, browse, history, redundancy, and alarm behavior without a live server.
/// </summary>
public sealed class FakeOpcUaClientService : IOpcUaClientService
{
// Configurable responses
/// <summary>
/// Gets or sets the connection metadata returned when a UI test performs a successful connect.
/// </summary>
public ConnectionInfo? ConnectResult { get; set; }
/// <summary>
/// Gets or sets the exception thrown to simulate a failed connect workflow in the UI.
/// </summary>
public Exception? ConnectException { get; set; }
/// <summary>
/// Gets or sets the default browse results returned when no parent-specific branch is configured.
/// </summary>
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = [];
/// <summary>
/// Gets or sets browse results keyed by parent node so tree-navigation tests can model multiple address-space branches.
/// </summary>
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; set; } = new();
/// <summary>
/// Gets or sets the exception thrown to simulate browse failures in the UI tree.
/// </summary>
public Exception? BrowseException { get; set; }
/// <summary>
/// Gets or sets the current value returned by point-read tests.
/// </summary>
public DataValue ReadResult { get; set; } =
new(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow);
/// <summary>
/// Gets or sets the exception thrown to simulate point-read failures in the UI.
/// </summary>
public Exception? ReadException { get; set; }
/// <summary>
/// Gets or sets the status code returned by write operations in UI tests.
/// </summary>
public StatusCode WriteResult { get; set; } = StatusCodes.Good;
/// <summary>
/// Gets or sets the exception thrown to simulate failed write workflows in the UI.
/// </summary>
public Exception? WriteException { get; set; }
/// <summary>
/// Gets or sets the redundancy snapshot returned when the UI inspects server redundancy state.
/// </summary>
public RedundancyInfo? RedundancyResult { get; set; }
/// <summary>
/// Gets or sets the exception thrown to simulate redundancy lookup failures.
/// </summary>
public Exception? RedundancyException { get; set; }
/// <summary>
/// Gets or sets the raw historical values returned to history-view tests.
/// </summary>
public IReadOnlyList<DataValue> HistoryRawResult { get; set; } = [];
/// <summary>
/// Gets or sets the aggregate historical values returned to history-view tests.
/// </summary>
public IReadOnlyList<DataValue> HistoryAggregateResult { get; set; } = [];
/// <summary>
/// Gets or sets the exception thrown to simulate historical-data failures in the UI.
/// </summary>
public Exception? HistoryException { get; set; }
// Call tracking
@@ -54,13 +108,22 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public NodeId? LastUnsubscribeNodeId { get; private set; }
public AggregateType? LastAggregateType { get; private set; }
/// <inheritdoc />
public bool IsConnected { get; set; }
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo { get; set; }
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCallCount++;
@@ -71,6 +134,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ConnectResult!);
}
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken ct = default)
{
DisconnectCallCount++;
@@ -79,6 +143,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ReadCallCount++;
@@ -87,6 +152,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(ReadResult);
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
WriteCallCount++;
@@ -96,6 +162,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(WriteResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
{
BrowseCallCount++;
@@ -108,6 +175,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(BrowseResults);
}
/// <inheritdoc />
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCallCount++;
@@ -116,6 +184,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
UnsubscribeCallCount++;
@@ -123,18 +192,21 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCallCount++;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
UnsubscribeAlarmsCallCount++;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
RequestConditionRefreshCallCount++;
@@ -145,6 +217,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public Exception? AcknowledgeException { get; set; }
public int AcknowledgeCallCount { get; private set; }
/// <inheritdoc />
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
@@ -153,6 +226,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(AcknowledgeResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default)
{
@@ -161,6 +235,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryRawResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default)
{
@@ -170,6 +245,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryAggregateResult);
}
/// <inheritdoc />
public Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
GetRedundancyInfoCallCount++;
@@ -177,24 +253,35 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(RedundancyResult!);
}
/// <summary>
/// Releases fake service resources at the end of a UI test run.
/// </summary>
public void Dispose()
{
// No-op for testing
}
// Methods to raise events from tests
/// <summary>
/// Raises a simulated data-change notification so UI tests can validate live update handling.
/// </summary>
public void RaiseDataChanged(DataChangedEventArgs args)
{
DataChanged?.Invoke(this, args);
}
/// <summary>
/// Raises a simulated alarm event so UI tests can validate alarm-list behavior.
/// </summary>
public void RaiseAlarmEvent(AlarmEventArgs args)
{
AlarmEvent?.Invoke(this, args);
}
/// <summary>
/// Raises a simulated connection-state transition so UI tests can validate status presentation and failover behavior.
/// </summary>
public void RaiseConnectionStateChanged(ConnectionStateChangedEventArgs args)
{
ConnectionStateChanged?.Invoke(this, args);
}
}
}

View File

@@ -9,6 +9,9 @@ using ConnectionState = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.ConnectionState;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
/// <summary>
/// Verifies the main UI shell behavior for connection state, settings persistence, browsing, subscriptions, and history navigation.
/// </summary>
public class MainWindowViewModelTests
{
private readonly FakeOpcUaClientService _service;
@@ -39,6 +42,9 @@ public class MainWindowViewModelTests
_vm = new MainWindowViewModel(factory, dispatcher, _settingsService);
}
/// <summary>
/// Verifies that the shell starts disconnected with the default endpoint and status text.
/// </summary>
[Fact]
public void DefaultState_IsDisconnected()
{
@@ -48,18 +54,27 @@ public class MainWindowViewModelTests
_vm.StatusMessage.ShouldBe("Disconnected");
}
/// <summary>
/// Verifies that the connect command is available before a session is established.
/// </summary>
[Fact]
public void ConnectCommand_CanExecute_WhenDisconnected()
{
_vm.ConnectCommand.CanExecute(null).ShouldBeTrue();
}
/// <summary>
/// Verifies that disconnect is disabled until a server session is active.
/// </summary>
[Fact]
public void DisconnectCommand_CannotExecute_WhenDisconnected()
{
_vm.DisconnectCommand.CanExecute(null).ShouldBeFalse();
}
/// <summary>
/// Verifies that a successful connect command updates the shell into the connected state.
/// </summary>
[Fact]
public async Task ConnectCommand_TransitionsToConnected()
{
@@ -70,6 +85,9 @@ public class MainWindowViewModelTests
_service.ConnectCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that the initial browse tree is loaded after a successful connect.
/// </summary>
[Fact]
public async Task ConnectCommand_LoadsRootNodes()
{
@@ -79,6 +97,9 @@ public class MainWindowViewModelTests
_vm.BrowseTree.RootNodes[0].DisplayName.ShouldBe("Root");
}
/// <summary>
/// Verifies that redundancy details are fetched and exposed after connecting.
/// </summary>
[Fact]
public async Task ConnectCommand_FetchesRedundancyInfo()
{
@@ -89,6 +110,9 @@ public class MainWindowViewModelTests
_vm.RedundancyInfo.ServiceLevel.ShouldBe((byte)200);
}
/// <summary>
/// Verifies that the session label shows the connected server and session identity.
/// </summary>
[Fact]
public async Task ConnectCommand_SetsSessionLabel()
{
@@ -98,6 +122,9 @@ public class MainWindowViewModelTests
_vm.SessionLabel.ShouldContain("TestSession");
}
/// <summary>
/// Verifies that disconnect returns the shell to the disconnected state.
/// </summary>
[Fact]
public async Task DisconnectCommand_TransitionsToDisconnected()
{
@@ -109,6 +136,9 @@ public class MainWindowViewModelTests
_service.DisconnectCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that disconnect clears session-specific UI state such as browse data and redundancy details.
/// </summary>
[Fact]
public async Task Disconnect_ClearsStateAndChildren()
{
@@ -121,6 +151,9 @@ public class MainWindowViewModelTests
_vm.SubscriptionCount.ShouldBe(0);
}
/// <summary>
/// Verifies that connection-state events from the client update the shell status text and state.
/// </summary>
[Fact]
public async Task ConnectionStateChangedEvent_UpdatesState()
{
@@ -134,6 +167,9 @@ public class MainWindowViewModelTests
_vm.StatusMessage.ShouldBe("Reconnecting...");
}
/// <summary>
/// Verifies that selecting a tree node updates the dependent read/write and history panels.
/// </summary>
[Fact]
public async Task SelectedTreeNode_PropagatesToChildViewModels()
{
@@ -146,6 +182,9 @@ public class MainWindowViewModelTests
_vm.History.SelectedNodeId.ShouldBe(node.NodeId);
}
/// <summary>
/// Verifies that a successful connect propagates connected state into the child tabs.
/// </summary>
[Fact]
public async Task ConnectCommand_PropagatesIsConnectedToChildViewModels()
{
@@ -157,6 +196,9 @@ public class MainWindowViewModelTests
_vm.History.IsConnected.ShouldBeTrue();
}
/// <summary>
/// Verifies that disconnect propagates disconnected state into the child tabs.
/// </summary>
[Fact]
public async Task DisconnectCommand_PropagatesIsConnectedFalseToChildViewModels()
{
@@ -169,6 +211,9 @@ public class MainWindowViewModelTests
_vm.History.IsConnected.ShouldBeFalse();
}
/// <summary>
/// Verifies that failed connection attempts restore the disconnected shell state and surface the error text.
/// </summary>
[Fact]
public async Task ConnectFailure_RevertsToDisconnected()
{
@@ -180,6 +225,9 @@ public class MainWindowViewModelTests
_vm.StatusMessage.ShouldContain("Connection refused");
}
/// <summary>
/// Verifies that connection-state transitions raise property-changed notifications for UI binding updates.
/// </summary>
[Fact]
public async Task PropertyChanged_FiredForConnectionState()
{
@@ -195,6 +243,9 @@ public class MainWindowViewModelTests
changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
}
/// <summary>
/// Verifies that the shell initializes advanced connection settings with the expected defaults.
/// </summary>
[Fact]
public void DefaultState_HasCorrectAdvancedSettings()
{
@@ -205,6 +256,9 @@ public class MainWindowViewModelTests
_vm.CertificateStorePath.ShouldContain("pki");
}
/// <summary>
/// Verifies that failover endpoint text is parsed into connection settings on connect.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsFailoverUrlsToSettings()
{
@@ -218,6 +272,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings.FailoverUrls[1].ShouldBe("opc.tcp://backup2:4840");
}
/// <summary>
/// Verifies that empty failover text is normalized to no configured failover endpoints.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsEmptyFailoverUrlsToNull()
{
@@ -228,6 +285,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.FailoverUrls.ShouldBeNull();
}
/// <summary>
/// Verifies that the configured session timeout is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsSessionTimeoutToSettings()
{
@@ -238,6 +298,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.SessionTimeoutSeconds.ShouldBe(120);
}
/// <summary>
/// Verifies that the auto-accept certificate toggle is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsAutoAcceptCertificatesToSettings()
{
@@ -248,6 +311,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.AutoAcceptCertificates.ShouldBeFalse();
}
/// <summary>
/// Verifies that a custom certificate store path is passed into the connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_MapsCertificateStorePathToSettings()
{
@@ -258,6 +324,9 @@ public class MainWindowViewModelTests
_service.LastConnectionSettings!.CertificateStorePath.ShouldBe("/custom/pki/path");
}
/// <summary>
/// Verifies that subscribing selected nodes adds subscriptions and switches the shell to the subscriptions tab.
/// </summary>
[Fact]
public async Task SubscribeSelectedNodesCommand_SubscribesAndSwitchesToTab()
{
@@ -275,6 +344,9 @@ public class MainWindowViewModelTests
_vm.SelectedTabIndex.ShouldBe(1);
}
/// <summary>
/// Verifies that subscribing selected nodes is a no-op when nothing is selected.
/// </summary>
[Fact]
public async Task SubscribeSelectedNodesCommand_DoesNothing_WhenNoSelection()
{
@@ -285,6 +357,9 @@ public class MainWindowViewModelTests
_vm.Subscriptions.ActiveSubscriptions.ShouldBeEmpty();
}
/// <summary>
/// Verifies that the history command targets the selected node and switches the shell to the history tab.
/// </summary>
[Fact]
public async Task ViewHistoryForSelectedNodeCommand_SetsNodeAndSwitchesToTab()
{
@@ -299,6 +374,9 @@ public class MainWindowViewModelTests
_vm.SelectedTabIndex.ShouldBe(3);
}
/// <summary>
/// Verifies that history actions are enabled when a variable node is selected.
/// </summary>
[Fact]
public async Task UpdateHistoryEnabledForSelection_TrueForVariableNode()
{
@@ -313,6 +391,9 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeTrue();
}
/// <summary>
/// Verifies that history actions stay disabled when an object node rather than a variable is selected.
/// </summary>
[Fact]
public async Task UpdateHistoryEnabledForSelection_FalseForObjectNode()
{
@@ -327,6 +408,9 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
}
/// <summary>
/// Verifies that history actions stay disabled when no server connection is active.
/// </summary>
[Fact]
public void UpdateHistoryEnabledForSelection_FalseWhenDisconnected()
{
@@ -335,12 +419,18 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeFalse();
}
/// <summary>
/// Verifies that saved user settings are loaded during shell construction.
/// </summary>
[Fact]
public void Constructor_LoadsSettingsFromService()
{
_settingsService.LoadCallCount.ShouldBe(1);
}
/// <summary>
/// Verifies that persisted connection and security settings are applied to the shell on startup.
/// </summary>
[Fact]
public void Constructor_AppliesSavedSettings()
{
@@ -376,6 +466,9 @@ public class MainWindowViewModelTests
vm.CertificateStorePath.ShouldBe("/custom/path");
}
/// <summary>
/// Verifies that successful connections persist the current connection settings.
/// </summary>
[Fact]
public async Task ConnectCommand_SavesSettingsOnSuccess()
{
@@ -390,6 +483,9 @@ public class MainWindowViewModelTests
_settingsService.LastSaved.Username.ShouldBe("admin");
}
/// <summary>
/// Verifies that failed connection attempts do not overwrite saved settings.
/// </summary>
[Fact]
public async Task ConnectCommand_DoesNotSaveOnFailure()
{
@@ -400,6 +496,9 @@ public class MainWindowViewModelTests
_settingsService.SaveCallCount.ShouldBe(0);
}
/// <summary>
/// Verifies that active subscriptions are persisted when the shell disconnects.
/// </summary>
[Fact]
public async Task ConnectCommand_SavesSubscribedNodes()
{
@@ -416,6 +515,9 @@ public class MainWindowViewModelTests
_settingsService.LastSaved!.SubscribedNodes.ShouldContain("ns=2;s=TestSub");
}
/// <summary>
/// Verifies that saved subscriptions are restored after reconnecting the shell.
/// </summary>
[Fact]
public async Task ConnectCommand_RestoresSavedSubscriptions()
{
@@ -437,4 +539,4 @@ public class MainWindowViewModelTests
vm.Subscriptions.ActiveSubscriptions[1].NodeId.ShouldBe("ns=2;s=Restored2");
vm.SubscriptionCount.ShouldBe(2);
}
}
}