235b8b8e6d
Equipment tags were stuck at Bad_WaitingForInitialData on the deployed driver: the equipment poll, fixed-tree loop, probe and recycle shared one FOCAS/2 socket with no serialization, and the steady-state read had no timeout — concurrent reads collided and a stalled read hung forever, never overwriting the node's initial-data seed.
- SynchronizedFocasClient: per-device SemaphoreSlim gate + per-call timeout around every wire op (Connect/Probe gated, not double-bounded); wired in EnsureConnectedAsync. ReadAsync/WriteAsync map a per-call timeout to BadCommunicationError instead of rethrowing.
- FlexibleStringConverter on FOCAS config Series: the AdminUI persists the enum as a number ("series":6); accept number-or-string instead of throwing -> stub.
- FocasHostAddress.TryParse tolerates a scheme-less {ip}[:{port}] (AdminUI hostAddress form); canonical focas:// unchanged, malformed schemes still rejected.
247 FOCAS tests green; each fix has a regression test. Live-validated on wonder-app-vd03 (tags read Good).
183 lines
7.6 KiB
C#
183 lines
7.6 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()
|
|
{
|
|
// A foreign URI scheme ("http://…") is rejected by TryParse → null. (A bare
|
|
// "{ip}[:{port}]" without a scheme is now tolerated, so it can't be the malformed case.)
|
|
var result = await Probe.ProbeAsync(
|
|
"{\"devices\":[{\"hostAddress\":\"http://10.0.0.5/\"}]}",
|
|
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.
|
|
}
|
|
}
|
|
}
|