441 lines
18 KiB
C#
441 lines
18 KiB
C#
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;
|
||
}
|
||
}
|