From 0444cb699d294685da1a50cb980cb842b25d0912 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 07:28:52 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20twincat-4.1=20=E2=80=94=20nested=20UDT?= =?UTF-8?q?=20browse=20via=20online=20type=20walker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #315 --- docs/Driver.TwinCAT.Cli.md | 50 ++ docs/drivers/TwinCAT-Test-Fixture.md | 16 + .../AdsTwinCATClient.cs | 24 +- .../ITwinCATClient.cs | 43 +- .../TwinCATDriver.cs | 71 ++- .../TwinCATDriverOptions.cs | 12 + .../TwinCATTypeWalker.cs | 233 ++++++++++ .../TwinCATUdtBrowseTests.cs | 79 ++++ .../PLC/DUTs/ST_AlarmRecord.TcDUT | 15 + .../PLC/DUTs/ST_NestedFlags.TcDUT | 20 + .../PLC/DUTs/ST_RecursiveCap.TcDUT | 18 + .../TwinCatProject/PLC/GVLs/GVL_Plant.TcGVL | 17 + .../TwinCatProject/README.md | 39 ++ .../FakeTwinCATClient.cs | 9 + .../TwinCATTypeWalkerTests.cs | 440 ++++++++++++++++++ 15 files changed, 1067 insertions(+), 19 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATUdtBrowseTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_AlarmRecord.TcDUT create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_NestedFlags.TcDUT create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_RecursiveCap.TcDUT create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Plant.TcGVL create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeWalkerTests.cs diff --git a/docs/Driver.TwinCAT.Cli.md b/docs/Driver.TwinCAT.Cli.md index 69412aa..e83b0e6 100644 --- a/docs/Driver.TwinCAT.Cli.md +++ b/docs/Driver.TwinCAT.Cli.md @@ -28,6 +28,56 @@ sessions. Pick one: The CLI compiles + runs without a router, but every wire call fails with a transport error until one is reachable. +## UDT decomposition + +PR 4.1 (issue #315) replaces the old "skip non-atomic symbols" behaviour +of `BrowseSymbolsAsync` with a recursive type walker +(`TwinCATTypeWalker`). When the OtOpcUa server's TwinCAT driver runs +discovery with `EnableControllerBrowse=true`, struct / UDT / function-block +typed symbols flatten into one OPC UA variable per atomic leaf. Browse +addresses use the same dotted-instance form the PLC exposes: + +| PLC declaration | OPC UA browse paths surfaced | +|---|---| +| `MAIN.bStart : BOOL` | `MAIN.bStart` | +| `GVL.stMotor : ST_Motor` | `GVL.stMotor.bRunning`, `GVL.stMotor.nState`, `GVL.stMotor.rTemperature`, … | +| `GVL.aRecipe : ARRAY[1..10] OF DINT` | `GVL.aRecipe[1]` … `GVL.aRecipe[10]` | +| `GVL.aPairs : ARRAY[0..2] OF ST_Pair` | `GVL.aPairs[0].nCount`, `GVL.aPairs[0].rValue`, `GVL.aPairs[1].…` | +| `GVL.aBig : ARRAY[1..5000] OF DINT` | `GVL.aBig` (single whole-array root — over the cap) | + +The CLI's `read` / `write` / `subscribe` commands take dotted paths +directly: + +```powershell +# Read a struct member +otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.stMotor.rTemperature -t Real + +# Read an array element +otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "GVL.aRecipe[3]" -t DInt +``` + +### Array expansion bound + +`TwinCATDriverOptions.MaxArrayExpansion` (default `1024`) caps how many +elements an array contributes to the discovered address space. Arrays +whose total element count exceeds the cap surface as a single +whole-array root with `IsArrayRoot=true` instead of one variable per +element. Raise the bound when operators routinely care about individual +elements of large recipe / lookup tables; lower it to keep discovery +cheap for symbol tables that ship multi-thousand-element scratch +arrays. Pre-declared whole-array tags from the `Tags` config bypass the +walker entirely — set `ArrayDimensions` on a `TwinCATTagDefinition` to +keep array reads on the existing PR 1.4 read-array path. + +### Cycle / depth guard + +The walker tracks the visited-type set + a hard depth cap of 8 levels +so a self-pointer (`POINTER TO ST_Self`) or pathological alias chain +terminates rather than spinning. POINTER / REFERENCE members are +skipped at the type-graph level — surfacing them would require +dereferencing through the AMS routing layer which has its own access +patterns. + ## Common flags | Flag | Default | Purpose | diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md index 82c59f8..4f0327b 100644 --- a/docs/drivers/TwinCAT-Test-Fixture.md +++ b/docs/drivers/TwinCAT-Test-Fixture.md @@ -57,6 +57,14 @@ All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or vars are unset. +PR 4.1 / #315 adds `TwinCATUdtBrowseTests.Driver_browses_UDT_tree_and_flattens_to_atomic_leaves` +which exercises `TwinCATDriver.DiscoverAsync` end-to-end against the +`GVL_Plant` UDT fixture. Asserts the discovery surface emits one OPC UA +variable per atomic leaf and folds `aAlarmRecords[1..2000]` into a +single `IsArrayRoot` placeholder when the element count exceeds the +default 1024-element cap (UDT per-member coverage; see +`TwinCatProject/README.md §Complex hierarchy` for the supporting DUTs). + ### Unit - `TwinCATAmsAddressTests` — `ads://:` parsing + routing @@ -66,6 +74,14 @@ vars are unset. - `TwinCATSymbolPathTests` — symbol-path routing for nested struct members - `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via `ReadSymbolsAsync` (#188) + system-symbol filtering +- `TwinCATTypeWalkerTests` — PR 4.1 / #315 nested-UDT decomposition: + atomic / single-level struct / nested struct / array-of-atomic + (in / over `MaxArrayExpansion`) / array-of-struct / alias chain / + pointer skip / self-referencing struct depth-cap / per-leaf + `MaxArrayExpansion` honored / ReadOnly propagation. Stub `IDataType` + / `IStructType` / `IArrayType` / `IMember` / `IDimensionCollection` + trees built in-test so the walker is exercised without + `Beckhoff.TwinCAT.Ads`-internal ctors. - `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189) registration, callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index 486a58c..5044149 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -485,21 +485,39 @@ internal sealed class AdsTwinCATClient : ITwinCATClient } public async IAsyncEnumerable BrowseSymbolsAsync( + int maxArrayExpansion, [EnumeratorCancellation] CancellationToken cancellationToken) { // SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the // async surface on this interface is for our callers, not for the underlying call which // is effectively sync on top of the already-open AdsClient. - var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat); + // + // PR 4.1 / #315 — switched from VirtualView-flat (top-level only) to VirtualTree so the + // loader hands us each top-level symbol with its full IDataType graph wired up. We then + // run TwinCATTypeWalker against each root to flatten structs / arrays into per-leaf + // entries. Atomic top-level symbols still surface as a single leaf — the walker treats + // a primitive root as a 1-leaf walk. + var settings = new SymbolLoaderSettings(SymbolsLoadMode.VirtualTree); var loader = SymbolLoaderFactory.Create(_client, settings); await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync + // The walker only needs MaxArrayExpansion off TwinCATDriverOptions — synthesise a + // throwaway options instance with just that field set so the walker doesn't gain a + // direct dependency on TwinCATDriverOptions. + var walkerOptions = new TwinCATDriverOptions { MaxArrayExpansion = Math.Max(1, maxArrayExpansion) }; + foreach (ISymbol symbol in loader.Symbols) { if (cancellationToken.IsCancellationRequested) yield break; - var mapped = ResolveSymbolDataType(symbol.DataType); var readOnly = !IsSymbolWritable(symbol); - yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly); + foreach (var leaf in TwinCATTypeWalker.Walk( + symbol.DataType, symbol.InstancePath, offsetRoot: 0, readOnly, walkerOptions)) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new TwinCATDiscoveredSymbol( + leaf.InstancePath, leaf.AtomicType, leaf.ReadOnly, + leaf.IsArrayRoot, leaf.ArrayLength); + } } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index b463731..1b9275b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -115,13 +115,22 @@ public interface ITwinCATClient : IDisposable CancellationToken cancellationToken); /// - /// Walk the target's symbol table via the TwinCAT SymbolLoaderFactory (flat mode). - /// Yields each top-level symbol the PLC exposes — global variables, program-scope locals, - /// function-block instance fields. Filters for our atomic type surface; structured / - /// UDT / function-block typed symbols surface with DataType = null so callers can - /// decide whether to drill in via their own walker. + /// Walk the target's symbol table via the TwinCAT SymbolLoaderFactory (flat mode) + /// and yield one per atomic leaf. PR 4.1 / #315 + /// extended this to recurse into struct / UDT members and array elements via + /// so callers see MyStruct.Inner.Field / + /// aTags[3] rows instead of the parent symbol with DataType = null. /// - IAsyncEnumerable BrowseSymbolsAsync(CancellationToken cancellationToken); + /// + /// Upper bound on per-element expansion for array-typed members. Arrays whose element + /// count exceeds this value surface as a single whole-array root with + /// set instead of N individual + /// Path[i] rows. + /// + /// Cancels the enumeration; in-flight loader downloads still complete. + IAsyncEnumerable BrowseSymbolsAsync( + int maxArrayExpansion, + CancellationToken cancellationToken); /// /// PR 2.2 — wipe process-scoped optional caches (today: the ADS variable-handle @@ -139,16 +148,28 @@ public interface ITwinCATNotificationHandle : IDisposable { } /// /// One symbol yielded by — full instance -/// path + detected + read-only flag. +/// path + detected + read-only flag. PR 4.1 / #315 added +/// + so the discovery layer can +/// surface arrays that exceeded +/// as whole-array tags instead of per-element entries. /// -/// Full dotted symbol path (e.g. MAIN.bStart, GVL.Counter). -/// Mapped ; null when the symbol's type -/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks). +/// Full dotted symbol path (e.g. MAIN.bStart, GVL.Counter, +/// MyStruct.Inner.Field, aTags[3]). +/// Mapped ; null when the symbol's +/// type doesn't map onto our supported atomic surface (struct-typed array root over the +/// expansion cap, pointer / reference / function-block instance). /// true when the symbol's AccessRights flag forbids writes. +/// true when the symbol represents an array whose element count +/// exceeded ; the caller should surface +/// it as a whole-array tag rather than per-element. +/// Element count when is true; +/// null otherwise. public sealed record TwinCATDiscoveredSymbol( string InstancePath, TwinCATDataType? DataType, - bool ReadOnly); + bool ReadOnly, + bool IsArrayRoot = false, + int? ArrayLength = null); /// Factory for s. One client per device. public interface ITwinCATClientFactory diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 3e0baed..be796d8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -408,23 +408,42 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery // Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any // client-side error so a flaky symbol-table download doesn't block discovery. + // + // PR 4.1 / #315 — the symbol stream now contains one entry per atomic leaf rather + // than per top-level symbol. Dotted paths (`MyStruct.Inner.Field`) split into + // nested folders so the OPC UA browse tree mirrors the PLC's UDT hierarchy. Array + // elements (`aTags[3]`) stay as variable leaves under the parent folder — they + // don't get an extra folder level because that would explode the tree for every + // array element while adding no useful navigation. if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state)) { IAddressSpaceBuilder? discoveredFolder = null; + // Cached per-path folder builders so a struct with N members shares the parent + // folder rather than creating it N times. Keyed on the full dotted prefix. + var folderCache = new Dictionary(StringComparer.Ordinal); try { var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false); - await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var sym in client.BrowseSymbolsAsync(_options.MaxArrayExpansion, cancellationToken).ConfigureAwait(false)) { if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue; - if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type + + // Array root over the expansion cap surfaces with IsArrayRoot=true; the + // element type may or may not be atomic. Skip unless the element is + // atomic — there's no useful read shape for a struct-of-N>cap today. + if (sym.IsArrayRoot && sym.DataType is null) continue; + if (sym.DataType is not TwinCATDataType dt) continue; // unsupported leaf type discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered"); - discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo( + var (parentBuilder, leafName) = ResolveLeafFolder( + discoveredFolder, sym.InstancePath, folderCache); + + parentBuilder.Variable(leafName, leafName, new DriverAttributeInfo( FullName: sym.InstancePath, DriverDataType: dt.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + IsArray: sym.IsArrayRoot, + ArrayDim: sym.IsArrayRoot && sym.ArrayLength is int n && n > 0 + ? (uint)n : null, SecurityClass: sym.ReadOnly ? SecurityClassification.ViewOnly : SecurityClassification.Operate, @@ -443,6 +462,48 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery } } + /// + /// PR 4.1 / #315 — split a dotted instance path into (parent folder, leaf segment). + /// Each interior segment maps to a folder; the final segment is the variable name. + /// Folders are cached so two members of the same struct share the parent. Array + /// subscripts on interior segments (e.g. aMotors[0].Status.Running) keep the + /// bracketed segment as a single folder name — the OPC UA browse name preserves the + /// array-element identifier. + /// + internal static (IAddressSpaceBuilder Parent, string LeafName) ResolveLeafFolder( + IAddressSpaceBuilder root, string instancePath, + Dictionary folderCache) + { + var lastDot = instancePath.LastIndexOf('.'); + if (lastDot < 0) + return (root, instancePath); // top-level — no parent folder + + var prefix = instancePath.Substring(0, lastDot); + var leaf = instancePath.Substring(lastDot + 1); + + if (folderCache.TryGetValue(prefix, out var cached)) + return (cached, leaf); + + // Walk segment-by-segment, materialising each folder once. Reuse cached prefixes so + // sibling members share the same parent. + var segments = prefix.Split('.'); + var current = root; + var sb = new System.Text.StringBuilder(); + foreach (var seg in segments) + { + if (sb.Length > 0) sb.Append('.'); + sb.Append(seg); + var key = sb.ToString(); + if (!folderCache.TryGetValue(key, out var folder)) + { + folder = current.Folder(seg, seg); + folderCache[key] = folder; + } + current = folder; + } + return (current, leaf); + } + // ---- ISubscribable (native ADS notifications with poll fallback) ---- private readonly ConcurrentDictionary _nativeSubs = new(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs index 5a3e5f0..10b621e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs @@ -32,6 +32,18 @@ public sealed class TwinCATDriverOptions /// the strict-config path for deployments where only declared tags should appear. /// public bool EnableControllerBrowse { get; init; } + + /// + /// PR 4.1 / #315 — upper bound on per-element array expansion during nested-UDT browse. + /// Arrays whose total ElementCount exceeds this value surface as a single + /// whole-array root leaf instead of N individual Path[i] entries; that keeps + /// pathological multi-thousand-element arrays from inflating the discovered address + /// space. The default of 1024 covers typical recipe / lookup tables; raise it + /// when the PLC routinely ships larger arrays whose individual elements are operationally + /// interesting, lower it to keep discovery cheap. Has no effect on whole-array tags + /// declared in (those bypass the walker entirely). + /// + public int MaxArrayExpansion { get; init; } = 1024; } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs new file mode 100644 index 0000000..ff241fa --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs @@ -0,0 +1,233 @@ +using TwinCAT.TypeSystem; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// PR 4.1 / #315 — pure helper that walks a TwinCAT tree starting at +/// a top-level instance and yields one per atomic leaf along +/// the dotted instance path. Lets expose +/// UDT members as individually-readable OPC UA variable nodes instead of dropping the whole +/// symbol the way PR 1.5 did. +/// +/// +/// Scope — IEC primitives + STRING/WSTRING + IEC time/date types +/// resolve to a leaf; aliases recurse into their base type; structs recurse into each +/// member; arrays expand element-by-element up to ; +/// pointers / references / unions / function-block instances / interfaces are skipped. +/// +/// Recursion guard — the walker tracks visited instances +/// plus a hard depth cap () so a self-pointer or cyclic typedef +/// terminates rather than blowing the stack. +/// +/// Array policy — when an array's ElementCount > MaxArrayExpansion +/// a single leaf is emitted with set so the +/// caller can surface the array root as a whole-array tag (the existing 1.4 read-path +/// handles that). Arrays-of-structs follow the same cap — over the limit, only the +/// root surfaces. +/// +/// Offset — every leaf carries the cumulative byte offset from the symbol +/// root. Not used by the current discovery path (which still resolves writes/reads by +/// symbolic name) but PR 4.x will consume it for offset-based bulk reads. +/// +internal static class TwinCATTypeWalker +{ + /// Hard recursion cap — kicks in before . + internal const int MaxDepth = 8; + + /// + /// One atomic leaf yielded by . Either an atomic-typed scalar / array + /// element ( = false, non-null) or a + /// "too-large array" placeholder where the element count exceeded + /// ( = true + /// + set). + /// + /// Dotted symbol path (e.g. MAIN.Recipe.Step, GVL.Tags[3]). + /// Mapped atomic ; null only when + /// is true and the element type itself is a struct. + /// Byte offset of this leaf relative to the walked root. + /// true when the parent symbol forbids writes. + /// true when this leaf represents an array whose element + /// count exceeded the configured expansion limit; the caller should expose it as a + /// whole-array tag rather than per-element. + /// Element count when is true. + public sealed record DiscoveredLeaf( + string InstancePath, + TwinCATDataType? AtomicType, + int Offset, + bool ReadOnly, + bool IsArrayRoot = false, + int? ArrayLength = null); + + /// + /// Walk and yield every atomic leaf reachable from it. Path + /// prefixes start at ; offsets accumulate from + /// . + /// + public static IEnumerable Walk( + IDataType? root, + string instancePathRoot, + int offsetRoot, + bool readOnly, + TwinCATDriverOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (root is null) yield break; + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var leaf in WalkInner(root, instancePathRoot, offsetRoot, readOnly, options, visited, depth: 0)) + yield return leaf; + } + + private static IEnumerable WalkInner( + IDataType type, + string instancePath, + int offset, + bool readOnly, + TwinCATDriverOptions options, + HashSet visited, + int depth) + { + if (depth >= MaxDepth) yield break; // depth cap — self-pointer / cyclic alias defence + if (!visited.Add(type)) yield break; // already on the recursion stack — break the cycle + + try + { + // Pointers / references — out of scope. Surfacing them would require dereferencing + // through the AMS routing layer which has its own access patterns. The Beckhoff + // 7.x stack moved the IsPointer / IsReference checks off IDataType onto the + // DataTypeExtension static helpers; using DataTypeCategory directly avoids the + // obsolete-warning and is the same wire-level signal. + if (type.Category is DataTypeCategory.Pointer or DataTypeCategory.Reference) yield break; + + // Aliases (incl. enums, since IEnumType : IAliasType) — recurse into BaseType + // without changing the path / offset. Atomic terminus is handled when the base + // hits a primitive. + if (type.Category is DataTypeCategory.Alias or DataTypeCategory.Enum + && type is IAliasType alias && alias.BaseType is not null) + { + foreach (var leaf in WalkInner(alias.BaseType, instancePath, offset, readOnly, options, visited, depth + 1)) + yield return leaf; + yield break; + } + + // Atomic primitives + strings — terminate. AdsTwinCATClient.MapSymbolTypeName is the + // single source of truth for IEC name → enum mapping; reuse it so alias chains that + // bottom out at primitives match the same surface as flat-mode browse. + if (type.Category is DataTypeCategory.Primitive or DataTypeCategory.String) + { + var atomic = AdsTwinCATClient.ResolveSymbolDataType(type); + if (atomic is TwinCATDataType dt) + yield return new DiscoveredLeaf(instancePath, dt, offset, readOnly); + // Unknown primitive name — drop. Caller still gets the rest of the tree. + yield break; + } + + // Structs — recurse into each member, appending ".Name" to the path and adding the + // member's byte offset. IStructType.AllMembers walks the inheritance chain so derived + // FB / struct types still surface members from the base — Members alone would miss them. + if (type is IStructType structType) + { + var members = structType.AllMembers ?? structType.Members; + if (members is null) yield break; + foreach (var member in members) + { + if (member?.DataType is null) continue; + var childPath = string.IsNullOrEmpty(instancePath) + ? member.InstanceName + : instancePath + "." + member.InstanceName; + var childOffset = offset + member.ByteOffset; + foreach (var leaf in WalkInner(member.DataType, childPath, childOffset, readOnly, options, visited, depth + 1)) + yield return leaf; + } + yield break; + } + + // Arrays — element-by-element expansion up to MaxArrayExpansion. Arrays of atoms + // produce N indexed leaves; arrays of structs produce N × (struct-member-count) + // leaves, also bounded by the cap. Multi-dim arrays surface as ElementCount in + // row-major order; a follow-up PR can break that out into per-dim indices if + // operators ever need them. + if (type is IArrayType arrayType) + { + var elementType = arrayType.ElementType; + var dims = arrayType.Dimensions; + if (elementType is null || dims is null) + yield break; + + var elementCount = dims.ElementCount; + if (elementCount <= 0) + yield break; + + if (elementCount > options.MaxArrayExpansion) + { + // Over the cap — surface only the array root. Element type may be atomic or + // structured; for atomic element types we set AtomicType so the caller can + // configure a whole-array read against it. For struct element types we + // leave AtomicType null + flag IsArrayRoot — the address-space layer + // typically renders this as a folder placeholder. + var rootAtomic = AdsTwinCATClient.ResolveSymbolDataType(elementType); + yield return new DiscoveredLeaf( + instancePath, rootAtomic, offset, readOnly, + IsArrayRoot: true, ArrayLength: elementCount); + yield break; + } + + var elementSize = Math.Max(0, elementType.ByteSize); + var lowerBounds = dims.LowerBounds; + var dimLengths = dims.GetDimensionLengths(); + // Multi-dim — emit per-element with row-major index linearisation. Single-dim + // is just the trivial case of that loop. + var indices = new int[dimLengths.Length]; + for (var flat = 0; flat < elementCount; flat++) + { + LinearToIndices(flat, dimLengths, indices); + var indexedPath = AppendIndices(instancePath, indices, lowerBounds); + var elementOffset = offset + flat * elementSize; + foreach (var leaf in WalkInner(elementType, indexedPath, elementOffset, readOnly, options, visited, depth + 1)) + yield return leaf; + } + yield break; + } + + // Anything else (Union / FB instance / Interface / Unknown) — out of scope. + yield break; + } + finally + { + visited.Remove(type); + } + } + + /// + /// Convert a flat row-major index into per-dimension indices (in-place into + /// ). Single-dim arrays just write flat to slot 0. + /// + private static void LinearToIndices(int flat, int[] dimLengths, int[] indices) + { + for (var d = dimLengths.Length - 1; d >= 0; d--) + { + var len = dimLengths[d] <= 0 ? 1 : dimLengths[d]; + indices[d] = flat % len; + flat /= len; + } + } + + /// + /// Render Path[i] / Path[i,j] form for an array element. Lower bounds add + /// so an ARRAY[1..N] element 0 surfaces as Path[1] rather than Path[0]. + /// + private static string AppendIndices(string path, int[] indices, int[]? lowerBounds) + { + var sb = new System.Text.StringBuilder(path.Length + 4 + indices.Length * 4); + sb.Append(path); + sb.Append('['); + for (var i = 0; i < indices.Length; i++) + { + if (i > 0) sb.Append(','); + var lb = lowerBounds is { Length: > 0 } && i < lowerBounds.Length ? lowerBounds[i] : 0; + sb.Append(indices[i] + lb); + } + sb.Append(']'); + return sb.ToString(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATUdtBrowseTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATUdtBrowseTests.cs new file mode 100644 index 0000000..c66c6c6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATUdtBrowseTests.cs @@ -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; + +/// +/// PR 4.1 / #315 — integration coverage for nested-UDT browse. Drives a real ADS +/// SymbolLoaderFactory in VirtualTree mode against the XAR fixture and +/// asserts that the discovery surface flattens UDT members into per-leaf +/// rows. Skips cleanly via +/// when the runtime isn't reachable. +/// +/// +/// Required PLC project state (see TwinCatProject/README.md §UDT): +/// +/// ST_NestedFlags DUT with at least 3 atomic members. +/// GVL_Plant (or compatible GVL) holding a nested-struct instance + a +/// large array (ARRAY[1..2000] OF ST_AlarmRecord) for cutoff coverage. +/// +/// The fixture project today is a stub (the .tsproj ships once the XAR VM +/// is up). When that lands the browse assertion below should observe ≥ 50 atomic +/// leaves under GVL_Plant's UDT tree. Until then the test is build-time +/// coverage. +/// +[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) { } } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_AlarmRecord.TcDUT b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_AlarmRecord.TcDUT new file mode 100644 index 0000000..646afc6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_AlarmRecord.TcDUT @@ -0,0 +1,15 @@ + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_NestedFlags.TcDUT b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_NestedFlags.TcDUT new file mode 100644 index 0000000..3307b25 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_NestedFlags.TcDUT @@ -0,0 +1,20 @@ + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_RecursiveCap.TcDUT b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_RecursiveCap.TcDUT new file mode 100644 index 0000000..01078b5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_RecursiveCap.TcDUT @@ -0,0 +1,18 @@ + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Plant.TcGVL b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Plant.TcGVL new file mode 100644 index 0000000..5349ff3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Plant.TcGVL @@ -0,0 +1,17 @@ + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md index a85f2ca..7580c47 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md @@ -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 diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index 2091500..08de58a 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -361,9 +361,18 @@ internal class FakeTwinCATClient : ITwinCATClient public List BrowseResults { get; } = new(); public bool ThrowOnBrowse { get; set; } + /// + /// PR 4.1 / #315 — captures the most recent maxArrayExpansion the driver passed + /// so tests can assert the option threaded through. Defaults to -1 until the + /// first browse call; 0 would be a meaningful (degenerate) caller value. + /// + public int LastBrowseMaxArrayExpansion { get; private set; } = -1; + public virtual async IAsyncEnumerable 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) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeWalkerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeWalkerTests.cs new file mode 100644 index 0000000..34db441 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeWalkerTests.cs @@ -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; + +/// +/// 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; + } +}