using System.Text.Json; 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.Tests; /// /// PR abcip-4.2 — write-deadband / write-on-change suppression in /// . The driver consults /// before issuing any wire write; tests assert the /// suppression rules + that suppressed writes still return Good + that the /// diagnostics counters increment in lockstep. /// [Trait("Category", "Unit")] public sealed class AbCipWriteDeadbandTests { private const string Device = "ab://10.0.0.5/1,0"; private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = tags, }, "drv-deadband", factory); return (drv, factory); } [Fact] public async Task WriteDeadband_suppresses_intermediate_jittery_setpoints() { // 0.5 deadband; sequence 10.0 → 10.3 → 10.4 → 10.6 — only 10.0 (first write, no prior) // and 10.6 (|10.6 - 10.0| = 0.6 ≥ 0.5) hit the wire. 10.3 + 10.4 are within 0.5 of 10.0 // and get suppressed. var (drv, factory) = NewDriver( new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real, WriteDeadband: 0.5)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 10.0)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 10.3)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 10.4)], CancellationToken.None); var last = await drv.WriteAsync([new WriteRequest("Setpoint", 10.6)], CancellationToken.None); last.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.Tags["Setpoint"].WriteCount.ShouldBe(2, "WriteDeadband=0.5 must suppress jittery values within the band"); factory.Tags["Setpoint"].Value.ShouldBe(10.6); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(2); drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2); } [Fact] public async Task WriteOnChange_suppresses_repeated_identical_values() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.DInt, WriteOnChange: true)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Counter", 5)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Counter", 5)], CancellationToken.None); var last = await drv.WriteAsync([new WriteRequest("Counter", 5)], CancellationToken.None); last.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.Tags["Counter"].WriteCount.ShouldBe(1, "WriteOnChange must suppress repeated identical writes"); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(2); drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(1); } [Fact] public async Task WriteDeadband_takes_priority_over_WriteOnChange_for_numerics() { // Numeric tag with both knobs set: deadband is the active rule, so a value just inside // the deadband suppresses even though it is *not* exactly equal. A value exactly equal // also suppresses (deadband path computes |0| < 0.5 = true). var (drv, factory) = NewDriver( new AbCipTagDefinition("Mixed", Device, "Mixed", AbCipDataType.Real, WriteDeadband: 0.5, WriteOnChange: true)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Mixed", 100.0)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Mixed", 100.2)], CancellationToken.None); // within band await drv.WriteAsync([new WriteRequest("Mixed", 100.0)], CancellationToken.None); // exact equal factory.Tags["Mixed"].WriteCount.ShouldBe(1); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(2); } [Fact] public async Task First_write_always_passes_through_when_no_prior_value() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt, WriteDeadband: 100.0, WriteOnChange: true)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Speed", 0)], CancellationToken.None); factory.Tags["Speed"].WriteCount.ShouldBe(1, "first write always passes through"); factory.Tags["Speed"].Value.ShouldBe(0); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0); drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(1); } [Fact] public async Task Reset_after_disconnect_lets_same_value_pass_through_again() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real, WriteOnChange: true)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 42.0)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 42.0)], CancellationToken.None); // suppressed factory.Tags["Setpoint"].WriteCount.ShouldBe(1); // Simulate reconnect — the PLC may have restarted while we were offline so the cached // "we already wrote 42" is no longer valid PLC state. drv.WriteCoalescer.Reset(Device); await drv.WriteAsync([new WriteRequest("Setpoint", 42.0)], CancellationToken.None); factory.Tags["Setpoint"].WriteCount.ShouldBe(2, "post-reset write must pay the full round-trip even when value is unchanged"); } [Fact] public async Task Two_devices_keep_independent_caches_for_same_tag_address() { const string device2 = "ab://10.0.0.6/1,0"; var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device), new AbCipDeviceOptions(device2)], Tags = [ new AbCipTagDefinition("DevA", Device, "Pressure", AbCipDataType.Real, WriteOnChange: true), new AbCipTagDefinition("DevB", device2, "Pressure", AbCipDataType.Real, WriteOnChange: true), ], }, "drv-multi-device", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Write 42.0 to DevA — passes (first), seeds DevA's cache. await drv.WriteAsync([new WriteRequest("DevA", 42.0)], CancellationToken.None); // Write 42.0 to DevB — must also pass (independent cache, no prior value on DevB). await drv.WriteAsync([new WriteRequest("DevB", 42.0)], CancellationToken.None); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0, "device-A and device-B must not share a coalescer cache"); drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2); } [Fact] public async Task Suppressed_write_returns_Good_status() { var (drv, _) = NewDriver( new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real, WriteDeadband: 1.0)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 50.0)], CancellationToken.None); var suppressed = await drv.WriteAsync([new WriteRequest("Setpoint", 50.5)], CancellationToken.None); suppressed.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good, "OPC UA write semantics: a suppressed write must look successful to the client"); } [Fact] public async Task Diagnostics_counters_surface_through_GetHealth() { var (drv, _) = NewDriver( new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.DInt, WriteOnChange: true)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Counter", 7)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Counter", 7)], CancellationToken.None); // suppressed await drv.WriteAsync([new WriteRequest("Counter", 8)], CancellationToken.None); var diag = drv.GetHealth().DiagnosticsOrEmpty; diag["AbCip.WritesSuppressed"].ShouldBe(1); diag["AbCip.WritesPassedThrough"].ShouldBe(2); } [Fact] public async Task NaN_or_Infinity_bypasses_deadband_suppression() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Sensor", Device, "Sensor", AbCipDataType.Real, WriteDeadband: 1.0)); await drv.InitializeAsync("{}", CancellationToken.None); // Seed the cache with a NaN — the next write of 100.0 must NOT be suppressed even // though |100 - NaN| comparison is mathematically meaningless. The wire decides. await drv.WriteAsync([new WriteRequest("Sensor", double.NaN)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sensor", 100.0)], CancellationToken.None); factory.Tags["Sensor"].WriteCount.ShouldBe(2); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0); } [Fact] public async Task Tag_without_either_knob_never_consults_coalescer_cache() { // Plain back-compat tag — no WriteDeadband, no WriteOnChange. Three identical writes // all hit the wire; the fast path in ShouldSuppress increments PassedThrough only. var (drv, factory) = NewDriver( new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Plain", 1)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Plain", 1)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Plain", 1)], CancellationToken.None); factory.Tags["Plain"].WriteCount.ShouldBe(3); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0); drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(3); } [Fact] public async Task Dto_round_trip_preserves_WriteDeadband_and_WriteOnChange() { // Ensure the DTO surface mirrors AbCipTagDefinition so config JSON drives the knobs. // Going through the static factory entry point guarantees the field names + casing // match what operators put in their driver-config JSON. var json = """ { "Devices": [{ "HostAddress": "ab://10.0.0.5/1,0" }], "Tags": [{ "Name": "Setpoint", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Setpoint", "DataType": "Real", "WriteDeadband": 0.25, "WriteOnChange": true }] } """; // Build the driver directly through the internal factory entry point so we can swap // in the FakeAbCipTagFactory after construction; the production CreateInstance path // wires a real LibplctagTagFactory which would try to dlopen libplctag at write time. var dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, })!; // The DTO carries WriteDeadband / WriteOnChange — the round-trip we actually want to // assert is that AbCipTagDto picks them up + AbCipDriverFactoryExtensions.BuildTag // forwards them to AbCipTagDefinition. Re-running the factory entry point would do // that, but a swappable FakeAbCipTagFactory keeps the test fast + offline. var tagDto = dto.Tags!.Single(); tagDto.WriteDeadband.ShouldBe(0.25); tagDto.WriteOnChange.ShouldBe(true); // Now use the same shape via the static factory + a fake tag factory so the live // driver actually runs the suppression logic + we can confirm the knobs propagated // all the way through to AbCipTagDefinition. var (drv, factory) = NewDriver( new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real, WriteDeadband: tagDto.WriteDeadband, WriteOnChange: tagDto.WriteOnChange ?? false)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 1.0)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Setpoint", 1.0)], CancellationToken.None); // WriteOnChange round-tripped — second write of identical value was suppressed. factory.Tags["Setpoint"].WriteCount.ShouldBe(1); drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(1); } }