Files
h-school/tests/HSchool.AppHost.Tests/GameSocketTests.cs
T

409 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.WebSockets;
using HSchool.Protocol;
namespace HSchool.AppHost.Tests;
/// <summary>
/// Talks to the realtime channel the way the browser does: binary frames over a WebSocket.
/// </summary>
[Collection(AppHostCollection.Name)]
public class GameSocketTests(AppHostFixture fixture)
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private static readonly DateTime StartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Handshake_AnswersWithAWelcomeFrame()
{
using var socket = await ConnectAsync();
var welcome = ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
Assert.Equal(20, welcome.TickRate);
Assert.Equal(6, welcome.MaxSchools);
}
[Fact]
public async Task ChangingSpeed_DoesNotResumeAPausedSchool()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 3)));
var afterSpeedChange = await ReceiveClockWhereAsync(socket, clock => clock.SpeedIndex == 3);
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
Assert.False(afterSpeedChange.Running);
Assert.False(later.Running);
Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
}
[Fact]
public async Task NoSchoolOpen_MeansNoClockFramesButTheCalendarKeepsRunning()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Живёт сама", StartDate);
using var socket = await ConnectAsync();
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
// A connection that opened nothing gets no clock frames…
await Assert.ThrowsAsync<TimeoutException>(() =>
ReceiveUntilAsync(socket, MessageType.ServerClock, TimeSpan.FromSeconds(1)));
// …but the school moved on anyway, which is what the menu cards show.
var reloaded = await FindAsync(client, school.Id);
Assert.True(reloaded.Running);
Assert.True(reloaded.GameTime > StartDate, $"The calendar stood still at {reloaded.GameTime:O}.");
}
[Fact]
public async Task OpeningASchool_StreamsItsClock()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
var first = await ReceiveClockAsync(socket);
Assert.Equal(school.Id, first.SchoolId);
Assert.True(first.Running);
Assert.Equal(1, first.SpeedIndex);
// 5 game minutes per real second at ×1, so one second of ticks has to move the calendar.
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
var elapsed = ToDate(later) - ToDate(first);
Assert.InRange(elapsed.TotalMinutes, 3, 8);
}
[Fact]
public async Task Pausing_FreezesTheClock()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
Assert.False(later.Running);
Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
}
[Fact]
public async Task SpeedIndex_ChangesHowFastTheCalendarMoves()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Быстрая", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 4)));
var fast = await ReceiveClockWhereAsync(socket, clock => clock.SpeedIndex == 4);
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
var elapsed = ToDate(later) - ToDate(fast);
// ×4 means 20 game minutes per real second.
Assert.InRange(elapsed.TotalMinutes, 12, 30);
}
[Fact]
public async Task LeavingASchool_KeepsItsClockRunning()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
}
var afterLeaving = await FindAsync(client, school.Id);
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var later = await FindAsync(client, school.Id);
Assert.True(later.Running);
Assert.True(later.GameTime > afterLeaving.GameTime, "The calendar stopped when the client left.");
}
[Fact]
public async Task APausedSchool_StaysPausedAfterLeaving()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
}
var afterLeaving = await FindAsync(client, school.Id);
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var later = await FindAsync(client, school.Id);
Assert.False(later.Running);
Assert.Equal(afterLeaving.GameTime, later.GameTime);
}
[Fact]
public async Task DeletingTheOpenSchool_TellsTheClient()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
await ReceiveClockAsync(socket);
using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
deleted.EnsureSuccessStatusCode();
var gone = ProtocolCodec.ReadSchoolGone(await ReceiveUntilAsync(socket, MessageType.ServerSchoolGone));
Assert.Equal(school.Id, gone.SchoolId);
}
[Fact]
public async Task OpeningASchoolThatDoesNotExist_IsAnsweredWithSchoolGone()
{
using var socket = await ConnectAsync();
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(999999)));
var gone = ProtocolCodec.ReadSchoolGone(await ReceiveUntilAsync(socket, MessageType.ServerSchoolGone));
Assert.Equal(999999, gone.SchoolId);
}
[Fact]
public async Task PausingOneSchool_DoesNotStopAnother()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var paused = await SchoolApiTests.CreateAsync(client, "На паузе", StartDate);
var running = await SchoolApiTests.CreateAsync(client, "Идёт дальше", StartDate);
using var socket = await OpenSchoolAsync(paused.Id);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
var pausedAt = await FindAsync(client, paused.Id);
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var pausedLater = await FindAsync(client, paused.Id);
var runningLater = await FindAsync(client, running.Id);
Assert.False(pausedLater.Running);
Assert.Equal(pausedAt.GameTime, pausedLater.GameTime);
Assert.True(runningLater.Running);
Assert.True(runningLater.GameTime > running.GameTime, "Pausing one school stopped the other.");
}
[Fact]
public async Task ReloadFromDisk_RestoresAPausedClock()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок паузы", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
}
var paused = await FindAsync(client, school.Id);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await FindAsync(client, school.Id);
Assert.Equal(paused.Name, restored.Name);
Assert.False(restored.Running);
Assert.Equal(paused.GameTime, restored.GameTime);
Assert.Equal(paused.SpeedIndex, restored.SpeedIndex);
}
[Fact]
public async Task Ping_IsAnsweredWithTheSameTimestamp()
{
using var socket = await ConnectAsync();
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
const long ClientTime = 1_700_000_000_123;
await SendAsync(socket, buffer => ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
var pong = ProtocolCodec.ReadPong(await ReceiveUntilAsync(socket, MessageType.ServerPong));
Assert.Equal(ClientTime, pong.ClientTimeMs);
}
[Fact]
public async Task VersionMismatch_IsRejected()
{
using var socket = await ConnectRawAsync();
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer,
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1))));
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
}
private static DateTime ToDate(ServerClockMessage clock) =>
DateTimeOffset.FromUnixTimeMilliseconds(clock.GameTimeUnixMs).UtcDateTime;
private async Task<SchoolApiTests.SchoolResponse> FindAsync(HttpClient client, int schoolId)
{
var state = await SchoolApiTests.GetSchoolsAsync(client);
var school = state.Schools.SingleOrDefault(candidate => candidate.Id == schoolId);
Assert.NotNull(school);
return school;
}
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId)
{
var socket = await ConnectAsync();
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer => ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
return socket;
}
private async Task<ClientWebSocket> ConnectAsync()
{
var socket = await ConnectRawAsync();
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version)));
return socket;
}
private async Task<ClientWebSocket> ConnectRawAsync()
{
var http = fixture.App.GetEndpoint("server", "http");
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
var socket = new ClientWebSocket();
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
return socket;
}
private static async Task SendAsync(WebSocket socket, Func<byte[], int> write)
{
var buffer = new byte[ProtocolCodec.MaxFrameSize];
var length = write(buffer);
await socket.SendAsync(
buffer.AsMemory(0, length),
WebSocketMessageType.Binary,
endOfMessage: true,
TestContext.Current.CancellationToken);
}
private static async Task<ServerClockMessage> ReceiveClockAsync(WebSocket socket) =>
ProtocolCodec.ReadClock(await ReceiveUntilAsync(socket, MessageType.ServerClock));
/// <summary>Drains clock frames until one satisfies <paramref name="predicate"/>.</summary>
private static async Task<ServerClockMessage> ReceiveClockWhereAsync(
WebSocket socket,
Func<ServerClockMessage, bool> predicate)
{
for (var attempt = 0; attempt < 40; attempt++)
{
var clock = await ReceiveClockAsync(socket);
if (predicate(clock))
{
return clock;
}
}
throw new InvalidOperationException("No clock frame matched within 40 frames.");
}
/// <summary>Keeps reading clock frames for <paramref name="duration"/> and returns the last one.</summary>
private static async Task<ServerClockMessage> ReceiveClockAfterAsync(WebSocket socket, TimeSpan duration)
{
var deadline = DateTime.UtcNow + duration;
var clock = await ReceiveClockAsync(socket);
while (DateTime.UtcNow < deadline)
{
clock = await ReceiveClockAsync(socket);
}
return clock;
}
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
private static async Task<byte[]> ReceiveUntilAsync(WebSocket socket, MessageType expected, TimeSpan? timeout = null)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(timeout ?? DefaultTimeout);
var buffer = new byte[ProtocolConstants.MaxMessageSize];
while (true)
{
WebSocketReceiveResult result;
try
{
result = await socket.ReceiveAsync(buffer, cts.Token);
}
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"No {expected} frame arrived within {timeout ?? DefaultTimeout}.");
}
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
}
var frame = buffer[..result.Count];
if (ProtocolCodec.PeekMessageType(frame) == expected)
{
return frame;
}
}
}
}