using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using S7NetDataType = global::S7.Net.DataType; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// TSAP / connection-type override (Gitea #295) and negotiated-PDU surfacing (Gitea #294). /// [Trait("Category", "Unit")] public sealed class S7TsapAndPduTests { // ---- TSAP text ---- [Theory] [InlineData("01.00", 0x0100)] [InlineData("03.01", 0x0301)] [InlineData("3.1", 0x0301)] [InlineData("0301", 0x0301)] [InlineData("0x0301", 0x0301)] [InlineData("03:21", 0x0321)] [InlineData(" 4d.57 ", 0x4D57)] public void Tsap_text_parses(string text, int expected) { S7Tsap.TryParse(text, out var v).ShouldBeTrue(); v.ShouldBe((ushort)expected); } [Theory] [InlineData("")] [InlineData("zz.00")] [InlineData("100.00")] [InlineData("01.00.00")] [InlineData("12345")] [InlineData("-1")] public void Tsap_garbage_is_rejected(string text) => S7Tsap.TryParse(text, out _).ShouldBeFalse(); [Fact] public void Tsap_formats_canonically() => S7Tsap.Format(0x0301).ShouldBe("03.01"); // ---- resolution ---- private static (byte, byte, byte, byte) Bytes(S7DriverOptions o) { var p = S7Tsap.Resolve(o).ShouldNotBeNull(); return (p.Local.FirstByte, p.Local.SecondByte, p.Remote.FirstByte, p.Remote.SecondByte); } [Fact] public void No_override_resolves_to_null_so_the_plain_constructor_is_kept() => S7Tsap.Resolve(new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2 }).ShouldBeNull(); [Fact] public void Local_override_replaces_only_the_local_tsap() { // S7-300 rack 0 slot 2 default remote = 03.02 (S7.Net slot-derived). var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, LocalTsap = "02.00" }; Bytes(o).ShouldBe(((byte)2, (byte)0, (byte)3, (byte)2)); } [Fact] public void Remote_override_replaces_the_whole_remote_tsap() { var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, RemoteTsap = "03.01" }; Bytes(o).ShouldBe(((byte)1, (byte)0, (byte)3, (byte)1)); } [Theory] [InlineData(S7ConnectionType.PG, 1)] [InlineData(S7ConnectionType.OP, 2)] [InlineData(S7ConnectionType.S7Basic, 3)] public void Connection_type_sets_only_the_first_remote_byte_and_keeps_rack_slot(S7ConnectionType ct, int first) { // rack 1 slot 3 → second byte 1*0x20 + 3 = 0x23. var o = new S7DriverOptions { CpuType = S7CpuType.S7400, Rack = 1, Slot = 3, ConnectionType = ct }; var (_, _, rFirst, rSecond) = Bytes(o); rFirst.ShouldBe((byte)first); rSecond.ShouldBe((byte)0x23); } // ---- factory JSON ---- [Fact] public void Json_accepts_string_number_and_enum_name_and_normalises() { var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","localTsap":"1.0","remoteTsap":769,"cpuType":"S7300"}"""); o.LocalTsap.ShouldBe("01.00"); o.RemoteTsap.ShouldBe("03.01"); o.ConnectionType.ShouldBeNull(); var ct = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","connectionType":"pg"}"""); ct.ConnectionType.ShouldBe(S7ConnectionType.PG); ct.RemoteTsap.ShouldBeNull(); } [Fact] public void Json_without_the_keys_leaves_the_defaults() { var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h"}"""); o.LocalTsap.ShouldBeNull(); o.RemoteTsap.ShouldBeNull(); o.ConnectionType.ShouldBeNull(); } [Theory] [InlineData("""{"host":"h","localTsap":"nope"}""")] [InlineData("""{"host":"h","remoteTsap":"01.00.00"}""")] [InlineData("""{"host":"h","remoteTsap":99999999}""")] [InlineData("""{"host":"h","connectionType":"Bogus"}""")] [InlineData("""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")] public void Json_rejects_bad_tsap_settings(string json) => Should.Throw(() => S7DriverFactoryExtensions.ParseOptions("d", json)); [Fact] public async Task Driver_hands_the_full_options_to_the_factory() { var factory = new CapturingFactory(); var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, RemoteTsap = "03.01", LocalTsap = "01.00", }; using var drv = new S7Driver(opts, "s7-tsap", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Seen.ShouldNotBeNull().RemoteTsap.ShouldBe("03.01"); factory.Seen!.LocalTsap.ShouldBe("01.00"); } // ---- PDU ---- [Fact] public async Task Negotiated_pdu_size_is_surfaced_after_connect_and_cleared_on_shutdown() { var factory = new CapturingFactory { Pdu = 480 }; var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } }; using var drv = new S7Driver(opts, "s7-pdu", factory); drv.NegotiatedPduSize.ShouldBe(0); drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0); ((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty("0 is 'not negotiated yet', not a size (#640)"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); drv.NegotiatedPduSize.ShouldBe(480); drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480); ((IDriverDiagnostics)drv).GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe("480"); await drv.ShutdownAsync(TestContext.Current.CancellationToken); drv.NegotiatedPduSize.ShouldBe(0); ((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty(); } [Fact] public async Task A_plc_that_does_not_report_a_pdu_size_yields_zero_not_a_throw() { var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } }; using var drv = new S7Driver(opts, "s7-pdu0", new ConnectingFakeS7PlcFactory()); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); drv.NegotiatedPduSize.ShouldBe(0); } private sealed class CapturingFactory : IS7PlcFactory { public S7DriverOptions? Seen { get; private set; } public int Pdu { get; init; } public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new Plc(Pdu); public IS7Plc Create(S7DriverOptions options) { Seen = options; return new Plc(Pdu); } private sealed class Plc(int pdu) : IS7Plc { public bool IsConnected { get; private set; } public int MaxPduSize => IsConnected ? pdu : 0; public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; } public void Close() => IsConnected = false; public Task ReadAsync(string a, CancellationToken ct) => throw new NotSupportedException(); public Task ReadBytesAsync(S7NetDataType area, int db, int s, int c, CancellationToken ct) => throw new NotSupportedException(); public Task> ReadMultipleVarsAsync( List i, CancellationToken ct) => throw new NotSupportedException(); public Task WriteAsync(string a, object v, CancellationToken ct) => throw new NotSupportedException(); public Task WriteBytesAsync(S7NetDataType area, int db, int s, byte[] v, CancellationToken ct) => throw new NotSupportedException(); public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask; public void Dispose() => IsConnected = false; } } [Fact] public void Factory_conflict_message_matches_the_shared_detail() { var ex = Should.Throw(() => S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")); ex.Message.ShouldContain(S7DriverOptions.TsapConflictDetail); } // ---- Test Connect data-access check (PUT/GET) ---- [Fact] public async Task Probe_data_access_succeeds_when_the_read_works() => (await S7DriverProbe.CheckDataAccessAsync(_ => Task.FromResult(new byte[2]), TestContext.Current.CancellationToken)) .ShouldBeNull(); [Fact] public async Task Probe_fails_with_actionable_message_when_put_get_is_disabled() { var denied = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Accessing object not allowed")); var r = await S7DriverProbe.CheckDataAccessAsync(_ => throw denied, TestContext.Current.CancellationToken); r.ShouldNotBeNull().Ok.ShouldBeFalse(); r.Message!.ShouldContain("PUT/GET"); } [Fact] public async Task Probe_tolerates_an_address_not_available_read_error() { var missing = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Object does not exist")); (await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull(); } [Fact] public async Task Probe_reports_any_other_read_fault_as_a_failure() { var r = await S7DriverProbe.CheckDataAccessAsync( _ => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ConnectionError, "socket dropped"), TestContext.Current.CancellationToken); r.ShouldNotBeNull().Ok.ShouldBeFalse(); r.Message!.ShouldContain("socket dropped"); } }