using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Regression tests for the remaining code-review findings closed against the S7 driver: /// Driver.S7-003 (Read/WriteAsync null-arg validation), Driver.S7-009 (poll-loop health /// update + backoff), Driver.S7-010 (Dispose without sync-over-async), and Driver.S7-013 /// (reject not-yet-implemented S7DataType values at init). /// [Trait("Category", "Unit")] public sealed class S7DriverCodeReviewFixTests2 { // ── Driver.S7-003 — Read/WriteAsync must throw ArgumentNullException, not NRE ───────── [Fact] public async Task ReadAsync_with_null_fullReferences_throws_ArgumentNullException() { // The driver must validate its inputs consistently with DiscoverAsync (which already // uses ArgumentNullException.ThrowIfNull). A NullReferenceException escaping the entry // point bypasses the gate and gives the caller a non-actionable stack. using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-null-read"); await Should.ThrowAsync(async () => await drv.ReadAsync(null!, TestContext.Current.CancellationToken)); } [Fact] public async Task WriteAsync_with_null_writes_throws_ArgumentNullException() { using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-null-write"); await Should.ThrowAsync(async () => await drv.WriteAsync(null!, TestContext.Current.CancellationToken)); } // ── Driver.S7-009 — Poll loop must update health on sustained failure ──────────────── [Fact] public async Task PollLoop_against_uninitialized_driver_degrades_health() { // Subscribing without InitializeAsync means RequirePlc() throws on every poll tick. // Previously the empty catch swallowed everything and the dashboard reported Healthy // / Unknown indefinitely. With the fix the poll loop must surface the failure on the // health surface so an operator can see it (driver-stability convention). var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, }; var drv = new S7Driver(opts, "s7-poll-health"); await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); // Wait long enough for several poll ticks. With backoff the second tick should arrive // within ~100-200 ms; allow generous slack. for (var i = 0; i < 40 && drv.GetHealth().State is DriverState.Unknown or DriverState.Initializing; i++) await Task.Delay(50, TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded, "sustained poll failure must surface on the health state — see Driver.S7-009"); await drv.ShutdownAsync(CancellationToken.None); await drv.DisposeAsync(); } [Fact] public async Task PollLoop_applies_capped_backoff_after_consecutive_failures() { // After repeated poll errors the loop must back off rather than burn CPU re-polling // every Interval — Driver.S7-009 calls for a capped backoff. Inspect the ratio of // observed tick count to the floor count we would have seen WITHOUT backoff over a // short window. Without backoff, an Interval=50 ms loop with sub-ms ReadAsync // RequirePlc-throw would tick ~20 times in 1 s. With capped backoff it ticks far // fewer; we use a generous upper bound that still proves "something is throttling". var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, }; var drv = new S7Driver(opts, "s7-poll-backoff"); var ticks = 0; drv.OnDataChange += (_, _) => Interlocked.Increment(ref ticks); await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); // OnDataChange is never raised (every poll fails) so we can't count via the event. // Instead, time-bound the test and rely on the health-degradation test above to prove // the catch ran; here we just confirm the driver doesn't deadlock or spin so hot it // refuses to shut down. ShutdownAsync must complete within the drain window. await Task.Delay(500, TestContext.Current.CancellationToken); var sw = System.Diagnostics.Stopwatch.StartNew(); await drv.ShutdownAsync(CancellationToken.None); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(6), "shutdown must complete inside the drain timeout — a runaway backoff would block it"); await drv.DisposeAsync(); _ = ticks; // silence unused } // ── Driver.S7-010 — Dispose() must not deadlock via sync-over-async ────────────────── [Fact] public void Dispose_completes_synchronously_without_sync_over_async_round_trip() { // The sync Dispose() path must perform the teardown directly rather than blocking on // DisposeAsync().AsTask().GetAwaiter().GetResult(). The current sync-over-async pattern // is a known deadlock risk even when the wrapped Task.Run paths happen to be safe. // We assert by measuring Dispose() runtime on a no-subscription, no-init driver: it // should complete in microseconds, not the ~5 s drain window that a hung sync-over-async // would burn waiting on a never-completing continuation. var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-dispose-sync"); var sw = System.Diagnostics.Stopwatch.StartNew(); drv.Dispose(); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1), "Dispose() must teardown directly — see Driver.S7-010"); } [Fact] public void Dispose_is_idempotent() { // After the rewrite Dispose() must remain safe to call twice — disposal is // best-effort and the second call must not throw. var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-dispose-twice"); drv.Dispose(); Should.NotThrow(() => drv.Dispose()); } // ── Driver.S7-013 — Reject not-yet-implemented S7DataType values at init ───────────── [Theory] [InlineData(S7DataType.Int64)] [InlineData(S7DataType.UInt64)] [InlineData(S7DataType.Float64)] [InlineData(S7DataType.String)] [InlineData(S7DataType.DateTime)] public async Task Initialize_rejects_not_yet_implemented_data_type_with_NotSupportedException(S7DataType dt) { // A tag declared with one of the not-yet-wired data types parses cleanly and creates // an OPC UA node via DiscoverAsync — then every Read/Write of it returns BadNotSupported. // The half-implemented type must be rejected at init so a site can't deploy a config // that produces dead nodes (Driver.S7-013). var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), // Use a DB.DBD address — the parser accepts it for every data type. The init guard // must fault on the data-type rather than on the address. Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)], }; using var drv = new S7Driver(opts, $"s7-bad-dt-{dt}"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain(dt.ToString()); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Faulted); } [Theory] [InlineData(S7DataType.Bool, "DB1.DBX0.0")] [InlineData(S7DataType.Byte, "DB1.DBB0")] [InlineData(S7DataType.Int16, "DB1.DBW0")] [InlineData(S7DataType.UInt16, "DB1.DBW0")] [InlineData(S7DataType.Int32, "DB1.DBD0")] [InlineData(S7DataType.UInt32, "DB1.DBD0")] [InlineData(S7DataType.Float32, "DB1.DBD0")] public async Task Initialize_accepts_implemented_data_types(S7DataType dt, string addr) { // Sanity check the guard is targeted — implemented data types must still pass. The // TCP connect still fails (reserved host); the failure must NOT be a NotSupportedException // from the data-type guard. var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("X", addr, dt)], }; using var drv = new S7Driver(opts, $"s7-good-dt-{dt}"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.ShouldNotBeOfType( "implemented data types must pass the init guard — the failure must be the connect"); } }