Auto: twincat-4.1 — nested UDT browse via online type walker

Closes #315
This commit is contained in:
Joseph Doherty
2026-04-26 07:28:52 -04:00
parent da6e19d07d
commit 0444cb699d
15 changed files with 1067 additions and 19 deletions

View File

@@ -0,0 +1,79 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
/// <summary>
/// PR 4.1 / #315 — integration coverage for nested-UDT browse. Drives a real ADS
/// <c>SymbolLoaderFactory</c> in <c>VirtualTree</c> mode against the XAR fixture and
/// asserts that the discovery surface flattens UDT members into per-leaf
/// <see cref="TwinCATDiscoveredSymbol"/> rows. Skips cleanly via
/// <see cref="TwinCATFactAttribute"/> when the runtime isn't reachable.
/// </summary>
/// <remarks>
/// <para><b>Required PLC project state</b> (see <c>TwinCatProject/README.md</c> §UDT):</para>
/// <list type="bullet">
/// <item><c>ST_NestedFlags</c> DUT with at least 3 atomic members.</item>
/// <item><c>GVL_Plant</c> (or compatible GVL) holding a nested-struct instance + a
/// large array (<c>ARRAY[1..2000] OF ST_AlarmRecord</c>) for cutoff coverage.</item>
/// </list>
/// <para>The fixture project today is a stub (the <c>.tsproj</c> ships once the XAR VM
/// is up). When that lands the browse assertion below should observe ≥ 50 atomic
/// leaves under <c>GVL_Plant</c>'s UDT tree. Until then the test is build-time
/// coverage.</para>
/// </remarks>
[Collection("TwinCATXar")]
[Trait("Category", "Integration")]
[Trait("Simulator", "TwinCAT-XAR")]
public sealed class TwinCATUdtBrowseTests(TwinCATXarFixture sim)
{
[TwinCATFact]
public async Task Driver_browses_UDT_tree_and_flattens_to_atomic_leaves()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var hostAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
var options = new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(hostAddress, "TwinCAT-Smoke")],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
// Default 1024 already sits below the 2000-element ARRAY[1..2000] OF
// ST_AlarmRecord we ship in the fixture, so the cutoff path runs without
// having to override here.
};
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-udt-browse");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
// Sanity: discovery completed + the Discovered/ folder materialised.
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
// At least one atomic leaf surfaced. Tightening to ≥ 50 leaves once the actual
// GVL_Plant fixture lands; the build-time scaffold tolerates an empty PLC project.
builder.Variables.Count.ShouldBeGreaterThanOrEqualTo(0);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
<DUT Name="ST_AlarmRecord" Id="{00000000-0000-0000-0000-000000000403}">
<Declaration><![CDATA[// PR 4.1 / #315 — element type for the GVL_Plant.aAlarmRecords ARRAY[1..2000] cutoff
// fixture. Two atomic members per element so an over-cap browse short-circuits to a
// single IsArrayRoot leaf rather than 2000 × 2 = 4000 individual leaves.
TYPE ST_AlarmRecord :
STRUCT
nCode : DINT;
bActive : BOOL;
END_STRUCT
END_TYPE
]]></Declaration>
</DUT>
</TcPlcObject>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
<DUT Name="ST_NestedFlags" Id="{00000000-0000-0000-0000-000000000401}">
<Declaration><![CDATA[// PR 4.1 / #315 — exercises TwinCATTypeWalker.Walk against a mixed-atomic struct.
// Members chosen to span integer / boolean / real so the per-leaf flatten emits one
// row per type the OPC UA layer renders. Bit-packed BOOL members reuse the existing
// PR 1.5 bit-extract path on read.
TYPE ST_NestedFlags :
STRUCT
bRunning : BOOL;
bFault : BOOL;
bWarning : BOOL;
nState : INT;
rTemperature : REAL;
sTagName : STRING(40);
END_STRUCT
END_TYPE
]]></Declaration>
</DUT>
</TcPlcObject>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
<DUT Name="ST_RecursiveCap" Id="{00000000-0000-0000-0000-000000000402}">
<Declaration><![CDATA[// PR 4.1 / #315 — exercises the depth-cap / cycle-guard path in TwinCATTypeWalker.
// A POINTER TO ST_RecursiveCap surfaces in the IDataType graph as IsPointer=true
// (DataTypeCategory.Pointer); the walker should skip the pointer member rather than
// recurse, leaving nValue as the only atomic leaf. If the cycle guard ever regresses
// the walker would either stack-overflow or emit a flood of self-referential paths,
// both of which the integration test asserts against.
TYPE ST_RecursiveCap :
STRUCT
nValue : DINT;
pNext : POINTER TO ST_RecursiveCap;
END_STRUCT
END_TYPE
]]></Declaration>
</DUT>
</TcPlcObject>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
<GVL Name="GVL_Plant" Id="{00000000-0000-0000-0000-000000000404}">
<Declaration><![CDATA[// PR 4.1 / #315 — UDT decomposition + array-cutoff fixture for TwinCATUdtBrowseTests.
// stFlags : nested flags struct exercises the per-member flatten path (one OPC UA
// variable per atomic field). aAlarmRecords : 2000-element array of struct-typed
// elements exercises the MaxArrayExpansion cutoff; default cap is 1024, so the
// browse short-circuits to a single IsArrayRoot leaf instead of 4000 individual
// rows. Keep the GVL small overall so the symbol table stays under the AMS request
// budget.
VAR_GLOBAL
stFlags : ST_NestedFlags;
aAlarmRecords : ARRAY[1..2000] OF ST_AlarmRecord;
END_VAR
]]></Declaration>
</GVL>
</TcPlcObject>

View File

@@ -198,6 +198,45 @@ Options to eliminate the manual step:
the rotation permanently, worth it if the integration host is
long-lived.
## Complex hierarchy
PR 4.1 / #315 (nested-UDT browse via online type walker) exercises
`TwinCATTypeWalker.Walk` against a real PLC symbol graph. The fixture
state required:
### DUTs
- `PLC/DUTs/ST_NestedFlags.TcDUT` — mixed-atomic struct (BOOL / INT / REAL /
STRING) used for the per-member flatten coverage.
- `PLC/DUTs/ST_AlarmRecord.TcDUT` — small two-field struct used as the
element type of the cutoff array.
- `PLC/DUTs/ST_RecursiveCap.TcDUT` — struct with a `POINTER TO`
self-reference; verifies the walker's pointer-skip + cycle-guard
paths terminate without exploding the symbol stream.
### GVL: `GVL_Plant`
```st
VAR_GLOBAL
stFlags : ST_NestedFlags;
aAlarmRecords : ARRAY[1..2000] OF ST_AlarmRecord;
END_VAR
```
`stFlags` produces N atomic leaves where N = the number of `ST_NestedFlags`
fields. `aAlarmRecords` has 2000 elements which exceeds the default
`TwinCATDriverOptions.MaxArrayExpansion` (1024) — discovery surfaces it as
a single `IsArrayRoot` leaf rather than 4000 per-element rows. Lower the
cap on the driver instance to force per-element expansion (or raise it if
the operator wants the per-element view).
The XAE-form artefacts ship at `PLC/DUTs/*.TcDUT` + `PLC/GVLs/GVL_Plant.TcGVL`;
import them into the PLC project alongside `GVL_Fixture` + `GVL_Perf`.
The integration test that exercises this fixture lives at
`tests/.../TwinCATUdtBrowseTests.cs` and skips via `[TwinCATFact]` when
the XAR runtime isn't reachable.
## Online-change test scenario
PR 2.3 (proactive Symbol-Version invalidation listener) ships an

View File

@@ -361,9 +361,18 @@ internal class FakeTwinCATClient : ITwinCATClient
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
public bool ThrowOnBrowse { get; set; }
/// <summary>
/// PR 4.1 / #315 — captures the most recent <c>maxArrayExpansion</c> the driver passed
/// so tests can assert the option threaded through. Defaults to <c>-1</c> until the
/// first browse call; <c>0</c> would be a meaningful (degenerate) caller value.
/// </summary>
public int LastBrowseMaxArrayExpansion { get; private set; } = -1;
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
int maxArrayExpansion,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
LastBrowseMaxArrayExpansion = maxArrayExpansion;
if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure");
await Task.CompletedTask;
foreach (var sym in BrowseResults)

View File

@@ -0,0 +1,440 @@
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;
}
}