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 remaining (Low/Medium) code-review findings: /// Driver.TwinCAT-004 (IEC time-type doc-comment accuracy), -006 (ResolveHost /// sentinel for no-devices fallback), -014 (NotificationMaxDelayMs config knob /// and ProbeOptions.Timeout wiring), -015 (Dispose runs a true synchronous /// teardown, no sync-over-async), -016 (gap-fill tests: Structure-tag rejection, /// concurrent probe + read race). /// [Trait("Category", "Unit")] public sealed class TwinCATLowFindingsRegressionTests { private const string DeviceA = "ads://5.23.91.23.1.1:851"; // ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ---- [Fact] public void Iec_time_types_map_to_uint32_raw_counter() { // Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD // surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan. // The next implementer who wants to decode them needs to see this mapping is intentional. TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32); } // ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ---- [Fact] public async Task ResolveHost_returns_unresolved_sentinel_when_no_devices() { // DriverInstanceId is a logical config-DB key, not a host address; consumers expect a // host key that correlates with GetHostStatuses(). When there are no devices and the // reference is unknown, ResolveHost must return the documented unresolved sentinel // (empty string), not the driver-instance ID. var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.ResolveHost("anything").ShouldBe(string.Empty); } [Fact] public async Task ResolveHost_unresolved_sentinel_matches_no_GetHostStatuses_entry() { // Documents the contract: the sentinel should never match a real connectivity-status row. var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); var sentinel = drv.ResolveHost("anything"); drv.GetHostStatuses().ShouldNotContain(s => s.HostName == sentinel); } // ---- Driver.TwinCAT-014 — config surface knobs are honoured ---- [Fact] public async Task ProbeOptions_Timeout_is_applied_to_probe_calls() { // The previous implementation declared a Timeout field but never read it — the probe // path connected with _options.Timeout. The probe must use its own configured timeout. var observed = new List(); var factory = new FakeTwinCATClientFactory { Customise = () => new ProbeTimeoutCapturingFake(observed), }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(100), Timeout = TimeSpan.FromMilliseconds(750), // distinct from the connect timeout }, Timeout = TimeSpan.FromMilliseconds(2_000), }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(() => observed.Count >= 1, TimeSpan.FromSeconds(2)); await drv.ShutdownAsync(CancellationToken.None); observed.ShouldContain(TimeSpan.FromMilliseconds(750)); } [Fact] public void NotificationMaxDelayMs_is_exposed_on_driver_options() { // The driver-spec lists NotificationMaxDelayMs as a per-device knob; the implementation // previously hard-coded 0 in NotificationSettings. Expose a configurable field so // operators can batch low-priority notifications. var options = new TwinCATDriverOptions { NotificationMaxDelayMs = 200 }; options.NotificationMaxDelayMs.ShouldBe(200); } [Fact] public void NotificationMaxDelayMs_parses_from_driver_config_json() { var json = JsonSerializer.Serialize(new { devices = new[] { new { hostAddress = DeviceA } }, notificationMaxDelayMs = 150, }); var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1"); parsed.NotificationMaxDelayMs.ShouldBe(150); } // ---- Driver.TwinCAT-015 — Dispose runs a true synchronous teardown ---- [Fact] public void Dispose_does_not_block_on_async_in_default_synchronization_context() { // Sync-over-async on a single-threaded sync context (like the OPC UA stack thread) can // deadlock. Dispose() must complete without scheduling continuations through a captured // sync context. We verify by running Dispose() inside a SynchronizationContext that // would deadlock a sync-over-async teardown. var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1"); drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult(); var ctx = new SingleThreadedSyncContext(); var prev = SynchronizationContext.Current; Exception? captured = null; try { SynchronizationContext.SetSynchronizationContext(ctx); // If Dispose schedules its continuations through the captured context (sync-over- // async pattern), and the context is single-threaded with nothing pumping, this // will hang. We give it 5 seconds — well above any reasonable sync teardown. var thread = new Thread(() => { try { SynchronizationContext.SetSynchronizationContext(ctx); drv.Dispose(); } catch (Exception ex) { captured = ex; } }) { IsBackground = true }; thread.Start(); thread.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue( "Dispose() did not complete within 5s — likely sync-over-async deadlock " + "(Driver.TwinCAT-015)."); } finally { SynchronizationContext.SetSynchronizationContext(prev); } captured.ShouldBeNull(); } /// /// Single-threaded sync context that posts continuations to an internal queue but never /// pumps them. Any sync-over-async code that captures this context and waits for a /// continuation will deadlock — exactly the OPC UA stack thread scenario. /// private sealed class SingleThreadedSyncContext : SynchronizationContext { private readonly System.Collections.Concurrent.ConcurrentQueue<(SendOrPostCallback cb, object? state)> _queue = new(); public override void Post(SendOrPostCallback d, object? state) => _queue.Enqueue((d, state)); public override void Send(SendOrPostCallback d, object? state) => d(state); } // ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ---- [Fact] public void Structure_typed_pre_declared_tag_is_rejected_at_config_parse() { // Driver.TwinCAT-003 — config-time rejection. A Structure tag must fail loudly with a // clear error rather than reading as a garbage int blob or failing late on a write. var json = JsonSerializer.Serialize(new { devices = new[] { new { hostAddress = DeviceA } }, tags = new[] { new { name = "Udt1", deviceHostAddress = DeviceA, symbolPath = "MAIN.fbInstance", dataType = "Structure", }, }, }); var ex = Should.Throw(() => TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1")); ex.Message.ShouldContain("Structure"); ex.Message.ShouldContain("Udt1"); } [Fact] public async Task Probe_loop_and_read_share_one_client_per_device() { // Driver.TwinCAT-007 / -009 — gap-fill: race the probe loop against concurrent reads on // the same device. The per-device gate must serialize connect; the probe-task await on // ShutdownAsync must let the loop exit cleanly. Without these, the test trips a leaked // client or a disposal race. var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 }, ProbeResult = true }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(DeviceA)], Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromMilliseconds(50), }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Race 64 readers against the probe loop for ~500ms. var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); var work = Enumerable.Range(0, 64).Select(_ => Task.Run(async () => { while (!cts.IsCancellationRequested) { try { await drv.ReadAsync(["X"], CancellationToken.None); } catch { /* shutdown-races on the very last call are ok */ } } })).ToArray(); await Task.WhenAll(work); await drv.ShutdownAsync(CancellationToken.None); // One client total. If the gate is broken, concurrent connects leak additional clients. factory.Clients.Count.ShouldBe(1); factory.Clients[0].ConnectCount.ShouldBe(1); } private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } /// Captures the timeout argument from ProbeAsync invocations (via the connect path). private sealed class ProbeTimeoutCapturingFake : FakeTwinCATClient { private readonly List _observed; public ProbeTimeoutCapturingFake(List observed) { _observed = observed; } public override Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct) { // The driver calls EnsureConnectedAsync with the probe timeout for probe-initiated // connects. The probe timeout is distinct from the driver-level Timeout; we record // it on the first connect. lock (_observed) _observed.Add(timeout); return base.ConnectAsync(address, timeout, ct); } } }