f179829ca6
- IMPORTANT: a successful probe records the PLC's ADS state; a PLC not in Run
keeps the driver Degraded with 'PLC in {state}' while LastSuccessfulRead
still advances (the device answered). RecordDeviceAnswered never restores
Healthy while any device is out of Run.
- Health is per device, rolled up worst-of: a comms fault is remembered on the
device it was seen on and only that device answering clears it (device A
answering no longer clears device B's fault).
- Refused (non-comms) dead registrations back off 30 s doubling to 10 min and
are re-armed by a symbol-version change or retried by the reconnect replay;
comms-class failures keep the every-tick retry. A retry whose failure class
changes republishes the tag's status, once.
- Bit-RMW gates are kept across generations and teardowns (never cleared).
- ShutdownAsync honours its CancellationToken: it stops waiting, finishes the
bookkeeping and throws OperationCanceledException.
- Remarks on why a superseded InitializeAsync throws OperationCanceledException.
- REAL / LREAL values (scalar or array element) outside the target range are
OverflowException -> BadOutOfRange instead of being written as Infinity.
- String / array write encoding factored into AdsTwinCATClient.PlanStringWrite
/ PlanArrayWrite; the fake runs them so a driver-level test covers the real
encode path against a fake resolved symbol.
- Timing-fragile tests use condition waits / measured probe-tick windows; the
dispose-during-shutdown test asserts ordering with a 1 s bound.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
105 lines
4.8 KiB
C#
105 lines
4.8 KiB
C#
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);
|
|
}
|
|
}
|