diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs new file mode 100644 index 0000000..2a60b5d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs @@ -0,0 +1,94 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// PR abcip-1.3 — issues one libplctag tag-create with ElementCount=N per Rockwell +/// array-slice tag (Tag[0..N] in ), then decodes the +/// contiguous buffer at element stride into N typed values. Mirrors the whole-UDT +/// planner pattern (): pure shape — the planner never +/// touches the runtime + never reads the PLC, the driver wires the runtime in. +/// +/// +/// 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). +/// +/// Output is a single object[] snapshot value containing the N decoded +/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the +/// ReadAsync 1:1 contract (one fullReference -> one snapshot) intact. +/// +public static class AbCipArrayReadPlanner +{ + /// + /// Build the libplctag create-params + decode descriptor for a slice tag. Returns + /// null 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. + /// + 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); + } + + /// + /// Decode .Count elements from at + /// element stride. Caller has already invoked + /// and confirmed == 0. + /// + 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; + } + } +} + +/// +/// Plan output: the libplctag create-params for the single array-read tag plus the +/// element-type / stride / slice metadata the decoder needs. +/// +public sealed record AbCipArrayReadPlan( + AbCipDataType ElementType, + AbCipTagPathSlice Slice, + int Stride, + AbCipTagCreateParams CreateParams); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 3ac96b7..a63d32b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -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, } } + /// + /// PR abcip-1.3 — slice read path. Builds an 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 object?[] at element stride. Unsupported + /// element types fall back to . + /// + 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); + } + } + + /// + /// Idempotently materialise a slice-read runtime. Slice runtimes share the device's + /// dict keyed by the tag's full name so repeated + /// reads reuse the same libplctag handle without re-creating the native tag every poll. + /// + private async Task 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; + } + /// /// 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 diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs index 0891664..04a20d9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs @@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; public sealed record AbCipTagPath( string? ProgramScope, IReadOnlyList Segments, - int? BitIndex) + int? BitIndex, + AbCipTagPathSlice? Slice = null) { /// Rebuild the canonical Logix tag string. 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(); } + /// + /// 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 + /// ..End suffix. The driver pairs this with + /// = to issue a single Rockwell array read. + /// + 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(); + } + /// /// Parse a Logix-symbolic tag reference. Returns null 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(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(); 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( /// One path segment: a member name plus any numeric subscripts. public sealed record AbCipTagPathSegment(string Name, IReadOnlyList Subscripts); + +/// +/// Inclusive-on-both-ends array slice carried on the trailing segment of an +/// . Tag[0..15] parses to Start=0, End=15; the +/// planner pairs this with libplctag's ElementCount attribute to issue a single +/// Rockwell array read covering End - Start + 1 elements. +/// +public sealed record AbCipTagPathSlice(int Start, int End) +{ + /// Total element count covered by the slice (inclusive both ends). + public int Count => End - Start + 1; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs index d6b496e..c720b13 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs @@ -69,6 +69,10 @@ public interface IAbCipTagFactory /// for STRING_20 / STRING_40 / STRING_80 UDTs). Threads through libplctag's /// str_max_capacity attribute. null keeps libplctag's default 82-byte STRING /// behaviour for back-compat. +/// Optional libplctag ElementCount override — set to N +/// to issue a Rockwell array read covering N consecutive elements starting at the +/// subscripted index in . Drives PR abcip-1.3 array-slice support; +/// null leaves libplctag's default scalar-element behaviour for back-compat. 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index 5389e6b..7bf8015 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -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); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayReadPlannerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayReadPlannerTests.cs new file mode 100644 index 0000000..2e5cdf8 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayReadPlannerTests.cs @@ -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 }); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs index 91369bc..f83c1b3 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs @@ -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(); + 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() { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs index 6896c0f..41096e2 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs @@ -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() {