using System.Text.Json; 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; /// /// Regression coverage for the five High code-review findings closed 2026-05-22: /// Driver.TwinCAT-001 (driverConfigJson ignored), -002 (LInt/ULInt narrowed to Int32), /// -007 (EnsureConnectedAsync not thread-safe), -008 (ADS callbacks on the router thread), /// -013 (no IRediscoverable / symbol-version-changed handling). /// [Trait("Category", "Unit")] public sealed class TwinCATHighFindingsRegressionTests { private const string DeviceA = "ads://5.23.91.23.1.1:851"; // ---- Driver.TwinCAT-001 — Reinitialize applies the new config generation ---- [Fact] public async Task ReinitializeAsync_applies_changed_device_config() { var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.DeviceCount.ShouldBe(1); // A config generation that adds a second device must be picked up at runtime. var newConfig = JsonSerializer.Serialize(new { probe = new { enabled = false }, devices = new[] { new { hostAddress = DeviceA, deviceName = "Machine1" }, new { hostAddress = "ads://10.0.0.1.1.1:852", deviceName = "Machine2" }, }, }); await drv.ReinitializeAsync(newConfig, CancellationToken.None); drv.DeviceCount.ShouldBe(2); drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2"); } [Fact] public async Task InitializeAsync_applies_supplied_config_over_constructor_options() { // Constructor seeds an empty option set; the JSON document is the authoritative config. var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1"); var config = JsonSerializer.Serialize(new { probe = new { enabled = false }, devices = new[] { new { hostAddress = DeviceA } }, }); await drv.InitializeAsync(config, CancellationToken.None); drv.DeviceCount.ShouldBe(1); drv.GetDeviceState(DeviceA).ShouldNotBeNull(); } // ---- Driver.TwinCAT-002 — 64-bit + unsigned types map without truncation ---- [Fact] public void DataType_mapping_preserves_width_and_signedness() { TwinCATDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64); TwinCATDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64); TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32); TwinCATDataType.UInt.ToDriverDataType().ShouldBe(DriverDataType.UInt16); TwinCATDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int16); TwinCATDataType.USInt.ToDriverDataType().ShouldBe(DriverDataType.UInt16); TwinCATDataType.SInt.ToDriverDataType().ShouldBe(DriverDataType.Int16); } [Fact] public async Task LInt_read_round_trips_value_above_int_MaxValue() { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Tags = [new TwinCATTagDefinition("Big", DeviceA, "GVL.Big", TwinCATDataType.LInt)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); long big = (long)int.MaxValue + 1_000; factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Big"] = big } }; var snapshot = (await drv.ReadAsync(["Big"], CancellationToken.None)).Single(); snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); snapshot.Value.ShouldBe(big); // no truncation into a 32-bit node } // ---- Driver.TwinCAT-007 — concurrent EnsureConnectedAsync creates exactly one client ---- [Fact] public async Task Concurrent_reads_on_one_device_create_a_single_client() { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 7 } }; // 32 readers race the lazy connect. Without the per-device gate this leaks clients. var tasks = Enumerable.Range(0, 32) .Select(_ => Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None))); await Task.WhenAll(tasks); factory.Clients.Count.ShouldBe(1); factory.Clients[0].ConnectCount.ShouldBe(1); } [Fact] public async Task Concurrent_reads_and_writes_share_one_client() { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } }; var work = new List(); for (var i = 0; i < 16; i++) { work.Add(Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None))); work.Add(Task.Run(() => drv.WriteAsync( [new WriteRequest("X", 5)], CancellationToken.None))); } await Task.WhenAll(work); factory.Clients.Count.ShouldBe(1); } // ---- Driver.TwinCAT-013 — symbol-version-changed routes to IRediscoverable ---- [Fact] public void TwinCATDriver_implements_IRediscoverable() { var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1"); drv.ShouldBeAssignableTo(); } [Fact] public async Task Symbol_version_changed_raises_OnRediscoveryNeeded() { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); RediscoveryEventArgs? raised = null; drv.OnRediscoveryNeeded += (_, args) => raised = args; // Force a connect so the driver wires its handler onto the client. await drv.ReadAsync(["X"], CancellationToken.None); factory.Clients.ShouldHaveSingleItem(); // The client observed ADS 0x0702 on the wire and signalled it. factory.Clients[0].FireSymbolVersionChanged(); raised.ShouldNotBeNull(); raised!.Reason.ShouldContain("0x0711"); } [Fact] public void StatusMapper_recognises_symbol_version_changed_code() { // DeviceSymbolVersionInvalid is 1809 (0x0711) per Beckhoff.TwinCAT.Ads 7.0.172. // Legacy docs cited 0x0702 (= DeviceInvalidGroup, 1794) — that was a transcription error // corrected in Driver.TwinCAT-011. TwinCATStatusMapper.AdsSymbolVersionChanged.ShouldBe(0x0711u); // = 1809u TwinCATStatusMapper.IsSymbolVersionChanged(0x0711u).ShouldBeTrue(); TwinCATStatusMapper.IsSymbolVersionChanged(0x0702u).ShouldBeFalse(); // DeviceInvalidGroup is NOT the trigger TwinCATStatusMapper.IsSymbolVersionChanged(0u).ShouldBeFalse(); } }