Auto: focas-f2a — DIAG: address scheme

New FocasAreaKind.Diagnostic parsed from DIAG:nnn (whole-CNC) and
DIAG:nnn/axis (per-axis), validated against a per-series
FocasCapabilityMatrix.DiagnosticRange table (16i: 0-499; 0i-F family:
0-999; 30i/31i/32i: 0-1023; Power Motion i: 0-255; Unknown: permissive
per existing matrix convention).

IFocasClient gains ReadDiagnosticAsync(diagNumber, axisOrZero, type,
ct) with a default returning BadNotSupported so older transport
variants degrade gracefully. FwlibFocasClient implements it via a new
cnc_rddiag P/Invoke that reuses the IODBPSD struct (same shape as
cnc_rdparam). FocasDriver.ReadAsync dispatches Diagnostic addresses
through the new path; non-Diagnostic kinds keep the existing
ReadAsync route unchanged.

Tests: parser positives (DIAG:1031, DIAG:280/2, case-insensitive,
zero, axis-8) + negatives (malformed, axis>31), capability matrix
boundaries per series, driver-level dispatch verifying axis index
threads through, init-time rejection on out-of-range, and
BadNotSupported fallback when the wire client doesn't override the
default. 266/266 pass in Driver.FOCAS.Tests.

Closes #263

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-25 19:31:49 -04:00
parent 6743d51db8
commit 451b37a632
9 changed files with 364 additions and 9 deletions

View File

@@ -0,0 +1,213 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Coverage for the <c>DIAG:</c> address scheme — parser, capability matrix,
/// driver dispatch (issue #263, plan PR F2-a). DIAG: addresses route to
/// <c>cnc_rddiag</c> on the wire; the driver validates against
/// <see cref="FocasCapabilityMatrix.DiagnosticRange"/> at init time + dispatches
/// <see cref="FocasAreaKind.Diagnostic"/> reads through
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> at runtime.
/// </summary>
[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<InvalidOperationException>(
() => 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);
}
/// <summary>
/// Test stand-in that overrides every interface method we need EXCEPT
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> — exercising the default
/// implementation that returns <c>BadNotSupported</c> for transports that
/// haven't extended their wire surface yet.
/// </summary>
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<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
Task.FromResult(FocasStatusMapper.Good);
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
public void Dispose() { }
}
private sealed class BareFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => new FakeWithoutDiagnosticOverride();
}
}