using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [Trait("Category", "Unit")] public sealed class AbCipDriverReadTests { private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = tags, }; var drv = new AbCipDriver(opts, "drv-1", factory); return (drv, factory); } [Fact] public async Task Unknown_reference_maps_to_BadNodeIdUnknown() { var (drv, _) = NewDriver(); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); snapshots.Single().Value.ShouldBeNull(); } [Fact] public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown() { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)], }; var drv = new AbCipDriver(opts, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Successful_DInt_read_returns_Good_with_value() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); // Customise the fake before the first read so the tag returns 4200. factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 }; var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); snapshots.Single().Value.ShouldBe(4200); factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1); } [Fact] public async Task Repeat_read_reuses_runtime_without_reinitialise() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 1 }; await drv.ReadAsync(["Speed"], CancellationToken.None); await drv.ReadAsync(["Speed"], CancellationToken.None); await drv.ReadAsync(["Speed"], CancellationToken.None); factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3); } [Fact] public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ }; var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); snapshots.Single().Value.ShouldBeNull(); } [Fact] public async Task Exception_during_read_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true }; var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); snapshots.Single().Value.ShouldBeNull(); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Batched_reads_preserve_order_and_per_tag_status() { var (drv, factory) = NewDriver( new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt), new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real), new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => p.TagName switch { "A" => new FakeAbCipTag(p) { Value = 42 }, "B" => new FakeAbCipTag(p) { Value = 3.14f }, _ => new FakeAbCipTag(p) { Value = "hello" }, }; var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); snapshots.Count.ShouldBe(3); snapshots[0].Value.ShouldBe(42); snapshots[1].Value.ShouldBe(3.14f); snapshots[2].Value.ShouldBe("hello"); snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good); } [Fact] public async Task Successful_read_marks_health_Healthy() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f }; await drv.ReadAsync(["Pressure"], CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); } [Fact] public async Task TagCreateParams_are_built_from_device_and_profile() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Counter"], CancellationToken.None); var p = factory.Tags["Program:P.Counter"].CreationParams; p.Gateway.ShouldBe("10.0.0.5"); p.Port.ShouldBe(44818); p.CipPath.ShouldBe("1,0"); p.LibplctagPlcAttribute.ShouldBe("controllogix"); 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() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true, Exception = new OperationCanceledException(), }; using var cts = new CancellationTokenSource(); await Should.ThrowAsync( () => drv.ReadAsync(["Slow"], cts.Token)); } [Fact] public async Task ShutdownAsync_disposes_each_tag_runtime() { var (drv, factory) = NewDriver( new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt), new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 1 }; await drv.ReadAsync(["A", "B"], CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); factory.Tags["A"].Disposed.ShouldBeTrue(); factory.Tags["B"].Disposed.ShouldBeTrue(); } [Fact] public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error() { var (drv, factory) = NewDriver( new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true }; var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); factory.Tags["Nope"].Disposed.ShouldBeTrue(); } // PR abcip-1.2 — STRINGnn variant decoding. Threading // through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs // decode against the right DATA-array size; null preserves the default 82-byte STRING. [Fact] public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String, StringLength: 40)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" }; await drv.ReadAsync(["Banner"], CancellationToken.None); factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40); } [Fact] public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat() { var (drv, factory) = NewDriver( new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = "world" }; await drv.ReadAsync(["LegacyStr"], CancellationToken.None); factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull(); } [Fact] public async Task StringLength_ignored_for_non_String_data_types() { // StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would // otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the // declared DataType. var (drv, factory) = NewDriver( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt, StringLength: 80)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 7 }; await drv.ReadAsync(["Speed"], CancellationToken.None); factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull(); } [Fact] public async Task UDT_member_StringLength_threads_through_to_member_runtime() { // STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from // AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member // runtime then receives the right StringMaxCapacity. var udt = new AbCipTagDefinition( Name: "Recipe", DeviceHostAddress: "ab://10.0.0.5/1,0", TagPath: "Recipe", DataType: AbCipDataType.Structure, Members: [ new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20), new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80), new AbCipStructureMember("Code", AbCipDataType.DInt), ]); var (drv, factory) = NewDriver(udt); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = "x" }; await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None); factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20); factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80); factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull(); } }