using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Regression tests for the High code-review findings closed against the S7 driver: /// Driver.S7-001 (Timer/Counter tags rejected at init), Driver.S7-006 (shutdown drains /// the probe/poll loops before disposing the gate), Driver.S7-007 (PUT/GET-disabled maps /// to a config alert, not a transient fault), and Driver.S7-011 (Initialize/Reinitialize /// honour the supplied driverConfigJson). /// [Trait("Category", "Unit")] public sealed class S7DriverCodeReviewFixTests { // ---- Driver.S7-001 — Timer/Counter tags must be rejected at init ---- [Theory] [InlineData("T0")] [InlineData("T15")] [InlineData("C0")] [InlineData("C10")] public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address) { // A Timer/Counter address parses cleanly but the read path has no decode case for it, // so it must fail fast at init rather than throw a misleading type-mismatch on every // read. The host is reserved-for-documentation so the TCP connect can never succeed — // the unsupported-address guard runs before the connect, so the NotSupportedException // is what surfaces. var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("Quirk", address, S7DataType.Int16)], }; using var drv = new S7Driver(opts, "s7-tc"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain(address); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver"); health.LastError.ShouldNotBeNull(); } [Fact] public async Task Initialize_accepts_DB_and_MIQ_addresses_without_the_unsupported_guard_tripping() { // Sanity check the guard is targeted — DB/M/I/Q tags must NOT be rejected by it. // The connect still fails (reserved host), so we assert the failure is the connect, // NOT a NotSupportedException from the address guard. var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [ new S7TagDefinition("Word", "DB1.DBW0", S7DataType.Int16), new S7TagDefinition("Bit", "M0.0", S7DataType.Bool), ], }; using var drv = new S7Driver(opts, "s7-ok"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.ShouldNotBeOfType( "DB/M/I/Q tags are supported — the failure must be the connect, not the address guard"); } // ---- Driver.S7-011 — driverConfigJson must be applied on Initialize ---- [Fact] public async Task Initialize_applies_the_supplied_driverConfigJson_over_the_constructor_options() { // Constructor options point at a real-looking host; the JSON config points at a // reserved-for-documentation host with a tiny timeout. If InitializeAsync honours the // JSON (the IDriver contract), the connect fails fast against 192.0.2.x. If it ignored // the JSON it would hang on the constructor host instead. var ctorOpts = new S7DriverOptions { Host = "10.255.255.1", Timeout = TimeSpan.FromSeconds(30) }; using var drv = new S7Driver(ctorOpts, "s7-cfg"); const string json = """ { "Host": "192.0.2.1", "TimeoutMs": 250, "Tags": [ { "Name": "W", "Address": "DB1.DBW0", "DataType": "Int16" } ] } """; var sw = System.Diagnostics.Stopwatch.StartNew(); await Should.ThrowAsync(async () => await drv.InitializeAsync(json, TestContext.Current.CancellationToken)); sw.Stop(); // A 30 s constructor timeout would dominate; the 250 ms JSON timeout proves the JSON won. sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "InitializeAsync must apply the driverConfigJson timeout, not the constructor's"); } [Fact] public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson() { // The Timer/Counter guard must run against the *re-parsed* config, not just the // constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly. var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) }; using var drv = new S7Driver(ctorOpts, "s7-cfg-tc"); const string json = """ { "Host": "192.0.2.1", "TimeoutMs": 250, "Tags": [ { "Name": "TimerTag", "Address": "T5", "DataType": "Int16" } ] } """; var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync(json, TestContext.Current.CancellationToken)); ex.Message.ShouldContain("T5"); } [Fact] public async Task Reinitialize_applies_a_changed_driverConfigJson() { // ReinitializeAsync is the only Core-initiated in-process recovery path; a config // change delivered through it must not be silently discarded. var ctorOpts = new S7DriverOptions { Host = "10.255.255.1", Timeout = TimeSpan.FromSeconds(30) }; using var drv = new S7Driver(ctorOpts, "s7-reinit"); const string changed = """{ "Host": "192.0.2.1", "TimeoutMs": 250 }"""; var sw = System.Diagnostics.Stopwatch.StartNew(); await Should.ThrowAsync(async () => await drv.ReinitializeAsync(changed, TestContext.Current.CancellationToken)); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "ReinitializeAsync must re-parse and apply the new driverConfigJson"); } // ---- Driver.S7-006 — Shutdown drains probe/poll loops before disposing the gate ---- [Fact] public async Task Shutdown_completes_cleanly_with_active_subscriptions_and_no_disposal_race() { // SubscribeAsync starts a poll loop; ShutdownAsync must cancel AND await it before // disposing the shared semaphore. A regression here surfaces as an // ObjectDisposedException escaping ShutdownAsync / DisposeAsync. var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, }; var drv = new S7Driver(opts, "s7-drain"); await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); // Let the poll loops actually start churning so cancellation has something to race. await Task.Delay(150, TestContext.Current.CancellationToken); // Must not throw — the drain awaits the poll tasks before disposing _gate. await Should.NotThrowAsync(async () => await drv.ShutdownAsync(CancellationToken.None)); await Should.NotThrowAsync(async () => await drv.DisposeAsync()); } [Fact] public async Task Dispose_after_subscribe_does_not_throw_ObjectDisposedException() { // The synchronous Dispose() path round-trips through DisposeAsync → ShutdownAsync; // it must also drain the poll loop rather than dispose the gate from under it. var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, }; var drv = new S7Driver(opts, "s7-dispose"); await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); await Task.Delay(150, TestContext.Current.CancellationToken); Should.NotThrow(() => drv.Dispose()); } }