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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user