using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasReadWriteTests { private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags) { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = tags, Probe = new FocasProbeOptions { 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(FocasStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Successful_PMC_read_returns_Good_value() { var (drv, factory) = NewDriver( new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } }; var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots.Single().Value.ShouldBe((sbyte)5); } [Fact] public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind() { var (drv, factory) = NewDriver( new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } }; var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots.Single().Value.ShouldBe(1500); } [Fact] public async Task Macro_read_routes_through_FocasAddress_Macro_kind() { var (drv, factory) = NewDriver( new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } }; var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None); snapshots.Single().Value.ShouldBe(3.14159); } [Fact] public async Task Repeat_read_reuses_connection() { var (drv, factory) = NewDriver( new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } }; await drv.ReadAsync(["X"], CancellationToken.None); await drv.ReadAsync(["X"], CancellationToken.None); factory.Clients.Count.ShouldBe(1); factory.Clients[0].ConnectCount.ShouldBe(1); } [Fact] public async Task FOCAS_error_status_maps_via_status_mapper() { var (drv, factory) = NewDriver( new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeFocasClient(); c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown; return c; }; var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Read_exception_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { ThrowOnRead = true }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError() { var (drv, factory) = NewDriver( new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); factory.Clients[0].DisposeCount.ShouldBe(1); } [Fact] public async Task Batched_reads_preserve_order_across_areas() { var (drv, factory) = NewDriver( new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32), new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5, ["PARAM:1820"] = 1500, ["MACRO:500"] = 2.718, }, }; var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); snapshots[0].Value.ShouldBe((sbyte)5); snapshots[1].Value.ShouldBe(1500); snapshots[2].Value.ShouldBe(2.718); } // ---- Write ---- [Fact] public async Task Non_writable_tag_rejected_with_BadNotWritable() { var (drv, _) = NewDriver( new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("RO", 1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); } [Fact] public async Task Successful_write_logs_address_type_value() { var (drv, factory) = NewDriver( new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Speed", (short)1800)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); var write = factory.Clients[0].WriteLog.Single(); write.addr.Canonical.ShouldBe("R100"); write.type.ShouldBe(FocasDataType.Int16); write.value.ShouldBe((short)1800); } [Fact] public async Task Write_status_code_maps_via_FocasStatusMapper() { var (drv, factory) = NewDriver( new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeFocasClient(); c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable; return c; }; var results = await drv.WriteAsync( [new WriteRequest("Protected", (sbyte)1)], CancellationToken.None); results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); } [Fact] public async Task Batch_write_preserves_order_across_outcomes() { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Tags = [ new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false), ], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [ new WriteRequest("A", (sbyte)1), new WriteRequest("B", (sbyte)2), new WriteRequest("Unknown", (sbyte)3), ], CancellationToken.None); results[0].StatusCode.ShouldBe(FocasStatusMapper.Good); results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); } [Fact] public async Task Cancellation_propagates() { var (drv, factory) = NewDriver( new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { 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 FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } }; await drv.ReadAsync(["X"], CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); factory.Clients[0].DisposeCount.ShouldBe(1); } }