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
164 lines
8.0 KiB
C#
164 lines
8.0 KiB
C#
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);
|
|
}
|
|
}
|