Merge local master 0d49eaf7 into remed/lane-AbLegacy
Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+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, ","));
|
||||
}
|
||||
}
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 Calculation remediation: a compile failure is negative-cached by source hash and its
|
||||
/// Warning/script-log fires on the Good→Bad transition only (P4); inputs are recorded before
|
||||
/// <c>InitializeAsync</c> so a Restart computes from current inputs (H6); array results dedup
|
||||
/// structurally (H8); <c>ShutdownAsync</c> drops the compiled scripts (L2).
|
||||
/// </summary>
|
||||
public sealed class CalculationDriverRemediationTests
|
||||
{
|
||||
private const uint StatusGood = 0u;
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
|
||||
private static RawTagEntry CalcTag(string rawPath, string source, bool changeTriggered = true, int? timerMs = null)
|
||||
{
|
||||
var timer = timerMs is { } ms ? $",\"timerIntervalMs\":{ms}" : "";
|
||||
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":{(changeTriggered ? "true" : "false")}{timer}," +
|
||||
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
|
||||
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
||||
}
|
||||
|
||||
private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs, CapturingLogger HostLog)
|
||||
Build(params RawTagEntry[] tags)
|
||||
{
|
||||
var logs = new List<ScriptLogEntry>();
|
||||
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
|
||||
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
|
||||
var hostLog = new CapturingLogger();
|
||||
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot, hostLog);
|
||||
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, e) => published.Enqueue(e);
|
||||
return (driver, published, logs, hostLog);
|
||||
}
|
||||
|
||||
private static async Task Settle(CalculationDriver driver)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
while (!driver.IsEvaluationIdle && DateTime.UtcNow < deadline) await Task.Delay(10);
|
||||
driver.IsEvaluationIdle.ShouldBeTrue("the calc driver's evaluation pump should have drained");
|
||||
}
|
||||
|
||||
/// <summary>Fix round 1 minor: Dispose releases the evaluation gate and its cancellation source — even
|
||||
/// with a runaway run in flight (the guard lets the caller's cancel win, so the awaited run returns
|
||||
/// promptly) — and a trigger arriving after Dispose is harmless.</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_with_a_run_in_flight_disposes_the_eval_gate_and_a_late_trigger_is_harmless()
|
||||
{
|
||||
var (driver, _, _, _) = Build(
|
||||
CalcTag("calc/e/spin", "var v = ctx.GetTag(\"src/a\").Value; var end = System.DateTime.UtcNow.AddSeconds(6); while (System.DateTime.UtcNow < end) { } return 1;"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
driver.OnDependencyValue("src/a", 1, StatusGood, DateTime.UtcNow);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (driver.IsEvaluationIdle && DateTime.UtcNow < deadline) await Task.Delay(5);
|
||||
driver.IsEvaluationIdle.ShouldBeFalse("positive control: a run is in flight when Dispose lands");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
driver.Dispose();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5), "the caller's cancel releases the awaited run; Dispose is not held for the 6 s spin");
|
||||
driver.EvalResourcesDisposedForTest.ShouldBeTrue();
|
||||
|
||||
Should.NotThrow(() => driver.OnDependencyValue("src/a", 2, StatusGood, DateTime.UtcNow));
|
||||
Should.NotThrow(() => driver.Dispose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_compile_failure_is_remembered_and_logged_once_across_repeated_triggers()
|
||||
{
|
||||
var (driver, published, logs, hostLog) = Build(
|
||||
CalcTag("calc/e/broken", "var x = (double)ctx.GetTag(\"src/a\").Value; this is not valid C#;"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
driver.OnDependencyValue("src/a", i, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
}
|
||||
|
||||
published.Count.ShouldBe(1, "Bad is published once per transition");
|
||||
published.TryDequeue(out var bad).ShouldBeTrue();
|
||||
bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
|
||||
logs.Count.ShouldBe(1, "the script-log entry fires on the Good→Bad transition only");
|
||||
hostLog.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("compile failed")).ShouldBe(1,
|
||||
"Roslyn is consulted once per source; later triggers hit the negative cache");
|
||||
driver.CompileFailureCountForTest.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_changed_failure_reason_is_logged_even_while_already_Bad()
|
||||
{
|
||||
// Throws with a different message depending on the input, so the failure REASON changes while the
|
||||
// published status stays Bad — the operator must see the new reason.
|
||||
var (driver, published, logs, _) = Build(
|
||||
CalcTag("calc/e/thrower", "throw new System.InvalidOperationException(\"boom \" + (int)ctx.GetTag(\"src/a\").Value);"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
driver.OnDependencyValue("src/a", 1, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
driver.OnDependencyValue("src/a", 1, StatusGood, DateTime.UtcNow); // same reason → no new log
|
||||
await Settle(driver);
|
||||
driver.OnDependencyValue("src/a", 2, StatusGood, DateTime.UtcNow); // new reason → logged
|
||||
await Settle(driver);
|
||||
|
||||
published.Count.ShouldBe(1);
|
||||
logs.Count.ShouldBe(2);
|
||||
logs[0].Message.ShouldContain("boom 1");
|
||||
logs[1].Message.ShouldContain("boom 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inputs_delivered_before_InitializeAsync_are_kept_so_the_first_trigger_computes()
|
||||
{
|
||||
// H6: a Restart is a new driver whose adapter registers interest at construction; the mux's
|
||||
// retained-value replay therefore lands BEFORE InitializeAsync. It used to be dropped.
|
||||
var (driver, published, _, _) = Build(
|
||||
CalcTag("calc/e/sum", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"));
|
||||
|
||||
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow); // replayed, pre-init
|
||||
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
||||
published.ShouldBeEmpty("evaluation is gated on running");
|
||||
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
driver.OnDependencyValue("src/a", 12.0, StatusGood, DateTime.UtcNow); // only ONE input moves after init
|
||||
await Settle(driver);
|
||||
|
||||
published.Count.ShouldBe(1, "src/b's pre-init value satisfied the change-gate");
|
||||
published.TryDequeue(out var e).ShouldBeTrue();
|
||||
e!.Snapshot.Value.ShouldBe(32.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_equal_array_result_is_deduped_structurally()
|
||||
{
|
||||
var (driver, published, _, _) = Build(
|
||||
CalcTag("calc/e/arr", "var v = (double)ctx.GetTag(\"src/a\").Value; return new double[] { v, v * 2 };"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
driver.OnDependencyValue("src/a", 1.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
driver.OnDependencyValue("src/a", 1.0, StatusGood, DateTime.UtcNow); // same array contents
|
||||
await Settle(driver);
|
||||
published.Count.ShouldBe(1, "a structurally equal array is not a change");
|
||||
|
||||
driver.OnDependencyValue("src/a", 2.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.Count.ShouldBe(2, "a different array still publishes");
|
||||
published.Last().Snapshot.Value.ShouldBe(new double[] { 2.0, 4.0 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_drops_the_compiled_scripts()
|
||||
{
|
||||
var (driver, published, _, _) = Build(CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
driver.OnDependencyValue("src/a", 1.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.Count.ShouldBe(1);
|
||||
driver.CompiledScriptCountForTest.ShouldBe(1);
|
||||
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
driver.CompiledScriptCountForTest.ShouldBe(0, "the collectible ALCs must not survive the generation");
|
||||
}
|
||||
|
||||
private sealed class CapturingPublisher(List<ScriptLogEntry> entries) : IScriptLogPublisher
|
||||
{
|
||||
public void Publish(ScriptLogEntry entry) { lock (entries) entries.Add(entry); }
|
||||
}
|
||||
|
||||
internal sealed class CapturingLogger : ILogger<CalculationDriver>
|
||||
{
|
||||
public ConcurrentBag<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
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)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -229,11 +229,13 @@ public sealed class CalculationDriverTests
|
||||
logs[0].ScriptId.ShouldBe("s-calc/e/div");
|
||||
logs[0].VirtualTagId.ShouldBe("calc/e/div");
|
||||
|
||||
// a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
|
||||
// a = 0 again → still failing for the SAME reason, NOT a fresh transition → no second Bad publish,
|
||||
// and (R07:P4) no second script-log entry either: the log fires on the transition or on a change of
|
||||
// reason, not on every trigger of a tag that is already Bad.
|
||||
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.ShouldBeEmpty();
|
||||
logs.Count.ShouldBe(2);
|
||||
logs.Count.ShouldBe(1);
|
||||
|
||||
// a = 5 → 20 → Good recovery is force-published.
|
||||
driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CalculationEvaluator"/> runs every compiled script through a <see cref="ScriptRunGuard"/>
|
||||
/// keyed by calc-tag RawPath. A timed-out run is abandoned on its pool thread; before the guard, every
|
||||
/// later trigger of the same tag stacked another orphan behind it (one leaked thread per trigger). Now
|
||||
/// every later trigger is refused ("previous evaluation still running" — Bad, but not a quarantine
|
||||
/// strike; only consecutive timeouts quarantine), and a healthy sibling on the same evaluator keeps computing.
|
||||
/// </summary>
|
||||
public sealed class CalculationEvaluatorRunGuardTests
|
||||
{
|
||||
private static ScriptRootLogger NoOpScriptRoot() => new(new LoggerConfiguration().CreateLogger());
|
||||
|
||||
/// <summary>Outlives the 60 ms budget by a wide margin but still ends, so the orphan frees itself.</summary>
|
||||
private const string SpinScript =
|
||||
"var end = System.DateTime.UtcNow.AddSeconds(6); while (System.DateTime.UtcNow < end) { } return 0;";
|
||||
|
||||
private static readonly Dictionary<string, object?> NoDeps = new();
|
||||
|
||||
[Fact]
|
||||
public async Task Triggers_during_the_previous_orphan_are_refused_not_quarantined_and_a_sibling_keeps_computing()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(
|
||||
NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(60));
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var first = await sut.EvaluateAsync("calc/loop", SpinScript, Snap(NoDeps), ct).WaitAsync(TimeSpan.FromSeconds(30), ct);
|
||||
first.Success.ShouldBeFalse();
|
||||
first.Reason.ShouldNotBeNull();
|
||||
first.Reason.ShouldContain("timed out");
|
||||
|
||||
// Every later trigger while the orphan runs is a Bad "previous evaluation still running" — a
|
||||
// refusal is not a strike, so the tag is NOT quarantined by them (fix round 1, I1).
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var refused = await sut.EvaluateAsync("calc/loop", SpinScript, Snap(NoDeps), ct);
|
||||
refused.Success.ShouldBeFalse();
|
||||
refused.Reason.ShouldBe(ScriptRunGuard.PreviousRunStillRunningReason);
|
||||
}
|
||||
sut.QuarantinedScripts.ShouldBeEmpty();
|
||||
sut.RunGuard.RefusedWhileRunningCount.ShouldBe(3);
|
||||
|
||||
// Exactly one run was ever dispatched for the runaway tag — the leak is one orphan, not one per trigger.
|
||||
sut.RunGuard.StartedRunCount.ShouldBe(1);
|
||||
sut.RunGuard.InFlightCount.ShouldBe(1);
|
||||
|
||||
// Positive control: a sibling tag on the same evaluator still computes.
|
||||
var sibling = await sut.EvaluateAsync("calc/ok", "return 42;", Snap(NoDeps), ct);
|
||||
sibling.Success.ShouldBeTrue(sibling.Reason);
|
||||
sibling.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearCompiledScripts_lifts_the_quarantine_as_a_redeploy_does()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(
|
||||
NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(60),
|
||||
new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = 1 });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var r = await sut.EvaluateAsync("calc/loop", SpinScript, Snap(NoDeps), ct).WaitAsync(TimeSpan.FromSeconds(30), ct);
|
||||
r.Reason.ShouldNotBeNull();
|
||||
r.Reason.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
|
||||
|
||||
sut.ClearCompiledScripts();
|
||||
|
||||
sut.QuarantinedScripts.ShouldBeEmpty();
|
||||
// The fixed script (new source) runs; the old orphan is a sunk cost that no longer blocks the tag.
|
||||
var fixedRun = await sut.EvaluateAsync("calc/loop", "return 7;", Snap(NoDeps), ct);
|
||||
fixedRun.Success.ShouldBeTrue(fixedRun.Reason);
|
||||
fixedRun.Value.ShouldBe(7);
|
||||
}
|
||||
|
||||
private static Dictionary<string, Core.Abstractions.DataValueSnapshot> Snap(Dictionary<string, object?> deps)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var d = new Dictionary<string, Core.Abstractions.DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kv in deps) d[kv.Key] = new Core.Abstractions.DataValueSnapshot(kv.Value, 0u, now, now);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
+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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user