ac5db0a9f8
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)".
1454 lines
53 KiB
C#
1454 lines
53 KiB
C#
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 <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();
|
||
}
|
||
}
|