Merge commit '5135a036' into remed/lane-S7

This commit is contained in:
Joseph Doherty
2026-10-22 15:46:38 -04:00
117 changed files with 9847 additions and 994 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));
}
}
@@ -33,6 +33,27 @@ public sealed class FocasCommandBaseValidationTests
/// <summary>Invokes option validation without interval.</summary>
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
/// <summary>Invokes the address/data-type check.</summary>
public void InvokeValidateDataType(string address, FocasDataType type) => ValidateDataType(address, type);
}
/// <summary>
/// #740 / R07:U8 — <c>--type String</c> on a PMC / PARAM / MACRO address is refused at the CLI boundary
/// (the driver would skip the tag and the read would print BadNodeIdUnknown); a fixed-tree path keeps it.
/// </summary>
[Theory]
[InlineData("R100", FocasDataType.String, false)]
[InlineData("MACRO:500", FocasDataType.String, false)]
[InlineData("PARAM:6711", FocasDataType.String, false)]
[InlineData("R100", FocasDataType.Byte, true)]
[InlineData("FIXED:Identity/SeriesNumber", FocasDataType.String, true)]
public void Validate_data_type_against_address(string address, FocasDataType type, bool ok)
{
var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
if (ok) Should.NotThrow(() => sut.InvokeValidateDataType(address, type));
else Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidateDataType(address, type))
.Message.ShouldContain("String");
}
/// <summary>Verifies that default options are accepted.</summary>
@@ -184,4 +184,24 @@ public sealed class WriteCommandParseValueTests
{
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
}
/// <summary>An array value splits on the separator and parses each element per --type (#739 U9 gate tooling).</summary>
[Fact]
public void ParseArrayValue_parses_each_element()
{
WriteCommand.ParseArrayValue("1, -2,3", TwinCATDataType.Int, 3, ",")
.ShouldBe(new object[] { (short)1, (short)-2, (short)3 });
WriteCommand.ParseArrayValue("a b|c", TwinCATDataType.String, 2, "|")
.ShouldBe(new object[] { "a b", "c" });
}
/// <summary>A count that differs from --array-length is refused before the PLC is touched.</summary>
[Fact]
public void ParseArrayValue_refuses_a_count_mismatch()
{
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseArrayValue("1,2", TwinCATDataType.Int, 3, ","));
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseArrayValue("1", TwinCATDataType.Int, 0, ","));
}
}
@@ -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);
}
}
@@ -72,6 +72,155 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>
/// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes
/// the request's 32-bit <c>Arg1</c> as the number, so a decoy is seeded at the address the old
/// <c>(short)</c> cast produced (99999 → -31073, 40000 → -25536, as the unsigned key the mock sees):
/// reading the decoy's value means the number was wrapped. #500 / #6711 are the positive control that
/// ordinary numbers still read.
/// </summary>
[Fact]
public async Task Macro_and_parameter_numbers_above_short_range_read_the_authored_variable()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
static string Wrapped(int number) => ((uint)(int)(short)number).ToString(System.Globalization.CultureInfo.InvariantCulture);
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
parameters = new Dictionary<string, object>
{
["6711"] = new { type = "long", value = 1234, @decimal = 0 },
["40000"] = new { type = "long", value = 4242, @decimal = 0 },
[Wrapped(40000)] = new { type = "long", value = -1, @decimal = 0 },
},
macros = new Dictionary<string, object>
{
["500"] = new { value = 42000, @decimal = 3 },
["99999"] = new { value = 7, @decimal = 0 },
[Wrapped(99999)] = new { value = 666, @decimal = 0 },
},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
// ThirtyOne_i: the capability matrix admits #99999 (its documented ceiling); the parameter is read on
// an Unknown-series device, which admits any number.
Devices = [new FocasDeviceOptions(DeviceHost, Series: FocasCncSeries.ThirtyOne_i)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("Macro99999", DeviceHost, "MACRO:99999", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-wide-numbers", clientFactory: new WireFocasClientFactory());
var unknownSeries = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Param40000", DeviceHost, "PARAM:40000", FocasDataType.Int32, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-wide-param", clientFactory: new WireFocasClientFactory());
await using (drv)
await using (unknownSeries)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["Macro500", "Macro99999", "Param6711"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToDouble(snaps[0].Value).ShouldBe(42.0, tolerance: 0.001);
snaps[1].Value.ShouldBe(7, "MACRO:99999 must read #99999, not the wrapped #-31073 decoy (666)");
Convert.ToInt32(snaps[2].Value).ShouldBe(1234);
await unknownSeries.InitializeAsync("{}", ct);
var param = await unknownSeries.ReadAsync(["Param40000"], ct);
param[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
Convert.ToInt32(param[0].Value).ShouldBe(4242, "PARAM:40000 must not read the wrapped decoy (-1)");
}
}
/// <summary>
/// #740 / R07:H13 — re-initialising a live driver (the fixed-tree loop running, reads in flight over the real
/// wire) swaps the device table instead of clearing it under its readers: no reader throws, reads keep
/// returning Good, and shutdown leaves no session behind (a fresh driver can still connect afterwards).
/// </summary>
[Fact]
public async Task Reinitialise_under_live_reads_swaps_the_device_table()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
macros = new Dictionary<string, object> { ["500"] = new { value = 42000, @decimal = 3 } },
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(50),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-reinit-swap", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
using var stop = CancellationTokenSource.CreateLinkedTokenSource(ct);
var errors = new ConcurrentQueue<Exception>();
var good = 0;
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
var s = await drv.ReadAsync(["Macro500", $"{DeviceHost}/Identity/SeriesNumber"], CancellationToken.None);
if (s[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
}
catch (Exception ex) { errors.Enqueue(ex); }
}
}, CancellationToken.None);
for (var i = 0; i < 5; i++)
{
await Task.Delay(150, ct);
await drv.InitializeAsync("{}", ct);
}
await Task.Delay(300, ct);
stop.Cancel();
await reader;
errors.ShouldBeEmpty();
good.ShouldBeGreaterThan(0, "positive control: reads went over the wire between re-initialises");
}
// A fresh driver still connects and reads Good after the re-initialise churn and the shutdown.
var after = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-reinit-after", clientFactory: new WireFocasClientFactory());
await using (after)
{
await after.InitializeAsync("{}", ct);
(await after.ReadAsync(["Macro500"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
}
}
/// <summary>Verifies that discover emits device folder and tag variables.</summary>
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
@@ -0,0 +1,218 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout)
/// against the focas-mock fixture.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendHealthAndLayoutTests
{
private readonly FocasSimFixture _fx;
/// <summary>Initializes a new instance of the test class with the FOCAS simulation fixture.</summary>
/// <param name="fx">The FOCAS simulation fixture.</param>
public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx;
/// <summary>
/// A local TCP relay in front of the shared mock, so ONE test can take "the CNC" away and bring it back
/// without stopping a fixture other suites use: <see cref="Down"/> closes every relayed session and stops
/// listening (new connects are refused); <see cref="Up"/> listens again on the same port.
/// </summary>
private sealed class CncRelay : IAsyncDisposable
{
private readonly string _targetHost;
private readonly int _targetPort;
private readonly List<TcpClient> _live = [];
private TcpListener? _listener;
private CancellationTokenSource? _cts;
public CncRelay(string targetHost, int targetPort)
{
_targetHost = targetHost;
_targetPort = targetPort;
Port = Listen(0);
}
public int Port { get; private set; }
private int Listen(int port)
{
_cts = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_listener.Start();
var ct = _cts.Token;
var listener = _listener;
_ = Task.Run(() => AcceptLoopAsync(listener, ct), CancellationToken.None);
return ((IPEndPoint)_listener.LocalEndpoint).Port;
}
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
TcpClient inbound;
try { inbound = await listener.AcceptTcpClientAsync(ct); }
catch { return; }
var outbound = new TcpClient();
try { await outbound.ConnectAsync(_targetHost, _targetPort, ct); }
catch { inbound.Dispose(); outbound.Dispose(); continue; }
lock (_live) { _live.Add(inbound); _live.Add(outbound); }
_ = Pump(inbound, outbound, ct);
_ = Pump(outbound, inbound, ct);
}
}
private static async Task Pump(TcpClient from, TcpClient to, CancellationToken ct)
{
try { await from.GetStream().CopyToAsync(to.GetStream(), ct); }
catch { /* the relay was taken down, or a side closed */ }
finally { from.Dispose(); to.Dispose(); }
}
public void Down()
{
_cts?.Cancel();
_listener?.Stop();
lock (_live)
{
foreach (var c in _live) c.Dispose();
_live.Clear();
}
}
/// <summary>
/// Listens again on the SAME port (the driver's host string names it). The re-bind is retried briefly: the
/// OS can hold the port for a moment after <see cref="Down"/>, or hand it to another process — the latter
/// surfaces as a clear failure after the retries rather than a random flake.
/// </summary>
public void Up()
{
for (var attempt = 1; ; attempt++)
{
try { Listen(Port); return; }
catch (SocketException) when (attempt < 20) { Thread.Sleep(100); }
}
}
public ValueTask DisposeAsync()
{
Down();
return ValueTask.CompletedTask;
}
}
/// <summary>
/// H15 over the wire: a driver serving only <c>FIXED:</c> tags is Healthy with a moving LastSuccessfulRead
/// while its CNC answers; when the CNC disappears it degrades with LastSuccessfulRead frozen (the #527
/// watchdog's conjunction); and it recovers to Healthy when the CNC returns (positive control).
/// </summary>
[Fact]
public async Task Fixed_only_driver_degrades_on_cnc_loss_and_recovers_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await using var relay = new CncRelay(_fx.Host, _fx.Port);
var host = $"focas://127.0.0.1:{relay.Port}";
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Series", host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(1),
FixedTree = new FocasFixedTreeOptions
{
PollInterval = TimeSpan.FromMilliseconds(50),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-fixed-health", clientFactory: new WireFocasClientFactory())
{
FixedTreeStaleBudgetOverride = TimeSpan.FromMilliseconds(500),
};
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() => drv.GetDeviceState(host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(10));
var first = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitFor(() => drv.GetHealth().LastSuccessfulRead > first, TimeSpan.FromSeconds(5));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
relay.Down();
await WaitFor(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(15));
var frozen = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "nothing answered, so LastSuccessfulRead stays frozen");
(await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError,
"the stale fixed-tree value reads Bad on the same budget that degrades the driver");
// Positive control: the CNC returns ⇒ Healthy again, LastSuccessfulRead moves. The connect backoff
// (1 s doubling) bounds how soon the loop re-dials.
relay.Up();
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(40));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
(await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
}
}
/// <summary>
/// H20 over the wire: the mock answers <c>cnc_rdalmmsg2</c> in the 80-byte LEGACY layout. Nineteen active
/// alarms make a 1,520-byte payload — also exactly 20 × 76 — which the old length test decoded as twenty
/// garbage vendor records. Positive control: the unambiguous 3-alarm reply decodes the same alarms.
/// </summary>
[Fact]
public async Task Nineteen_legacy_alarms_decode_as_nineteen_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
var client = new WireFocasClientFactory().Create();
try
{
await client.ConnectAsync(FocasHostAddress.TryParse(_fx.DeviceUri)!, TimeSpan.FromSeconds(2), ct);
await _fx.PatchStateAsync(new { alarms = Alarms(3) }, ct);
var three = await client.ReadAlarmsAsync(ct);
three.Count.ShouldBe(3);
three[2].AlarmNumber.ShouldBe(402);
await _fx.PatchStateAsync(new { alarms = Alarms(19) }, ct);
var nineteen = await client.ReadAlarmsAsync(ct);
nineteen.Count.ShouldBe(19, "a 1,520-byte legacy reply is 19 records, not 20 vendor ones");
nineteen.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(400, 19));
nineteen[18].Message.ShouldBe("SERVO ALARM 418");
nineteen[18].Axis.ShouldBe((short)(1 + (18 % 4)));
}
finally
{
client.Dispose();
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
}
static object[] Alarms(int n) => [.. Enumerable.Range(0, n)
.Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })];
}
private static async Task WaitFor(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(25);
}
}
}
@@ -259,7 +259,7 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
Clients.Add(c);
lock (Clients) Clients.Add(c);
return c;
}
}
@@ -0,0 +1,114 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H20 — <c>ParseAlarms</c> picked the 76-byte vendor layout whenever <c>Length % 76 == 0</c>, so a
/// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The
/// response block carries no record count, so the layout is now chosen from the two things that are known: the
/// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasAlarmLayoutTests
{
private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records)
{
var buf = new byte[records.Length * 80];
for (var i = 0; i < records.Length; i++)
{
var o = i * 80;
var (no, type, axis, msg) = records[i];
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length);
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16));
}
return buf;
}
private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records)
{
var buf = new byte[records.Length * 76];
for (var i = 0; i < records.Length; i++)
{
var o = i * 76;
var (no, type, axis, msg) = records[i];
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length);
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12));
}
return buf;
}
private static (int, int, int, string)[] LegacySet(int n) =>
[.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))];
private static (int, short, short, string)[] VendorSet(int n) =>
[.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))];
/// <summary>The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones.</summary>
[Fact]
public void Nineteen_legacy_records_decode_as_legacy()
{
var payload = Legacy(LegacySet(19));
payload.Length.ShouldBe(1520);
(payload.Length % 76).ShouldBe(0, "the length really is ambiguous");
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(19);
alarms[0].AlarmNumber.ShouldBe(400);
alarms[0].Message.ShouldBe("SERVO ALARM 400");
alarms[18].AlarmNumber.ShouldBe(418);
alarms[18].Axis.ShouldBe((short)(1 + (18 % 4)));
}
/// <summary>Positive control at the same length: 20 vendor records still decode as vendor.</summary>
[Fact]
public void Twenty_vendor_records_decode_as_vendor()
{
var payload = Vendor(VendorSet(20));
payload.Length.ShouldBe(1520);
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(20);
alarms[0].AlarmNumber.ShouldBe(1000);
alarms[19].Message.ShouldBe("OVERTRAVEL +19");
}
/// <summary>
/// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the
/// int32 read of <c>type&lt;&lt;16 | axis</c> is just the axis, and the "length" int32 is the empty message's
/// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode.
/// </summary>
[Fact]
public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor()
{
var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]);
payload.Length.ShouldBe(1520);
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(20);
alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20));
alarms.ShouldAllBe(a => a.Type == 0 && a.Message == "");
}
/// <summary>The unambiguous lengths are unchanged.</summary>
[Fact]
public void Unambiguous_lengths_are_unchanged()
{
FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3);
FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3);
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
}
}
@@ -645,7 +645,8 @@ public sealed class FocasAlarmProjectionTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
// A COMMS-class status: a CNC refusal (e.g. BadOutOfRange) is not a value-path Degraded since #740.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadCommunicationError;
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
@@ -0,0 +1,284 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:P9 — <see cref="FocasDriver.ReadAsync"/> walked the whole batch sequentially, so a SLOW CNC
/// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other
/// device was read. Devices are now read concurrently, each still serialised on its own socket.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasConcurrentDeviceReadTests
{
private const string HostA = "focas://10.0.0.5:8193";
private const string HostB = "focas://10.0.0.6:8193";
/// <summary>
/// A client that records which device it dialled. Device A's read only completes once device B's read has
/// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts
/// the peak number of reads in flight on any one client.
/// </summary>
private sealed class OrderingClient(TaskCompletionSource bStarted, int[] global) : FakeFocasClient
{
private string _host = "";
private int _inFlight;
public int PeakInFlight;
public int Reads;
public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
_host = address.ToString();
return base.ConnectAsync(address, timeout, ct);
}
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
var now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref PeakInFlight, now);
Interlocked.Increment(ref Reads);
// global[0] = reads in flight across EVERY client, global[1] = its peak.
InterlockedMax(ref global[1], Interlocked.Increment(ref global[0]));
try
{
if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult();
else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
await Task.Delay(20, ct).ConfigureAwait(false);
return ((sbyte)1, FocasStatusMapper.Good);
}
finally
{
Interlocked.Decrement(ref _inFlight);
Interlocked.Decrement(ref global[0]);
}
}
private static void InterlockedMax(ref int target, int value)
{
int seen;
while ((seen = Volatile.Read(ref target)) < value
&& Interlocked.CompareExchange(ref target, value, seen) != seen) { }
}
}
/// <summary>A stalled device no longer holds every other device's read hostage — and each device stays serialised.</summary>
[Fact]
public async Task Devices_are_read_concurrently_and_each_stays_serialised()
{
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var global = new int[2];
var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted, global) };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
// Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing.
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
}, "focas-p9", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good,
"device A's read can only finish once device B's has started — a sequential walk times A out");
factory.Clients.Count.ShouldBe(2);
var clients = factory.Clients.Cast<OrderingClient>().ToList();
// Every client really served reads (2 on A, 1 on B), so "peak 1" is measured, not vacuous …
clients.Select(c => c.Reads).OrderBy(n => n).ShouldBe([1, 2]);
clients.ShouldAllBe(c => c.PeakInFlight == 1,
"one device's socket is still request→response: never two reads in flight on it");
// … and the two devices overlapped: two reads were in flight at once across the clients.
global[1].ShouldBe(2, "the devices must be read at the same time, not one after the other");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A device that accepted TCP and then stopped answering: every read hangs until the call timeout.</summary>
private sealed class StalledClient : FakeFocasClient
{
public int Reads;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
Interlocked.Increment(ref Reads);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return (null, FocasStatusMapper.Good);
}
}
/// <summary>
/// #740 review I1 — <c>Task.WhenAll</c> waits for the slowest device, so concurrency alone still made a
/// stalled device cost (its tag count × the call timeout) per batch, and the healthy device's values waited
/// that long. After the first timeout on a device its remaining tags in the batch fail fast: the batch is
/// bounded by about ONE call timeout and the healthy device's values are Good.
/// </summary>
[Fact]
public async Task A_stalled_device_costs_one_call_timeout_per_batch_not_one_per_tag()
{
var callTimeout = TimeSpan.FromMilliseconds(200);
var factory = new FakeFocasClientFactory();
factory.Customise = () => factory.Clients.Count == 0 ? new StalledClient() : new FakeFocasClient { Values = { ["R100"] = (sbyte)9 } };
var stalledTags = Enumerable.Range(0, 10)
.Select(i => FocasRawTags.Entry(new FocasTagDefinition($"raw/A{i}", "A", $"R{100 + i}", FocasDataType.Byte)));
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags = [.. stalledTags, FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
Timeout = callTimeout,
}, "focas-stall", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Connect device A first so it gets the stalled client (connect order = client order).
await drv.ReadAsync(["raw/A0"], CancellationToken.None);
var refs = Enumerable.Range(0, 10).Select(i => $"raw/A{i}").Append("raw/B1").ToArray();
var sw = System.Diagnostics.Stopwatch.StartNew();
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
sw.Stop();
sw.Elapsed.ShouldBeLessThan(callTimeout * 3, "one stalled device must cost ~one call timeout, not ten");
snaps[10].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[10].Value.ShouldBe((sbyte)9);
snaps.Take(10).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
((StalledClient)factory.Clients[0]).Reads.ShouldBe(2, "one read in the warm-up batch, one in the measured batch");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Counts per-tag reads; one address can be made to time out (throw) or answer a status.</summary>
private sealed class CountingClient : FakeFocasClient
{
public int Reads;
public string? TimeoutAt;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
Interlocked.Increment(ref Reads);
if (address.Canonical == TimeoutAt)
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
}
}
private static FocasDriver OneDevice(FakeFocasClientFactory factory, bool coalesce, params string[] addresses) => new(
new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A")],
RawTags = [.. addresses.Select(a => FocasRawTags.Entry(new FocasTagDefinition($"raw/{a}", "A", a, FocasDataType.Byte)))],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = coalesce },
Timeout = TimeSpan.FromMilliseconds(200),
}, "focas-linkdown", factory);
/// <summary>
/// #740 review round 2 N1 — a status the CNC RETURNED (EW_REJECT and every unlisted rc map to
/// BadCommunicationError) proves the device answered, so it must not end the device's batch: the tags after it
/// still read. Before the fix, a deterministic rc on the first tag starved every later tag forever.
/// </summary>
[Fact]
public async Task A_returned_comms_status_does_not_fail_the_rest_of_the_batch()
{
var client = new CountingClient { Values = { ["R101"] = (sbyte)1, ["R102"] = (sbyte)2 } };
client.ReadStatuses["R100"] = FocasStatusMapper.MapFocasReturn(13); // EW_REJECT ⇒ BadCommunicationError
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe((sbyte)2);
client.Reads.ShouldBe(3, "every tag was really read");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control for N1: a TIMEOUT (no reply at all) still fails the rest of the batch without reading it.</summary>
[Fact]
public async Task A_timeout_still_fails_the_rest_of_the_batch_fast()
{
var client = new CountingClient { TimeoutAt = "R100", Values = { ["R101"] = (sbyte)1 } };
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
client.Reads.ShouldBe(1, "only the tag that timed out touched the wire");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review round 2 m5 — the coalescer's link-down paths: a transport fault on the first merged range fails the
/// second range WITHOUT a second range read, and the per-tag tags of the same device without a per-tag read.
/// </summary>
[Fact]
public async Task Coalescer_transport_fault_fails_later_ranges_and_per_tag_tags_without_reading_them()
{
var client = new CountingClient { ThrowOnPmcRange = true };
// Two ranges far apart (gap > MaxGapBytes) + one lone tag that takes the per-tag path.
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: true,
"R100", "R101", "R500", "R501", "D900");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R500", "raw/R501", "raw/D900"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
client.PmcRangeReads.Count.ShouldBe(1, "the second range must not pay another transport attempt");
client.Reads.ShouldBe(0, "the per-tag tag must not pay one either");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review round 2 m5 — a coalescer CONNECT failure fails the per-tag tags of the device without a second connect.</summary>
[Fact]
public async Task Coalescer_connect_failure_fails_per_tag_tags_without_reconnecting()
{
var factory = new FakeFocasClientFactory { Customise = () => new CountingClient { ThrowOnConnect = true } };
var drv = OneDevice(factory, coalesce: true, "R100", "R101", "D900");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/D900"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
factory.Clients.Count.ShouldBe(1, "one connect attempt per device per batch");
factory.Clients.Sum(c => c.ConnectCount).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Results land at their own indices whatever order the devices finish in.</summary>
[Fact]
public async Task Results_keep_their_request_order_across_devices()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-p9-order", factory)
;
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } };
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None);
snaps[0].Value.ShouldBe((sbyte)2);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[2].Value.ShouldBe((sbyte)1);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,200 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm
/// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than
/// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A:
/// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next
/// attempt's shutdown.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDeviceTableLifecycleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new()
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) },
};
/// <summary>A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied.</summary>
[Fact]
public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds()
{
var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.DeviceTable;
before.Count.ShouldBe(1);
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceTable.ShouldNotBeSameAs(before);
drv.DeviceTable.Count.ShouldBe(1);
before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it");
await drv.ShutdownAsync(CancellationToken.None);
before.Count.ShouldBe(1);
drv.DeviceTable.Count.ShouldBe(0);
}
/// <summary>Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed.</summary>
[Fact]
public async Task Readers_survive_repeated_reinitialise()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var good = 0;
var errors = new List<Exception>();
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None);
if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
}
catch (Exception ex) { lock (errors) errors.Add(ex); }
}
});
for (var i = 0; i < 100; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(100);
stop.Cancel();
await reader;
await drv.ShutdownAsync(CancellationToken.None);
errors.ShouldBeEmpty();
good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table");
}
/// <summary>A client whose connect parks until released and ignores cancellation.</summary>
private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
}
}
/// <summary>
/// A data-path read parked in connect when shutdown runs must not leave a live client on the released
/// device: the device is retired, so the connect that completes afterwards disposes what it opened.
/// </summary>
[Fact]
public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) };
var drv = new FocasDriver(Options(), "focas-retire", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var read = drv.ReadAsync(["raw/R"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(50);
release.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5));
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client");
factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
/// <summary>
/// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not
/// resurrect the driver when it finally completes: no device table, no loops, no connection.
/// </summary>
[Fact]
public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing()
{
var factory = new FakeFocasClientFactory();
var options = Options(probe: true);
using var gate = new ManualResetEventSlim(false);
var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ =>
{
inRebind.TrySetResult();
gate.Wait(TimeSpan.FromSeconds(10));
return new FocasDriverBinding(options, factory);
});
var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None));
await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.ShutdownAsync(CancellationToken.None);
gate.Set();
await Should.ThrowAsync<OperationCanceledException>(() => init.WaitAsync(TimeSpan.FromSeconds(10)));
await Task.Delay(200); // several probe intervals — a started loop would have connected by now
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: the same driver, initialised without being superseded, does start its probe.
await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>A loop wedged in a read that ignores cancellation for a while.</summary>
private sealed class SlowProbeClient : FakeFocasClient
{
public override async Task<bool> ProbeAsync(CancellationToken ct)
{
await Task.Delay(400, CancellationToken.None).ConfigureAwait(false);
return true;
}
}
/// <summary>
/// Lane A disposes while its own shutdown may still be running. The dispose must not return before the
/// teardown it overlaps has released the client — it waits for it rather than finding an empty table.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() };
var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
factory.Clients.ShouldAllBe(c => !c.IsConnected);
factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
drv.DeviceCount.ShouldBe(0);
}
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}
@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H15 — the fixed-tree loop swallowed every failure at Debug and never touched driver health, so a
/// driver serving only <c>FIXED:</c> tags (Identity / Axes / Program / Status — the typical FOCAS shape) read
/// Healthy on <c>/hosts</c> with every node Bad, and the #527 comms-loss watchdog could never fire. The loop
/// now credits each answered axis pass (Healthy + LastSuccessfulRead) and, once a device has been silent past
/// the fixed-tree staleness budget, degrades the driver with LastSuccessfulRead frozen.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasFixedTreeHealthTests
{
private const string Host = "focas://10.0.0.5:8193";
private static readonly TimeSpan Stale = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan BlipBudget = TimeSpan.FromMilliseconds(1500);
/// <summary>A CNC that can be switched off (transport failure) or made to refuse the dynamic read.</summary>
private sealed class CncSwitch
{
public volatile bool Down;
public volatile bool Refuse;
/// <summary>Transport failures served so far — what the tests wait on instead of wall-clock delays.</summary>
public int Failures;
/// <summary>Refusals served so far.</summary>
public int Refusals;
}
private sealed class SwitchableCnc(CncSwitch sw) : FakeFocasClient
{
private bool Down => sw.Down;
private bool Refuse => sw.Refuse;
public override Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct)
{
if (!Down) return base.GetSysInfoAsync(ct);
Interlocked.Increment(ref sw.Failures);
throw new IOException("socket closed");
}
public override Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (Down)
{
Interlocked.Increment(ref sw.Failures);
throw new IOException("socket closed");
}
if (Refuse)
{
Interlocked.Increment(ref sw.Refusals);
throw new FocasWireException("cnc_rddynamic2 refused", rc: 6);
}
return base.ReadDynamicAsync(axisIndex, ct);
}
}
private static FocasDriver FixedOnlyDriver(CncSwitch sw, TimeSpan? budget = null) => new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/X", Host, "FIXED:Axes/X/AbsolutePosition", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
PollInterval = TimeSpan.FromMilliseconds(20),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, "focas-fixed-health", new FakeFocasClientFactory { Customise = () => new SwitchableCnc(sw) })
{
FixedTreeStaleBudgetOverride = budget ?? Stale,
};
/// <summary>
/// Positive control first, then the outage: an answering CNC keeps the driver Healthy with a moving
/// LastSuccessfulRead; a CNC that stops answering degrades it once the budget passes, with
/// LastSuccessfulRead frozen (the watchdog's conjunction); and its return restores Healthy.
/// </summary>
[Fact]
public async Task Fixed_only_driver_degrades_when_its_cnc_disappears_and_recovers_when_it_returns()
{
var sw = new CncSwitch();
// A wide budget (review round 2 m4): two 20 ms ticks always land well inside it, so the "blip does not
// degrade" absence check below runs unconditionally instead of being skipped on a slow agent.
await using var drv = FixedOnlyDriver(sw, BlipBudget);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
var first = drv.GetHealth().LastSuccessfulRead!.Value;
// An answered axis pass is a successful read: wait for it to move rather than sleeping.
await WaitUntil(() => drv.GetHealth().LastSuccessfulRead > first);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
sw.Down = true;
var lastAnswer = drv.GetDeviceState(Host)!.LastFixedPollUtc!.Value;
// Two failed ticks have demonstrably happened (positive evidence for the absence check below) …
await WaitUntil(() => Volatile.Read(ref sw.Failures) >= 2);
// … inside the budget (asserted, not assumed), and the driver is still Healthy.
(DateTime.UtcNow - lastAnswer).ShouldBeLessThan(BlipBudget, "the two failed ticks must land inside the budget");
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a blip inside the staleness budget does not degrade");
await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded);
var frozen = drv.GetHealth().LastSuccessfulRead;
var failuresAtDegrade = Volatile.Read(ref sw.Failures);
await WaitUntil(() => Volatile.Read(ref sw.Failures) >= failuresAtDegrade + 5);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "LastSuccessfulRead must stay frozen while the CNC is silent");
sw.Down = false;
await WaitUntil(() => drv.GetHealth().State == DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
}
/// <summary>A CNC that is down from the start (bootstrap never succeeds) degrades once the budget passes.</summary>
[Fact]
public async Task Bootstrap_that_never_succeeds_degrades_the_driver()
{
var sw = new CncSwitch { Down = true };
await using var drv = FixedOnlyDriver(sw);
await drv.InitializeAsync("{}", CancellationToken.None);
// The bootstrap retries every 2 s, so the first failure is what has to trip it.
await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(6));
sw.Down = false;
}
/// <summary>
/// A CNC that ANSWERS with a refusal is not a comms loss: the transport is fine, so the driver is not
/// degraded (else the watchdog would reconnect-loop a production control).
/// </summary>
[Fact]
public async Task Cnc_refusal_of_the_dynamic_read_does_not_degrade()
{
var sw = new CncSwitch();
await using var drv = FixedOnlyDriver(sw);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
sw.Refuse = true;
var started = DateTime.UtcNow;
await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 1);
var atFirstRefusal = drv.GetHealth().LastSuccessfulRead!.Value;
// Refused ticks really happened, for longer than the staleness budget …
await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 5 && DateTime.UtcNow - started > Stale * 2);
// … and the driver stayed Healthy, with each refusal crediting the answer (review minor 2).
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atFirstRefusal,
"a refusal is an answer from the CNC — LastSuccessfulRead advances");
sw.Refuse = false;
}
private static async Task WaitUntil(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}
@@ -62,6 +62,73 @@ public sealed class FocasHandleRecycleTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A client whose reads take a while — long enough for a recycle tick to land mid-read.</summary>
private sealed class SlowReadClient : FakeFocasClient
{
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
await Task.Delay(200, ct).ConfigureAwait(false);
if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read");
return ((sbyte)5, FocasStatusMapper.Good);
}
}
/// <summary>
/// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing
/// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the
/// device's connect gate and lets the in-flight read finish before disposing the client it detached.
/// </summary>
[Fact]
public async Task Recycle_tick_landing_mid_read_lets_the_read_finish()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Each 200 ms read spans at least three recycle ticks.
for (var i = 0; i < 3; i++)
{
var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick");
snap.Value.ShouldBe((sbyte)5);
}
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
// Positive control: the recycle really ran — the first client was disposed and a fresh one opened.
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
factory.Clients.Count.ShouldBeGreaterThan(1);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned");
}
/// <summary>DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket.</summary>
[Fact]
public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones()
{
var inner = new SlowReadClient();
await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None);
var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
var addr = FocasAddress.TryParse("R100")!;
var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None);
await Task.Delay(20);
sync.PendingOperations.ShouldBe(1);
var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5));
(await read).status.ShouldBe(FocasStatusMapper.Good);
await dispose;
inner.DisposeCount.ShouldBe(1);
await Should.ThrowAsync<ObjectDisposedException>(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None));
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -57,4 +57,32 @@ public sealed class FocasPollErrorHealthTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// #740 review round 2 m3 — a poll failure carries the generation its batch STARTED under, so a batch that
/// began before a re-initialise cannot degrade the new generation. Positive control: a failure stamped with
/// the live generation (or unstamped) does degrade.
/// </summary>
[Fact]
public async Task PollReaderFailure_from_a_previous_generation_does_not_degrade_the_new_one()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var oldGeneration = drv.LifecycleGeneration;
await drv.InitializeAsync("{}", CancellationToken.None);
var stale = new InvalidOperationException("old batch boom");
stale.Data[FocasDriver.BatchGenerationKey] = oldGeneration;
drv.HandlePollError(stale);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var live = new InvalidOperationException("live batch boom");
live.Data[FocasDriver.BatchGenerationKey] = drv.LifecycleGeneration;
drv.HandlePollError(live);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
}
@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 driver-lane health checklist (drivers.md, #527/#611): a deterministic CNC refusal — the control
/// answered with a non-OK return code, or with a value the tag's type cannot hold (vacant macro, fractional
/// macro on an integer tag) — is per-tag Bad with ONE Warning per tag and NO health write, and it still
/// proves the device answered (LastSuccessfulRead advances). Only a comms-class status degrades the driver.
/// Before this, every non-Good status latched Degraded every poll, so one vacant macro made a reachable CNC
/// look sick forever.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReadHealthContractTests
{
private const string Host = "focas://10.0.0.5:8193";
private sealed class WarningCounter : ILogger<FocasDriver>
{
private int _warnings;
public int Warnings => Volatile.Read(ref _warnings);
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning && formatter(state, exception).Contains("refused", StringComparison.Ordinal))
Interlocked.Increment(ref _warnings);
}
}
private static (FocasDriver drv, FakeFocasClient client, WarningCounter log) NewDriver(uint r100Status)
{
var client = new FakeFocasClient { Values = { ["R101"] = (sbyte)1 } };
client.ReadStatuses["R100"] = r100Status;
var log = new WarningCounter();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/R101", Host, "R101", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
}, "focas-contract", new FakeFocasClientFactory { Customise = () => client }, log);
return (drv, client, log);
}
/// <summary>A refusal leaves health alone, warns once, and still credits the answer.</summary>
[Theory]
[InlineData(FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
[InlineData(FocasStatusMapper.BadNotSupported)] // EW_FUNC / EW_NOOPT
[InlineData(FocasStatusMapper.BadNoData)] // vacant macro
[InlineData(FocasStatusMapper.BadTypeMismatch)] // fractional macro on an integer tag
[InlineData(FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
public async Task Cnc_refusal_is_per_tag_bad_with_one_warning_and_no_health_write(uint refusal)
{
var (drv, _, log) = NewDriver(refusal);
await drv.InitializeAsync("{}", CancellationToken.None);
var atInit = drv.GetHealth().LastSuccessfulRead;
for (var i = 0; i < 3; i++)
{
await Task.Delay(5);
var snap = (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(refusal);
}
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy, "a CNC refusal is not a comms fault");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value, "the CNC answered — that is evidence of life");
log.Warnings.ShouldBe(1, "one Warning per refused tag, not one per poll");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A refused tag that recovers and is refused again warns again (the dedup clears on Good).</summary>
[Fact]
public async Task Refusal_warning_rearms_after_the_tag_reads_good()
{
var (drv, client, log) = NewDriver(FocasStatusMapper.BadOutOfRange);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
client.ReadStatuses.Remove("R100");
client.Values["R100"] = (sbyte)3;
(await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
log.Warnings.ShouldBe(2);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control: a comms-class status still degrades, preserving LastSuccessfulRead.</summary>
[Fact]
public async Task Comms_status_still_degrades_with_last_successful_read_preserved()
{
var (drv, _, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R101"], CancellationToken.None);
var lastGood = drv.GetHealth().LastSuccessfulRead;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(lastGood);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review minor 2 — a device that recovers from a comms fault and then only REFUSES (every tag it serves is
/// refused) is answering, so it is restored to Healthy instead of staying Degraded forever.
/// </summary>
[Fact]
public async Task Refusal_after_a_comms_fault_restores_healthy()
{
var (drv, client, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the comms fault degraded it");
client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A read whose CNC answer is held until released, then fails at the transport.</summary>
private sealed class HeldReadClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public volatile bool Hold = true;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (!Hold) return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
entered.TrySetResult();
await release.ConfigureAwait(false);
throw new IOException("socket reset");
}
}
/// <summary>
/// Review minor 8 — a read that started against the PREVIOUS table and fails after a re-initialise must not
/// write its Degraded onto the new generation's health. Positive control: the same failure under the live
/// generation does degrade.
/// </summary>
[Fact]
public async Task A_read_from_the_previous_generation_cannot_degrade_the_new_one()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var clients = new List<HeldReadClient>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(10),
}, "focas-gen", new FakeFocasClientFactory
{
Customise = () => { var c = new HeldReadClient(entered, release.Task); lock (clients) clients.Add(c); return c; },
});
await drv.InitializeAsync("{}", CancellationToken.None);
var stale = drv.ReadAsync(["raw/R100"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.InitializeAsync("{}", CancellationToken.None); // a new generation, Healthy
release.SetResult();
(await stale.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the old generation's failure must not land on the new one");
// Positive control: a failure under the live generation degrades.
var live = drv.ReadAsync(["raw/R100"], CancellationToken.None);
(await live.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A write the driver refuses (NotSupportedException) is not a comms fault either.</summary>
[Fact]
public async Task Write_refusal_does_not_degrade()
{
var client = new ThrowingWriteClient();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/W", Host, "R100", FocasDataType.Byte, Writable: true))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-write-refusal", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var result = await drv.WriteAsync([new WriteRequest("raw/W", (sbyte)1)], CancellationToken.None);
result[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class ThrowingWriteClient : FakeFocasClient
{
public override Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
throw new NotSupportedException("bit writes are not supported on this area");
}
}
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:U8 — <see cref="FocasDataType.String"/> was authorable on any address, but no area read
/// produces a string: PMC fell through to a byte read, PARAM to an Int32, MACRO to a double, and a
/// String-typed node received a boxed number at Good. String is now refused on PMC / PARAM / MACRO by the
/// one shared check, everywhere a tag is authored or loaded; the fixed tree (Identity / Program name, …),
/// whose values really are strings, keeps it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasStringDataTypeTests
{
private const string Host = "focas://10.0.0.5:8193";
private static string Config(string address, string dataType) =>
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""";
/// <summary>String on an area read is refused by the mapper.</summary>
[Theory]
[InlineData("R100")]
[InlineData("X0.0")]
[InlineData("PARAM:6711")]
[InlineData("MACRO:500")]
public void Mapper_refuses_string_on_an_area_read(string address)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, "String"), "raw/T", out _).ShouldBeFalse();
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, address).ShouldNotBeNull();
}
/// <summary>Positive control: numeric types on the same addresses, and String on the fixed tree, still map.</summary>
[Theory]
[InlineData("R100", "Byte")]
[InlineData("PARAM:6711", "Int32")]
[InlineData("MACRO:500", "Float64")]
[InlineData("FIXED:Identity/SeriesNumber", "String")]
[InlineData("FIXED:Program/Name", "String")]
public void Mapper_accepts_numeric_area_reads_and_string_fixed_tree_paths(string address, string dataType)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, dataType), "raw/T", out var def).ShouldBeTrue();
def.Address.ShouldBe(address);
}
/// <summary>An ALARM: tag has no value, so its data type is irrelevant and String is not refused.</summary>
[Fact]
public void Mapper_accepts_string_on_an_alarm_tag()
{
FocasTagDefinitionFactory.FromTagConfig(
"""{"address":"ALARM:*","dataType":"String","alarm":{"severity":500}}""", "raw/A", out _).ShouldBeTrue();
}
/// <summary>An unparseable address is left to the address check — it still draws THAT diagnosis.</summary>
[Fact]
public void Unparseable_address_keeps_its_own_diagnosis()
{
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, "not-an-address").ShouldBeNull();
FocasTagDefinitionFactory.FromTagConfig(Config("not-an-address", "String"), "raw/T", out _).ShouldBeTrue();
}
/// <summary>The deploy-time inspector names the reason rather than falling to the generic backstop.</summary>
[Fact]
public void Inspect_names_the_string_refusal()
{
var warnings = FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "String"));
warnings.ShouldContain(w => w.Contains("String", StringComparison.Ordinal) && w.Contains("MACRO:500", StringComparison.Ordinal));
FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "Float64")).ShouldBeEmpty();
}
/// <summary>
/// At init a String area tag is skipped and reported (never served as a Good number); the numeric tag
/// beside it reads normally (positive control).
/// </summary>
[Fact]
public async Task Driver_skips_and_reports_a_string_area_tag()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
new RawTagEntry("raw/Str", Config("R100", "String"), false, Host),
new RawTagEntry("raw/Num", Config("R100", "Byte"), false, Host),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-u8", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/Str", "raw/Num"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[0].Value.ShouldBeNull();
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe((sbyte)7);
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "raw/Str");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H14 — the capability matrix admits macro numbers up to 99999 (30i/31i/32i) and parameters up
/// to 29999 (Unknown: anything), and the FOCAS/2 request block carries 32-bit arguments, but the adapter
/// cast every number to <c>short</c>: <c>MACRO:99999</c> asked the CNC for <c>#-31073</c> and published
/// that variable Good under the authored tag. Numbers now travel as <c>int</c> end to end.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWideNumberTests
{
/// <summary>The macro request carries the full number in both 32-bit argument slots.</summary>
[Theory]
[InlineData(500)]
[InlineData(32767)]
[InlineData(32768)]
[InlineData(99999)]
public void Macro_request_carries_the_unnarrowed_number(int number)
{
var block = FocasWireClient.MacroRequest(number, pathId: 1);
block.Command.ShouldBe((ushort)0x0015);
block.Arg1.ShouldBe(number);
block.Arg2.ShouldBe(number);
}
/// <summary>
/// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real
/// 31i-B answered in the <c>ref-macro-500-0x0015</c> capture, so widening changed nothing below 32768.
/// </summary>
[Fact]
public void Macro_request_for_500_matches_the_live_capture_bytes()
{
var body = FocasWireProtocol.BuildRequestBody([FocasWireClient.MacroRequest(500, pathId: 1)]);
Convert.ToHexString(body).ShouldBe("0001001C0001000100150000 01F4000001F4000000000000000000000000".Replace(" ", ""));
}
/// <summary>A global parameter puts its number in both arguments; an axis parameter puts the axis second.</summary>
[Theory]
[InlineData(6711, (short)0, 6711)]
[InlineData(40000, (short)0, 40000)]
[InlineData(40000, (short)2, 2)]
public void Parameter_request_carries_the_unnarrowed_number(int number, short axis, int expectedArg2)
{
var block = FocasWireClient.ParameterRequest(number, axis, pathId: 1);
block.Command.ShouldBe((ushort)0x000e);
block.Arg1.ShouldBe(number);
block.Arg2.ShouldBe(expectedArg2);
}
/// <summary>The decoded macro reports the number that was asked for, not a wrapped one.</summary>
[Fact]
public void Parsed_macro_keeps_a_number_above_short_range()
{
var payload = new byte[] { 0x05, 0xF5, 0xE1, 0x00, 0x00, 0x0A, 0x00, 0x08 };
var m = FocasWireClient.ParseMacro(99999, payload);
m.Number.ShouldBe(99999);
m.Scaled.ShouldBe(1.0);
}
/// <summary>
/// pmc_rdpmcrng addresses are 16-bit on the wire; a PMC number whose slot runs past 65535 is refused
/// rather than wrapped onto a different address (Unknown series admits any number).
/// </summary>
[Theory]
[InlineData(100, 1, true)]
[InlineData(65535, 1, true)]
[InlineData(65534, 2, true)]
[InlineData(65535, 2, false)]
[InlineData(70000, 1, false)]
public void Pmc_range_outside_16_bits_is_refused_not_wrapped(int number, int width, bool ok)
{
WireFocasClient.TryPmcRange(number, width, out var start, out var end).ShouldBe(ok);
if (ok)
{
((int)start).ShouldBe(number);
((int)end).ShouldBe(number + width - 1);
}
}
}
@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
/// <summary>
/// Wire-level cases for the #739 High/Medium findings, against the live TwinCAT 3 XAR fixture
/// (skipped without it, like <see cref="TwinCAT3SmokeTests"/>). Every case that writes restores what it
/// changed: <c>GVL_Primitives.vWord</c> and <c>vString</c> are seeds the primitives theory reads.
/// </summary>
[Collection("TwinCATXar")]
[Trait("Category", "Integration")]
[Trait("Simulator", "TwinCAT-XAR")]
public sealed class TwinCATRemediationGateTests(TwinCATXarFixture sim)
{
private string Host => $"ads://{sim.TargetNetId}:{sim.AmsPort}";
private TwinCATDriver NewDriver(string id, bool native, params TwinCATTagDefinition[] tags) => new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host, "XAR-VM")],
RawTags = TwinCATRawTags.Entries(tags),
UseNativeNotifications = native,
Timeout = TimeSpan.FromSeconds(5),
Probe = new TwinCATProbeOptions { Enabled = false },
}, id);
/// <summary>H11: a typo'd symbol in a native subscribe is isolated; the real counter keeps delivering.</summary>
[TwinCATFact]
public async Task Native_subscribe_isolates_an_unknown_symbol()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h11", native: true,
new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt),
new TwinCATTagDefinition("Typo", Host, "GVL_Fixture.nCounterTypo", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Counter", "Typo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (events.Count(e => e.FullReference == "Counter") < 2 && DateTime.UtcNow < deadline)
await Task.Delay(50, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
events.Count(e => e.FullReference == "Counter" && e.Snapshot.StatusCode == 0u).ShouldBeGreaterThanOrEqualTo(2);
events.First(e => e.FullReference == "Typo").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refused symbol is not a comms fault");
}
/// <summary>H12: native pushes alone advance LastSuccessfulRead.</summary>
[TwinCATFact]
public async Task Native_pushes_advance_LastSuccessfulRead()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h12", native: true,
new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
var handle = await drv.SubscribeAsync(["Counter"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await Task.Delay(1500, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(500));
}
/// <summary>
/// H18: a bit write on the 16-bit <c>vWord</c> (0xBEEF) flips exactly that bit at the WORD's own width
/// (the old 32-bit UDInt RMW could not even address a WORD), and a bit beyond it is refused.
/// </summary>
[TwinCATFact]
public async Task Bit_write_on_a_WORD_flips_one_bit_and_refuses_bit_16()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h18", native: false,
new TwinCATTagDefinition("Word", Host, "GVL_Primitives.vWord", TwinCATDataType.UInt, Writable: false),
new TwinCATTagDefinition("Bit4", Host, "GVL_Primitives.vWord.4", TwinCATDataType.Bool),
new TwinCATTagDefinition("Bit16", Host, "GVL_Primitives.vWord.16", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var before = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
var bit4WasSet = (before & 0x10) != 0;
try
{
(await drv.WriteAsync([new WriteRequest("Bit4", !bit4WasSet)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
var after = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
after.ShouldBe((ushort)(before ^ 0x10), "only bit 4 may change");
(await drv.WriteAsync([new WriteRequest("Bit16", true)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
}
finally
{
await drv.WriteAsync([new WriteRequest("Bit4", bit4WasSet)], CancellationToken.None);
}
Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value).ShouldBe(before);
}
/// <summary>U9: a whole-array write round-trips through the codec (CLR double[] converts to REAL elements).</summary>
[TwinCATFact]
public async Task Whole_array_write_round_trips()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-u9-array", native: false,
new TwinCATTagDefinition("Arr", Host, "GVL_Arrays.aReal1D", TwinCATDataType.Real, ArrayLength: 32));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var values = Enumerable.Range(0, 32).Select(i => i * 1.5 + 0.25).ToArray();
(await drv.WriteAsync([new WriteRequest("Arr", values)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
var read = (await drv.ReadAsync(["Arr"], TestContext.Current.CancellationToken))[0];
read.StatusCode.ShouldBe(0u);
((float[])read.Value!).ShouldBe(values.Select(v => (float)v).ToArray());
(await drv.WriteAsync([new WriteRequest("Arr", values[..31])], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
}
/// <summary>U9: a STRING write is NUL-padded at the PLC width (no stale tail) and an over-long one is refused.</summary>
[TwinCATFact]
public async Task String_write_round_trips_and_refuses_overflow()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-u9-string", native: false,
new TwinCATTagDefinition("S", Host, "GVL_Primitives.vString", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var original = (string)(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value!;
try
{
(await drv.WriteAsync([new WriteRequest("S", "Grüße 40°")], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe("Grüße 40°");
(await drv.WriteAsync([new WriteRequest("S", new string('x', 81))], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange, "vString is STRING(80)");
}
finally
{
await drv.WriteAsync([new WriteRequest("S", original)], CancellationToken.None);
}
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(original);
}
}
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -56,10 +57,22 @@ internal class FakeTwinCATClient : ITwinCATClient
{
ConnectCount++;
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
if (BlockConnect is { } block)
{
// Models the real client's blocking SDK Connect: ignores the token until released.
return block.Task.ContinueWith(_ => { IsConnected = true; }, TaskScheduler.Default);
}
IsConnected = true;
return Task.CompletedTask;
}
/// <summary>When set, <see cref="ConnectAsync"/> does not complete (and ignores cancellation) until this
/// is completed — a probe wedged in the synchronous ADS <c>Connect</c>.</summary>
public TaskCompletionSource? BlockConnect { get; set; }
/// <summary>Gets or sets the simulated notification-queue drop count.</summary>
public long DroppedNotificationCount { get; set; }
/// <summary>Simulates reading a value from a symbol.</summary>
/// <param name="symbolPath">The path to the symbol to read.</param>
/// <param name="type">The data type of the symbol.</param>
@@ -70,16 +83,31 @@ internal class FakeTwinCATClient : ITwinCATClient
public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
if (BlockRead is { } block)
{
// A read wedged in the (uncancellable) wire call; completes with the normal answer once released.
return block.Task.ContinueWith(_ =>
{
BlockRead = null;
return ReadValueAsync(symbolPath, type, bitIndex, arrayCount, CancellationToken.None).Result;
}, TaskScheduler.Default);
}
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
SingleReadCount++;
Interlocked.Increment(ref _singleReadCount);
LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status));
}
/// <summary>When set, <see cref="ReadValueAsync"/> does not complete (and ignores cancellation) until this is
/// completed — a poll read wedged in the wire call.</summary>
public TaskCompletionSource? BlockRead { get; set; }
private int _singleReadCount;
/// <summary>Gets the number of individual <see cref="ReadValueAsync"/> calls served.</summary>
public int SingleReadCount { get; private set; }
public int SingleReadCount { get => Volatile.Read(ref _singleReadCount); private set => Volatile.Write(ref _singleReadCount, value); }
/// <summary>Gets the size of each <see cref="ReadValuesAsync"/> batch the driver issued, in order (#645).</summary>
public List<int> SumReadBatches { get; } = new();
/// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a
@@ -130,21 +158,102 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (type is TwinCATDataType.String or TwinCATDataType.WString
&& SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (resolve → CheckRead → codec at the PLC width).
var (planStatus, bytes) = AdsTwinCATClient.PlanStringWrite(type, shape, value, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
WriteLog.Add((symbolPath, type, bitIndex, value));
Values[symbolPath] = value;
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
return Task.FromResult(status);
}
/// <summary>Gets the log of every <see cref="WriteArrayAsync"/> call.</summary>
public List<(string symbol, TwinCATDataType type, IReadOnlyList<object> elements)> ArrayWriteLog { get; } = new();
/// <summary>Simulates an array write: records it, stores the elements and answers from <see cref="WriteStatuses"/>.</summary>
/// <param name="symbolPath">The array symbol path.</param>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The write status.</returns>
public virtual Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (ArrayShapes.TryGetValue(symbolPath, out var array) && SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (CheckArray layout → TwinCATArrayCodec.Encode).
var (planStatus, bytes) = AdsTwinCATClient.PlanArrayWrite(type, elements, shape, array, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
ArrayWriteLog.Add((symbolPath, type, elements));
Values[symbolPath] = elements;
return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good);
}
/// <summary>Resolved PLC symbol shapes; a string / array write to a symbol listed here runs the real client's
/// encode plan and lands its bytes in <see cref="RawWrites"/> (#739 fix round 1).</summary>
public Dictionary<string, TwinCATPlcSymbolShape> SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Resolved PLC array shapes for <see cref="SymbolShapes"/> entries that are arrays.</summary>
public Dictionary<string, TwinCATPlcArrayShape> ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The raw bytes the encode plan produced, by symbol.</summary>
public ConcurrentDictionary<string, byte[]> RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.</summary>
public Dictionary<string, byte[]> ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Optional parent shapes; absent = an unsigned integer word as wide as its <see cref="ParentWords"/> entry.</summary>
public Dictionary<string, TwinCATPlcSymbolShape> ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the log of every <see cref="WriteBitAsync"/> call.</summary>
public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new();
/// <summary>When set, <see cref="WriteBitAsync"/> yields between its read and its write so a missing
/// per-parent lock in the driver loses updates.</summary>
public bool YieldInBitWrite { get; set; }
/// <summary>Simulates the parent-width bit RMW with the driver's own <see cref="TwinCATBitAccess.ApplyBitWrite"/>.</summary>
/// <param name="parentPath">The parent word's symbol path.</param>
/// <param name="bit">The bit index.</param>
/// <param name="value">The bit value.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The write status.</returns>
public virtual async Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
lock (BitWriteLog) BitWriteLog.Add((parentPath, bit, value));
if (ReadStatuses.TryGetValue(parentPath, out var readStatus) && readStatus != TwinCATStatusMapper.Good)
return readStatus;
byte[] current;
lock (ParentWords) current = ParentWords.TryGetValue(parentPath, out var w) ? [.. w] : [];
var shape = ParentShapes.TryGetValue(parentPath, out var sh)
? sh
: new TwinCATPlcSymbolShape(TwinCATPlcKind.Scalar, current.Length == 0 ? 4 : current.Length, false, "UDINT");
if (YieldInBitWrite) await Task.Yield();
var status = TwinCATBitAccess.ApplyBitWrite(shape, current, bit, value, out var updated);
if (status != TwinCATStatusMapper.Good) return status;
lock (ParentWords) ParentWords[parentPath] = updated;
return WriteStatuses.TryGetValue(parentPath, out var s) ? s : TwinCATStatusMapper.Good;
}
/// <summary>Simulates probing the connection status.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the probe result.</returns>
public virtual Task<bool> ProbeAsync(CancellationToken ct)
{
Interlocked.Increment(ref _probeCount);
if (ThrowOnProbe) return Task.FromResult(false);
return Task.FromResult(ProbeResult);
}
private int _probeCount;
/// <summary>Gets how many times <see cref="ProbeAsync"/> ran — a measured window for absence assertions.</summary>
public int ProbeCount => Volatile.Read(ref _probeCount);
/// <summary>Releases unmanaged resources.</summary>
public virtual void Dispose()
{
@@ -160,9 +269,19 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnAddNotification { get; set; }
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, int> _addAttempts = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many times <see cref="AddNotificationAsync"/> was called for <paramref name="symbolPath"/>.</summary>
/// <param name="symbolPath">The symbol path.</param>
/// <returns>The attempt count.</returns>
public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0;
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate BEFORE applying
/// <see cref="AddNotificationErrors"/> — wedges an attempt in flight whose outcome is a failure.</summary>
public TaskCompletionSource? PreAttemptGate { get; set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
/// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).</summary>
public TaskCompletionSource? AddNotificationGate { get; set; }
@@ -181,6 +300,8 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{
_addAttempts.AddOrUpdate(symbolPath, 1, (_, n) => n + 1);
if (PreAttemptGate is { } pre) await pre.Task.ConfigureAwait(false);
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
@@ -0,0 +1,199 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 U9: array writes and STRING writes go through the same codecs the read side decodes with.
/// Arrays were advertised writable but every write threw InvalidCastException (reported BadTypeMismatch);
/// strings bypassed the codec (host-ANSI encoding, no length check against STRING(n)).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATArrayStringWriteTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
// ---- string codec: encode ----
/// <summary>A STRING(n) encodes NUL-terminated and NUL-padded to the PLC width, in Windows-1252.</summary>
[Fact]
public void EncodeString_is_NUL_padded_Windows1252_at_the_PLC_width()
{
TwinCATStringCodec.EncodeString("AB", 4, utf8: false).ShouldBe(new byte[] { 0x41, 0x42, 0, 0 });
TwinCATStringCodec.EncodeString("ä€", 3, utf8: false).ShouldBe(new byte[] { 0xE4, 0x80, 0 });
TwinCATStringCodec.EncodeString("ä", 3, utf8: true).ShouldBe(new byte[] { 0xC3, 0xA4, 0 });
TwinCATStringCodec.EncodeString("", 2, utf8: false).ShouldBe(new byte[] { 0, 0 });
}
/// <summary>The encode is the decode's inverse (a STRING(n) of exactly n characters fits).</summary>
[Fact]
public void EncodeString_round_trips_through_DecodeString()
{
foreach (var utf8 in new[] { false, true })
{
var bytes = TwinCATStringCodec.EncodeString("Grüße 40°", 81, utf8);
TwinCATStringCodec.DecodeString(bytes, utf8).ShouldBe("Grüße 40°");
}
TwinCATStringCodec.EncodeString("ABC", 4, utf8: false).Length.ShouldBe(4);
}
/// <summary>A value longer than STRING(n), or a character the target encoding cannot hold, is out of range.</summary>
[Fact]
public void EncodeString_refuses_a_value_that_does_not_fit()
{
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("ABC", 3, utf8: false));
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("中", 10, utf8: false));
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("ää", 4, utf8: true)); // 4 bytes + NUL
}
/// <summary>A WSTRING(n) encodes UTF-16LE with a 2-byte terminator and round-trips.</summary>
[Fact]
public void EncodeWString_is_UTF16LE_with_a_terminator()
{
TwinCATStringCodec.EncodeWString("Hi", 6).ShouldBe(new byte[] { 0x48, 0, 0x69, 0, 0, 0 });
TwinCATStringCodec.DecodeWString(TwinCATStringCodec.EncodeWString("中文", 22)).ShouldBe("中文");
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeWString("Hi", 4));
}
// ---- array codec: elements + encode ----
/// <summary>
/// A CLR list converts element-wise to the declared type (ScadaBridge writes an <c>int[]</c> to an
/// Int16 node); the element type is converted, not exact-matched.
/// </summary>
[Fact]
public void ToWriteElements_converts_each_element_to_the_declared_type()
{
var elements = TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, -2, 300 }, 3);
elements.ShouldBe(new object[] { (short)1, (short)-2, (short)300 });
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Real, new List<object> { 1, 2.5 }, 2)
.ShouldBe(new object[] { 1f, 2.5f });
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, new[] { "a", "b" }, 2)
.ShouldBe(new object[] { "a", "b" });
}
/// <summary>A length mismatch and an out-of-range element are out of range; a scalar is a type mismatch.</summary>
[Fact]
public void ToWriteElements_refuses_a_wrong_shape()
{
Should.Throw<OverflowException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, 2 }, 3));
Should.Throw<OverflowException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 70_000 }, 1));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, 5, 1));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, "abc", 3));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, null, 1));
}
/// <summary>Encode lays elements out little-endian at the PLC stride — the exact inverse of Decode.</summary>
[Fact]
public void Encode_is_the_inverse_of_Decode()
{
var intLayout = new TwinCATArrayLayout(2, 2);
var intBytes = TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1, (short)-2], intLayout, utf8: false);
intBytes.ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF });
RoundTrip(TwinCATDataType.Bool, [true, false, true], new TwinCATArrayLayout(3, 1));
RoundTrip(TwinCATDataType.DInt, [int.MinValue, 7], new TwinCATArrayLayout(2, 4));
RoundTrip(TwinCATDataType.ULInt, [ulong.MaxValue], new TwinCATArrayLayout(1, 8));
RoundTrip(TwinCATDataType.LReal, [1.5, -2.25], new TwinCATArrayLayout(2, 8));
RoundTrip(TwinCATDataType.String, ["ab", "Grüße"], new TwinCATArrayLayout(2, 11));
RoundTrip(TwinCATDataType.WString, ["中", "x"], new TwinCATArrayLayout(2, 8));
// TIME elements arrive as the encoded uint ms (ConvertForWrite) and decode back to double ms.
var time = TwinCATArrayCodec.Encode(TwinCATDataType.Time,
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Time, new[] { 1500.0, 20.0 }, 2), new TwinCATArrayLayout(2, 4), false);
TwinCATArrayCodec.Decode(TwinCATDataType.Time, time, new TwinCATArrayLayout(2, 4), false)
.ShouldBe(new[] { 1500.0, 20.0 });
}
/// <summary>A string element longer than the element's STRING(n) is out of range.</summary>
[Fact]
public void Encode_refuses_a_string_element_wider_than_its_stride()
{
Should.Throw<OverflowException>(() =>
TwinCATArrayCodec.Encode(TwinCATDataType.String, ["toolong"], new TwinCATArrayLayout(1, 4), utf8: false));
Should.Throw<ArgumentException>(() =>
TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1], new TwinCATArrayLayout(2, 2), utf8: false));
}
private static void RoundTrip(TwinCATDataType type, object[] elements, TwinCATArrayLayout layout)
{
var bytes = TwinCATArrayCodec.Encode(type, elements, layout, utf8: false);
bytes.Length.ShouldBe(layout.ByteLength);
var decoded = TwinCATArrayCodec.Decode(type, bytes, layout, utf8: false);
decoded.Cast<object>().ShouldBe(elements);
}
// ---- driver ----
private static (TwinCATDriver drv, FakeTwinCATClient fake) NewDriver(params TwinCATTagDefinition[] tags)
{
var fake = new FakeTwinCATClient();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake });
return (drv, fake);
}
/// <summary>An array tag's write sends ArrayLength converted elements through WriteArrayAsync.</summary>
[Fact]
public async Task An_array_write_sends_converted_elements()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Buf", new[] { 1, 2, 3 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
var write = fake.ArrayWriteLog.ShouldHaveSingleItem();
write.symbol.ShouldBe("MAIN.Buf");
write.elements.ShouldBe(new object[] { (short)1, (short)2, (short)3 });
fake.WriteLog.ShouldBeEmpty(); // never the scalar path (that threw InvalidCastException)
}
/// <summary>
/// A length mismatch is BadOutOfRange and a scalar value BadTypeMismatch — nothing reaches the wire
/// and health is untouched (a refusal is not a comms fault). Positive control: the good write above.
/// </summary>
[Fact]
public async Task A_wrong_shape_array_write_is_refused_before_the_wire()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, 2 }),
new WriteRequest("Buf", 5),
new WriteRequest("Buf", new[] { 1, 2, 70_000 }),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[
TwinCATStatusMapper.BadOutOfRange,
TwinCATStatusMapper.BadTypeMismatch,
TwinCATStatusMapper.BadOutOfRange,
]);
fake.ArrayWriteLog.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An over-long string surfaced by the client as OverflowException is BadOutOfRange, not a comms fault.</summary>
[Fact]
public async Task An_over_long_string_write_is_BadOutOfRange()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ThrowOnWrite = true;
fake.Exception = new OverflowException("6 bytes do not fit STRING(4)");
var result = await drv.WriteAsync([new WriteRequest("Name", "toolong")], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 / R02:P1 — TwinCAT is an <see cref="IBatchSubscribable"/>. DriverInstanceActor subscribes ONLY the
/// batch event of a batch-capable driver, so every data change — poll cycle AND native push, including the
/// Bad snapshots the native path publishes itself — must reach <c>OnDataChangeBatch</c>; the per-tag event
/// keeps firing for legacy consumers (the CLI).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATBatchSubscribeTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool native) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = native,
}, "drv-1", factory);
/// <summary>The driver advertises the batch capability.</summary>
[Fact]
public void Driver_is_batch_subscribable() =>
NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo<IBatchSubscribable>();
/// <summary>A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice.</summary>
[Fact]
public async Task A_poll_cycle_raises_one_batch_with_every_changed_tag()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 } },
};
var drv = NewDriver(factory, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
var single = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
drv.OnDataChange += (_, e) => single.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => !batches.IsEmpty, TimeSpan.FromSeconds(3));
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
var first = batches.First();
first.SubscriptionHandle.ShouldBe(handle);
first.Items.Select(i => i.FullReference).ShouldBe(["A", "B"], ignoreOrder: true);
single.Count(e => e.FullReference is "A" or "B").ShouldBeGreaterThanOrEqualTo(2);
}
/// <summary>A native push and the native path's own Bad snapshot both reach the batch event.</summary>
[Fact]
public async Task Native_pushes_and_native_bad_snapshots_reach_the_batch_event()
{
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, native: true);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
_ = await drv.SubscribeAsync(["A", "Ghost"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.FireNotification("MAIN.A", 42);
var items = batches.SelectMany(b => b.Items).ToList();
items.Single(i => i.FullReference == "Ghost").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
var a = items.Single(i => i.FullReference == "A");
a.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
a.Snapshot.Value.ShouldBe(42);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -55,4 +55,50 @@ public sealed class TwinCATBitAccessTests
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
}
// ---- #739 H18: the bit WRITE is a read-modify-write at the parent's own width ----
/// <summary>A bit write flips exactly one bit of the parent's raw bytes at the parent's width.</summary>
[Fact]
public void ApplyBitWrite_flips_one_bit_at_the_parent_width()
{
var word = TwinCATPlcSymbolShape.Classify("WORD", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(word, [0x01, 0x00], 9, true, out var set).ShouldBe(TwinCATStatusMapper.Good);
set.ShouldBe(new byte[] { 0x01, 0x02 });
TwinCATBitAccess.ApplyBitWrite(word, [0xFF, 0xFF], 3, false, out var cleared).ShouldBe(TwinCATStatusMapper.Good);
cleared.ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// <summary>
/// A signed parent with its sign bit set is plain bytes — the old UDInt round-trip read it back as
/// a value ≥ 2³¹ and a checked conversion turned ANY bit write into BadOutOfRange.
/// </summary>
[Fact]
public void ApplyBitWrite_on_a_negative_signed_parent_preserves_the_sign_bit()
{
var intWord = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(intWord, [0x00, 0x80], 0, true, out var updated).ShouldBe(TwinCATStatusMapper.Good);
updated.ShouldBe(new byte[] { 0x01, 0x80 });
}
/// <summary>A bit beyond the parent is BadOutOfRange; a non-integer parent BadTypeMismatch; nothing is produced.</summary>
[Fact]
public void ApplyBitWrite_refuses_a_bit_the_parent_does_not_have()
{
var word = TwinCATPlcSymbolShape.Classify("UINT", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(word, [0, 0], 16, true, out var none).ShouldBe(TwinCATStatusMapper.BadOutOfRange);
none.ShouldBeEmpty();
var real = TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4);
TwinCATBitAccess.ApplyBitWrite(real, [0, 0, 0, 0], 1, true, out _).ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
/// <summary>A parent read shorter than the parent's width is never written back (it would zero bytes).</summary>
[Fact]
public void ApplyBitWrite_refuses_a_short_parent_buffer()
{
var dword = TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4);
TwinCATBitAccess.ApplyBitWrite(dword, [0xFF, 0xFF], 1, true, out var none)
.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
none.ShouldBeEmpty();
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>Collects the driver's Warning-level messages (thread-safe) for once-per-X assertions.</summary>
internal sealed class TwinCATCapturingLogger : ILogger<TwinCATDriver>
{
private readonly List<string> _warnings = [];
/// <summary>Gets a snapshot of the formatted Warning messages logged so far.</summary>
public IReadOnlyList<string> Warnings { get { lock (_warnings) return [.. _warnings]; } }
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning) lock (_warnings) _warnings.Add(formatter(state, exception));
}
}
@@ -0,0 +1,163 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// The driver health contract (#739 H12): data proving the device answered — a native notification or
/// a successful probe — advances <c>LastSuccessfulRead</c>; sustained probe/connect failure degrades with
/// it preserved; a refusal (a write the client cannot express, a replay the PLC refuses) is no comms fault.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATHealthContractTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native = true) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
/// <summary>A native push advances <c>LastSuccessfulRead</c> and restores Healthy after a comms-fault write.</summary>
[Fact]
public async Task A_native_notification_advances_LastSuccessfulRead_and_restores_Healthy()
{
var fake = new FakeTwinCATClient();
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead;
initStamp.ShouldNotBeNull();
// A write that fails on the wire degrades (comms fault) with the stamp preserved.
fake.ThrowOnWrite = true;
_ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(initStamp);
// Condition, not a fixed delay: the clock only has to move past the stamp (the Degraded state means
// the notification is not inside the Healthy throttle window).
await WaitForAsync(() => DateTime.UtcNow > initStamp!.Value, TimeSpan.FromSeconds(2));
fake.FireNotification("MAIN.Speed", 7);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Healthy);
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp!.Value);
}
/// <summary>A successful probe advances <c>LastSuccessfulRead</c> in a driver that serves only notifications.</summary>
[Fact]
public async Task A_successful_probe_advances_LastSuccessfulRead()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, FastProbe);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp.AddMilliseconds(250), TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(250));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// An unreachable AMS router (every probe connect throws) degrades the driver after
/// <see cref="TwinCATDriver.ProbeFailuresBeforeDegraded"/> consecutive failures, preserving the stamp —
/// before, the probe caught it and health stayed Healthy forever.
/// </summary>
[Fact]
public async Task Consecutive_probe_failures_degrade_preserving_LastSuccessfulRead()
{
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = true } };
var drv = NewDriver(factory, FastProbe);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(initStamp);
factory.Clients.Count.ShouldBeGreaterThanOrEqualTo(TwinCATDriver.ProbeFailuresBeforeDegraded);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// A write the client refuses with <see cref="NotSupportedException"/> is <c>BadNotSupported</c> with no
/// health write (#611: a refusal is not a comms fault). Positive control: a wire failure does degrade.
/// </summary>
[Fact]
public async Task A_NotSupported_write_leaves_health_alone()
{
var fake = new FakeTwinCATClient { ThrowOnWrite = true, Exception = new NotSupportedException("no") };
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
fake.Exception = new InvalidOperationException("wire");
result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// A reconnect replay the PLC REFUSES (an isolated type-mismatch tag re-attempted on every reconnect)
/// republishes the refusal's status and does not degrade. Positive control: a replay that fails on the
/// wire degrades (covered for the same flow in <c>TwinCATReconnectReplayTests</c>).
/// </summary>
[Fact]
public async Task A_refused_replay_republishes_the_refusal_status_without_degrading()
{
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
if (++build >= 2)
c.AddNotificationErrors["MAIN.Speed"] = new TwinCATTypeMismatchException("MAIN.Speed", "now a STRING");
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].SimulateWireDrop();
// A write reconnects (and replays) without itself touching health on success.
_ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 L8 + the Lane A runtime contract (final stop = <c>ShutdownAsync</c> then <c>DisposeAsync</c>,
/// bounded 5 s + 250 ms): a probe wedged in the blocking ADS connect cannot hang teardown; dispose may run
/// while a shutdown is still running; an abandoned initialise overlapping the next shutdown never installs
/// its generation; a value queued before unsubscribe is not delivered after it; re-init swaps its tables
/// and retracts the previous generation's notifications; queue drops are reported.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATLifecycleTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe = true, bool native = true,
TwinCATCapturingLogger? log = null, params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags.Length > 0
? tags
: [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = probe
? new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30), Timeout = TimeSpan.FromMilliseconds(100) }
: new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = native,
}, "drv-1", factory, log);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
/// <summary>
/// A probe wedged in the (uncancellable) connect no longer hangs ShutdownAsync — the join is bounded —
/// and the client the wedged connect eventually produces is disposed, not installed on a dead device.
/// </summary>
[Fact]
public async Task Shutdown_is_bounded_when_a_probe_is_wedged_in_connect()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
factory.Clients.Count.ShouldBe(1); // the probe is inside the wedged connect
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
wedge.SetResult();
await WaitForAsync(() => factory.Clients[0].DisposeCount == 1, TimeSpan.FromSeconds(2));
factory.Clients[0].DisposeCount.ShouldBe(1);
drv.DeviceCount.ShouldBe(0);
}
/// <summary>
/// DisposeAsync while a ShutdownAsync is still running (joining a wedged probe) returns promptly and
/// both complete without throwing; the device is released exactly once.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_a_running_shutdown_completes_promptly()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var sw = Stopwatch.StartNew();
await drv.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
// Ordering is the real assertion: dispose finished while the first shutdown was still joining the
// wedged probe. The elapsed bound is generous (the runtime allows 250 ms; a loaded CI box gets 1 s).
shutdown.IsCompleted.ShouldBeFalse("the positive control: the first shutdown really was still joining the probe");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
wedge.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
drv.Dispose(); // and a late sync Dispose is a no-op
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// An InitializeAsync overtaken by a ShutdownAsync before it commits must not install its devices or
/// start probe loops when it resumes. Positive control: a fresh initialise afterwards installs normally.
/// </summary>
[Fact]
public async Task An_initialise_overtaken_by_shutdown_before_commit_installs_nothing()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory);
var paused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.BeforeCommitForTest = async () => { paused.TrySetResult(); await resume.Task; };
var abandoned = drv.InitializeAsync("{}", CancellationToken.None);
await paused.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
resume.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => abandoned.WaitAsync(TimeSpan.FromSeconds(5)));
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// Positive control + measured window: a fresh generation's probe creates exactly one client and
// ticks several times; an overtaken-generation probe loop (same interval, started earlier) would have
// created a second client within that window.
drv.BeforeCommitForTest = null;
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.DeviceCount.ShouldBe(1);
await WaitForAsync(() => factory.Clients.Count >= 1 && factory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
factory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
factory.Clients.Count.ShouldBe(1, "no probe loop of the overtaken generation may be running");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// The Lane A shape: an InitializeAsync the runtime abandoned while it is still releasing the previous
/// generation (joining a probe wedged in connect) overlaps the next ShutdownAsync. Shutdown stays
/// bounded and, once everything settles, no device, probe loop or live client survives.
/// </summary>
[Fact]
public async Task An_abandoned_initialise_overlapping_shutdown_leaves_nothing_running()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient { BlockConnect = first ? wedge : null };
first = false;
return c;
},
};
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // joins the wedged generation-1 probe
await WaitForAsync(() => drv.DeviceCount == 1 && factory.Clients.Count >= 2, TimeSpan.FromSeconds(3)); // gen 2 committed + probing
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
wedge.SetResult();
try { await abandoned.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); }
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested) { }
drv.DeviceCount.ShouldBe(0);
await WaitForAsync(() => factory.Clients.All(c => c.DisposeCount > 0 || !c.IsConnected), TimeSpan.FromSeconds(2));
var settled = factory.Clients.Count;
// Measured window: a sibling driver on the same probe interval ticks 4 times; any surviving probe
// loop of the torn-down driver would have built a new client in that time.
var witnessFactory = new FakeTwinCATClientFactory();
var witness = NewDriver(witnessFactory);
await witness.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => witnessFactory.Clients.Count >= 1 && witnessFactory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
witnessFactory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
await witness.ShutdownAsync(TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(settled, "no probe loop may still be creating clients");
factory.Clients.ShouldAllBe(c => c.DisposeCount > 0 || !c.IsConnected);
}
/// <summary>
/// A value the client queued before the unsubscribe is dropped if dispatched after it.
/// Positive control: the same callback delivers while subscribed.
/// </summary>
[Fact]
public async Task A_notification_dispatched_after_unsubscribe_is_dropped()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var callback = factory.Clients[0].Notifications.Single().OnChange; // what the client's queue holds
callback("MAIN.Speed", 1);
events.Count.ShouldBe(1);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
callback("MAIN.Speed", 2);
events.Count.ShouldBe(1);
}
/// <summary>
/// A direct re-initialise with a changed config swaps the tag table (the removed tag is unknown, the
/// added one resolves) and retracts the previous generation's live notifications.
/// </summary>
[Fact]
public async Task Reinitialise_swaps_tables_and_retracts_the_previous_generations_notifications()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.Count.ShouldBe(1);
var next = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
rawTags = new[]
{
new
{
rawPath = "Torque",
tagConfig = TwinCATTagDefinitionFactory.ToTagConfig(
new TwinCATTagDefinition("Torque", Host, "MAIN.Torque", TwinCATDataType.DInt)),
writeIdempotent = false,
deviceName = Host,
},
},
probe = new { enabled = false },
});
await drv.InitializeAsync(next, TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.ShouldBeEmpty();
factory.Clients[0].DisposeCount.ShouldBe(1);
(await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
(await drv.ReadAsync(["Torque"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>Reads racing a stream of re-initialises never throw (no table is ever mid-Clear).</summary>
[Fact]
public async Task Reads_racing_reinitialise_never_throw()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Run for at least 300 ms of overlap AND until the reader has seen a Good read mid-churn (positive
// control), bounded at 10 s — a condition, not a fixed window, so a loaded machine cannot starve it.
var good = 0;
var reads = 0;
var inits = 0;
var minUntil = DateTime.UtcNow.AddMilliseconds(300);
var deadline = DateTime.UtcNow.AddSeconds(10);
bool Done() => (DateTime.UtcNow > minUntil && Volatile.Read(ref good) > 0 && Volatile.Read(ref inits) > 10)
|| DateTime.UtcNow > deadline;
var reader = Task.Run(async () =>
{
while (!Done())
{
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
Interlocked.Increment(ref reads);
if (r[0].StatusCode == TwinCATStatusMapper.Good) Interlocked.Increment(ref good);
}
}, TestContext.Current.CancellationToken);
while (!Done())
{
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
Interlocked.Increment(ref inits);
await Task.Yield(); // let the reader in between generations
}
await reader; // throws if any read threw
good.ShouldBeGreaterThan(0);
inits.ShouldBeGreaterThan(10);
}
/// <summary>Notification-queue drops are reported once per probe tick that sees new drops.</summary>
[Fact]
public async Task Notification_queue_drops_are_reported()
{
var fake = new FakeTwinCATClient();
var log = new TwinCATCapturingLogger();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, log: log);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.ProbeCount >= 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // 3 ticks, no drops, no warning
fake.DroppedNotificationCount = 5;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 5")), TimeSpan.FromSeconds(2));
var ticks = fake.ProbeCount;
await WaitForAsync(() => fake.ProbeCount >= ticks + 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1); // 3 more ticks, no new drops, no repeat
fake.DroppedNotificationCount = 8;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 3")), TimeSpan.FromSeconds(2));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
}
@@ -0,0 +1,225 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Native-subscribe isolation (#739): an unresolvable reference publishes <c>BadNodeIdUnknown</c>
/// (U12) instead of keeping whatever status the server seeded, and one failing registration does not
/// take down the rest of the subscription (H11).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATNativeIsolationTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
/// <summary>
/// U12: an unknown reference and a tag routed to an unconfigured device each publish one
/// <c>BadNodeIdUnknown</c> snapshot; the resolvable tag still registers and delivers Good (positive control).
/// </summary>
[Fact]
public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt),
new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
var ghost = events.Single(e => e.FullReference == "Ghost");
ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
ghost.Snapshot.Value.ShouldBeNull();
events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
// Positive control: the resolvable tag registered and its push arrives Good.
factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed");
factory.Clients[0].FireNotification("MAIN.Speed", 42);
var good = events.Single(e => e.FullReference == "Speed");
good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
good.Snapshot.Value.ShouldBe(42);
}
/// <summary>
/// H11: one tag whose AddDeviceNotification round-trip fails (not a refusal) goes Bad on its own and
/// the subscribe call SUCCEEDS — before, the exception disposed every registration and rethrew, so one
/// bad tag served zero data for the whole driver. The good tags keep delivering (positive control).
/// </summary>
[Fact]
public async Task One_failing_registration_does_not_tear_down_the_subscription()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("AddDeviceNotificationEx blew up");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.C"], ignoreOrder: true);
events.ShouldHaveSingleItem().FullReference.ShouldBe("B");
events.Single().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
fake.FireNotification("MAIN.C", 3);
events.Last().FullReference.ShouldBe("C");
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
// The failed tag is kept as a dead intent and unsubscribe still removes everything.
drv.GetDeviceState(Host)!.NativeRegistrations.Count.ShouldBe(3);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
drv.GetDeviceState(Host)!.NativeRegistrations.ShouldBeEmpty();
fake.Notifications.ShouldBeEmpty();
}
/// <summary>
/// H11: a refusal (symbol not found — the device answered) isolates the tag with the refusal's own
/// status and leaves driver health alone; the good tag delivers.
/// </summary>
[Fact]
public async Task A_refused_symbol_is_isolated_without_a_health_write()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.Typo"] = new TwinCATNotificationRefusedException(
"MAIN.Typo", "resolving the symbol failed", TwinCATStatusMapper.BadNodeIdUnknown);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
new TwinCATTagDefinition("Good", Host, "MAIN.Good", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Typo", "Good"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
fake.FireNotification("MAIN.Good", 1);
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>
/// H11: in a two-device driver an unreachable device only blacks out ITS tags (Bad + dead intents,
/// health Degraded); the reachable device's tags register and deliver.
/// </summary>
[Fact]
public async Task An_unreachable_device_blacks_out_only_its_own_tags()
{
const string hostB = "ads://5.23.91.24.1.1:851";
var build = 0;
var factory = new FakeTwinCATClientFactory
{
// Device A's client is created first and works; device B's connect throws.
Customise = () => new FakeTwinCATClient { ThrowOnConnect = ++build == 2 },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host), new TwinCATDeviceOptions(hostB)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A1", Host, "MAIN.A1", TwinCATDataType.DInt),
new TwinCATTagDefinition("B1", hostB, "MAIN.B1", TwinCATDataType.DInt),
new TwinCATTagDefinition("B2", hostB, "MAIN.B2", TwinCATDataType.DInt),
new TwinCATTagDefinition("A2", Host, "MAIN.A2", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["A1", "B1", "B2", "A2"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2); // B2 did not attempt a second connect
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A1", "MAIN.A2"], ignoreOrder: true);
events.Select(e => e.FullReference).ShouldBe(["B1", "B2"], ignoreOrder: true);
events.ShouldAllBe(e => e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError);
drv.GetDeviceState(hostB)!.NativeRegistrations.Count.ShouldBe(2);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Clients[0].FireNotification("MAIN.A2", 22);
events.Last().FullReference.ShouldBe("A2");
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>
/// H11: the dead intent left by a failed registration is re-registered by the probe-tick retry, and
/// its first push then arrives Good — the subscription heals without a re-subscribe.
/// </summary>
[Fact]
public async Task A_failed_registration_is_recovered_by_the_probe_tick_retry()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("transient");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(40),
Timeout = TimeSpan.FromMilliseconds(100),
},
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.AddNotificationErrors.Clear();
await WaitForAsync(() => fake.Notifications.Count == 1, TimeSpan.FromSeconds(3));
fake.Notifications.Count.ShouldBe(1);
fake.FireNotification("MAIN.B", 5);
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>A comms-class registration status stays an exception; any other status is a per-tag refusal.</summary>
[Fact]
public void Registration_failures_are_classified_by_status()
{
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadNodeIdUnknown)
.ShouldBeOfType<TwinCATNotificationRefusedException>().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadOutOfService)
.ShouldBeOfType<TwinCATNotificationRefusedException>();
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadCommunicationError)
.ShouldBeOfType<InvalidOperationException>();
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadTimeout)
.ShouldBeOfType<InvalidOperationException>();
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -98,13 +98,14 @@ public sealed class TwinCATNativeNotificationTests
events.Count.ShouldBe(afterUnsub);
}
/// <summary>Verifies that native subscribe failure mid-registration cleans up partial state.</summary>
/// <summary>
/// A registration failing mid-subscribe isolates that one tag (#739 H11): the first registration
/// stays live, the second is kept as a dead intent and goes Bad, and the call does not throw.
/// </summary>
[Fact]
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
public async Task Native_subscribe_failure_mid_registration_isolates_the_failed_tag()
{
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
// Subscribe's catch block must tear the first one down before rethrowing so no zombie
// notification lingers.
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
@@ -120,12 +121,19 @@ public sealed class TwinCATNativeNotificationTests
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(() =>
drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None));
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
// First registration succeeded then got torn down by the catch; second threw.
fake.AddCallCount.ShouldBe(2);
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
fake.Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.A");
var bad = events.ShouldHaveSingleItem();
bad.FullReference.ShouldBe("B");
bad.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
fake.Notifications.ShouldBeEmpty();
}
/// <summary>
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 P10: a bad tag warns ONCE (not once per poll — 50 typo'd symbols on a 500 ms poll used to be 100
/// Warnings/s forever), re-arms when it reads Good, and a device refusal leaves driver health alone while a
/// comms-class status degrades.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReadWarningTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static (TwinCATDriver drv, FakeTwinCATClient fake, TwinCATCapturingLogger log) NewDriver()
{
var fake = new FakeTwinCATClient();
var log = new TwinCATCapturingLogger();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
new TwinCATTagDefinition("Other", Host, "MAIN.Other", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake }, log);
return (drv, fake, log);
}
private static int WarningsFor(TwinCATCapturingLogger log, string reference) =>
log.Warnings.Count(w => w.Contains($"'{reference}'", StringComparison.Ordinal));
/// <summary>Repeated bad reads of one tag log one Warning; a second bad tag gets its own (positive control).</summary>
[Fact]
public async Task A_bad_tag_warns_once_across_polls_and_rearms_after_Good()
{
var (drv, fake, log) = NewDriver();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
for (var i = 0; i < 5; i++)
(await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken))
.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
WarningsFor(log, "Typo").ShouldBe(1);
fake.ReadStatuses["MAIN.Other"] = TwinCATStatusMapper.BadNodeIdUnknown;
_ = await drv.ReadAsync(["Other"], TestContext.Current.CancellationToken);
WarningsFor(log, "Other").ShouldBe(1);
// Fixed, then broken again: a new episode warns again.
fake.ReadStatuses.Remove("MAIN.Typo");
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
WarningsFor(log, "Typo").ShouldBe(2);
}
/// <summary>A re-initialise re-arms the warning.</summary>
[Fact]
public async Task A_reinitialise_rearms_the_warning()
{
var (drv, fake, log) = NewDriver();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
WarningsFor(log, "Typo").ShouldBe(2);
}
/// <summary>
/// A status proving the device answered (symbol not found) keeps the driver Healthy and advances the
/// stamp; a comms-class status (timeout) degrades with the stamp preserved.
/// </summary>
[Fact]
public async Task A_refusal_status_does_not_degrade_but_a_comms_status_does()
{
var (drv, fake, _) = NewDriver();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow <= initStamp && DateTime.UtcNow < deadline) await Task.Delay(5, TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
var afterRefusal = drv.GetHealth();
afterRefusal.State.ShouldBe(DriverState.Healthy);
afterRefusal.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
fake.ReadStatuses["MAIN.Other"] = TwinCATStatusMapper.BadTimeout;
_ = await drv.ReadAsync(["Other"], TestContext.Current.CancellationToken);
var afterTimeout = drv.GetHealth();
afterTimeout.State.ShouldBe(DriverState.Degraded);
afterTimeout.LastSuccessfulRead.ShouldBe(afterRefusal.LastSuccessfulRead);
}
}
@@ -296,23 +296,23 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1);
}
// ---- BOOL-within-word RMW writes ----
// ---- BOOL-within-word RMW writes (#739 H18: at the parent's own width, on raw bytes) ----
/// <summary>A BOOL-within-word set reads the parent word, ORs the bit, writes it back as UDInt.</summary>
/// <summary>A BOOL-within-word set read-modify-writes the parent word's raw bytes through WriteBitAsync.</summary>
[Fact]
public async Task Bit_set_RMWs_parent_word_as_UDInt()
public async Task Bit_set_RMWs_the_parent_word_bytes()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0b0001u } };
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0b0001, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0b1001u);
factory.Clients[0].WriteLog.ShouldContain(e =>
e.symbol == "MAIN.Flags" && e.type == TwinCATDataType.UDInt && e.bit == null);
factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0b1001, 0, 0, 0 });
factory.Clients[0].BitWriteLog.ShouldBe([("MAIN.Flags", 3, true)]);
factory.Clients[0].WriteLog.ShouldBeEmpty(); // no 32-bit UDInt round-trip any more
}
/// <summary>A BOOL-within-word clear preserves the other bits in the parent word.</summary>
@@ -321,12 +321,12 @@ public sealed class TwinCATReadWriteTests
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0xFFFFu } };
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0xFF, 0xFF] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0xFFF7u);
factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// <summary>RMW works on a DWORD parent (bit 20 set above the 16-bit boundary).</summary>
@@ -335,12 +335,31 @@ public sealed class TwinCATReadWriteTests
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "GVL.Status.20", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Status"] = 0u } };
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["GVL.Status"] = [0, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
factory.Clients[0].Values["GVL.Status"].ShouldBe(1u << 20);
BitConverter.ToUInt32(factory.Clients[0].ParentWords["GVL.Status"]).ShouldBe(1u << 20);
}
/// <summary>
/// A bit beyond a 16-bit parent is refused BadOutOfRange and nothing is written — before, the hardcoded
/// 32-bit RMW wrote into the neighbouring bytes. No health write: a refusal is not a comms fault.
/// </summary>
[Fact]
public async Task Bit_beyond_a_16_bit_parent_is_refused_without_writing()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "MAIN.Word.20", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Word"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
factory.Clients[0].ParentWords["MAIN.Word"].ShouldBe(new byte[] { 0, 0 });
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A failed parent read short-circuits the RMW and surfaces the read status.</summary>
@@ -351,6 +370,7 @@ public sealed class TwinCATReadWriteTests
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient
{
ParentWords = { ["MAIN.Flags"] = [0xAA, 0] },
ReadStatuses = { ["MAIN.Flags"] = TwinCATStatusMapper.BadNodeIdUnknown },
};
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -358,25 +378,24 @@ public sealed class TwinCATReadWriteTests
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
factory.Clients[0].WriteLog.ShouldBeEmpty();
factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xAA, 0 });
}
/// <summary>A Good parent read that yields a null value must NOT zero the word — it surfaces
/// a Bad status and writes nothing (treating null as 0 would clear every set bit).</summary>
/// <summary>A Good parent read that yields no bytes must NOT zero the word — it surfaces a Bad status
/// and writes nothing (treating it as 0 would clear every set bit).</summary>
[Fact]
public async Task Bit_write_with_null_parent_value_does_not_zero_the_word()
public async Task Bit_write_with_no_parent_bytes_does_not_zero_the_word()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
// Do NOT seed Values["MAIN.Flags"] — the fake returns (null, Good) for the parent read.
// Do NOT seed ParentWords["MAIN.Flags"] — the fake answers the parent read Good with no bytes.
factory.Customise = () => new FakeTwinCATClient();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
// No parent write attempted — the word is left untouched on the device.
factory.Clients[0].WriteLog.ShouldBeEmpty();
factory.Clients[0].ParentWords.ShouldNotContainKey("MAIN.Flags");
}
/// <summary>Concurrent bit writes to the same word compose correctly (per-parent lock).</summary>
@@ -387,12 +406,12 @@ public sealed class TwinCATReadWriteTests
.Select(b => new TwinCATTagDefinition($"Bit{b}", "ads://5.23.91.23.1.1:851", $"MAIN.Flags.{b}", TwinCATDataType.Bool))
.ToArray();
var (drv, factory) = NewDriver(tags);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0u } };
factory.Customise = () => new FakeTwinCATClient { YieldInBitWrite = true, ParentWords = { ["MAIN.Flags"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
Convert.ToUInt32(factory.Clients[0].Values["MAIN.Flags"]).ShouldBe(0xFFu);
factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xFF, 0 });
}
}
@@ -0,0 +1,302 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 review fix round 1: the PLC-not-in-RUN health ruling, refused-registration retry backoff,
/// per-device worst-of health, RMW gates surviving teardown, a cancellable shutdown, the real
/// STRING / array encode path through the client planning helpers, and REAL range checks.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReviewFixRound1Tests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private const string HostB = "ads://5.23.91.24.1.1:851";
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [.. tags.Select(t => t.DeviceHostAddress).Append(Host).Distinct().Select(h => new TwinCATDeviceOptions(h))],
RawTags = TwinCATRawTags.Entries(tags),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
// ---- IMPORTANT: a PLC not in RUN leaves the driver Degraded ----
/// <summary>
/// A probe that answers with ADS state Stop writes Degraded "PLC in Stop" and still advances
/// LastSuccessfulRead (the device answered); back in Run the driver is Healthy (positive control).
/// </summary>
[Fact]
public async Task A_PLC_not_in_Run_is_Degraded_and_Run_restores_Healthy()
{
var fake = new FakeTwinCATClient { AdsStateText = "Stop" };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: false,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var stopped = drv.GetHealth();
stopped.State.ShouldBe(DriverState.Degraded);
stopped.LastError.ShouldNotBeNull().ShouldContain("PLC in Stop");
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp, TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
// A Good read while the PLC is stopped must not paper over it.
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fake.AdsStateText = "Run";
await WaitForAsync(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(3));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 1: refused dead registrations back off; a class change republishes ----
/// <summary>
/// A REFUSED dead registration is not re-attempted every probe tick; a comms-failed one is (positive
/// control: it recovers). A symbol-version-changed signal re-arms the refused one at once.
/// </summary>
[Fact]
public async Task Refused_registrations_back_off_while_comms_failures_retry_each_tick()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "declared DInt, PLC STRING");
fake.AddNotificationErrors["MAIN.C"] = new InvalidOperationException("transient");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T", "C"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.AttemptsFor("MAIN.C") >= 3, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.C").ShouldBeGreaterThanOrEqualTo(3); // comms class: every tick
fake.AttemptsFor("MAIN.T").ShouldBe(1); // refusal: backed off
fake.AddNotificationErrors.TryRemove("MAIN.C", out _);
await WaitForAsync(() => fake.Notifications.Any(n => n.SymbolPath == "MAIN.C"), TimeSpan.FromSeconds(3));
fake.Notifications.ShouldContain(n => n.SymbolPath == "MAIN.C");
fake.FireSymbolVersionChanged();
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.T").ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>When a retry fails with a different failure class the tag's status is republished, once.</summary>
[Fact]
public async Task A_retry_whose_failure_class_changes_republishes_the_status()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.X"] = new InvalidOperationException("router hiccup");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
await WaitForAsync(() => fake.AttemptsFor("MAIN.X") >= 3, TimeSpan.FromSeconds(3));
events.Count.ShouldBe(1, "a repeat of the same failure class publishes nothing");
fake.AddNotificationErrors["MAIN.X"] = new TwinCATNotificationRefusedException(
"MAIN.X", "symbol gone", TwinCATStatusMapper.BadNodeIdUnknown);
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(3));
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 2: per-device worst-of health ----
/// <summary>
/// A comms fault on device B is not cleared by device A answering; B answering clears it
/// (positive control).
/// </summary>
[Fact]
public async Task One_devices_answer_does_not_clear_another_devices_comms_fault()
{
var clients = new ConcurrentDictionary<int, FakeTwinCATClient>();
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
clients[Interlocked.Increment(ref build)] = c;
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", HostB, "MAIN.B", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); // client 1 = device A
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken); // client 2 = device B
clients[2].ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadTimeout;
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "device A answering says nothing about device B");
clients[2].ReadStatuses.Remove("MAIN.B");
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- MINOR 3: RMW gates are kept across generations ----
/// <summary>The per-parent RMW gate survives a teardown (a write still in flight keeps the same gate).</summary>
[Fact]
public async Task Bit_RMW_gates_survive_a_shutdown()
{
var fake = new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0, 0] } };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Bit", Host, "MAIN.Flags.1", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
var gate = drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldNotBeNull();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", false)], TestContext.Current.CancellationToken);
drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldBeSameAs(gate);
}
// ---- MINOR 4: ShutdownAsync honours its token ----
/// <summary>
/// A cancelled ShutdownAsync stops waiting on a probe wedged in connect and throws
/// OperationCanceledException; the teardown itself still happened (tables emptied).
/// </summary>
[Fact]
public async Task Shutdown_honours_its_cancellation_token()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory, FastProbe, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var sw = Stopwatch.StartNew();
await Should.ThrowAsync<OperationCanceledException>(() => drv.ShutdownAsync(cts.Token));
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ProbeJoinTimeout, "the wait ended on the token, not the join bound");
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// The background teardown finishes once the wedge releases, and disposes the client the wedged
// connect produced (no dangling teardown left behind the test).
var teardown = drv.LastTeardownForTest.ShouldNotBeNull();
wedge.SetResult();
await teardown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
}
// ---- MINOR 7: the real STRING / array encode path through the client planning helpers ----
/// <summary>
/// A STRING write through the driver lands as NUL-padded Windows-1252 bytes at the PLC symbol's width;
/// an over-long value is BadOutOfRange and a WSTRING symbol under a String tag BadTypeMismatch.
/// </summary>
[Fact]
public async Task String_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
fake.SymbolShapes["MAIN.Name"] = TwinCATPlcSymbolShape.Classify("STRING(4)", DataTypeCategory.String, 5);
fake.SymbolShapes["MAIN.Wide"] = TwinCATPlcSymbolShape.Classify("WSTRING(4)", DataTypeCategory.String, 10);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String),
new TwinCATTagDefinition("Wide", Host, "MAIN.Wide", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Name", "äb"),
new WriteRequest("Name", "toolong"),
new WriteRequest("Wide", "x"),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[TwinCATStatusMapper.Good, TwinCATStatusMapper.BadOutOfRange, TwinCATStatusMapper.BadTypeMismatch]);
fake.RawWrites["MAIN.Name"].ShouldBe(new byte[] { 0xE4, (byte)'b', 0, 0, 0 });
fake.RawWrites.ShouldNotContainKey("MAIN.Wide");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An array write through the driver lands as the codec's bytes at the PLC element stride.</summary>
[Fact]
public async Task Array_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
fake.SymbolShapes["MAIN.Buf"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..3] OF INT", DataTypeCategory.Array, 8);
fake.ArrayShapes["MAIN.Buf"] = new TwinCATPlcArrayShape([4], 8, element);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 2),
new TwinCATTagDefinition("BufAsReal", Host, "MAIN.Buf", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, -2 }),
new WriteRequest("BufAsReal", new[] { 1.0, 2.0 }),
], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
fake.RawWrites["MAIN.Buf"].ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF }); // leading sub-range only
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
// ---- MINOR 8: REAL / LREAL range ----
/// <summary>A REAL (scalar or array element) beyond float range is BadOutOfRange, never written as Infinity.</summary>
[Fact]
public async Task Out_of_range_REAL_values_are_BadOutOfRange()
{
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 1e300));
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "1e400"));
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 3.5).ShouldBe(3.5f);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, float.PositiveInfinity).ShouldBe(float.PositiveInfinity);
float.IsNaN((float)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, double.NaN)).ShouldBeTrue();
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1.0, 1e300 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
fake.ArrayWriteLog.ShouldBeEmpty();
}
}
@@ -0,0 +1,262 @@
using System.Collections.Concurrent;
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 review fix round 2: a cancelled shutdown's background teardown is confined to its own generation
/// (I1); write outcomes feed per-device health (M1); a symbol-version re-arm survives an in-flight retry
/// and restarts the backoff (M2/M3); WSTRING / multi-dimensional encode plans (M5); infinity strings (M6).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReviewFixRound2Tests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
// ---- I1 ----
/// <summary>
/// The exact reviewed sequence: a poll read is wedged; ShutdownAsync is cancelled (its teardown
/// continues in the background); the same instance is re-initialised and subscribed; the wedge is
/// released and the OLD teardown completes. The NEW subscription keeps polling and delivering (positive
/// control), UnsubscribeAsync finds and stops it, and a final shutdown leaves nothing polling.
/// </summary>
[Fact]
public async Task A_cancelled_shutdowns_background_teardown_never_touches_the_next_generations_subscription()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var counter = 0;
var clients = new ConcurrentQueue<FakeTwinCATClient>();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
// Client 1 (generation 1) wedges its first read; later clients answer an ever-changing value
// so every poll publishes.
var c = new ChangingValueFake(() => Interlocked.Increment(ref counter))
{
BlockRead = clients.IsEmpty ? wedge : null,
};
clients.Enqueue(c);
return c;
},
};
var tag = new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt);
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false, tag);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
await WaitForAsync(() => !clients.IsEmpty, TimeSpan.FromSeconds(2)); // gen-1 poll read is wedged
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)))
await Should.ThrowAsync<OperationCanceledException>(() => drv.ShutdownAsync(cts.Token));
var oldTeardown = drv.LastTeardownForTest.ShouldNotBeNull();
oldTeardown.IsCompleted.ShouldBeFalse("the old teardown must still be running in the background");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var handle = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
wedge.SetResult();
await oldTeardown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
// Positive control: the new subscription delivers AFTER the old teardown finished.
var afterTeardown = events.Count(e => e.SubscriptionHandle.Equals(handle));
await WaitForAsync(() => events.Count(e => e.SubscriptionHandle.Equals(handle)) >= afterTeardown + 3, TimeSpan.FromSeconds(3));
events.Count(e => e.SubscriptionHandle.Equals(handle)).ShouldBeGreaterThanOrEqualTo(afterTeardown + 3);
// UnsubscribeAsync can still find and stop it.
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
var stoppedAt = events.Count(e => e.SubscriptionHandle.Equals(handle));
// Measured window: a sibling driver's poll publishes 5 times; the stopped loop must publish nothing.
var witnessFactory = new FakeTwinCATClientFactory { Customise = () => new ChangingValueFake(() => Environment.TickCount) };
var witnessDrv = NewDriver(witnessFactory, new TwinCATProbeOptions { Enabled = false }, native: false, tag);
await witnessDrv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var witnessEvents = 0;
witnessDrv.OnDataChange += (_, _) => Interlocked.Increment(ref witnessEvents);
_ = await witnessDrv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
await WaitForAsync(() => Volatile.Read(ref witnessEvents) >= 5, TimeSpan.FromSeconds(3));
Volatile.Read(ref witnessEvents).ShouldBeGreaterThanOrEqualTo(5);
await witnessDrv.ShutdownAsync(TestContext.Current.CancellationToken);
events.Count(e => e.SubscriptionHandle.Equals(handle)).ShouldBe(stoppedAt, "the unsubscribed loop must have stopped");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>Answers each read with a new value so every poll cycle publishes.</summary>
private sealed class ChangingValueFake(Func<int> next) : FakeTwinCATClient
{
public override Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
if (BlockRead is not null) return base.ReadValueAsync(symbolPath, type, bitIndex, arrayCount, ct);
Values[symbolPath] = next();
return base.ReadValueAsync(symbolPath, type, bitIndex, arrayCount, ct);
}
}
// ---- M1 ----
/// <summary>
/// A write-only device with the probe off: a wire-failed write degrades; a later Good write is the device
/// answering — Healthy again with the stamp advanced. A refused write (BadNotWritable) changes nothing.
/// </summary>
[Fact]
public async Task A_good_write_clears_that_devices_comms_fault()
{
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("W", Host, "MAIN.W", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
fake.ThrowOnWrite = true;
_ = await drv.WriteAsync([new WriteRequest("W", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fake.ThrowOnWrite = false;
fake.WriteStatuses["MAIN.W"] = TwinCATStatusMapper.BadNotWritable;
_ = await drv.WriteAsync([new WriteRequest("W", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a refusal says nothing about the comms fault");
await WaitForAsync(() => DateTime.UtcNow > initStamp, TimeSpan.FromSeconds(1));
fake.WriteStatuses.Remove("MAIN.W");
_ = await drv.WriteAsync([new WriteRequest("W", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
}
// ---- M2 + M3 ----
/// <summary>
/// A symbol-version re-arm that lands while a retry of the refused registration is in flight is not
/// overwritten by that retry's failure: the next tick re-attempts at once. (Without the epoch the
/// failure re-imposed a 30 s+ backoff.)
/// </summary>
[Fact]
public async Task A_rearm_during_an_in_flight_retry_is_not_lost()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "PLC STRING");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.AttemptsFor("MAIN.T").ShouldBe(1);
var inFlight = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
fake.PreAttemptGate = inFlight;
fake.FireSymbolVersionChanged(); // re-arm: the next tick starts a retry, which wedges on the gate
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
fake.FireSymbolVersionChanged(); // a second program change while that retry is in flight
fake.PreAttemptGate = null;
inFlight.SetResult(); // the in-flight retry now fails (still refused)
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 3, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.T").ShouldBeGreaterThanOrEqualTo(3);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>A re-arm restarts the refusal backoff from the initial delay instead of doubling the old one.</summary>
[Fact]
public async Task A_rearm_restarts_the_refusal_backoff()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "PLC STRING");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var reg = drv.GetDeviceState(Host)!.NativeRegistrations.Values.Single();
reg.RefusalBackoff.ShouldBe(TwinCATDriver.RefusedRetryInitial);
fake.FireSymbolVersionChanged();
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
await WaitForAsync(() => reg.RefusalBackoff != TimeSpan.Zero, TimeSpan.FromSeconds(1));
reg.RefusalBackoff.ShouldBe(TwinCATDriver.RefusedRetryInitial);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- M5 ----
/// <summary>A WSTRING tag over a WSTRING symbol writes UTF-16LE with an aligned 2-byte terminator, zero-padded.</summary>
[Fact]
public async Task A_WSTRING_write_goes_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
fake.SymbolShapes["MAIN.W"] = TwinCATPlcSymbolShape.Classify("WSTRING(3)", DataTypeCategory.String, 8);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("W", Host, "MAIN.W", TwinCATDataType.WString));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("W", "中x"), new WriteRequest("W", "abcd")], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); // 4 chars do not fit WSTRING(3)
fake.RawWrites["MAIN.W"].ShouldBe(new byte[] { 0x2D, 0x4E, (byte)'x', 0, 0, 0, 0, 0 });
}
/// <summary>A multi-dimensional array is written flattened in PLC memory order (last index fastest).</summary>
[Fact]
public async Task A_multi_dimensional_array_write_is_flattened_row_major()
{
var fake = new FakeTwinCATClient();
var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
fake.SymbolShapes["MAIN.M"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..1,0..2] OF INT", DataTypeCategory.Array, 12);
fake.ArrayShapes["MAIN.M"] = new TwinCATPlcArrayShape([2, 3], 12, element);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("M", Host, "MAIN.M", TwinCATDataType.Int, ArrayLength: 6));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("M", new[] { 1, 2, 3, 4, 5, 6 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
fake.RawWrites["MAIN.M"].ShouldBe(new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0 });
TwinCATArrayCodec.Decode(TwinCATDataType.Int, fake.RawWrites["MAIN.M"], new TwinCATArrayLayout(6, 2), false)
.ShouldBe(new short[] { 1, 2, 3, 4, 5, 6 });
}
// ---- M6 ----
/// <summary>An infinity spelled as a string passes like a float/double infinity; a finite overflow does not.</summary>
[Fact]
public void Infinity_strings_are_explicit_infinities()
{
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, "Infinity").ShouldBe(float.PositiveInfinity);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "-Infinity").ShouldBe(double.NegativeInfinity);
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, "1e300"));
}
}
@@ -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();
@@ -169,4 +169,26 @@ public sealed class FocasTagConfigModelTests
[InlineData("""{"address":"R100","dataType":2}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> FocasTagConfigModel.FromJson(json).Validate().ShouldBeNull();
/// <summary>
/// #740 / R07:U8 — String on a PMC / PARAM / MACRO address is refused by the driver (no area read
/// returns text), so the modal refuses it too, for a model built in the editor and for a loaded blob.
/// </summary>
[Theory]
[InlineData("R100")]
[InlineData("PARAM:6711")]
[InlineData("MACRO:500")]
public void Validate_refuses_string_on_an_area_address(string address)
{
new FocasTagConfigModel { Address = address, DataType = FocasDataType.String }
.Validate().ShouldNotBeNull().ShouldContain("String");
FocasTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"String"}""")
.Validate().ShouldNotBeNull().ShouldContain("String");
}
/// <summary>Positive control: the fixed tree's string fields keep String.</summary>
[Fact]
public void Validate_accepts_string_on_a_fixed_tree_path()
=> new FocasTagConfigModel { Address = "FIXED:Identity/SeriesNumber", DataType = FocasDataType.String }
.Validate().ShouldBeNull();
}
@@ -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>()