using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// /// PR abcip-4.2 — end-to-end coverage for write-deadband / write-on-change suppression /// against a running ab_server. Drives a 5-write jittery sequence with /// WriteDeadband=1.0 and asserts the driver's AbCip.WritesSuppressed /// diagnostics counter reflects the expected number of suppressions. Wire-level write /// count isn't directly observable in ab_server (no admin shim for "tell me how /// many CIP writes you got"), so the suppression evidence is the driver's own counter /// plus the final read confirming the last passed-through value reached the PLC. /// /// /// Unit coverage in /// proves the suppression math against an in-process fake. This test exercises the full /// libplctag stack so a regression in how the driver wires its coalescer to the real wire /// path shows up here. /// [Trait("Category", "Integration")] [Trait("Requires", "AbServer")] public sealed class AbCipWriteDeadbandTests { [AbServerFact] public async Task Jittery_setpoints_within_deadband_dont_reach_the_wire() { var profile = KnownProfiles.ControlLogix; var fixture = new AbServerFixture(profile); await fixture.InitializeAsync(); try { var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0"; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)], // ab_server seeds TestDINT — drive integer setpoints with a 1.0 deadband so // values that differ by 0 are suppressed. Real-world deadbanding usually // targets REAL setpoints; integer here is fine because the suppression rule // looks at the boxed numeric value, not the on-wire encoding. Tags = [new AbCipTagDefinition("Setpoint", deviceUri, "TestDINT", AbCipDataType.DInt, WriteDeadband: 1.0)], Timeout = TimeSpan.FromSeconds(5), }, "drv-write-deadband-smoke"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Five-write jittery sequence: 100, 100, 100, 102, 102. // - 100 (first): passes (no prior). // - 100, 100: suppressed (|0| < 1.0). // - 102: passes (|2| ≥ 1.0). // - 102: suppressed (|0| < 1.0). // Expected: 2 wire writes, 3 suppressions. var inputs = new[] { 100, 100, 100, 102, 102 }; foreach (var v in inputs) { var results = await drv.WriteAsync( [new WriteRequest("Setpoint", v)], TestContext.Current.CancellationToken); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good, "every write — suppressed or not — must surface as Good to the OPC UA client"); } drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(3); drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2); // Final readback proves the last passed-through value (102) made it to the PLC. var readback = await drv.ReadAsync(["Setpoint"], TestContext.Current.CancellationToken); readback.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); Convert.ToInt32(readback.Single().Value).ShouldBe(102); // Diagnostics counters are also reflected through GetHealth — the path the // driver-diagnostics RPC + Admin UI consume. var diag = drv.GetHealth().DiagnosticsOrEmpty; diag["AbCip.WritesSuppressed"].ShouldBe(3); diag["AbCip.WritesPassedThrough"].ShouldBe(2); await drv.ShutdownAsync(TestContext.Current.CancellationToken); } finally { await fixture.DisposeAsync(); } } }