Auto: twincat-3.2 — cycle-time / jitter / PLC-state diagnostics
Closes #314
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 3.2 / issue #314 — unit-level coverage for the cycle-time / jitter / online-change
|
||||
/// diagnostics surfaced by the probe loop. The probe-loop scheduler is hard to drive
|
||||
/// deterministically (it has its own <see cref="Task.Delay"/>), so these tests invoke the
|
||||
/// internal <see cref="TwinCATDriver.SampleDeviceDiagnosticsAsync"/> directly with seeded
|
||||
/// values on the <see cref="FakeTwinCATClient"/>. Wire-level coverage lives in the
|
||||
/// integration suite (<c>TwinCATDiagnosticsIntegrationTests</c>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATDeviceDiagnosticsTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
[Fact]
|
||||
public async Task SampleDeviceDiagnosticsAsync_populates_LastDiagnostics_with_seeded_values()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
// Seed: AppName "Plc1", OnlineChangeCnt 7, CycleTime 100_000 ticks (10 ms),
|
||||
// LastExecTime 35_000 ticks (3.5 ms) → JitterMs = 3.5 - 10.0 = -6.5 (healthy).
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 7u);
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 100_000u);
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 35_000u);
|
||||
|
||||
await using var drv = BuildDriverWithClient(fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
|
||||
|
||||
var diags = drv.GetDeviceDiagnostics(Host).ShouldNotBeNull();
|
||||
diags.AppName.ShouldBe("Plc1");
|
||||
diags.OnlineChangeCnt.ShouldBe(7u);
|
||||
diags.CycleTimeMs.ShouldBe(10.0, tolerance: 0.001);
|
||||
diags.LastExecTimeMs.ShouldBe(3.5, tolerance: 0.001);
|
||||
diags.JitterMs.ShouldBe(-6.5, tolerance: 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleDeviceDiagnosticsAsync_skips_failing_TaskInfo_read_without_failing_probe()
|
||||
{
|
||||
// Older TC2 / soft-PLC builds may not surface _TaskInfo[1]; the probe loop should still
|
||||
// produce a partial snapshot rather than failing the whole tick.
|
||||
var fake = new FakeTwinCATClient();
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 3u);
|
||||
// CycleTime + LastExecTime not seeded; force them to surface as Bad so the probe path
|
||||
// exercises the per-symbol try/catch.
|
||||
fake.FailNextReadOf(TwinCATDriver.SymCycleTime);
|
||||
fake.FailNextReadOf(TwinCATDriver.SymLastExecTime);
|
||||
|
||||
await using var drv = BuildDriverWithClient(fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
|
||||
|
||||
var diags = drv.GetDeviceDiagnostics(Host).ShouldNotBeNull();
|
||||
diags.AppName.ShouldBe("Plc1");
|
||||
diags.OnlineChangeCnt.ShouldBe(3u);
|
||||
// Failed reads leave the previous-tick value (or 0 on the first tick) — both are
|
||||
// acceptable since the API guarantees the field is best-effort.
|
||||
diags.CycleTimeMs.ShouldBe(0.0);
|
||||
diags.LastExecTimeMs.ShouldBe(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleDeviceDiagnosticsAsync_OnlineChangeCnt_increment_surfaces_in_diagnostics_dictionary()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 2u);
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 100_000u);
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 50_000u);
|
||||
|
||||
await using var drv = BuildDriverWithClient(fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
|
||||
drv.OnlineChangeIncrementsObserved.ShouldBe(0,
|
||||
"first tick has no prior snapshot to diff against");
|
||||
|
||||
// Operator reloads the project; runtime increments OnlineChangeCnt by 3.
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 5u);
|
||||
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
|
||||
|
||||
drv.OnlineChangeIncrementsObserved.ShouldBe(3);
|
||||
|
||||
var diagsAfter = drv.GetDeviceDiagnostics(Host).ShouldNotBeNull();
|
||||
diagsAfter.OnlineChangeCnt.ShouldBe(5u);
|
||||
|
||||
// Aggregate dict on _health surfaces both the latest snapshot + the cumulative
|
||||
// increment counter so the cross-driver RPC can render either view.
|
||||
var health = drv.GetHealth();
|
||||
var dict = health.DiagnosticsOrEmpty;
|
||||
dict["TwinCAT.OnlineChangeCnt"].ShouldBe(5.0);
|
||||
dict["TwinCAT.OnlineChangeIncrements"].ShouldBe(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleDeviceDiagnosticsAsync_folds_snapshot_into_DriverHealth_diagnostics()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 11u);
|
||||
// 2 ms cycle / 5 ms exec → 3 ms jitter (overrunning).
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 20_000u);
|
||||
fake.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 50_000u);
|
||||
|
||||
await using var drv = BuildDriverWithClient(fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
|
||||
|
||||
var dict = drv.GetHealth().DiagnosticsOrEmpty;
|
||||
dict["TwinCAT.OnlineChangeCnt"].ShouldBe(11.0);
|
||||
dict["TwinCAT.CycleTimeMs"].ShouldBe(2.0, tolerance: 0.001);
|
||||
dict["TwinCAT.LastExecTimeMs"].ShouldBe(5.0, tolerance: 0.001);
|
||||
dict["TwinCAT.JitterMs"].ShouldBe(3.0, tolerance: 0.001);
|
||||
dict.ShouldContainKey("TwinCAT.DiagnosticsUpdatedAtTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleDeviceDiagnosticsAsync_multiple_devices_get_host_prefixed_keys()
|
||||
{
|
||||
const string Host2 = "ads://10.0.0.1.1.1:852";
|
||||
var fake1 = new FakeTwinCATClient();
|
||||
fake1.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
|
||||
fake1.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 1u);
|
||||
fake1.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 10_000u);
|
||||
fake1.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 5_000u);
|
||||
|
||||
var fake2 = new FakeTwinCATClient();
|
||||
fake2.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc2");
|
||||
fake2.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 9u);
|
||||
fake2.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 100_000u);
|
||||
fake2.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 80_000u);
|
||||
|
||||
var queue = new Queue<FakeTwinCATClient>([fake1, fake2]);
|
||||
var factory = new FakeTwinCATClientFactory { Customise = () => queue.Dequeue() };
|
||||
await using var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [
|
||||
new TwinCATDeviceOptions(Host),
|
||||
new TwinCATDeviceOptions(Host2),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-multi", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var s1 = drv.GetDeviceState(Host)!;
|
||||
var s2 = drv.GetDeviceState(Host2)!;
|
||||
await fake1.ConnectAsync(s1.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
await fake2.ConnectAsync(s2.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.SampleDeviceDiagnosticsAsync(s1, fake1, TestContext.Current.CancellationToken);
|
||||
await drv.SampleDeviceDiagnosticsAsync(s2, fake2, TestContext.Current.CancellationToken);
|
||||
|
||||
var dict = drv.GetHealth().DiagnosticsOrEmpty;
|
||||
// Multi-device: keys are prefixed with the host address so the cross-driver RPC
|
||||
// can disambiguate.
|
||||
dict[$"TwinCAT.{Host}.OnlineChangeCnt"].ShouldBe(1.0);
|
||||
dict[$"TwinCAT.{Host2}.OnlineChangeCnt"].ShouldBe(9.0);
|
||||
dict[$"TwinCAT.{Host}.CycleTimeMs"].ShouldBe(1.0, tolerance: 0.001);
|
||||
dict[$"TwinCAT.{Host2}.CycleTimeMs"].ShouldBe(10.0, tolerance: 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TicksToMilliseconds_converts_100ns_units()
|
||||
{
|
||||
TwinCATDriver.TicksToMilliseconds(0u).ShouldBe(0.0);
|
||||
TwinCATDriver.TicksToMilliseconds(10_000u).ShouldBe(1.0, tolerance: 0.001);
|
||||
TwinCATDriver.TicksToMilliseconds(100_000u).ShouldBe(10.0, tolerance: 0.001);
|
||||
TwinCATDriver.TicksToMilliseconds(uint.MaxValue).ShouldBe(429_496.7295, tolerance: 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeviceDiagnostics_returns_null_before_first_probe()
|
||||
{
|
||||
await using var drv = BuildDriverWithClient(new FakeTwinCATClient());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
drv.GetDeviceDiagnostics(Host).ShouldBeNull();
|
||||
drv.GetDeviceDiagnostics("ads://does.not.exist:851").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a driver wired to the supplied <paramref name="fake"/>. Inserts the fake into a
|
||||
/// factory's <c>Customise</c> hook so the first <see cref="ITwinCATClientFactory.Create"/>
|
||||
/// call hands back the seeded fake. Caller drives <see cref="TwinCATDriver.SampleDeviceDiagnosticsAsync"/>
|
||||
/// directly.
|
||||
/// </summary>
|
||||
private static TwinCATDriver BuildDriverWithClient(FakeTwinCATClient fake)
|
||||
{
|
||||
var dispensed = false;
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
if (dispensed) return new FakeTwinCATClient();
|
||||
dispensed = true;
|
||||
return fake;
|
||||
},
|
||||
};
|
||||
return new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-diag", factory);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user