Auto: abcip-1.3 — array-slice read addressing

Closes #227
This commit is contained in:
Joseph Doherty
2026-04-25 13:03:45 -04:00
parent 29edd835a3
commit 767ac4aec5
8 changed files with 480 additions and 6 deletions

View File

@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
/// </summary>
/// <remarks>
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
///
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
/// </remarks>
public static class AbCipArrayReadPlanner
{
/// <summary>
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
/// <c>null</c> when the slice element type isn't supported under this declaration-only
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
/// scalar read path so the operator gets a clean per-element result instead.
/// </summary>
public static AbCipArrayReadPlan? TryBuild(
AbCipTagDefinition definition,
AbCipTagPath parsedPath,
AbCipTagCreateParams baseParams)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(parsedPath);
ArgumentNullException.ThrowIfNull(baseParams);
if (parsedPath.Slice is null) return null;
if (!TryGetStride(definition.DataType, out var stride)) return null;
var slice = parsedPath.Slice;
var createParams = baseParams with
{
TagName = parsedPath.ToLibplctagSliceArrayName(),
ElementCount = slice.Count,
};
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
}
/// <summary>
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
/// </summary>
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(runtime);
var values = new object?[plan.Slice.Count];
for (var i = 0; i < plan.Slice.Count; i++)
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
return values;
}
private static bool TryGetStride(AbCipDataType type, out int stride)
{
switch (type)
{
case AbCipDataType.SInt: case AbCipDataType.USInt:
stride = 1; return true;
case AbCipDataType.Int: case AbCipDataType.UInt:
stride = 2; return true;
case AbCipDataType.DInt: case AbCipDataType.UDInt:
case AbCipDataType.Real: case AbCipDataType.Dt:
stride = 4; return true;
case AbCipDataType.LInt: case AbCipDataType.ULInt:
case AbCipDataType.LReal:
stride = 8; return true;
default:
stride = 0; return false;
}
}
}
/// <summary>
/// Plan output: the libplctag create-params for the single array-read tag plus the
/// element-type / stride / slice metadata the decoder needs.
/// </summary>
public sealed record AbCipArrayReadPlan(
AbCipDataType ElementType,
AbCipTagPathSlice Slice,
int Stride,
AbCipTagCreateParams CreateParams);

View File

@@ -358,6 +358,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return;
}
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
// Rockwell array read; the contiguous buffer is decoded at element stride into a
// single snapshot whose Value is an object[] of the N elements.
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
if (parsedPath?.Slice is not null)
{
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
@@ -373,8 +384,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
var bitIndex = parsedPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
@@ -391,6 +401,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
/// </summary>
private async Task ReadSliceAsync(
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var baseParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
Timeout: _options.Timeout);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadNotSupported, null, now);
return;
}
try
{
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
.ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading slice {def.Name}");
return;
}
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
var runtime = _tagFactory.Create(createParams);
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[tagName] = runtime;
return runtime;
}
/// <summary>
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group

View File

@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public sealed record AbCipTagPath(
string? ProgramScope,
IReadOnlyList<AbCipTagPathSegment> Segments,
int? BitIndex)
int? BitIndex,
AbCipTagPathSlice? Slice = null)
{
/// <summary>Rebuild the canonical Logix tag string.</summary>
public string ToLibplctagName()
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
return buf.ToString();
}
/// <summary>
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
/// </summary>
public string ToLibplctagSliceArrayName()
{
if (Slice is null) return ToLibplctagName();
var buf = new System.Text.StringBuilder();
if (ProgramScope is not null)
buf.Append("Program:").Append(ProgramScope).Append('.');
for (var i = 0; i < Segments.Count; i++)
{
if (i > 0) buf.Append('.');
var seg = Segments[i];
buf.Append(seg.Name);
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
// "read N consecutive elements starting at index 0", which is the exact Rockwell
// array-read semantic this PR is wiring up.
buf.Append('[').Append(Slice.Start).Append(']');
return buf.ToString();
}
/// <summary>
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
/// doesn't support — the driver surfaces that as a config-validation error rather than
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
}
var segments = new List<AbCipTagPathSegment>(parts.Count);
foreach (var part in parts)
AbCipTagPathSlice? slice = null;
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
{
var part = parts[partIdx];
var bracketIdx = part.IndexOf('[');
if (bracketIdx < 0)
{
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
var name = part[..bracketIdx];
if (!IsValidIdent(name)) return null;
var inner = part[(bracketIdx + 1)..^1];
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
// Any other shape is rejected so callers see a config-validation error rather than
// the driver attempting a best-effort scalar read.
if (inner.Contains(".."))
{
if (partIdx != parts.Count - 1) return null; // slice + sub-element
if (bitIndex is not null) return null; // slice + bit index
if (inner.Contains(',')) return null; // slice cannot be multi-dim
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
if (parts2.Length != 2) return null;
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
segments.Add(new AbCipTagPathSegment(name, []));
continue;
}
var subs = new List<int>();
foreach (var tok in inner.Split(','))
{
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
}
if (segments.Count == 0) return null;
return new AbCipTagPath(programScope, segments, bitIndex);
return new AbCipTagPath(programScope, segments, bitIndex, slice);
}
private static bool IsValidIdent(string s)
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
/// <summary>
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
/// Rockwell array read covering <c>End - Start + 1</c> elements.
/// </summary>
public sealed record AbCipTagPathSlice(int Start, int End)
{
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
public int Count => End - Start + 1;
}

View File

@@ -69,6 +69,10 @@ public interface IAbCipTagFactory
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
/// behaviour for back-compat.</param>
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -76,4 +80,5 @@ public sealed record AbCipTagCreateParams(
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout,
int? StringMaxCapacity = null);
int? StringMaxCapacity = null,
int? ElementCount = null);

View File

@@ -30,6 +30,11 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
if (p.StringMaxCapacity is int cap && cap > 0)
_tag.StringMaxCapacity = (uint)cap;
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
// to issue one Rockwell array read for a [N..M] slice.
if (p.ElementCount is int n && n > 0)
_tag.ElementCount = n;
}
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);

View File

@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipArrayReadPlannerTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static AbCipTagCreateParams BaseParams(string tagName) => new(
Gateway: "10.0.0.5",
Port: 44818,
CipPath: "1,0",
LibplctagPlcAttribute: "controllogix",
TagName: tagName,
Timeout: TimeSpan.FromSeconds(5));
[Fact]
public void TryBuild_emits_single_tag_create_with_element_count()
{
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
plan.ShouldNotBeNull();
plan.ElementType.ShouldBe(AbCipDataType.DInt);
plan.Stride.ShouldBe(4);
plan.Slice.Count.ShouldBe(16);
plan.CreateParams.ElementCount.ShouldBe(16);
// Anchored at the slice start; libplctag reads N consecutive elements from there.
plan.CreateParams.TagName.ShouldBe("Data[0]");
}
[Fact]
public void TryBuild_returns_null_when_path_has_no_slice()
{
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
}
[Theory]
[InlineData(AbCipDataType.Bool)]
[InlineData(AbCipDataType.String)]
[InlineData(AbCipDataType.Structure)]
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
{
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
}
[Theory]
[InlineData(AbCipDataType.SInt, 1)]
[InlineData(AbCipDataType.Int, 2)]
[InlineData(AbCipDataType.DInt, 4)]
[InlineData(AbCipDataType.Real, 4)]
[InlineData(AbCipDataType.LInt, 8)]
[InlineData(AbCipDataType.LReal, 8)]
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
{
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
plan.Stride.ShouldBe(expectedStride);
}
[Fact]
public void Decode_walks_buffer_at_element_stride()
{
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
var fake = new FakeAbCipTag(plan.CreateParams);
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
fake.ValuesByOffset[0] = 100;
fake.ValuesByOffset[4] = 200;
fake.ValuesByOffset[8] = 300;
fake.ValuesByOffset[12] = 400;
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
decoded.Length.ShouldBe(4);
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
}
[Fact]
public void Decode_preserves_slice_count_for_real_arrays()
{
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
var fake = new FakeAbCipTag(plan.CreateParams);
fake.ValuesByOffset[0] = 1.5f;
fake.ValuesByOffset[4] = 2.5f;
fake.ValuesByOffset[8] = 3.5f;
fake.ValuesByOffset[12] = 4.5f;
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
}
}

View File

@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
p.TagName.ShouldBe("Program:P.Counter");
}
[Fact]
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
{
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
// buffer decoded at element stride into one snapshot whose Value is an object?[].
var (drv, factory) = NewDriver(
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p =>
{
var t = new FakeAbCipTag(p);
t.ValuesByOffset[0] = 10;
t.ValuesByOffset[4] = 20;
t.ValuesByOffset[8] = 30;
t.ValuesByOffset[12] = 40;
return t;
};
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
// Exactly ONE libplctag tag was created — anchored at the slice start with
// ElementCount=4. Without the planner this would have been four scalar reads.
factory.Tags.Count.ShouldBe(1);
factory.Tags.ShouldContainKey("Data[0]");
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
{
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
// of attempting a best-effort decode.
var (drv, _) = NewDriver(
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
snapshots.Single().Value.ShouldBeNull();
}
[Fact]
public async Task Cancellation_propagates_from_read()
{

View File

@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
[Fact]
public void Slice_basic_inclusive_range()
{
var p = AbCipTagPath.TryParse("Data[0..15]");
p.ShouldNotBeNull();
p.Slice.ShouldNotBeNull();
p.Slice!.Start.ShouldBe(0);
p.Slice.End.ShouldBe(15);
p.Slice.Count.ShouldBe(16);
p.BitIndex.ShouldBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBeEmpty();
p.ToLibplctagName().ShouldBe("Data[0..15]");
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
// index; pair with ElementCount to cover the whole range.
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
}
[Fact]
public void Slice_with_program_scope_and_member_chain()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
p.Slice!.Start.ShouldBe(3);
p.Slice.End.ShouldBe(7);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
}
[Fact]
public void Slice_zero_length_single_element_allowed()
{
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
var p = AbCipTagPath.TryParse("Data[5..5]");
p.ShouldNotBeNull();
p.Slice!.Count.ShouldBe(1);
}
[Theory]
[InlineData("Data[5..3]")] // M < N
[InlineData("Data[-1..5]")] // negative start
[InlineData("Data[0..15].Member")] // slice + sub-element
[InlineData("Data[0..15].3")] // slice + bit index
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
[InlineData("Data[..5]")] // missing start
[InlineData("Data[5..]")] // missing end
[InlineData("Data[a..5]")] // non-numeric start
public void Invalid_slice_shapes_return_null(string input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{