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; /// /// 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 ), so these tests invoke the /// internal directly with seeded /// values on the . Wire-level coverage lives in the /// integration suite (TwinCATDiagnosticsIntegrationTests). /// [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([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(); } /// /// Build a driver wired to the supplied . Inserts the fake into a /// factory's Customise hook so the first /// call hands back the seeded fake. Caller drives /// directly. /// 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); } }