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 FocasScaffoldingTests { // ---- FocasHostAddress ---- [Theory] [InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)] [InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port [InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)] [InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)] [InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme public void HostAddress_parses_valid(string input, string host, int port) { var parsed = FocasHostAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Host.ShouldBe(host); parsed.Port.ShouldBe(port); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("http://10.0.0.5/")] [InlineData("focas:10.0.0.5:8193")] // missing // [InlineData("focas://")] // empty body [InlineData("focas://10.0.0.5:0")] // port 0 [InlineData("focas://10.0.0.5:65536")] // port out of range [InlineData("focas://10.0.0.5:abc")] // non-numeric port public void HostAddress_rejects_invalid(string? input) { FocasHostAddress.TryParse(input).ShouldBeNull(); } [Fact] public void HostAddress_ToString_strips_default_port() { new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5"); new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345"); } // ---- FocasAddress ---- [Theory] [InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)] [InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)] [InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)] [InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)] [InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)] [InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)] [InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)] [InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)] [InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)] [InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)] [InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)] [InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)] public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit) { var a = FocasAddress.TryParse(input); a.ShouldNotBeNull(); a.Kind.ShouldBe(kind); a.PmcLetter.ShouldBe(letter); a.Number.ShouldBe(num); a.BitIndex.ShouldBe(bit); } [Theory] [InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)] [InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)] [InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)] public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit) { var a = FocasAddress.TryParse(input); a.ShouldNotBeNull(); a.Kind.ShouldBe(kind); a.PmcLetter.ShouldBeNull(); a.Number.ShouldBe(num); a.BitIndex.ShouldBe(bit); } [Theory] [InlineData("MACRO:100", FocasAreaKind.Macro, 100)] [InlineData("MACRO:500", FocasAreaKind.Macro, 500)] public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num) { var a = FocasAddress.TryParse(input); a.ShouldNotBeNull(); a.Kind.ShouldBe(kind); a.Number.ShouldBe(num); a.BitIndex.ShouldBeNull(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("Z0")] // unknown PMC letter [InlineData("X")] // missing number [InlineData("X-1")] // negative number [InlineData("Xabc")] // non-numeric [InlineData("X0.8")] // bit out of range (0-7) [InlineData("X0.-1")] // negative bit [InlineData("PARAM:")] // missing number [InlineData("PARAM:1815/32")] // bit out of range (0-31) [InlineData("MACRO:abc")] // non-numeric public void Address_rejects_invalid_forms(string? input) { FocasAddress.TryParse(input).ShouldBeNull(); } [Theory] [InlineData("X0.0")] [InlineData("R100")] [InlineData("F20.3")] [InlineData("PARAM:1020")] [InlineData("PARAM:1815/0")] [InlineData("MACRO:100")] public void Address_Canonical_roundtrips(string input) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Canonical.ShouldBe(input); } // ---- FocasDataType ---- [Fact] public void DataType_mapping_covers_atomic_focas_types() { FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean); FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32); FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32); FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32); FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64); FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String); } // ---- FocasStatusMapper ---- [Theory] [InlineData(0, FocasStatusMapper.Good)] [InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER [InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH [InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT [InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT [InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA [InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY [InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE [InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET [InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic public void StatusMapper_covers_known_focas_returns(int ret, uint expected) { FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected); } // ---- FocasDriver ---- [Fact] public void DriverType_is_FOCAS() { var drv = new FocasDriver(new FocasDriverOptions(), "drv-1"); drv.DriverType.ShouldBe("FOCAS"); drv.DriverInstanceId.ShouldBe("drv-1"); } [Fact] public async Task InitializeAsync_parses_device_addresses() { var drv = new FocasDriver(new FocasDriverOptions { Devices = [ new FocasDeviceOptions("focas://10.0.0.5:8193"), new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"), ], }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.DeviceCount.ShouldBe(2); drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193); drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2"); } [Fact] public async Task InitializeAsync_malformed_address_faults() { var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("not-an-address")], }, "drv-1"); await Should.ThrowAsync( () => drv.InitializeAsync("{}", CancellationToken.None)); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } [Fact] public async Task ShutdownAsync_clears_devices() { var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); drv.DeviceCount.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } // ---- UnimplementedFocasClientFactory ---- [Fact] public void Default_factory_throws_on_Create_with_deployment_pointer() { var factory = new UnimplementedFocasClientFactory(); var ex = Should.Throw(() => factory.Create()); ex.Message.ShouldContain("Fwlib32.dll"); ex.Message.ShouldContain("licensed"); } }