Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeWalkerTests.cs
2026-04-26 07:28:52 -04:00

441 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// PR 4.1 / #315 — coverage for <see cref="TwinCATTypeWalker"/>. Each test builds a
/// synthetic <see cref="IDataType"/> tree using the in-test stubs below (avoiding
/// dependence on the Beckhoff <c>SymbolType</c> / <c>StructType</c> internal ctors)
/// and asserts the flattened-leaf shape the walker emits.
/// </summary>
[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 ----
/// <summary>
/// Minimal struct type stub. Implements <see cref="IStructType"/> but only the surface
/// <see cref="TwinCATTypeWalker"/> consumes — every other member throws so accidental
/// dependence in future tests surfaces loudly.
/// </summary>
private sealed class FakeStructType : IStructType, IInterfaceType
{
private readonly List<IMember> _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<string>();
public IInterfaceType[] InterfaceImplementations => Array.Empty<IInterfaceType>();
public string BaseTypeName => string.Empty;
public IDataType? BaseType => null;
public IRpcMethodCollection RpcMethods => null!;
}
/// <summary>Member stub — only Name / DataType / ByteOffset are consumed by the walker.</summary>
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;
}
/// <summary>
/// Wraps an <see cref="IList{T}"/> of members in just enough <see cref="IMemberCollection"/>
/// surface for the walker. We don't implement add / remove / etc. because the walker
/// only enumerates; calls to mutating members deliberately throw.
/// </summary>
private sealed class FakeMemberCollection(List<IMember> 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<IMember> Statics => null!;
public IInstanceCollection<IMember> 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<IMember> 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<IMember>? 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<IMember> 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);
}
/// <summary>Array type stub — fixed dimension list with configurable lower bound + length.</summary>
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<IDimension> _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<IDimension> 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;
/// <summary>Pointer type stub — exists solely so the walker's pointer-skip path runs.</summary>
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;
}
}