@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user