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 AbCipDriverWriteTests { private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = tags, }, "drv-1", factory); return (drv, factory); } [Fact] public async Task Unknown_reference_maps_to_BadNodeIdUnknown() { var (drv, _) = NewDriver(); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("does-not-exist", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Non_writable_tag_maps_to_BadNotWritable() { var (drv, _) = NewDriver( new AbCipTagDefinition("ReadOnly", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("ReadOnly", 7)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); } [Fact] public async Task Successful_DInt_write_encodes_and_flushes() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Speed", 4200)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.Tags["Motor1.Speed"].Value.ShouldBe(4200); factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1); } [Fact] public async Task Bit_in_dint_write_now_succeeds_via_RMW() { // Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through // WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather // than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests. var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Flag3", true)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); } [Fact] public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ }; var results = await drv.WriteAsync( [new WriteRequest("Broken", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout); } [Fact] public async Task Type_mismatch_surfaces_BadTypeMismatch() { var (drv, _) = NewDriver( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); // Force a FormatException inside Convert.ToInt32 via a runtime that forwards to real Convert. var factory = new FakeAbCipTagFactory { Customise = p => new RealConvertFake(p), }; var drv2 = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)], }, "drv-2", factory); await drv2.InitializeAsync("{}", CancellationToken.None); var results = await drv2.WriteAsync( [new WriteRequest("Speed", "not-a-number")], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); } [Fact] public async Task Overflow_surfaces_BadOutOfRange() { var factory = new FakeAbCipTagFactory { Customise = p => new RealConvertFake(p) }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Narrow", 1_000_000)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); } [Fact] public async Task Exception_during_write_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ThrowOnWriteFake(p); var results = await drv.WriteAsync( [new WriteRequest("Broken", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Batch_preserves_order_across_success_and_failure() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [ 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, Writable: false), new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt), ], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [ new WriteRequest("A", 1), new WriteRequest("B", 2), new WriteRequest("UnknownTag", 3), new WriteRequest("C", 4), ], CancellationToken.None); results.Count.ShouldBe(4); results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good); } [Fact] public async Task Cancellation_propagates_from_write() { 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 CancelOnWriteFake(p); await Should.ThrowAsync( () => drv.WriteAsync([new WriteRequest("Slow", 1)], CancellationToken.None)); } // ---- test-fake variants that exercise the real type / error handling ---- private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { switch (type) { case AbCipDataType.Int: _ = Convert.ToInt16(value); break; case AbCipDataType.DInt: _ = Convert.ToInt32(value); break; default: _ = Convert.ToInt32(value); break; } Value = value; } } private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { if (type == AbCipDataType.Bool && bitIndex is not null) throw new NotSupportedException("bit-in-DINT deferred"); Value = value; } } private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override Task WriteAsync(CancellationToken ct) => Task.FromException(new InvalidOperationException("wire dropped")); } private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override Task WriteAsync(CancellationToken ct) => Task.FromException(new OperationCanceledException()); } }