using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using Flyshot.Runtime.Fanuc.Protocol;
namespace Flyshot.Core.Tests;
///
/// 验证 FANUC TCP 10010 状态通道客户端的后台接收与缓存能力。
///
public sealed class FanucStateClientTests : IDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
///
/// 在随机可用端口启动本地模拟控制器。
///
public FanucStateClientTests()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
}
///
/// 获取分配给本地模拟控制器的端口。
///
private int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
///
/// 清理模拟控制器和取消源。
///
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
_cts.Dispose();
}
///
/// 验证状态客户端可以连接本地模拟控制器。
///
[Fact]
public async Task ConnectAsync_ConnectsToLocalListener()
{
using var client = new FanucStateClient();
var acceptTask = _listener.AcceptTcpClientAsync();
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
Assert.True(client.IsConnected);
await acceptTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
}
///
/// 验证后台循环能正确解析抓包样本状态帧。
///
[Fact]
public async Task GetLatestFrame_ReceivesAndParsesCapturedStateFrame()
{
using var client = new FanucStateClient();
var capturedFrame = Convert.FromHexString(
"646f7a0000005a000000004388a23243f1ed7f43e9de6bc265031ec2b33cc3c278e0153f8742f53c3f128dbc929529bc7861d63cb0184c3c1ca1a7000000000000000000000000000000020000000000000000000000017a6f64");
var handlerTask = RunStreamingControllerAsync(capturedFrame, _cts.Token);
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
// 给后台循环留出接收和解析的时间。
await Task.Delay(200, _cts.Token);
var latest = client.GetLatestFrame();
Assert.NotNull(latest);
Assert.Equal(0u, latest.MessageId);
Assert.Equal(6, latest.Pose.Count);
Assert.Equal(9, latest.JointOrExtensionValues.Count);
Assert.Equal([2u, 0u, 0u, 1u], latest.TailWords);
client.Disconnect();
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
}
///
/// 验证状态客户端在连接前调用 GetLatestFrame 返回 null。
///
[Fact]
public void GetLatestFrame_BeforeConnect_ReturnsNull()
{
using var client = new FanucStateClient();
Assert.Null(client.GetLatestFrame());
}
///
/// 验证 Disconnect 后最新帧被清空。
///
[Fact]
public async Task Disconnect_ClearsLatestFrame()
{
using var client = new FanucStateClient();
var capturedFrame = CapturedStateFrame();
var handlerTask = RunStreamingControllerAsync(capturedFrame, _cts.Token);
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
await Task.Delay(200, _cts.Token);
Assert.NotNull(client.GetLatestFrame());
client.Disconnect();
Assert.Null(client.GetLatestFrame());
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
}
///
/// 验证状态通道长时间收不到完整帧时会标记陈旧并触发重连。
///
[Fact]
public async Task GetStatus_MarksFrameStaleAndReconnectsWhenFrameTimesOut()
{
using var client = new FanucStateClient(new FanucStateClientOptions
{
FrameTimeout = TimeSpan.FromMilliseconds(100),
ReconnectInitialDelay = TimeSpan.FromMilliseconds(20),
ReconnectMaxDelay = TimeSpan.FromMilliseconds(50),
ConnectTimeout = TimeSpan.FromSeconds(1),
});
var acceptTask = _listener.AcceptTcpClientAsync(_cts.Token);
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
using var controller = await acceptTask.AsTask().WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
await WaitUntilAsync(
() => client.GetStatus().ReconnectAttemptCount > 0,
TimeSpan.FromSeconds(2),
_cts.Token);
var status = client.GetStatus();
Assert.True(status.IsFrameStale);
Assert.True(status.State is FanucStateConnectionState.TimedOut or FanucStateConnectionState.Reconnecting or FanucStateConnectionState.Connected);
Assert.NotNull(status.LastErrorMessage);
Assert.Contains("超时", status.LastErrorMessage);
}
///
/// 验证状态通道在控制柜主动断开后可以退避重连并接收新连接上的状态帧。
///
[Fact]
public async Task ReceiveLoop_ReconnectsAfterEofAndKeepsReceivingFrames()
{
using var client = new FanucStateClient(new FanucStateClientOptions
{
FrameTimeout = TimeSpan.FromMilliseconds(500),
ReconnectInitialDelay = TimeSpan.FromMilliseconds(20),
ReconnectMaxDelay = TimeSpan.FromMilliseconds(50),
ConnectTimeout = TimeSpan.FromSeconds(1),
});
var firstFrame = CapturedStateFrame(1);
var secondFrame = CapturedStateFrame(2);
var handlerTask = RunReconnectControllerAsync(firstFrame, secondFrame, _cts.Token);
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
await WaitUntilAsync(
() => client.GetLatestFrame()?.MessageId == 2u,
TimeSpan.FromSeconds(2),
_cts.Token);
var status = client.GetStatus();
Assert.Equal(FanucStateConnectionState.Connected, status.State);
Assert.True(status.ReconnectAttemptCount >= 1);
client.Disconnect();
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
}
///
/// 启动模拟控制器,持续发送状态帧流。
///
private async Task RunStreamingControllerAsync(byte[] frames, CancellationToken cancellationToken)
{
using var controller = await _listener.AcceptTcpClientAsync(cancellationToken);
await using var stream = controller.GetStream();
try
{
while (!cancellationToken.IsCancellationRequested)
{
await stream.WriteAsync(frames, cancellationToken);
await Task.Delay(50, cancellationToken);
}
}
catch (OperationCanceledException)
{
// 正常取消。
}
catch (IOException)
{
// 客户端断开。
}
}
///
/// 启动模拟控制器:第一条连接发一帧后主动断开,第二条连接持续发送新帧。
///
private async Task RunReconnectControllerAsync(byte[] firstFrame, byte[] secondFrame, CancellationToken cancellationToken)
{
using (var firstController = await _listener.AcceptTcpClientAsync(cancellationToken))
{
await using var firstStream = firstController.GetStream();
await firstStream.WriteAsync(firstFrame, cancellationToken);
}
using var secondController = await _listener.AcceptTcpClientAsync(cancellationToken);
await using var secondStream = secondController.GetStream();
try
{
while (!cancellationToken.IsCancellationRequested)
{
await secondStream.WriteAsync(secondFrame, cancellationToken);
await Task.Delay(50, cancellationToken);
}
}
catch (OperationCanceledException)
{
// 正常取消。
}
catch (IOException)
{
// 客户端断开。
}
}
///
/// 构造来自 j519 抓包的状态帧,并按测试需要覆写 message_id。
///
private static byte[] CapturedStateFrame(uint messageId = 0)
{
var frame = Convert.FromHexString(
"646f7a0000005a000000004388a23243f1ed7f43e9de6bc265031ec2b33cc3c278e0153f8742f53c3f128dbc929529bc7861d63cb0184c3c1ca1a7000000000000000000000000000000020000000000000000000000017a6f64");
BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(7, 4), messageId);
return frame;
}
///
/// 等待异步后台循环达到预期状态,超时后让测试明确失败。
///
private static async Task WaitUntilAsync(Func predicate, TimeSpan timeout, CancellationToken cancellationToken)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (predicate())
{
return;
}
await Task.Delay(20, cancellationToken);
}
Assert.True(predicate(), "等待状态通道后台循环达到预期状态超时。");
}
}