Auto: abcip-4.1 — per-tag scan rate / scan group bucketing

Closes #238
This commit is contained in:
Joseph Doherty
2026-04-26 02:15:50 -04:00
parent e5c38a5a0e
commit b45713622f
8 changed files with 761 additions and 7 deletions

View File

@@ -0,0 +1,312 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-4.1 — unit coverage for per-tag <see cref="AbCipTagDefinition.ScanRateMs"/>
/// bucketing in <see cref="AbCipDriver.SubscribeAsync"/>. Each test wires a small tag map
/// and asserts the driver spins up exactly the expected number of internal poll-engine
/// subscriptions by inspecting the test-only <c>GetSubscriptionBucketCount</c> entry
/// point. End-to-end tick cadence is covered by
/// <c>AbCipPerTagScanRateTests</c> in the IntegrationTests project.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipPerTagScanRateTests
{
private static AbCipDriver NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = 0 },
};
return new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
}, "drv-scan", factory);
}
[Fact]
public async Task Two_tags_with_distinct_ScanRate_produce_two_buckets()
{
var drv = NewDriver(
new AbCipTagDefinition("Fast", "ab://10.0.0.5/1,0", "Fast", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt, ScanRateMs: 1000));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Fast", "Slow"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(2,
"two distinct ScanRateMs values must produce two separate poll-engine subscriptions");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tag_without_ScanRate_uses_subscription_default()
{
var drv = NewDriver(
new AbCipTagDefinition("PlainA", "ab://10.0.0.5/1,0", "PlainA", AbCipDataType.DInt),
new AbCipTagDefinition("PlainB", "ab://10.0.0.5/1,0", "PlainB", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["PlainA", "PlainB"], TimeSpan.FromMilliseconds(750), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"no ScanRateMs configured → single default bucket (legacy behaviour)");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tag_with_ScanRate_equal_to_default_collapses_into_default_bucket()
{
// The driver buckets by resolved TimeSpan, not by "did the tag declare a rate" — a tag
// whose ScanRateMs matches the subscription default lands in the same bucket as plain
// tags. This avoids fragmenting the poll loop when the override is redundant.
var drv = NewDriver(
new AbCipTagDefinition("Plain", "ab://10.0.0.5/1,0", "Plain", AbCipDataType.DInt),
new AbCipTagDefinition("Match", "ab://10.0.0.5/1,0", "Match", AbCipDataType.DInt, ScanRateMs: 500));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Plain", "Match"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"ScanRateMs=500 with default=500ms must collapse into the same bucket");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task All_tags_at_same_explicit_rate_produce_one_bucket()
{
var drv = NewDriver(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt, ScanRateMs: 250),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, ScanRateMs: 250),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt, ScanRateMs: 250));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"three tags at the same ScanRateMs share one bucket");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Three_distinct_rates_produce_three_buckets()
{
var drv = NewDriver(
new AbCipTagDefinition("Fast", "ab://10.0.0.5/1,0", "Fast", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Medium", "ab://10.0.0.5/1,0", "Medium", AbCipDataType.DInt, ScanRateMs: 500),
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt, ScanRateMs: 5000));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Fast", "Medium", "Slow"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(3);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Mixed_overrides_and_default_produce_correct_bucket_count()
{
// Two fast (100 ms), three default (=1000 ms): 2 buckets.
var drv = NewDriver(
new AbCipTagDefinition("Hmi1", "ab://10.0.0.5/1,0", "Hmi1", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Hmi2", "ab://10.0.0.5/1,0", "Hmi2", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Slow1", "ab://10.0.0.5/1,0", "Slow1", AbCipDataType.DInt),
new AbCipTagDefinition("Slow2", "ab://10.0.0.5/1,0", "Slow2", AbCipDataType.DInt),
new AbCipTagDefinition("Slow3", "ab://10.0.0.5/1,0", "Slow3", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(
["Hmi1", "Hmi2", "Slow1", "Slow2", "Slow3"],
TimeSpan.FromMilliseconds(1000),
CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(2);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Test fake whose <see cref="DecodeValue"/> returns a strictly-monotonic counter so
/// every poll registers as a "change" against <see cref="PollGroupEngine"/>'s
/// boxed-equality diff. Lets us count actual poll cadence end-to-end.
/// </summary>
private sealed class CountingFakeTag : FakeAbCipTag
{
private int _readCount;
public CountingFakeTag(AbCipTagCreateParams p) : base(p) { }
public override Task ReadAsync(CancellationToken ct)
{
Interlocked.Increment(ref _readCount);
return base.ReadAsync(ct);
}
public override object? DecodeValue(AbCipDataType type, int? bitIndex) => _readCount;
}
[Fact]
public async Task Faster_bucket_publishes_more_frequently_than_slower_bucket()
{
// End-to-end timing assertion against an in-process fake: a 100 ms tag must
// accumulate substantially more DataChange events than a 1000 ms tag over a 1.2 s
// window. We force every poll to register as a *change* by returning a strictly
// monotonic counter (CountingFakeTag) so the diff path doesn't suppress events.
var factory = new FakeAbCipTagFactory
{
Customise = p => new CountingFakeTag(p),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Fast", "ab://10.0.0.5/1,0", "Fast", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt, ScanRateMs: 1000),
],
}, "drv-scan-cadence", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var fastEvents = 0;
var slowEvents = 0;
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == "Fast") Interlocked.Increment(ref fastEvents);
else if (e.FullReference == "Slow") Interlocked.Increment(ref slowEvents);
};
var handle = await drv.SubscribeAsync(
["Fast", "Slow"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
await Task.Delay(TimeSpan.FromMilliseconds(1200));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
// Fast at 100 ms: ~12 polls in 1.2 s. Slow at 1000 ms: ~2 polls (initial + 1 tick).
// Demand at least 4x — generous against jitter, definitive enough to prove buckets
// tick independently.
fastEvents.ShouldBeGreaterThan(slowEvents * 4,
$"fast bucket ({fastEvents} events) must outpace slow bucket ({slowEvents} events) by >4x");
}
[Fact]
public async Task Unsubscribe_disposes_every_bucket_subscription()
{
// Multi-bucket unsubscribe must not leak any inner poll-engine handle. We assert this
// by checking the post-unsubscribe bucket count is zero (composite handle purged) and
// by counting the engine's ActiveSubscriptionCount via reflection.
var drv = NewDriver(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, ScanRateMs: 200),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt, ScanRateMs: 500));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(3);
var pollField = typeof(AbCipDriver).GetField("_poll",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var poll = (PollGroupEngine)pollField!.GetValue(drv)!;
poll.ActiveSubscriptionCount.ShouldBe(3, "three buckets → three live engine subscriptions");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
poll.ActiveSubscriptionCount.ShouldBe(0, "every bucket subscription must be torn down on unsubscribe");
// Idempotent: a second unsubscribe must be a no-op, not throw.
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public void Dto_round_trip_preserves_ScanRateMs()
{
// The DTO surface must round-trip ScanRateMs through JSON so a config file edited via
// the Admin UI or hand-written JSON keeps the per-tag rate intact across redeploys.
const string configJson = """
{
"Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "ControlLogix" } ],
"Tags": [
{ "Name": "Fast", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Fast", "DataType": "DInt", "ScanRateMs": 100 },
{ "Name": "Slow", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Slow", "DataType": "DInt", "ScanRateMs": 1000 },
{ "Name": "Plain", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Plain", "DataType": "DInt" }
]
}
""";
var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-roundtrip", configJson);
// Reach into the driver's options via reflection to check the Tag definitions hold ScanRateMs.
var optionsField = typeof(AbCipDriver).GetField("_options",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var opts = (AbCipDriverOptions)optionsField!.GetValue(driver)!;
opts.Tags.Single(t => t.Name == "Fast").ScanRateMs.ShouldBe(100);
opts.Tags.Single(t => t.Name == "Slow").ScanRateMs.ShouldBe(1000);
opts.Tags.Single(t => t.Name == "Plain").ScanRateMs.ShouldBeNull();
}
[Fact]
public async Task Negative_or_zero_ScanRate_is_treated_as_unset()
{
// Mis-typed config (ScanRateMs: 0 or -1) must NOT crash the driver — fall back to the
// subscription default. Same "config typo degrades" stance the factory layer takes.
var drv = NewDriver(
new AbCipTagDefinition("Zero", "ab://10.0.0.5/1,0", "Zero", AbCipDataType.DInt, ScanRateMs: 0),
new AbCipTagDefinition("Neg", "ab://10.0.0.5/1,0", "Neg", AbCipDataType.DInt, ScanRateMs: -1),
new AbCipTagDefinition("Plain", "ab://10.0.0.5/1,0", "Plain", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Zero", "Neg", "Plain"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"0 + negative ScanRateMs must collapse into the subscription default bucket");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Udt_member_inherits_parent_ScanRate()
{
// The driver fans out UDT members at InitializeAsync — each member tag inherits the
// parent's ScanRateMs so an entire UDT subscribed at 100 ms publishes coherently.
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = 0 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition(
"Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)],
ScanRateMs: 250),
],
}, "drv-udt-inherit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Subscribing the member tag alone must use the parent's 250 ms rate, not the default.
var handle = await drv.SubscribeAsync(["Motor1.Speed"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1);
drv.ResolveTagInterval("Motor1.Speed", TimeSpan.FromMilliseconds(1000))
.ShouldBe(TimeSpan.FromMilliseconds(250));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
}