Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadbandTests.cs
2026-04-26 01:14:59 -04:00

292 lines
11 KiB
C#

using System.Text.Json;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// PR-S7-C4 — unit coverage for the deadband / on-change filter
/// (<see cref="S7Driver.ShouldPublish"/>). 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 <c>PollOnceAsync</c> is implicit
/// — the same helper is the only suppressor in the publish path.
/// </summary>
[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<JsonElement>(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();
}
}