using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; [Trait("Category", "Unit")] public sealed class TwinCATReadWriteTests { private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = tags, Probe = new TwinCATProbeOptions { Enabled = false }, }, "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(TwinCATStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Successful_DInt_read_returns_Good_value() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4200 } }; var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); snapshots.Single().Value.ShouldBe(4200); factory.Clients[0].ConnectCount.ShouldBe(1); factory.Clients[0].IsConnected.ShouldBeTrue(); } [Fact] public async Task Repeat_read_reuses_connection() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "GVL.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } }; await drv.ReadAsync(["X"], CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); // One client, one connect — subsequent calls reuse the connected client. factory.Clients.Count.ShouldBe(1); factory.Clients[0].ConnectCount.ShouldBe(1); } [Fact] public async Task Read_with_ADS_error_maps_via_status_mapper() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("Ghost", "ads://5.23.91.23.1.1:851", "MAIN.Missing", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeTwinCATClient(); c.ReadStatuses["MAIN.Missing"] = TwinCATStatusMapper.BadNodeIdUnknown; return c; }; var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Read_exception_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { ThrowOnRead = true }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Connect_failure_surfaces_BadCommunicationError_and_disposes_client() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { ThrowOnConnect = true }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); factory.Clients[0].DisposeCount.ShouldBe(1); } [Fact] public async Task Batched_reads_preserve_order() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real), new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 3.14f, ["MAIN.C"] = "hello", }, }; var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); snapshots[0].Value.ShouldBe(1); snapshots[1].Value.ShouldBe(3.14f); snapshots[2].Value.ShouldBe("hello"); } // ---- Write ---- [Fact] public async Task Non_writable_tag_rejected_with_BadNotWritable() { var (drv, _) = NewDriver( new TwinCATTagDefinition("RO", "ads://5.23.91.23.1.1:851", "MAIN.RO", TwinCATDataType.DInt, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("RO", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); } [Fact] public async Task Successful_write_logs_symbol_type_value() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Speed", 4200)], CancellationToken.None); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); var write = factory.Clients[0].WriteLog.Single(); write.symbol.ShouldBe("MAIN.Speed"); write.type.ShouldBe(TwinCATDataType.DInt); write.value.ShouldBe(4200); } [Fact] public async Task Write_with_ADS_error_surfaces_mapped_status() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeTwinCATClient(); c.WriteStatuses["MAIN.X"] = TwinCATStatusMapper.BadNotWritable; return c; }; var results = await drv.WriteAsync( [new WriteRequest("X", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); } [Fact] public async Task Write_exception_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { ThrowOnWrite = true }; var results = await drv.WriteAsync( [new WriteRequest("X", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); } [Fact] public async Task Batch_write_preserves_order_across_outcomes() { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = [ new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt, Writable: false), ], Probe = new TwinCATProbeOptions { Enabled = 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(TwinCATStatusMapper.Good); results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Cancellation_propagates() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { ThrowOnRead = true, Exception = new OperationCanceledException(), }; await Should.ThrowAsync( () => drv.ReadAsync(["X"], CancellationToken.None)); } [Fact] public async Task ShutdownAsync_disposes_client() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } }; await drv.ReadAsync(["X"], CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); factory.Clients[0].DisposeCount.ShouldBe(1); } }