using System.Text.Json; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// PR-S7-C4 — unit coverage for the deadband / on-change filter /// (). The filter is a pure function so the tests /// drive every decision branch (absolute, percent, both, sign flip, NaN, ±Inf, /// near-zero baseline, non-numeric) without spinning up the partition / poll-loop /// machinery. End-to-end deadband behaviour through PollOnceAsync is implicit /// — the same helper is the only suppressor in the publish path. /// [Trait("Category", "Unit")] public sealed class S7DeadbandTests { private static S7TagDefinition Tag(double? abs = null, double? pct = null, S7DataType type = S7DataType.Float32, string name = "T") => new(name, "DB1.DBD0", type, DeadbandAbsolute: abs, DeadbandPercent: pct); // ---- DeadbandAbsolute -------------------------------------------------- [Fact] public void Absolute_deadband_walks_the_canonical_sequence() { // DeadbandAbsolute=1.0, samples [10.0, 10.5, 11.5, 11.6] → publishes [10.0, 11.5]. // We feed the helper "prev → current" pairs after the first-publish bootstrap. var tag = Tag(abs: 1.0); // 10.0 → 10.5: |delta| = 0.5 < 1.0 → suppress S7Driver.ShouldPublish(tag, 10.0, 10.5).ShouldBeFalse(); // 10.0 → 11.5: |delta| = 1.5 ≥ 1.0 → publish (prev still 10.0 because last suppressed) S7Driver.ShouldPublish(tag, 10.0, 11.5).ShouldBeTrue(); // 11.5 → 11.6: |delta| = 0.1 < 1.0 → suppress S7Driver.ShouldPublish(tag, 11.5, 11.6).ShouldBeFalse(); } [Fact] public void Absolute_deadband_at_threshold_publishes() { // delta == threshold counts as "publish" (Kepware-aligned ≥ semantics). var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, 10.0, 11.0).ShouldBeTrue(); } // ---- DeadbandPercent --------------------------------------------------- [Fact] public void Percent_deadband_walks_the_canonical_sequence() { // DeadbandPercent=10, samples [100, 105, 115] → publishes [100, 115]. var tag = Tag(pct: 10); // 100 → 105: |delta|=5 < 10% of 100 (= 10) → suppress S7Driver.ShouldPublish(tag, 100.0, 105.0).ShouldBeFalse(); // 100 → 115: |delta|=15 ≥ 10 → publish S7Driver.ShouldPublish(tag, 100.0, 115.0).ShouldBeTrue(); } [Fact] public void Percent_deadband_negative_baseline_uses_absolute_value() { // |prev|=100, threshold = |delta| ≥ 10. Negative baseline doesn't flip the sign // of the threshold. var tag = Tag(pct: 10); S7Driver.ShouldPublish(tag, -100.0, -95.0).ShouldBeFalse(); // delta=5 < 10 S7Driver.ShouldPublish(tag, -100.0, -85.0).ShouldBeTrue(); // delta=15 ≥ 10 } // ---- Both deadbands set: OR semantics --------------------------------- [Fact] public void Both_set_publishes_when_either_threshold_triggers() { // abs=10 fails (delta=5<10), pct=2% with |prev|=100 → threshold=2, delta=5 ≥ 2 → publish. var tag = Tag(abs: 10, pct: 2); S7Driver.ShouldPublish(tag, 100.0, 105.0).ShouldBeTrue(); } [Fact] public void Both_set_suppresses_when_neither_triggers() { // abs=10 fails (delta=1<10), pct=10% of 100 = 10 fails (1<10) → both suppress → suppress. var tag = Tag(abs: 10, pct: 10); S7Driver.ShouldPublish(tag, 100.0, 101.0).ShouldBeFalse(); } [Fact] public void Both_set_publishes_when_only_absolute_triggers() { // abs=1 passes (delta=2 ≥ 1), pct=5% of 100 = 5 fails (2 < 5) → publish (OR). var tag = Tag(abs: 1, pct: 5); S7Driver.ShouldPublish(tag, 100.0, 102.0).ShouldBeTrue(); } // ---- First sample / null prev ------------------------------------------ // // PollOnceAsync handles "no prior sample" via the hasPrev check and forces publish. // ShouldPublish is only entered when there IS a prev — but defensively, a null prev // with deadbands set should still publish (an absent baseline isn't a deadband miss). [Fact] public void Null_prev_with_deadbands_falls_back_to_inequality() { // null != 5.0 → publish. The helper isn't on the first-sample path in the live // poll loop (PollOnceAsync gates that out), but a null prev arriving here means // "no usable baseline" and the safe answer is publish. var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, null, 5.0).ShouldBeTrue(); } [Fact] public void No_deadband_falls_back_to_exact_equality() { var tag = Tag(); S7Driver.ShouldPublish(tag, 5.0, 5.0).ShouldBeFalse(); S7Driver.ShouldPublish(tag, 5.0, 5.001).ShouldBeTrue(); } [Fact] public void Null_tag_def_falls_back_to_exact_equality() { // Defensive path — no tag def in the map (test seed gap). Behaves like legacy. S7Driver.ShouldPublish(null, 5.0, 5.0).ShouldBeFalse(); S7Driver.ShouldPublish(null, 5.0, 6.0).ShouldBeTrue(); } // ---- Non-numeric types ignore deadband -------------------------------- [Fact] public void Non_numeric_string_ignores_deadband() { var tag = new S7TagDefinition("S", "DB1.DBB0", S7DataType.String, DeadbandAbsolute: 1.0, DeadbandPercent: 10); S7Driver.ShouldPublish(tag, "hello", "hello").ShouldBeFalse(); S7Driver.ShouldPublish(tag, "hello", "world").ShouldBeTrue(); } [Fact] public void Non_numeric_bool_ignores_deadband() { var tag = new S7TagDefinition("B", "M0.0", S7DataType.Bool, DeadbandAbsolute: 1.0, DeadbandPercent: 10); S7Driver.ShouldPublish(tag, false, false).ShouldBeFalse(); S7Driver.ShouldPublish(tag, false, true).ShouldBeTrue(); } [Fact] public void Non_numeric_byte_array_ignores_deadband() { var tag = Tag(abs: 1.0); var prev = new byte[] { 1, 2, 3 }; var same = new byte[] { 1, 2, 3 }; var diff = new byte[] { 1, 2, 4 }; // byte[] equality is reference, so even "same content" goes through the // !Equals fallback — which is the behaviour PollOnceAsync had before C4 // (legacy semantics preserved for non-numeric). S7Driver.ShouldPublish(tag, prev, prev).ShouldBeFalse(); S7Driver.ShouldPublish(tag, prev, same).ShouldBeTrue(); S7Driver.ShouldPublish(tag, prev, diff).ShouldBeTrue(); } // ---- NaN / Infinity edges --------------------------------------------- [Fact] public void NaN_current_publishes() { var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, 10.0, double.NaN).ShouldBeTrue(); } [Fact] public void NaN_prev_publishes() { var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, double.NaN, 10.0).ShouldBeTrue(); } [Fact] public void Positive_infinity_prev_publishes() { var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, double.PositiveInfinity, 10.0).ShouldBeTrue(); } [Fact] public void Negative_infinity_current_publishes() { var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, 10.0, double.NegativeInfinity).ShouldBeTrue(); } // ---- Sign flip --------------------------------------------------------- [Fact] public void Sign_flip_with_absolute_deadband_publishes() { // 10 → -10, abs=1 → |delta|=20 ≥ 1 → publish. var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, 10.0, -10.0).ShouldBeTrue(); } // ---- Near-zero baseline ----------------------------------------------- [Fact] public void Near_zero_baseline_with_percent_only_publishes() { // |prev|=1e-7 < 1e-6 → fall back to absolute. None set → publish. var tag = Tag(pct: 10); S7Driver.ShouldPublish(tag, 1e-7, 5.0).ShouldBeTrue(); } [Fact] public void Near_zero_baseline_with_percent_and_absolute_uses_absolute() { // |prev|=1e-7 < 1e-6 → percent rule disabled, absolute kicks in. var tag = Tag(abs: 10.0, pct: 10); // delta=5 < abs=10 → suppress S7Driver.ShouldPublish(tag, 1e-7, 5.0).ShouldBeFalse(); // delta=15 ≥ abs=10 → publish S7Driver.ShouldPublish(tag, 1e-7, 15.0).ShouldBeTrue(); } [Fact] public void Zero_baseline_with_percent_only_publishes_any_change() { // |prev|=0 < 1e-6 → near-zero rule, no abs → publish on any change. var tag = Tag(pct: 10); S7Driver.ShouldPublish(tag, 0.0, 0.0001).ShouldBeTrue(); } // ---- Mixed numeric types coerce to double ----------------------------- [Fact] public void Int_and_float_compare_via_double_coercion() { // prev as Int16, current as float — both coerce to double. var tag = Tag(abs: 1.0); S7Driver.ShouldPublish(tag, (short)10, 10.5f).ShouldBeFalse(); // |delta|=0.5 < 1 S7Driver.ShouldPublish(tag, (short)10, 12f).ShouldBeTrue(); // |delta|=2 ≥ 1 } // ---- DTO JSON round-trip ---------------------------------------------- [Fact] public void Tag_dto_json_round_trip_preserves_deadband_fields() { const string json = """ { "Host": "192.0.2.10", "Tags": [ { "Name": "Pressure", "Address": "DB1.DBD0", "DataType": "Float32", "DeadbandAbsolute": 0.5, "DeadbandPercent": 2.5 } ] } """; var driver = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-deadband", json); // Reach into _tagsByName via reflection — same trick the partitioning tests use. var field = typeof(S7Driver).GetField("_tagsByName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); field.ShouldNotBeNull(); // Pre-init: dictionary is empty until InitializeAsync. Build the tag directly via // a JsonSerializer round-trip on the DTO instead so we don't have to spin up TCP. var dtoOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; using var doc = JsonDocument.Parse(json); var tagJson = doc.RootElement.GetProperty("Tags")[0].GetRawText(); // The DTO type is internal; round-trip through a public projection instead. var probe = JsonSerializer.Deserialize(tagJson, dtoOptions); probe.GetProperty("DeadbandAbsolute").GetDouble().ShouldBe(0.5); probe.GetProperty("DeadbandPercent").GetDouble().ShouldBe(2.5); // And the actual driver factory must have plumbed both knobs onto the options so // the live tag definition carries them — interrogate via reflection. var optsField = typeof(S7Driver).GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); optsField.ShouldNotBeNull(); var opts = (S7DriverOptions)optsField!.GetValue(driver)!; opts.Tags.Count.ShouldBe(1); opts.Tags[0].DeadbandAbsolute.ShouldBe(0.5); opts.Tags[0].DeadbandPercent.ShouldBe(2.5); driver.Dispose(); } }