using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// PR ablegacy-12 / #255 — wire-level smoke for auto-demote on comm failure. /// Runs only when ab_server is reachable. Two devices: one healthy (the live /// ab_server slc500 simulator), one pointed at 127.0.0.1:1 which /// refuses every connection. After three consecutive failures the faulty /// device's reads must short-circuit with BadCommunicationError /// while the healthy device keeps returning Good — the whole point /// of the feature: one slow / unreachable PLC sharing the driver thread /// can't starve faster peers. /// /// /// /// Build-only by default — the assertion that demotion latency is /// bounded depends on the ab_server simulator timing out on the faulty /// port within the per-device timeout. We pin the faulty endpoint at /// 127.0.0.1:1 (the bogus-port standard) which RST's the /// connection immediately on most stacks; environments that whitelist /// outbound to localhost:1 will see different timing but still trip /// the threshold within the test budget. /// /// /// The Docker fixture extension (slc500-faulty) noted in the PR /// plan is a documentation-only placeholder for now — implementing a /// refusing-proxy container is non-trivial and the localhost:1 trick /// covers the same surface deterministically. /// /// [Collection(AbLegacyServerCollection.Name)] [Trait("Category", "Integration")] [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyAutoDemoteTests(AbLegacyServerFixture sim) { [AbLegacyFact] public async Task Two_devices_one_unreachable_does_not_starve_healthy_reads() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var healthy = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; // 127.0.0.1:1 is the bogus-port standard — typical Linux/Windows TCP // stacks RST immediately. The driver still reports it as a comm // failure (libplctag wraps the failure as a transient throw). var faulty = "ab://127.0.0.1:1/1,0"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [ new AbLegacyDeviceOptions(healthy, AbLegacyPlcFamily.Slc500, Timeout: TimeSpan.FromSeconds(5)), new AbLegacyDeviceOptions(faulty, AbLegacyPlcFamily.Slc500, // Snappy timeout so the test budget stays short. Timeout: TimeSpan.FromMilliseconds(500), Demote: new AbLegacyDemoteOptions( FailureThreshold: 3, DemoteFor: TimeSpan.FromSeconds(30))), ], Tags = [ new AbLegacyTagDefinition("Healthy", healthy, "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("Faulty", faulty, "N7:0", AbLegacyDataType.Int), ], Probe = new AbLegacyProbeOptions { Enabled = false }, }, driverInstanceId: "ablegacy-auto-demote-it"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Trip the demote on the faulty device. for (var i = 0; i < 3; i++) { await drv.ReadAsync(["Faulty"], TestContext.Current.CancellationToken); } // Healthy host MUST keep returning Good even though the sibling is demoted. var healthyResult = await drv.ReadAsync(["Healthy"], TestContext.Current.CancellationToken); healthyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // Faulty host now short-circuits without waiting on libplctag's timeout. var sw = System.Diagnostics.Stopwatch.StartNew(); var faultyResult = await drv.ReadAsync(["Faulty"], TestContext.Current.CancellationToken); sw.Stop(); faultyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); // Short-circuit should be ~1 ms; pad generously for CI noise. The pre-PR-12 // path would have waited the full 500 ms timeout. sw.ElapsedMilliseconds.ShouldBeLessThan(200); // Counter access via the public diagnostic short-circuit path — the // internal Snapshot() seam isn't visible from this assembly. var demoteCountRef = $"_Diagnostics/{faulty}/DemoteCount"; var lastDemotedRef = $"_Diagnostics/{faulty}/LastDemotedUtc"; var diag = await drv.ReadAsync( [demoteCountRef, lastDemotedRef], TestContext.Current.CancellationToken); ((long)diag[0].Value!).ShouldBeGreaterThan(0); ((string)diag[1].Value!).Length.ShouldBeGreaterThan(0); } }