test(battle-node): TestWebSocket mock for pump-level unit tests

This commit is contained in:
gamer147
2026-06-01 11:13:54 -04:00
parent 4dd61343aa
commit 21b7ddf6ae

View File

@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Threading.Channels;
namespace SVSim.UnitTests.BattleNode.Infrastructure;
/// <summary>
/// In-memory <see cref="WebSocket"/> for driving <see cref="SVSim.BattleNode.Sessions.BattleSession"/>
/// without booting the EmulatedEntrypoint host. Enqueue inbound frames with
/// <see cref="EnqueueIncoming"/>; outbound sends land in <see cref="Sends"/>. Call
/// <see cref="CompleteIncoming"/> to signal end-of-stream — the next <see cref="ReceiveAsync"/>
/// returns a Close result so <see cref="SVSim.BattleNode.Sessions.BattleSession.RunAsync"/>
/// exits its loop cleanly.
/// </summary>
public sealed class TestWebSocket : WebSocket
{
private readonly Channel<TestWebSocketFrame> _incoming = Channel.CreateUnbounded<TestWebSocketFrame>();
private readonly ConcurrentQueue<TestWebSocketFrame> _sends = new();
private WebSocketState _state = WebSocketState.Open;
private TaskCompletionSource<bool>? _sendGate;
public IReadOnlyCollection<TestWebSocketFrame> Sends => _sends.ToArray();
public override WebSocketCloseStatus? CloseStatus { get; } = null;
public override string? CloseStatusDescription { get; } = null;
public override WebSocketState State => _state;
public override string? SubProtocol { get; } = null;
public void EnqueueIncoming(byte[] payload, WebSocketMessageType type)
{
_incoming.Writer.TryWrite(new TestWebSocketFrame(payload, type));
}
public void CompleteIncoming()
{
_incoming.Writer.TryComplete();
}
/// <summary>
/// Install a gate that blocks every subsequent <see cref="SendAsync"/> call until
/// <see cref="ReleaseSends"/>. Used by the cancellation regression test to suspend
/// a write while the caller cancels the session CT.
/// </summary>
public void BlockNextSend()
{
_sendGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
}
public void ReleaseSends()
{
_sendGate?.TrySetResult(true);
_sendGate = null;
}
public override async Task<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> buffer, CancellationToken cancellationToken)
{
try
{
var frame = await _incoming.Reader.ReadAsync(cancellationToken);
var count = Math.Min(buffer.Count, frame.Payload.Length);
Array.Copy(frame.Payload, 0, buffer.Array!, buffer.Offset, count);
// Single-fragment frames only — the test never enqueues partial frames.
return new WebSocketReceiveResult(count, frame.Type, endOfMessage: true);
}
catch (ChannelClosedException)
{
_state = WebSocketState.Closed;
return new WebSocketReceiveResult(0, WebSocketMessageType.Close, endOfMessage: true);
}
}
public override async Task SendAsync(
ArraySegment<byte> buffer, WebSocketMessageType messageType,
bool endOfMessage, CancellationToken cancellationToken)
{
if (_sendGate is not null)
{
// Race-aware: stash the gate locally because ReleaseSends nulls the field.
var gate = _sendGate;
using var reg = cancellationToken.Register(() => gate.TrySetCanceled(cancellationToken));
await gate.Task;
}
var copy = new byte[buffer.Count];
Array.Copy(buffer.Array!, buffer.Offset, copy, 0, buffer.Count);
_sends.Enqueue(new TestWebSocketFrame(copy, messageType));
}
public override Task CloseAsync(
WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
{
_state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override Task CloseOutputAsync(
WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
{
_state = WebSocketState.CloseSent;
return Task.CompletedTask;
}
public override void Abort() => _state = WebSocketState.Aborted;
public override void Dispose() { /* no-op */ }
}
public sealed record TestWebSocketFrame(byte[] Payload, WebSocketMessageType Type);