1d7a4b7a53
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
351 lines
17 KiB
C#
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);
|
|
}
|
|
}
|