Auto: twincat-3.1 — per-tag MaxDelay tuning

Closes #313
This commit is contained in:
Joseph Doherty
2026-04-26 01:45:12 -04:00
parent 621de94126
commit fb57717f6f
11 changed files with 261 additions and 9 deletions

View File

@@ -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; }

View File

@@ -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()
{