using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// PR 9 — per-device Timeout + Retries overrides. SLC 5/01 needs ~5 s, /// SLC 5/05 ~2 s, MicroLogix 1100 ~3 s — a single driver-wide timeout always misfires on /// at least one chassis. Verifies the precedence rules (device > driver-wide > default), /// that the resolved timeout flows into , and /// that the retry loop honours the per-device count. /// [Trait("Category", "Unit")] public sealed class AbLegacyPerDeviceTimeoutTests { private const string Host = "ab://10.0.0.5/1,0"; [Fact] public async Task Per_device_Timeout_flows_into_AbLegacyTagCreateParams() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { // Driver-wide default 2 s — the device override below should win. Timeout = TimeSpan.FromSeconds(2), Devices = [ new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, DeviceName: "slc-501", Timeout: TimeSpan.FromSeconds(5)), ], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(5)); } [Fact] public async Task Absent_per_device_Timeout_falls_back_to_driver_wide() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Timeout = TimeSpan.FromSeconds(7), Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7)); } [Fact] public async Task Two_devices_each_use_their_own_Timeout_override() { const string fastHost = "ab://10.0.0.5/1,0"; const string slowHost = "ab://10.0.0.6/"; var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Timeout = TimeSpan.FromSeconds(2), Devices = [ new AbLegacyDeviceOptions(fastHost, AbLegacyPlcFamily.Slc500, Timeout: TimeSpan.FromMilliseconds(500)), new AbLegacyDeviceOptions(slowHost, AbLegacyPlcFamily.MicroLogix, Timeout: TimeSpan.FromSeconds(5)), ], Tags = [ new AbLegacyTagDefinition("Fast", fastHost, "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("Slow", slowHost, "N7:1", AbLegacyDataType.Int), ], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Fast", "Slow"], CancellationToken.None); factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500)); factory.Tags["N7:1"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(5)); } [Fact] public async Task Per_device_Retries_2_yields_3_attempts_before_failure() { var factory = new FakeAbLegacyTagFactory(); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException("simulated transient"), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [ new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 2), ], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); // 1 initial + 2 retries = 3 attempts. factory.Tags["N7:0"].ReadCount.ShouldBe(3); } [Fact] public async Task No_Retries_yields_single_attempt() { var factory = new FakeAbLegacyTagFactory(); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException("simulated transient"), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { // Both null — defaults to 0 retries (single attempt). Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].ReadCount.ShouldBe(1); } [Fact] public async Task Driver_wide_Retries_applies_when_device_omits_override() { var factory = new FakeAbLegacyTagFactory(); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException("simulated transient"), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Retries = 1, // driver-wide → 2 attempts total Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].ReadCount.ShouldBe(2); } [Fact] public async Task Per_device_Retries_overrides_driver_wide_default() { var factory = new FakeAbLegacyTagFactory(); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException("simulated transient"), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Retries = 5, // driver-wide // Per-device says zero retries — should win, single attempt. Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 0)], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].ReadCount.ShouldBe(1); } [Fact] public async Task Successful_read_after_one_transient_does_not_burn_remaining_retries() { // Verifies retries stop once the call succeeds — we shouldn't keep hammering. var factory = new FakeAbLegacyTagFactory(); var attemptsBeforeSuccess = 1; factory.Customise = p => { var fake = new FlappyFake(p) { FailFirstN = attemptsBeforeSuccess, FinalValue = 42, }; return fake; }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 3)], Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snapshots.Single().Value.ShouldBe(42); // 1 throw + 1 success = 2 attempts (we should NOT use all 4). factory.Tags["N7:0"].ReadCount.ShouldBe(2); } [Fact] public async Task Dto_round_trip_preserves_TimeoutMs_and_Retries_at_both_levels() { const string json = """ { "TimeoutMs": 4000, "Retries": 1, "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "Slc500", "TimeoutMs": 5000, "Retries": 2 } ], "Probe": { "Enabled": false }, "Tags": [ { "Name": "X", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "N7:0", "DataType": "Int" } ] } """; // Use the static factory so we exercise the deserialisation path used in production. var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-roundtrip", json); await drv.InitializeAsync(json, CancellationToken.None); var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull(); state.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(5000)); state.Options.Retries.ShouldBe(2); // Per-device override wins over driver-wide. drv.ResolveRetries(state).ShouldBe(2); drv.ResolveTimeout(state).ShouldBe(TimeSpan.FromMilliseconds(5000)); await drv.ShutdownAsync(CancellationToken.None); } /// /// A fake that throws the first FailFirstN reads then succeeds. Used to assert /// the retry loop stops once a call succeeds — it should not exhaust the retry budget. /// private sealed class FlappyFake : FakeAbLegacyTag { public int FailFirstN { get; set; } public object? FinalValue { get; set; } private int _calls; public FlappyFake(AbLegacyTagCreateParams p) : base(p) { } public override Task ReadAsync(CancellationToken ct) { _calls++; // Increment ReadCount via the base accessor (it does its own increment + throw // bookkeeping). Toggle ThrowOnRead based on the call number so the base helper does // the throw for us. if (_calls <= FailFirstN) { ThrowOnRead = true; Exception = new TimeoutException("flap"); } else { ThrowOnRead = false; Value = FinalValue; } return base.ReadAsync(ct); } } }