using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// #740 driver-lane health checklist (drivers.md, #527/#611): a deterministic CNC refusal — the control /// answered with a non-OK return code, or with a value the tag's type cannot hold (vacant macro, fractional /// macro on an integer tag) — is per-tag Bad with ONE Warning per tag and NO health write, and it still /// proves the device answered (LastSuccessfulRead advances). Only a comms-class status degrades the driver. /// Before this, every non-Good status latched Degraded every poll, so one vacant macro made a reachable CNC /// look sick forever. /// [Trait("Category", "Unit")] public sealed class FocasReadHealthContractTests { private const string Host = "focas://10.0.0.5:8193"; private sealed class WarningCounter : ILogger { private int _warnings; public int Warnings => Volatile.Read(ref _warnings); 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) { if (logLevel == LogLevel.Warning && formatter(state, exception).Contains("refused", StringComparison.Ordinal)) Interlocked.Increment(ref _warnings); } } private static (FocasDriver drv, FakeFocasClient client, WarningCounter log) NewDriver(uint r100Status) { var client = new FakeFocasClient { Values = { ["R101"] = (sbyte)1 } }; client.ReadStatuses["R100"] = r100Status; var log = new WarningCounter(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], RawTags = [ FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte)), FocasRawTags.Entry(new FocasTagDefinition("raw/R101", Host, "R101", FocasDataType.Byte)), ], Probe = new FocasProbeOptions { Enabled = false }, PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false }, }, "focas-contract", new FakeFocasClientFactory { Customise = () => client }, log); return (drv, client, log); } /// A refusal leaves health alone, warns once, and still credits the answer. [Theory] [InlineData(FocasStatusMapper.BadOutOfRange)] // EW_NUMBER [InlineData(FocasStatusMapper.BadNotSupported)] // EW_FUNC / EW_NOOPT [InlineData(FocasStatusMapper.BadNoData)] // vacant macro [InlineData(FocasStatusMapper.BadTypeMismatch)] // fractional macro on an integer tag [InlineData(FocasStatusMapper.BadDeviceFailure)] // EW_BUSY public async Task Cnc_refusal_is_per_tag_bad_with_one_warning_and_no_health_write(uint refusal) { var (drv, _, log) = NewDriver(refusal); await drv.InitializeAsync("{}", CancellationToken.None); var atInit = drv.GetHealth().LastSuccessfulRead; for (var i = 0; i < 3; i++) { await Task.Delay(5); var snap = (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0]; snap.StatusCode.ShouldBe(refusal); } var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Healthy, "a CNC refusal is not a comms fault"); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value, "the CNC answered — that is evidence of life"); log.Warnings.ShouldBe(1, "one Warning per refused tag, not one per poll"); await drv.ShutdownAsync(CancellationToken.None); } /// A refused tag that recovers and is refused again warns again (the dedup clears on Good). [Fact] public async Task Refusal_warning_rearms_after_the_tag_reads_good() { var (drv, client, log) = NewDriver(FocasStatusMapper.BadOutOfRange); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["raw/R100"], CancellationToken.None); client.ReadStatuses.Remove("R100"); client.Values["R100"] = (sbyte)3; (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good); client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange; await drv.ReadAsync(["raw/R100"], CancellationToken.None); log.Warnings.ShouldBe(2); await drv.ShutdownAsync(CancellationToken.None); } /// Positive control: a comms-class status still degrades, preserving LastSuccessfulRead. [Fact] public async Task Comms_status_still_degrades_with_last_successful_read_preserved() { var (drv, _, _) = NewDriver(FocasStatusMapper.BadCommunicationError); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["raw/R101"], CancellationToken.None); var lastGood = drv.GetHealth().LastSuccessfulRead; await drv.ReadAsync(["raw/R100"], CancellationToken.None); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Degraded); health.LastSuccessfulRead.ShouldBe(lastGood); await drv.ShutdownAsync(CancellationToken.None); } /// /// Review minor 2 — a device that recovers from a comms fault and then only REFUSES (every tag it serves is /// refused) is answering, so it is restored to Healthy instead of staying Degraded forever. /// [Fact] public async Task Refusal_after_a_comms_fault_restores_healthy() { var (drv, client, _) = NewDriver(FocasStatusMapper.BadCommunicationError); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["raw/R100"], CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the comms fault degraded it"); client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange; await drv.ReadAsync(["raw/R100"], CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.ShutdownAsync(CancellationToken.None); } /// A read whose CNC answer is held until released, then fails at the transport. private sealed class HeldReadClient(TaskCompletionSource entered, Task release) : FakeFocasClient { public volatile bool Hold = true; public override async Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { if (!Hold) return await base.ReadAsync(address, type, ct).ConfigureAwait(false); entered.TrySetResult(); await release.ConfigureAwait(false); throw new IOException("socket reset"); } } /// /// Review minor 8 — a read that started against the PREVIOUS table and fails after a re-initialise must not /// write its Degraded onto the new generation's health. Positive control: the same failure under the live /// generation does degrade. /// [Fact] public async Task A_read_from_the_previous_generation_cannot_degrade_the_new_one() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var clients = new List(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte))], Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(10), }, "focas-gen", new FakeFocasClientFactory { Customise = () => { var c = new HeldReadClient(entered, release.Task); lock (clients) clients.Add(c); return c; }, }); await drv.InitializeAsync("{}", CancellationToken.None); var stale = drv.ReadAsync(["raw/R100"], CancellationToken.None); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); await drv.InitializeAsync("{}", CancellationToken.None); // a new generation, Healthy release.SetResult(); (await stale.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the old generation's failure must not land on the new one"); // Positive control: a failure under the live generation degrades. var live = drv.ReadAsync(["raw/R100"], CancellationToken.None); (await live.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); await drv.ShutdownAsync(CancellationToken.None); } /// A write the driver refuses (NotSupportedException) is not a comms fault either. [Fact] public async Task Write_refusal_does_not_degrade() { var client = new ThrowingWriteClient(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/W", Host, "R100", FocasDataType.Byte, Writable: true))], Probe = new FocasProbeOptions { Enabled = false }, }, "focas-write-refusal", new FakeFocasClientFactory { Customise = () => client }); await drv.InitializeAsync("{}", CancellationToken.None); var result = await drv.WriteAsync([new WriteRequest("raw/W", (sbyte)1)], CancellationToken.None); result[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported); drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.ShutdownAsync(CancellationToken.None); } private sealed class ThrowingWriteClient : FakeFocasClient { public override Task WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) => throw new NotSupportedException("bit writes are not supported on this area"); } }