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; /// Gitea #645 / #314 — PLC state / task-cycle facts on the optional IDriverDiagnostics bag. [Trait("Category", "Unit")] public sealed class TwinCATDiagnosticsTests { private const string Host1 = "ads://5.23.91.23.1.1:851"; private const string Host2 = "ads://5.23.91.24.1.1:851"; private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe, bool diagnostics, params (string host, string? name)[] devices) => new(new TwinCATDriverOptions { Devices = [.. devices.Select(d => new TwinCATDeviceOptions(d.host, d.name))], Probe = new TwinCATProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) }, DiagnosticsEnabled = diagnostics, }, "drv-diag", factory); private static FakeTwinCATClient PlcClient() => new() { AdsStateText = "Run", Values = { [TwinCATDriver.SymAppName] = "Plc30 App", [TwinCATDriver.SymOnlineChangeCnt] = 7u, [TwinCATDriver.SymCycleTime] = 100_000u, // 10.0 ms [TwinCATDriver.SymLastExecTime] = 25_000u, // 2.5 ms }, }; private static async Task Sample(TwinCATDriver drv, FakeTwinCATClient client, string host) { var state = drv.GetDeviceState(host)!; await drv.SampleDeviceDiagnosticsAsync(state, client, CancellationToken.None); } /// Verifies the driver advertises the optional capability and is empty before any sample. [Fact] public async Task Bag_is_empty_before_the_first_sample() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); var diag = drv.ShouldBeAssignableTo()!; diag.GetDiagnostics().ShouldBeEmpty(); } /// Verifies a single-device driver publishes flat keys with state, app, cycle, exec and headroom. [Fact] public async Task Single_device_publishes_flat_keys() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); await Sample(drv, PlcClient(), Host1); var bag = ((IDriverDiagnostics)drv).GetDiagnostics(); bag["TwinCAT.AdsState"].ShouldBe("Run"); bag["TwinCAT.AppName"].ShouldBe("Plc30 App"); bag["TwinCAT.OnlineChangeCnt"].ShouldBe("7"); bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0"); bag["TwinCAT.LastExecTimeMs"].ShouldBe("2.5"); bag["TwinCAT.HeadroomMs"].ShouldBe("7.5"); DriverDiagnostics.Bound(bag).Count.ShouldBe(bag.Count); // within the host's 16-entry cap } /// Verifies several devices are told apart by device name, falling back to host address. [Fact] public async Task Multiple_devices_are_prefixed() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, "Elma01"), (Host2, null)); await drv.InitializeAsync("{}", CancellationToken.None); await Sample(drv, PlcClient(), Host1); await Sample(drv, PlcClient(), Host2); var bag = ((IDriverDiagnostics)drv).GetDiagnostics(); bag["TwinCAT.Elma01"].ShouldBe("Run; Plc30 App; online-changes 7; cycle 10.0 ms; exec 2.5 ms; headroom 7.5 ms"); bag[$"TwinCAT.{Host2}"].ShouldContain("cycle 10.0 ms"); bag.ContainsKey("TwinCAT.AdsState").ShouldBeFalse(); } /// Verifies a runtime without _TaskInfo (older TwinCAT 2) still yields the facts it has, and /// the missing symbols are asked for once, not on every probe tick. [Fact] public async Task Missing_task_info_gives_a_partial_bag_and_is_not_re_asked() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); var client = new FakeTwinCATClient { AdsStateText = "Run" }; client.Values[TwinCATDriver.SymAppName] = "Plc1"; client.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown; client.ReadStatuses[TwinCATDriver.SymLastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown; client.ReadStatuses[TwinCATDriver.SymOnlineChangeCnt] = TwinCATStatusMapper.BadNodeIdUnknown; // ...and the TwinCAT 2 equivalents are absent too. client.ReadStatuses[TwinCATDriver.SymTc2CycleTime] = TwinCATStatusMapper.BadNodeIdUnknown; client.ReadStatuses[TwinCATDriver.SymTc2LastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown; client.ReadStatuses[TwinCATDriver.SymTc2OnlineChangeCount] = TwinCATStatusMapper.BadNodeIdUnknown; await Sample(drv, client, Host1); var readsAfterFirst = client.SingleReadCount; await Sample(drv, client, Host1); var bag = ((IDriverDiagnostics)drv).GetDiagnostics(); bag["TwinCAT.AdsState"].ShouldBe("Run"); bag["TwinCAT.AppName"].ShouldBe("Plc1"); bag.Keys.ShouldNotContain("TwinCAT.CycleTimeMs"); bag.Keys.ShouldNotContain("TwinCAT.HeadroomMs"); client.SingleReadCount.ShouldBe(readsAfterFirst + 1); // only AppName is read again } /// hw2 live gate (TC2 2.11 Elma Z34834): a TwinCAT 2 runtime has no TwinCAT_SystemInfoVarList but /// serves the same facts as `.SYSTEMINFO` / `.SYSTEMTASKINFOARR[1]`; the bag must be filled from those, and /// after the first tick only the TC2 names are read (4 reads, not 8). [Fact] public async Task TwinCAT2_system_globals_fill_the_bag_when_the_TC3_names_are_absent() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); var client = new FakeTwinCATClient { AdsStateText = "Run" }; foreach (var tc3 in new[] { TwinCATDriver.SymAppName, TwinCATDriver.SymOnlineChangeCnt, TwinCATDriver.SymCycleTime, TwinCATDriver.SymLastExecTime }) client.ReadStatuses[tc3] = TwinCATStatusMapper.BadNodeIdUnknown; client.Values[TwinCATDriver.SymTc2ProjectName] = "ELMA_5STAGE"; client.Values[TwinCATDriver.SymTc2OnlineChangeCount] = (ushort)3; client.Values[TwinCATDriver.SymTc2CycleTime] = 100_000u; // 10.0 ms client.Values[TwinCATDriver.SymTc2LastExecTime] = 40_000u; // 4.0 ms await Sample(drv, client, Host1); var readsAfterFirst = client.SingleReadCount; await Sample(drv, client, Host1); var bag = ((IDriverDiagnostics)drv).GetDiagnostics(); bag["TwinCAT.AppName"].ShouldBe("ELMA_5STAGE"); bag["TwinCAT.OnlineChangeCnt"].ShouldBe("3"); bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0"); bag["TwinCAT.LastExecTimeMs"].ShouldBe("4.0"); bag["TwinCAT.HeadroomMs"].ShouldBe("6.0"); readsAfterFirst.ShouldBe(8); client.SingleReadCount.ShouldBe(readsAfterFirst + 4); } /// A TC3 target that serves TwinCAT_SystemInfoVarList is never asked for the TC2 names. [Fact] public async Task A_TC3_target_never_reads_the_TwinCAT2_names() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); var client = PlcClient(); await Sample(drv, client, Host1); client.SingleReadCount.ShouldBe(4); } /// Verifies a failed sample keeps the previous snapshot rather than blanking the bag. [Fact] public async Task Failed_sample_keeps_the_last_values() { var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); var client = PlcClient(); await Sample(drv, client, Host1); client.ThrowOnRead = true; client.AdsStateText = null; await Sample(drv, client, Host1); var bag = ((IDriverDiagnostics)drv).GetDiagnostics(); bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0"); bag["TwinCAT.AdsState"].ShouldBe("Run"); } /// Verifies the probe loop itself fills the bag — no manual sampling. [Fact] public async Task Probe_loop_populates_the_bag() { var factory = new FakeTwinCATClientFactory { Customise = PlcClient }; var drv = NewDriver(factory, true, true, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); try { IReadOnlyDictionary bag = new Dictionary(); for (var i = 0; i < 100 && bag.Count == 0; i++) { await Task.Delay(50); bag = ((IDriverDiagnostics)drv).GetDiagnostics(); } bag["TwinCAT.OnlineChangeCnt"].ShouldBe("7"); } finally { await drv.ShutdownAsync(CancellationToken.None); } } /// Verifies a bag for the maximum number of devices fits the host's cap (one line per device). [Fact] public async Task Many_devices_all_fit_the_bag_cap() { var hosts = Enumerable.Range(1, 12).Select(i => ($"ads://5.23.91.{i}.1.1:851", (string?)$"D{i}")).ToArray(); var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, hosts); await drv.InitializeAsync("{}", CancellationToken.None); foreach (var (host, _) in hosts) await Sample(drv, PlcClient(), host); var bag = DriverDiagnostics.Bound(((IDriverDiagnostics)drv).GetDiagnostics()); bag.Count.ShouldBe(12); } /// Verifies sampling is opt-in: with it off the probe loop adds no reads and the bag stays empty. [Fact] public async Task Disabled_by_default_adds_no_reads() { var factory = new FakeTwinCATClientFactory { Customise = PlcClient }; var drv = NewDriver(factory, true, false, (Host1, null)); await drv.InitializeAsync("{}", CancellationToken.None); try { for (var i = 0; i < 100 && drv.GetHostStatuses().All(h => h.State != HostState.Running); i++) await Task.Delay(20); await Task.Delay(100); // several more probe ticks } finally { await drv.ShutdownAsync(CancellationToken.None); } factory.Clients[0].SingleReadCount.ShouldBe(0); ((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty(); } /// Verifies a symbol the PLC did not export is asked for again once the client is rebuilt. [Fact] public async Task Unavailable_symbols_are_cleared_when_a_new_client_is_installed() { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host1)], RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("A", Host1, "MAIN.A", TwinCATDataType.DInt)]), Probe = new TwinCATProbeOptions { Enabled = false }, DiagnosticsEnabled = true, }, "drv-diag", factory); await drv.InitializeAsync("{}", CancellationToken.None); var first = new FakeTwinCATClient(); first.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown; factory.Customise = () => first; await drv.ReadAsync(["A"], CancellationToken.None); // installs the first client await Sample(drv, first, Host1); drv.GetDeviceState(Host1)!.UnavailableDiagSymbols.ContainsKey(TwinCATDriver.SymCycleTime).ShouldBeTrue(); first.SimulateWireDrop(); factory.Customise = () => new FakeTwinCATClient(); await drv.ReadAsync(["A"], CancellationToken.None); // reconnect installs a new client drv.GetDeviceState(Host1)!.UnavailableDiagSymbols.ShouldBeEmpty(); } }