Merge lane K: clients, CLI, AB transport (#747)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
using CliFx.Exceptions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>historyread --aggregate</c> accepts every aggregate the client offers (which is exactly the served set), so the
|
||||
/// gate recipes in docs/Historian.md that need Range / Delta / PercentGood / ... can run from this CLI.
|
||||
/// </summary>
|
||||
public class HistoryReadAggregateParsingTests
|
||||
{
|
||||
/// <summary>Every client aggregate, by its name.</summary>
|
||||
/// <returns>Each <see cref="AggregateType"/> value.</returns>
|
||||
public static TheoryData<AggregateType> AllAggregates() => new(Enum.GetValues<AggregateType>());
|
||||
|
||||
/// <summary>Every aggregate name (in any case) reaches the service as that aggregate.</summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public async Task Every_aggregate_name_parses(AggregateType aggregate)
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = aggregate.ToString().ToUpperInvariant()
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(aggregate);
|
||||
}
|
||||
|
||||
/// <summary>The short aliases still parse.</summary>
|
||||
/// <param name="alias">The alias.</param>
|
||||
/// <param name="expected">The aggregate it names.</param>
|
||||
[Theory]
|
||||
[InlineData("avg", AggregateType.Average)]
|
||||
[InlineData("min", AggregateType.Minimum)]
|
||||
[InlineData("last", AggregateType.End)]
|
||||
[InlineData("interp", AggregateType.Interpolative)]
|
||||
[InlineData("mintime", AggregateType.MinimumActualTime)]
|
||||
[InlineData("maxtime", AggregateType.MaximumActualTime)]
|
||||
public async Task Aliases_parse(string alias, AggregateType expected)
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = alias
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StandardDeviation is no longer offered (no backend serves it): it is refused before connecting, naming the
|
||||
/// supported set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StandardDeviation_is_refused_before_connecting()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = "stddev"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
|
||||
ex.Message.ShouldContain("PercentGood");
|
||||
fakeService.HistoryReadAggregateCalls.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// A deleted subscription must also leave the SDK session's <c>Subscriptions</c> list, or every alarm re-target in the UI
|
||||
/// leaves a dead <see cref="Subscription"/> behind until the session is disposed.
|
||||
/// </summary>
|
||||
public class DefaultSubscriptionAdapterTests
|
||||
{
|
||||
/// <summary>DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteAsync_detaches_the_subscription_from_its_session_once()
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
var detached = new List<Subscription>();
|
||||
var adapter = new DefaultSubscriptionAdapter(subscription, (s, _) =>
|
||||
{
|
||||
lock (detached) detached.Add(s);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await adapter.DeleteAsync(TestContext.Current.CancellationToken);
|
||||
adapter.Dispose();
|
||||
|
||||
detached.ShouldHaveSingleItem().ShouldBeSameAs(subscription);
|
||||
}
|
||||
|
||||
/// <summary>Dispose alone (no prior DeleteAsync) detaches too.</summary>
|
||||
[Fact]
|
||||
public void Dispose_detaches_the_subscription_from_its_session()
|
||||
{
|
||||
var detached = 0;
|
||||
var adapter = new DefaultSubscriptionAdapter(new Subscription(), (_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref detached);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
adapter.Dispose();
|
||||
|
||||
detached.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A failing detach is logged, not thrown: teardown stays best effort.</summary>
|
||||
[Fact]
|
||||
public async Task A_failing_detach_does_not_throw_from_DeleteAsync()
|
||||
{
|
||||
var adapter = new DefaultSubscriptionAdapter(new Subscription(),
|
||||
(_, _) => throw new InvalidOperationException("boom"));
|
||||
|
||||
await Should.NotThrowAsync(() => adapter.DeleteAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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>
|
||||
/// Lane K review I1: a keep-alive failover belongs to the connection it started under. A user Disconnect + Connect
|
||||
/// while it is still connecting starts a new connection epoch; the stale failover must abandon itself — never
|
||||
/// overwrite the user's new session, never flip its state. Every step is gate-driven (no sleeps).
|
||||
/// </summary>
|
||||
public sealed class FailoverEpochTests : IDisposable
|
||||
{
|
||||
private const string Primary = "opc.tcp://primary:4840";
|
||||
private const string Backup = "opc.tcp://backup:4840";
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly FakeSessionFactory _factory = new();
|
||||
private readonly OpcUaClientService _service;
|
||||
private readonly List<ConnectionState> _states = [];
|
||||
|
||||
public FailoverEpochTests()
|
||||
{
|
||||
_service = new OpcUaClientService(new FakeApplicationConfigurationFactory(), new FakeEndpointDiscovery(), _factory);
|
||||
_service.ConnectionStateChanged += (_, e) => { lock (_states) _states.Add(e.NewState); };
|
||||
}
|
||||
|
||||
public void Dispose() => _service.Dispose();
|
||||
|
||||
private static ConnectionSettings Settings() => new()
|
||||
{
|
||||
EndpointUrl = Primary, FailoverUrls = [Backup], SessionTimeoutSeconds = 60
|
||||
};
|
||||
|
||||
/// <summary>Connects S1, then holds a keep-alive failover inside its connect attempt.</summary>
|
||||
private async Task<(FakeSessionAdapter S1, Task Failover, TaskCompletionSource Gate)> StartHeldFailoverAsync()
|
||||
{
|
||||
var s1 = new FakeSessionAdapter();
|
||||
_factory.EnqueueSession(s1);
|
||||
await _service.ConnectAsync(Settings());
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
_factory.CreateGate = gate;
|
||||
s1.SimulateKeepAlive(false);
|
||||
var failover = _service.LastFailover.ShouldNotBeNull();
|
||||
await _factory.CreateEntered.Task.WaitAsync(Wait);
|
||||
return (s1, failover, gate);
|
||||
}
|
||||
|
||||
/// <summary>The stale failover's attempt SUCCEEDS after the user reconnected: its session is disposed, the user's stays.</summary>
|
||||
[Fact]
|
||||
public async Task A_stale_failover_that_connects_does_not_replace_the_users_new_session()
|
||||
{
|
||||
var (_, failover, gate) = await StartHeldFailoverAsync();
|
||||
var userSession = new FakeSessionAdapter();
|
||||
var failoverSession = new FakeSessionAdapter();
|
||||
_factory.EnqueueSession(userSession); // dequeued by the user's Connect (the failover is still held)
|
||||
_factory.EnqueueSession(failoverSession); // dequeued by the failover once released
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(Settings());
|
||||
lock (_states) _states.Clear();
|
||||
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
|
||||
failoverSession.Disposed.ShouldBeTrue();
|
||||
userSession.Disposed.ShouldBeFalse();
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Primary);
|
||||
lock (_states) _states.ShouldBeEmpty(); // the stale failover made no state transition at all
|
||||
}
|
||||
|
||||
/// <summary>The stale failover's attempts all FAIL after the user reconnected: the user's connection stays Connected.</summary>
|
||||
[Fact]
|
||||
public async Task A_stale_failover_that_fails_does_not_flip_the_users_connection_to_Disconnected()
|
||||
{
|
||||
var (_, failover, gate) = await StartHeldFailoverAsync();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(Settings());
|
||||
lock (_states) _states.Clear();
|
||||
var createsBefore = _factory.CreateCallCount;
|
||||
|
||||
_factory.ThrowOnCreate = true;
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
lock (_states) _states.ShouldBeEmpty();
|
||||
// It stopped at the abandoned attempt instead of trying every remaining endpoint (review m5).
|
||||
_factory.CreateCallCount.ShouldBe(createsBefore);
|
||||
// And the user can still disconnect / reconnect normally afterwards.
|
||||
_factory.ThrowOnCreate = false;
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(Settings());
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: an undisturbed failover still reconnects to the backup.</summary>
|
||||
[Fact]
|
||||
public async Task A_genuine_failover_still_reconnects()
|
||||
{
|
||||
var (_, failover, gate) = await StartHeldFailoverAsync();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Backup);
|
||||
lock (_states) _states.ShouldContain(ConnectionState.Reconnecting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A late bad keep-alive from the PREVIOUS connection's session must not start a failover against the new one.
|
||||
/// Positive control: the same signal from the current session does (the genuine-failover test above).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_keep_alive_from_a_previous_connection_does_not_fail_over_the_current_one()
|
||||
{
|
||||
var s1 = new FakeSessionAdapter();
|
||||
_factory.EnqueueSession(s1);
|
||||
await _service.ConnectAsync(Settings());
|
||||
await _service.DisconnectAsync();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
await _service.ConnectAsync(Settings());
|
||||
var creates = _factory.CreateCallCount;
|
||||
lock (_states) _states.Clear();
|
||||
|
||||
s1.SimulateKeepAlive(false);
|
||||
if (_service.LastFailover is { } t) await t.WaitAsync(Wait);
|
||||
|
||||
_factory.CreateCallCount.ShouldBe(creates);
|
||||
lock (_states) _states.ShouldBeEmpty();
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Review m8: re-entrant bad keep-alives do not overwrite the seam with an already-completed no-op.</summary>
|
||||
[Fact]
|
||||
public async Task Reentrant_keep_alives_leave_the_seam_on_the_running_failover()
|
||||
{
|
||||
var (s1, failover, gate) = await StartHeldFailoverAsync();
|
||||
s1.SimulateKeepAlive(false);
|
||||
s1.SimulateKeepAlive(false);
|
||||
|
||||
_service.LastFailover.ShouldBeSameAs(failover);
|
||||
failover.IsCompleted.ShouldBeFalse();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m5: an Unsubscribe whose server-side remove fails (the session died under it) still drops the
|
||||
/// bookkeeping, so a failover does not replay an item the caller asked to stop. Proven by a re-subscribe of the
|
||||
/// same node creating a fresh monitored item instead of returning "already subscribed".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_failed_unsubscribe_still_forgets_the_item()
|
||||
{
|
||||
var sub = new FakeSubscriptionAdapter();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
|
||||
await _service.ConnectAsync(Settings());
|
||||
var node = new Opc.Ua.NodeId("ns=2;s=X");
|
||||
await _service.SubscribeAsync(node);
|
||||
|
||||
sub.ThrowOnRemove = true;
|
||||
await Should.ThrowAsync<Opc.Ua.ServiceResultException>(() => _service.UnsubscribeAsync(node));
|
||||
sub.ThrowOnRemove = false;
|
||||
|
||||
await _service.SubscribeAsync(node);
|
||||
sub.AddDataChangeCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public int BrowseCount { get; private set; }
|
||||
/// <summary>Gets the number of times BrowseNextAsync has been called.</summary>
|
||||
public int BrowseNextCount { get; private set; }
|
||||
/// <summary>Gets the number of times HasChildrenAsync has been called.</summary>
|
||||
public int HasChildrenCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadRawAsync has been called.</summary>
|
||||
public int HistoryReadRawCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadAggregateAsync has been called.</summary>
|
||||
@@ -57,8 +55,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||
/// <summary>Gets or sets the result returned by has-children operations.</summary>
|
||||
public bool HasChildrenResponse { get; set; } = false;
|
||||
|
||||
/// <summary>Gets or sets the historical values returned by raw history-read operations.</summary>
|
||||
public List<DataValue> HistoryReadRawResponse { get; set; } = [];
|
||||
@@ -150,13 +146,6 @@ 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)
|
||||
@@ -178,15 +167,31 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadAggregateResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional gate that, when set, holds <see cref="CreateSubscriptionAsync" /> open until completed, so a test can keep
|
||||
/// a subscribe in flight while something else (a failover) happens. Signalled via <see cref="CreateSubscriptionEntered" />.
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CreateSubscriptionGate { get; set; }
|
||||
|
||||
/// <summary>Completed when <see cref="CreateSubscriptionAsync" /> is entered while a gate is set.</summary>
|
||||
public TaskCompletionSource CreateSubscriptionEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
|
||||
public async Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
|
||||
{
|
||||
if (CreateSubscriptionGate is { } gate)
|
||||
{
|
||||
CreateSubscriptionEntered.TrySetResult();
|
||||
await gate.Task;
|
||||
}
|
||||
|
||||
lock (_createdSubscriptions)
|
||||
{
|
||||
var sub = NextSubscription ?? new FakeSubscriptionAdapter();
|
||||
NextSubscription = null;
|
||||
_createdSubscriptions.Add(sub);
|
||||
return Task.FromResult<ISubscriptionAdapter>(sub);
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CreateGate { get; set; }
|
||||
|
||||
/// <summary>Completed when a call observes <see cref="CreateGate" />.</summary>
|
||||
public TaskCompletionSource CreateEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the list of sessions created via CreateSessionAsync.</summary>
|
||||
public IReadOnlyList<FakeSessionAdapter> CreatedSessions => _createdSessions;
|
||||
|
||||
@@ -39,8 +42,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
CreateCallCount++;
|
||||
LastEndpointUrl = endpoint.EndpointUrl;
|
||||
|
||||
if (CreateGate != null)
|
||||
await CreateGate.Task;
|
||||
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
|
||||
// failover is held) runs freely.
|
||||
if (CreateGate is { } gate)
|
||||
{
|
||||
CreateGate = null;
|
||||
CreateEntered.TrySetResult();
|
||||
await gate.Task;
|
||||
}
|
||||
|
||||
if (ThrowOnCreate)
|
||||
throw new InvalidOperationException("FakeSessionFactory configured to fail.");
|
||||
|
||||
@@ -37,6 +37,9 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <summary>Gets the number of times AddEventMonitoredItemAsync was called.</summary>
|
||||
public int AddEventCount { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether RemoveMonitoredItemAsync throws (a session that died).</summary>
|
||||
public bool ThrowOnRemove { get; set; }
|
||||
|
||||
/// <summary>Gets the number of times RemoveMonitoredItemAsync was called.</summary>
|
||||
public int RemoveCount { get; private set; }
|
||||
|
||||
@@ -70,6 +73,8 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <inheritdoc />
|
||||
public Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRemove)
|
||||
throw new ServiceResultException(StatusCodes.BadSessionClosed, "Session closed");
|
||||
lock (_itemsLock)
|
||||
{
|
||||
RemoveCount++;
|
||||
|
||||
+29
-10
@@ -3,6 +3,7 @@ using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
@@ -17,7 +18,6 @@ public class AggregateTypeMapperTests
|
||||
[InlineData(AggregateType.Count)]
|
||||
[InlineData(AggregateType.Start)]
|
||||
[InlineData(AggregateType.End)]
|
||||
[InlineData(AggregateType.StandardDeviation)]
|
||||
[InlineData(AggregateType.TimeAverage)]
|
||||
[InlineData(AggregateType.TimeAverage2)]
|
||||
[InlineData(AggregateType.Total)]
|
||||
@@ -72,14 +72,6 @@ public class AggregateTypeMapperTests
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that StandardDeviation aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_StandardDeviation_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.StandardDeviation)
|
||||
.ShouldBe(ObjectIds.AggregateFunction_StandardDeviationPopulation);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid aggregate type value throws ArgumentOutOfRangeException.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_InvalidValue_Throws()
|
||||
@@ -98,4 +90,31 @@ public class AggregateTypeMapperTests
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Total2).ShouldBe(ObjectIds.AggregateFunction_Total2);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Interpolative).ShouldBe(ObjectIds.AggregateFunction_Interpolative);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client offers exactly the aggregates the server's SQL backends serve (<see cref="AggregateFamilies.Served"/>):
|
||||
/// nothing the server always refuses (the old <c>StandardDeviation</c>), nothing served left unreachable
|
||||
/// (Range, Delta, Min/MaxActualTime, PercentGood/Bad).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Client_aggregates_are_exactly_the_served_set()
|
||||
{
|
||||
Enum.GetNames<AggregateType>().Order()
|
||||
.ShouldBe(AggregateFamilies.Served.Select(a => a.ToString()).Order());
|
||||
}
|
||||
|
||||
/// <summary>Each aggregate maps to the Part 13 ObjectId of the same name.</summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
|
||||
{
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
|
||||
field.ShouldNotBeNull();
|
||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
|
||||
}
|
||||
|
||||
/// <summary>Every client aggregate.</summary>
|
||||
/// <returns>Each <see cref="AggregateType"/> value.</returns>
|
||||
public static TheoryData<AggregateType> AllAggregates() => new(Enum.GetValues<AggregateType>());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the client-side HistoryRead paging loop shared by the raw and modified reads: continuation pages are read
|
||||
/// (never released) while the caller wants more, and a read stopped by the caller's own cap releases the server cursor.
|
||||
/// </summary>
|
||||
public class HistoryReadPagingTests
|
||||
{
|
||||
private static readonly NodeId Node = new("t", 2);
|
||||
|
||||
/// <summary>One recorded call to the page reader.</summary>
|
||||
private sealed record Call(byte[]? ContinuationPoint, bool Release);
|
||||
|
||||
/// <summary>A scripted server: each read returns the next page; a release returns an empty Good result.</summary>
|
||||
private sealed class ScriptedServer
|
||||
{
|
||||
private readonly Queue<(int Count, byte[]? Cp)> _pages;
|
||||
public List<Call> Calls { get; } = [];
|
||||
|
||||
public ScriptedServer(params (int Count, byte[]? Cp)[] pages) => _pages = new Queue<(int, byte[]?)>(pages);
|
||||
|
||||
public Task<HistoryReadResult?> ReadPageAsync(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(new Call(cp, release));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
var (count, next) = _pages.Dequeue();
|
||||
var data = new HistoryData();
|
||||
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
ContinuationPoint = next,
|
||||
HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
||||
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
|
||||
|
||||
/// <summary>
|
||||
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
||||
/// page. Releasing it instead (the old raw path passed <c>release = cp != null</c>) silently truncated every raw read
|
||||
/// to its first server page.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Continuation_pages_are_read_not_released_until_the_server_finishes()
|
||||
{
|
||||
var server = new ScriptedServer((3, [1]), (3, [2]), (2, null));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 100, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(8);
|
||||
server.Calls.Select(c => c.Release).ShouldAllBe(r => r == false);
|
||||
server.Calls.Select(c => c.ContinuationPoint?.FirstOrDefault()).ShouldBe(new byte?[] { null, 1, 2 });
|
||||
}
|
||||
|
||||
/// <summary>A read stopped by the caller's cap while the server still holds a cursor releases that cursor.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_stopped_by_the_cap_releases_the_outstanding_continuation_point()
|
||||
{
|
||||
var server = new ScriptedServer((5, [7]));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 5, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(5);
|
||||
server.Calls.Count.ShouldBe(2);
|
||||
server.Calls[1].Release.ShouldBeTrue();
|
||||
server.Calls[1].ContinuationPoint.ShouldBe(new byte[] { 7 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read the server finished (no continuation point) has nothing to release — and the positive control above
|
||||
/// proves a release would have been recorded had one been owed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_read_the_server_finished_issues_no_release()
|
||||
{
|
||||
var server = new ScriptedServer((5, null));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 5, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(5);
|
||||
server.Calls.ShouldHaveSingleItem().Release.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m3: a non-positive cap is refused. An uncapped client read would materialise a tag's whole window
|
||||
/// (the client side of R08 1.1), and <c>(uint)-1</c> on the wire asked the server for everything.
|
||||
/// </summary>
|
||||
/// <param name="max">The cap.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task A_non_positive_cap_is_refused_before_any_read(int max)
|
||||
{
|
||||
var server = new ScriptedServer();
|
||||
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, max, Node, CancellationToken.None));
|
||||
server.Calls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Review m2: server pages that overshoot the cap are trimmed to it (60 + 60 with a cap of 100 is 100).</summary>
|
||||
[Fact]
|
||||
public async Task The_result_is_trimmed_to_the_cap()
|
||||
{
|
||||
var server = new ScriptedServer((60, [1]), (60, [2]));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 100, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(100);
|
||||
server.Calls.Last().Release.ShouldBeTrue(); // the outstanding cursor is still released
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m1: a read cancelled between pages releases the outstanding cursor (best effort, not on the caller's
|
||||
/// cancelled token) and still surfaces the cancellation. Positive control: the release IS recorded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_read_cancelled_mid_paging_releases_the_cursor_and_rethrows()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var calls = new List<(byte[]? Cp, bool Release, bool TokenCancelled)>();
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
calls.Add((cp, release, ct.IsCancellationRequested));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, cts.Token));
|
||||
|
||||
var release = calls.Where(c => c.Release).ShouldHaveSingleItem();
|
||||
release.Cp.ShouldBe(new byte[] { 5 });
|
||||
release.TokenCancelled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A cancellation that lands while the cap release is in flight does not lose the values read.</summary>
|
||||
[Fact]
|
||||
public async Task The_cap_release_does_not_ride_the_callers_token()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var releaseSawCancelled = (bool?)null;
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
if (release)
|
||||
{
|
||||
releaseSawCancelled = ct.IsCancellationRequested;
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
}
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
cts.Cancel(); // the caller gives up right as the capped page arrives
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 1, Node, cts.Token);
|
||||
|
||||
values.Count.ShouldBe(1);
|
||||
releaseSawCancelled.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>A failed release is best effort: the values already read are still returned.</summary>
|
||||
[Fact]
|
||||
public async Task A_failed_release_does_not_lose_the_values_read()
|
||||
{
|
||||
var calls = 0;
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
calls++;
|
||||
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 1, Node, CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(1);
|
||||
calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>A refused page still surfaces as an exception, not an empty read.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_page_throws()
|
||||
{
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct) =>
|
||||
Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.BadHistoryOperationUnsupported
|
||||
});
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -132,10 +132,10 @@ public class ModelConstructionTests
|
||||
((int)ConnectionState.Reconnecting).ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AggregateType enum has the expected number of values.</summary>
|
||||
/// <summary>AggregateType holds the seventeen served aggregates (the parity test pins which).</summary>
|
||||
[Fact]
|
||||
public void AggregateType_Enum_HasExpectedValues()
|
||||
{
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(12);
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(17);
|
||||
}
|
||||
}
|
||||
@@ -384,10 +384,12 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches.
|
||||
/// R08 3.6: browsing a level issues ONE Browse (plus BrowseNext pages) however many Object children it holds. Child
|
||||
/// detection is lazy: an Object may have children and is confirmed when it is expanded/recursed, instead of one
|
||||
/// extra Browse per Object child (~50 000 calls for a recursive subscribe over a 50k-node raw tree).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
|
||||
public async Task BrowseAsync_ObjectChildren_MayHaveChildren_WithoutAPerChildBrowse()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
@@ -398,17 +400,29 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
|
||||
DisplayName = new LocalizedText("Folder1"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
|
||||
DisplayName = new LocalizedText("Folder2"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
|
||||
DisplayName = new LocalizedText("Tag"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
],
|
||||
HasChildrenResponse = true
|
||||
]
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var results = await _service.BrowseAsync();
|
||||
|
||||
results[0].HasChildren.ShouldBeTrue();
|
||||
session.HasChildrenCount.ShouldBe(1);
|
||||
results.Select(r => r.HasChildren).ShouldBe([true, true, false]);
|
||||
session.BrowseCount.ShouldBe(1);
|
||||
session.BrowseNextCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1432,6 +1446,97 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 4.5: a ConnectAsync on an already-connected client is refused instead of overwriting (and leaking) the live
|
||||
/// session. The positive control: after a disconnect the same client connects again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_WhenAlreadyConnected_IsRefused_AndKeepsTheLiveSession()
|
||||
{
|
||||
var session1 = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session1);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => _service.ConnectAsync(ValidSettings()));
|
||||
|
||||
_sessionFactory.CreateCallCount.ShouldBe(1);
|
||||
session1.Disposed.ShouldBeFalse();
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
_sessionFactory.CreateCallCount.ShouldBe(2);
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 4.5: a SubscribeAsync in flight when a keep-alive failover starts must not be lost. Failover runs under the
|
||||
/// subscription gate, so it waits for the in-flight subscribe to record its item and then replays it onto the new
|
||||
/// session. Before the fix, failover nulled the subscription state outside the lock and finished first; the subscribe
|
||||
/// then completed onto the dying session's subscription and was never replayed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_in_flight_during_failover_is_replayed_onto_the_new_session()
|
||||
{
|
||||
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 gate = new TaskCompletionSource();
|
||||
session1.CreateSubscriptionGate = gate;
|
||||
var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000);
|
||||
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
session1.SimulateKeepAlive(false);
|
||||
var failover = _service.LastFailover.ShouldNotBeNull();
|
||||
|
||||
gate.SetResult();
|
||||
await subscribe.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await failover.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
|
||||
var replayed = session2.CreatedSubscriptions.ShouldHaveSingleItem();
|
||||
replayed.AddDataChangeCount.ShouldBe(1);
|
||||
|
||||
// And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's.
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"));
|
||||
replayed.RemoveCount.ShouldBe(1);
|
||||
replayed.ActiveHandles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A DisconnectAsync issued while a failover is still connecting wins: the failover disposes the session it just
|
||||
/// opened instead of resurrecting the connection. The positive control is the plain failover test above, where the
|
||||
/// same gated attempt ends Connected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_during_failover_is_not_undone_by_the_failover()
|
||||
{
|
||||
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 gate = new TaskCompletionSource();
|
||||
_sessionFactory.CreateGate = gate;
|
||||
session1.SimulateKeepAlive(false);
|
||||
var failover = _service.LastFailover.ShouldNotBeNull();
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
_service.IsConnected.ShouldBeFalse();
|
||||
session2.Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-005: concurrent subscribe/unsubscribe calls mutating the
|
||||
/// active-subscription bookkeeping must not corrupt the dictionary or throw.
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Client\ZB.MOM.WW.OtOpcUa.Client.Shared\ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<!-- Test-only: the served-aggregate parity oracle (AggregateFamilies.Served). -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -141,7 +141,16 @@ public class HistoryViewModelTests
|
||||
public void AggregateTypes_ContainsNullForRaw()
|
||||
{
|
||||
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
|
||||
_vm.AggregateTypes.Count.ShouldBe(13); // null + 12 enum values
|
||||
_vm.AggregateTypes.Count.ShouldBe(Enum.GetValues<AggregateType>().Length + 1); // null + every aggregate
|
||||
}
|
||||
|
||||
/// <summary>The picker offers every client aggregate (the served set), including Range/Delta/PercentGood/...</summary>
|
||||
[Fact]
|
||||
public void AggregateTypes_offers_every_aggregate()
|
||||
{
|
||||
_vm.AggregateTypes.Where(a => a != null).Select(a => a!.Value)
|
||||
.ShouldBe(Enum.GetValues<AggregateType>(), ignoreOrder: true);
|
||||
_vm.AggregateTypes.ShouldContain(AggregateType.PercentGood);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Clear method resets the view model state.</summary>
|
||||
|
||||
@@ -111,6 +111,70 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 3.6: notifications are routed through a NodeId index, not a scan of every row. The index follows every
|
||||
/// mutation of <see cref="SubscriptionsViewModel.ActiveSubscriptions"/> (add, remove, replace, clear).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NodeId_index_follows_every_collection_mutation()
|
||||
{
|
||||
var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
|
||||
var c = new SubscriptionItemViewModel("ns=2;s=C", 1000);
|
||||
_vm.ActiveSubscriptions.Add(a);
|
||||
_vm.ActiveSubscriptions.Add(b);
|
||||
_vm.ActiveSubscriptions.Add(c);
|
||||
_vm.IndexedNodeCount.ShouldBe(3);
|
||||
|
||||
_vm.ActiveSubscriptions.Remove(b);
|
||||
_vm.IndexedNodeCount.ShouldBe(2);
|
||||
|
||||
_vm.ActiveSubscriptions[0] = new SubscriptionItemViewModel("ns=2;s=D", 1000); // replaces A
|
||||
Raise("ns=2;s=A", 1);
|
||||
Raise("ns=2;s=D", 2);
|
||||
a.Value.ShouldBeNull();
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBe("2");
|
||||
|
||||
_vm.ActiveSubscriptions.Clear();
|
||||
_vm.IndexedNodeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A removed row stops receiving values while its sibling (the positive control) still does.</summary>
|
||||
[Fact]
|
||||
public void DataChanged_after_remove_updates_only_the_remaining_rows()
|
||||
{
|
||||
var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
|
||||
_vm.ActiveSubscriptions.Add(a);
|
||||
_vm.ActiveSubscriptions.Add(b);
|
||||
_vm.ActiveSubscriptions.Remove(a);
|
||||
|
||||
Raise("ns=2;s=A", 7);
|
||||
Raise("ns=2;s=B", 42);
|
||||
|
||||
a.Value.ShouldBeNull();
|
||||
b.Value.ShouldBe("42");
|
||||
}
|
||||
|
||||
/// <summary>Two rows for the same node (no dedup on the manual-add path) both update, as before.</summary>
|
||||
[Fact]
|
||||
public void DataChanged_updates_every_row_for_the_node()
|
||||
{
|
||||
var first = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var second = new SubscriptionItemViewModel("ns=2;s=A", 500);
|
||||
_vm.ActiveSubscriptions.Add(first);
|
||||
_vm.ActiveSubscriptions.Add(second);
|
||||
|
||||
Raise("ns=2;s=A", 5);
|
||||
|
||||
first.Value.ShouldBe("5");
|
||||
second.Value.ShouldBe("5");
|
||||
}
|
||||
|
||||
private void Raise(string nodeId, int value) =>
|
||||
_service.RaiseDataChanged(new DataChangedEventArgs(nodeId,
|
||||
new DataValue(new Variant(value), StatusCodes.Good, DateTime.UtcNow)));
|
||||
|
||||
/// <summary>Verifies that Clear removes all subscriptions.</summary>
|
||||
[Fact]
|
||||
public void Clear_RemovesAllSubscriptions()
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7: the AbCip probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
|
||||
/// Ctrl-C printed a stack trace. A command cancelled by the operator now exits cleanly with <c>Cancelled.</c>.
|
||||
/// The gateway is TEST-NET (never answers), so nothing is sent to a real device.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CancellationHandlingTests
|
||||
{
|
||||
private const string Blackhole = "ab://192.0.2.1/1,0";
|
||||
|
||||
private static async Task<string> RunCancelledAsync(AbCipCommandBase command)
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
console.RequestCancellation();
|
||||
|
||||
await Should.NotThrowAsync(async () => await command.ExecuteAsync(console));
|
||||
return console.ReadErrorString();
|
||||
}
|
||||
|
||||
/// <summary>A cancelled read prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_read_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new ReadCommand { Gateway = Blackhole, TagPath = "Motor01" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancelled probe prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_probe_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new ProbeCommand { Gateway = Blackhole, TagPath = "Motor01" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancelled write prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_write_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new WriteCommand { Gateway = Blackhole, TagPath = "Motor01", Value = "1" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7, shared half: arrays render their elements (not <c>System.Int32[]</c>), Ctrl-C prints
|
||||
/// <c>Cancelled.</c>, and the subscribe change-line writer is serialised and never throws into the poll loop.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CliHelpersU7Tests
|
||||
{
|
||||
private sealed record Handle(string DiagnosticId) : ISubscriptionHandle;
|
||||
|
||||
private static DataChangeEventArgs Change(string reference, object? value) =>
|
||||
new(new Handle("h"), reference, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
/// <summary>An array value renders as its elements, each formatted like a scalar.</summary>
|
||||
[Fact]
|
||||
public void FormatValue_renders_array_elements()
|
||||
{
|
||||
SnapshotFormatter.FormatValue(new[] { 1, 2, 3 }).ShouldBe("[1, 2, 3]");
|
||||
SnapshotFormatter.FormatValue(new[] { 1.5f, -2f }).ShouldBe("[1.5, -2]");
|
||||
SnapshotFormatter.FormatValue(new[] { true, false }).ShouldBe("[true, false]");
|
||||
SnapshotFormatter.FormatValue(new[] { "a", "b" }).ShouldBe("[\"a\", \"b\"]");
|
||||
SnapshotFormatter.FormatValue(new List<short> { 7 }).ShouldBe("[7]");
|
||||
SnapshotFormatter.FormatValue(Array.Empty<int>()).ShouldBe("[]");
|
||||
}
|
||||
|
||||
/// <summary>A string is still a scalar, not a sequence of chars (positive control for the array arm).</summary>
|
||||
[Fact]
|
||||
public void FormatValue_keeps_a_string_scalar()
|
||||
{
|
||||
SnapshotFormatter.FormatValue("abc").ShouldBe("\"abc\"");
|
||||
}
|
||||
|
||||
/// <summary>Operator Ctrl-C prints Cancelled. instead of a stack trace.</summary>
|
||||
[Fact]
|
||||
public async Task RunCancellableAsync_operator_cancellation_prints_Cancelled()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await DriverCommandBase.RunCancellableAsync(console, cts.Token, () => Task.FromCanceled(cts.Token)));
|
||||
|
||||
console.ReadErrorString().ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancellation the operator did not request still propagates.</summary>
|
||||
[Fact]
|
||||
public async Task RunCancellableAsync_unrequested_cancellation_propagates()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await DriverCommandBase.RunCancellableAsync(console, CancellationToken.None,
|
||||
() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>A change line carries the reference, the formatted value and the status.</summary>
|
||||
[Fact]
|
||||
public void ChangeLineWriter_writes_one_formatted_line_per_change()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(output, new object());
|
||||
|
||||
write(this, Change("Tag:DInt", new[] { 4, 5 }));
|
||||
|
||||
var line = output.ToString().TrimEnd();
|
||||
line.ShouldContain("Tag:DInt = [4, 5] (0x00000000 (Good))");
|
||||
}
|
||||
|
||||
/// <summary>Concurrent poll-thread changes produce whole lines, never interleaved fragments.</summary>
|
||||
[Fact]
|
||||
public async Task ChangeLineWriter_serialises_concurrent_writes()
|
||||
{
|
||||
var output = new StringWriter(new StringBuilder());
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(output, new object());
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < 200; i++) write(this, Change($"T{t}", i));
|
||||
})));
|
||||
|
||||
var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
lines.Length.ShouldBe(1600);
|
||||
lines.ShouldAllBe(l => l.StartsWith('[') && l.EndsWith("(Good))"));
|
||||
}
|
||||
|
||||
/// <summary>A failing console (closed pipe) is swallowed so it cannot fault the driver's poll loop.</summary>
|
||||
[Fact]
|
||||
public void ChangeLineWriter_swallows_a_console_write_failure()
|
||||
{
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(new ThrowingWriter(), new object());
|
||||
|
||||
Should.NotThrow(() => write(this, Change("T", 1)));
|
||||
}
|
||||
|
||||
private sealed class ThrowingWriter : TextWriter
|
||||
{
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override void Write(char value) => throw new IOException("pipe closed");
|
||||
public override void WriteLine(string? value) => throw new IOException("pipe closed");
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,15 @@ public sealed class AbStatusCodesTests
|
||||
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x1E, AbStatus.BadReply)]
|
||||
[InlineData((byte)0x01, AbStatus.BadReply)]
|
||||
[InlineData((byte)0x07, AbStatus.BadReply)] // connection lost: opaque on purpose (AbLegacy #738)
|
||||
// Lane K F1 (#737): the controller answered and refused this request — not a garbled reply.
|
||||
[InlineData((byte)0x1C, AbStatus.Unsupported)] // missing attribute list entry data
|
||||
[InlineData((byte)0x20, AbStatus.Unsupported)] // invalid parameter
|
||||
public void Cip_general_status_maps(byte general, AbStatus expected) =>
|
||||
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)0x2104, AbStatus.OutOfBounds)] // offset beyond end of the tag (Lane K F1)
|
||||
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
|
||||
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
|
||||
[InlineData((ushort)0x9999, AbStatus.BadReply)]
|
||||
@@ -37,16 +42,42 @@ public sealed class AbStatusCodesTests
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, null, AbStatus.Ok)]
|
||||
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
|
||||
[InlineData((byte)0x20, null, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
|
||||
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
|
||||
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x10, null, AbStatus.Unsupported)] // illegal command or format
|
||||
// Lane K F3 (AbLegacy F8, review 07 r0 I4), per the DF1/PCCC STS table (1770-6.5.16):
|
||||
[InlineData((byte)0x20, null, AbStatus.BadConnection)] // host has a problem and will not communicate
|
||||
[InlineData((byte)0x30, null, AbStatus.BadConnection)] // remote node host missing / disconnected / shut down
|
||||
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)] // host hardware fault (the host answered)
|
||||
[InlineData((byte)0x50, null, AbStatus.NotFound)] // addressing problem or memory protect rungs
|
||||
[InlineData((byte)0x60, null, AbStatus.NotAllowed)] // function not allowed due to command protection
|
||||
[InlineData((byte)0x70, null, AbStatus.DeviceStateConflict)] // processor is in Program mode
|
||||
[InlineData((byte)0x80, null, AbStatus.BadReply)] // compatibility-mode file missing: unclassified
|
||||
[InlineData((byte)0x90, null, AbStatus.BadConnection)] // remote node cannot buffer command
|
||||
[InlineData((byte)0xB0, null, AbStatus.DeviceStateConflict)] // remote node problem due to download
|
||||
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)] // address does not point to something usable
|
||||
[InlineData((byte)0xF0, (byte)0x07, AbStatus.OutOfBounds)] // file is wrong size
|
||||
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
|
||||
[InlineData((byte)0xF0, null, AbStatus.BadReply)]
|
||||
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
|
||||
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// A dead DH+/DH-485 node behind a bridge answers STS 0x30 (and a busy one 0x90). Neither proves the END
|
||||
/// device answered, so they are the no-reply comms class, never one of the "answered" refusals — that
|
||||
/// classification made such a device count as reachable forever (no backoff, no comms-loss watchdog).
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x20)]
|
||||
[InlineData((byte)0x30)]
|
||||
[InlineData((byte)0x90)]
|
||||
public void Pccc_no_reply_statuses_are_never_answered_refusals(byte sts)
|
||||
{
|
||||
var status = AbStatusCodes.FromPccc(sts, null);
|
||||
status.ShouldBe(AbStatus.BadConnection);
|
||||
status.ShouldNotBeOneOf(AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds,
|
||||
AbStatus.DeviceStateConflict, AbStatus.TooLarge, AbStatus.TooSmall, AbStatus.TypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ok_is_zero_and_every_error_is_negative()
|
||||
{
|
||||
|
||||
+24
@@ -115,6 +115,30 @@ public sealed class ConnectionManagerServicesTests
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane K F1 remapped general 0x1C / 0x20 and 0xFF/0x2104 for TAG requests (a per-request refusal). A Forward
|
||||
/// Open refused with one of them is still a connection failure, exactly as before: the whole connection is
|
||||
/// unusable, so it must not read as a per-tag refusal.
|
||||
/// </summary>
|
||||
/// <param name="hex">The refused Forward Open reply.</param>
|
||||
[Theory]
|
||||
[InlineData("DB001C00")]
|
||||
[InlineData("DB002000")]
|
||||
[InlineData("DB00FF010421")]
|
||||
public void Forward_open_refused_with_a_request_level_status_is_still_BadConnection(string hex)
|
||||
{
|
||||
var ex = Should.Throw<ForwardOpenRefusedException>(() =>
|
||||
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString(hex))));
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a Forward Open refusal that was already specifically mapped keeps its status.</summary>
|
||||
[Fact]
|
||||
public void Forward_open_refused_service_not_supported_stays_Unsupported() =>
|
||||
Should.Throw<ForwardOpenRefusedException>(() =>
|
||||
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString("DB000800"))))
|
||||
.Status.ShouldBe(AbStatus.Unsupported);
|
||||
|
||||
[Fact]
|
||||
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
|
||||
{
|
||||
|
||||
+43
-1
@@ -90,6 +90,32 @@ public sealed class PcccServicesTests
|
||||
[Fact]
|
||||
public void Plc5_write_is_not_shipped_and_flagged() => PcccServices.Plc5WriteVerified.ShouldBeFalse();
|
||||
|
||||
// ---- ParseReply: golden STS replies (Lane K F3 / AbLegacy F8) ----
|
||||
|
||||
/// <summary>
|
||||
/// Wire-level golden replies: the Execute PCCC reply bytes a device (or a bridge in front of it) sends for each
|
||||
/// STS, through <see cref="PcccServices.ParseReply"/>, to the status the drivers classify.
|
||||
/// </summary>
|
||||
/// <param name="stsHex">The reply's STS (+ EXT STS) bytes as hex.</param>
|
||||
/// <param name="expected">The status ParseReply raises.</param>
|
||||
[Theory]
|
||||
[InlineData("20", AbStatus.BadConnection)]
|
||||
[InlineData("30", AbStatus.BadConnection)]
|
||||
[InlineData("90", AbStatus.BadConnection)]
|
||||
[InlineData("50", AbStatus.NotFound)]
|
||||
[InlineData("60", AbStatus.NotAllowed)]
|
||||
[InlineData("70", AbStatus.DeviceStateConflict)]
|
||||
[InlineData("B0", AbStatus.DeviceStateConflict)]
|
||||
[InlineData("F0|06", AbStatus.NotFound)]
|
||||
[InlineData("F0|07", AbStatus.OutOfBounds)]
|
||||
public void ParseReply_golden_sts(string stsHex, AbStatus expected)
|
||||
{
|
||||
var parts = stsHex.Split('|');
|
||||
var hex = Id + "4F" + parts[0] + "9987" + (parts.Length > 1 ? parts[1] : "");
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(hex), 0x8799))
|
||||
.Status.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- ParseReply ----
|
||||
private static CipReply Reply(string hex, byte general = 0) =>
|
||||
new(0x4B, general, [], Hex(hex));
|
||||
@@ -122,7 +148,23 @@ public sealed class PcccServicesTests
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_cip_error_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
|
||||
/// <summary>Review I2: a status the device REPLIED with is marked as such (envelope CIP status and PCCC STS).</summary>
|
||||
[Fact]
|
||||
public void ParseReply_marks_device_statuses_as_device_replies()
|
||||
{
|
||||
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply("", 0x20), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply(Id + "4F10 9987"), 0x8799)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a refusal raised by a builder before anything is sent is NOT a device reply.</summary>
|
||||
[Fact]
|
||||
public void A_pre_wire_builder_refusal_is_not_a_device_reply()
|
||||
{
|
||||
var ex = Should.Throw<AbTransportException>(() => PcccServices.SlcTypedRead(1, A("N7:0"), 999));
|
||||
ex.ShouldNotBeOfType<AbDeviceReplyException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_wrong_command_byte_is_BadReply() =>
|
||||
|
||||
@@ -25,3 +25,60 @@ public sealed class CipWriterTests
|
||||
w.ToArray().ShouldBe(new byte[] { 0, 0, 7 }); // nothing was patched
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R07:L5: <see cref="CipWriter"/> works in a buffer rented from <see cref="System.Buffers.ArrayPool{T}"/> and hands
|
||||
/// it back on <see cref="CipWriter.ToArray"/>. The returned array must be the caller's own (a later writer reusing
|
||||
/// the pooled buffer cannot change it), growth must keep every byte, and a writer used after ToArray must refuse
|
||||
/// rather than write into a buffer it no longer owns.
|
||||
/// </summary>
|
||||
public sealed class CipWriterPoolingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Growth_past_the_initial_buffer_keeps_every_byte()
|
||||
{
|
||||
var w = new CipWriter();
|
||||
for (var i = 0; i < 1000; i++) w.U8((byte)i);
|
||||
var bytes = w.ToArray();
|
||||
bytes.Length.ShouldBe(1000);
|
||||
bytes.Select((b, i) => b == (byte)i).ShouldAllBe(ok => ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_returned_array_is_not_affected_by_a_later_writer_reusing_the_pooled_buffer()
|
||||
{
|
||||
var first = new CipWriter().U32(0xAABBCCDD).ToArray();
|
||||
for (var i = 0; i < 50; i++) new CipWriter().U32(0x11111111).U32(0x22222222).ToArray();
|
||||
first.ShouldBe(new byte[] { 0xDD, 0xCC, 0xBB, 0xAA });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToArray_twice_returns_equal_independent_copies()
|
||||
{
|
||||
var w = new CipWriter().U16(0x0102);
|
||||
var a = w.ToArray();
|
||||
var b = w.ToArray();
|
||||
b.ShouldBe(a);
|
||||
b.ShouldNotBeSameAs(a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writing_after_ToArray_is_refused()
|
||||
{
|
||||
var w = new CipWriter().U8(1);
|
||||
_ = w.ToArray();
|
||||
Should.Throw<InvalidOperationException>(() => w.U8(2));
|
||||
Should.Throw<InvalidOperationException>(() => w.PatchU16(0, 1));
|
||||
w.Length.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_writers_produce_independent_frames()
|
||||
{
|
||||
// The Execute PCCC / encapsulation builders build an inner writer while an outer one is live.
|
||||
var outer = new CipWriter().U8(0xAA);
|
||||
var inner = new CipWriter().U8(0x01).U8(0x02).ToArray();
|
||||
var frame = outer.Bytes(inner).U8(0xBB).ToArray();
|
||||
frame.ShouldBe(new byte[] { 0xAA, 0x01, 0x02, 0xBB });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,11 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
[InlineData("D2000A00", AbStatus.OutOfBounds)]
|
||||
[InlineData("D2000F00", AbStatus.NotAllowed)]
|
||||
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
|
||||
// Lane K F1 (#737): these used to arrive as BadReply, i.e. a comms fault (Degraded + evict) for a tag the
|
||||
// controller had simply refused. AbCipRefusals.IsDeviceRefusal classifies the new statuses with no driver change.
|
||||
[InlineData("D2001C00", AbStatus.Unsupported)] // missing attribute list entry data
|
||||
[InlineData("D2002000", AbStatus.Unsupported)] // invalid parameter
|
||||
[InlineData("D200FF010421", AbStatus.OutOfBounds)] // offset beyond end of the tag
|
||||
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
|
||||
|
||||
+19
-3
@@ -220,14 +220,30 @@ public sealed class NativeAbLegacyTagRuntimeTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task STS_0x20_on_initialize_is_NotFound()
|
||||
public async Task STS_0x50_on_initialize_is_NotFound()
|
||||
{
|
||||
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x20, null, []));
|
||||
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x50, null, []));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
|
||||
ex.Status.ShouldBe(AbStatus.NotFound);
|
||||
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane K F3 (F8): STS 0x20 / 0x30 come from the host or a bridge when the END device cannot be reached, so they
|
||||
/// are the no-reply class (BadConnection), not an answered refusal.
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x20)]
|
||||
[InlineData((byte)0x30)]
|
||||
public async Task STS_from_a_bridge_for_an_unreachable_node_is_BadConnection(byte sts)
|
||||
{
|
||||
await using var h = new Harness("slc500", "N7:0").Responder((_, _) => (sts, null, []));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
h.Runtime.GetStatus().ShouldBe((int)AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_performs_exactly_one_read()
|
||||
{
|
||||
@@ -305,7 +321,7 @@ public sealed class NativeAbLegacyTagRuntimeTests
|
||||
public async Task A_failed_second_write_chunk_is_reported_as_failed_not_Good()
|
||||
{
|
||||
await using var h = new Harness("slc500", "N7:0", count: 130).Responder((_, n) =>
|
||||
n == 1 ? (0x30, null, []) : (0x00, null, []));
|
||||
n == 1 ? (0x60, null, []) : (0x00, null, []));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.WriteAsync(CancellationToken.None));
|
||||
ex.Status.ShouldBe(AbStatus.NotAllowed);
|
||||
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
|
||||
/// <see cref="NativeAbLegacyTagFactory"/>: a bridge (DHRIO / 1761-NET-ENI) in front of a dead DH+ node answers every
|
||||
/// PCCC request with STS 0x30 "remote node host missing". That is NOT the device answering, so the driver must treat
|
||||
/// it as a comms fault — Degraded, <c>LastSuccessfulRead</c> not advanced — or the comms-loss watchdog never fires
|
||||
/// for it. The positive control is a live device refusing a tag (STS 0x60, command protection): per-tag Bad, Healthy,
|
||||
/// <c>LastSuccessfulRead</c> advanced.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NativePcccBridgeStatusHealthTests
|
||||
{
|
||||
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
|
||||
|
||||
/// <summary>A fake whose Execute PCCC envelope itself is refused with CIP general status <paramref name="general"/>.</summary>
|
||||
private static FakeEipServer CipEnvelopeServer(byte general)
|
||||
{
|
||||
var server = new FakeEipServer { ZeroReplyContext = true };
|
||||
server.Handler = req => FakeEipServer.Command(req) == Ab.Transport.Eip.EncapCommand.SendRRData
|
||||
? FakeEipServer.RRReply(req, [0xCB, 0x00, general, 0x00])
|
||||
: null;
|
||||
return server;
|
||||
}
|
||||
|
||||
/// <summary>A PCCC fake that answers every request with one fixed STS.</summary>
|
||||
private static FakeEipServer StsServer(byte sts)
|
||||
{
|
||||
var server = new FakeEipServer { ZeroReplyContext = true };
|
||||
server.Handler = req =>
|
||||
{
|
||||
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
|
||||
var cip = FakeEipServer.RequestCip(req);
|
||||
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
|
||||
return FakeEipServer.RRReply(req, [.. head, sts, cip[15], cip[16]]);
|
||||
};
|
||||
return server;
|
||||
}
|
||||
|
||||
#pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap)
|
||||
private static async Task<AbLegacyDriver> DriverAsync(FakeEipServer server)
|
||||
{
|
||||
var host = $"ab://127.0.0.1:{server.Port}/1,0";
|
||||
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
||||
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("T", host, "N7:0", AbLegacyDataType.Int)]),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-pccc-bridge", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>STS 0x30 from a bridge: a comms fault, never evidence the device answered.</summary>
|
||||
[Fact]
|
||||
public async Task A_dead_node_behind_a_bridge_degrades_and_does_not_advance_LastSuccessfulRead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var server = StsServer(0x30);
|
||||
var driver = await DriverAsync(server);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
||||
|
||||
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded);
|
||||
health.LastSuccessfulRead.ShouldBe(before);
|
||||
await driver.ShutdownAsync(ct);
|
||||
server.HandlerErrors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.</summary>
|
||||
[Fact]
|
||||
public async Task A_live_device_refusing_the_tag_stays_Healthy_and_advances_LastSuccessfulRead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var server = StsServer(0x60);
|
||||
var driver = await DriverAsync(server);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
||||
|
||||
result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead.ShouldNotBe(before);
|
||||
await driver.ShutdownAsync(ct);
|
||||
server.HandlerErrors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I2: a refusal that came OFF THE WIRE — the device (or the bridge's Execute PCCC object) answered with
|
||||
/// CIP 0x08 / 0x1C / 0x20 on the envelope, or PCCC STS 0x10 — is an answered refusal: per-tag BadNotSupported,
|
||||
/// Healthy, <c>LastSuccessfulRead</c> advanced. It must not land in the "refused before the wire" neutral class
|
||||
/// (which advances nothing). The local-refusal counterpart stays neutral (NativeAbLegacyRefusedWriteTests).
|
||||
/// </summary>
|
||||
/// <param name="general">The CIP general status of the envelope reply, or 0 for a PCCC STS 0x10 reply.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x08)]
|
||||
[InlineData((byte)0x1C)]
|
||||
[InlineData((byte)0x20)]
|
||||
[InlineData((byte)0x00)]
|
||||
public async Task A_wire_Unsupported_reply_is_an_answered_refusal(byte general)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var server = general == 0 ? StsServer(0x10) : CipEnvelopeServer(general);
|
||||
var driver = await DriverAsync(server);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
||||
|
||||
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead.ShouldNotBe(before);
|
||||
await driver.ShutdownAsync(ct);
|
||||
server.HandlerErrors.ShouldBeEmpty();
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:L5 (Modbus half): the RTU ADU is now written into a pooled buffer and the response is deframed in one pooled
|
||||
/// buffer. The wire bytes and the decoded PDUs must be exactly what the allocating code produced, and a reused pooled
|
||||
/// buffer must never leak a previous frame's bytes into the next result.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusPooledFramingTests
|
||||
{
|
||||
/// <summary>The pooled ADU writer matches the reference construction (unit + PDU, CRC appended low byte first).</summary>
|
||||
/// <param name="hex">The PDU as hex.</param>
|
||||
[Theory]
|
||||
[InlineData("0300000001")]
|
||||
[InlineData("10000A0002040001000200")]
|
||||
[InlineData("06")]
|
||||
public void WriteAdu_matches_the_reference_ADU(string hex)
|
||||
{
|
||||
var pdu = Convert.FromHexString(hex);
|
||||
var reference = ModbusCrc.Append([0x11, .. pdu]);
|
||||
|
||||
var pooled = new byte[ModbusRtuFraming.AduLength(pdu.Length) + 7]; // oversized, like a rented array
|
||||
ModbusRtuFraming.WriteAdu(0x11, pdu, pooled.AsSpan(0, ModbusRtuFraming.AduLength(pdu.Length)));
|
||||
|
||||
pooled.AsSpan(0, reference.Length).ToArray().ShouldBe(reference);
|
||||
ModbusRtuFraming.BuildAdu(0x11, pdu).ShouldBe(reference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A long read reply followed by a short echo and an exception frame, each deframed through the pooled buffer:
|
||||
/// every result is exactly its own PDU (no stale tail from the previous, longer frame).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Consecutive_deframes_through_the_pooled_buffer_do_not_leak_bytes()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
byte[] longRead = [0x03, 200, .. Enumerable.Range(0, 200).Select(i => (byte)(i + 1))];
|
||||
byte[] echo = [0x06, 0x00, 0x01, 0x00, 0x2A];
|
||||
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, longRead)), 1, ct))
|
||||
.ShouldBe(longRead);
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, echo)), 1, ct))
|
||||
.ShouldBe(echo);
|
||||
var ex = await Should.ThrowAsync<ModbusException>(() =>
|
||||
ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, [0x83, 0x02])), 1, ct));
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, [0x03, 2, 9, 8])), 1, ct))
|
||||
.ShouldBe(new byte[] { 0x03, 2, 9, 8 });
|
||||
}
|
||||
|
||||
/// <summary>A byte-count-255 read (the largest RTU reply) fits the pooled frame.</summary>
|
||||
[Fact]
|
||||
public async Task The_largest_read_reply_deframes()
|
||||
{
|
||||
byte[] max = [0x04, 255, .. new byte[255]];
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, max)), 1,
|
||||
TestContext.Current.CancellationToken)).ShouldBe(max);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user