using System.Text.Json; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Unit tests for the PR-S7-C2 TSAP / Connection Type selector. Validates the /// raw-byte mapping, the → /// constructor branching in , and JSON DTO round-trip on /// . The live-PLC handshake is exercised /// separately (and is hardware-gated to the dev-box S7-1500 lab rig). /// [Trait("Category", "Unit")] public sealed class S7TsapModeTests { [Fact] public void Default_TsapMode_is_Auto_to_preserve_existing_behaviour() { new S7DriverOptions().TsapMode.ShouldBe(TsapMode.Auto); new S7DriverOptions().LocalTsap.ShouldBeNull(); new S7DriverOptions().RemoteTsap.ShouldBeNull(); } // ---- High-byte mapping ---- [Theory] [InlineData(TsapMode.Pg, S7TsapDefaults.PgClassHighByte)] [InlineData(TsapMode.Op, S7TsapDefaults.OpClassHighByte)] [InlineData(TsapMode.S7Basic, S7TsapDefaults.S7BasicClassHighByte)] public void HighByteFor_returns_the_class_selector_for_each_named_mode(TsapMode mode, byte expected) { S7TsapDefaults.HighByteFor(mode).ShouldBe(expected); } [Theory] [InlineData(TsapMode.Auto)] [InlineData(TsapMode.Other)] public void HighByteFor_throws_for_Auto_and_Other(TsapMode mode) { Should.Throw(() => S7TsapDefaults.HighByteFor(mode)); } // ---- Raw-byte construction ---- [Theory] [InlineData(S7TsapDefaults.PgClassHighByte, 0x0100)] [InlineData(S7TsapDefaults.OpClassHighByte, 0x0200)] [InlineData(S7TsapDefaults.S7BasicClassHighByte, 0x0300)] public void BuildLocalTsap_uses_class_high_byte_with_zero_low_byte(byte cls, ushort expected) { S7TsapDefaults.BuildLocalTsap(cls).ShouldBe(expected); } [Theory] // PG / S7-300 default — rack=0 slot=2 → 0x0102 (matches docs/v2/s7.md table). [InlineData(S7TsapDefaults.PgClassHighByte, 0, 2, 0x0102)] // OP / S7-300 default — rack=0 slot=2 → 0x0202. [InlineData(S7TsapDefaults.OpClassHighByte, 0, 2, 0x0202)] // S7-Basic / S7-300 default — rack=0 slot=2 → 0x0302. [InlineData(S7TsapDefaults.S7BasicClassHighByte, 0, 2, 0x0302)] // S7-1200 / S7-1500 onboard PN — rack=0 slot=0 → low byte 0x00. [InlineData(S7TsapDefaults.PgClassHighByte, 0, 0, 0x0100)] // S7-400 distributed — rack=1 slot=3 → low byte = (1<<5)|3 = 0x23. [InlineData(S7TsapDefaults.OpClassHighByte, 1, 3, 0x0223)] public void BuildRemoteTsap_packs_class_rack_and_slot_per_S7_spec(byte cls, int rack, int slot, ushort expected) { S7TsapDefaults.BuildRemoteTsap(cls, rack, slot).ShouldBe(expected); } [Theory] [InlineData(-1, 0)] [InlineData(16, 0)] [InlineData(0, -1)] [InlineData(0, 32)] public void BuildRemoteTsap_throws_on_out_of_range_rack_or_slot(int rack, int slot) { Should.Throw(() => S7TsapDefaults.BuildRemoteTsap(S7TsapDefaults.PgClassHighByte, rack, slot)); } // ---- Driver construction (Other-mode missing-overrides validation) ---- [Fact] public async Task Other_mode_without_LocalTsap_or_RemoteTsap_throws_at_initialize() { // No LocalTsap / RemoteTsap → BuildPlc should refuse before opening the socket. var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), TsapMode = TsapMode.Other, }; using var drv = new S7Driver(opts, "s7-other-missing"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("LocalTsap"); ex.Message.ShouldContain("RemoteTsap"); } [Fact] public async Task Other_mode_with_only_LocalTsap_set_still_throws() { var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), TsapMode = TsapMode.Other, LocalTsap = 0x4D57, // RemoteTsap intentionally null }; using var drv = new S7Driver(opts, "s7-other-half"); await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); } [Fact] public async Task Other_mode_with_both_overrides_does_not_fail_at_BuildPlc_stage() { // With Other-mode + both overrides, BuildPlc must succeed; the connect itself fails // because 192.0.2.1 is unroutable (RFC 5737), so we expect a transport-shaped Exception // (NOT InvalidOperationException complaining about missing TSAPs). var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), TsapMode = TsapMode.Other, LocalTsap = 0x4D57, RemoteTsap = 0x4D58, }; using var drv = new S7Driver(opts, "s7-other-ok"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); // Should not be the "missing LocalTsap/RemoteTsap" guard — that guard rejects on // Type, this should be a transport / timeout failure shape. (ex is InvalidOperationException io && io.Message.Contains("LocalTsap")) .ShouldBeFalse("Other-mode with both overrides set must pass the BuildPlc guard"); } // ---- DTO round-trip ---- [Fact] public void DTO_round_trip_with_TsapMode_Op_and_explicit_overrides() { // Mirrors what the central config DB hands to S7DriverFactoryExtensions.CreateInstance. var json = """ { "Host": "192.168.1.30", "CpuType": "S71500", "Rack": 0, "Slot": 0, "TsapMode": "Op", "LocalTsap": 768, "RemoteTsap": 770, "Tags": [] } """; var drv = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-dto", json); drv.ShouldNotBeNull(); drv.DriverInstanceId.ShouldBe("s7-dto"); drv.Dispose(); } [Fact] public void DTO_unknown_TsapMode_is_rejected() { var json = """ { "Host": "192.168.1.30", "TsapMode": "ProfiNet", "Tags": [] } """; Should.Throw(() => S7DriverFactoryExtensions.CreateInstance("s7-bad-mode", json)); } [Fact] public void DTO_omitting_TsapMode_falls_back_to_Auto() { // Critical for backwards-compat: pre-PR-S7-C2 configs must still load. var json = """ { "Host": "192.168.1.30", "Tags": [] } """; var drv = S7DriverFactoryExtensions.CreateInstance("s7-legacy", json); drv.ShouldNotBeNull(); drv.Dispose(); } [Fact] public void DTO_round_trip_serialise_then_deserialise_preserves_TSAP_fields() { // Round-trip through the DTO type the factory uses, to catch property-name mismatches. var dto = new S7DriverFactoryExtensions.S7DriverConfigDto { Host = "10.0.0.5", TsapMode = "S7Basic", LocalTsap = 0x0300, RemoteTsap = 0x0302, }; var json = JsonSerializer.Serialize(dto); var back = JsonSerializer.Deserialize(json)!; back.TsapMode.ShouldBe("S7Basic"); back.LocalTsap.ShouldBe((ushort)0x0300); back.RemoteTsap.ShouldBe((ushort)0x0302); } }