Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeadbandTests.cs
2026-04-25 23:50:07 -04:00

217 lines
9.2 KiB
C#

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;
/// <summary>
/// PR 8 — per-tag deadband / change filter tests. The driver layers the deadband on top of
/// <see cref="PollGroupEngine"/>'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 <c>OnDataChange</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDeadbandTests
{
private const uint Good = 0x00000000u;
private const uint BadCommunication = 0x80050000u;
private static async Task<AbLegacyDriver> 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();
}
}
}