Merge local master 5135a036 into remed/lane-AbLegacy
Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
+172
@@ -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;
|
||||
}
|
||||
|
||||
+22
-2
@@ -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]
|
||||
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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();
|
||||
|
||||
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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();
|
||||
}
|
||||
}
|
||||
+224
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+14
-14
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user