using System.Collections; using Shouldly; using TwinCAT.Ads.TypeSystem; using TwinCAT.TypeSystem; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// PR 4.1 / #315 — coverage for . Each test builds a /// synthetic tree using the in-test stubs below (avoiding /// dependence on the Beckhoff SymbolType / StructType internal ctors) /// and asserts the flattened-leaf shape the walker emits. /// [Trait("Category", "Unit")] public sealed class TwinCATTypeWalkerTests { private static readonly TwinCATDriverOptions DefaultOptions = new(); [Fact] public void Atomic_root_emits_single_leaf() { var dint = new PrimitiveType("DINT", typeof(int)); var leaves = TwinCATTypeWalker .Walk(dint, "MAIN.nCounter", offsetRoot: 0, readOnly: false, DefaultOptions) .ToList(); leaves.Count.ShouldBe(1); leaves[0].InstancePath.ShouldBe("MAIN.nCounter"); leaves[0].AtomicType.ShouldBe(TwinCATDataType.DInt); leaves[0].IsArrayRoot.ShouldBeFalse(); } [Fact] public void Single_level_struct_emits_one_leaf_per_member() { var dint = new PrimitiveType("DINT", typeof(int)); var real = new PrimitiveType("REAL", typeof(float)); var boolType = new PrimitiveType("BOOL", typeof(bool)); var st = new FakeStructType("ST_Sample", new FakeMember("nCounter", dint, byteOffset: 0), new FakeMember("rSetpoint", real, byteOffset: 4), new FakeMember("bRunning", boolType, byteOffset: 8)); var leaves = TwinCATTypeWalker .Walk(st, "MAIN.Sample", 0, false, DefaultOptions) .ToList(); leaves.Select(l => l.InstancePath).ShouldBe( ["MAIN.Sample.nCounter", "MAIN.Sample.rSetpoint", "MAIN.Sample.bRunning"]); leaves.Select(l => l.AtomicType).ShouldBe( [TwinCATDataType.DInt, TwinCATDataType.Real, TwinCATDataType.Bool]); leaves.Select(l => l.Offset).ShouldBe([0, 4, 8]); } [Fact] public void Nested_struct_recurses_with_dotted_paths() { var dint = new PrimitiveType("DINT", typeof(int)); var inner = new FakeStructType("ST_Inner", new FakeMember("nValue", dint, byteOffset: 0), new FakeMember("nFlags", dint, byteOffset: 4)); var outer = new FakeStructType("ST_Outer", new FakeMember("Inner", inner, byteOffset: 8), new FakeMember("nTop", dint, byteOffset: 0)); var leaves = TwinCATTypeWalker .Walk(outer, "GVL.Sample", 0, false, DefaultOptions) .ToList(); // First member walks into Inner — its offset is 8, so leaves are at 8/12; nTop at 0. leaves.Select(l => (l.InstancePath, l.Offset)).ShouldBe( [ ("GVL.Sample.Inner.nValue", 8), ("GVL.Sample.Inner.nFlags", 12), ("GVL.Sample.nTop", 0), ]); } [Fact] public void Array_of_atomic_within_bound_emits_per_element_leaves() { var dint = new PrimitiveType("DINT", typeof(int)); var arr = new FakeArrayType("ARRAY[1..5] OF DINT", dint, elementByteSize: 4, lowerBound: 1, length: 5); var leaves = TwinCATTypeWalker .Walk(arr, "GVL.Tags", 0, false, DefaultOptions) .ToList(); leaves.Count.ShouldBe(5); leaves.Select(l => l.InstancePath).ShouldBe( ["GVL.Tags[1]", "GVL.Tags[2]", "GVL.Tags[3]", "GVL.Tags[4]", "GVL.Tags[5]"]); leaves.All(l => l.AtomicType == TwinCATDataType.DInt).ShouldBeTrue(); leaves.Select(l => l.Offset).ShouldBe([0, 4, 8, 12, 16]); } [Fact] public void Array_of_atomic_over_bound_emits_single_root_leaf() { var dint = new PrimitiveType("DINT", typeof(int)); var arr = new FakeArrayType("ARRAY[1..5000] OF DINT", dint, elementByteSize: 4, lowerBound: 1, length: 5000); var leaves = TwinCATTypeWalker .Walk(arr, "GVL.Big", 0, false, DefaultOptions) .ToList(); leaves.Count.ShouldBe(1); leaves[0].InstancePath.ShouldBe("GVL.Big"); leaves[0].IsArrayRoot.ShouldBeTrue(); leaves[0].ArrayLength.ShouldBe(5000); leaves[0].AtomicType.ShouldBe(TwinCATDataType.DInt); } [Fact] public void Array_of_struct_within_bound_expands_per_member_per_element() { var dint = new PrimitiveType("DINT", typeof(int)); var real = new PrimitiveType("REAL", typeof(float)); var st = new FakeStructType("ST_Pair", new FakeMember("nCount", dint, byteOffset: 0), new FakeMember("rValue", real, byteOffset: 4)); var arr = new FakeArrayType("ARRAY[0..2] OF ST_Pair", st, elementByteSize: 8, lowerBound: 0, length: 3); var leaves = TwinCATTypeWalker .Walk(arr, "GVL.Pairs", 0, false, DefaultOptions) .ToList(); // 3 elements × 2 members = 6 leaves, with progressing offsets. leaves.Count.ShouldBe(6); leaves.Select(l => l.InstancePath).ShouldBe( [ "GVL.Pairs[0].nCount", "GVL.Pairs[0].rValue", "GVL.Pairs[1].nCount", "GVL.Pairs[1].rValue", "GVL.Pairs[2].nCount", "GVL.Pairs[2].rValue", ]); leaves.Select(l => l.Offset).ShouldBe([0, 4, 8, 12, 16, 20]); } [Fact] public void Alias_walks_through_to_base_type() { var dint = new PrimitiveType("DINT", typeof(int)); var alias = new AliasType("DegreesC", dint); var leaves = TwinCATTypeWalker .Walk(alias, "MAIN.tTemp", 0, false, DefaultOptions) .ToList(); leaves.Count.ShouldBe(1); leaves[0].InstancePath.ShouldBe("MAIN.tTemp"); leaves[0].AtomicType.ShouldBe(TwinCATDataType.DInt); } [Fact] public void Pointer_member_is_skipped() { var dint = new PrimitiveType("DINT", typeof(int)); var ptr = new FakePointerType("POINTER TO DINT"); var st = new FakeStructType("ST_WithPtr", new FakeMember("nValue", dint, byteOffset: 0), new FakeMember("pNext", ptr, byteOffset: 4)); var leaves = TwinCATTypeWalker .Walk(st, "MAIN.Node", 0, false, DefaultOptions) .ToList(); // Pointer member dropped; atomic neighbour preserved. leaves.Count.ShouldBe(1); leaves[0].InstancePath.ShouldBe("MAIN.Node.nValue"); } [Fact] public void Self_referencing_struct_terminates_within_depth_cap() { var dint = new PrimitiveType("DINT", typeof(int)); var st = new FakeStructType("ST_Recursive"); // Self-pointer-via-reference: a member whose type is the same struct. Real PLCs would // use POINTER TO ST_Recursive (which is filtered by the IsPointer guard) but a direct // recursive struct is the stronger guard test. st.AddMember(new FakeMember("nValue", dint, byteOffset: 0)); st.AddMember(new FakeMember("Self", st, byteOffset: 4)); var leaves = TwinCATTypeWalker .Walk(st, "MAIN.Recursive", 0, false, DefaultOptions) .ToList(); // Walker should terminate. nValue surfaces; the Self member breaks the cycle. leaves.Any().ShouldBeTrue(); leaves.Any(l => l.InstancePath == "MAIN.Recursive.nValue").ShouldBeTrue(); // No infinite recursion: count is bounded. leaves.Count.ShouldBeLessThan(20); } [Fact] public void Custom_max_array_expansion_is_honored() { var dint = new PrimitiveType("DINT", typeof(int)); var arr = new FakeArrayType("ARRAY[1..50] OF DINT", dint, elementByteSize: 4, lowerBound: 1, length: 50); var opts = new TwinCATDriverOptions { MaxArrayExpansion = 32 }; var leaves = TwinCATTypeWalker .Walk(arr, "GVL.Mid", 0, false, opts) .ToList(); // 50 > 32 → single-root leaf. leaves.Count.ShouldBe(1); leaves[0].IsArrayRoot.ShouldBeTrue(); leaves[0].ArrayLength.ShouldBe(50); } [Fact] public void Read_only_flag_propagates_to_every_leaf() { var dint = new PrimitiveType("DINT", typeof(int)); var st = new FakeStructType("ST_RO", new FakeMember("a", dint, byteOffset: 0), new FakeMember("b", dint, byteOffset: 4)); var leaves = TwinCATTypeWalker .Walk(st, "GVL.Status", 0, readOnly: true, DefaultOptions) .ToList(); leaves.Count.ShouldBe(2); leaves.All(l => l.ReadOnly).ShouldBeTrue(); } [Fact] public void Empty_path_root_does_not_emit_leading_dot() { var dint = new PrimitiveType("DINT", typeof(int)); var st = new FakeStructType("ST_Empty", new FakeMember("Field", dint, byteOffset: 0)); var leaves = TwinCATTypeWalker .Walk(st, "", 0, false, DefaultOptions) .ToList(); leaves.Single().InstancePath.ShouldBe("Field"); } // ---- in-test stubs implementing the TwinCAT interface surface the walker depends on ---- /// /// Minimal struct type stub. Implements but only the surface /// consumes — every other member throws so accidental /// dependence in future tests surfaces loudly. /// private sealed class FakeStructType : IStructType, IInterfaceType { private readonly List _members = new(); public FakeStructType(string name, params IMember[] members) { Name = name; foreach (var m in members) _members.Add(m); } public void AddMember(IMember m) => _members.Add(m); public string Name { get; } public DataTypeCategory Category => DataTypeCategory.Struct; public IMemberCollection Members => new FakeMemberCollection(_members); public IMemberCollection AllMembers => new FakeMemberCollection(_members); public string FullName => Name; public string Namespace => string.Empty; public int Id => 0; public string Comment => string.Empty; public ITypeAttributeCollection Attributes => null!; public bool IsContainer => true; public bool IsPointer => false; public bool IsReference => false; public bool IsPrimitive => false; public int Size => 0; public int ByteSize => _members.Sum(m => m.ByteSize); public int BitSize => ByteSize * 8; public bool IsBitType => false; public bool IsByteAligned => true; public bool HasStaticFields => false; public bool HasRpcMethods => false; public string[] InterfaceImplementationNames => Array.Empty(); public IInterfaceType[] InterfaceImplementations => Array.Empty(); public string BaseTypeName => string.Empty; public IDataType? BaseType => null; public IRpcMethodCollection RpcMethods => null!; } /// Member stub — only Name / DataType / ByteOffset are consumed by the walker. private sealed class FakeMember(string name, IDataType type, int byteOffset) : IMember { public IDataType DataType { get; } = type; public string TypeName => DataType.Name; public string InstanceName { get; } = name; public string InstancePath => InstanceName; public bool IsStatic => false; public bool IsReference => false; public bool IsPointer => false; public string Comment => string.Empty; public bool IsProperty => false; public IDataType ParentType => null!; public int Offset => byteOffset; public int ByteOffset { get; } = byteOffset; public int BitOffset => byteOffset * 8; public int Size => DataType.ByteSize; public int ByteSize => DataType.ByteSize; public int BitSize => DataType.BitSize; public bool IsBitType => false; public bool IsByteAligned => true; public ITypeAttributeCollection Attributes => null!; public System.Text.Encoding ValueEncoding => System.Text.Encoding.ASCII; } /// /// Wraps an of members in just enough /// surface for the walker. We don't implement add / remove / etc. because the walker /// only enumerates; calls to mutating members deliberately throw. /// private sealed class FakeMemberCollection(List items) : IMemberCollection { public IMember this[int index] { get => items[index]; set => throw new NotSupportedException(); } public IMember this[string name] => items.First(m => m.InstanceName == name); public int Count => items.Count; public bool IsReadOnly => true; public IInstanceCollection Statics => null!; public IInstanceCollection Instances => null!; public InstanceCollectionMode Mode => InstanceCollectionMode.Names; public void Add(IMember item) => throw new NotSupportedException(); public void Clear() => throw new NotSupportedException(); public bool Contains(IMember item) => items.Contains(item); public void CopyTo(IMember[] array, int arrayIndex) => items.CopyTo(array, arrayIndex); public bool Remove(IMember item) => throw new NotSupportedException(); public IEnumerator GetEnumerator() => items.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator(); public int IndexOf(IMember item) => items.IndexOf(item); public void Insert(int index, IMember item) => throw new NotSupportedException(); public void RemoveAt(int index) => throw new NotSupportedException(); public bool Contains(string instancePath) => items.Any(m => m.InstanceName == instancePath); public bool ContainsName(string name) => items.Any(m => m.InstanceName == name); public bool TryGetInstance(string instancePath, out IMember symbol) { symbol = items.FirstOrDefault(m => m.InstanceName == instancePath)!; return symbol is not null; } public bool TryGetInstanceByName(string name, out IList? matches) { matches = items.Where(m => m.InstanceName == name).ToList(); return matches.Count > 0; } public IMember GetInstance(string instancePath) => items.First(m => m.InstanceName == instancePath); public IList GetInstanceByName(string name) => items.Where(m => m.InstanceName == name).ToList(); public bool TryGetMember(string name, out IMember? member) { member = items.FirstOrDefault(m => m.InstanceName == name); return member is not null; } public int CalcSize() => items.Sum(m => m.ByteSize); } /// Array type stub — fixed dimension list with configurable lower bound + length. private sealed class FakeArrayType(string name, IDataType elementType, int elementByteSize, int lowerBound, int length) : IArrayType { public string Name { get; } = name; public DataTypeCategory Category => DataTypeCategory.Array; public IDataType ElementType { get; } = elementType; public string ElementTypeName => ElementType.Name; public IDimensionCollection Dimensions { get; } = new FakeDimensionCollection(lowerBound, length); public bool IsJagged => false; public int JaggedLevel => 1; public string FullName => Name; public string Namespace => string.Empty; public int Id => 0; public string Comment => string.Empty; public ITypeAttributeCollection Attributes => null!; public bool IsContainer => true; public bool IsPointer => false; public bool IsReference => false; public bool IsPrimitive => false; public int Size => length * elementByteSize; public int ByteSize => length * elementByteSize; public int BitSize => ByteSize * 8; public bool IsBitType => false; public bool IsByteAligned => true; } private sealed class FakeDimensionCollection(int lowerBound, int length) : IDimensionCollection { private readonly List _dims = new() { new FakeDimension(lowerBound, length) }; public int ElementCount => length; public int[] LowerBounds => new[] { lowerBound }; public int[] UpperBounds => new[] { lowerBound + length - 1 }; public bool IsNonZeroBased => lowerBound != 0; public int[] GetDimensionLengths() => new[] { length }; public IDimension this[int index] { get => _dims[index]; set => throw new NotSupportedException(); } public int Count => _dims.Count; public bool IsReadOnly => true; public void Add(IDimension item) => throw new NotSupportedException(); public void Clear() => throw new NotSupportedException(); public bool Contains(IDimension item) => _dims.Contains(item); public void CopyTo(IDimension[] array, int arrayIndex) => _dims.CopyTo(array, arrayIndex); public bool Remove(IDimension item) => throw new NotSupportedException(); public IEnumerator GetEnumerator() => _dims.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _dims.GetEnumerator(); public int IndexOf(IDimension item) => _dims.IndexOf(item); public void Insert(int index, IDimension item) => throw new NotSupportedException(); public void RemoveAt(int index) => throw new NotSupportedException(); } private sealed record FakeDimension(int LowerBound, int ElementCount) : IDimension; /// Pointer type stub — exists solely so the walker's pointer-skip path runs. private sealed class FakePointerType(string name) : IDataType { public string Name { get; } = name; public DataTypeCategory Category => DataTypeCategory.Pointer; public string FullName => Name; public string Namespace => string.Empty; public int Id => 0; public string Comment => string.Empty; public ITypeAttributeCollection Attributes => null!; public bool IsContainer => false; public bool IsPointer => true; public bool IsReference => false; public bool IsPrimitive => false; public int Size => 4; public int ByteSize => 4; public int BitSize => 32; public bool IsBitType => false; public bool IsByteAligned => true; } }