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