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();
}
}