From 257f4fd3f53c905f5fe03128cce0aeeeb71bc08f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 16:57:52 -0400 Subject: [PATCH] =?UTF-8?q?AB=20CIP=20PR=204=20=E2=80=94=20IWritable=20imp?= =?UTF-8?q?lementation.=20LibplctagTagRuntime.EncodeValue=20fills=20in=20t?= =?UTF-8?q?he=20switch=20for=20every=20atomic=20Logix=20type=20the=20drive?= =?UTF-8?q?r=20currently=20surfaces=20=E2=80=94=20Bool=20(standalone=20BOO?= =?UTF-8?q?L=20via=20SetInt8=200/1),=20SInt/USInt=20(SetInt8/SetUInt8),=20?= =?UTF-8?q?Int/UInt=20(SetInt16/SetUInt16),=20DInt/UDInt=20(SetInt32/SetUI?= =?UTF-8?q?nt32),=20LInt/ULInt=20(SetInt64/SetUInt64),=20Real=20(SetFloat3?= =?UTF-8?q?2),=20LReal=20(SetFloat64),=20String=20(SetString=200),=20Dt=20?= =?UTF-8?q?(epoch=20DINT=20via=20SetInt32).=20BOOL-within-DINT=20writes=20?= =?UTF-8?q?throw=20NotSupportedException=20with=20a=20code=20comment=20mat?= =?UTF-8?q?ching=20the=20Modbus=20BitInRegister=20pattern=20at=20ModbusDri?= =?UTF-8?q?ver.cs=20line=20640=20=E2=80=94=20the=20read-modify-write=20log?= =?UTF-8?q?ic=20+=20lock-per-DINT=20discipline=20is=20a=20follow-up=20PR?= =?UTF-8?q?=20rather=20than=20squeezing=20it=20into=20the=20initial=20wire?= =?UTF-8?q?=20plumbing.=20Structure=20writes=20throw=20NotSupportedExcepti?= =?UTF-8?q?on=20pointing=20at=20PR=206=20when=20UDT=20support=20lands.=20A?= =?UTF-8?q?bCipDriver=20now=20implements=20IWritable.=20WriteAsync=20itera?= =?UTF-8?q?tes=20writes=20preserving=20order,=20short-circuits=20on=20unkn?= =?UTF-8?q?own=20reference=20=E2=86=92=20BadNodeIdUnknown,=20on=20non-writ?= =?UTF-8?q?able=20tag=20definition=20=E2=86=92=20BadNotWritable,=20on=20un?= =?UTF-8?q?known=20device=20=E2=86=92=20BadNodeIdUnknown.=20Happy=20path?= =?UTF-8?q?=20materialises=20the=20cached=20runtime=20via=20EnsureTagRunti?= =?UTF-8?q?meAsync=20(shares=20PR=203's=20lazy-init=20path=20so=20read+wri?= =?UTF-8?q?te=20on=20the=20same=20tag=20hits=20one=20native=20handle),=20E?= =?UTF-8?q?ncodeValue=20into=20the=20tag's=20buffer,=20WriteAsync=20flushe?= =?UTF-8?q?s,=20GetStatus=20confirms=20the=20wire=20status,=20maps=20libpl?= =?UTF-8?q?ctag=20error=20codes=20via=20AbCipStatusMapper.MapLibplctagStat?= =?UTF-8?q?us,=20sets=20health=20Healthy=20on=20success.=20Per=20plan=20de?= =?UTF-8?q?cisions=20#44,=20#45,=20#143=20the=20driver=20does=20NOT=20auto?= =?UTF-8?q?-retry=20writes=20=E2=80=94=20that's=20a=20resilience-layer=20c?= =?UTF-8?q?oncern=20(Polly=20pipeline=20sitting=20above)=20keyed=20on=20th?= =?UTF-8?q?e=20tag's=20WriteIdempotent=20flag.=20Exception-mapping=20table?= =?UTF-8?q?=20=E2=80=94=20OperationCanceledException=20rethrows=20(honors?= =?UTF-8?q?=20cancellation),=20NotSupportedException=20=E2=86=92=20BadNotS?= =?UTF-8?q?upported=20(bit-in-DINT,=20Structure,=20future=20unsupported=20?= =?UTF-8?q?types),=20FormatException=20=E2=86=92=20BadTypeMismatch=20(Conv?= =?UTF-8?q?ert.ToInt32=20of=20a=20non-numeric=20string),=20InvalidCastExce?= =?UTF-8?q?ption=20=E2=86=92=20BadTypeMismatch=20(caller=20passed=20an=20o?= =?UTF-8?q?bject=20incompatible=20with=20the=20conversion=20target),=20Ove?= =?UTF-8?q?rflowException=20=E2=86=92=20BadOutOfRange=20(value=20exceeds?= =?UTF-8?q?=20target=20type=20range,=20e.g.=20Int16=20write=20of=201=5F000?= =?UTF-8?q?=5F000),=20any=20other=20Exception=20=E2=86=92=20BadCommunicati?= =?UTF-8?q?onError=20(wire=20drop,=20libplctag-internal=20failure).=20Heal?= =?UTF-8?q?th=20surface=20updates=20Degraded=20on=20every=20non-Cancellati?= =?UTF-8?q?on=20exception=20path,=20Healthy=20on=20success.=20Introduces?= =?UTF-8?q?=20AbCipStatusMapper.BadTypeMismatch=20(0x80730000).=2010=20new?= =?UTF-8?q?=20unit=20tests=20in=20AbCipDriverWriteTests=20covering=20?= =?UTF-8?q?=E2=80=94=20unknown=20ref=20=E2=86=92=20BadNodeIdUnknown,=20non?= =?UTF-8?q?-writable=20tag=20=E2=86=92=20BadNotWritable,=20successful=20DI?= =?UTF-8?q?nt=20write=20encodes=20+=20flushes=20the=20value=20+=20marks=20?= =?UTF-8?q?WriteCount=3D1,=20BOOL-in-DINT=20rejected=20as=20BadNotSupporte?= =?UTF-8?q?d=20(separate=20ThrowingBoolBitFake=20mirrors=20LibplctagTagRun?= =?UTF-8?q?time's=20runtime=20check),=20non-zero=20libplctag=20status=20af?= =?UTF-8?q?ter=20write=20mapped=20via=20AbCipStatusMapper=20(timeout=20-5?= =?UTF-8?q?=20=E2=86=92=20BadTimeout),=20FormatException=20from=20non-nume?= =?UTF-8?q?ric-string=20write=20=E2=86=92=20BadTypeMismatch=20(RealConvert?= =?UTF-8?q?Fake=20exercises=20real=20Convert.ToInt32),=20OverflowException?= =?UTF-8?q?=20from=20Int16=20write=20of=201=5F000=5F000=20=E2=86=92=20BadO?= =?UTF-8?q?utOfRange,=20generic=20exception=20during=20write=20=E2=86=92?= =?UTF-8?q?=20BadCommunicationError=20+=20health=20Degraded,=20batch=20wit?= =?UTF-8?q?h=20mixed=20success+failure=20preserves=20order=20across=20four?= =?UTF-8?q?=20request=20types,=20cancellation=20propagates=20as=20Operatio?= =?UTF-8?q?nCanceledException.=20FakeAbCipTag's=20test-fake=20base=20class?= =?UTF-8?q?=20methods=20made=20virtual=20so=20override=20hooks=20work=20co?= =?UTF-8?q?rrectly=20through=20the=20IAbCipTagRuntime=20interface=20(new-s?= =?UTF-8?q?hadow=20was=20silently=20falling=20through=20to=20the=20base=20?= =?UTF-8?q?implementation).=20Total=20AbCip=20unit=20tests=20now=2098/98?= =?UTF-8?q?=20passing;=20Modbus=20+=20other=20existing=20tests=20untouched?= =?UTF-8?q?;=20full=20solution=20builds=200=20errors.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbCipDriver.cs | 84 ++++++- .../AbCipStatusMapper.cs | 1 + .../LibplctagTagRuntime.cs | 60 ++++- .../AbCipDriverWriteTests.cs | 230 ++++++++++++++++++ .../FakeAbCipTag.cs | 16 +- 5 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 53b75f8..7e73c3e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// from native-heap growth that the CLR allocator can't see; it tears down every /// and reconnects each device. /// -public sealed class AbCipDriver : IDriver, IReadable, IDisposable, IAsyncDisposable +public sealed class AbCipDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable { private readonly AbCipDriverOptions _options; private readonly string _driverInstanceId; @@ -147,6 +147,88 @@ public sealed class AbCipDriver : IDriver, IReadable, IDisposable, IAsyncDisposa return results; } + // ---- IWritable ---- + + /// + /// Write each request in order. Writes are NOT auto-retried by the driver — per plan + /// decisions #44, #45, #143 the caller opts in via + /// and the resilience pipeline (layered above the driver) decides whether to replay. + /// Non-writable configurations surface as BadNotWritable; type-conversion failures + /// as BadTypeMismatch; transport errors as BadCommunicationError. + /// + public async Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + var results = new WriteResult[writes.Count]; + var now = DateTime.UtcNow; + + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (!_tagsByName.TryGetValue(w.FullReference, out var def)) + { + results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); + continue; + } + if (!def.Writable) + { + results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); + continue; + } + + try + { + var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); + var tagPath = AbCipTagPath.TryParse(def.TagPath); + runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value); + await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); + + var status = runtime.GetStatus(); + results[i] = new WriteResult(status == 0 + ? AbCipStatusMapper.Good + : AbCipStatusMapper.MapLibplctagStatus(status)); + if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null); + } + catch (OperationCanceledException) + { + throw; + } + catch (NotSupportedException nse) + { + results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + } + catch (FormatException fe) + { + results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message); + } + catch (InvalidCastException ice) + { + results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message); + } + catch (OverflowException oe) + { + results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message); + } + catch (Exception ex) + { + results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + /// /// Idempotently materialise the runtime handle for a tag definition. First call creates /// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs index b12eec6..d791855 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs @@ -40,6 +40,7 @@ public static class AbCipStatusMapper public const uint BadDeviceFailure = 0x80550000u; public const uint BadCommunicationError = 0x80050000u; public const uint BadTimeout = 0x800A0000u; + public const uint BadTypeMismatch = 0x80730000u; /// Map a CIP general-status byte to an OPC UA StatusCode. public static uint MapCipGeneralStatus(byte status) => status switch diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index 931b58b..414de55 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -55,12 +55,60 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { - // Writes land in PR 4 — Encode is declared here so the interface surface is stable; - // PR 4 fills in the switch. - _ = type; - _ = bitIndex; - _ = value; - throw new NotSupportedException("AbCip writes land in PR 4."); + switch (type) + { + case AbCipDataType.Bool: + if (bitIndex is int bit) + { + // BOOL-within-DINT writes require read-modify-write on the parent DINT. + // Deferred to a follow-up PR — matches the Modbus BitInRegister pattern at + // ModbusDriver.cs:640. + throw new NotSupportedException( + "BOOL-within-DINT writes require read-modify-write; not implemented in PR 4."); + } + _tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0); + break; + case AbCipDataType.SInt: + _tag.SetInt8(0, Convert.ToSByte(value)); + break; + case AbCipDataType.USInt: + _tag.SetUInt8(0, Convert.ToByte(value)); + break; + case AbCipDataType.Int: + _tag.SetInt16(0, Convert.ToInt16(value)); + break; + case AbCipDataType.UInt: + _tag.SetUInt16(0, Convert.ToUInt16(value)); + break; + case AbCipDataType.DInt: + _tag.SetInt32(0, Convert.ToInt32(value)); + break; + case AbCipDataType.UDInt: + _tag.SetUInt32(0, Convert.ToUInt32(value)); + break; + case AbCipDataType.LInt: + _tag.SetInt64(0, Convert.ToInt64(value)); + break; + case AbCipDataType.ULInt: + _tag.SetUInt64(0, Convert.ToUInt64(value)); + break; + case AbCipDataType.Real: + _tag.SetFloat32(0, Convert.ToSingle(value)); + break; + case AbCipDataType.LReal: + _tag.SetFloat64(0, Convert.ToDouble(value)); + break; + case AbCipDataType.String: + _tag.SetString(0, Convert.ToString(value) ?? string.Empty); + break; + case AbCipDataType.Dt: + _tag.SetInt32(0, Convert.ToInt32(value)); + break; + case AbCipDataType.Structure: + throw new NotSupportedException("Whole-UDT writes land in PR 6."); + default: + throw new NotSupportedException($"AbCipDataType {type} not writable."); + } } public void Dispose() => _tag.Dispose(); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs new file mode 100644 index 0000000..d6fc72c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs @@ -0,0 +1,230 @@ +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_returns_BadNotSupported() + { + var factory = new FakeAbCipTagFactory { Customise = p => new ThrowingBoolBitFake(p) }; + 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.BadNotSupported); + } + + [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()); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs index bd2945b..78dac91 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// to simulate libplctag error codes, /// / to simulate exceptions. /// -internal sealed class FakeAbCipTag : IAbCipTagRuntime +internal class FakeAbCipTag : IAbCipTagRuntime { public AbCipTagCreateParams CreationParams { get; } public object? Value { get; set; } @@ -23,33 +23,33 @@ internal sealed class FakeAbCipTag : IAbCipTagRuntime public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams; - public Task InitializeAsync(CancellationToken cancellationToken) + public virtual Task InitializeAsync(CancellationToken cancellationToken) { InitializeCount++; if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure"); return Task.CompletedTask; } - public Task ReadAsync(CancellationToken cancellationToken) + public virtual Task ReadAsync(CancellationToken cancellationToken) { ReadCount++; if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure"); return Task.CompletedTask; } - public Task WriteAsync(CancellationToken cancellationToken) + public virtual Task WriteAsync(CancellationToken cancellationToken) { WriteCount++; return Task.CompletedTask; } - public int GetStatus() => Status; + public virtual int GetStatus() => Status; - public object? DecodeValue(AbCipDataType type, int? bitIndex) => Value; + public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value; - public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value; + public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value; - public void Dispose() => Disposed = true; + public virtual void Dispose() => Disposed = true; } /// Test factory that produces s and indexes them for assertion.