using System.Text.Json; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// The driver health contract (review 07 H4 + H7, and the lifecycle/fail-fast rules every driver lane /// must meet): a device REFUSAL is per-tag Bad with no Degraded and proves the device answered; a comms /// fault degrades and preserves LastSuccessfulRead; health is one write per batch; a comms timeout on /// one unit fails the rest of that unit's batch fast; writes map transport failures to /// BadCommunicationError and value problems to BadOutOfRange / BadTypeMismatch. /// [Trait("Category", "Unit")] public sealed class ModbusHealthContractTests { private const uint Good = 0u; private const uint BadOutOfRange = 0x803C0000u; private const uint BadDeviceFailure = 0x808B0000u; private const uint BadCommunicationError = 0x80050000u; private const uint BadTypeMismatch = 0x80740000u; private const uint BadNodeIdUnknown = 0x80340000u; private static ModbusDriverOptions Opts(params ModbusTagDefinition[] tags) => new() { Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false }, }; private static ModbusTagDefinition Hr(string name, ushort addr, byte? unit = null, ModbusDataType type = ModbusDataType.Int16) => new(name, ModbusRegion.HoldingRegisters, addr, type, UnitId: unit); private static Task Refuse(byte code, byte[] pdu) => Task.FromException(new ModbusException(pdu[0], code, $"exception {code}")); private static ushort Addr(byte[] pdu) => (ushort)((pdu[1] << 8) | pdu[2]); private static async Task<(ModbusDriver Driver, ScriptedTransport Transport)> StartAsync( ModbusDriverOptions opts, ILogger? logger = null) { var t = new ScriptedTransport(); var d = new ModbusDriver(opts, "m1", _ => t, logger); await d.InitializeAsync("{}", TestContext.Current.CancellationToken); return (d, t); } // ---------------- H4: refusals ---------------- [Fact] public async Task All_tags_refused_keeps_Healthy_and_advances_LastSuccessfulRead() { var (drv, t) = await StartAsync(Opts(Hr("A", 10), Hr("B", 11))); await using var _ = drv; t.Handler = (_, pdu, _) => Refuse(0x02, pdu); var before = drv.GetHealth().LastSuccessfulRead; var r = await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken); r.ShouldAllBe(s => s.StatusCode == BadOutOfRange); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy, "a refusal means the device answered — no Degraded, no watchdog fodder"); h.LastSuccessfulRead.ShouldNotBeNull(); h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before!.Value); } [Fact] public async Task A_refusal_after_a_comms_fault_restores_Healthy() { var (drv, t) = await StartAsync(Opts(Hr("A", 10))); await using var _ = drv; t.Handler = (_, _, _) => Task.FromException(new IOException("socket dropped")); await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); var degraded = drv.GetHealth(); degraded.State.ShouldBe(DriverState.Degraded); t.Handler = (_, pdu, _) => Refuse(0x02, pdu); await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value); } [Theory] [InlineData(0x04)] [InlineData(0x0B)] public async Task Non_refusal_exception_codes_still_degrade(byte code) { // Positive control for the refusal tests: a server-failure / gateway code is NOT a refusal. var (drv, t) = await StartAsync(Opts(Hr("A", 10))); await using var _ = drv; t.Handler = (_, pdu, _) => Refuse(code, pdu); await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Theory] [InlineData(false)] [InlineData(true)] public async Task Mixed_refused_and_good_batch_is_Healthy_regardless_of_tag_order(bool refusedFirst) { var (drv, t) = await StartAsync(Opts(Hr("Refused", 10), Hr("Fine", 50))); await using var _ = drv; t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Refuse(0x02, pdu) : null; string[] refs = refusedFirst ? ["Refused", "Fine"] : ["Fine", "Refused"]; var r = await drv.ReadAsync(refs, TestContext.Current.CancellationToken); r.Count(s => s.StatusCode == Good).ShouldBe(1); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task A_refused_tag_warns_once_not_once_per_poll() { var logger = new ListLogger(); var (drv, t) = await StartAsync(Opts(Hr("A", 10)), logger); await using var _ = drv; t.Handler = (_, pdu, _) => Refuse(0x02, pdu); for (var i = 0; i < 3; i++) await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); logger.Warnings.Count(m => m.Contains("refused") && m.Contains("A")).ShouldBe(1); } // ---------------- fail fast per unit / endpoint ---------------- [Fact] public async Task A_comms_timeout_on_one_unit_fails_the_rest_of_that_units_batch_fast() { var (drv, t) = await StartAsync(Opts(Hr("U2a", 10, 2), Hr("U2b", 11, 2), Hr("U2c", 12, 2), Hr("U1", 13, 1))); await using var _ = drv; t.Bank.HoldingRegisters[13] = 42; t.Handler = (unit, _, _) => unit == 2 ? Task.FromException(new ModbusTransportDesyncException( ModbusTransportDesyncException.DesyncReason.Timeout, "timeout")) : null; var r = await drv.ReadAsync(["U2a", "U2b", "U2c", "U1"], TestContext.Current.CancellationToken); t.SendsTo(2).ShouldBe(1, "one timeout per unit per batch, not one per tag"); r.Take(3).ShouldAllBe(s => s.StatusCode == BadCommunicationError); // Positive control: the healthy unit in the same batch is still read. r[3].StatusCode.ShouldBe(Good); r[3].Value.ShouldBe((short)42); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task An_unreachable_endpoint_fails_the_whole_batch_fast() { var (drv, t) = await StartAsync(Opts(Hr("A", 10, 1), Hr("B", 11, 2), Hr("C", 12, 3))); await using var _ = drv; t.Handler = (_, _, _) => Task.FromException(new ModbusTransportUnavailableException("throttled")); var r = await drv.ReadAsync(["A", "B", "C"], TestContext.Current.CancellationToken); t.TotalSends.ShouldBe(1); r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); } // ---------------- H7: write status mapping + write-side health ---------------- [Fact] public async Task Write_transport_failure_is_BadCommunicationError_and_a_good_read_clears_it() { // Fix round 1 (review Important #3): ONE health writer for reads and writes. A single write blip used // to latch Degraded until the next good WRITE — forever, in a read-mostly plant. var (drv, t) = await StartAsync(Opts(Hr("A", 10))); await using var _ = drv; t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException(new IOException("dropped")) : null; var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken); w[0].StatusCode.ShouldBe(BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); (await drv.ReadAsync(["A"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a good read proves the shared transport answered"); } [Fact] public async Task A_good_write_clears_a_read_side_comms_fault() { var (drv, t) = await StartAsync(Opts(Hr("A", 10))); await using var _ = drv; t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException(new IOException("dropped")) : null; await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded); (await drv.WriteAsync([new WriteRequest("A", (short)2)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task A_malformed_reply_does_not_fail_the_rest_of_the_unit_fast() { // Review m3: a byte-count mismatch is a reply that ARRIVED — only a timeout / desync / IO fails the unit fast. var (drv, t) = await StartAsync(Opts(Hr("Bad", 10), Hr("Fine", 50)) is var o ? new ModbusDriverOptions { Host = o.Host, RawTags = o.RawTags, Probe = o.Probe, DisableReadCoalescing = true } : null!); await using var _ = drv; t.Bank.HoldingRegisters[50] = 9; t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Task.FromResult(new byte[] { 0x03, 0x04, 0, 0 }) : null; var r = await drv.ReadAsync(["Bad", "Fine"], TestContext.Current.CancellationToken); r[0].StatusCode.ShouldBe(BadCommunicationError); r[1].Value.ShouldBe((short)9, "the sibling on the same unit is still read"); } [Fact] public async Task DisposeAsync_leaves_health_Unknown() { // Review m1: Dispose bumps the generation (dropping a concurrent shutdown's write), so it must write its own. var (drv, _) = await StartAsync(Opts(Hr("A", 10))); drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.DisposeAsync(); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } [Fact] public async Task A_write_refusal_counts_as_answered_and_clears_a_write_side_fault() { var (drv, t) = await StartAsync(Opts(Hr("A", 10))); await using var _ = drv; t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException(new IOException("dropped")) : null; await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded); t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Refuse(0x03, pdu) : null; var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken); w[0].StatusCode.ShouldBe(BadOutOfRange); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Write_value_problems_map_to_OutOfRange_and_TypeMismatch_without_touching_health() { var arr = new ModbusTagDefinition("Arr", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Int16, ArrayCount: 3); var (drv, t) = await StartAsync(Opts(Hr("Bcd", 10, type: ModbusDataType.Bcd16), Hr("I16", 11), arr)); await using var _ = drv; var w = await drv.WriteAsync([ new WriteRequest("Bcd", 12345), // > 4 BCD digits new WriteRequest("I16", 70000), // > Int16 new WriteRequest("I16", "not a number"), new WriteRequest("Arr", new short[] { 1, 2 }), // length mismatch new WriteRequest("Arr", new int[] { 1, 2, 3 }), // CLR list of another element type converts (ScadaBridge shape) ], TestContext.Current.CancellationToken); w[0].StatusCode.ShouldBe(BadOutOfRange); w[1].StatusCode.ShouldBe(BadOutOfRange); w[2].StatusCode.ShouldBe(BadTypeMismatch); w[3].StatusCode.ShouldBe(BadOutOfRange); w[4].StatusCode.ShouldBe(Good); t.Bank.HoldingRegisters[22].ShouldBe((ushort)3); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task A_failed_read_invalidates_the_WriteOnChangeOnly_cache() { // Value-only suppression is the write-side twin of the #527 dedup trap: after a comms fault the // device's register is unknown (it may have rebooted), so re-asserting the same setpoint must reach it. var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]), WriteOnChangeOnly = true, Probe = new ModbusProbeOptions { Enabled = false }, }; var (drv, t) = await StartAsync(opts); await using var _ = drv; int Fc06() => t.Sent.Count(s => s.Pdu[0] == 0x06); await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken); await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken); Fc06().ShouldBe(1, "positive control: an identical re-write is suppressed while nothing failed"); t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException(new IOException("dropped")) : null; await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); t.Handler = null; await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken); Fc06().ShouldBe(2, "after a failed read the device value is unknown; the write must go to the wire"); } // ---------------- lifecycle generation ---------------- [Fact] public async Task A_superseded_initialize_never_publishes_over_the_shutdown_that_overtook_it() { var t = new ScriptedTransport { ConnectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) }; var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => t) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) }; var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken); await t.ConnectEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // The init is wedged in its connect; shutdown waits out the gate timeout and tears down anyway. await drv.ShutdownAsync(TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Unknown); t.ConnectGate.SetResult(); // the abandoned connect finally completes await Should.ThrowAsync(() => init); drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the superseded init must not publish Healthy"); t.DisposeCount.ShouldBe(1, "the superseded init releases the transport it built"); await Should.ThrowAsync(() => drv.ReadAsync(["A"], TestContext.Current.CancellationToken)); } [Fact] public async Task A_read_that_straddles_a_reinitialize_does_not_write_health_about_the_new_transport() { var first = new ScriptedTransport(); var second = new ScriptedTransport(); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); first.Handler = (_, _, _) => { entered.TrySetResult(); return release.Task; }; var calls = 0; var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => Interlocked.Increment(ref calls) == 1 ? first : second); await using var _ = drv; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var read = drv.ReadAsync(["A"], TestContext.Current.CancellationToken); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // re-init onto `second` drv.GetHealth().State.ShouldBe(DriverState.Healthy); release.SetException(new IOException("old socket died")); // the straddling read now fails (await read)[0].StatusCode.ShouldBe(BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a read on the superseded transport must not degrade the new one"); // Positive control: a failure on the CURRENT transport still degrades. second.Handler = (_, _, _) => Task.FromException(new IOException("new socket died")); await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Reinitialize_with_a_new_tag_set_drops_tags_the_new_config_no_longer_has() { var t = new ScriptedTransport(); var drv = new ModbusDriver(Opts(Hr("Old", 10)), "m1", _ => t); await using var _ = drv; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); (await drv.ReadAsync(["Old"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good); var config = JsonSerializer.Serialize(new { host = "f", probe = new { enabled = false }, rawTags = ModbusRawTags.Entries([Hr("New", 11)]), }); await drv.InitializeAsync(config, TestContext.Current.CancellationToken); var r = await drv.ReadAsync(["Old", "New"], TestContext.Current.CancellationToken); r[0].StatusCode.ShouldBe(BadNodeIdUnknown, "a re-init publishes a fresh table, it does not merge into the old one"); r[1].StatusCode.ShouldBe(Good); } // ---------------- complete teardown ---------------- [Fact] public async Task Shutdown_joins_the_probe_loop_so_no_host_transition_fires_after_it_returns() { var t = new ScriptedTransport(); var probeBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var probes = 0; t.Handler = async (_, pdu, ct) => { if (Interlocked.Increment(ref probes) == 1) return await t.Bank.SendAsync(1, pdu, ct); // first probe: Running probeBlocked.TrySetResult(); try { await Task.Delay(Timeout.Infinite, ct); } catch (OperationCanceledException) { } throw new ObjectDisposedException("transport"); // a teardown-time failure, NOT an OCE }; var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]), Probe = new ModbusProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(30) }, }; var drv = new ModbusDriver(opts, "m1", _ => t); var running = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var afterShutdown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var shutdownReturned = false; drv.OnHostStatusChanged += (_, e) => { if (e.NewState == HostState.Running) running.TrySetResult(); if (Volatile.Read(ref shutdownReturned)) afterShutdown.TrySetResult(); }; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); await running.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // positive control await probeBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await drv.ShutdownAsync(TestContext.Current.CancellationToken); Volatile.Write(ref shutdownReturned, true); await Should.ThrowAsync( () => afterShutdown.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); } private sealed class ListLogger : ILogger { public System.Collections.Concurrent.ConcurrentQueue Messages { get; } = new(); public IEnumerable Warnings => Messages.Where(m => m.StartsWith("Warning:", StringComparison.Ordinal)); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Messages.Enqueue($"{logLevel}: {formatter(state, exception)}"); } }