using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Coverage for the DIAG: address scheme — parser, capability matrix, /// driver dispatch (issue #263, plan PR F2-a). DIAG: addresses route to /// cnc_rddiag on the wire; the driver validates against /// at init time + dispatches /// reads through /// at runtime. /// [Trait("Category", "Unit")] public sealed class FocasDiagnosticAddressTests { // ---- Parser positive ---- [Theory] [InlineData("DIAG:1000", 1000, 0)] [InlineData("DIAG:280/2", 280, 2)] [InlineData("DIAG:0", 0, 0)] [InlineData("diag:500", 500, 0)] // case-insensitive prefix [InlineData("DIAG:1023/8", 1023, 8)] public void TryParse_accepts_DIAG_forms(string input, int expectedNumber, int expectedAxis) { var parsed = FocasAddress.TryParse(input); parsed.ShouldNotBeNull(); parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic); parsed.Number.ShouldBe(expectedNumber); (parsed.BitIndex ?? 0).ShouldBe(expectedAxis); } [Theory] [InlineData("DIAG:abc")] [InlineData("DIAG:")] [InlineData("DIAG:-1")] [InlineData("DIAG:100/-1")] [InlineData("DIAG:100/99")] // axis > 31 (parser ceiling) public void TryParse_rejects_malformed_DIAG(string input) { FocasAddress.TryParse(input).ShouldBeNull(); } [Fact] public void Canonical_round_trip_for_DIAG_whole_CNC() { var parsed = FocasAddress.TryParse("DIAG:1000"); parsed!.Canonical.ShouldBe("DIAG:1000"); } [Fact] public void Canonical_round_trip_for_DIAG_per_axis() { var parsed = FocasAddress.TryParse("DIAG:280/2"); parsed!.Canonical.ShouldBe("DIAG:280/2"); } // ---- Capability matrix ---- [Theory] [InlineData(FocasCncSeries.Thirty_i, 1023, true)] [InlineData(FocasCncSeries.Thirty_i, 1024, false)] [InlineData(FocasCncSeries.ThirtyOne_i, 500, true)] [InlineData(FocasCncSeries.ThirtyTwo_i, 0, true)] [InlineData(FocasCncSeries.Sixteen_i, 499, true)] [InlineData(FocasCncSeries.Sixteen_i, 500, false)] // 16i caps lower [InlineData(FocasCncSeries.Zero_i_F, 999, true)] [InlineData(FocasCncSeries.Zero_i_F, 1000, false)] [InlineData(FocasCncSeries.Zero_i_D, 280, true)] [InlineData(FocasCncSeries.Zero_i_D, 600, false)] [InlineData(FocasCncSeries.PowerMotion_i, 255, true)] [InlineData(FocasCncSeries.PowerMotion_i, 256, false)] public void Diagnostic_range_matches_series(FocasCncSeries series, int number, bool accepted) { var address = new FocasAddress(FocasAreaKind.Diagnostic, null, number, null); var result = FocasCapabilityMatrix.Validate(series, address); (result is null).ShouldBe(accepted, $"DIAG:{number} on {series}: expected {(accepted ? "accept" : "reject")}, got '{result}'"); } [Fact] public void Unknown_series_accepts_any_diagnostic_number() { var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 99_999, null); FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull(); } [Fact] public void Diagnostic_rejection_message_names_series_and_limit() { var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 5_000, null); var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Sixteen_i, address); reason.ShouldNotBeNull(); reason.ShouldContain("5000"); reason.ShouldContain("Sixteen_i"); reason.ShouldContain("499"); } // ---- Driver dispatch ---- private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver( FocasCncSeries series, params FocasTagDefinition[] tags) { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: series)], Tags = tags, Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); return (drv, factory); } [Fact] public async Task DIAG_read_routes_through_ReadDiagnosticAsync_with_axis_zero() { var (drv, factory) = NewDriver( FocasCncSeries.Thirty_i, new FocasTagDefinition("AlarmCause", "focas://10.0.0.5:8193", "DIAG:1000", FocasDataType.Int32)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeFocasClient(); c.Values["DIAG:1000"] = 42; return c; }; var snapshots = await drv.ReadAsync(["AlarmCause"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots.Single().Value.ShouldBe(42); var fake = factory.Clients.Single(); fake.DiagnosticReads.Single().ShouldBe((1000, 0, FocasDataType.Int32)); } [Fact] public async Task DIAG_per_axis_read_threads_axis_index_through() { var (drv, factory) = NewDriver( FocasCncSeries.Thirty_i, new FocasTagDefinition("ServoLoad2", "focas://10.0.0.5:8193", "DIAG:280/2", FocasDataType.Int16)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => { var c = new FakeFocasClient(); c.Values["DIAG:280/2"] = (short)17; return c; }; var snapshots = await drv.ReadAsync(["ServoLoad2"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots.Single().Value.ShouldBe((short)17); factory.Clients.Single().DiagnosticReads.Single().ShouldBe((280, 2, FocasDataType.Int16)); } [Fact] public async Task DIAG_out_of_range_for_series_rejected_at_init() { var (drv, _) = NewDriver( FocasCncSeries.Sixteen_i, new FocasTagDefinition("Bad", "focas://10.0.0.5:8193", "DIAG:5000", FocasDataType.Int32)); var ex = await Should.ThrowAsync( () => drv.InitializeAsync("{}", CancellationToken.None)); ex.Message.ShouldContain("5000"); ex.Message.ShouldContain("Sixteen_i"); } [Fact] public async Task DIAG_default_interface_method_surfaces_BadNotSupported() { // Stand-in client that does NOT override ReadDiagnosticAsync — falls through to // the IFocasClient default returning BadNotSupported. Models a transport variant // (e.g. older IPC contract) that hasn't extended its wire surface to diagnostics. var factory = new BareFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: FocasCncSeries.Unknown)], Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "DIAG:100", FocasDataType.Int32)], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported); } /// /// Test stand-in that overrides every interface method we need EXCEPT /// — exercising the default /// implementation that returns BadNotSupported for transports that /// haven't extended their wire surface yet. /// private sealed class FakeWithoutDiagnosticOverride : IFocasClient { public bool IsConnected { get; private set; } public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) { IsConnected = true; return Task.CompletedTask; } public Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.Good)); public Task WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) => Task.FromResult(FocasStatusMapper.Good); public Task ProbeAsync(CancellationToken ct) => Task.FromResult(true); public void Dispose() { } } private sealed class BareFocasClientFactory : IFocasClientFactory { public IFocasClient Create() => new FakeWithoutDiagnosticOverride(); } }