using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// PR-S7-D2 — UDT / STRUCT fan-out unit tests. Verifies the recursive flattening of /// UDT-typed parent tags into scalar leaf member tags happens correctly at /// time, that the depth cap (4 levels) is /// enforced, and that misordered / overlapping / unknown UDT references fail with /// the expected error shape. /// [Trait("Category", "Unit")] public sealed class S7UdtFanOutTests { private static S7DriverOptions OptionsWith(IReadOnlyList tags, IReadOnlyList udts) => new() { Host = "192.0.2.1", // RFC 5737 reserved — never reachable; we only exercise the parse + fan-out path. Timeout = TimeSpan.FromMilliseconds(50), Tags = tags, Udts = udts, Probe = new S7ProbeOptions { Enabled = false }, }; /// /// Run InitializeAsync against an unreachable host so the parse / fan-out validation /// happens before the TCP connect attempt fails. Returns the exception so tests can /// assert on its shape; throws if the unexpected exception type bubbled up. /// private static async Task InitAndCaptureFanOutErrorAsync(S7DriverOptions opts) { using var drv = new S7Driver(opts, "s7-udt-test"); return await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); } /// /// Direct-call helper that bypasses InitializeAsync's TCP connect: invokes the /// fan-out helper directly so the test can inspect the produced leaf tag list /// without spinning up a Plc. /// private static IReadOnlyList FanOut( S7TagDefinition parent, IReadOnlyList udts) { var parsed = S7AddressParser.Parse(parent.Address); return S7UdtFanOut.Expand(parent, udts, parsed); } [Fact] public void Single_level_UDT_with_three_scalar_members_emits_three_tags() { var udt = new S7UdtDefinition("Sensor", new[] { new S7UdtMember("Pressure", 0, S7DataType.Float32), new S7UdtMember("Status", 4, S7DataType.Int16), new S7UdtMember("Enabled", 6, S7DataType.Bool), }, SizeBytes: 8); var parent = new S7TagDefinition("MySensor", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Sensor"); var leaves = FanOut(parent, [udt]); leaves.Count.ShouldBe(3); leaves[0].Name.ShouldBe("MySensor.Pressure"); leaves[0].Address.ShouldBe("DB1.DBD0"); leaves[0].DataType.ShouldBe(S7DataType.Float32); leaves[1].Name.ShouldBe("MySensor.Status"); leaves[1].Address.ShouldBe("DB1.DBW4"); leaves[2].Name.ShouldBe("MySensor.Enabled"); leaves[2].Address.ShouldBe("DB1.DBX6.0"); } [Fact] public void Nested_UDT_recursively_flattens_with_dot_separated_paths() { var inner = new S7UdtDefinition("Telemetry", new[] { new S7UdtMember("Pressure", 0, S7DataType.Float32), new S7UdtMember("Temperature", 4, S7DataType.Float32), }, SizeBytes: 8); var outer = new S7UdtDefinition("Pump", new[] { new S7UdtMember("Telemetry", 0, S7DataType.Byte, UdtName: "Telemetry"), new S7UdtMember("Speed", 8, S7DataType.Int16), }, SizeBytes: 10); var parent = new S7TagDefinition("Pump1", "DB2.DBX0.0", S7DataType.Byte, UdtName: "Pump"); var leaves = FanOut(parent, [outer, inner]); leaves.Count.ShouldBe(3); leaves[0].Name.ShouldBe("Pump1.Telemetry.Pressure"); leaves[0].Address.ShouldBe("DB2.DBD0"); leaves[1].Name.ShouldBe("Pump1.Telemetry.Temperature"); leaves[1].Address.ShouldBe("DB2.DBD4"); leaves[2].Name.ShouldBe("Pump1.Speed"); leaves[2].Address.ShouldBe("DB2.DBW8"); } [Fact] public void Four_level_nesting_succeeds_at_the_cap() { // L4 is the deepest level the cap allows. var l4 = new S7UdtDefinition("L4", [new S7UdtMember("Leaf", 0, S7DataType.Int16)], SizeBytes: 2); var l3 = new S7UdtDefinition("L3", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L4")], SizeBytes: 2); var l2 = new S7UdtDefinition("L2", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L3")], SizeBytes: 2); var l1 = new S7UdtDefinition("L1", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L2")], SizeBytes: 2); var parent = new S7TagDefinition("Root", "DB1.DBX0.0", S7DataType.Byte, UdtName: "L1"); var leaves = FanOut(parent, [l1, l2, l3, l4]); leaves.Count.ShouldBe(1); leaves[0].Name.ShouldBe("Root.X.X.X.Leaf"); } [Fact] public void Five_level_nesting_throws_with_clear_error() { // Add a fifth level — exceeds the depth cap. var l5 = new S7UdtDefinition("L5", [new S7UdtMember("Leaf", 0, S7DataType.Int16)], SizeBytes: 2); var l4 = new S7UdtDefinition("L4", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L5")], SizeBytes: 2); var l3 = new S7UdtDefinition("L3", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L4")], SizeBytes: 2); var l2 = new S7UdtDefinition("L2", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L3")], SizeBytes: 2); var l1 = new S7UdtDefinition("L1", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L2")], SizeBytes: 2); var parent = new S7TagDefinition("Root", "DB1.DBX0.0", S7DataType.Byte, UdtName: "L1"); var ex = Should.Throw(() => FanOut(parent, [l1, l2, l3, l4, l5])); ex.Message.ShouldContain("nesting depth", Case.Insensitive); ex.Message.ShouldContain("4"); } [Fact] public void Reference_to_unknown_UDT_throws_with_clear_error() { var parent = new S7TagDefinition("MissingUdtTag", "DB1.DBX0.0", S7DataType.Byte, UdtName: "DoesNotExist"); var ex = Should.Throw(() => FanOut(parent, [])); ex.Message.ShouldContain("DoesNotExist"); ex.Message.ShouldContain("MissingUdtTag"); ex.Message.ShouldContain("not declared", Case.Insensitive); } [Fact] public void Misordered_member_offsets_throw_at_fan_out() { var udt = new S7UdtDefinition("Bad", new[] { new S7UdtMember("First", 4, S7DataType.Int16), new S7UdtMember("Second", 0, S7DataType.Int16), // earlier byte than First. }, SizeBytes: 8); var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Bad"); var ex = Should.Throw(() => FanOut(parent, [udt])); ex.Message.ShouldContain("misordered", Case.Insensitive); } [Fact] public void Overlapping_member_offsets_throw_at_fan_out() { // Float32 at offset 0 occupies bytes 0..3; Int16 at offset 2 overlaps. var udt = new S7UdtDefinition("Overlap", new[] { new S7UdtMember("Wide", 0, S7DataType.Float32), new S7UdtMember("Narrow", 2, S7DataType.Int16), }, SizeBytes: 8); var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Overlap"); var ex = Should.Throw(() => FanOut(parent, [udt])); ex.Message.ShouldContain("overlap", Case.Insensitive); } [Fact] public void Array_member_with_three_elements_emits_three_indexed_sub_tags() { var udt = new S7UdtDefinition("Bank", new[] { new S7UdtMember("Sensors", 0, S7DataType.Float32, ArrayDim: 3), new S7UdtMember("Count", 12, S7DataType.Int16), }, SizeBytes: 14); var parent = new S7TagDefinition("Bank1", "DB3.DBX0.0", S7DataType.Byte, UdtName: "Bank"); var leaves = FanOut(parent, [udt]); leaves.Count.ShouldBe(4); leaves[0].Name.ShouldBe("Bank1.Sensors[0]"); leaves[0].Address.ShouldBe("DB3.DBD0"); leaves[1].Name.ShouldBe("Bank1.Sensors[1]"); leaves[1].Address.ShouldBe("DB3.DBD4"); leaves[2].Name.ShouldBe("Bank1.Sensors[2]"); leaves[2].Address.ShouldBe("DB3.DBD8"); leaves[3].Name.ShouldBe("Bank1.Count"); leaves[3].Address.ShouldBe("DB3.DBW12"); } [Fact] public void DTO_round_trip_preserves_UdtName_and_Udts_collection() { var dto = new S7DriverFactoryExtensions.S7DriverConfigDto { Host = "10.0.0.5", Tags = [ new S7DriverFactoryExtensions.S7TagDto { Name = "Pump1", Address = "DB1.DBX0.0", DataType = "Byte", UdtName = "Pump", } ], Udts = [ new S7DriverFactoryExtensions.S7UdtDto { Name = "Pump", SizeBytes = 8, Members = [ new S7DriverFactoryExtensions.S7UdtMemberDto { Name = "Pressure", Offset = 0, DataType = "Float32" }, new S7DriverFactoryExtensions.S7UdtMemberDto { Name = "Status", Offset = 4, DataType = "Int16" }, ], } ], }; var json = JsonSerializer.Serialize(dto); var back = JsonSerializer.Deserialize(json)!; back.Tags.ShouldNotBeNull(); back.Tags!.Count.ShouldBe(1); back.Tags[0].UdtName.ShouldBe("Pump"); back.Udts.ShouldNotBeNull(); back.Udts!.Count.ShouldBe(1); back.Udts[0].Name.ShouldBe("Pump"); back.Udts[0].SizeBytes.ShouldBe(8); back.Udts[0].Members!.Count.ShouldBe(2); back.Udts[0].Members![0].Name.ShouldBe("Pressure"); back.Udts[0].Members![0].DataType.ShouldBe("Float32"); } [Fact] public void Tag_with_both_UdtName_and_DataType_uses_UdtName() { // When both are set, UdtName wins — the primitive DataType is ignored because the // parent tag is replaced wholesale by its scalar leaves at fan-out time. var udt = new S7UdtDefinition("Tiny", [new S7UdtMember("V", 0, S7DataType.Int16)], SizeBytes: 2); // Parent declares DataType=Float32 and UdtName=Tiny. The leaf comes out as Int16 (per UDT), // not Float32, proving UdtName trumps the primitive DataType. var parent = new S7TagDefinition("Either", "DB1.DBX0.0", S7DataType.Float32, UdtName: "Tiny"); var leaves = FanOut(parent, [udt]); leaves.Count.ShouldBe(1); leaves[0].DataType.ShouldBe(S7DataType.Int16); leaves[0].Name.ShouldBe("Either.V"); } [Fact] public async Task Initialize_with_UDT_tag_replaces_parent_with_leaves_in_address_space() { // End-to-end driver path: fan-out should populate the discovery folder with the // leaves, NOT the parent. Initialize will fail at TCP connect (unreachable host), // but only AFTER the parse + fan-out has populated the tag map. var udt = new S7UdtDefinition("Sensor", new[] { new S7UdtMember("Pressure", 0, S7DataType.Float32), new S7UdtMember("Status", 4, S7DataType.Int16), }, SizeBytes: 6); var opts = OptionsWith( tags: [new S7TagDefinition("MySensor", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Sensor")], udts: [udt]); var ex = await InitAndCaptureFanOutErrorAsync(opts); // The fan-out itself didn't throw (UDT was found, layout valid) — only the TCP connect // failed with a socket / timeout exception. That's the expected shape. ex.ShouldNotBeOfType(); } [Fact] public async Task Initialize_with_UDT_referencing_unknown_layout_fails_at_fan_out() { var opts = OptionsWith( tags: [new S7TagDefinition("MissingUdtTag", "DB1.DBX0.0", S7DataType.Byte, UdtName: "DoesNotExist")], udts: []); var ex = await InitAndCaptureFanOutErrorAsync(opts); ex.ShouldBeOfType(); ex.Message.ShouldContain("DoesNotExist"); } [Fact] public async Task Initialize_rejects_UDT_tag_with_ElementCount_greater_than_one() { var udt = new S7UdtDefinition("Tiny", [new S7UdtMember("V", 0, S7DataType.Int16)], SizeBytes: 2); var opts = OptionsWith( tags: [new S7TagDefinition("BadArrayUdt", "DB1.DBX0.0", S7DataType.Byte, ElementCount: 5, UdtName: "Tiny")], udts: [udt]); var ex = await InitAndCaptureFanOutErrorAsync(opts); ex.ShouldBeOfType(); ex.Message.ShouldContain("array-of-UDT", Case.Insensitive); } [Fact] public void Array_of_nested_UDT_strides_member_addresses_by_inner_size() { // Inner UDT is 4 bytes wide (one Float32). An array of 3 of these strides 4 bytes // per element. Outer UDT places the array at offset 0, so the leaves should be at // bytes 0, 4, 8. var inner = new S7UdtDefinition("Reading", [new S7UdtMember("Value", 0, S7DataType.Float32)], SizeBytes: 4); var outer = new S7UdtDefinition("Channel", new[] { new S7UdtMember("Readings", 0, S7DataType.Byte, ArrayDim: 3, UdtName: "Reading"), }, SizeBytes: 12); var parent = new S7TagDefinition("Ch1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Channel"); var leaves = FanOut(parent, [outer, inner]); leaves.Count.ShouldBe(3); leaves[0].Name.ShouldBe("Ch1.Readings[0].Value"); leaves[0].Address.ShouldBe("DB1.DBD0"); leaves[1].Name.ShouldBe("Ch1.Readings[1].Value"); leaves[1].Address.ShouldBe("DB1.DBD4"); leaves[2].Name.ShouldBe("Ch1.Readings[2].Value"); leaves[2].Address.ShouldBe("DB1.DBD8"); } [Fact] public void Members_extending_past_SizeBytes_throw() { // A 4-byte Float32 at offset 6 ends at 10, but SizeBytes claims 8. var udt = new S7UdtDefinition("Cramped", new[] { new S7UdtMember("X", 6, S7DataType.Float32), }, SizeBytes: 8); var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Cramped"); var ex = Should.Throw(() => FanOut(parent, [udt])); ex.Message.ShouldContain("SizeBytes", Case.Insensitive); } }