diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index f0585c9..23957a7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -8,18 +8,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// -public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable +public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable { private readonly AbLegacyDriverOptions _options; private readonly string _driverInstanceId; + private readonly IAbLegacyTagFactory _tagFactory; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); - public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId) + public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId, + IAbLegacyTagFactory? tagFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; + _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); } public string DriverInstanceId => _driverInstanceId; @@ -38,6 +42,7 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily); _devices[device.HostAddress] = new DeviceState(addr, device, profile); } + foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) @@ -56,7 +61,9 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable public Task ShutdownAsync(CancellationToken cancellationToken) { + foreach (var state in _devices.Values) state.DisposeRuntimes(); _devices.Clear(); + _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); return Task.CompletedTask; } @@ -69,6 +76,153 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; + // ---- IReadable ---- + + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + var now = DateTime.UtcNow; + var results = new DataValueSnapshot[fullReferences.Count]; + + for (var i = 0; i < fullReferences.Count; i++) + { + var reference = fullReferences[i]; + if (!_tagsByName.TryGetValue(reference, out var def)) + { + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + + try + { + var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); + await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); + + var status = runtime.GetStatus(); + if (status != 0) + { + results[i] = new DataValueSnapshot(null, + AbLegacyStatusMapper.MapLibplctagStatus(status), null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"libplctag status {status} reading {reference}"); + continue; + } + + var parsed = AbLegacyAddress.TryParse(def.Address); + var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); + results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); + _health = new DriverHealth(DriverState.Healthy, now, null); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results[i] = new DataValueSnapshot(null, + AbLegacyStatusMapper.BadCommunicationError, null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + // ---- IWritable ---- + + public async Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + var results = new WriteResult[writes.Count]; + + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (!_tagsByName.TryGetValue(w.FullReference, out var def)) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); + continue; + } + if (!def.Writable) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); + continue; + } + + try + { + var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); + var parsed = AbLegacyAddress.TryParse(def.Address); + runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); + await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); + + var status = runtime.GetStatus(); + results[i] = new WriteResult(status == 0 + ? AbLegacyStatusMapper.Good + : AbLegacyStatusMapper.MapLibplctagStatus(status)); + } + catch (OperationCanceledException) { throw; } + catch (NotSupportedException nse) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch); + } + catch (OverflowException) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange); + } + catch (Exception ex) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + private async Task EnsureTagRuntimeAsync( + DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) + { + if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; + + var parsed = AbLegacyAddress.TryParse(def.Address) + ?? throw new InvalidOperationException( + $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); + + var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( + Gateway: device.ParsedAddress.Gateway, + Port: device.ParsedAddress.Port, + CipPath: device.ParsedAddress.CipPath, + LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, + TagName: parsed.ToLibplctagName(), + Timeout: _options.Timeout)); + try + { + await runtime.InitializeAsync(ct).ConfigureAwait(false); + } + catch + { + runtime.Dispose(); + throw; + } + device.Runtimes[def.Name] = runtime; + return runtime; + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); @@ -80,5 +234,13 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress; public AbLegacyDeviceOptions Options { get; } = options; public AbLegacyPlcFamilyProfile Profile { get; } = profile; + public Dictionary Runtimes { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public void DisposeRuntimes() + { + foreach (var r in Runtimes.Values) r.Dispose(); + Runtimes.Clear(); + } } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs new file mode 100644 index 0000000..4e0c98b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Wire-layer abstraction over a single PCCC tag. Mirrors IAbCipTagRuntime's shape so +/// the same test-fake pattern applies; the only meaningful difference is the protocol layer +/// underneath (ab_pccc vs ab_eip). +/// +public interface IAbLegacyTagRuntime : IDisposable +{ + Task InitializeAsync(CancellationToken cancellationToken); + Task ReadAsync(CancellationToken cancellationToken); + Task WriteAsync(CancellationToken cancellationToken); + int GetStatus(); + object? DecodeValue(AbLegacyDataType type, int? bitIndex); + void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value); +} + +public interface IAbLegacyTagFactory +{ + IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams); +} + +public sealed record AbLegacyTagCreateParams( + string Gateway, + int Port, + string CipPath, + string LibplctagPlcAttribute, + string TagName, + TimeSpan Timeout); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs new file mode 100644 index 0000000..b05b1fd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -0,0 +1,97 @@ +using libplctag; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Default libplctag-backed . Uses ab_pccc protocol +/// on top of EtherNet/IP — libplctag's PCCC layer handles the file-letter + word + bit + +/// sub-element decoding internally, so our wrapper just has to forward the atomic type to +/// the right Get/Set call. +/// +internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime +{ + private readonly Tag _tag; + + public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) + { + _tag = new Tag + { + Gateway = p.Gateway, + Path = p.CipPath, + PlcType = MapPlcType(p.LibplctagPlcAttribute), + Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer + Name = p.TagName, + Timeout = p.Timeout, + }; + } + + public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); + public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken); + public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken); + + public int GetStatus() => (int)_tag.GetStatus(); + + public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch + { + AbLegacyDataType.Bit => bitIndex is int bit + ? _tag.GetBit(bit) + : _tag.GetInt8(0) != 0, + AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(0), + AbLegacyDataType.Long => _tag.GetInt32(0), + AbLegacyDataType.Float => _tag.GetFloat32(0), + AbLegacyDataType.String => _tag.GetString(0), + AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement + or AbLegacyDataType.ControlElement => _tag.GetInt32(0), + _ => null, + }; + + public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) + { + switch (type) + { + case AbLegacyDataType.Bit: + if (bitIndex is int) + throw new NotSupportedException( + "Bit-within-word writes require read-modify-write; tracked in task #181."); + _tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0); + break; + case AbLegacyDataType.Int: + case AbLegacyDataType.AnalogInt: + _tag.SetInt16(0, Convert.ToInt16(value)); + break; + case AbLegacyDataType.Long: + _tag.SetInt32(0, Convert.ToInt32(value)); + break; + case AbLegacyDataType.Float: + _tag.SetFloat32(0, Convert.ToSingle(value)); + break; + case AbLegacyDataType.String: + _tag.SetString(0, Convert.ToString(value) ?? string.Empty); + break; + case AbLegacyDataType.TimerElement: + case AbLegacyDataType.CounterElement: + case AbLegacyDataType.ControlElement: + _tag.SetInt32(0, Convert.ToInt32(value)); + break; + default: + throw new NotSupportedException($"AbLegacyDataType {type} not writable."); + } + } + + public void Dispose() => _tag.Dispose(); + + private static PlcType MapPlcType(string attribute) => attribute switch + { + "slc500" => PlcType.Slc500, + "micrologix" => PlcType.MicroLogix, + "plc5" => PlcType.Plc5, + "logixpccc" => PlcType.LogixPccc, + _ => PlcType.Slc500, + }; +} + +internal sealed class LibplctagLegacyTagFactory : IAbLegacyTagFactory +{ + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) => + new LibplctagLegacyTagRuntime(createParams); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs new file mode 100644 index 0000000..1997c6f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs @@ -0,0 +1,256 @@ +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_rejected_as_BadNotSupported() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) }; + 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.BadNotSupported); + } + + [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; + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs new file mode 100644 index 0000000..914fa8f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs @@ -0,0 +1,59 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +internal class FakeAbLegacyTag : IAbLegacyTagRuntime +{ + public AbLegacyTagCreateParams CreationParams { get; } + public object? Value { get; set; } + public int Status { get; set; } + public bool ThrowOnInitialize { get; set; } + public bool ThrowOnRead { get; set; } + public bool ThrowOnWrite { get; set; } + public Exception? Exception { get; set; } + public int InitializeCount { get; private set; } + public int ReadCount { get; private set; } + public int WriteCount { get; private set; } + public bool Disposed { get; private set; } + + public FakeAbLegacyTag(AbLegacyTagCreateParams p) => CreationParams = p; + + public virtual Task InitializeAsync(CancellationToken ct) + { + InitializeCount++; + if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException(); + return Task.CompletedTask; + } + + public virtual Task ReadAsync(CancellationToken ct) + { + ReadCount++; + if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); + return Task.CompletedTask; + } + + public virtual Task WriteAsync(CancellationToken ct) + { + WriteCount++; + if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); + return Task.CompletedTask; + } + + public virtual int GetStatus() => Status; + public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value; + public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value; + public virtual void Dispose() => Disposed = true; +} + +internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory +{ + public Dictionary Tags { get; } = new(StringComparer.OrdinalIgnoreCase); + public Func? Customise { get; set; } + + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) + { + var fake = Customise?.Invoke(p) ?? new FakeAbLegacyTag(p); + Tags[p.TagName] = fake; + return fake; + } +}