using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; [Trait("Category", "Unit")] public sealed class AbLegacyReadWriteTests { private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags) { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = tags, }, "drv-1", factory); return (drv, factory); } // ---- Read ---- [Fact] public async Task Unknown_reference_maps_to_BadNodeIdUnknown() { var (drv, _) = NewDriver(); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Successful_N_file_read_returns_Good_value() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 }; var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snapshots.Single().Value.ShouldBe(42); factory.Tags["N7:0"].InitializeCount.ShouldBe(1); factory.Tags["N7:0"].ReadCount.ShouldBe(1); } [Fact] public async Task Repeat_read_reuses_runtime() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 }; await drv.ReadAsync(["X"], CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Tags["N7:0"].InitializeCount.ShouldBe(1); factory.Tags["N7:0"].ReadCount.ShouldBe(2); } [Fact] public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Read_exception_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Batched_reads_preserve_order() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float), new AbLegacyTagDefinition("C", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => p.TagName switch { "N7:0" => new FakeAbLegacyTag(p) { Value = 1 }, "F8:0" => new FakeAbLegacyTag(p) { Value = 3.14f }, _ => new FakeAbLegacyTag(p) { Value = "hello" }, }; var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); snapshots.Count.ShouldBe(3); snapshots[0].Value.ShouldBe(1); snapshots[1].Value.ShouldBe(3.14f); snapshots[2].Value.ShouldBe("hello"); } [Fact] public async Task Read_TagCreateParams_composed_from_device_and_profile() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); var p = factory.Tags["N7:5"].CreationParams; p.Gateway.ShouldBe("10.0.0.5"); p.Port.ShouldBe(44818); p.CipPath.ShouldBe("1,0"); p.LibplctagPlcAttribute.ShouldBe("slc500"); p.TagName.ShouldBe("N7:5"); } // ---- Write ---- [Fact] public async Task Non_writable_tag_rejects_with_BadNotWritable() { var (drv, _) = NewDriver( new AbLegacyTagDefinition("RO", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("RO", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable); } [Fact] public async Task Successful_N_file_write_encodes_and_flushes() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("X", 123)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); factory.Tags["N7:0"].Value.ShouldBe(123); factory.Tags["N7:0"].WriteCount.ShouldBe(1); } [Fact] public async Task Bit_within_word_write_now_succeeds_via_RMW() { // Task #181 pass 2 lifted this gap — N-file bit writes now go through // WriteBitInWordAsync + a parallel parent-word runtime, so the status is Good rather // than BadNotSupported. Full RMW semantics covered by AbLegacyBitRmwTests. var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition("Bit3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Bit3", true)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); } [Fact] public async Task Write_exception_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnWrite = true }; var results = await drv.WriteAsync( [new WriteRequest("X", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); } [Fact] public async Task Batch_write_preserves_order_across_outcomes() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [ new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: false), ], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [ new WriteRequest("A", 1), new WriteRequest("B", 2), new WriteRequest("Unknown", 3), ], CancellationToken.None); results.Count.ShouldBe(3); results[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); results[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable); results[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Cancellation_propagates() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new OperationCanceledException(), }; await Should.ThrowAsync( () => drv.ReadAsync(["X"], CancellationToken.None)); } [Fact] public async Task ShutdownAsync_disposes_runtimes() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 }; await drv.ReadAsync(["A"], CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); factory.Tags["N7:0"].Disposed.ShouldBeTrue(); } private sealed class RmwThrowingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) { public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) { if (type == AbLegacyDataType.Bit && bitIndex is not null) throw new NotSupportedException("bit-within-word RMW deferred"); Value = value; } } // ---- Timer / Counter / Control sub-element bit semantics (issue #246) ---- [Theory] [InlineData("T4:0.DN", 13)] [InlineData("T4:0.TT", 14)] [InlineData("T4:0.EN", 15)] public async Task Timer_status_bit_decodes_correct_position(string address, int bitPos) { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.TimerElement)); await drv.InitializeAsync("{}", CancellationToken.None); // Seed a parent-word with only the target bit set. factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().Value.ShouldBe(true); // The driver must have asked the runtime for the right bit position. factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos); } [Fact] public async Task Timer_PRE_subelement_decodes_as_int_word() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Pre", "ab://10.0.0.5/1,0", "T4:0.PRE", AbLegacyDataType.TimerElement)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5000 }; var snapshots = await drv.ReadAsync(["Pre"], CancellationToken.None); snapshots.Single().Value.ShouldBe(5000); factory.Tags["T4:0.PRE"].LastDecodeBitIndex.ShouldBeNull(); } [Theory] [InlineData("C5:0.UN", 10)] [InlineData("C5:0.OV", 11)] [InlineData("C5:0.DN", 12)] [InlineData("C5:0.CD", 13)] [InlineData("C5:0.CU", 14)] public async Task Counter_status_bit_decodes_correct_position(string address, int bitPos) { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.CounterElement)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().Value.ShouldBe(true); factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos); } [Theory] [InlineData("R6:0.FD", 8)] [InlineData("R6:0.IN", 9)] [InlineData("R6:0.UL", 10)] [InlineData("R6:0.ER", 11)] [InlineData("R6:0.EM", 12)] [InlineData("R6:0.DN", 13)] [InlineData("R6:0.EU", 14)] [InlineData("R6:0.EN", 15)] public async Task Control_status_bit_decodes_correct_position(string address, int bitPos) { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.ControlElement)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().Value.ShouldBe(true); factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos); } [Fact] public async Task Status_bit_returns_false_when_parent_word_bit_is_clear() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Done", "ab://10.0.0.5/1,0", "T4:0.DN", AbLegacyDataType.TimerElement)); await drv.InitializeAsync("{}", CancellationToken.None); // Bit 14 (TT) set, bit 13 (DN) clear. factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << 14 }; var snapshots = await drv.ReadAsync(["Done"], CancellationToken.None); snapshots.Single().Value.ShouldBe(false); } [Theory] [InlineData("T4:0.DN", AbLegacyDataType.TimerElement)] [InlineData("T4:0.TT", AbLegacyDataType.TimerElement)] [InlineData("C5:0.DN", AbLegacyDataType.CounterElement)] [InlineData("C5:0.OV", AbLegacyDataType.CounterElement)] [InlineData("C5:0.UN", AbLegacyDataType.CounterElement)] [InlineData("R6:0.ER", AbLegacyDataType.ControlElement)] [InlineData("R6:0.EM", AbLegacyDataType.ControlElement)] [InlineData("R6:0.DN", AbLegacyDataType.ControlElement)] [InlineData("R6:0.FD", AbLegacyDataType.ControlElement)] public async Task Writes_to_PLC_set_status_bits_return_BadNotWritable( string address, AbLegacyDataType dataType) { var (drv, _) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, dataType)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("X", true)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable); } }