Merge master 4fb04a9f (lane D-a) into remed/lane-D-b

ScriptEdit.razor conflict resolved keeping both D-a's on-demand Monaco sync +
Problems list and D-b's delete confirm.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 18:14:53 -04:00
85 changed files with 4618 additions and 812 deletions
@@ -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();
}
}
@@ -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++;
@@ -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.
@@ -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()
@@ -15,12 +15,27 @@ public sealed class DriverArrayWriteSupportTests
[Theory]
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", true)]
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // FC15 ignores dataType
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // absent dataType ⇒ Bool
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // a bit region COERCES any type to Bool
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int17\"" + Arr2 + "}", false)] // ...but a typo refuses the tag
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"" + Arr2 + "}", true)]
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"" + Arr2 + "}", true)] // case-insensitive, like the driver
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0" + Arr2 + "}", true)] // dataType ⇒ Int16
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", true)] // #741 U3
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\",\"bitIndex\":15" + Arr2 + "}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\",\"bitIndex\":16" + Arr2 + "}", false)] // factory refuses
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":4" + Arr2 + "}", true)] // #741 U3
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)] // no stringLength ⇒ refused
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":0" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"byteOrder\":\"Sideways\"" + Arr2 + "}", false)] // strict enum
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":4,\"stringByteOrder\":\"Middle\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65536,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // not a register address
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":1.5,\"dataType\":\"Int16\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":1}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)] // span runs past 65535
[InlineData("{\"addressString\":\"40001:S::2\"" + Arr2 + "}", false)] // string-only: family unknown at compose time
[InlineData("{\"region\":\"HoldingRegisters\",\"addressString\":\"40001:S::2\"" + Arr2 + "}", false)] // region alone is not the keys
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"addressString\":\"30001\",\"dataType\":\"Int16\"" + Arr2 + "}", true)] // keys win
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // read-only region
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0" + Arr2 + "}", false)]
@@ -59,11 +74,50 @@ public sealed class DriverArrayWriteSupportTests
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":100}", "{\"maxRegistersPerWrite\":9}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":0}", null, "not json").ShouldBeTrue();
// A larger configured cap lifts the default: 124 registers fit under 200.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
// A configured cap above the FC16 protocol ceiling (123) never lifts it: a conformant device refuses 124
// registers (and past 127 the driver's one-byte byte-count field wraps), so the write always fails.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 123), "{\"maxRegistersPerWrite\":200}")
.ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("BitInRegister", 124), "{\"maxRegistersPerWrite\":200}")
.ShouldBeFalse();
}
/// <summary>A BitInRegister[] write is a read-modify-write: one unchunked FC03 of the whole span first, so its
/// length is also bounded by <c>maxRegistersPerRead</c> (smallest positive value across the configs; absent / 0 =>
/// 125, and never above FC03's protocol ceiling of 125). The other register shapes never read on write.</summary>
[Fact]
public void Modbus_bit_in_register_arrays_respect_the_read_cap()
{
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 64), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":64}").ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":100}", "{\"MaxRegistersPerRead\":64}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 123), "{\"maxRegistersPerRead\":0}").ShouldBeTrue();
// A numeric array of the same length is not read on write, so the read cap does not apply to it.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 65), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
// With the write cap lifted, the read cap is the bound.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 100),
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 101),
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeFalse();
}
/// <summary>Every shape's span must end at or before address 65535 - a conformant device refuses a write past it
/// (exception 02).</summary>
[Theory]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":6}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":7}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65532,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65533,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65534,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", true)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", false)]
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":536}", true)]
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":537}", false)]
public void Modbus_array_spans_end_within_the_address_space(string tagConfig, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
/// writable.</summary>
[Fact]
@@ -75,6 +129,40 @@ public sealed class DriverArrayWriteSupportTests
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1969}").ShouldBeFalse();
}
/// <summary>#741 U3 — String[] and BitInRegister[] arrays ride the same FC16 cap: <c>(stringLength+1)/2</c> registers
/// per String element, one per BitInRegister element.</summary>
[Theory]
[InlineData("\"dataType\":\"String\",\"stringLength\":4", 61, true)] // 122 registers
[InlineData("\"dataType\":\"String\",\"stringLength\":4", 62, false)] // 124
[InlineData("\"dataType\":\"String\",\"stringLength\":5", 41, true)] // 3 registers each: 123
[InlineData("\"dataType\":\"String\",\"stringLength\":5", 42, false)] // 126
[InlineData("\"dataType\":\"BitInRegister\"", 123, true)]
[InlineData("\"dataType\":\"BitInRegister\"", 124, false)]
public void Modbus_string_and_bit_arrays_respect_the_write_cap(string typeKeys, int length, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16",
$"{{\"region\":\"HoldingRegisters\",\"address\":0,{typeKeys},\"isArray\":true,\"arrayLength\":{length}}}").ShouldBe(expected);
/// <summary>Elements CONVERT (ScadaBridge's <c>int[]</c> to an Int16 node), so the Modbus row keys on no exact node
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element (a DateTime never converts; a String converts only when its text happens to
/// parse, which the server cannot promise). A
/// String element takes any node type (each element is <c>Convert.ToString</c>'d).</summary>
[Theory]
[InlineData("Int16", "Int16", true)]
[InlineData("Int16", "Int32", true)]
[InlineData("Int16", "Double", true)]
[InlineData("Int16", "Boolean", true)]
[InlineData("Int16", "String", false)]
[InlineData("Int16", "DateTime", false)]
[InlineData("BitInRegister", "Boolean", true)]
[InlineData("BitInRegister", "String", false)]
[InlineData("String", "String", true)]
[InlineData("String", "Int16", true)]
[InlineData("String", "DateTime", true)]
[InlineData("String", null, true)]
public void Modbus_rows_leave_out_only_node_types_that_never_convert(string element, string? nodeType, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, nodeType,
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{element}\",\"stringLength\":4" + Arr2 + "}").ShouldBe(expected);
private static string RegisterArray(string dataType, int length) =>
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{dataType}\",\"isArray\":true,\"arrayLength\":{length}}}";
@@ -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()
{
@@ -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()
{
@@ -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)) };
@@ -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);
@@ -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
}
@@ -5,131 +5,432 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For every
/// region × element type, the server advertises an array node writable exactly when the driver accepts a
/// whole-array write for it. A driver change that gains or loses an array encoder fails here until the table
/// follows — so the server can never advertise a write this driver refuses (nor hide one it accepts).
/// Gitea #610 / #741 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For
/// every region × element type × node DataType, and for every shape edge (the FC16 / FC15 caps, the declared
/// length, the type-dependent guards, the address keys), the server advertises an array node writable exactly when
/// <see cref="ModbusDriver.WriteAsync"/> encodes the whole array onto the wire. A driver change that gains or loses
/// an array encoder fails here until the table follows — so the server can never advertise a write this driver
/// refuses (nor hide one it accepts).
/// </summary>
/// <remarks>
/// "Accepted" means Good status AND a write PDU (FC15 / FC16 / FC23) the device took. The transport is a
/// spec-conformant device: it holds the whole 0–65535 address space and refuses what every conformant device
/// refuses — a quantity over a protocol ceiling (FC15 1968 coils, FC16 123 and FC03 125 registers) or a span past
/// address 65535. The driver sends one FC15 / FC16 (and, for BitInRegister[], one FC03) without chunking, so those
/// ceilings are the device's refusal; a configured <c>MaxRegistersPerWrite</c> below 123 is the driver's own. FC23
/// is opt-in and never changes the answer (pinned below).
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ModbusArrayWriteSupportParityTests
{
private static IEnumerable<(ModbusRegion Region, ModbusDataType DataType)> Shapes() =>
from region in Enum.GetValues<ModbusRegion>()
from dt in Enum.GetValues<ModbusDataType>()
select (region, dt);
private const string Ref = "cell/modbus/arr";
public static TheoryData<ModbusRegion, ModbusDataType> AllShapes()
/// <summary>The CLR array the node manager hands the driver for a node of this DataType (it converts a numeric
/// client array to the node's element type first).</summary>
private static Array ClrArray(string nodeType, int n) => nodeType switch
{
var data = new TheoryData<ModbusRegion, ModbusDataType>();
foreach (var (region, dt) in Shapes()) data.Add(region, dt);
return data;
}
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
"Int32" => Enumerable.Repeat(1, n).ToArray(),
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
"Float" => Enumerable.Repeat(1f, n).ToArray(),
"Double" => Enumerable.Repeat(1d, n).ToArray(),
// Non-numeric text: a String node over a numeric / bit element converts only when the text happens to parse,
// which is not a write the server can promise — the table keeps it read-only.
"String" => Enumerable.Repeat("a", n).ToArray(),
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
_ => throw new ArgumentOutOfRangeException(nameof(nodeType)),
};
/// <summary>
/// Shapes where the driver has moved ahead of the server-side table (review 07 P6 / U3, Modbus lane) and
/// <see cref="DriverArrayWriteSupport"/> — owned outside the Modbus lane — has not followed yet
/// (follow-up F1): the tag factory now REFUSES a Coils tag with a non-Bool type (so the driver serves no
/// such tag), and the driver now encodes String[] and BitInRegister[] holding-register writes.
/// <see cref="Pending_F1_divergences_still_exist"/> fails the moment the table follows, so this list
/// is removed with it rather than left to rot.
/// </summary>
private static bool PendingF1(ModbusRegion region, ModbusDataType dataType) =>
(region == ModbusRegion.Coils && dataType != ModbusDataType.Bool)
|| (region == ModbusRegion.HoldingRegisters && dataType is ModbusDataType.String or ModbusDataType.BitInRegister);
private static readonly string[] NodeTypes = ["Boolean", "Int16", "UInt16", "Int32", "Int64", "Float", "Double", "String", "DateTime"];
public static TheoryData<ModbusRegion, ModbusDataType> PendingShapes()
/// <summary>Drives the real driver: true iff the write is Good AND a write PDU reached the device.</summary>
private static async Task<(bool Accepted, uint Status)> DriverEncodesAsync(
string tagConfig, object value, ushort maxRegistersPerWrite = 123, bool fc23 = false, ushort maxRegistersPerRead = 125)
{
var data = new TheoryData<ModbusRegion, ModbusDataType>();
foreach (var (region, dt) in Shapes().Where(s => PendingF1(s.Region, s.DataType))) data.Add(region, dt);
return data;
}
[Theory]
[MemberData(nameof(PendingShapes))]
public void Pending_F1_divergences_still_exist(ModbusRegion region, ModbusDataType dataType)
{
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
var driverAccepts = region == ModbusRegion.HoldingRegisters; // String[] / BitInRegister[] encode; mistyped Coils are refused
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldNotBe(driverAccepts,
"F1 landed — DriverArrayWriteSupport now agrees with the driver for this shape; delete it from PendingF1");
}
[Theory]
[MemberData(nameof(AllShapes))]
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
{
if (PendingF1(region, dataType)) return; // covered by Pending_F1_divergences_still_exist
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
var fake = new ModbusDriverTests.FakeTransport();
// The operator configures maxRegistersPerRead because the DEVICE reads no more than that per FC03, so the
// modelled device refuses a longer read (never above FC03's protocol ceiling of 125).
var device = new ConformantDevice { MaxReadRegisters = Math.Min(maxRegistersPerRead == 0 ? 125 : (int)maxRegistersPerRead, 125) };
var drv = new ModbusDriver(
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)] },
"modbus-array-parity", _ => fake);
new ModbusDriverOptions
{
Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)],
MaxRegistersPerWrite = maxRegistersPerWrite, UseFC23ForMultiRegisterWrites = fc23,
MaxRegistersPerRead = maxRegistersPerRead,
},
"modbus-array-parity", _ => device);
await drv.InitializeAsync("{}", CancellationToken.None);
// A two-element value every element type can convert (Convert.ToBoolean / ToInt16 / ToSingle … of 1).
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", new object[] { 1, 1 })], CancellationToken.None);
var driverAccepts = result.Single().StatusCode == 0u;
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(
driverAccepts, $"region={region} dataType={dataType}: driver status 0x{result.Single().StatusCode:X8}");
var result = await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None);
var status = result.Single().StatusCode;
return (status == 0u && device.WritePdus > 0, status);
}
/// <summary>Positive control: the theory above is not vacuously "everything refused".</summary>
public static TheoryData<ModbusRegion, ModbusDataType, string> TypeMatrix()
{
var data = new TheoryData<ModbusRegion, ModbusDataType, string>();
foreach (var region in Enum.GetValues<ModbusRegion>())
foreach (var dt in Enum.GetValues<ModbusDataType>())
foreach (var node in NodeTypes)
data.Add(region, dt, node);
return data;
}
/// <summary>Every region × element type × node DataType: the table answers exactly what the write path does —
/// including String[] and BitInRegister[] holding-register arrays (#741 U3) and Coils authored with a non-Bool type,
/// which the resolver COERCES to Bool (never refuses).</summary>
[Theory]
[MemberData(nameof(TypeMatrix))]
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType, string nodeType)
{
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"bitIndex":3,"isArray":true,"arrayLength":2}""";
var (accepted, status) = await DriverEncodesAsync(json, ClrArray(nodeType, 2));
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, nodeType, json).ShouldBe(
accepted, $"region={region} dataType={dataType} node={nodeType}: driver status 0x{status:X8}");
}
/// <summary>Positive control: the matrix above is not vacuously one answer, and it contains the shapes #741 added.</summary>
[Fact]
public void The_matrix_contains_both_answers()
{
var answers = Shapes()
.Select(s => DriverArrayWriteSupport.Supports(
var answers = Enum.GetValues<ModbusRegion>()
.SelectMany(r => Enum.GetValues<ModbusDataType>().Select(d => DriverArrayWriteSupport.Supports(
DriverTypeNames.Modbus, "Int16",
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}","isArray":true,"arrayLength":2}"""))
$$"""{"region":"{{r}}","address":10,"dataType":"{{d}}","stringLength":4,"isArray":true,"arrayLength":2}""")))
.ToHashSet();
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
}
/// <summary>
/// #610 review MINOR-1/-2 — the table must also agree with the driver on the SHAPE edges: the FC16 write cap
/// (the driver refuses rather than chunks an over-cap array; default 123 registers, or the configured
/// <c>MaxRegistersPerWrite</c>) and a missing / zero <c>arrayLength</c> (the factory maps it to a scalar tag,
/// whose scalar encoder refuses an array value).
/// The shape edges, both directions, against the real write path: the FC16 cap for numeric, String and
/// BitInRegister arrays (default 123, or a configured <c>MaxRegistersPerWrite</c>), the FC15 ceiling, a
/// missing / zero <c>arrayLength</c> (a scalar to the factory, whose scalar encoder refuses an array value),
/// the String length and bit-index guards, the BitInRegister span end, and the strict key reads.
/// </summary>
[Theory]
[InlineData("Int32", "\"arrayLength\":61", 0, 61)] // 122 registers — fits the default cap
[InlineData("Int32", "\"arrayLength\":62", 0, 62)] // 124 registers — over it
[InlineData("Float64", "\"arrayLength\":31", 0, 31)] // 124 registers
[InlineData("Int16", "\"arrayLength\":10", 10, 10)] // exactly a configured cap
[InlineData("Int16", "\"arrayLength\":11", 10, 11)] // one over it
[InlineData("Int16", "\"arrayLength\":0", 0, 2)] // declared length 0 ⇒ scalar to the driver
[InlineData("Int16", "\"isArrayLengthMissing\":true", 0, 2)]
public async Task Table_agrees_with_the_driver_on_write_cap_and_declared_length(
string dataType, string lengthProp, ushort maxRegistersPerWrite, int elements)
// Numeric FC16 cap.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""", 61, 0)] // 122 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""", 62, 0)] // 124 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":123}""", 123, 0)] // exactly 123
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":124}""", 124, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Float64","isArray":true,"arrayLength":30}""", 30, 0)] // 120 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Float64","isArray":true,"arrayLength":31}""", 31, 0)] // 124 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":10}""", 10, 10)] // exactly a configured cap
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":11}""", 11, 10)] // one over it
// String FC16 cap: (stringLength+1)/2 registers per element.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":61}""", 61, 0)] // 122 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":62}""", 62, 0)] // 124 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":41}""", 41, 0)] // 3 regs each: 123
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":42}""", 42, 0)] // 126
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":1,"isArray":true,"arrayLength":10}""", 10, 10)] // 1 reg each, at a configured cap
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":1,"isArray":true,"arrayLength":11}""", 11, 10)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":0,"isArray":true,"arrayLength":2}""", 2, 0)] // factory refuses
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","isArray":true,"arrayLength":2}""", 2, 0)] // no length ⇒ refused
// BitInRegister FC16 cap: one register per element, plus the bit-index guard and the span end.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":15,"isArray":true,"arrayLength":123}""", 123, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":15,"isArray":true,"arrayLength":124}""", 124, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":10}""", 10, 10)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":11}""", 11, 10)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":16,"isArray":true,"arrayLength":2}""", 2, 0)] // factory refuses
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":6}""", 6, 0)] // ends at 65535
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":7}""", 7, 0)] // past the end
// FC15 ceiling (one request, no chunking; a conformant device refuses more than 1968 coils).
[InlineData("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1968}""", 1968, 0)]
[InlineData("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1969}""", 1969, 0)]
[InlineData("""{"region":"Coils","address":0,"dataType":"Float64","isArray":true,"arrayLength":1968}""", 1968, 0)] // coerced to Bool
[InlineData("""{"region":"Coils","address":0,"dataType":"Float64","isArray":true,"arrayLength":1969}""", 1969, 0)]
// Declared length.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":0}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":1}""", 1, 0)]
// Strict key reads: a present-but-invalid enum or an out-of-range address refuses the tag.
[InlineData("""{"region":"Coils","address":0,"dataType":"Int17","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","byteOrder":"Sideways","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"stringByteOrder":"Middle","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","byteOrder":"WordSwap","isArray":true,"arrayLength":2}""", 2, 0)] // valid order: positive control
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"stringByteOrder":"LowByteFirst","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65536,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":-1,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":1.5,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":"10","dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Bool","isArray":true,"arrayLength":2}""", 2, 0)] // register region never carries Bool
[InlineData("""{"region":0,"address":10,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)] // non-string region ⇒ absent ⇒ HoldingRegisters
// Keys win over an addressString beside them (the driver never reads the string then).
[InlineData("""{"region":"HoldingRegisters","address":10,"addressString":"30011","dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
public async Task Table_agrees_with_the_driver_on_each_shape_edge(string tagConfig, int elements, ushort maxRegistersPerWrite)
{
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","isArray":true,{{lengthProp}}}""";
var driverConfig = maxRegistersPerWrite == 0 ? "{}" : $$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""";
var fake = new ModbusDriverTests.FakeTransport();
var options = new ModbusDriverOptions
{
Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)],
MaxRegistersPerWrite = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
};
var drv = new ModbusDriver(options, "modbus-array-cap-parity", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var cap = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite;
var value = Enumerable.Repeat<object>(1, elements).ToArray();
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", value)], CancellationToken.None);
var driverAccepts = result.Single().StatusCode == 0u;
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", elements), cap);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json, driverConfig).ShouldBe(
driverAccepts, $"{dataType} {lengthProp} cap={maxRegistersPerWrite}: driver status 0x{result.Single().StatusCode:X8}");
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig, driverConfig).ShouldBe(
accepted, $"{tagConfig} cap={cap}: driver status 0x{status:X8}");
}
/// <summary>Positive control for the theory above: both answers occur, so it is not vacuously all-refused.</summary>
/// <summary>The table restates the Modbus enums' member names (Core.Abstractions cannot reference them); every
/// byte order and string byte order the driver accepts must be one it accepts too, so a new member fails here.</summary>
[Fact]
public async Task Every_byte_order_member_agrees_with_the_driver()
{
foreach (var order in Enum.GetValues<ModbusByteOrder>())
foreach (var stringOrder in Enum.GetValues<ModbusStringByteOrder>())
{
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","byteOrder":"{{order}}","stringByteOrder":"{{stringOrder}}","isArray":true,"arrayLength":2}""";
var (accepted, _) = await DriverEncodesAsync(json, ClrArray("Int16", 2));
accepted.ShouldBeTrue(json);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", json).ShouldBeTrue(json);
}
}
/// <summary>
/// The protocol ceilings and the configured caps together, against the real write path:
/// <list type="bullet">
/// <item>a <c>maxRegistersPerWrite</c> above FC16's 123 never lifts it - the driver sends the over-ceiling
/// FC16 and a conformant device refuses it (past 127 the byte-count field even wraps);</item>
/// <item>a BitInRegister[] write reads its span first in ONE unchunked FC03, so it is also bounded by
/// <c>maxRegistersPerRead</c> (and FC03's 125); numeric arrays never read on write;</item>
/// <item>every span ends at or before address 65535 (exception 02 past it).</item>
/// </list>
/// </summary>
[Theory]
// Write cap above the FC16 ceiling.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":123}""", 123, 200, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":124}""", 124, 200, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":150}""", 150, 200, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":62}""", 62, 200, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":123}""", 123, 200, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":124}""", 124, 200, 200)]
// BitInRegister[] RMW read bounded by maxRegistersPerRead.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":64}""", 64, 0, 64)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":65}""", 65, 0, 64)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":100}""", 100, 0, 64)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":100}""", 100, 0, 64)] // no read on write
// Span end at 65535.
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"Int16","isArray":true,"arrayLength":6}""", 6, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"Int16","isArray":true,"arrayLength":7}""", 7, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65532,"dataType":"Int32","isArray":true,"arrayLength":2}""", 2, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65533,"dataType":"Int32","isArray":true,"arrayLength":2}""", 2, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65534,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":1}""", 1, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65535,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":1}""", 1, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":6}""", 6, 0, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":7}""", 7, 0, 0)]
[InlineData("""{"region":"Coils","address":65000,"isArray":true,"arrayLength":536}""", 536, 0, 0)]
[InlineData("""{"region":"Coils","address":65000,"isArray":true,"arrayLength":537}""", 537, 0, 0)]
public async Task Table_agrees_with_the_driver_on_protocol_ceilings_and_spans(
string tagConfig, int elements, ushort maxRegistersPerWrite, ushort maxRegistersPerRead)
{
var configs = new List<string>();
if (maxRegistersPerWrite != 0) configs.Add($$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""");
if (maxRegistersPerRead != 0) configs.Add($$"""{"maxRegistersPerRead":{{maxRegistersPerRead}}}""");
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", elements),
maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
maxRegistersPerRead: maxRegistersPerRead == 0 ? (ushort)125 : maxRegistersPerRead);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig, configs.ToArray()).ShouldBe(
accepted, $"{tagConfig} write={maxRegistersPerWrite} read={maxRegistersPerRead}: driver status 0x{status:X8}");
}
/// <summary>Positive control for the edges: each cap boundary has a writable side and a refused side in the table.</summary>
[Fact]
public void The_shape_edges_contain_both_answers()
{
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
static bool S(string json) => DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json);
S("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
S("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
S("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":41}""").ShouldBeTrue();
S("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":42}""").ShouldBeFalse();
S("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":123}""").ShouldBeTrue();
S("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":124}""").ShouldBeFalse();
S("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1968}""").ShouldBeTrue();
S("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1969}""").ShouldBeFalse();
}
/// <summary>
/// FC23 is opt-in and never changes writability: with <c>UseFC23ForMultiRegisterWrites</c> on, the driver
/// writes a multi-register array via FC23 up to its 121-register ceiling and falls back to FC16 above it, so
/// the accept boundary is still the FC16 cap the table uses.
/// </summary>
[Theory]
[InlineData("Int16", 121)]
[InlineData("Int16", 123)]
[InlineData("Int16", 124)]
[InlineData("String", 61)]
[InlineData("String", 62)]
public async Task FC23_does_not_change_the_answer(string dataType, int elements)
{
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":{{elements}}}""";
var (fc16, _) = await DriverEncodesAsync(json, ClrArray("Int16", elements), fc23: false);
var (fc23, _) = await DriverEncodesAsync(json, ClrArray("Int16", elements), fc23: true);
fc23.ShouldBe(fc16);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(fc23);
}
/// <summary>
/// A string-only tag (<c>addressString</c>, no <c>region</c> + <c>address</c> keys) is resolved by the DRIVER
/// with its own family. Core.Abstractions cannot parse that grammar (it references no driver assembly), so the
/// table cannot prove such an array writable and keeps it read-only — even though the Generic-family driver does
/// write this one. That is the conservative direction (a hidden write, never an advertised refusal); a tag that
/// needs to be array-writable is authored with the keys (the editor's "Resolve" pins them).
/// </summary>
[Theory]
[InlineData("""{"addressString":"40011:S::2","isArray":true,"arrayLength":2}""")]
[InlineData("""{"addressString":"40011:F:2"}""")]
[InlineData("""{"region":"HoldingRegisters","addressString":"40011:S::2","isArray":true,"arrayLength":2}""")] // region alone is not the keys
public async Task A_string_only_array_tag_stays_read_only(string tagConfig)
{
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", 2));
accepted.ShouldBeTrue($"the Generic driver resolves and writes it (status 0x{status:X8}) — this pins the conservative gap, not a refusal");
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBeFalse();
}
/// <summary>
/// The ScadaBridge wire shape: a CLR <c>int[]</c> / <c>List&lt;int&gt;</c> to an Int16 holding-register array on
/// an Int16 node is advertised writable and CONVERTS element by element onto the wire (it is not exact-matched).
/// </summary>
[Fact]
public async Task A_ScadaBridge_int_list_to_an_Int16_array_encodes()
{
const string json = """{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":3}""";
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBeTrue();
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
{
var device = new ConformantDevice();
var drv = new ModbusDriver(
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry(Ref, json, false)] }, "modbus-sb", _ => device);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u);
device.Holding[10..13].ShouldBe(new ushort[] { 1, 0xFFFE, 300 });
}
}
/// <summary>The two #741 U3 shapes encode end to end with their own node types: String[] on a String node and
/// BitInRegister[] on a Boolean node, each preserving what the wire must carry.</summary>
[Fact]
public async Task String_and_BitInRegister_arrays_encode_on_their_own_node_types()
{
const string strings = """{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":3,"isArray":true,"arrayLength":2}""";
const string bits = """{"region":"HoldingRegisters","address":20,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":2}""";
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "String", strings).ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", bits).ShouldBeTrue();
var device = new ConformantDevice();
device.Holding[20] = 0x0001;
device.Holding[21] = 0x0005;
var drv = new ModbusDriver(
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("s", strings, false), new RawTagEntry("b", bits, false)] },
"modbus-u3", _ => device);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.WriteAsync(
[new WriteRequest("s", new[] { "ABC", "de" }), new WriteRequest("b", new[] { true, false })], CancellationToken.None);
r.Select(x => x.StatusCode).ShouldBe(new[] { 0u, 0u });
device.Holding[10..14].ShouldBe(new ushort[] { 0x4142, 0x4300, 0x6465, 0x0000 }); // 2 regs per 3-char element, nul-padded
device.Holding[20..22].ShouldBe(new ushort[] { 0x0005, 0x0001 }); // bit 2 set / cleared, other bits kept
}
/// <summary>
/// A spec-conformant Modbus device over the whole 0-65535 address space. It enforces the protocol quantity
/// ceilings (FC01/02 <= 2000, FC03/04 <= 125, FC15 <= 1968, FC16 <= 123, FC23 write <= 121 / read <= 125) and the
/// FC15 / FC16 / FC23 byte-count field AND the exact frame length it implies (a strict device refuses a trailing
/// byte) with exception 03 (Illegal Data Value), and a span past address 65535 with
/// exception 02 (Illegal Data Address) - what a real device answers. Everything else it answers.
/// </summary>
private sealed class ConformantDevice : IModbusTransport
{
public readonly ushort[] Holding = new ushort[65536];
public readonly bool[] Coils = new bool[65536];
public int WritePdus;
/// <summary>The device's FC03/FC04 quantity limit: the protocol's 125, or a smaller device-specific one.</summary>
public int MaxReadRegisters { get; init; } = 125;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
private static Task<byte[]> Refuse(byte fc, byte code) =>
Task.FromException<byte[]>(new ModbusException(fc, code, $"fc={fc:X2} exception {code:X2}"));
// Exception 03 for a quantity outside 1..max, 02 for a span past the last address, else null (accepted).
private static byte? Check(int addr, int qty, int max) =>
qty < 1 || qty > max ? (byte)0x03 : addr + qty > 65536 ? (byte)0x02 : null;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var fc = pdu[0];
var addr = (pdu[1] << 8) | pdu[2];
var qty = (pdu[3] << 8) | pdu[4];
switch (fc)
{
case 0x01:
case 0x02:
{
if (Check(addr, qty, 2000) is { } e) return Refuse(fc, e);
var resp = new byte[2 + (qty + 7) / 8];
resp[0] = fc; resp[1] = (byte)((qty + 7) / 8);
for (var i = 0; i < qty; i++) if (Coils[addr + i]) resp[2 + i / 8] |= (byte)(1 << (i % 8));
return Task.FromResult(resp);
}
case 0x03:
case 0x04:
if (Check(addr, qty, MaxReadRegisters) is { } re) return Refuse(fc, re);
return Task.FromResult(ReadRegs(fc, addr, qty));
case 0x05:
WritePdus++;
Coils[addr] = pdu[3] == 0xFF;
return Task.FromResult(pdu);
case 0x06:
WritePdus++;
Holding[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
return Task.FromResult(pdu);
case 0x0F:
if (Check(addr, qty, 1968) is { } ce) return Refuse(fc, ce);
if (pdu[5] != (qty + 7) / 8 || pdu.Length != 6 + pdu[5]) return Refuse(fc, 0x03); // strict frame length
WritePdus++;
for (var i = 0; i < qty; i++) Coils[addr + i] = ((pdu[6 + i / 8] >> (i % 8)) & 1) == 1;
return Task.FromResult(new byte[] { 0x0F, pdu[1], pdu[2], pdu[3], pdu[4] });
case 0x10:
if (Check(addr, qty, 123) is { } we) return Refuse(fc, we);
if (pdu[5] != qty * 2 || pdu.Length != 6 + pdu[5]) return Refuse(fc, 0x03); // strict frame length
WritePdus++;
for (var i = 0; i < qty; i++) Holding[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
case 0x17:
{
var writeAddr = (pdu[5] << 8) | pdu[6];
var writeQty = (pdu[7] << 8) | pdu[8];
if (Check(addr, qty, 125) is { } r23) return Refuse(fc, r23);
if (Check(writeAddr, writeQty, 121) is { } w23) return Refuse(fc, w23);
if (pdu[9] != writeQty * 2 || pdu.Length != 10 + pdu[9]) return Refuse(fc, 0x03); // strict frame length
WritePdus++;
for (var i = 0; i < writeQty; i++) Holding[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
return Task.FromResult(ReadRegs(0x17, addr, qty));
}
default:
return Refuse(fc, 0x01);
}
}
private byte[] ReadRegs(byte fc, int addr, int qty)
{
var resp = new byte[2 + qty * 2];
resp[0] = fc; resp[1] = (byte)(qty * 2);
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(Holding[addr + i] >> 8);
resp[3 + i * 2] = (byte)(Holding[addr + i] & 0xFF);
}
return resp;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -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);
}
}
@@ -0,0 +1,107 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #741 (Integration-W3 fix round): every multi-write PDU the driver builds is EXACTLY its header plus the bytes its
/// byte-count field declares — FC15 and FC16 are <c>6 + byteCount</c>, FC23 is <c>10 + writeByteCount</c>. The
/// builders used to allocate one byte more than they filled, so every FC15 / FC16 / FC23 (and the BitInRegister[]
/// read-modify-write's FC16) carried a trailing zero byte past the declared data — which a strict device or gateway
/// that checks the frame length against the byte count refuses.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusWritePduFramingTests
{
private const string Ref = "arr";
/// <summary>Records every PDU; answers reads with zeros and multi-writes with the standard echo.</summary>
private sealed class CapturingTransport : IModbusTransport
{
public readonly List<byte[]> Sent = [];
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
Sent.Add(pdu);
var qty = (pdu[3] << 8) | pdu[4];
return Task.FromResult(pdu[0] switch
{
0x01 or 0x02 => [pdu[0], (byte)((qty + 7) / 8), .. new byte[(qty + 7) / 8]],
0x03 or 0x04 or 0x17 => [pdu[0], (byte)(qty * 2), .. new byte[qty * 2]],
0x0F or 0x10 => new[] { pdu[0], pdu[1], pdu[2], pdu[3], pdu[4] },
_ => pdu,
});
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static async Task<byte[]> WriteAndCaptureAsync(string tagConfig, object value, byte functionCode, bool fc23 = false)
{
var transport = new CapturingTransport();
var drv = new ModbusDriver(
new ModbusDriverOptions
{
Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)], UseFC23ForMultiRegisterWrites = fc23,
},
"modbus-framing", _ => transport);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u);
return transport.Sent.Single(p => p[0] == functionCode);
}
[Theory]
[InlineData(1)]
[InlineData(8)]
[InlineData(9)]
public async Task FC15_pdu_is_exactly_header_plus_byte_count(int coils)
{
var pdu = await WriteAndCaptureAsync(
$$"""{"region":"Coils","address":0,"isArray":true,"arrayLength":{{coils}}}""",
Enumerable.Repeat(true, coils).ToArray(), 0x0F);
pdu[5].ShouldBe((byte)((coils + 7) / 8));
pdu.Length.ShouldBe(6 + pdu[5]);
}
[Theory]
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""", 6)]
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Float64","isArray":true,"arrayLength":2}""", 16)]
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int32"}""", 4)] // scalar multi-register
public async Task FC16_pdu_is_exactly_header_plus_byte_count(string tagConfig, int byteCount)
{
object value = tagConfig.Contains("isArray") ? new[] { 1, 2, 3 }.Take(tagConfig.Contains("Float64") ? 2 : 3).ToArray() : 7;
var pdu = await WriteAndCaptureAsync(tagConfig, value, 0x10);
pdu[5].ShouldBe((byte)byteCount);
pdu.Length.ShouldBe(6 + pdu[5]);
}
[Fact]
public async Task BitInRegister_array_RMW_FC16_pdu_is_exactly_header_plus_byte_count()
{
var pdu = await WriteAndCaptureAsync(
"""{"region":"HoldingRegisters","address":0,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":4}""",
new[] { true, false, true, false }, 0x10);
pdu[5].ShouldBe((byte)8);
pdu.Length.ShouldBe(6 + pdu[5]);
}
[Fact]
public async Task FC23_pdu_is_exactly_header_plus_write_byte_count()
{
var pdu = await WriteAndCaptureAsync(
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""",
new[] { 1, 2, 3 }, 0x17, fc23: true);
pdu[9].ShouldBe((byte)6);
pdu.Length.ShouldBe(10 + pdu[9]);
}
}
@@ -0,0 +1,134 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
/// catalog, and the endpoints bind <c>HttpContext.RequestAborted</c>.
/// </summary>
public sealed class AnalysisCancellationTests
{
internal sealed class CapturingCatalog : IScriptTagCatalog
{
public readonly List<CancellationToken> Seen = new();
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult<IReadOnlyList<string>>(new[] { "Plant/Dev/Speed" });
}
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
}
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
}
}
private static CancellationToken Cancelled() => new(canceled: true);
[Fact]
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
{
var svc = new ScriptAnalysisService();
// Positive control: the same request with a live token produces the error marker.
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
.Markers.ShouldContain(m => m.Severity == 8);
await Should.ThrowAsync<OperationCanceledException>(
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
}
[Fact]
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
{
var svc = new ScriptAnalysisService(new CapturingCatalog());
const string code = "return ctx.GetTag(\"\");";
// Positive controls.
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
.Markdown.ShouldNotBeNull();
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
await Should.ThrowAsync<OperationCanceledException>(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
await Should.ThrowAsync<OperationCanceledException>(
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
Should.Throw<OperationCanceledException>(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
}
[Fact]
public async Task The_request_token_reaches_the_catalog()
{
var catalog = new CapturingCatalog();
var svc = new ScriptAnalysisService(catalog);
using var cts = new CancellationTokenSource();
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
catalog.Seen.Count.ShouldBe(3);
catalog.Seen.ShouldAllBe(t => t == cts.Token);
}
// ---------------------------------------------------------------- endpoints
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
{
public bool CanHaveBody => true;
}
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
{
var builder = WebApplication.CreateBuilder();
var catalog = new CapturingCatalog();
builder.Services.AddSingleton<IScriptTagCatalog>(catalog);
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService<IScriptTagCatalog>()));
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapScriptAnalysisEndpoints();
return (app, catalog);
}
internal static IReadOnlyList<RouteEndpoint> Endpoints(WebApplication app)
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>().ToList();
[Fact]
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
{
var (app, catalog) = BuildApp();
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
using var scope = app.Services.CreateScope();
using var aborted = new CancellationTokenSource();
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
ctx.Request.Method = "POST";
ctx.Request.ContentType = "application/json";
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
ctx.Request.Body = new MemoryStream(body);
ctx.Request.ContentLength = body.Length;
ctx.Response.Body = new MemoryStream();
ctx.Features.Set<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>(new HasBody());
await endpoint.RequestDelegate!(ctx);
ctx.Response.StatusCode.ShouldBe(200);
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
}
}
@@ -0,0 +1,58 @@
using Microsoft.CodeAnalysis;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P2 — the editor used to build a brand-new <c>CSharpCompilation</c> (and re-bind the whole sandbox
/// reference set) on every completion / hover / signature-help / diagnostics request. Each analysis now
/// derives from ONE process-wide base compilation via <c>ReplaceSyntaxTree</c>, so the referenced-assembly
/// binding is done once and shared.
/// </summary>
public sealed class AnalysisCompilationReuseTests
{
[Fact]
public void Two_services_share_the_bound_reference_set()
{
// Two separate scopes (two HTTP requests) analysing different sources.
var c1 = new ScriptAnalysisService().CompilationFor("return 1;");
var c2 = new ScriptAnalysisService().CompilationFor("return 2;");
// Positive control: the two compilations really carry different user sources.
c1.SyntaxTrees.Single().ToString().ShouldContain("return 1;");
c2.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
c1.ShouldNotBeSameAs(c2);
// Derived from one bound base: the corlib symbol is the SAME instance, not a re-bound copy.
c1.GetSpecialType(SpecialType.System_Object)
.ShouldBeSameAs(c2.GetSpecialType(SpecialType.System_Object));
c1.GetSpecialType(SpecialType.System_Object)
.ShouldBeSameAs(ScriptAnalysisService.BaseCompilation.GetSpecialType(SpecialType.System_Object));
}
[Fact]
public void Repeated_analysis_in_one_scope_replaces_the_tree_rather_than_adding_one()
{
var svc = new ScriptAnalysisService();
svc.CompilationFor("return 1;");
var second = svc.CompilationFor("return 2;");
second.SyntaxTrees.Count().ShouldBe(1);
second.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
second.SyntaxTrees.Single().ToString().ShouldNotContain("return 1;");
}
[Fact]
public void Diagnostics_are_unchanged_by_reuse()
{
// A clean script stays clean and an error stays an error across interleaved analyses on one service
// — a stale tree leaking from the previous call would surface here.
var svc = new ScriptAnalysisService();
svc.Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers.ShouldContain(m => m.Severity == 8);
svc.Diagnose(new DiagnoseRequest("""return (double)ctx.GetTag("A").Value * 2.0;""")).Markers.ShouldBeEmpty();
svc.Diagnose(new DiagnoseRequest("var a = 1;\nreturn ctx.Nope();")).Markers
.ShouldContain(m => m.Severity == 8 && m.StartLineNumber == 2);
}
}
@@ -0,0 +1,99 @@
using System.Text;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// Review D-a I-1 — the diagnostics markers cross the Blazor circuit (JS → .NET, for the Problems list) as ONE
/// hub message. A non-C# paste yields several Roslyn errors per line; uncapped, a few thousand markers exceed
/// the 1 MB hub limit and kill the circuit with every unsaved edit. Both the endpoint response and the circuit
/// report are now bounded; these tests pin the bounds on the C# side of the contract.
/// </summary>
public sealed class MarkerPayloadBoundTests
{
// ~150 KB of log-ish text: every line is several C# errors.
private static string NonCSharpPaste()
{
var sb = new StringBuilder();
for (var i = 0; sb.Length < 150 * 1024; i++)
sb.Append($"2026-10-22 12:00:{i % 60:00} INFO [worker-{i}] value={i}, state=ok; next -> {i + 1} @@ ##\n");
return sb.ToString();
}
[Fact]
public void A_non_csharp_paste_returns_a_bounded_marker_set_that_still_holds_the_errors()
{
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest(NonCSharpPaste())).Markers;
markers.Count.ShouldBe(ScriptEditorLimits.MaxDiagnosticMarkers); // positive control: the cap was hit
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
markers[0].Severity.ShouldBe(8); // errors first — publish rejects, so must the editor
markers.Select(m => (m.Severity, m.StartLineNumber, m.StartColumn))
.ShouldBe(ScriptProblems.Order(markers).Select(m => (m.Severity, m.StartLineNumber, m.StartColumn)));
}
[Fact]
public void A_long_quoted_token_has_its_message_truncated()
{
var giant = new string('x', 20_000);
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest($"return {giant};")).Markers;
markers.ShouldNotBeEmpty();
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
markers.ShouldContain(m => m.Message.EndsWith("…"));
}
[Fact]
public void A_small_script_is_not_truncated()
{
// Positive control for the cap: ordinary diagnostics pass through untouched.
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers;
markers.ShouldContain(m => m.Severity == 8 && !m.Message.EndsWith("…"));
}
[Fact]
public void The_worst_case_circuit_report_fits_inside_the_sync_budget()
{
// The JS sends at most MaxReportedMarkers markers, each message cut to MaxReportedMessageChars. Worst case:
// control characters (\u0001 → 6 JSON bytes, 7 when escaped again) and the largest line/column numbers.
var message = new string('\u0001', ScriptEditorLimits.MaxReportedMessageChars);
var marker = new DiagnosticMarker(8, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, message, "OTSCRIPT_SETVTAG_INERT");
var report = Enumerable.Repeat(marker, ScriptEditorLimits.MaxReportedMarkers).ToArray();
var once = JsonSerializer.Serialize(new object[] { report, int.MaxValue });
var twice = Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(once));
twice.ShouldBeLessThan(ScriptEditorLimits.MaxEditorSyncBytes);
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
}
[Fact]
public void A_truncated_report_says_how_many_were_left_out()
{
var shown = Enumerable.Range(1, 3).Select(i => new DiagnosticMarker(8, i, 1, i, 2, "m", "CS1002")).ToArray();
var report = new ScriptProblemReport(shown, 4321);
report.Omitted.ShouldBe(4318);
ScriptProblems.Summary(report).ShouldBe("3 errors (showing 3 of 4321)");
// Positive control: an untruncated report carries no suffix.
ScriptProblems.Summary(new ScriptProblemReport(shown, 3)).ShouldBe("3 errors");
ScriptProblemReport.Empty.Omitted.ShouldBe(0);
}
[Fact]
public void A_report_from_js_is_clamped_even_if_the_js_cap_regressed()
{
var flood = Enumerable.Range(1, 5000).Select(i => new DiagnosticMarker(4, i, 1, i, 2, new string('m', 2000), "W")).ToArray();
var report = ScriptProblemReport.FromEditor(flood, total: 5000);
report.Markers.Count.ShouldBe(ScriptEditorLimits.MaxReportedMarkers);
report.Total.ShouldBe(5000);
report.Markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars);
ScriptProblemReport.FromEditor(null, 0).ShouldBe(ScriptProblemReport.Empty);
}
}
@@ -0,0 +1,104 @@
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// Source pins for the Monaco interop (AdminUI has no bUnit and no JS test runner). These do not prove the
/// browser behaviour — the docker-dev live check does — they stop the specific regressions R04:P1/P9/S2/S3 fixed
/// from quietly coming back: per-keystroke text sync, unsequenced diagnostics, the inlay-hints POST, and an
/// unconsumed marker round-trip.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MonacoInteropSourceTests
{
private static string Read(params string[] relative)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root");
return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relative).ToArray()));
}
private static string Js() => Read("wwwroot", "js", "monaco-init.js");
[Fact]
public void The_content_change_handler_does_not_ship_the_text_to_dotnet()
{
var js = Js();
var handler = Regex.Match(js, @"onDidChangeModelContent\(function \(\) \{(?<body>.*?)\n \}\);", RegexOptions.Singleline);
handler.Success.ShouldBeTrue("the content-change handler moved; update this pin");
handler.Groups["body"].Value.ShouldNotContain("OnValueChanged");
handler.Groups["body"].Value.ShouldNotContain("getValue");
// Positive control: the text IS still reported — at blur.
js.ShouldContain("onDidBlurEditorText");
js.ShouldContain("\"OnValueChanged\"");
}
[Fact]
public void Diagnostics_are_generation_guarded_and_abortable()
{
var js = Js();
js.ShouldContain("diagGeneration");
js.ShouldContain("generation !== e.diagGeneration");
js.ShouldContain("new AbortController()");
js.ShouldContain("signal: signal");
}
[Fact]
public void Providers_honour_the_monaco_cancellation_token()
{
var js = Js();
js.ShouldContain("token.onCancellationRequested");
foreach (var path in new[] { "\"completions\"", "\"hover\"", "\"format\"", "\"signature-help\"" })
js.ShouldContain("postAnalysis(" + path);
}
[Fact]
public void The_inlay_hints_provider_is_not_registered() => Js().ShouldNotContain("registerInlayHintsProvider");
[Fact]
public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list()
{
Js().ShouldContain("if (e.reportMarkers) {");
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
}
[Fact]
public void The_markers_report_is_capped_before_it_crosses_the_circuit()
{
// Review I-1: the only OnMarkersChanged invoke sends the capped report, never the raw marker array.
var js = Js();
Regex.Matches(js, "\"OnMarkersChanged\"").Count.ShouldBe(1);
js.ShouldContain("dotNetRef.invokeMethodAsync(\"OnMarkersChanged\", report.markers, report.total)");
js.ShouldContain("const report = markerReport(e, markers);");
js.ShouldContain(".slice(0, max)");
js.ShouldContain("m.message.substring(0, maxChars - 1)");
// The caps come from the C# constants, so the JS and ScriptProblemReport.FromEditor agree.
var razor = Read("Components", "Shared", "MonacoEditor.razor");
razor.ShouldContain("maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers");
razor.ShouldContain("maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars");
razor.ShouldContain("ScriptProblemReport.FromEditor(markers, total)");
}
[Fact]
public void An_oversize_text_is_announced_at_blur_not_only_at_save()
{
Js().ShouldContain("invokeMethodAsync(\"OnSyncRefused\", serializedBytes(v))");
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("public void OnSyncRefused(int bytes)");
}
[Theory]
[InlineData("Components", "Pages", "ScriptEdit.razor")]
[InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")]
public void Script_hosts_read_the_editor_on_save_and_render_the_problems_list(params string[] path)
{
var src = Read(path);
src.ShouldContain("GetValueAsync()");
src.ShouldContain("<ScriptProblemsList");
src.ShouldContain("RevealLineAsync(");
src.ShouldContain("MarkersChanged=");
}
}
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P1 / S2 — the Monaco editor no longer ships its text over the circuit per keystroke; the page reads it on
/// demand at blur / Save. That single read is one hub message, so the Blazor hub's receive limit is raised from
/// the 32 KB default to 1 MB, and the JS refuses (rather than sends) a value that would not fit.
/// </summary>
public sealed class MonacoSyncTests
{
private static HubOptions ComponentHubOptions(IServiceProvider sp)
{
// ComponentHub is internal to Microsoft.AspNetCore.Components.Server; resolve its options by type name.
var hubType = Type.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub, Microsoft.AspNetCore.Components.Server", throwOnError: true)!;
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(HubOptions<>).MakeGenericType(hubType));
var options = sp.GetRequiredService(optionsType);
return (HubOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
}
[Fact]
public void The_blazor_hub_accepts_a_1_MB_message()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddAdminUI();
using var sp = services.BuildServiceProvider();
ComponentHubOptions(sp).MaximumReceiveMessageSize.ShouldBe(1024 * 1024);
ScriptEditorLimits.HubMaximumReceiveMessageSize.ShouldBe(1024 * 1024);
}
[Fact]
public void Without_AddAdminUI_the_default_limit_is_the_32_KB_that_truncated_large_scripts()
{
// Positive control for the test above: the 1 MB comes from AddAdminUI, not from a framework default.
var services = new ServiceCollection();
services.AddLogging();
services.AddRazorComponents().AddInteractiveServerComponents();
using var sp = services.BuildServiceProvider();
(ComponentHubOptions(sp).MaximumReceiveMessageSize ?? 32 * 1024).ShouldBe(32 * 1024);
}
[Fact]
public void The_editor_sync_budget_leaves_headroom_inside_the_hub_limit_and_fits_a_100_KB_script()
{
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeGreaterThan(4 * 100 * 1024); // 100 KB even after escaping twice
}
}
@@ -0,0 +1,59 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:S3 / S2 — the marker round-trip now feeds a real Problems list (MarkersChanged → list → RevealLineAsync),
/// and the dead inlay-hints endpoint is gone. The Razor wiring is live-verified; the ordering and wording it
/// renders are pinned here.
/// </summary>
public sealed class ProblemsListTests
{
private static DiagnosticMarker M(int sev, int line, int col, string code = "X") => new(sev, line, col, line, col + 1, "msg", code);
[Fact]
public void Problems_are_ordered_errors_first_then_by_position()
{
var ordered = ScriptProblems.Order(new[]
{
M(4, 1, 1, "W1"), M(8, 9, 2, "E3"), M(8, 2, 5, "E2"), M(8, 2, 1, "E1"), M(2, 1, 1, "I1"),
});
ordered.Select(m => m.Code).ShouldBe(new[] { "E1", "E2", "E3", "W1", "I1" });
}
[Fact]
public void Null_or_empty_markers_order_to_an_empty_list() => ScriptProblems.Order(null).ShouldBeEmpty();
[Theory]
[InlineData(8, "Error")]
[InlineData(4, "Warning")]
[InlineData(2, "Info")]
[InlineData(1, "Hint")]
public void Severity_labels_follow_monaco_values(int severity, string label)
=> ScriptProblems.SeverityLabel(severity).ShouldBe(label);
[Fact]
public void Summary_counts_errors_and_warnings()
{
ScriptProblems.Summary(Array.Empty<DiagnosticMarker>()).ShouldBe("No problems");
ScriptProblems.Summary(new[] { M(8, 1, 1) }).ShouldBe("1 error");
ScriptProblems.Summary(new[] { M(8, 1, 1), M(8, 2, 1), M(4, 3, 1) }).ShouldBe("2 errors, 1 warning");
ScriptProblems.Summary(new[] { M(4, 1, 1), M(4, 2, 1), M(2, 3, 1) }).ShouldBe("2 warnings, 1 other");
}
[Fact]
public void The_inlay_hints_endpoint_is_gone()
{
// The always-empty inlay-hints endpoint (and the JS provider that POSTed the full source to it on every
// edit) were removed. Positive control: the other five routes are still mapped.
var (app, _) = AnalysisCancellationTests.BuildApp();
var routes = AnalysisCancellationTests.Endpoints(app).Select(e => e.RoutePattern.RawText!).ToList();
routes.ShouldNotContain(r => r.Contains("inlay", StringComparison.OrdinalIgnoreCase));
foreach (var r in new[] { "/diagnostics", "/completions", "/hover", "/signature-help", "/format" })
routes.ShouldContain(x => x.EndsWith(r));
}
}
@@ -0,0 +1,79 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P3 — the catalog used to build a HashSet of every fleet path and sort it on every completion keystroke,
/// and hover did a linear scan. The snapshot now carries a sorted, de-duplicated <c>string[]</c> (prefix-searched
/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot.
/// </summary>
public sealed class ScriptTagCatalogIndexTests
{
private const int Max = 200;
// The previous per-keystroke implementation, kept here as the oracle the index must match exactly.
private static IReadOnlyList<string> Naive(IReadOnlyList<ScriptTagInfo> entries, string? filter)
{
IEnumerable<string> q = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(filter))
q = q.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return q.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Take(Max).ToList();
}
private static ScriptTagInfo Tag(string path) => new(path, "Tag", "Float", "DRV-1");
[Fact]
public void Prefix_search_is_case_insensitive_sorted_and_bounded()
{
var index = ScriptTagCatalogIndex.Build(new[]
{
Tag("Plant/b/Speed"), Tag("plant/A/Temp"), Tag("Other/X"), Tag("Plant/a/Flow"),
});
index.FindByPrefix("PLANT/", Max).ShouldBe(new[] { "Plant/a/Flow", "plant/A/Temp", "Plant/b/Speed" });
index.FindByPrefix("other", Max).ShouldBe(new[] { "Other/X" });
index.FindByPrefix("zzz", Max).ShouldBeEmpty();
index.FindByPrefix(null, 2).Count.ShouldBe(2);
index.FindByPrefix(" ", Max).Count.ShouldBe(4); // whitespace filter = unfiltered, as before
}
[Fact]
public void Case_only_duplicates_collapse_to_the_first_seen_casing()
{
// HashSet(OrdinalIgnoreCase) kept the FIRST-added casing; the index must too.
var index = ScriptTagCatalogIndex.Build(new[] { Tag("A/speed"), Tag("A/Speed"), Tag("A/SPEED") });
index.SortedPaths.ShouldBe(new[] { "A/speed" });
}
[Fact]
public void Exact_lookup_is_ordinal_and_keeps_the_first_entry()
{
var first = new ScriptTagInfo("A/B", "Tag", "Float", "DRV-1");
var index = ScriptTagCatalogIndex.Build(new[] { first, new ScriptTagInfo("A/B", "Virtual tag", "Int32", null) });
index.TryGet("A/B").ShouldBeSameAs(first);
index.TryGet("a/b").ShouldBeNull(); // case-mismatched path does not resolve at runtime
index.TryGet("A/C").ShouldBeNull();
}
[Fact]
public void Matches_the_previous_implementation_on_a_generated_fleet()
{
// Deterministic seed — a mixed-case fleet with case-only duplicates and shared prefixes.
var rng = new Random(745);
string Seg() => new string(Enumerable.Range(0, rng.Next(1, 4)).Select(_ => "abAB_/1"[rng.Next(7)]).ToArray());
var entries = Enumerable.Range(0, 3000).Select(_ => Tag(Seg() + "/" + Seg() + "/" + Seg())).ToList();
var index = ScriptTagCatalogIndex.Build(entries);
var filters = new List<string?> { null, "", "a", "A", "ab", "aB/", "b/", "_", "1", "/", "zz" };
filters.AddRange(entries.Take(50).Select(e => e.Path[..Math.Min(e.Path.Length, rng.Next(1, 6))]));
foreach (var f in filters)
index.FindByPrefix(f, Max).ShouldBe(Naive(entries, f), $"filter '{f}'");
// Positive control: the generated fleet really exercises truncation and filtering.
Naive(entries, null).Count.ShouldBe(Max);
Naive(entries, "ab").Count.ShouldBeGreaterThan(0);
}
}
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:S1 — <c>ctx.SetVirtualTag(...)</c> compiles and publishes but is dropped at runtime (single-tag mode).
/// Only a hover used to say so; <c>Diagnose</c> now emits an <c>OTSCRIPT_SETVTAG_INERT</c> WARNING marker so the
/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too.
/// </summary>
public sealed class SetVirtualTagInertDiagnosticTests
{
private const string InertCode = "OTSCRIPT_SETVTAG_INERT";
private static readonly ScriptAnalysisService Svc = new();
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
[Fact]
public void SetVirtualTag_call_gets_a_warning_marker_on_its_line()
{
var markers = Diag("var a = 1;\nvar b = 2;\nctx.SetVirtualTag(\"Other\", a + b);\nreturn 0;");
var m = markers.ShouldHaveSingleItem();
m.Code.ShouldBe(InertCode);
m.Severity.ShouldBe(4); // Monaco warning
m.StartLineNumber.ShouldBe(3);
m.StartColumn.ShouldBe(1);
m.Message.ShouldContain("SetVirtualTag");
}
[Fact]
public void Every_SetVirtualTag_call_is_marked()
=> Diag("ctx.SetVirtualTag(\"A\", 1);\nctx.SetVirtualTag(\"B\", 2);\nreturn 0;")
.Count(m => m.Code == InertCode).ShouldBe(2);
[Theory]
[InlineData("return ctx.GetTag(\"A\").Value;")] // a different ctx member
[InlineData("return 0; // ctx.SetVirtualTag(\"A\", 1)")] // a comment
[InlineData("var s = \"ctx.SetVirtualTag(\\\"A\\\", 1)\"; return s;")] // a string literal
public void Non_calls_are_not_marked(string code)
{
var markers = Diag(code);
markers.ShouldNotContain(m => m.Code == InertCode);
markers.ShouldNotContain(m => m.Severity == 8); // the probe scripts themselves are valid
}
[Fact]
public void The_warning_does_not_block_and_publish_accepts_the_script()
{
// Editor accepts ⇔ publish accepts: no error-severity marker, and the real publish compiler accepts it.
const string source = "ctx.SetVirtualTag(\"Other\", 1);\nreturn 0;";
Diag(source).ShouldNotContain(m => m.Severity == 8);
using var evaluator = ScriptEvaluator<VirtualTagContext, object>.Compile(source);
evaluator.ShouldNotBeNull();
}
}
@@ -414,4 +414,64 @@ public sealed class AddressSpaceComposerDualNamespaceTests
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
}
/// <summary>
/// #741 (Modbus lane U3, follow-up F1): the Modbus driver encodes String[] and BitInRegister[] holding-register
/// arrays, so an authored-<c>ReadWrite</c> array of each materialises WRITABLE — raw node and UNS mirror alike. A
/// Coils array authored with a non-Bool type (the resolver coerces a bit region to Bool) is writable too. Shapes
/// the driver refuses stay read-only on the SAME driver: a BitInRegister array over the FC16 cap, a String array
/// with no <c>stringLength</c>, and a string-only (<c>addressString</c>) array whose family the composer cannot
/// know — the negative controls.
/// </summary>
[Fact]
public void Modbus_string_and_bit_in_register_arrays_materialise_writable_and_refused_shapes_stay_read_only()
{
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{}" };
var dev = new Device { DeviceId = "d1", DriverInstanceId = "drv-mb", Name = "plc", DeviceConfig = "{}" };
static Tag Arr(string id, string dataType, string tagConfig) => new()
{
TagId = id, DeviceId = "d1", Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = tagConfig,
};
var tags = new[]
{
Arr("names", "String", "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"String\",\"stringLength\":8,\"isArray\":true,\"arrayLength\":4}"),
Arr("flags", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":200,\"dataType\":\"BitInRegister\",\"bitIndex\":3,\"isArray\":true,\"arrayLength\":8}"),
Arr("legacyCoils", "Int16", "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":8}"),
Arr("overCap", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":300,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":124}"),
Arr("noLength", "String", "{\"region\":\"HoldingRegisters\",\"address\":400,\"dataType\":\"String\",\"isArray\":true,\"arrayLength\":4}"),
Arr("stringOnly", "Int16", "{\"addressString\":\"40501:S::4\",\"isArray\":true,\"arrayLength\":4}"),
};
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
var refs = new[]
{
new UnsTagReference { UnsTagReferenceId = "r-names", EquipmentId = "e", TagId = "names" },
new UnsTagReference { UnsTagReferenceId = "r-flags", EquipmentId = "e", TagId = "flags" },
new UnsTagReference { UnsTagReferenceId = "r-over", EquipmentId = "e", TagId = "overCap" },
};
var c = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { dev });
foreach (var writable in new[] { "names", "flags", "legacyCoils" })
{
var plan = c.RawTags.Single(t => t.Name == writable);
plan.ArrayWritable.ShouldBeTrue(writable);
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
}
foreach (var refused in new[] { "overCap", "noLength", "stringOnly" })
{
var plan = c.RawTags.Single(t => t.Name == refused);
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
plan.ArrayWritable.ShouldBeFalse(refused);
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
}
foreach (var id in new[] { "r-names", "r-flags" })
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-over")).ShouldBeFalse();
}
}