using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Scaffold-level tests that don't need a live S7 PLC — exercise driver lifecycle shape, /// default option values, and failure-mode transitions. PR 64 adds IReadable/IWritable /// tests against a mock-server, PR 65 adds discovery + subscribe. /// [Trait("Category", "Unit")] public sealed class S7DriverScaffoldTests { /// Verifies that default options target S7-1500 slot 0 on port 102. [Fact] public void Default_options_target_S7_1500_slot_0_on_port_102() { var opts = new S7DriverOptions(); opts.Port.ShouldBe(102, "ISO-on-TCP is always 102 for S7; documented in driver-specs.md §5"); opts.CpuType.ShouldBe(S7CpuType.S71500); opts.Rack.ShouldBe((short)0); opts.Slot.ShouldBe((short)0, "S7-1200/1500 onboard PN ports are slot 0 by convention"); } /// Verifies that the default probe interval is reasonable for S7 scan cycles. [Fact] public void Default_probe_interval_is_reasonable_for_S7_scan_cycle() { // S7 PLCs scan 2-10 ms but comms mailbox typically processed once per scan. // 5 s default probe is lightweight — ~0.001% of comms budget. new S7ProbeOptions().Interval.ShouldBe(TimeSpan.FromSeconds(5)); } /// Verifies that tag definition defaults to writable with S7 max string length. [Fact] public void Tag_definition_defaults_to_writable_with_S7_max_string_length() { var tag = new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16); tag.Writable.ShouldBeTrue(); tag.StringLength.ShouldBe(254, "S7 STRING type max length is 254 chars"); } /// Verifies that the driver instance reports type and ID before connect. [Fact] public void Driver_instance_reports_type_and_id_before_connect() { var opts = new S7DriverOptions { Host = "127.0.0.1" }; using var drv = new S7Driver(opts, "s7-test"); drv.DriverType.ShouldBe("S7"); drv.DriverInstanceId.ShouldBe("s7-test"); drv.GetHealth().State.ShouldBe(DriverState.Unknown, "health starts Unknown until InitializeAsync runs"); } /// Verifies that Initialize against unreachable host transitions to Faulted and throws. [Fact] public async Task Initialize_against_unreachable_host_transitions_to_Faulted_and_throws() { // Pick an RFC 5737 reserved-for-documentation IP so the connect attempt fails fast // (no DNS mismatch, no accidental traffic to a real PLC). var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) }; using var drv = new S7Driver(opts, "s7-unreach"); await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Faulted, "unreachable host must flip the driver to Faulted so operators see it"); health.LastError.ShouldNotBeNull(); } // ── Phase 4d T1 — wide-type / Timer-Counter init guards ────────────────────────────── // // The init flow runs RejectUnsupportedTagConfigs BEFORE plc.OpenAsync, so a guard // rejection throws before any TCP connect. The reserved-for-documentation host (192.0.2.1) // means a config that PASSES the guard still throws on connect — so a positive case // asserts the failure is NOT the guard's NotSupportedException/FormatException (the same // shape used by S7DriverCodeReviewFixTests2.Initialize_accepts_implemented_data_types). /// Verifies that a valid byte-addressed wide scalar (Float64 at DB1.DBB0) passes the init guard. [Fact] public async Task Initialize_accepts_byte_addressed_wide_scalar_Float64() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)], }; using var drv = new S7Driver(opts, "s7-wide-ok"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.ShouldNotBeOfType( "a byte-addressed wide scalar must pass the init guard — the failure must be the connect"); ex.ShouldNotBeOfType( "DB1.DBB0 parses cleanly — the failure must be the connect, not an address-parse error"); } /// Verifies that a wide-type array (Int64 + ArrayCount) is rejected as out-of-scope this phase. [Fact] public async Task Initialize_rejects_wide_type_array_with_clear_message() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)], }; using var drv = new S7Driver(opts, "s7-wide-array"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Batch64"); ex.Message.ShouldContain("array", Case.Insensitive); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } /// Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message. [Fact] public async Task Initialize_rejects_wide_scalar_with_non_byte_address() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)], }; using var drv = new S7Driver(opts, "s7-wide-nonbyte"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("WideWord"); ex.Message.ShouldContain("byte", Case.Insensitive); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } /// Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected. [Fact] public async Task Initialize_rejects_Timer_tag_with_wrong_data_type() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Int32)], }; using var drv = new S7Driver(opts, "s7-timer-bad"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Timer5"); ex.Message.ShouldContain("Float64"); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } /// Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected. [Fact] public async Task Initialize_rejects_Counter_tag_with_wrong_data_type() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Float32)], }; using var drv = new S7Driver(opts, "s7-counter-bad"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Counter3"); ex.Message.ShouldContain("Int32"); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } // ── Phase 4d bundle-review fix — Writable Timer/Counter guard ─────────────────────── // // A Timer/Counter tag declared Writable=true must be rejected at init: without this guard // the node is discovered as Operate-writable but every write returns BadNotSupported // (EncodeScalarBlock throws). The guard fires before plc.OpenAsync, so the reserved-for- // documentation host never receives traffic. /// Verifies that a Timer tag declared Writable=true is rejected at init with a Writable message. [Fact] public async Task Initialize_rejects_Timer_tag_declared_Writable() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)], }; using var drv = new S7Driver(opts, "s7-timer-writable"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Timer5"); ex.Message.ShouldContain("Writable", Case.Insensitive); ex.Message.ShouldContain("read-only", Case.Insensitive); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } /// Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message. [Fact] public async Task Initialize_rejects_Counter_tag_declared_Writable() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)], }; using var drv = new S7Driver(opts, "s7-counter-writable"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Counter3"); ex.Message.ShouldContain("Writable", Case.Insensitive); ex.Message.ShouldContain("read-only", Case.Insensitive); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } // ── Driver.S7-015 — writable array tag must fail fast at init ──────────────────────── // // Array reads are implemented (ReadArrayAsync / DecodeArrayBlock), but WriteOneAsync has // no WriteArrayAsync path. Without the init guard a writable array node is discovered and // accepted, then every write returns BadCommunicationError (InvalidCastException from // BoxValueForWrite receiving a typed array). Fail fast at init instead. /// Verifies that a writable non-wide array (e.g., Int16[4]) is rejected at init /// with a clear "array writes not yet supported" message — Driver.S7-015. [Theory] [InlineData(S7DataType.Bool, "DB1.DBX0.0", 4)] [InlineData(S7DataType.Byte, "DB1.DBB0", 8)] [InlineData(S7DataType.Int16, "DB1.DBW0", 4)] [InlineData(S7DataType.UInt16, "DB1.DBW0", 4)] [InlineData(S7DataType.Int32, "DB1.DBD0", 2)] [InlineData(S7DataType.UInt32, "DB1.DBD0", 2)] [InlineData(S7DataType.Float32,"DB1.DBD0", 2)] public async Task Initialize_rejects_writable_array_tag_with_NotSupportedException( S7DataType dt, string addr, int count) { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)], }; using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("ArrTag"); ex.Message.ShouldContain("array", Case.Insensitive); ex.Message.ShouldContain("write", Case.Insensitive); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } /// Verifies that a read-only (Writable=false) array passes the init guard — /// array reads are fully implemented; only writes are gated. [Fact] public async Task Initialize_accepts_readonly_array_tag() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Tags = [new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)], }; using var drv = new S7Driver(opts, "s7-readonly-arr"); // Must NOT throw NotSupportedException — the failure must be the TCP connect (unreachable host). var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.ShouldNotBeOfType( "read-only arrays are fully supported — the failure must be the TCP connect, not the array guard"); } }