From 3e74239532c92bb9e1b1b916ab43bfeee10f83f3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 21:59:17 -0400 Subject: [PATCH] feat(twincat): 1-D array symbol read via ADS + IsArray discovery --- .../TwinCATDriverOptions.cs | 8 +- .../TwinCATEquipmentTagParser.cs | 21 +- .../AdsTwinCATClient.cs | 66 +++++- .../ITwinCATClient.cs | 16 +- .../TwinCATDriver.cs | 21 +- .../FakeTwinCATClient.cs | 8 +- .../TwinCATArraySupportTests.cs | 215 ++++++++++++++++++ 7 files changed, 341 insertions(+), 14 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs index 8fddaad4..dbd33435 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs @@ -70,13 +70,19 @@ public sealed record TwinCATDeviceOptions( /// One TwinCAT-backed OPC UA variable. is the full TwinCAT /// symbolic name (e.g. MAIN.bStart, GVL.Counter, Motor1.Status.Running). /// +/// +/// When non-null, this tag is a 1-D array of elements of +/// . Drives IsArray/ArrayDim at discovery and a +/// native ADS array read at runtime (Phase 4c). null = scalar (the default). +/// public sealed record TwinCATTagDefinition( string Name, string DeviceHostAddress, string SymbolPath, TwinCATDataType DataType, bool Writable = true, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + int? ArrayLength = null); /// Probe options for TwinCAT connection monitoring. public sealed class TwinCATProbeOptions diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs index 09e0e41c..4c6131b7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs @@ -29,9 +29,14 @@ public static class TwinCATEquipmentTagParser if (string.IsNullOrWhiteSpace(symbolPath)) return false; var deviceHostAddress = ReadString(root, "deviceHostAddress"); var dataType = ReadEnum(root, "dataType", TwinCATDataType.DInt); + // Array intent — same shape the runtime/OPC-UA foundation parses (camelCase + // `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray + // is true AND it is a positive JSON number, so a stale length behind a cleared + // isArray never produces an orphan array tag (Phase 4c). + var arrayLength = ReadArrayLength(root); def = new TwinCATTagDefinition( Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath, - DataType: dataType, Writable: true); + DataType: dataType, Writable: true, ArrayLength: arrayLength); return true; } catch (JsonException) { return false; } @@ -46,4 +51,18 @@ public static class TwinCATEquipmentTagParser private static string ReadString(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() ?? "" : ""; + + /// + /// Reads the optional 1-D array length: arrayLength (a positive uint) honoured ONLY + /// when isArray is the JSON literal true. Returns null (scalar) when + /// isArray is absent/false, when arrayLength is absent / non-numeric / zero / negative. + /// + private static int? ReadArrayLength(JsonElement o) + { + if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True) + return null; + if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number) + return null; + return lEl.TryGetInt32(out var len) && len > 0 ? len : null; + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index 16325afb..a19da231 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -97,12 +97,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient /// The ADS symbol path to read from. /// The TwinCAT data type. /// Optional bit index for BOOL values within larger containers. + /// When non-null, read a 1-D array of this many + /// elements; the boxed value is the element-typed CLR array (e.g. int[]). Phase 4c. /// The cancellation token. /// A tuple containing the value and OPC UA status code. public async Task<(object? value, uint status)> ReadValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, + int? arrayCount, CancellationToken cancellationToken) { try @@ -112,7 +115,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient // container as its widest unsigned primitive and extract the bit locally. The // .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off // first. uint covers WORD / DWORD containers; BYTE-sized bit containers are - // rare in real code and promoting to uint is harmless for them. + // rare in real code and promoting to uint is harmless for them. Bit-within-word + // is inherently scalar — arrayCount does not apply. if (bitIndex is int bit && type == TwinCATDataType.Bool) { var parent = StripBitSuffix(symbolPath); @@ -123,6 +127,25 @@ internal sealed class AdsTwinCATClient : ITwinCATClient return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good); } + // Array read — ADS reads array symbols natively into a typed managed array. We ask + // the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding + // marshals the whole array in one read. The returned value is already the boxed + // element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's + // OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here): + // AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the + // typed array — this matches Beckhoff's documented generic ReadValue surface + // (InfoSys tcadsnetref / SumRead samples). Verified only against the fake client. + if (arrayCount is int count && count > 0) + { + var elementType = MapToClrType(type); + var arrayType = elementType.MakeArrayType(); + var arrResult = await _client.ReadValueAsync(symbolPath, arrayType, cancellationToken) + .ConfigureAwait(false); + if (arrResult.ErrorCode != AdsErrorCode.NoError) + return (null, MapAndSignal((uint)arrResult.ErrorCode)); + return (arrResult.Value, TwinCATStatusMapper.Good); + } + var clrType = MapToClrType(type); var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken) .ConfigureAwait(false); @@ -313,12 +336,49 @@ internal sealed class AdsTwinCATClient : ITwinCATClient // distinctly from a genuine browse failure; a yield break would let a partial // symbol set appear as a fully successful discovery (Driver.TwinCAT-010). cancellationToken.ThrowIfCancellationRequested(); - var mapped = MapSymbolTypeName(symbol.DataType?.Name); + var (mapped, arrayLength) = MapSymbolType(symbol.DataType); var readOnly = !IsSymbolWritable(symbol); - yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly); + yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly, arrayLength); } } + /// + /// Resolves a symbol's to the driver's atomic + /// plus an optional 1-D array length. + /// + /// A 1-D array symbol (Category == Array, count 1) + /// surfaces as its ELEMENT type with the dimension's as + /// the array length. ASSUMPTION (no live ADS here): the array exposes + /// with + a + /// collection whose single carries + /// — matches the TwinCAT TypeSystem surface + /// (TwinCAT.TypeSystem.IArrayType / IDimensionCollection.GetDimensionLengths()). + /// Verified by unit test against the fake client only. + /// + /// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for + /// Phase 4c — they fall through as a scalar/unsupported null (which DiscoverAsync drops), + /// never silently mis-reported as a 1-D array. + /// + private static (TwinCATDataType? type, int? arrayLength) MapSymbolType(IDataType? dataType) + { + if (dataType is null) return (null, null); + + if (dataType.Category == DataTypeCategory.Array && dataType is IArrayType arr) + { + // 1-D only. A multi-dim / jagged array is out of atomic scope → drop (null). + var dims = arr.Dimensions; + if (arr.IsJagged || dims is null || dims.Count != 1) return (null, null); + + var element = MapSymbolTypeName(arr.ElementType?.Name); + if (element is null) return (null, null); // element type is itself unsupported (UDT array etc.) + + var length = dims[0].ElementCount; + return length > 0 ? (element, length) : (null, null); + } + + return (MapSymbolTypeName(dataType.Name), null); + } + private static TwinCATDataType? MapSymbolTypeName(string? typeName) { if (typeName is null) return null; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index 03a12b4b..f76bbb9e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -40,11 +40,15 @@ public interface ITwinCATClient : IDisposable /// The ADS symbol path. /// The target data type. /// Optional bit index for bit extraction within a word. + /// When non-null, read a 1-D array of this many + /// elements; the boxed value is the element-typed CLR array (int[] / float[] / + /// bool[] / string[] / …). When null, read a scalar (Phase 4c). /// Cancellation token for the read operation. Task<(object? value, uint status)> ReadValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, + int? arrayCount, CancellationToken cancellationToken); /// @@ -113,13 +117,19 @@ public interface ITwinCATNotificationHandle : IDisposable { } /// path + detected + read-only flag. /// /// 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). +/// Mapped of the (element) type; null +/// when the symbol's type doesn't map onto our supported atomic surface (UDTs, pointers, +/// function blocks). For an array symbol this is the ELEMENT type, with +/// carrying the dimension. /// true when the symbol's AccessRights flag forbids writes. +/// When non-null, the symbol is a 1-D array of this many +/// elements. null = scalar. Multi-dimensional arrays are +/// reported as null (treated as scalar/unsupported) — only 1-D is in scope for Phase 4c. public sealed record TwinCATDiscoveredSymbol( string InstancePath, TwinCATDataType? DataType, - bool ReadOnly); + bool ReadOnly, + int? ArrayLength = null); /// Factory for s. One client per device. public interface ITwinCATClientFactory diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 37c61c8d..4baa97b3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -227,8 +227,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; + // An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read; + // the boxed result is an element-typed CLR array. Scalar tags pass null + // (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores + // arrayCount when a bitIndex is present (Phase 4c). var (value, status) = await client.ReadValueAsync( - symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false); + symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken) + .ConfigureAwait(false); results[i] = new DataValueSnapshot(value, status, now, now); if (status == TwinCATStatusMapper.Good) @@ -340,8 +345,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + // A pre-declared tag with a positive ArrayLength is a 1-D array node; null + // (or non-positive) stays scalar (Phase 4c). + IsArray: tag.ArrayLength is > 0, + ArrayDim: tag.ArrayLength is > 0 ? (uint)tag.ArrayLength.Value : null, SecurityClass: tag.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, @@ -367,8 +374,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo( FullName: sym.InstancePath, DriverDataType: dt.ToDriverDataType(), - IsArray: false, - ArrayDim: null, + // A discovered 1-D array symbol carries its element count in + // sym.ArrayLength (the browser reports the ELEMENT type as dt); + // multi-dim/unsupported arrays arrive with null ArrayLength → scalar + // (Phase 4c). + IsArray: sym.ArrayLength is > 0, + ArrayDim: sym.ArrayLength is > 0 ? (uint)sym.ArrayLength.Value : null, SecurityClass: sym.ReadOnly ? SecurityClassification.ViewOnly : SecurityClassification.Operate, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index 41495e23..46830b95 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -27,6 +27,10 @@ internal class FakeTwinCATClient : ITwinCATClient public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); /// Gets the write statuses by symbol path. public Dictionary WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); + /// Records the arrayCount argument of the most recent + /// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested + /// the array with the authored length. + public int? LastReadArrayCount { get; private set; } /// Gets the log of all write operations. public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new(); /// Gets or sets the result returned by ProbeAsync. @@ -55,12 +59,14 @@ internal class FakeTwinCATClient : ITwinCATClient /// The path to the symbol to read. /// The data type of the symbol. /// The optional bit index for bit-level reads. + /// The optional 1-D array element count (null = scalar read). /// The cancellation token. /// A task that returns the simulated value and status. public virtual Task<(object? value, uint status)> ReadValueAsync( - string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct) + string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct) { if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); + LastReadArrayCount = arrayCount; var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; var value = Values.TryGetValue(symbolPath, out var v) ? v : null; return Task.FromResult((value, status)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs new file mode 100644 index 00000000..fd5ec364 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs @@ -0,0 +1,215 @@ +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.Tests; + +/// +/// Phase 4c — 1-D array support for the TwinCAT (ADS) driver. Covers the two discovery +/// hard-wire flips (pre-declared + discovered symbols now report IsArray/ArrayDim from the +/// ADS symbol's array dimension), the array READ path (the equipment tag's arrayLength drives +/// a native array read boxed into a typed CLR array), and the equipment-tag parser threading +/// arrayLength from the TagConfig JSON. +/// +[Trait("Category", "Unit")] +public sealed class TwinCATArraySupportTests +{ + private const string Host = "ads://5.23.91.23.1.1:851"; + + // ---- (1) discovery: pre-declared array tag flips IsArray/ArrayDim ---- + + /// A pre-declared tag carrying an ArrayLength surfaces as a 1-D array node. + [Fact] + public async Task Predeclared_array_tag_reports_IsArray_and_ArrayDim() + { + var builder = new RecordingBuilder(); + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Tags = + [ + new TwinCATTagDefinition("Speeds", Host, "MAIN.Speeds", TwinCATDataType.DInt, + Writable: true, WriteIdempotent: false, ArrayLength: 8), + ], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = false, + }, "drv-1", new FakeTwinCATClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var v = builder.Variables.Single(x => x.BrowseName == "Speeds").Info; + v.IsArray.ShouldBeTrue(); + v.ArrayDim.ShouldBe(8u); + } + + /// A scalar pre-declared tag still reports IsArray=false / ArrayDim=null. + [Fact] + public async Task Predeclared_scalar_tag_stays_scalar() + { + var builder = new RecordingBuilder(); + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)], + Probe = new TwinCATProbeOptions { Enabled = false }, + }, "drv-1", new FakeTwinCATClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var v = builder.Variables.Single(x => x.BrowseName == "Speed").Info; + v.IsArray.ShouldBeFalse(); + v.ArrayDim.ShouldBeNull(); + } + + // ---- (1) discovery: discovered (browsed) array symbol flips IsArray/ArrayDim ---- + + /// A discovered (browsed) 1-D array symbol surfaces as a 1-D array node. + [Fact] + public async Task Discovered_array_symbol_reports_IsArray_and_ArrayDim() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => + { + var c = new FakeTwinCATClient(); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol( + "MAIN.Buffer", TwinCATDataType.Int, ReadOnly: false, ArrayLength: 16)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol( + "GVL.Scalar", TwinCATDataType.Real, ReadOnly: false)); + return c; + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var arr = builder.Variables.Single(x => x.Info.FullName == "MAIN.Buffer").Info; + arr.IsArray.ShouldBeTrue(); + arr.ArrayDim.ShouldBe(16u); + + var scalar = builder.Variables.Single(x => x.Info.FullName == "GVL.Scalar").Info; + scalar.IsArray.ShouldBeFalse(); + scalar.ArrayDim.ShouldBeNull(); + } + + // ---- (2) read path: array read returns a typed CLR array ---- + + /// An equipment array tag reads a typed CLR array (boxed as object) through the driver. + [Fact] + public async Task Driver_reads_an_array_equipment_tag_as_typed_clr_array() + { + var json = + $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}"""; + var expected = new[] { 10, 20, 30, 40 }; + var factory = new FakeTwinCATClientFactory + { + Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speeds"] = expected } }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Tags = [], + Probe = new TwinCATProbeOptions { Enabled = false }, + }, "twincat-arr", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync([json], CancellationToken.None); + + r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); + r[0].Value.ShouldBeOfType().ShouldBe(expected); + // The driver must request the array read with the authored length. + factory.Clients[0].LastReadArrayCount.ShouldBe(4); + } + + /// A scalar equipment tag reads with a null array count (scalar path unchanged). + [Fact] + public async Task Driver_reads_a_scalar_equipment_tag_with_null_array_count() + { + var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}"""; + var factory = new FakeTwinCATClientFactory + { + Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 7 } }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Tags = [], + Probe = new TwinCATProbeOptions { Enabled = false }, + }, "twincat-scalar", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync([json], CancellationToken.None); + + r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); + r[0].Value.ShouldBe(7); + factory.Clients[0].LastReadArrayCount.ShouldBeNull(); + } + + // ---- (3) resolver: equipment-tag parser threads arrayLength ---- + + /// The equipment-tag parser threads arrayLength into the transient definition. + [Fact] + public void Parser_threads_arrayLength_into_the_definition() + { + var json = + """{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}"""; + TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.ArrayLength.ShouldBe(12); + } + + /// A blob without isArray (or with isArray=false) leaves ArrayLength null. + [Fact] + public void Parser_leaves_ArrayLength_null_for_a_scalar_blob() + { + TwinCATEquipmentTagParser.TryParse( + """{"symbolPath":"MAIN.X","dataType":"DInt"}""", out var def).ShouldBeTrue(); + def!.ArrayLength.ShouldBeNull(); + } + + /// arrayLength is ignored when isArray is absent/false (no orphan length). + [Fact] + public void Parser_ignores_arrayLength_when_isArray_is_false() + { + TwinCATEquipmentTagParser.TryParse( + """{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""", + out var def).ShouldBeTrue(); + def!.ArrayLength.ShouldBeNull(); + } + + // ---- helpers ---- + + 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 _, DriverDataType __, object? ___) { } + + 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) { } + } + } +}