using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Coverage for multi-path / multi-channel CNC support — parser, driver bootstrap, /// cnc_setpath dispatch (issue #264, plan PR F2-b). The @N suffix /// selects which path a given address is read from; default PathId=1 /// preserves single-path back-compat. /// [Trait("Category", "Unit")] public sealed class FocasMultiPathTests { // ---- Parser positive ---- [Theory] [InlineData("R100", "R", 100, null, 1)] [InlineData("R100@2", "R", 100, null, 2)] [InlineData("R100@3.0", "R", 100, 0, 3)] [InlineData("X0.7", "X", 0, 7, 1)] [InlineData("X0@2.7", "X", 0, 7, 2)] public void TryParse_PMC_supports_optional_path_suffix( string input, string letter, int number, int? bit, int expectedPath) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Kind.ShouldBe(FocasAreaKind.Pmc); parsed.PmcLetter.ShouldBe(letter); parsed.Number.ShouldBe(number); parsed.BitIndex.ShouldBe(bit); parsed.PathId.ShouldBe(expectedPath); } [Theory] [InlineData("PARAM:1815", 1815, null, 1)] [InlineData("PARAM:1815@2", 1815, null, 2)] [InlineData("PARAM:1815@2/0", 1815, 0, 2)] [InlineData("PARAM:1815/0", 1815, 0, 1)] public void TryParse_PARAM_supports_optional_path_suffix( string input, int number, int? bit, int expectedPath) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Kind.ShouldBe(FocasAreaKind.Parameter); parsed.Number.ShouldBe(number); parsed.BitIndex.ShouldBe(bit); parsed.PathId.ShouldBe(expectedPath); } [Theory] [InlineData("MACRO:500", 500, 1)] [InlineData("MACRO:500@2", 500, 2)] [InlineData("MACRO:500@10", 500, 10)] public void TryParse_MACRO_supports_optional_path_suffix(string input, int number, int expectedPath) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Kind.ShouldBe(FocasAreaKind.Macro); parsed.Number.ShouldBe(number); parsed.PathId.ShouldBe(expectedPath); } [Theory] [InlineData("DIAG:280", 280, 0, 1)] [InlineData("DIAG:280@2", 280, 0, 2)] [InlineData("DIAG:280@2/1", 280, 1, 2)] [InlineData("DIAG:280/1", 280, 1, 1)] public void TryParse_DIAG_supports_optional_path_suffix( string input, int number, int axis, int expectedPath) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic); parsed.Number.ShouldBe(number); (parsed.BitIndex ?? 0).ShouldBe(axis); parsed.PathId.ShouldBe(expectedPath); } // ---- Parser negative ---- [Theory] [InlineData("R100@0")] // path 0 — reserved (FOCAS path numbering is 1-based) [InlineData("R100@-1")] // negative path [InlineData("R100@11")] // above FWLIB ceiling [InlineData("R100@abc")] // non-numeric [InlineData("R100@")] // empty [InlineData("PARAM:1815@0")] [InlineData("PARAM:1815@99")] [InlineData("MACRO:500@0")] [InlineData("DIAG:280@0/1")] public void TryParse_rejects_invalid_path_suffix(string input) { FocasAddress.TryParse(input).ShouldBeNull(); } // ---- Canonical round-trip ---- [Theory] [InlineData("R100")] [InlineData("R100@2")] [InlineData("R100@3.0")] [InlineData("PARAM:1815")] [InlineData("PARAM:1815@2")] [InlineData("PARAM:1815@2/0")] [InlineData("MACRO:500")] [InlineData("MACRO:500@2")] [InlineData("DIAG:280")] [InlineData("DIAG:280@2")] [InlineData("DIAG:280@2/1")] public void Canonical_round_trips_through_parser(string input) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Canonical.ShouldBe(input); } // ---- Driver dispatch ---- 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); } [Fact] public async Task Default_PathId_1_does_not_trigger_SetPath() { 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 { PathCount = 2, Values = { ["R100"] = (sbyte)1 }, }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); factory.Clients.Single().SetPathLog.ShouldBeEmpty(); } [Fact] public async Task Non_default_PathId_calls_SetPath_before_read() { var (drv, factory) = NewDriver( new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { PathCount = 2, Values = { ["R100@2"] = (sbyte)7 }, }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots.Single().Value.ShouldBe((sbyte)7); factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 }); } [Fact] public async Task Repeat_read_on_same_path_only_calls_SetPath_once() { var (drv, factory) = NewDriver( new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101@2", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { PathCount = 2, Values = { ["R100@2"] = (sbyte)1, ["R101@2"] = (sbyte)2 }, }; await drv.ReadAsync(["A", "B"], CancellationToken.None); // Two reads on the same non-default path — SetPath should only fire on the first. factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 }); } [Fact] public async Task Switching_paths_in_one_read_batch_logs_each_change() { var (drv, factory) = NewDriver( new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte), new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R200@3", FocasDataType.Byte), new FocasTagDefinition("C", "focas://10.0.0.5:8193", "R300@2", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { PathCount = 3, Values = { ["R100@2"] = (sbyte)1, ["R200@3"] = (sbyte)2, ["R300@2"] = (sbyte)3, }, }; await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); // Path 2 → 3 → 2 — each transition fires SetPath. factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2, 3, 2 }); } [Fact] public async Task PathId_above_PathCount_returns_BadOutOfRange() { var (drv, factory) = NewDriver( new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@5", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { PathCount = 2 }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadOutOfRange); // Out-of-range tag must not pollute the wire with a setpath call. factory.Clients.Single().SetPathLog.ShouldBeEmpty(); } [Fact] public async Task Diagnostic_path_threads_through_SetPath_then_ReadDiagnosticAsync() { var (drv, factory) = NewDriver( new FocasTagDefinition("ServoLoad", "focas://10.0.0.5:8193", "DIAG:280@2/1", FocasDataType.Int16)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { PathCount = 2, Values = { ["DIAG:280/1"] = (short)42 }, }; var snapshots = await drv.ReadAsync(["ServoLoad"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots.Single().Value.ShouldBe((short)42); var fake = factory.Clients.Single(); fake.SetPathLog.ShouldBe(new[] { 2 }); fake.DiagnosticReads.Single().ShouldBe((280, 1, FocasDataType.Int16)); } [Fact] public async Task Single_path_controller_with_default_addresses_never_calls_SetPath() { 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:1815", FocasDataType.Int32), new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeFocasClient { PathCount = 1, Values = { ["R100"] = (sbyte)1, ["PARAM:1815"] = 100, ["MACRO:500"] = 1.5, }, }; await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); factory.Clients.Single().SetPathLog.ShouldBeEmpty(); } }