fix(abcip): browse program-scoped tags and surface symbol array dimensions
Live read-only check against production CompactLogix 5069-L320ERS2 presses: - The controller @tags listing names each program (Program:UN01_Press, a system symbol) but none of its tags; those are listed only by Program:<name>.@tags. The enumerator read @tags alone, so every program-scoped tag was unbrowseable — including the PackML state (Program:UN01_Press.Out_eState) and all 51 alarm bits the Ignition project reads. The enumerator now reads each program's listing and decodes it with that program as scope; an unreadable program listing is skipped, not fatal. - The Symbol Object decoder ignored the dimension bits + dim words, so every discovered array (DINT[32], REAL[32], STRING[50], UDT[23] on the two controllers checked) browsed as a scalar. IsArray/ElementCount are now populated (multi-dim flattened). A 1-D single element entry stays scalar: ab_server reports every scalar that way, a real controller sets no dimension bits on scalars. Pinned by entries captured off the controllers (CipSymbolObjectRealControllerCaptureTests, AbCipProgramScopeEnumerationTests).
This commit is contained in:
@@ -42,6 +42,10 @@ public static class CipSymbolObjectDecoder
|
||||
private const ushort SymbolTypeSystemFlag = 0x1000;
|
||||
private const ushort SymbolTypeStructFlag = 0x8000;
|
||||
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
|
||||
private const int SymbolTypeDimsShift = 13;
|
||||
private const int SymbolTypeDimsMask = 0x3;
|
||||
|
||||
private const string ProgramPrefix = "Program:";
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
|
||||
@@ -49,22 +53,44 @@ public static class CipSymbolObjectDecoder
|
||||
/// cleanly before the corruption.
|
||||
/// </summary>
|
||||
/// <param name="buffer">The raw byte buffer from the Symbol Object response.</param>
|
||||
/// <param name="programScope">The program this listing belongs to when it is a
|
||||
/// <c>Program:<name>.@tags</c> read — whose entries carry bare names — or <c>null</c>
|
||||
/// for the controller-scope <c>@tags</c> listing.</param>
|
||||
/// <returns>An enumerable sequence of discovered CIP tags.</returns>
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer, string? programScope = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
return DecodeImpl(buffer);
|
||||
return DecodeImpl(buffer, programScope);
|
||||
}
|
||||
|
||||
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
|
||||
/// <summary>
|
||||
/// The program name when <paramref name="tag"/> is a controller-scope program entry
|
||||
/// (<c>Program:UN01_Press</c>, a system symbol) — the name to read <c>Program:<name>.@tags</c>
|
||||
/// for, since a controller lists program-scope tags only under their own program. <c>null</c>
|
||||
/// for anything else.
|
||||
/// </summary>
|
||||
/// <param name="tag">A decoded controller-scope symbol.</param>
|
||||
/// <returns>The program name, or <c>null</c>.</returns>
|
||||
public static string? ProgramNameOf(AbCipDiscoveredTag tag)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
if (!tag.IsSystemTag || tag.ProgramScope is not null) return null;
|
||||
if (!tag.Name.StartsWith(ProgramPrefix, StringComparison.Ordinal)) return null;
|
||||
var name = tag.Name[ProgramPrefix.Length..];
|
||||
return name.Length == 0 || name.Contains('.') ? null : name;
|
||||
}
|
||||
|
||||
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer, string? listingScope)
|
||||
{
|
||||
var pos = 0;
|
||||
while (pos + FixedHeaderSize <= buffer.Length)
|
||||
{
|
||||
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
|
||||
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
|
||||
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
|
||||
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
|
||||
// element_length at pos+6 (u16) — not surfaced.
|
||||
var dim0 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 8));
|
||||
var dim1 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 12));
|
||||
var dim2 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 16));
|
||||
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
|
||||
pos += FixedHeaderSize;
|
||||
|
||||
@@ -83,7 +109,10 @@ public static class CipSymbolObjectDecoder
|
||||
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
|
||||
var typeCode = symbolType & SymbolTypeTypeCodeMask;
|
||||
|
||||
var (programScope, simpleName) = SplitProgramScope(name);
|
||||
// A program listing (Program:X.@tags) carries bare names; the scope is the program read.
|
||||
var (programScope, simpleName) = listingScope is null
|
||||
? SplitProgramScope(name)
|
||||
: (listingScope, name);
|
||||
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
|
||||
|
||||
// For a struct symbol the lower 12 bits (typeCode) are NOT a primitive CIP type code —
|
||||
@@ -92,18 +121,46 @@ public static class CipSymbolObjectDecoder
|
||||
// carry no template id.
|
||||
var templateInstanceId = isStruct ? (uint?)typeCode : null;
|
||||
|
||||
var (isArray, elementCount) = ArrayShape((symbolType >> SymbolTypeDimsShift) & SymbolTypeDimsMask,
|
||||
dim0, dim1, dim2);
|
||||
|
||||
yield return new AbCipDiscoveredTag(
|
||||
Name: simpleName,
|
||||
ProgramScope: programScope,
|
||||
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
|
||||
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
|
||||
IsSystemTag: isSystem,
|
||||
ElementCount: elementCount,
|
||||
IsArray: isArray,
|
||||
TemplateInstanceId: templateInstanceId);
|
||||
|
||||
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Array shape from the symbol type's dimension count (bits 13-14) and the three dimension
|
||||
/// words. A real controller sets no dimension bits on a scalar; <c>ab_server</c> reports every
|
||||
/// scalar as a one-dimensional array of one, so a 1-D single-element entry is taken as a scalar
|
||||
/// too. A multi-dimensional array is surfaced flattened (element count = product of dims) —
|
||||
/// the driver addresses 1-D arrays only.
|
||||
/// </summary>
|
||||
/// <param name="dimCount">Dimension count from bits 13-14 of the symbol type.</param>
|
||||
/// <param name="dim0">First dimension.</param>
|
||||
/// <param name="dim1">Second dimension.</param>
|
||||
/// <param name="dim2">Third dimension.</param>
|
||||
/// <returns>Whether the symbol is an array, and its flattened element count.</returns>
|
||||
internal static (bool IsArray, int ElementCount) ArrayShape(int dimCount, uint dim0, uint dim1, uint dim2)
|
||||
{
|
||||
if (dimCount == 0) return (false, 1);
|
||||
long count = 1;
|
||||
if (dimCount >= 1) count *= Math.Max(1u, dim0);
|
||||
if (dimCount >= 2) count *= Math.Max(1u, dim1);
|
||||
if (dimCount >= 3) count *= Math.Max(1u, dim2);
|
||||
if (dimCount == 1 && count == 1) return (false, 1);
|
||||
return (true, (int)Math.Min(count, int.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
|
||||
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
|
||||
|
||||
@@ -19,29 +19,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private Tag? _tag;
|
||||
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
|
||||
|
||||
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
|
||||
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
|
||||
|
||||
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
|
||||
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
|
||||
/// returns its raw Symbol Object bytes.</param>
|
||||
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. So the controller listing is read first, then each program's
|
||||
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
|
||||
/// skipped — browse is best-effort and one unreadable program must not cost the others.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
_tag = BuildSymbolListTag(deviceParams);
|
||||
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var buffer = _tag.GetBuffer();
|
||||
// The decoder already surfaces each struct tag's CIP template instance id on
|
||||
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
|
||||
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
|
||||
var programs = new List<string>();
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||
{
|
||||
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
foreach (var program in programs)
|
||||
{
|
||||
byte[] programBuffer;
|
||||
try
|
||||
{
|
||||
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
|
||||
yield return tag;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadListingAsync(
|
||||
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var tag = BuildSymbolListTag(deviceParams, listingName);
|
||||
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return tag.GetBuffer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the (uninitialized) libplctag tag for the <c>@tags</c> pseudo — same gateway +
|
||||
/// path as the device, distinguished by the name alone.
|
||||
/// Builds the (uninitialized) libplctag tag for a symbol listing pseudo-tag — <c>@tags</c> or
|
||||
/// <c>Program:<name>.@tags</c> — same gateway + path as the device, distinguished by the
|
||||
/// name alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
|
||||
@@ -49,18 +93,21 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
/// host, so a browse against a device authored on another port silently returns whatever
|
||||
/// answers on 44818 (Gitea #539, the discovery half of #534).
|
||||
/// </remarks>
|
||||
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new()
|
||||
/// <param name="deviceParams">The device's create params.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name (default <c>@tags</c>).</param>
|
||||
/// <returns>The uninitialized tag.</returns>
|
||||
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
|
||||
{
|
||||
Gateway = deviceParams.LibplctagGateway,
|
||||
Path = deviceParams.CipPath,
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = "@tags",
|
||||
Name = listingName,
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
|
||||
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
|
||||
public void Dispose() { }
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A controller's <c>@tags</c> listing names each program (<c>Program:UN01_Press</c>, a system
|
||||
/// symbol) but none of its tags — those are listed only by <c>Program:<name>.@tags</c>.
|
||||
/// Seen live on a production press: the PackML state (<c>Program:UN01_Press.Out_eState</c>) and
|
||||
/// every alarm bit the Ignition project reads are program-scoped, and the browse showed none.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipProgramScopeEnumerationTests
|
||||
{
|
||||
// Captured entries (CompactLogix 5069-L320ERS2 rev 34.11): WW_InCycle + the Program:UN01_Press entry
|
||||
// from @tags, and Out_eState from Program:UN01_Press.@tags.
|
||||
private const string ControllerListing =
|
||||
"AF010000C10001000000000000000000000000000A0057575F496E4379636C65" +
|
||||
"7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
|
||||
|
||||
private const string Un01Listing = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
|
||||
|
||||
private static readonly AbCipTagCreateParams Params = new(
|
||||
Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
|
||||
TagName: "@tags", Timeout: TimeSpan.FromSeconds(2));
|
||||
|
||||
/// <summary>The enumerator reads each program's own listing and yields its tags program-scoped.</summary>
|
||||
[Fact]
|
||||
public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
|
||||
{
|
||||
var reads = new List<string>();
|
||||
using var enumerator = new LibplctagTagEnumerator((_, name, _) =>
|
||||
{
|
||||
reads.Add(name);
|
||||
return Task.FromResult(name switch
|
||||
{
|
||||
"@tags" => Convert.FromHexString(ControllerListing),
|
||||
"Program:UN01_Press.@tags" => Convert.FromHexString(Un01Listing),
|
||||
_ => throw new InvalidOperationException(name),
|
||||
});
|
||||
});
|
||||
|
||||
var tags = new List<AbCipDiscoveredTag>();
|
||||
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
|
||||
tags.Add(t);
|
||||
|
||||
reads.ShouldBe(["@tags", "Program:UN01_Press.@tags"]);
|
||||
var outState = tags.Single(t => t.Name == "Out_eState");
|
||||
outState.ProgramScope.ShouldBe("UN01_Press");
|
||||
outState.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags.ShouldContain(t => t.Name == "WW_InCycle" && t.ProgramScope == null);
|
||||
}
|
||||
|
||||
/// <summary>One unreadable program listing does not cost the controller tags or other programs.</summary>
|
||||
[Fact]
|
||||
public async Task A_failing_program_listing_is_skipped()
|
||||
{
|
||||
using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags"
|
||||
? Task.FromResult(Convert.FromHexString(ControllerListing))
|
||||
: Task.FromException<byte[]>(new InvalidOperationException("ErrorNotFound")));
|
||||
|
||||
var tags = new List<AbCipDiscoveredTag>();
|
||||
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
|
||||
tags.Add(t);
|
||||
|
||||
tags.ShouldContain(t => t.Name == "WW_InCycle");
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol Object (<c>@tags</c>) entries captured byte for byte off two real CompactLogix
|
||||
/// controllers on 2026-10-07 (read-only, one listing read each): a 5069-L320ERS2 rev 34.11 press
|
||||
/// and a second CompactLogix. Unlike the <c>ab_server</c> capture in
|
||||
/// <see cref="CipSymbolObjectWireCaptureTests"/> — which reports every scalar as a 1-element
|
||||
/// array — a real controller sets the dimension bits only on genuine arrays.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipSymbolObjectRealControllerCaptureTests
|
||||
{
|
||||
// Controller scope, press: WW_InCycle (BOOL), Inf_LookupFwkB (UDT[23], template 3386),
|
||||
// Program:UN01_Press (the program entry itself, type 0x1068).
|
||||
private const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65";
|
||||
private const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42";
|
||||
private const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
|
||||
|
||||
// Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]).
|
||||
private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65";
|
||||
private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573";
|
||||
|
||||
// Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix.
|
||||
private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
|
||||
private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573";
|
||||
|
||||
private static List<AbCipDiscoveredTag> Decode(params string[] entries) =>
|
||||
CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
|
||||
|
||||
/// <summary>Array dimensions in the entry surface as IsArray + ElementCount; scalars stay scalar.</summary>
|
||||
[Fact]
|
||||
public void Real_controller_arrays_surface_their_element_count()
|
||||
{
|
||||
var tags = Decode(WwInCycle, InfLookupFwkB, ArrSysTime, ArrMessages);
|
||||
|
||||
var inCycle = tags.Single(t => t.Name == "WW_InCycle");
|
||||
inCycle.IsArray.ShouldBeFalse();
|
||||
inCycle.ElementCount.ShouldBe(1);
|
||||
|
||||
var sysTime = tags.Single(t => t.Name == "arrSysTime");
|
||||
sysTime.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
sysTime.IsArray.ShouldBeTrue();
|
||||
sysTime.ElementCount.ShouldBe(7);
|
||||
|
||||
var lookup = tags.Single(t => t.Name == "Inf_LookupFwkB");
|
||||
lookup.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
lookup.TemplateInstanceId.ShouldBe(3386u);
|
||||
lookup.IsArray.ShouldBeTrue();
|
||||
lookup.ElementCount.ShouldBe(23);
|
||||
|
||||
var messages = tags.Single(t => t.Name == "arrMessages");
|
||||
messages.TemplateInstanceId.ShouldBe(0x0FCEu);
|
||||
messages.IsArray.ShouldBeTrue();
|
||||
messages.ElementCount.ShouldBe(50);
|
||||
}
|
||||
|
||||
/// <summary>A program entry is a system tag whose program name the enumerator can walk.</summary>
|
||||
[Fact]
|
||||
public void Program_entry_is_system_and_names_its_program()
|
||||
{
|
||||
var program = Decode(ProgramUn01).ShouldHaveSingleItem();
|
||||
|
||||
program.IsSystemTag.ShouldBeTrue();
|
||||
CipSymbolObjectDecoder.ProgramNameOf(program).ShouldBe("UN01_Press");
|
||||
CipSymbolObjectDecoder.ProgramNameOf(Decode(WwInCycle).Single()).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A program listing decodes with the program as each tag's scope.</summary>
|
||||
[Fact]
|
||||
public void Program_listing_decodes_with_program_scope()
|
||||
{
|
||||
var tags = CipSymbolObjectDecoder.Decode(
|
||||
Convert.FromHexString(OutEState + DisableStates), programScope: "UN01_Press").ToList();
|
||||
|
||||
tags.Select(t => t.Name).ShouldBe(["Out_eState", "_DisableStates"]);
|
||||
tags.ShouldAllBe(t => t.ProgramScope == "UN01_Press");
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[1].IsArray.ShouldBeTrue();
|
||||
tags[1].ElementCount.ShouldBe(32);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,24 @@ public sealed class CipSymbolObjectWireCaptureTests
|
||||
tags.Single(t => t.Name == "TestSINT").DataType.ShouldBe(AbCipDataType.SInt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>ab_server</c> marks every symbol as a one-dimensional array (a scalar is
|
||||
/// <c>[1]</c>); only <c>TestArray</c> has more than one element. A 1-element, 1-D entry is
|
||||
/// treated as a scalar, so the fixture's scalars do not browse as 1-element arrays — a
|
||||
/// real controller sets no dimension bits on a scalar at all
|
||||
/// (<see cref="CipSymbolObjectRealControllerCaptureTests"/>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Only_the_multi_element_capture_entry_is_an_array()
|
||||
{
|
||||
var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList();
|
||||
|
||||
var array = tags.Single(t => t.Name == "TestArray");
|
||||
array.IsArray.ShouldBeTrue();
|
||||
array.ElementCount.ShouldBe(16);
|
||||
tags.Where(t => t.Name != "TestArray").ShouldAllBe(t => !t.IsArray && t.ElementCount == 1);
|
||||
}
|
||||
|
||||
/// <summary>None of the captured symbols is misread as a system tag or a structure.</summary>
|
||||
/// <remarks>
|
||||
/// The dimension bits sit next to the flags the decoder tests for, so a mask that is one
|
||||
|
||||
Reference in New Issue
Block a user