@@ -0,0 +1,83 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 3.1 (#313) — per-tag <c>MaxDelay</c> coalescing observed on the wire. Subscribes
|
||||
/// to <c>GVL_Fixture.nCounter</c> (the cycle-incrementing fixture counter) with a
|
||||
/// 500 ms <c>MaxDelayMs</c>; over a 1 s observation window the runtime should batch
|
||||
/// the per-cycle changes into ≤ 3 callbacks rather than one-per-PLC-cycle (~100 with a
|
||||
/// 10 ms task) which is what <c>MaxDelay=0</c> would deliver.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Skipped via <see cref="TwinCATFactAttribute"/> when the XAR VM isn't reachable
|
||||
/// / env vars aren't set. Build-only proof in CI; full runtime cover happens on the
|
||||
/// XAR-equipped lab box.</para>
|
||||
///
|
||||
/// <para>The 1 s window + ≤ 3 events tolerance is generous on purpose — the runtime's
|
||||
/// coalescer can still fire mid-window when the cycle accumulates a value-change burst,
|
||||
/// and the test mostly cares that <i>coalescing happens at all</i> (i.e. it's
|
||||
/// dramatically less than the no-coalescing baseline).</para>
|
||||
/// </remarks>
|
||||
[Collection("TwinCATXar")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "TwinCAT-XAR")]
|
||||
public sealed class TwinCATMaxDelayTests(TwinCATXarFixture sim)
|
||||
{
|
||||
[TwinCATFact]
|
||||
public async Task Driver_coalesces_notifications_at_max_delay()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildOptions(sim);
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-maxdelay");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var observed = new List<DateTime>();
|
||||
drv.OnDataChange += (_, _) =>
|
||||
{
|
||||
lock (observed) observed.Add(DateTime.UtcNow);
|
||||
};
|
||||
|
||||
var handle = await drv.SubscribeAsync(
|
||||
["Counter"], TimeSpan.FromMilliseconds(50),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Observe for 1 s. With MAIN incrementing nCounter every 10 ms PLC cycle, the
|
||||
// un-coalesced rate would be ~100 events; with MaxDelay=500 ms we expect ≤ 3
|
||||
// (initial fire + at most two coalesce-window flushes).
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1000), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
|
||||
int count;
|
||||
lock (observed) count = observed.Count;
|
||||
count.ShouldBeLessThanOrEqualTo(
|
||||
3,
|
||||
$"MaxDelayMs=500 must coalesce nCounter changes — observed {count} events in 1 s");
|
||||
}
|
||||
|
||||
private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new()
|
||||
{
|
||||
Devices = [
|
||||
new TwinCATDeviceOptions(
|
||||
HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
DeviceName: "XAR-VM"),
|
||||
],
|
||||
Tags = [
|
||||
new TwinCATTagDefinition(
|
||||
Name: "Counter",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Fixture.nCounter",
|
||||
DataType: TwinCATDataType.DInt,
|
||||
MaxDelayMs: 500),
|
||||
],
|
||||
UseNativeNotifications = true,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
// Disable probe so it doesn't race with the 1 s observation window.
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,12 @@ GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
|
||||
- `PlcTask` — cyclic, 10 ms interval, priority 20
|
||||
- Assigned to `MAIN`
|
||||
|
||||
> **Note (PR 3.1 / #313)**: `GVL_Fixture.nCounter` doubles as the
|
||||
> coalescing-test driver for `TwinCATMaxDelayTests`. The 10 ms cycle +
|
||||
> per-cycle increment in `MAIN` means a no-coalescing subscriber sees ~100
|
||||
> events / s; with `MaxDelayMs=500` the test asserts ≤ 3 events / s. No new
|
||||
> project state required.
|
||||
|
||||
## Performance scenarios
|
||||
|
||||
PR 2.1 (ADS Sum-read / Sum-write) ships an opt-in perf-tier integration test
|
||||
|
||||
@@ -315,12 +315,13 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
|
||||
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnAddNotification)
|
||||
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
|
||||
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, this);
|
||||
Notifications.Add(reg);
|
||||
return Task.FromResult<ITwinCATNotificationHandle>(reg);
|
||||
}
|
||||
@@ -352,11 +353,16 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
|
||||
public sealed class FakeNotification(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex,
|
||||
TimeSpan cycleTime, int maxDelayMs,
|
||||
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
|
||||
{
|
||||
public string SymbolPath { get; } = symbolPath;
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
public int? BitIndex { get; } = bitIndex;
|
||||
/// <summary>Cycle time the driver requested (PR 3.1 — captured for tests).</summary>
|
||||
public TimeSpan CycleTime { get; } = cycleTime;
|
||||
/// <summary>Per-tag MaxDelay in ms (PR 3.1 / #313). 0 = no coalescing.</summary>
|
||||
public int MaxDelayMs { get; } = maxDelayMs;
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
|
||||
@@ -137,12 +137,13 @@ public sealed class TwinCATNativeNotificationTests
|
||||
|
||||
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (AddCallCount > _succeedBefore)
|
||||
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken);
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +188,84 @@ public sealed class TwinCATNativeNotificationTests
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- PR 3.1 (#313) — per-tag MaxDelay tuning ----
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_default_MaxDelay_is_zero_per_tag()
|
||||
{
|
||||
// No MaxDelayMs on the tag → fake captures 0 (the pre-PR-3.1 "fire ASAP" default).
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(1);
|
||||
factory.Clients[0].Notifications[0].MaxDelayMs.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_plumbs_per_tag_MaxDelayMs_into_NotificationSettings()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt,
|
||||
MaxDelayMs: 500));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(1);
|
||||
factory.Clients[0].Notifications[0].MaxDelayMs.ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_per_tag_MaxDelay_values_flow_independently()
|
||||
{
|
||||
// Mixing tags in a single Subscribe call: each tag's MaxDelayMs lands on its own
|
||||
// NotificationSettings, no cross-talk between subscriptions.
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt,
|
||||
MaxDelayMs: 250),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt,
|
||||
MaxDelayMs: 1000),
|
||||
new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(50), CancellationToken.None);
|
||||
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(3);
|
||||
var bySymbol = factory.Clients[0].Notifications
|
||||
.ToDictionary(n => n.SymbolPath, n => n.MaxDelayMs, StringComparer.OrdinalIgnoreCase);
|
||||
bySymbol["MAIN.A"].ShouldBe(250);
|
||||
bySymbol["MAIN.B"].ShouldBe(1000);
|
||||
bySymbol["MAIN.C"].ShouldBe(0); // unset = default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagDefinition_MaxDelayMs_default_is_null()
|
||||
{
|
||||
// Defaulting MaxDelayMs on the record itself — preserves the call sites that don't
|
||||
// set it (every existing TwinCATTagDefinition usage in tests + production).
|
||||
var def = new TwinCATTagDefinition(
|
||||
"X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt);
|
||||
def.MaxDelayMs.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagDefinition_MaxDelayMs_round_trips_via_record_with()
|
||||
{
|
||||
// The `with` expression is the closest thing to a DTO round-trip at this layer —
|
||||
// confirms the new property is a regular init member that participates in equality
|
||||
// / copy semantics. Caller-side serialisation layers (server / admin) layer their
|
||||
// own DTOs on top of this record.
|
||||
var def = new TwinCATTagDefinition(
|
||||
"X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt);
|
||||
var withDelay = def with { MaxDelayMs = 750 };
|
||||
withDelay.MaxDelayMs.ShouldBe(750);
|
||||
// sanity — original is untouched
|
||||
def.MaxDelayMs.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user