Merge local master 5135a036 into remed/lane-AbLegacy

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:36:11 -04:00
66 changed files with 4024 additions and 469 deletions
@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// <c>ctx.CancellationToken</c> — the run token, visible to scripts. <see cref="ScriptRunGuard"/> already
/// cancels the token it hands <see cref="ScriptEvaluator{TContext, TResult}.RunAsync"/> when a run times out;
/// a script that polls it can now end its own abandoned run instead of burning a pool thread until it
/// returns. Cooperative only: a script that ignores it is still an orphan.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptCancellationTokenTests
{
/// <summary>Spins until cancelled, but ends by itself after 10 s so a broken implementation cannot pin a
/// pool thread past the test.</summary>
private const string CooperativeSpin =
"""
var end = System.DateTime.UtcNow.AddSeconds(10);
while (!ctx.CancellationToken.IsCancellationRequested && System.DateTime.UtcNow < end) { }
return ctx.CancellationToken.IsCancellationRequested ? -1 : 1;
""";
[Fact]
public async Task A_script_sees_an_uncancelled_token_on_a_normal_run()
{
using var eval = ScriptEvaluator<FakeScriptContext, bool>.Compile(
"return ctx.CancellationToken.CanBeCanceled && !ctx.CancellationToken.IsCancellationRequested;");
using var cts = new CancellationTokenSource();
(await eval.RunAsync(new FakeScriptContext(), cts.Token)).ShouldBeTrue();
}
[Fact]
public async Task Outside_a_run_the_token_is_None()
{
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile("return 1;");
var ctx = new FakeScriptContext();
(await eval.RunAsync(ctx)).ShouldBe(1);
ctx.CancellationToken.ShouldBe(CancellationToken.None, "the token is scoped to the run, not left on the context");
}
[Fact]
public async Task A_timed_out_run_whose_script_polls_the_token_ends_its_own_orphan()
{
var guard = new ScriptRunGuard();
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile(CooperativeSpin);
var ct = TestContext.Current.CancellationToken;
await Should.ThrowAsync<ScriptTimeoutException>(
() => guard.RunAsync("k", CooperativeSpin, eval, new FakeScriptContext(), TimeSpan.FromMilliseconds(100), ct));
// The guard cancelled the run token on timeout; the script saw it and returned, which frees the slot well
// inside its own 10 s fallback. Without ctx.CancellationToken the orphan would hold it for the full 10 s.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (guard.InFlightCount != 0 && DateTime.UtcNow < deadline)
await Task.Delay(20, ct);
guard.InFlightCount.ShouldBe(0, "the cooperative script ended its abandoned run");
// Positive control: the slot is free, so the next run is dispatched rather than refused. Same source
// identity on purpose: a different one would reset the key and dispatch even behind a live orphan.
using var fast = ScriptEvaluator<FakeScriptContext, int>.Compile("return 5;");
(await guard.RunAsync("k", CooperativeSpin, fast, new FakeScriptContext(), TimeSpan.FromSeconds(5), ct))
.ShouldBe(5);
}
}
@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// <see cref="ScriptingOptions.Sanitized"/> — the bind-time clamp of the <c>Scripting</c> section's numeric
/// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an
/// <see cref="OverflowException"/> inside a script run (<c>timeout * StuckOrphanQuarantineFactor</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptingOptionsTests
{
[Fact]
public void Defaults_pass_through_unchanged_with_no_adjustments()
{
var sanitized = new ScriptingOptions().Sanitized(out var adjustments);
adjustments.ShouldBeEmpty();
sanitized.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
sanitized.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
sanitized.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
}
[Theory]
[InlineData(double.MaxValue, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
[InlineData(double.PositiveInfinity, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
[InlineData(1e300, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
[InlineData(double.NaN, ScriptingOptions.DefaultStuckOrphanQuarantineFactor)]
[InlineData(-5, 0)]
[InlineData(double.NegativeInfinity, 0)]
[InlineData(0, 0)]
[InlineData(2.5, 2.5)]
public void StuckOrphanQuarantineFactor_is_clamped(double configured, double expected)
{
var sanitized = new ScriptingOptions { StuckOrphanQuarantineFactor = configured }.Sanitized(out var adjustments);
sanitized.StuckOrphanQuarantineFactor.ShouldBe(expected);
// Negative already means "disabled" and is documented as such; normalising it to 0 is not worth a warning.
if (!double.IsNaN(configured) && configured >= 0 && configured <= ScriptingOptions.MaxStuckOrphanQuarantineFactor)
adjustments.ShouldBeEmpty();
}
[Theory]
[InlineData(0, 1)]
[InlineData(-3, 1)]
[InlineData(int.MaxValue, ScriptingOptions.MaxQuarantineAfterConsecutiveTimeouts)]
[InlineData(5, 5)]
public void QuarantineAfterConsecutiveTimeouts_is_clamped(int configured, int expected) =>
new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = configured }.Sanitized(out _)
.QuarantineAfterConsecutiveTimeouts.ShouldBe(expected);
[Theory]
[InlineData(-1L, 0L)]
[InlineData(0L, 0L)]
[InlineData(1024L, ScriptingOptions.MinMaxAllocBytesPerRun)]
[InlineData(long.MaxValue, long.MaxValue)]
public void MaxAllocBytesPerRun_is_clamped(long configured, long expected) =>
new ScriptingOptions { MaxAllocBytesPerRun = configured }.Sanitized(out _)
.MaxAllocBytesPerRun.ShouldBe(expected);
[Fact]
public void An_out_of_range_value_is_reported_as_an_adjustment_naming_the_key()
{
_ = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }.Sanitized(out var adjustments);
adjustments.ShouldHaveSingleItem().ShouldContain("Scripting:StuckOrphanQuarantineFactor");
}
[Fact]
public void Sanitized_returns_a_copy_and_leaves_the_source_untouched()
{
var source = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 };
var sanitized = source.Sanitized(out _);
sanitized.ShouldNotBeSameAs(source);
source.StuckOrphanQuarantineFactor.ShouldBe(1e300);
}
/// <summary>
/// The guard takes options from more places than the bind (the Calculation driver's per-driver DTO, tests),
/// so it must not overflow either: an absurd factor reaching the guard directly is clamped, and a refusal
/// behind a young orphan is still a plain refusal rather than an <see cref="OverflowException"/>.
/// </summary>
[Fact]
public async Task An_absurd_factor_reaching_the_guard_directly_does_not_overflow_on_a_refusal()
{
var guard = new ScriptRunGuard(new ScriptingOptions { StuckOrphanQuarantineFactor = double.MaxValue });
const string spin =
"""
var end = System.DateTime.UtcNow.AddSeconds(3);
while (System.DateTime.UtcNow < end) { }
return 1;
""";
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile(spin);
var ct = TestContext.Current.CancellationToken;
var timeout = TimeSpan.FromMilliseconds(60);
await Should.ThrowAsync<ScriptTimeoutException>(
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
// The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException.
await Should.ThrowAsync<ScriptRunRefusedException>(
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
// A huge timeout times the clamped maximum factor must not overflow either.
await Should.ThrowAsync<ScriptRunRefusedException>(
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct));
}
}
@@ -0,0 +1,172 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Review 07 (Gitea #737) against a running <c>ab_server</c> on both transports: a controller refusal is a per-tag
/// config error, not a comms fault (H10 probe, H1/L4 data path), and a whole-array write round-trips (U10).
/// Skipped when the fixture is unreachable (<see cref="AbServerTheoryAttribute"/>); the array write additionally
/// needs <c>AB_SERVER_ALLOW_WRITES=1</c> (<see cref="IntegrationWriteGate"/>).
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipRefusalLiveTests
{
private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0";
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string what)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(50);
}
}
/// <summary>
/// H10: a typo'd probe tag on a reachable controller leaves the host Running, and a COLD data tag created
/// after many probe ticks still connects (the backoff window was never opened by the probe's NotFound).
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_typod_probe_tag_does_not_black_out_a_reachable_device(AbCipTransport transport)
{
var device = Device(new AbServerFixture());
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "NoSuchProbeTag_xyz",
Interval = TimeSpan.FromMilliseconds(200),
Timeout = TimeSpan.FromSeconds(2),
},
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-h10-{transport}");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
TimeSpan.FromSeconds(10), "the host never went Running on a refused probe tag");
await Task.Delay(1_000); // several probe ticks — each one used to record a Forward-Open failure
var r = await drv.ReadAsync(["Counter"], CancellationToken.None); // a cold create
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// H1/L4: a tag the controller does not have reads BadNodeIdUnknown every poll while a sibling reads Good, and
/// the driver stays Healthy with LastSuccessfulRead advancing (it used to latch Degraded and, with no other
/// tag, trip the comms-loss watchdog against a reachable controller).
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_missing_tag_is_per_tag_Bad_and_never_degrades_the_driver(AbCipTransport transport)
{
var device = Device(new AbServerFixture());
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Typo", device, "NoSuchTag_xyz", AbCipDataType.DInt),
new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-h1-{transport}");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
DateTime? last = null;
for (var i = 0; i < 3; i++)
{
var r = await drv.ReadAsync(["Typo", "Counter"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
if (last is { } previous) health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(previous);
last = health.LastSuccessfulRead;
}
// Only the refused tag: still Healthy, still answering (the watchdog conjunction can never trip).
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.ReadAsync(["Typo"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// U10: a whole <c>int[]</c> written to the 16-element <c>TestArray</c> (DINT[16]) reads back element for element
/// through a fresh driver; a wrong-length write is refused BadOutOfRange and changes nothing.
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_whole_array_write_round_trips(AbCipTransport transport)
{
IntegrationWriteGate.SkipUnlessWritesAllowed();
var device = Device(new AbServerFixture());
var seed = Random.Shared.Next(1_000, 1_000_000);
var values = Enumerable.Range(0, 16).Select(i => seed + i * 7).ToArray();
AbCipDriver Make(string id) => new(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Arr", device, "TestArray", AbCipDataType.DInt, ElementCount: 16, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-u10-{id}-{transport}");
var writer = Make("writer");
await writer.InitializeAsync("{}", CancellationToken.None);
try
{
var w = await writer.WriteAsync([new WriteRequest("Arr", values)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var bad = await writer.WriteAsync([new WriteRequest("Arr", values.Take(15).ToArray())], CancellationToken.None);
bad[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
writer.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await writer.DisposeAsync();
}
var reader = Make("reader");
await reader.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await reader.ReadAsync(["Arr"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[0].Value.ShouldBe(values);
}
finally
{
await reader.DisposeAsync();
}
}
}
@@ -311,8 +311,9 @@ public sealed class AbCipAlmaProjectionTests
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
// every re-created runtime must fail too (a fresh fake would read as "all clear").
var recreations = 0;
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
// A comms status (not a device refusal, which keeps the handle since review 07 H1) so the handle is evicted.
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; };
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.BadConnection;
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
await r.StopAsync();
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
@@ -334,9 +335,9 @@ public sealed class AbCipAlmaProjectionTests
{
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
Interlocked.Increment(ref bad);
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; // comms ⇒ evicted + re-created
};
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.BadConnection;
await WaitFor(() => Volatile.Read(ref bad) >= 4);
// The link recovers and HAcked reads true again.
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes
/// Operate-writable, but both runtimes encoded a SCALAR: an <c>int[]</c> threw InvalidCastException (BadTypeMismatch
/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only
/// element 0 patched. The driver now encodes <c>count</c> elements at the element width, refuses a length mismatch
/// with <c>BadOutOfRange</c>, and converts element values (ScadaBridge writes <c>int[]</c> to INT arrays).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayWriteTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
AbCipDataType type, int count, int size = -1)
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-array-write", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
// ---- the codec ----
/// <summary>Each element is converted and laid out little-endian at the element width.</summary>
[Fact]
public void The_codec_converts_elements_and_lays_them_out_at_the_element_width()
{
AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01"));
AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05"));
AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000"));
AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040"));
AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF"));
Should.Throw<OverflowException>(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000]));
Should.Throw<NotSupportedException>(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true]));
}
// ---- the driver ----
/// <summary>ScadaBridge's wire shape: an <c>int[]</c> to an INT[3] encodes 3 two-byte elements and writes once, Good.</summary>
[Fact]
public async Task An_int_array_to_an_INT_array_encodes_every_element()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var tag = factory.Tags["Arr"];
tag.EncodedArrayType.ShouldBe(AbCipDataType.Int);
tag.EncodedArrayCount.ShouldBe(3);
tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300"));
tag.WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A list of a different length is BadOutOfRange; nothing is created, encoded or written.</summary>
/// <param name="length">The client's element count.</param>
[Theory]
[InlineData(2)]
[InlineData(4)]
public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length)
{
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only.</summary>
[Fact]
public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch()
{
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
var r = await drv.WriteAsync(
[new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags.ShouldNotContainKey("Arr");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An element that does not fit is BadOutOfRange with no health write and nothing sent.</summary>
[Fact]
public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags["Arr"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>BOOL / STRING arrays are refused BadNotSupported before any runtime exists.</summary>
/// <param name="type">The array element type.</param>
[Theory]
[InlineData(AbCipDataType.Bool)]
[InlineData(AbCipDataType.String)]
public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type)
{
var (drv, factory) = await BuildAsync(type, 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
factory.Tags.ShouldNotContainKey("Arr");
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // M5: a refusal writes no health
}
/// <summary>The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.</summary>
[Fact]
public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Arr"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// End to end over the REAL native runtime: an <c>int[]</c> to an INT[3] goes out as ONE Write Tag of type
/// 0xC3 with element count 3 and the three 2-byte elements.
/// </summary>
[Fact]
public async Task A_native_array_write_is_one_write_of_count_elements()
{
await using var target = new FakeCipTarget();
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
{
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0
0x4D => [0xCD, 0x00, 0x00, 0x00],
_ => target.ServiceReply(req),
});
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
var typeAt = 2 + write[1] * 2;
write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01"));
}
finally
{
await drv.DisposeAsync();
}
}
}
@@ -0,0 +1,161 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H17: Logix allows a <c>.n</c> bit selector on SINT / INT / DINT (and LINT) hosts, and reads of
/// <c>Flags.3</c> on an INT worked — but the bit write's read-modify-write hard-coded a DINT parent, so its
/// <c>DecodeValue(DInt)</c> on a 2-byte buffer threw OutOfBounds and the write was reported as a comms fault
/// (BadCommunicationError + Degraded). The parent width now comes from the parent read's size.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipBitInWordWidthTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(int parentSize, object parentValue, string bitPath)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = parentValue, Size = parentSize },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", Device, bitPath, AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-bit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>SINT / INT / DINT hosts: the bit is set, every other bit is preserved, nothing degrades.</summary>
/// <param name="size">The parent's wire width.</param>
/// <param name="before">The parent's value before the write.</param>
/// <param name="bit">The bit written true.</param>
/// <param name="after">The value the parent must be written back with.</param>
[Theory]
[InlineData(1, 0x81, 3, 0x89)] // SINT 0x81 (-127): bit 7 set stays set, written as its unsigned byte
[InlineData(1, 0x00, 7, 0x80)] // SINT top bit: 128 would overflow an sbyte encode
[InlineData(2, 0x8001, 3, 0x8009)] // INT top bit preserved
[InlineData(2, 0x0000, 15, 0x8000)] // INT top bit: 32768 would overflow a short encode
[InlineData(4, 0x00000001, 3, 0x00000009)]
public async Task A_bit_write_uses_the_parent_width(int size, int before, int bit, int after)
{
var (drv, factory) = await BuildAsync(size, before, $"Motor.Flags.{bit}");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
Convert.ToInt64(factory.Tags["Motor.Flags"].Value).ShouldBe(after);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A bit past the host's width is refused before anything is written, with no health write.</summary>
[Fact]
public async Task A_bit_past_the_host_width_is_BadOutOfRange_and_nothing_is_written()
{
var (drv, factory) = await BuildAsync(2, 0, "Motor.Flags.20");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A host whose width is no integer's is a type mismatch, refused before writing, no health write.</summary>
[Fact]
public async Task A_host_of_no_integer_width_is_BadTypeMismatch()
{
var (drv, factory) = await BuildAsync(3, 0, "Motor.Flags.1");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// End to end over the REAL native runtime against a fake CIP target whose <c>Flags</c> is an INT: the write
/// goes out as ONE INT (type 0xC3, two data bytes) with the bit set and the other bits preserved.
/// </summary>
[Fact]
public async Task A_native_bit_write_on_an_INT_host_writes_an_INT()
{
await using var target = new FakeCipTarget();
// Read Tag Fragmented (0x52) → INT (0x00C3) 0x8001; Write Tag (0x4D) → success.
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
{
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x01, 0x80],
0x4D => [0xCD, 0x00, 0x00, 0x00],
_ => target.ServiceReply(req),
});
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", host, "Flags.3", AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-bit-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
var typeAt = 2 + write[1] * 2;
write[typeAt].ShouldBe((byte)0xC3); // INT, not DINT
write.Length.ShouldBe(typeAt + 2 + 2 + 2); // type, element count, ONE 2-byte element
write[^2].ShouldBe((byte)0x09); // 0x8001 | bit 3
write[^1].ShouldBe((byte)0x80);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// M1: a parent read that fails with a COMMS status evicts the parent runtime and degrades (LastSuccessfulRead
/// preserved); one the controller REFUSES is per-tag Bad, keeps the runtime and writes no health.
/// </summary>
[Fact]
public async Task A_failed_parent_read_follows_the_health_contract()
{
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection };
var before = drv.GetHealth();
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Motor.Flags"].Disposed.ShouldBeTrue();
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
}
/// <summary>M1 positive control: a refused parent read keeps the runtime, no health write.</summary>
[Fact]
public async Task A_refused_parent_read_keeps_the_runtime_and_health()
{
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotAllowed };
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
factory.Tags["Motor.Flags"].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,298 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed,
/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It
/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide
/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDeviceRefusalTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise).</summary>
private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public AbStatus? ReadThrows { get; set; }
public AbStatus? WriteThrows { get; set; }
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString());
}
public override async Task WriteAsync(CancellationToken cancellationToken)
{
await base.WriteAsync(cancellationToken);
if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString());
}
}
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(
Func<AbCipTagCreateParams, FakeAbCipTag> make)
{
var factory = new FakeAbCipTagFactory { Customise = make };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt),
new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = 1,
}, "abcip-refusal", factory);
return (drv, factory);
}
/// <summary>The classification itself: every answered refusal is reachable; transport failures are not.</summary>
/// <param name="status">The transport status.</param>
/// <param name="refusal">Whether it is a device refusal.</param>
[Theory]
[InlineData(AbStatus.NotFound, true)]
[InlineData(AbStatus.OutOfBounds, true)]
[InlineData(AbStatus.NotAllowed, true)]
[InlineData(AbStatus.TooLarge, true)]
[InlineData(AbStatus.TooSmall, true)]
[InlineData(AbStatus.TypeMismatch, true)]
[InlineData(AbStatus.Unsupported, true)]
[InlineData(AbStatus.Ok, false)]
[InlineData(AbStatus.Timeout, false)]
[InlineData(AbStatus.BadConnection, false)]
[InlineData(AbStatus.BadReply, false)]
[InlineData(AbStatus.Partial, false)]
[InlineData(AbStatus.Abort, false)]
public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal)
{
AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal);
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok);
}
/// <summary>A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead.</summary>
/// <param name="status">The refusal the controller answers with.</param>
/// <param name="expected">The OPC UA status the tag reads.</param>
[Theory]
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)]
public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected)
{
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
for (var i = 0; i < 3; i++)
{
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
r[0].StatusCode.ShouldBe(expected);
}
var bad = factory.Tags["Arr[999]"];
bad.InitializeCount.ShouldBe(1); // never evicted → never re-created
bad.Disposed.ShouldBeFalse();
bad.ReadCount.ShouldBe(3);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered
}
/// <summary>Positive control: a genuine transport failure on the same tag still evicts and degrades.</summary>
[Fact]
public async Task A_transport_failure_still_evicts_and_degrades()
{
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null });
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
factory.Tags["Arr[999]"].Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>A refusal returned as a status word (no throw) is classified the same way.</summary>
[Fact]
public async Task A_refusal_status_word_keeps_the_runtime()
{
var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 });
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Bad"], CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1);
factory.Tags["Arr[999]"].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the
/// device-wide backoff window, so a sibling cold tag still connects and reads Good.
/// </summary>
[Fact]
public async Task A_refused_create_does_not_throttle_sibling_cold_creates()
{
var (drv, _) = Build(p => p.TagName == "Arr[999]"
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") }
: new FakeAbCipTag(p) { Value = 42 });
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[1].Value.ShouldBe(42);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A write the controller refuses keeps the runtime and writes no health.</summary>
[Fact]
public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded()
{
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-refusal", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None);
var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None);
w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1);
factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// L4 end to end over the REAL native runtime + a private pool: a renamed tag (CIP 0x04) that is the device's
/// ONLY lease holder used to be evicted every poll — the last release, so Forward Close, then a throttled Forward
/// Open on the next poll. One runtime and one Forward Open across N polls and no Forward Close proves no churn.
/// </summary>
[Fact]
public async Task A_native_NotFound_tag_causes_no_runtime_or_connection_churn()
{
await using var target = new FakeCipTarget();
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
req[0] == 0x52 ? [0xD2, 0x00, 0x04, 0x00] : target.ServiceReply(req));
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var creates = 0;
var native = new NativeAbCipTagFactory(new DeviceSessionPool());
var factory = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); });
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Renamed", host, "Renamed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-l4-native", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
for (var n = 0; n < 5; n++)
(await drv.ReadAsync(["Renamed"], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
Volatile.Read(ref creates).ShouldBe(1);
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(5); // positive control: every poll hit the wire
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await drv.DisposeAsync();
}
}
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
}
/// <summary>
/// #737 I2: a LOCAL refusal (libplctag ErrorNotImplemented — no I/O) is per-tag BadNotSupported, keeps health
/// state, and does NOT advance LastSuccessfulRead (nothing proves the device answered). Positive control: the
/// device's own Unsupported refusal does advance it (M2).
/// </summary>
[Fact]
public async Task A_local_refusal_does_not_advance_LastSuccessfulRead_but_a_device_one_does()
{
var (drv, _) = Build(p => p.TagName == "Arr[999]"
? new LocalRefusingTag(p)
: new RefusingTag(p) { ReadThrows = AbStatus.Unsupported });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.ReadAsync(["Bad"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().LastSuccessfulRead.ShouldBe(before);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["Good"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
}
/// <summary>#737 I2: a local refusal at create opens no backoff — a sibling cold create still connects.</summary>
[Fact]
public async Task A_local_refusal_at_create_does_not_throttle_or_count_as_answered()
{
var (drv, _) = Build(p => p.TagName == "Arr[999]"
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbCipLocalRefusalException("ErrorNotImplemented") }
: new FakeAbCipTag(p) { Value = 3 });
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>M2: a write the device refuses proves it answered — LastSuccessfulRead advances.</summary>
[Fact]
public async Task A_refused_write_advances_LastSuccessfulRead()
{
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-refusal", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadNotWritable);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private sealed class LocalRefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
throw new AbCipLocalRefusalException("ErrorNotImplemented");
}
}
}
@@ -0,0 +1,239 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Lifecycle overlap the runtime can produce (review 07 H13/F10, Lane A): the final stop runs
/// <c>ShutdownAsync</c> then <c>DisposeAsync</c> under one bounded budget, so a slow shutdown is abandoned and
/// the dispose runs CONCURRENTLY with it; an abandoned <c>InitializeAsync</c> can overlap the next attempt's
/// shutdown. Device tables are build-and-swapped, so each teardown owns a disjoint set of device states.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipLifecycleConcurrencyTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>A fake whose reads ignore cancellation for a while, so a teardown waits on the probe loop.</summary>
private sealed class SlowTag(AbCipTagCreateParams p, TimeSpan readDelay) : FakeAbCipTag(p)
{
private int _disposeCount;
public int DisposeCount => Volatile.Read(ref _disposeCount);
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
if (readDelay > TimeSpan.Zero) await Task.Delay(readDelay, CancellationToken.None);
}
public override void Dispose()
{
Interlocked.Increment(ref _disposeCount);
base.Dispose();
}
}
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(TimeSpan probeReadDelay)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new SlowTag(p, p.TagName == "ProbeTag" ? probeReadDelay : TimeSpan.Zero) { Value = 1 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "ProbeTag",
Interval = TimeSpan.FromMilliseconds(10),
Timeout = TimeSpan.FromMilliseconds(200),
},
}, "abcip-lifecycle", factory);
return (drv, factory);
}
private static async Task WaitUntilAsync(Func<bool> condition, string what, int seconds = 5)
{
var deadline = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(5);
}
}
/// <summary>
/// Shutdown and Dispose racing each other tear the device down exactly once: the data runtime is
/// disposed once (not once per teardown), neither call throws, and the probe loop has exited.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_a_running_Shutdown_tears_each_state_down_exactly_once()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var state = drv.GetDeviceState(Host)!;
var probeLoop = state.ProbeTask!;
var data = (SlowTag)factory.Tags["Motor1.Speed"];
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await Task.WhenAll(shutdown, dispose);
data.DisposeCount.ShouldBe(1);
probeLoop.IsCompleted.ShouldBeTrue();
drv.GetDeviceState(Host).ShouldBeNull();
}
/// <summary>
/// An InitializeAsync that a ShutdownAsync overtakes publishes nothing: no device state and no probe
/// loop survive the shutdown. Positive control: an init that nothing overtakes still publishes.
/// </summary>
[Fact]
public async Task An_init_overtaken_by_Shutdown_publishes_no_device_and_starts_no_probe()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var firstProbe = factory.Tags["ProbeTag"];
// The second init's own release waits on the slow first probe loop; the shutdown overtakes it there.
var init = drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(50);
await drv.ShutdownAsync(CancellationToken.None);
var superseded = await Should.ThrowAsync<AbCipInitializationSupersededException>(() => init);
superseded.ShouldNotBeAssignableTo<OperationCanceledException>(); // M4: distinct from a caller cancellation
drv.GetDeviceState(Host).ShouldBeNull();
drv.GetHostStatuses().ShouldBeEmpty();
await Task.Delay(100);
factory.Tags["ProbeTag"].ShouldBeSameAs(firstProbe); // no second probe loop ever created a runtime
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: a later init that nothing overtakes publishes the device again.
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Host).ShouldNotBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>
/// Readers of the device/routing tables (the retained poll engine, the actor's host-status pull) never
/// see a table mid-mutation while the driver re-initialises underneath them, never trip over a per-tag lock a
/// retire disposed, and never leak a runtime they cold-created into a device state a retire already tore down.
/// </summary>
[Fact]
public async Task Readers_survive_repeated_reinitialisation_and_leak_no_runtime()
{
var created = new System.Collections.Concurrent.ConcurrentBag<FakeAbCipTag>();
var factory = new FakeAbCipTagFactory
{
Customise = p =>
{
var t = new FakeAbCipTag(p) { Value = 1 };
created.Add(t);
return t;
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-lifecycle", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var badHealth = 0;
var reader = Task.Run(async () =>
{
var reads = 0;
while (!stop.IsCancellationRequested)
{
_ = drv.GetHostStatuses();
_ = drv.ResolveHost("Speed");
await drv.ReadAsync(["Speed"], CancellationToken.None);
if (drv.GetHealth().State == DriverState.Degraded) Interlocked.Increment(ref badHealth);
reads++;
started.TrySetResult();
}
return reads;
});
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
for (var i = 0; i < 200; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
stop.Cancel();
(await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran
Volatile.Read(ref badHealth).ShouldBe(0, "a re-init is not a comms fault");
await drv.DisposeAsync();
created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown");
}
/// <summary>F10: DisposeAsync is the complete, idempotent teardown (the runtime prefers it).</summary>
[Fact]
public async Task DisposeAsync_is_a_complete_idempotent_teardown()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
await drv.DisposeAsync();
await drv.DisposeAsync(); // idempotent
loop.IsCompleted.ShouldBeTrue();
factory.Tags["ProbeTag"].Disposed.ShouldBeTrue();
}
/// <summary>
/// F10: the sync <c>Dispose()</c> no longer blocks the caller's thread on the async teardown (sync-over-async —
/// a dispatcher thread for up to the probe join). It starts the same teardown and returns; the teardown still
/// completes.
/// </summary>
[Fact]
public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromSeconds(4));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
await Task.Delay(50); // the probe is inside its slow, cancellation-deaf read
var sw = System.Diagnostics.Stopwatch.StartNew();
((IDisposable)drv).Dispose();
sw.Stop();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // did not wait out the 4 s probe read (CI-tolerant margin)
await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed", seconds: 15);
drv.GetDeviceState(Host).ShouldBeNull();
}
/// <summary>
/// M4: a ShutdownAsync overtaken by a NEWER InitializeAsync (its probe join is slow) must not stamp Unknown over
/// the newer init's Healthy when it finally finishes.
/// </summary>
[Fact]
public async Task A_slow_shutdown_does_not_overwrite_a_newer_inits_health()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(400));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
await Task.Delay(30); // inside the slow probe read
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(30);
await drv.InitializeAsync("{}", CancellationToken.None); // newer: publishes while the shutdown still joins
await shutdown;
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetDeviceState(Host).ShouldNotBeNull();
await drv.DisposeAsync();
}
}
@@ -7,7 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task 22 review r1: a runtime that refuses an operation with <see cref="AbStatus.Unsupported"/> (the native
/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported —
/// transport does this before the wire, e.g. a write it cannot type — an <see cref="AbCipLocalRefusalException"/>
/// since #737 I2) reaches the client as BadNotSupported —
/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched.
/// </summary>
[Trait("Category", "Unit")]
@@ -19,12 +20,12 @@ public sealed class AbCipLocalRefusalTests
{
public override Task ReadAsync(CancellationToken cancellationToken) =>
refuseRead
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read"))
? Task.FromException(new AbCipLocalRefusalException("refused read"))
: base.ReadAsync(cancellationToken);
public override Task WriteAsync(CancellationToken cancellationToken) =>
refuseWrite
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write"))
? Task.FromException(new AbCipLocalRefusalException("refused write"))
: base.WriteAsync(cancellationToken);
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H10: a typo'd (or download-deleted) <c>Probe.ProbeTagPath</c> used to record a Forward-Open
/// FAILURE every probe tick, so the device's backoff window never closed and every cold / evicted data tag read
/// <c>BadCommunicationError</c> forever — a permanent device blackout that a restart did not cure. The controller
/// ANSWERED, so the probe now counts it as reachable: backoff success, host Running, one config Warning.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipProbeRefusalTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver Driver, CapturingLogger Logger) Build(Exception probeFault)
{
var logger = new CapturingLogger();
var factory = new FakeAbCipTagFactory
{
Customise = p => p.TagName == "ProbeTagTypo"
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = probeFault }
: new FakeAbCipTag(p) { Value = 7 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "ProbeTagTypo",
Interval = TimeSpan.FromMilliseconds(10),
Timeout = TimeSpan.FromMilliseconds(100),
},
}, "abcip-probe-refusal", factory, logger: logger);
return (drv, logger);
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition never met");
await Task.Delay(5);
}
}
/// <summary>A NotFound probe tag leaves the device reachable: data tags still connect, host is Running, warned once.</summary>
[Fact]
public async Task A_missing_probe_tag_does_not_black_out_the_device()
{
var (drv, logger) = Build(new AbTransportException(AbStatus.NotFound, "NotFound"));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running);
await Task.Delay(100); // many probe ticks
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); // a cold create, NOT throttled
r[0].Value.ShouldBe(7);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("ProbeTagTypo")).ShouldBe(1);
await drv.DisposeAsync();
}
/// <summary>Positive control: a probe that genuinely cannot connect still stops the host and throttles cold creates.</summary>
[Fact]
public async Task An_unreachable_device_still_stops_the_host_and_throttles()
{
var (drv, _) = Build(new AbTransportException(AbStatus.Timeout, "Timeout"));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Stopped);
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); // throttled by the open window
await drv.DisposeAsync();
}
}
@@ -0,0 +1,96 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 P8 / P7: the bounded-parallel read window. On the native transport every concurrent tag read of a
/// device rides ONE shared request scheduler that packs whatever is queued into a Multiple Service Packet, so a
/// window of 8 capped MSP coalescing at 8 services per round trip; it is widened to at least 64 there (the
/// scheduler's reply budget already prevents over-packing). And a cold create waiting on a device's connect gate
/// must not occupy a window slot, or one dead device's backoff-expiry attempt stalls every other device's reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReadWindowTests
{
private static AbCipDriverOptions Options(int maxConcurrentReads, AbCipTransport? transport = null) => new()
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = maxConcurrentReads,
Transport = transport,
};
/// <summary>The window per transport: native ≥ 64; libplctag and injected factories honour the option exactly.</summary>
[Fact]
public void The_native_window_is_at_least_64_and_other_transports_keep_the_option()
{
new AbCipDriver(Options(8, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
AbCipDriver.NativeMinReadWindow.ShouldBeGreaterThanOrEqualTo(64);
new AbCipDriver(Options(1, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
new AbCipDriver(Options(200, AbCipTransport.Native), "a").ReadWindow.ShouldBe(200);
new AbCipDriver(Options(8), "a", defaultTransport: AbCipTransport.Native).ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
// Positive controls: libplctag (per-handle concurrency) and an injected test factory keep the option.
new AbCipDriver(Options(8, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(8);
new AbCipDriver(Options(0, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(1);
new AbCipDriver(Options(3, AbCipTransport.Native), "a", new FakeAbCipTagFactory()).ReadWindow.ShouldBe(3);
}
/// <summary>A tag whose initialisation blocks until released (a cold connect to a slow / dead device).</summary>
private sealed class BlockingInitTag(AbCipTagCreateParams p, Task release) : FakeAbCipTag(p)
{
public override async Task InitializeAsync(CancellationToken cancellationToken)
{
await base.InitializeAsync(cancellationToken);
await release.WaitAsync(cancellationToken);
}
}
/// <summary>
/// With a window of ONE, device B's blocked cold create (and its queued sibling) must not stop device A's tag
/// from being read: cold waiters sit on B's connect gate, not in the window.
/// </summary>
[Fact]
public async Task A_blocked_cold_create_on_one_device_does_not_hold_the_window_against_another()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeAbCipTagFactory
{
Customise = p => p.Gateway == "10.0.0.6"
? new BlockingInitTag(p, release.Task) { Value = 2 }
: new FakeAbCipTag(p) { Value = 1 },
};
const string a = "ab://10.0.0.5/1,0", b = "ab://10.0.0.6/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("B1", b, "B1", AbCipDataType.DInt),
new AbCipTagDefinition("B2", b, "B2", AbCipDataType.DInt),
new AbCipTagDefinition("A1", a, "A1", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = 1,
}, "abcip-window", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var read = drv.ReadAsync(["B1", "B2", "A1"], CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("A1", out var tag) && tag.ReadCount > 0))
{
if (DateTime.UtcNow > deadline)
{
release.TrySetResult();
await read;
throw new ShouldAssertException("device A's read waited behind device B's blocked cold create");
}
await Task.Delay(5);
}
read.IsCompleted.ShouldBeFalse(); // B is still blocked — A was read regardless
release.SetResult();
var r = await read;
r.Select(x => x.Value).ShouldBe([2, 2, 1]); // positive control: B's reads complete once it connects
await drv.DisposeAsync();
}
}
@@ -77,7 +77,7 @@ public sealed class AbCipStatusMapperTests
Status.Ok => AbCipStatusMapper.Good,
Status.Pending => AbCipStatusMapper.GoodMoreData,
Status.ErrorTimeout => AbCipStatusMapper.BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown,
Status.ErrorNotFound => AbCipStatusMapper.BadNodeIdUnknown, // #737 I2: NoMatch/BadDevice are comms
Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
@@ -73,7 +73,7 @@ public sealed class AbCipTypeMismatchTests
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
MismatchWarnings(log).ShouldBe(1);
}
@@ -104,7 +104,7 @@ public sealed class AbCipTypeMismatchTests
Volatile.Read(ref creates).ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
MismatchWarnings(log).ShouldBe(1);
}
@@ -121,7 +121,7 @@ public sealed class AbCipTypeMismatchTests
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
MismatchWarnings(log).ShouldBe(1);
}
@@ -179,7 +179,7 @@ public sealed class AbCipTypeMismatchTests
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
MismatchWarnings(log).ShouldBe(1);
}
finally
@@ -226,7 +226,7 @@ public sealed class AbCipTypeMismatchTests
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
}
finally
{
@@ -267,12 +267,23 @@ public sealed class AbCipTypeMismatchTests
Volatile.Read(ref creates).ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
}
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
}
/// <summary>
/// A refusal leaves the health STATE and error alone; since the controller answered, LastSuccessfulRead may
/// only move forward (review 07 H1 — any data proving the device answered advances it).
/// </summary>
private static void AssertStateUnchanged(DriverHealth actual, DriverHealth before)
{
actual.State.ShouldBe(before.State);
actual.LastError.ShouldBe(before.LastError);
(actual.LastSuccessfulRead ?? DateTime.MinValue).ShouldBeGreaterThanOrEqualTo(before.LastSuccessfulRead ?? DateTime.MinValue);
}
#pragma warning restore OTOPCUA0001
}
@@ -99,4 +99,26 @@ public sealed class AbCipUdtMemberLayoutTests
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
}).ShouldBeNull();
}
/// <summary>Review 07 P11: the layout is computed once per member list, not once per member per poll.</summary>
[Fact]
public void GetOrBuild_computes_a_layout_once_per_member_list()
{
IReadOnlyList<AbCipStructureMember> members =
[
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("B", AbCipDataType.Real),
];
var first = AbCipUdtMemberLayout.GetOrBuild(members);
first.ShouldNotBeNull();
first["B"].ShouldBe(4);
AbCipUdtMemberLayout.GetOrBuild(members).ShouldBeSameAs(first);
// Positive control: a different (equal-content) list is its own entry, and a not-groupable verdict caches too.
IReadOnlyList<AbCipStructureMember> copy = [.. members];
AbCipUdtMemberLayout.GetOrBuild(copy).ShouldNotBeSameAs(first);
IReadOnlyList<AbCipStructureMember> withBool = [new AbCipStructureMember("F", AbCipDataType.Bool)];
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
}
}
@@ -0,0 +1,175 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must
/// not latch <c>Degraded</c>; and a good write must not overwrite a <c>Degraded</c> the read path just recorded
/// with <c>Healthy</c> (the batch read path is worst-wins for exactly this reason). A good write proves the device
/// answered, so it advances <c>LastSuccessfulRead</c> without touching the state.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipWriteHealthTests
{
private const string Device = "ab://10.0.0.5/1,0";
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override Task WriteAsync(CancellationToken cancellationToken) =>
throw new InvalidOperationException("socket reset");
}
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
Func<AbCipTagCreateParams, FakeAbCipTag>? make = null)
{
var factory = new FakeAbCipTagFactory
{
Customise = make ?? (p => p.TagName == "Broken"
? new FakeAbCipTag(p) { ThrowOnRead = true }
: new RealEncodingTag(p) { Value = 0 }),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt),
new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int),
new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-write-health", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16).</summary>
private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
Value = type switch
{
AbCipDataType.Int => Convert.ToInt16(value),
_ => Convert.ToInt32(value),
};
}
/// <summary>Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent.</summary>
[Fact]
public async Task A_client_conversion_error_writes_no_health()
{
var (drv, factory) = await BuildAsync();
var before = drv.GetHealth();
var r = await drv.WriteAsync(
[
new WriteRequest("D", "not a number"), // FormatException
new WriteRequest("D", new object()), // InvalidCastException
new WriteRequest("I", 1_000_000), // OverflowException
], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
drv.GetHealth().ShouldBe(before);
factory.Tags["D"].WriteCount.ShouldBe(0);
factory.Tags["I"].WriteCount.ShouldBe(0);
}
/// <summary>A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead.</summary>
[Fact]
public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy()
{
var (drv, _) = await BuildAsync();
await drv.ReadAsync(["Broken"], CancellationToken.None);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
await Task.Delay(20);
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastError.ShouldBe(degraded.LastError);
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered
}
/// <summary>Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead.</summary>
[Fact]
public async Task A_write_transport_fault_still_degrades()
{
var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p));
var before = drv.GetHealth();
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
factory.Tags["D"].Disposed.ShouldBeTrue();
}
/// <summary>M3: a good write clears a Degraded that a WRITE comms fault recorded.</summary>
[Fact]
public async Task A_good_write_clears_a_write_side_Degraded()
{
var fail = true;
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
(await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fail = false; // the evicted runtime is re-created healthy
(await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastError.ShouldBeNull();
}
/// <summary>
/// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
/// write still clears it.
/// </summary>
[Fact]
public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
{
var fail = true;
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
for (var round = 0; round < 20; round++)
{
fail = true;
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fail = false;
(await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
}
}
/// <summary>
/// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
/// good write still clears it.
/// </summary>
[Fact]
public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
{
var fail = true;
var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
: fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
(await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
fail = false;
await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -115,6 +115,25 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// <param name="value">The value to encode.</param>
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
/// <summary>The element type of the last <see cref="EncodeArray"/>, if any.</summary>
public AbCipDataType? EncodedArrayType { get; private set; }
/// <summary>The bytes the last <see cref="EncodeArray"/> produced (the shared codec, so conversions throw as in production).</summary>
public byte[]? EncodedArrayBytes { get; private set; }
/// <summary>The element count of the last <see cref="EncodeArray"/>.</summary>
public int EncodedArrayCount { get; private set; }
/// <summary>Encodes an array write through the production codec and records it.</summary>
/// <param name="type">The element type.</param>
/// <param name="values">The element values.</param>
public virtual void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
{
EncodedArrayBytes = AbCipArrayCodec.Encode(type, values);
EncodedArrayType = type;
EncodedArrayCount = values.Count;
}
/// <summary>Marks the tag as disposed.</summary>
public virtual void Dispose() => Disposed = true;
}
@@ -16,8 +16,8 @@ public sealed class LibplctagStatusTranslationTests
[InlineData(Status.Pending, AbStatus.Pending)]
[InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
[InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
[InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
[InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
[InlineData(Status.ErrorNoMatch, AbStatus.BadConnection)] // #737 I2: not proof the controller answered
[InlineData(Status.ErrorBadDevice, AbStatus.BadConnection)]
[InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
@@ -148,4 +148,24 @@ public sealed class LibplctagStatusTranslationTests
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam)));
thrown.Status.ShouldBe(AbStatus.BadConnection);
}
/// <summary>
/// #737 I2: libplctag's <c>ErrorNotImplemented</c> is the LIBRARY refusing (no I/O): a local refusal, not a device
/// refusal — it must not count as "the controller answered".
/// </summary>
[Fact]
public async Task NotImplemented_is_a_local_refusal_not_a_device_refusal()
{
var ex = await Should.ThrowAsync<AbTransportException>(() =>
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorNotImplemented")));
ex.ShouldBeOfType<AbCipLocalRefusalException>();
ex.Status.ShouldBe(AbStatus.Unsupported);
AbCipRefusals.IsDeviceRefusal(ex).ShouldBeFalse();
AbCipDriverProbe.IsReachableException(ex).ShouldBeFalse();
// Positive control: the device's own Unsupported refusal still is one.
var device = await Should.ThrowAsync<AbTransportException>(() =>
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorUnsupported")));
AbCipRefusals.IsDeviceRefusal(device).ShouldBeTrue();
}
}
@@ -112,6 +112,163 @@ public sealed class NativeAbCipTagRuntimeTests
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// Review 07 U11: a scalar BOOL authored over a BOOL-array element <c>Bits[n]</c> reads DWORD <c>n / 32</c>
/// (a Logix BOOL array is stored as DWORDs and its CIP element index addresses them) and decodes bit
/// <c>n % 32</c> of it — it used to request element n and report "any of bits 0-7 of the reply set".
/// </summary>
/// <param name="n">The authored BOOL-array element.</param>
/// <param name="dword">The DWORD the request must address.</param>
/// <param name="reply">The DWORD the controller returns (little-endian hex).</param>
/// <param name="expected">The element's value.</param>
[Theory]
[InlineData(37, 1, "20000000", true)] // bit 5 of DWORD 1
[InlineData(36, 1, "20000000", false)] // bit 4 of the same DWORD — the old "low byte != 0" read true
[InlineData(5, 0, "20000000", true)]
[InlineData(1, 0, "01000000", false)] // bit 1, not bit 0
[InlineData(31, 0, "00000080", true)] // top bit of DWORD 0 — outside the low byte entirely
public async Task A_BOOL_array_element_decodes_its_bit_of_its_DWORD(int n, int dword, string reply, bool expected)
{
var ch = Replying("D300", reply);
using var rt = await ReadAsync(ch, Params($"Bits[{n}]", AbCipDataType.Bool));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(expected);
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000"));
}
/// <summary>
/// #737 I1: a family whose BOOL array is element-addressed (the controller answers the DWORD-index request with a
/// plain BOOL, 0xC1) falls back to the ORIGINAL element path <c>Bits[n]</c> — it must neither decode nor write
/// element <c>n / 32</c>. Positive control: the 0xD3 theory above still decodes bit <c>n % 32</c>.
/// </summary>
[Fact]
public async Task A_per_element_BOOL_array_falls_back_to_the_element_path_for_read_and_write()
{
var ch = new FakeCipChannel
{
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : "D2000000C10001")),
};
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None);
await rt.ReadAsync(CancellationToken.None);
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2825" + "0100" + "00000000")); // Bits[37]
rt.EncodeValue(AbCipDataType.Bool, null, false);
await rt.WriteAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37]
}
/// <summary>A channel whose reply can be switched mid-test (read replies; writes always succeed).</summary>
private static FakeCipChannel Switchable(Func<string> readReply) => new()
{
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : readReply())),
};
private const string Dword1 = "5204" + "910442697473" + "2801" + "0100" + "00000000"; // Bits[1] (DWORD 37/32)
private const string Element37 = "5204" + "910442697473" + "2825" + "0100" + "00000000"; // Bits[37] (element)
/// <summary>
/// #737 fix round 2 (N1): a REFUSAL of the DWORD request says nothing about the layout — it must not settle it.
/// Tag deployed before the online edit that creates <c>Bits : BOOL[2048]</c>: the init read is refused NotFound;
/// once the array exists the controller answers 0xD3, and the read must address DWORD n/32 and decode bit n%32
/// (the round-1 code had permanently fallen back to <c>Bits[37]</c> = DWORD 37 and decoded byte 0 as Good).
/// </summary>
/// <param name="refusal">The refusal reply the DWORD request first gets.</param>
[Theory]
[InlineData("D2000400")] // NotFound
[InlineData("D2000F00")] // NotAllowed
[InlineData("D2000A00")] // OutOfBounds
public async Task A_refusal_leaves_the_BOOL_layout_unsettled_and_a_later_DWORD_reply_decodes_the_right_bit(string refusal)
{
var reply = refusal;
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None); // refused: completes with the refusal status
rt.GetStatus().ShouldNotBe((int)AbStatus.Ok);
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); // never fell back to the element path
reply = "D2000000D300" + "20000000"; // bit 5 of DWORD 1 = element 37
await rt.ReadAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// N1 positive control: a refusal, then a 0xC1 reply, settles PER-ELEMENT — the n/32 element that came back is
/// discarded and <c>Bits[n]</c> is re-read and decoded.
/// </summary>
[Fact]
public async Task A_refusal_then_a_per_element_reply_settles_the_element_path()
{
var reply = "D2000A00";
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None);
reply = "D2000000C10001";
await rt.ReadAsync(CancellationToken.None);
ch.Sent[^2].ShouldBe(Convert.FromHexString(Dword1)); // asked DWORD-addressed, got a plain BOOL…
ch.Sent[^1].ShouldBe(Convert.FromHexString(Element37)); // …so element 37 is re-read and decoded
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// N1: a layout that changes after it was settled (an online edit re-types the array) re-settles from the reply
/// type — it never decodes a DWORD reply to the ELEMENT request (DWORD n, byte 0) as the element's value.
/// </summary>
[Fact]
public async Task A_settled_per_element_layout_resettles_when_the_reply_becomes_a_DWORD()
{
var reply = "D2000000C10000";
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[36]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None); // settles per-element
reply = "D2000000D300" + "01000000"; // byte 0 non-zero, bit 4 (element 36) clear
await rt.ReadAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2801" + "0100" + "00000000"));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(false); // bit 4 of DWORD 1, not "byte 0 != 0"
}
/// <summary>N1 minimum guard: a scalar BOOL with no element bit answered as a packed DWORD is refused, not byte 0.</summary>
[Fact]
public async Task A_scalar_BOOL_answered_as_a_packed_DWORD_without_an_element_bit_is_refused()
{
using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Flag", AbCipDataType.Bool));
(Should.Throw<AbTransportException>(() => rt.DecodeValue(AbCipDataType.Bool, null))).Status.ShouldBe(AbStatus.TypeMismatch);
}
/// <summary>
/// m4: a runtime whose init read failed on the wire (layout still unsettled) settles the layout by a read BEFORE
/// it writes, so the write goes to the right element of a per-element array.
/// </summary>
[Fact]
public async Task A_write_on_an_unsettled_layout_reads_first_and_writes_the_element()
{
var reply = "D2000100"; // 0x01: not a refusal — init throws, layout unsettled
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
reply = "D2000000C10000";
rt.EncodeValue(AbCipDataType.Bool, null, true);
await rt.WriteAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "01")); // Bits[37]
}
/// <summary>Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte.</summary>
[Fact]
public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte()
{
using var rt = await ReadAsync(Replying("C100", "01"), Params("Flag", AbCipDataType.Bool));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
[Fact]
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
{
@@ -129,14 +286,35 @@ public sealed class NativeAbCipTagRuntimeTests
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
}
[Fact]
public async Task Unknown_tag_fails_initialize_with_NotFound()
/// <summary>
/// Review 07 L4: a controller refusal on the existence read completes the initialization (the session works —
/// throwing made the driver dispose + re-create the runtime, re-leasing the connection, every poll); the
/// refusal stays readable and every later read reports it again.
/// </summary>
[Theory]
[InlineData("D2000400", AbStatus.NotFound)]
[InlineData("D2000500", AbStatus.NotFound)]
[InlineData("D2000A00", AbStatus.OutOfBounds)]
[InlineData("D2000F00", AbStatus.NotAllowed)]
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
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("D2000400")) };
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
await rt.InitializeAsync(CancellationToken.None);
rt.GetStatus().ShouldBe((int)expected);
(await Should.ThrowAsync<AbTransportException>(() => rt.ReadAsync(CancellationToken.None)))
.Status.ShouldBe(expected);
}
/// <summary>Positive control: a reply the transport cannot classify as a refusal still fails initialize.</summary>
[Fact]
public async Task An_unclassified_reply_still_fails_initialize()
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000100")) };
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.NotFound);
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
.Status.ShouldBe(AbStatus.BadReply);
}
[Fact]
@@ -0,0 +1,69 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
/// <summary>
/// The Calculation driver's run-guard options: the node-wide <c>Scripting</c> section is the base, and a key
/// authored in the driver's own config overrides only that knob. Before the section was bound, a Calculation
/// driver always ran on the compiled defaults whatever the node configured.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CalculationScriptingOptionsResolutionTests
{
private static readonly ScriptingOptions Node = new()
{
MaxAllocBytesPerRun = 8L * 1024 * 1024,
QuarantineAfterConsecutiveTimeouts = 7,
StuckOrphanQuarantineFactor = 4,
};
[Fact]
public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() =>
CalculationDriverFactoryExtensions.ResolveScripting(null, null, null, out _).ShouldBeNull();
[Fact]
public void No_driver_keys_inherits_every_node_knob()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node, out _).ShouldNotBeNull();
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun);
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
resolved.StuckOrphanQuarantineFactor.ShouldBe(4);
}
[Fact]
public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node, out var adjustments).ShouldNotBeNull();
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2);
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value");
resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide");
resolved.ShouldNotBeSameAs(Node);
Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
adjustments.ShouldBeEmpty();
}
/// <summary>Review m-2: an out-of-range value authored in the driver's own config is clamped AND reported, so
/// the factory can log it rather than the guard changing it silently.</summary>
[Fact]
public void An_out_of_range_driver_key_is_clamped_and_reported()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(1024L, 0, Node, out var adjustments).ShouldNotBeNull();
resolved.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun);
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(1);
adjustments.Count.ShouldBe(2);
}
[Fact]
public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null, out _).ShouldNotBeNull();
resolved.MaxAllocBytesPerRun.ShouldBe(0);
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
}
}
@@ -57,10 +57,11 @@ public class PageAuthorizationGuardTests
// AddSecretsAuthorization(). ──
[typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
// ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.FleetDiagnostics)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead,
@@ -18,6 +18,7 @@ public sealed class CompletionTests
labels.ShouldContain("SetVirtualTag");
labels.ShouldContain("Now");
labels.ShouldContain("Logger");
labels.ShouldContain("CancellationToken");
}
[Fact] public async Task Scope_completion_includes_the_ctx_local()
@@ -48,6 +48,7 @@ public sealed class DiagnoseTests
[InlineData("""var x = 41; System.Threading.Interlocked.Increment(ref x); return x;""")]
[InlineData("""var x = 42; return System.Threading.Volatile.Read(ref x);""")]
[InlineData("""object o = 42; return o.GetType().IsValueType ? 1 : 0;""")]
[InlineData("""ctx.CancellationToken.ThrowIfCancellationRequested(); return ctx.CancellationToken.IsCancellationRequested ? 1 : 0;""")]
public void Allow_listed_forms_are_not_flagged_in_the_editor(string code)
=> Diag(code).ShouldBeEmpty();
@@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// Review m-1: the <c>Scripting</c> section binds by the documented key names and is clamped at bind. Each
/// configured value differs from the compiled default, so a misspelled key (which the binder would silently
/// ignore, falling back to the default) goes red.
/// </summary>
public sealed class ScriptingOptionsBindingTests
{
private static IConfiguration Config(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
[Fact]
public void Every_documented_key_binds_to_its_property()
{
var bound = ScriptingOptionsBinding.Bind(Config(new()
{
["Scripting:MaxAllocBytesPerRun"] = "8388608",
["Scripting:QuarantineAfterConsecutiveTimeouts"] = "7",
["Scripting:StuckOrphanQuarantineFactor"] = "4.5",
// Sibling keys owned by the script-log pipeline must not disturb the bind.
["Scripting:LogFilePath"] = "logs/x-.log",
["Scripting:LogTopicMinLevel"] = "Warning",
}), out var adjustments);
adjustments.ShouldBeEmpty();
bound.MaxAllocBytesPerRun.ShouldBe(8L * 1024 * 1024);
bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
bound.StuckOrphanQuarantineFactor.ShouldBe(4.5);
}
[Fact]
public void Out_of_range_values_are_clamped_at_bind_and_reported()
{
var bound = ScriptingOptionsBinding.Bind(Config(new()
{
["Scripting:StuckOrphanQuarantineFactor"] = "1e300",
["Scripting:MaxAllocBytesPerRun"] = "1024",
["Scripting:QuarantineAfterConsecutiveTimeouts"] = "0",
}), out var adjustments);
bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.MaxStuckOrphanQuarantineFactor);
bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun);
bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(1);
adjustments.Count.ShouldBe(3);
}
[Fact]
public void An_absent_section_yields_the_compiled_defaults()
{
var bound = ScriptingOptionsBinding.Bind(Config(new()), out var adjustments);
adjustments.ShouldBeEmpty();
bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
}
}
@@ -0,0 +1,103 @@
using System.Text.Json;
using Akka.Actor;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Review I-2: under the factory Props a supervision restart of the scripted-alarm host builds a fresh, EMPTY
/// engine. Nothing re-sent <c>ApplyScriptedAlarms</c>, so until the next deploy the host had no mux interest
/// (inputs stopped) and its ownership filter dropped every operator ack/shelve as "not owned". The host now
/// tells its parent <see cref="ScriptedAlarms.ScriptedAlarmHostActor.ScriptedAlarmHostRestarted"/> from <c>PostRestart</c>,
/// and <see cref="DriverHostActor"/> re-sends the last plans it applied. This test never re-applies by hand.
/// </summary>
public sealed class DriverHostActorScriptedAlarmRestartTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-alarmhost-restart-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('d', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
private static DeploymentId SeedAlarmDeployment(Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
ScriptedAlarms = new[]
{
new
{
ScriptedAlarmId = "alm-1", EquipmentId = "eq-1", Name = "HighTemp", AlarmType = "AlarmCondition",
Severity = 800, MessageTemplate = "hot", PredicateScriptId = "s-1",
HistorizeToAveva = false, Retain = true, Enabled = true,
},
},
Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return (int)ctx.GetTag(\"M.T\").Value > 90;" } },
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value, RevisionHash = RevA.Value, Status = DeploymentStatus.Sealed,
CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
[Fact]
public void After_a_supervision_restart_the_alarm_host_is_re_applied_and_inputs_and_acks_work_again()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedAlarmDeployment(db);
var publish = this.CreateCleanOutcomePublishProbe();
var mux = CreateTestProbe();
var coordinator = CreateTestProbe();
var host = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: mux.Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref,
scriptRootLogger: new ScriptRootLogger(new LoggerConfiguration().CreateLogger()),
alarmStateStore: new InMemoryAlarmStateStore()));
V3ArtifactSeeder.Dispatch(host, coordinator, deploymentId, RevA);
var first = mux.FishForMessage<DependencyMuxActor.RegisterInterest>(r => r.TagRefs.Contains("M.T"), Timeout);
var alarmHost = first.Subscriber;
// The crash: a batch item with a null TagId makes the upstream Push throw (the documented unguarded
// path), so supervision restarts the alarm host. NO manual re-apply follows.
alarmHost.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged(null!, 1, DateTime.UtcNow),
}));
mux.FishForMessage<DependencyMuxActor.UnregisterInterest>(u => u.Subscriber.Equals(alarmHost), Timeout);
// The parent re-applies on the restart signal: interest is registered again by the new incarnation.
mux.FishForMessage<DependencyMuxActor.RegisterInterest>(r => r.TagRefs.Contains("M.T"), Timeout);
// Inputs work: a dependency push activates the alarm.
alarmHost.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(
m => m.AlarmNodeId == "alm-1" && m.State.Active && !m.State.Acknowledged, Timeout);
// Acks work: the restarted engine owns alm-1 again, so the command is not filtered out as unowned.
alarmHost.Tell(new AlarmCommand(AlarmId: "alm-1", Operation: "Acknowledge", User: "alice", Comment: null, UnshelveAtUtc: null));
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(
m => m.AlarmNodeId == "alm-1" && m.State.Acknowledged, Timeout)
.State.Acknowledged.ShouldBeTrue();
}
}
@@ -0,0 +1,224 @@
using Akka.Actor;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The dependency mux retains a ref's last value for replay-opting subscribers and evicts a driver's values on
/// <see cref="DependencyMuxActor.SourceRemoved"/>. Before this, nothing in production sent that message, so a
/// driver removed by a deploy kept its last Good in the mux and replayed it to the next replay-opting
/// subscriber as if it were current. The host now sends it wherever a driver OBJECT ends (deploy removal,
/// config-change respawn, operator Restart), never on a supervision restart, and drops value bursts from the
/// child incarnations it has already stopped so a late burst cannot re-retain what was evicted. All through a
/// REAL mux and real driver children.
/// </summary>
public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-sourceremoved-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
private const string Path1 = "Plant/Modbus/dev1/speed";
private const string Path2 = "Plant/Modbus2/dev2/flow";
private static readonly (string, string, string, string, string?)[] NoRefs = [];
private static readonly (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] BothDrivers =
[
("DI-1", "Modbus", "dev1", "speed", "Double", false),
("DI-2", "Modbus2", "dev2", "flow", "Double", false),
];
private IActorRef _host = ActorRefs.Nobody;
private IActorRef _mux = ActorRefs.Nobody;
private TestProbe _coordinator = null!;
private TestProbe _holder = null!;
private RecordingSubscribableDriverFactory _factory = null!;
private IDbContextFactory<OtOpcUaConfigDbContext> _db = null!;
/// <summary>Spawns a host over a real mux with two enabled drivers, waits for both children to subscribe,
/// registers a replay-opting holder (so the mux retains both refs) and publishes one value per driver.</summary>
private void ArrangeTwoDriversWithRetainedValues()
{
_db = NewInMemoryDbFactory();
var revA = V3ArtifactSeeder.SeedV3Deployment(_db, RevA, BothDrivers, NoRefs, enabled: true);
_factory = new RecordingSubscribableDriverFactory("Modbus");
_mux = Sys.ActorOf(DependencyMuxActor.Props());
_coordinator = CreateTestProbe();
_holder = CreateTestProbe();
_host = Sys.ActorOf(DriverHostActor.Props(
_db, TestNode, _coordinator.Ref,
driverFactory: _factory,
localRoles: new HashSet<string> { "driver" },
dependencyMux: _mux,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
V3ArtifactSeeder.Dispatch(_host, _coordinator, revA, RevA);
AwaitAssert(() => _factory.Created.Count.ShouldBe(2), PresenceBudget);
// The spawn hands a child the PREVIOUS apply's set (empty on the first deploy) and the bulk push then
// re-subscribes with the real one; a value fired before that lands is lost, so wait for the real set.
AwaitSubscribed("DI-1", Path1);
AwaitSubscribed("DI-2", Path2);
_mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, _holder.Ref, ReplayRetained: true));
Driver("DI-1").FireDataChange(Path1, 11.0);
Driver("DI-2").FireDataChange(Path2, 22.0);
// One fish for both: the two drivers' bursts arrive in either order, and fishing for one at a time would
// discard the other.
var pending = new HashSet<string>(StringComparer.Ordinal) { Path1, Path2 };
_holder.FishForMessage<VirtualTagActor.DependencyValueBatch>(b =>
{
foreach (var i in b.Items)
if ((i.TagId == Path1 && Equals(i.Value, 11.0)) || (i.TagId == Path2 && Equals(i.Value, 22.0)))
pending.Remove(i.TagId);
return pending.Count == 0;
}, PresenceBudget);
}
/// <summary>Waits until the holder has been delivered <paramref name="value"/> for <paramref name="path"/>.</summary>
private void AwaitHolder(string path, object value) =>
_holder.FishForMessage<VirtualTagActor.DependencyValueBatch>(
b => b.Items.Any(i => i.TagId == path && Equals(i.Value, value)), PresenceBudget);
private void AwaitSubscribed(string driverId, string path) =>
AwaitAssert(() => (Driver(driverId).LastSubscribedRefs ?? []).ShouldContain(path), PresenceBudget);
private RecordingSubscribableDriver Driver(string id) => _factory.Created.Last(d => d.DriverInstanceId == id);
/// <summary>A fresh replay-opting subscriber for both refs; returns what it was replayed, keyed by ref.</summary>
private Dictionary<string, VirtualTagActor.DependencyValueChanged> Replayed()
{
var probe = CreateTestProbe();
_mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, probe.Ref, ReplayRetained: true));
return probe.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget)
.Items.ToDictionary(i => i.TagId, StringComparer.Ordinal);
}
/// <summary>The live child actor for <paramref name="driverId"/> (named <c>drv-{id}-g{generation}</c>).</summary>
private IActorRef ChildOf(string driverId)
{
var probe = CreateTestProbe();
Sys.ActorSelection(_host.Path.ToStringWithoutAddress() + "/drv-" + driverId + "-g*").Tell(new Identify(driverId), probe.Ref);
return probe.ExpectMsg<ActorIdentity>(PresenceBudget).Subject.ShouldNotBeNull();
}
private static void ShouldNotBeTheOldGood(Dictionary<string, VirtualTagActor.DependencyValueChanged> replay, string path, object oldValue)
{
if (!replay.TryGetValue(path, out var item)) return;
(Equals(item.Value, oldValue) && OpcUaStatus.IsGood(item.StatusCode))
.ShouldBeFalse($"the stopped incarnation's last Good for {path} was replayed as current");
}
[Fact]
public void A_driver_removed_by_a_deploy_is_no_longer_replayed_while_a_still_present_driver_is()
{
ArrangeTwoDriversWithRetainedValues();
// Positive control before the removal: both values replay.
Replayed().Keys.ShouldBe(new[] { Path1, Path2 }, ignoreOrder: true);
// Deploy B drops DI-1 (StopChild).
var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true);
V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB);
AwaitAssert(
() => Replayed().Keys.ToArray().ShouldBe(new[] { Path2 }, "DI-1 was removed: its last Good must not replay; DI-2's must"),
PresenceBudget);
}
/// <summary>Review I-1: a burst the removed child sent after the eviction (it stops asynchronously, so it keeps
/// publishing through the rest of the apply) must be dropped by the host, not forwarded to re-retain the value.</summary>
[Fact]
public void A_late_burst_from_a_removed_child_does_not_re_retain_its_value()
{
ArrangeTwoDriversWithRetainedValues();
var oldChild = ChildOf("DI-1");
// Hold the removed child in its PostStop so it has NOT terminated when the stale burst lands; a terminated
// actor cannot send, and the host forgets a retired child on Terminated.
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Driver("DI-1").ShutdownGate = gate;
var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true);
V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB);
// The stale burst, sent AS the stopped child.
_host.Tell(ValuePublishes.Single("DI-1", Path1, 11.0, OpcUaStatus.Good, DateTime.UtcNow), oldChild);
// Positive control / ordering fence: a live driver's value sent after it is delivered, so the stale burst
// has been processed by the host by the time we look.
Driver("DI-2").FireDataChange(Path2, 23.0);
AwaitHolder(Path2, 23.0);
Replayed().Keys.ToArray().ShouldBe(new[] { Path2 }, "the removed child's late burst re-retained its last Good");
gate.TrySetResult();
}
/// <summary>Review m-3: a config edit respawns the driver (stop + spawn); the OLD object's value is evicted, and
/// the NEW driver's value replays (positive control).</summary>
[Fact]
public void A_config_change_respawn_evicts_the_old_value_and_the_new_drivers_value_replays()
{
ArrangeTwoDriversWithRetainedValues();
// Adding a tag to DI-1 changes its merged DriverConfig, which respawns it.
var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB,
[.. BothDrivers, ("DI-1", "Modbus", "dev1", "temp", "Double", false)], NoRefs, enabled: true);
V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB);
AwaitAssert(() => _factory.Created.Count.ShouldBe(3), PresenceBudget);
var respawned = Driver("DI-1");
// The spawn hands the child the PREVIOUS apply's set, then the bulk push re-subscribes with the new one
// (which names the added tag); a value fired between the two can be lost, so wait for the final set.
AwaitAssert(() => (respawned.LastSubscribedRefs ?? []).ShouldContain("Plant/Modbus/dev1/temp"), PresenceBudget);
ShouldNotBeTheOldGood(Replayed(), Path1, 11.0);
respawned.FireDataChange(Path1, 33.0);
AwaitHolder(Path1, 33.0);
var replay = Replayed();
replay[Path1].Value.ShouldBe(33.0, "the respawned driver's own value replays");
replay[Path2].Value.ShouldBe(22.0, "the untouched driver is unaffected");
}
/// <summary>Review m-3: an operator Restart builds a new driver object; the old one's value is evicted and the
/// new one's replays.</summary>
[Fact]
public void An_operator_restart_evicts_the_old_value_and_the_new_drivers_value_replays()
{
ArrangeTwoDriversWithRetainedValues();
_host.Tell(new RestartDriver("c1", "DI-1", "tester", Guid.NewGuid()));
AwaitAssert(() => _factory.Created.Count.ShouldBe(3), PresenceBudget);
var restarted = Driver("DI-1");
AwaitSubscribed("DI-1", Path1);
ShouldNotBeTheOldGood(Replayed(), Path1, 11.0);
restarted.FireDataChange(Path1, 44.0);
AwaitHolder(Path1, 44.0);
Replayed()[Path1].Value.ShouldBe(44.0);
}
[Fact]
public void A_supervision_restart_does_not_evict_the_drivers_retained_value()
{
ArrangeTwoDriversWithRetainedValues();
var di1 = Driver("DI-1");
// Crash DI-1's child: Akka restarts it around the SAME driver object, and the host reprovisions it.
di1.FireMalformedDataChange(Path1);
AwaitAssert(() => di1.InitializeCount.ShouldBe(2), PresenceBudget);
_factory.Created.Count.ShouldBe(2, "a supervision restart re-uses the driver; no new one is created");
var replay = Replayed();
replay.Keys.ShouldBe(new[] { Path1, Path2 }, ignoreOrder: true,
"a restart is the same driver instance: its last value stays replayable");
}
}
@@ -110,7 +110,7 @@ public sealed class DriverInstanceActorBatchForwardTests : RuntimeActorTestBase
batch.Items.Count.ShouldBe(2);
// The per-tag raises for the SAME burst produced nothing extra — no second batch, no
// AttributeValuePublished. Anything here means both handlers were attached.
// single-item batch. Anything here means both handlers were attached.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
@@ -279,11 +279,18 @@ internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAla
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// When set, <see cref="ShutdownAsync"/> awaits it (the actor bounds the wait), which keeps a stopping child
/// ALIVE in its PostStop for as long as the test wants — the window in which a stopped child can still
/// have messages reaching its parent.
/// </summary>
public TaskCompletionSource? ShutdownGate { get; set; }
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _shutdownCount);
return Task.CompletedTask;
return ShutdownGate?.Task ?? Task.CompletedTask;
}
/// <inheritdoc />
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
/// <summary>
/// Builds the ONE message shape a driver child sends for its values — a
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> — for tests that push a single value into the
/// dependency mux by hand. The per-value <see cref="DriverInstanceActor.AttributeValuePublished"/> has no
/// production sender (02/U4); tests should not keep one alive either.
/// dependency mux by hand. There is no per-value message: the former per-value record had no production
/// sender and was deleted (02/U4).
/// </summary>
public static class ValuePublishes
{
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
/// <remarks>
/// Adapted from the plan's Task 17: the recorder handles the REAL fan-out message the mux emits —
/// <see cref="VirtualTagActor.DependencyValueChanged"/> (TagId/Value/TimestampUtc/StatusCode) —
/// not <c>DriverInstanceActor.AttributeValuePublished</c>. The typed-value / real-status behaviour is
/// not the driver-side <c>DriverInstanceActor.AttributeValueBatchPublished</c>. The typed-value / real-status behaviour is
/// pinned in <see cref="ContinuousHistorizationRecorderTypedValueTests"/>.
/// </remarks>
public sealed class ContinuousHistorizationRecorderTests : TestKit
@@ -10,9 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
public sealed class DependencyMuxActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that AttributeValuePublished messages are forwarded only to subscribers interested in the specific reference.</summary>
/// <summary>Verifies that a published value is forwarded only to subscribers interested in the specific reference.</summary>
[Fact]
public void AttributeValuePublished_is_forwarded_only_to_subscribers_interested_in_that_ref()
public void A_published_value_is_forwarded_only_to_subscribers_interested_in_that_ref()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var subA = CreateTestProbe();
@@ -21,9 +21,9 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1", "tag-2" }, subA.Ref));
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-2", "tag-3" }, subB.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-3", 30, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-3", 30, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
// The mux emits ONLY batches — the per-value comms-loss ingress fans out as a single-item batch.
// subA hears tag-1 + tag-2.
@@ -54,7 +54,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "nobody-cares", 99, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "nobody-cares", 99, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -67,11 +67,11 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>();
mux.Tell(new DependencyMuxActor.UnregisterInterest(sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 20, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 20, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -85,10 +85,10 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-2" }, sub.Ref)); // replaces tag-1
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>().Items.ShouldHaveSingleItem().TagId.ShouldBe("tag-2");
}
@@ -110,18 +110,18 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
// gets dropped (no subscriber for "ref-a" yet), so we re-publish each pass.
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
mux.Tell(ValuePublishes.Single(
"driver-1", "ref-a", 10, OpcUaStatus.Good, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromMilliseconds(200))
.Value.ShouldBe(10);
}, duration: PresenceBudget);
// From here the actor is wired — second publish drives the sum.
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-b", 32, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "ref-b", 32, OpcUaStatus.Good, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget).Value.ShouldBe(42);
// Unrelated ref shouldn't fire eval.
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-unrelated", 99, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(ValuePublishes.Single("driver-1", "ref-unrelated", 99, OpcUaStatus.Good, DateTime.UtcNow));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -221,7 +221,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
mux.Tell(ValuePublishes.Single(
"driver-1", "tag-1", 10, OpcUaStatus.Bad, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>()