Auto: abcip-4.2 — write deadband / write-on-change

Closes #239
This commit is contained in:
Joseph Doherty
2026-04-26 02:31:50 -04:00
parent 9202ebe5ef
commit da9936f7f0
9 changed files with 855 additions and 5 deletions

View File

@@ -0,0 +1,87 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// PR abcip-4.2 — end-to-end coverage for write-deadband / write-on-change suppression
/// against a running <c>ab_server</c>. Drives a 5-write jittery sequence with
/// <c>WriteDeadband=1.0</c> and asserts the driver's <c>AbCip.WritesSuppressed</c>
/// diagnostics counter reflects the expected number of suppressions. Wire-level write
/// count isn't directly observable in <c>ab_server</c> (no admin shim for "tell me how
/// many CIP writes you got"), so the suppression evidence is the driver's own counter
/// plus the final read confirming the last passed-through value reached the PLC.
/// </summary>
/// <remarks>
/// Unit coverage in <see cref="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipWriteDeadbandTests"/>
/// proves the suppression math against an in-process fake. This test exercises the full
/// libplctag stack so a regression in how the driver wires its coalescer to the real wire
/// path shows up here.
/// </remarks>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipWriteDeadbandTests
{
[AbServerFact]
public async Task Jittery_setpoints_within_deadband_dont_reach_the_wire()
{
var profile = KnownProfiles.ControlLogix;
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
// ab_server seeds TestDINT — drive integer setpoints with a 1.0 deadband so
// values that differ by 0 are suppressed. Real-world deadbanding usually
// targets REAL setpoints; integer here is fine because the suppression rule
// looks at the boxed numeric value, not the on-wire encoding.
Tags = [new AbCipTagDefinition("Setpoint", deviceUri, "TestDINT",
AbCipDataType.DInt, WriteDeadband: 1.0)],
Timeout = TimeSpan.FromSeconds(5),
}, "drv-write-deadband-smoke");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Five-write jittery sequence: 100, 100, 100, 102, 102.
// - 100 (first): passes (no prior).
// - 100, 100: suppressed (|0| < 1.0).
// - 102: passes (|2| ≥ 1.0).
// - 102: suppressed (|0| < 1.0).
// Expected: 2 wire writes, 3 suppressions.
var inputs = new[] { 100, 100, 100, 102, 102 };
foreach (var v in inputs)
{
var results = await drv.WriteAsync(
[new WriteRequest("Setpoint", v)],
TestContext.Current.CancellationToken);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good,
"every write — suppressed or not — must surface as Good to the OPC UA client");
}
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(3);
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2);
// Final readback proves the last passed-through value (102) made it to the PLC.
var readback = await drv.ReadAsync(["Setpoint"], TestContext.Current.CancellationToken);
readback.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
Convert.ToInt32(readback.Single().Value).ShouldBe(102);
// Diagnostics counters are also reflected through GetHealth — the path the
// driver-diagnostics RPC + Admin UI consume.
var diag = drv.GetHealth().DiagnosticsOrEmpty;
diag["AbCip.WritesSuppressed"].ShouldBe(3);
diag["AbCip.WritesPassedThrough"].ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
finally
{
await fixture.DisposeAsync();
}
}
}