using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// PR 8 — per-tag deadband / change filter tests. The driver layers the deadband on top of /// 's built-in change-detection: the engine fires its callback /// whenever value or status differs from its own last snapshot; the driver then applies the /// per-tag absolute / percent / type-aware filter before raising OnDataChange. /// [Trait("Category", "Unit")] public sealed class AbLegacyDeadbandTests { private const uint Good = 0x00000000u; private const uint BadCommunication = 0x80050000u; private static async Task BuildDriverAsync(params AbLegacyTagDefinition[] tags) { var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [], // ShouldPublish only inspects the tag-by-name map; no device is needed. Tags = tags, Probe = new AbLegacyProbeOptions { Enabled = false, ProbeAddress = null }, }, "drv-deadband", new FakeAbLegacyTagFactory()); await drv.InitializeAsync("{}", CancellationToken.None); return drv; } private static DataValueSnapshot Snap(object? v, uint status = Good) => new(v, status, DateTime.UtcNow, DateTime.UtcNow); [Fact] public async Task Absolute_deadband_suppresses_changes_below_threshold() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, AbsoluteDeadband: 1.0); await using var drv = await BuildDriverAsync(tag); // Walk the published flag for each sample — the asserted sequence is [10.0, 11.5]. drv.ShouldPublish("t", Snap(10.0)).ShouldBeTrue(); // first-seen drv.ShouldPublish("t", Snap(10.5)).ShouldBeFalse(); // 0.5 < 1.0 drv.ShouldPublish("t", Snap(11.5)).ShouldBeTrue(); // 1.5 from last published 10.0 drv.ShouldPublish("t", Snap(11.6)).ShouldBeFalse(); // 0.1 from last published 11.5 } [Fact] public async Task Percent_deadband_suppresses_changes_below_threshold() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, PercentDeadband: 10.0); await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap(100.0)).ShouldBeTrue(); // first-seen drv.ShouldPublish("t", Snap(105.0)).ShouldBeFalse(); // 5% < 10% drv.ShouldPublish("t", Snap(115.0)).ShouldBeTrue(); // |115-100|=15 >= 10% of 100 } [Fact] public async Task Percent_deadband_with_zero_prev_always_publishes_on_change() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, PercentDeadband: 50.0); await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap(0.0)).ShouldBeTrue(); // first-seen drv.ShouldPublish("t", Snap(0.001)).ShouldBeTrue(); // prev=0 short-circuit } [Fact] public async Task Boolean_publishes_every_transition_and_suppresses_no_op() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "B3:0/0", AbLegacyDataType.Bit, AbsoluteDeadband: 1.0); await using var drv = await BuildDriverAsync(tag); // Edge sequence false -> true -> false -> true => 4 publishes. drv.ShouldPublish("t", Snap(false)).ShouldBeTrue(); drv.ShouldPublish("t", Snap(true)).ShouldBeTrue(); drv.ShouldPublish("t", Snap(false)).ShouldBeTrue(); drv.ShouldPublish("t", Snap(true)).ShouldBeTrue(); // No-op (true -> true) suppressed. drv.ShouldPublish("t", Snap(true)).ShouldBeFalse(); } [Fact] public async Task Status_change_always_publishes_even_within_deadband() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, AbsoluteDeadband: 100.0); // ridiculously large — would normally suppress everything await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap(10.0, Good)).ShouldBeTrue(); // first-seen drv.ShouldPublish("t", Snap(10.5, Good)).ShouldBeFalse(); // within deadband drv.ShouldPublish("t", Snap(10.5, BadCommunication)).ShouldBeTrue(); // status flip drv.ShouldPublish("t", Snap(10.5, Good)).ShouldBeTrue(); // status flip back } [Fact] public async Task String_publishes_on_value_change_and_suppresses_no_op() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "ST20:0", AbLegacyDataType.String, AbsoluteDeadband: 1.0); // deadband meaningless on strings — should be ignored await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap("alpha")).ShouldBeTrue(); // first-seen drv.ShouldPublish("t", Snap("alpha")).ShouldBeFalse(); // identical drv.ShouldPublish("t", Snap("beta")).ShouldBeTrue(); // change } [Fact] public async Task Both_deadbands_set_either_triggers_publish() { // 1.0 absolute OR 50% percent — picking deltas where one passes, the other does not. var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, AbsoluteDeadband: 1.0, PercentDeadband: 50.0); await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap(2.0)).ShouldBeTrue(); // first-seen // |3.0 - 2.0| = 1.0 -> absolute passes (>= 1.0); percent test 50% of 2.0 = 1.0 -> 1.0 >= 1.0 also passes. drv.ShouldPublish("t", Snap(3.0)).ShouldBeTrue(); // After last-publish=3.0: small delta of 0.6, absolute fails (0.6 < 1.0), percent fails (0.6 < 1.5). drv.ShouldPublish("t", Snap(3.6)).ShouldBeFalse(); // |5.0 - 3.0| = 2.0; absolute passes (>= 1.0). drv.ShouldPublish("t", Snap(5.0)).ShouldBeTrue(); } [Fact] public async Task First_seen_always_publishes_even_with_huge_deadband() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, AbsoluteDeadband: 1e9); await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap(1.0)).ShouldBeTrue(); } [Fact] public async Task ReinitializeAsync_clears_last_published_cache() { var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, AbsoluteDeadband: 1.0); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [], Tags = [tag], Probe = new AbLegacyProbeOptions { Enabled = false, ProbeAddress = null }, }, "drv-reinit", new FakeAbLegacyTagFactory()); await drv.InitializeAsync("{}", CancellationToken.None); drv.ShouldPublish("t", Snap(10.0)).ShouldBeTrue(); drv.ShouldPublish("t", Snap(10.2)).ShouldBeFalse(); await drv.ReinitializeAsync("{}", CancellationToken.None); // After reinit the cache is empty — the very next sample is treated as first-seen and // publishes regardless of how close it is to the pre-reinit last-published value. drv.ShouldPublish("t", Snap(10.2)).ShouldBeTrue(); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Tag_without_deadband_publishes_every_sample_reaching_dispatcher() { // No deadband fields set — the driver passes through whatever the engine gives it. // (PollGroupEngine itself filters by value-equality, so any sample reaching ShouldPublish // is already a real change.) var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float); await using var drv = await BuildDriverAsync(tag); drv.ShouldPublish("t", Snap(1.0)).ShouldBeTrue(); drv.ShouldPublish("t", Snap(1.001)).ShouldBeTrue(); drv.ShouldPublish("t", Snap(1.002)).ShouldBeTrue(); } [Fact] public async Task Dto_round_trip_preserves_deadband_fields() { const string json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "Slc500" } ], "Tags": [ { "Name": "T1", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "F8:0", "DataType": "Float", "AbsoluteDeadband": 0.5, "PercentDeadband": 5.0 } ] } """; var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-json", json); try { await drv.InitializeAsync(json, CancellationToken.None); // ShouldPublish exercises the deadband — confirms the DTO values landed on the // AbLegacyTagDefinition. drv.ShouldPublish("T1", Snap(100.0)).ShouldBeTrue(); // first-seen drv.ShouldPublish("T1", Snap(100.4)).ShouldBeFalse(); // 0.4 < 0.5 absolute drv.ShouldPublish("T1", Snap(101.0)).ShouldBeTrue(); // 1.0 >= 0.5 absolute } finally { await drv.DisposeAsync(); } } }