using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// #141 subscribe-side knobs: per-tag Deadband, driver-wide WriteOnChangeOnly. /// [Trait("Category", "Unit")] public sealed class ModbusSubscribeOptionsTests { /// /// Programmable transport: caller seeds a bank-of-registers value, each FC03 returns /// the current value. Lets tests step the underlying register through a sequence and /// observe how the deadband filter responds. /// private sealed class ProgrammableTransport : IModbusTransport { public ushort CurrentValue; public int WritesSent; public int FC06Count; public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { switch (pdu[0]) { case 0x03: { var qty = (ushort)((pdu[3] << 8) | pdu[4]); var resp = new byte[2 + qty * 2]; resp[0] = 0x03; resp[1] = (byte)(qty * 2); for (var i = 0; i < qty; i++) { resp[2 + i * 2] = (byte)(CurrentValue >> 8); resp[3 + i * 2] = (byte)(CurrentValue & 0xFF); } return Task.FromResult(resp); } case 0x06: WritesSent++; FC06Count++; CurrentValue = (ushort)((pdu[3] << 8) | pdu[4]); return Task.FromResult(pdu); default: return Task.FromResult(new byte[] { pdu[0], 0, 0 }); } } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } [Fact] public async Task Deadband_Suppresses_SubThreshold_Changes() { var fake = new ProgrammableTransport(); var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0); var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); var publishes = new List(); drv.OnDataChange += (_, e) => publishes.Add((short)e.Snapshot.Value!); // First publish always passes (no baseline). Then step the value: // 100 → 102 (delta 2 < 5, suppressed) → 106 (delta 6 ≥ 5, published) → 107 (delta 1, suppressed). var sub = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(50), CancellationToken.None); try { fake.CurrentValue = 100; await Task.Delay(150); fake.CurrentValue = 102; await Task.Delay(150); fake.CurrentValue = 106; await Task.Delay(150); fake.CurrentValue = 107; await Task.Delay(150); } finally { await drv.UnsubscribeAsync(sub, CancellationToken.None); } // Expect at most 2 distinct values surfaced (100 baseline + 106). The 102 and 107 should // be suppressed by the deadband. Ordering can be flaky on slow CI so we assert the set, // not the exact sequence. publishes.ShouldContain((short)100); publishes.ShouldContain((short)106); publishes.ShouldNotContain((short)102); publishes.ShouldNotContain((short)107); } [Fact] public async Task Deadband_Null_Publishes_Every_Change() { var fake = new ProgrammableTransport(); var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); // no deadband var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); var publishes = new List(); drv.OnDataChange += (_, e) => publishes.Add((short)e.Snapshot.Value!); var sub = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(50), CancellationToken.None); try { fake.CurrentValue = 100; await Task.Delay(150); fake.CurrentValue = 101; await Task.Delay(150); // tiny change still publishes } finally { await drv.UnsubscribeAsync(sub, CancellationToken.None); } publishes.ShouldContain((short)100); publishes.ShouldContain((short)101); } [Fact] public async Task WriteOnChangeOnly_Suppresses_Identical_Repeated_Writes() { var fake = new ProgrammableTransport(); var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], WriteOnChangeOnly = true, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); // suppressed await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); // suppressed await drv.WriteAsync([new WriteRequest("Sp", (short)43)], CancellationToken.None); // distinct fake.WritesSent.ShouldBe(2, "two distinct values written; identical-value repeats suppressed"); } [Fact] public async Task WriteOnChangeOnly_Default_False_Always_Writes() { var fake = new ProgrammableTransport(); var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); fake.WritesSent.ShouldBe(3, "default false → every write goes to the wire"); } [Fact] public async Task WriteOnChangeOnly_Cache_Invalidated_By_Read_Divergence() { var fake = new ProgrammableTransport(); var tag = new ModbusTagDefinition("Sp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [tag], WriteOnChangeOnly = true, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); fake.FC06Count.ShouldBe(1); // External change at the PLC (panel writes 99). Read sees 99 → invalidates the cache. fake.CurrentValue = 99; var read = await drv.ReadAsync(["Sp"], CancellationToken.None); read[0].Value.ShouldBe((short)99); // Now writing 42 again should NOT be suppressed because the cache was invalidated. await drv.WriteAsync([new WriteRequest("Sp", (short)42)], CancellationToken.None); fake.FC06Count.ShouldBe(2, "post-divergence write not suppressed"); } }