Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiagnosticsTests.cs
T
Joseph Doherty af19db4ed1 feat(twincat): opt-in ADS sum-read and /hosts diagnostics bag; refresh TwinCAT backlog (#645, #612)
Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems,
per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the
command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via
IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps
commits predate it). Sum-write not ported. Unit tests only; live gate pending a route.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:18:42 -04:00

149 lines
6.3 KiB
C#

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>Gitea #645 / #314 — PLC state / task-cycle facts on the optional IDriverDiagnostics bag.</summary>
[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, 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) },
}, "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);
}
/// <summary>Verifies the driver advertises the optional capability and is empty before any sample.</summary>
[Fact]
public async Task Bag_is_empty_before_the_first_sample()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
var diag = drv.ShouldBeAssignableTo<IDriverDiagnostics>()!;
diag.GetDiagnostics().ShouldBeEmpty();
}
/// <summary>Verifies a single-device driver publishes flat keys with state, app, cycle, exec and jitter.</summary>
[Fact]
public async Task Single_device_publishes_flat_keys()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (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.JitterMs"].ShouldBe("-7.5");
DriverDiagnostics.Bound(bag).Count.ShouldBe(bag.Count); // within the host's 16-entry cap
}
/// <summary>Verifies several devices are told apart by device name, falling back to host address.</summary>
[Fact]
public async Task Multiple_devices_are_prefixed()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (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.AdsState"].ShouldBe("Run");
bag[$"TwinCAT.{Host2}.CycleTimeMs"].ShouldBe("10.0");
bag.ContainsKey("TwinCAT.AdsState").ShouldBeFalse();
}
/// <summary>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.</summary>
[Fact]
public async Task Missing_task_info_gives_a_partial_bag_and_is_not_re_asked()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (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;
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.JitterMs");
client.SingleReadCount.ShouldBe(readsAfterFirst + 1); // only AppName is read again
}
/// <summary>Verifies a failed sample keeps the previous snapshot rather than blanking the bag.</summary>
[Fact]
public async Task Failed_sample_keeps_the_last_values()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (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");
}
/// <summary>Verifies the probe loop itself fills the bag — no manual sampling.</summary>
[Fact]
public async Task Probe_loop_populates_the_bag()
{
var factory = new FakeTwinCATClientFactory { Customise = PlcClient };
var drv = NewDriver(factory, true, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
IReadOnlyDictionary<string, string> bag = new Dictionary<string, string>();
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); }
}
}