202 lines
6.5 KiB
C#
202 lines
6.5 KiB
C#
using System.Net.WebSockets;
|
||
using Microsoft.AspNetCore.Mvc.Testing;
|
||
using Vanguard.Shared.Protocol;
|
||
using Xunit.Sdk;
|
||
|
||
namespace Vanguard.Server.Tests;
|
||
|
||
/// <summary>
|
||
/// Минимальный клиент протокола для интеграционных тестов:
|
||
/// шлёт интенты, догоняет состояние и проверяет кадры.
|
||
/// </summary>
|
||
internal sealed class TestGameClient : IAsyncDisposable
|
||
{
|
||
private readonly WebSocket _socket;
|
||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||
|
||
private TestGameClient(WebSocket socket, string playerId)
|
||
{
|
||
_socket = socket;
|
||
PlayerId = playerId;
|
||
}
|
||
|
||
public string PlayerId { get; }
|
||
public int Seat { get; private set; }
|
||
public long LastSeq { get; private set; }
|
||
public MatchView View { get; private set; } = null!;
|
||
|
||
public static async Task<TestGameClient> ConnectAsync(
|
||
WebApplicationFactory<Program> factory,
|
||
string matchId,
|
||
string ticket,
|
||
string playerId,
|
||
long resumeFromSeq,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var webSocketClient = factory.Server.CreateWebSocketClient();
|
||
var socket = await webSocketClient.ConnectAsync(
|
||
new Uri($"ws://localhost/game"), cancellationToken);
|
||
|
||
var client = new TestGameClient(socket, playerId);
|
||
|
||
await client.SendAsync(new ClientHello
|
||
{
|
||
ProtocolVersion = ProtocolConstants.Version,
|
||
Ticket = ticket,
|
||
ClientBuild = "tests",
|
||
ResumeFromSeq = resumeFromSeq,
|
||
}, cancellationToken);
|
||
|
||
var hello = await client.ReadUntilAsync<ServerHello>(cancellationToken);
|
||
if (hello.MatchId != matchId)
|
||
throw new XunitException($"Ожидался матч {matchId}, пришёл {hello.MatchId}.");
|
||
|
||
client.Seat = hello.PlayerIndex;
|
||
client.LastSeq = hello.EventSeq;
|
||
|
||
if (hello.Resumed)
|
||
{
|
||
// после hello сервер присылает события догона и часы
|
||
var events = await client.ReadUntilAsync<ServerEvents>(cancellationToken);
|
||
if (events.ToSeq != hello.EventSeq)
|
||
throw new XunitException($"Догон дошёл до {events.ToSeq}, ожидалось {hello.EventSeq}.");
|
||
}
|
||
else
|
||
{
|
||
var snapshot = await client.ReadUntilAsync<ServerSnapshot>(cancellationToken);
|
||
client.View = snapshot.View;
|
||
client.LastSeq = snapshot.View.EventSeq;
|
||
}
|
||
|
||
// после догона нужен снимок, чтобы получить MatchView
|
||
if (client.View is null)
|
||
await client.RequestSnapshotAsync(cancellationToken);
|
||
|
||
return client;
|
||
}
|
||
|
||
public async Task SendIntentAsync(
|
||
string intentId,
|
||
IntentPayload intent,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
await SendAsync(new ClientIntent
|
||
{
|
||
IntentId = intentId,
|
||
BasedOnSeq = LastSeq,
|
||
Intent = intent,
|
||
}, cancellationToken);
|
||
}
|
||
|
||
public async Task<ServerIntentResult> SubmitAsync(
|
||
string intentId,
|
||
IntentPayload intent,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
await SendIntentAsync(intentId, intent, cancellationToken);
|
||
var result = await ReadUntilAsync<ServerIntentResult>(cancellationToken);
|
||
|
||
if (result.IntentId != intentId)
|
||
throw new XunitException($"Ожидался результат {intentId}, пришёл {result.IntentId}.");
|
||
|
||
return result;
|
||
}
|
||
|
||
public async Task RequestSnapshotAsync(CancellationToken cancellationToken)
|
||
{
|
||
await SendAsync(new ClientResync
|
||
{
|
||
FromSeq = LastSeq + 1,
|
||
FullSnapshot = true,
|
||
}, cancellationToken);
|
||
|
||
var snapshot = await ReadUntilAsync<ServerSnapshot>(cancellationToken);
|
||
View = snapshot.View;
|
||
LastSeq = snapshot.View.EventSeq;
|
||
}
|
||
|
||
public async Task<T> ReadUntilAsync<T>(CancellationToken cancellationToken)
|
||
where T : ServerFrame
|
||
{
|
||
for (var i = 0; i < 2000; i++)
|
||
{
|
||
var frame = await ReadFrameAsync(cancellationToken);
|
||
|
||
if (frame is T typed)
|
||
return typed;
|
||
|
||
if (frame is ServerError error)
|
||
throw new XunitException(
|
||
$"Сервер ответил ошибкой {error.Code}: {error.Message}");
|
||
}
|
||
|
||
throw new XunitException($"Не дождались кадра {typeof(T).Name}.");
|
||
}
|
||
|
||
public async Task SendAsync(ClientFrame frame, CancellationToken cancellationToken)
|
||
{
|
||
var bytes = ProtocolSerializer.Encode(frame);
|
||
|
||
await _sendLock.WaitAsync(cancellationToken);
|
||
try
|
||
{
|
||
await _socket.SendAsync(
|
||
bytes.AsMemory(),
|
||
WebSocketMessageType.Binary,
|
||
endOfMessage: true,
|
||
cancellationToken);
|
||
}
|
||
finally
|
||
{
|
||
_sendLock.Release();
|
||
}
|
||
}
|
||
|
||
private async Task<ServerFrame> ReadFrameAsync(CancellationToken cancellationToken)
|
||
{
|
||
var buffer = new byte[16 * 1024];
|
||
using var stream = new MemoryStream();
|
||
|
||
while (true)
|
||
{
|
||
var result = await _socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
|
||
|
||
if (result.MessageType == WebSocketMessageType.Close)
|
||
throw new XunitException(
|
||
$"Сокет закрыт: {_socket.CloseStatus} {_socket.CloseStatusDescription}");
|
||
|
||
if (result.MessageType != WebSocketMessageType.Binary)
|
||
throw new XunitException("Сервер прислал текстовый кадр.");
|
||
|
||
stream.Write(buffer, 0, result.Count);
|
||
|
||
if (stream.Length > ProtocolConstants.MaxFrameBytes)
|
||
throw new XunitException("Кадр сервера слишком велик.");
|
||
|
||
if (result.EndOfMessage)
|
||
break;
|
||
}
|
||
|
||
return ProtocolSerializer.DecodeServer(stream.ToArray());
|
||
}
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
try
|
||
{
|
||
if (_socket.State == WebSocketState.Open)
|
||
await _socket.CloseOutputAsync(
|
||
WebSocketCloseStatus.NormalClosure,
|
||
"bye",
|
||
CancellationToken.None);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// тестовый клиент мог уже отвалиться
|
||
}
|
||
|
||
_socket.Dispose();
|
||
_sendLock.Dispose();
|
||
}
|
||
}
|