Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
T

351 lines
17 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good
/// or refused write — restores Healthy and advances <c>LastSuccessfulRead</c>; a no-reply failure on either path
/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyHealthEvidenceTests
{
private const string Host = "ab://10.0.0.5/1,0";
private sealed class SwitchableFactory : IAbLegacyTagFactory
{
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag> Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 };
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> Tags { get; } = new();
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p);
}
private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) =>
new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) };
private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) =>
new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = false,
}, "drv-health", f);
private static async Task<AbLegacyDriver> DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags)
{
var drv = NewDriver(f, tags);
await drv.InitializeAsync("{}", CancellationToken.None);
f.Make = Dead;
(await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode
.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition
return drv;
}
[Fact]
public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead()
{
var f = new SwitchableFactory();
var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
var before = drv.GetHealth().LastSuccessfulRead;
f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound };
await Task.Delay(5, TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["X"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Theory]
[InlineData(0)] // a Good write
[InlineData((int)AbStatus.NotAllowed)] // a write the device refused
public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus)
{
var f = new SwitchableFactory();
var drv = await DegradedDriverAsync(f,
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
var before = drv.GetHealth().LastSuccessfulRead;
f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus };
await Task.Delay(5, TestContext.Current.CancellationToken);
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable);
f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_no_reply_write_status_degrades_like_the_exception_path_does()
{
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } };
var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead()
{
var f = new SwitchableFactory
{
Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = NewDriver(f,
new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(5, TestContext.Current.CancellationToken);
var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Degraded);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle()
{
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
var f = new SwitchableFactory();
var drv = NewDriver(f,
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int),
new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int),
new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int));
drv.Clock = () => now;
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W
var cached = f.Tags["N7:5"];
f.Make = Dead;
await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down
drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue();
var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
cached.WriteCount.ShouldBe(0);
// Positive control: once the window has passed, the same cached handle is written.
now = now.AddSeconds(2);
(await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
cached.WriteCount.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle()
{
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } };
var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd
f.Tags["N7:0"].Value = (short)6;
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse();
state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue();
f.Tags["N7:5"].Disposed.ShouldBeTrue();
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A probe read that parks ignoring cancellation, so ShutdownAsync is still joining.</summary>
private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p)
{
public override async Task ReadAsync(CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
}
}
[Fact]
public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var parked = false;
var f = new SwitchableFactory
{
Make = p =>
{
if (parked) return new FakeAbLegacyTag(p) { Value = 1 };
parked = true;
return new ParkingTag(p, release.Task, entered);
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-m1", f);
await drv.InitializeAsync("{}", CancellationToken.None);
await entered.Task;
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
await Task.Delay(50, TestContext.Current.CancellationToken);
shutdown.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
release.SetResult();
await shutdown;
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Positive control: an uncontested shutdown still writes Unknown.
await drv.ShutdownAsync(CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public override Task ReadAsync(CancellationToken ct) =>
Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct);
}
/// <summary>
/// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and
/// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves
/// the handle once the gate is held.
/// </summary>
[Fact]
public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved()
{
var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } };
var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
var first = f.Tags["N7:0"];
var gate = state.GetRuntimeLock("X");
await gate.WaitAsync(TestContext.Current.CancellationToken);
// Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always
// lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously).
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); };
var read = drv.ReadAsync(["X"], CancellationToken.None);
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue();
evicted!.Dispose();
gate.Release();
var r = await read;
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and
/// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody
/// else would get. It now answers that one call neutrally and leaves the key gone.
/// </summary>
[Fact]
public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate()
{
var creates = new System.Collections.Concurrent.ConcurrentQueue<string>();
var f = new SwitchableFactory
{
Make = p =>
{
creates.Enqueue(p.TagName);
return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 };
},
};
var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control
var state = drv.GetDeviceState(Host)!;
const string key = "Ind=>N7:5";
state.Runtimes.ContainsKey(key).ShouldBeTrue();
var gate = state.GetRuntimeLock(key);
await gate.WaitAsync(TestContext.Current.CancellationToken);
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); };
var read = drv.ReadAsync(["Ind"], CancellationToken.None);
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
// What the sweep does when the index moves: evict the resolution, then forget its gate.
state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue();
swept!.Dispose();
state.ForgetGates(key);
gate.Release();
var r = await read;
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected
creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault.</summary>
[Fact]
public async Task An_index_read_answered_Unsupported_is_neutral()
{
var f = new SwitchableFactory
{
Make = p => p.TagName == "N7:0"
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported }
: new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = NewDriver(f,
new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good.</summary>
[Fact]
public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage()
{
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } };
var drv = NewDriver(f,
new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2),
new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
r[1].Value.ShouldBe(new[] { 1f, 2f });
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
}