using System.Reflection; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using S7NetErrorCode = global::S7.Net.ErrorCode; using S7NetPlcException = global::S7.Net.PlcException; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// PR-S7-C5 — unit coverage for the post-OpenAsync pre-flight PUT/GET /// enablement probe and its typed exception. The classifier branch tests run /// without any network. Driver-level tests reach for unreachable IPs to drive /// the "pre-flight skipped because OpenAsync failed" lifecycle path. /// [Trait("Category", "Unit")] public sealed class S7PreflightTests { // ---- S7PutGetDisabledException ---- [Fact] public void Typed_exception_carries_the_probe_address() { var ex = new S7PutGetDisabledException("MW0"); ex.ProbeAddress.ShouldBe("MW0"); } [Fact] public void Typed_exception_message_names_TIA_Portal_fix_path() { var ex = new S7PutGetDisabledException("DB1.DBW0"); ex.Message.ShouldContain("DB1.DBW0"); ex.Message.ShouldContain("PUT/GET"); ex.Message.ShouldContain("TIA Portal"); ex.Message.ShouldContain("Permit access"); } [Fact] public void Typed_exception_preserves_inner_for_diagnostics() { var inner = new InvalidOperationException("simulated"); var ex = new S7PutGetDisabledException("MW0", inner); ex.InnerException.ShouldBeSameAs(inner); } // ---- S7PreflightClassifier.IsPutGetDisabled ---- [Theory] [InlineData(S7NetErrorCode.WrongCPU_Type, true)] [InlineData(S7NetErrorCode.ReadData, true)] [InlineData(S7NetErrorCode.NoError, false)] [InlineData(S7NetErrorCode.ConnectionError, false)] [InlineData(S7NetErrorCode.IPAddressNotAvailable, false)] [InlineData(S7NetErrorCode.WrongVarFormat, false)] [InlineData(S7NetErrorCode.WrongNumberReceivedBytes, false)] [InlineData(S7NetErrorCode.SendData, false)] [InlineData(S7NetErrorCode.WriteData, false)] public void Classifier_matches_only_PUT_GET_disabled_error_codes(S7NetErrorCode code, bool expected) { var pex = new S7NetPlcException(code); S7PreflightClassifier.IsPutGetDisabled(pex).ShouldBe(expected); } [Fact] public void Classifier_returns_false_for_null_exception() { S7PreflightClassifier.IsPutGetDisabled(null).ShouldBeFalse(); } // ---- S7ProbeOptions defaults ---- [Fact] public void Default_probe_address_is_MW0() { // Documented default — the convention all the docs / CLI examples reference. new S7ProbeOptions().ProbeAddress.ShouldBe("MW0"); } [Fact] public void Default_skip_preflight_is_false() { // Pre-flight runs by default. Operators must opt in to skip. new S7ProbeOptions().SkipPreflight.ShouldBeFalse(); } [Fact] public void Probe_address_can_be_null_to_skip_preflight() { var probe = new S7ProbeOptions { ProbeAddress = null }; probe.ProbeAddress.ShouldBeNull(); } // ---- DTO JSON round-trip preserves SkipPreflight ---- [Fact] public void JSON_round_trip_preserves_SkipPreflight_true() { const string json = """ { "Host": "10.0.0.50", "Probe": { "Enabled": false, "ProbeAddress": "DB1.DBW0", "SkipPreflight": true } } """; // Hit the CreateInstance flow exactly as the bootstrapper does. // Use reflection because the DTO + entry method are internal. var ext = typeof(S7DriverFactoryExtensions); var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic) .ShouldNotBeNull("S7DriverFactoryExtensions.CreateInstance entry method must exist"); var driver = create.Invoke(null, ["s7-skipflight-test", json]).ShouldBeOfType(); // Read the resolved options off the driver via reflection; SkipPreflight must // have flowed through the JSON → DTO → S7ProbeOptions path unchanged. var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic) .ShouldNotBeNull(); var opts = optsField.GetValue(driver).ShouldBeOfType(); opts.Probe.SkipPreflight.ShouldBeTrue("DTO -> options must round-trip SkipPreflight=true"); opts.Probe.ProbeAddress.ShouldBe("DB1.DBW0"); } [Fact] public void JSON_round_trip_defaults_SkipPreflight_to_false_when_omitted() { const string json = """ { "Host": "10.0.0.50", "Probe": { "ProbeAddress": "MW0" } } """; var ext = typeof(S7DriverFactoryExtensions); var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic) .ShouldNotBeNull(); var driver = create.Invoke(null, ["s7-default-test", json]).ShouldBeOfType(); var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic) .ShouldNotBeNull(); var opts = optsField.GetValue(driver).ShouldBeOfType(); opts.Probe.SkipPreflight.ShouldBeFalse(); } [Fact] public void JSON_empty_probe_address_means_skip() { // Explicit empty string → no probe wire-up; null in the runtime options means // "no probe address configured" so RunPreflightAsync skips. const string json = """ { "Host": "10.0.0.50", "Probe": { "ProbeAddress": "" } } """; var ext = typeof(S7DriverFactoryExtensions); var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic) .ShouldNotBeNull(); var driver = create.Invoke(null, ["s7-noprobe-test", json]).ShouldBeOfType(); var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic) .ShouldNotBeNull(); var opts = optsField.GetValue(driver).ShouldBeOfType(); opts.Probe.ProbeAddress.ShouldBeNull(); } [Fact] public void JSON_omitted_probe_object_keeps_default_MW0_address() { // No Probe object at all — existing configs from S7-A/B PRs must keep working. const string json = """ { "Host": "10.0.0.50" } """; var ext = typeof(S7DriverFactoryExtensions); var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic) .ShouldNotBeNull(); var driver = create.Invoke(null, ["s7-omittedprobe", json]).ShouldBeOfType(); var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic) .ShouldNotBeNull(); var opts = optsField.GetValue(driver).ShouldBeOfType(); opts.Probe.ProbeAddress.ShouldBe("MW0"); opts.Probe.SkipPreflight.ShouldBeFalse(); } // ---- Driver-level lifecycle around the pre-flight probe ---- // // We can't drive the "PUT/GET disabled" wire path without a fake S7 server, so // these tests verify that the SkipPreflight / null-ProbeAddress short-circuits // are honoured by checking the lifecycle never reaches OpenAsync against an // unreachable host (otherwise we'd see a Connection Error before the probe // path runs at all). The classifier branch above is the unit coverage for the // exception-mapping decision. [Fact] public async Task Driver_with_SkipPreflight_still_throws_on_unreachable_host() { // Sanity: skipping the pre-flight does NOT skip OpenAsync. An unreachable host // still flips the driver to Faulted via the existing Connection-Error path. var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Probe = new S7ProbeOptions { Enabled = false, SkipPreflight = true }, }; await using var drv = new S7Driver(opts, "s7-skip-unreach"); await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } [Fact] public async Task Driver_with_null_ProbeAddress_still_throws_on_unreachable_host() { // Same sanity check for the "ProbeAddress = null" path. var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null }, }; await using var drv = new S7Driver(opts, "s7-null-addr-unreach"); await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } }