using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; [Trait("Category", "Unit")] public sealed class TwinCATSymbolExpanderTests { /// /// A struct with atomic members + a nested struct expands to exactly the atomic leaves, each /// carrying its full dotted InstancePath and mapped atomic type. The struct nodes themselves /// contribute no variable. /// [Fact] public void Struct_expands_to_atomic_member_leaves_with_full_paths() { // MAIN.Motor1 : struct { Speed: REAL, Running: BOOL, Status: struct { Code: DINT } } var motor = Struct("MAIN.Motor1", Atomic("MAIN.Motor1.Speed", TwinCATDataType.Real), Atomic("MAIN.Motor1.Running", TwinCATDataType.Bool), Struct("MAIN.Motor1.Status", Atomic("MAIN.Motor1.Status.Code", TwinCATDataType.DInt))); var leaves = TwinCATSymbolExpander.ExpandLeaves([motor]).ToList(); leaves.Select(l => l.InstancePath).ShouldBe( ["MAIN.Motor1.Speed", "MAIN.Motor1.Running", "MAIN.Motor1.Status.Code"]); leaves.Single(l => l.InstancePath == "MAIN.Motor1.Speed").DataType.ShouldBe(TwinCATDataType.Real); leaves.Single(l => l.InstancePath == "MAIN.Motor1.Running").DataType.ShouldBe(TwinCATDataType.Bool); leaves.Single(l => l.InstancePath == "MAIN.Motor1.Status.Code").DataType.ShouldBe(TwinCATDataType.DInt); // No struct container is ever emitted as a leaf. leaves.ShouldNotContain(l => l.InstancePath == "MAIN.Motor1"); leaves.ShouldNotContain(l => l.InstancePath == "MAIN.Motor1.Status"); } /// A top-level atomic symbol yields itself unchanged. [Fact] public void Top_level_atomic_yields_itself() { var counter = Atomic("MAIN.Counter", TwinCATDataType.DInt); var leaves = TwinCATSymbolExpander.ExpandLeaves([counter]).ToList(); leaves.Count.ShouldBe(1); leaves[0].InstancePath.ShouldBe("MAIN.Counter"); leaves[0].DataType.ShouldBe(TwinCATDataType.DInt); } /// The atomic leaf carries its mapped array length through unchanged (1-D array element). [Fact] public void Array_leaf_carries_element_type_and_length() { var arr = new FakeNode("GVL.Data", IsStruct: false, Mapped: (TwinCATDataType.Int, 4), Children: [], ReadOnly: false); var leaf = TwinCATSymbolExpander.ExpandLeaves([arr]).Single(); leaf.InstancePath.ShouldBe("GVL.Data"); leaf.DataType.ShouldBe(TwinCATDataType.Int); leaf.ArrayLength.ShouldBe(4); } /// The read-only flag is propagated onto the emitted discovered symbol. [Fact] public void ReadOnly_flag_propagates() { var ro = new FakeNode("MAIN.Status", IsStruct: false, Mapped: (TwinCATDataType.DInt, null), Children: [], ReadOnly: true); TwinCATSymbolExpander.ExpandLeaves([ro]).Single().ReadOnly.ShouldBeTrue(); } /// An unsupported atomic leaf (mapped Type == null, e.g. a pointer) is dropped. [Fact] public void Unsupported_atomic_leaf_is_dropped() { var pointer = new FakeNode("MAIN.pData", IsStruct: false, Mapped: (null, null), Children: [], ReadOnly: false); var ok = Atomic("MAIN.Counter", TwinCATDataType.DInt); var leaves = TwinCATSymbolExpander.ExpandLeaves([pointer, ok]).ToList(); leaves.Select(l => l.InstancePath).ShouldBe(["MAIN.Counter"]); } /// An empty struct (no members) yields nothing — there is no addressable atomic. [Fact] public void Empty_struct_yields_nothing() { var empty = Struct("MAIN.Empty"); TwinCATSymbolExpander.ExpandLeaves([empty]).ShouldBeEmpty(); } /// /// A member nested beyond is dropped; members at /// or above the floor still surface. /// [Fact] public void Node_beyond_MaxDepth_is_dropped() { // Build a chain of nested structs: root (depth 0) → struct → struct → ... with a single // atomic leaf at the very bottom. The leaf sits at depth == number-of-struct-levels. // Place the leaf at depth MaxDepth (== one past the deepest descended level) so it is dropped, // and a sibling leaf at a shallow depth so we prove the walk itself still works. ITwinCATSymbolNode deep = Atomic("RootLeaf.Deep", TwinCATDataType.DInt); // Wrap the atomic in MaxDepth struct levels so the atomic ends up at depth == MaxDepth. for (var i = 0; i < TwinCATSymbolExpander.MaxDepth; i++) deep = Struct($"Level{i}", deep); // Shallow leaf reachable at depth 1. var shallow = Struct("Shallow", Atomic("Shallow.Ok", TwinCATDataType.Int)); var leaves = TwinCATSymbolExpander.ExpandLeaves([deep, shallow]).ToList(); leaves.Select(l => l.InstancePath).ShouldBe(["Shallow.Ok"]); } /// A node at exactly the deepest descended level is still emitted (boundary check). [Fact] public void Node_at_max_depth_boundary_is_emitted() { // Nest the atomic under (MaxDepth - 1) struct levels so it sits at depth == MaxDepth - 1, // which is the last level the walker still descends. ITwinCATSymbolNode node = Atomic("Deep.Boundary", TwinCATDataType.DInt); for (var i = 0; i < TwinCATSymbolExpander.MaxDepth - 1; i++) node = Struct($"L{i}", node); var leaves = TwinCATSymbolExpander.ExpandLeaves([node]).ToList(); leaves.Select(l => l.InstancePath).ShouldBe(["Deep.Boundary"]); } /// A duplicate InstancePath (Flat-vs-tree double-listing) is emitted exactly once. [Fact] public void Duplicate_InstancePath_is_emitted_once() { var a = Atomic("MAIN.Counter", TwinCATDataType.DInt); var b = Atomic("MAIN.Counter", TwinCATDataType.DInt); var leaves = TwinCATSymbolExpander.ExpandLeaves([a, b]).ToList(); leaves.Count.ShouldBe(1); leaves[0].InstancePath.ShouldBe("MAIN.Counter"); } // ---- helpers ---- private static FakeNode Atomic(string path, TwinCATDataType type) => new(path, IsStruct: false, Mapped: (type, null), Children: [], ReadOnly: false); private static FakeNode Struct(string path, params ITwinCATSymbolNode[] children) => new(path, IsStruct: true, Mapped: (null, null), Children: children, ReadOnly: false); private sealed record FakeNode( string InstancePath, bool IsStruct, (TwinCATDataType? Type, int? ArrayLength) Mapped, IReadOnlyList Children, bool ReadOnly) : ITwinCATSymbolNode; }