Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs
T
Joseph Doherty 5f0a52864c feat(focas): real FANUC 30i/31i-B PDU-v3 support (live-validated on a 31i-B)
First real FOCAS hardware contact (Makino Pro 5 / 31i-B @ 10.201.31.5). A full
v3 data-PDU capture corrected the initial diagnosis: the v3 block envelope is
identical to v1, so only specific payload structs / request math / one client
robustness gap were wrong — not "framing rewrites".

Fixes (all re-validated live through the fixed driver):
- version gate: accept inbound PDU {1,3}, keep emitting v1 (FocasWireProtocol).
- cnc_rdtimer: 8-byte {minute,msec} payload is little-endian (ParseTimer) — the
  only decode with an in-range msec field.
- pmc_rdpmcrng: request range widened to the data-type byte width
  (end = start + width - 1) so a Word/Long isn't truncated to 0 values
  (was spurious BadOutOfRange); decode extracted to ParsePmcRange.
- cnc_rdsvmeter: per-axis LOADELM is 8 bytes (not 12) and names come from the
  0x0089 block — ParseServoMeters fixes the misaligned 655360 garbage. Also the
  "hang" was NetworkStream.ReadAsync not aborting a stalled socket: ReadExactlyAsync
  now disposes the stream on cancellation so a stalled peer can't wedge a poll loop.
- cnc_rddynamic2: contract guard rejecting axis < 1 (driver poll already 1-based).
- FocasDriverProbe: run a real wire session (initiate + cnc_statinfo) instead of
  degrading to Ok=true "TCP reachability only" when FWLIB is absent — a bare TCP
  listener no longer reports HEALTHY.

cnc_rdparam (0x000e) is unsupported on this control — EW_FUNC across 14
request-framing variants x 4 known-present params; needs a reference FWLIB trace
or is restricted. Deferred (deployed config uses macros, not parameters).

Tests: FOCAS suite 234 green (+16), full solution builds 0 errors. Raw v3
captures checked in under tests/.../Fixtures/v3/. Capture tools under scripts/focas/.

Docs: docs/plans/2026-06-25-focas-pdu-v3-{30i-b-support,implementation-plan}.md,
docs/drivers/FOCAS.md, docs/v2/focas-version-matrix.md,
docs/deployments/wonder-app-vd03-makino-z-34184.md.
2026-06-25 16:41:42 -04:00

182 lines
7.5 KiB
C#

using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8
/// truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener)
/// must report <c>Ok=false</c>, because the probe now completes a real <c>FocasWireClient</c>
/// session (initiate handshake + <c>cnc_statinfo</c>) rather than degrading to "TCP
/// reachability only" when FWLIB is absent.
/// <para>
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC completes the handshake + read →
/// "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test
/// time. It is verified against the live 31i-B at <c>10.201.31.5</c> (see the implementation
/// plan's deploy/validate step).
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDriverProbeTests
{
private static readonly FocasDriverProbe Probe = new();
// -------------------------------------------------------------------------
// 1. Invalid JSON
// -------------------------------------------------------------------------
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
[Fact]
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
{
var result = await Probe.ProbeAsync(
"not-valid-json{{{",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ToLowerInvariant().ShouldContain("invalid");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 2. Config with no device / no host
// -------------------------------------------------------------------------
/// <summary>A config with an empty Devices list returns Ok=false with a "no host/port" message.</summary>
[Fact]
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
{
var result = await Probe.ProbeAsync(
"{\"devices\":[]}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
/// <summary>
/// A config whose first device carries a malformed host address (not focas://) returns
/// Ok=false with a "no host/port" message because <see cref="FocasHostAddress.TryParse"/>
/// returns null for the address.
/// </summary>
[Fact]
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
{
// "not-a-focas-url" is not a focas:// URL — TryParse returns null.
var result = await Probe.ProbeAsync(
"{\"devices\":[{\"hostAddress\":\"not-a-focas-url\"}]}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 3. Unreachable target — TCP connect fails at preflight
// -------------------------------------------------------------------------
/// <summary>
/// A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>; the
/// probe must return Ok=false with a "Connect failed" message. The port is reserved then
/// released so the OS refuses the connection immediately (no black-hole delay needed).
/// </summary>
[Fact]
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
{
// Reserve an ephemeral port then release it so the port is definitely closed.
var reserved = new TcpListener(IPAddress.Loopback, 0);
reserved.Start();
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
reserved.Stop();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(3),
cts.Token);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("Connect failed");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 4. TCP reachable but not a CNC — wire-session probe must say Ok=false (Phase 8)
// -------------------------------------------------------------------------
/// <summary>
/// Against an in-process <see cref="TcpListener"/> that accepts the connection but speaks no
/// FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire
/// session can't complete the initiate handshake + <c>cnc_statinfo</c> read. The probe MUST
/// report <c>Ok=false</c> — a bare TCP listener is not a CNC. This is the Phase-8 fix: the
/// old probe degraded such a listener to <c>Ok=true</c> "FWLIB absent, TCP reachability
/// only", which made any TCP listener look HEALTHY.
/// </summary>
[Fact]
public async Task TcpReachable_NotACnc_Returns_OkFalse()
{
// Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
// Keep accepting so the connect always completes; drop the accepted socket.
_ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(3),
cts.Token);
// A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok.
result.Ok.ShouldBeFalse(
$"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}");
result.Message.ShouldNotBeNull();
result.Latency.ShouldBeNull();
}
finally
{
listener.Stop();
}
}
private static async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var socket = await listener.AcceptSocketAsync(ct);
// Drop the connection immediately — we only need the TCP handshake to complete.
socket.Dispose();
}
}
catch
{
// Listener stopped / token cancelled — expected at test teardown.
}
}
}