Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
This commit is contained in:
@@ -1,243 +0,0 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the running server exactly the way the browser client does: binary frames over
|
||||
/// a WebSocket, plus the HTTP endpoints the dashboard and probes use.
|
||||
/// </summary>
|
||||
[Collection(AppHostCollection.Name)]
|
||||
public class GameServerIntegrationTests(AppHostFixture fixture)
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private DistributedApplication App => fixture.App;
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReportsHealthy()
|
||||
{
|
||||
using var client = App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusEndpoint_ReportsARunningLoop()
|
||||
{
|
||||
using var client = App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.GetAsync("/api/status", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var status = JsonSerializer.Deserialize<StatusResponse>(
|
||||
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
|
||||
JsonSerializerOptions.Web);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(20, status.TickRate);
|
||||
Assert.True(status.WorldWidth > 0);
|
||||
|
||||
// The loop runs on its own thread; give it a moment to produce a tick.
|
||||
await WaitUntilAsync(
|
||||
async () => (await GetStatusAsync(client)).Tick > 0,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_AnswersWithAWelcomeFrame()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
|
||||
Assert.Equal(20, welcome.TickRate);
|
||||
Assert.True(welcome.PlayerEntityId > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshots_ArriveAndIncludeTheJoinedPlayer()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
|
||||
Assert.Contains(entities, entity => entity.Kind == EntityKind.Obstacle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Input_MovesThePlayerOnTheServer()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
var first = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
var startX = first.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
||||
|
||||
// Hold "right" for a few ticks, draining snapshots so the socket never backs up.
|
||||
var sequence = 0u;
|
||||
var lastX = startX;
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteInput(buffer, new ClientInputMessage(++sequence, InputButtons.Right)));
|
||||
|
||||
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
lastX = entities.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
||||
}
|
||||
|
||||
Assert.True(lastX > startX, $"Player did not move right: {startX} -> {lastX}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_IsAnsweredWithTheSameTimestamp()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
await ReceiveWelcomeAsync(socket);
|
||||
|
||||
const long ClientTime = 1_700_000_000_123;
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
|
||||
|
||||
var pong = await ReceiveUntilAsync(socket, MessageType.ServerPong);
|
||||
|
||||
Assert.Equal(ClientTime, ProtocolCodec.ReadPong(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), "stale-client")));
|
||||
|
||||
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 async Task<ClientWebSocket> ConnectAsync(string playerName = "integration-test")
|
||||
{
|
||||
var socket = await ConnectRawAsync();
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, playerName)));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private async Task<ClientWebSocket> ConnectRawAsync()
|
||||
{
|
||||
var http = 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[64];
|
||||
var length = write(buffer);
|
||||
|
||||
await socket.SendAsync(
|
||||
buffer.AsMemory(0, length),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<ServerWelcomeMessage> ReceiveWelcomeAsync(WebSocket socket) =>
|
||||
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
|
||||
|
||||
private static async Task<EntitySnapshot[]> ReceiveSnapshotAsync(WebSocket socket)
|
||||
{
|
||||
var frame = await ReceiveUntilAsync(socket, MessageType.ServerSnapshot);
|
||||
|
||||
var entities = new EntitySnapshot[ushort.MaxValue];
|
||||
var count = ProtocolCodec.ReadSnapshot(frame, entities, out _);
|
||||
|
||||
return entities[..count];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads snapshots until the given entity shows up. The very first snapshot after a join can
|
||||
/// still describe the tick before the spawn was applied.
|
||||
/// </summary>
|
||||
private static async Task<EntitySnapshot[]> ReceiveSnapshotWithAsync(WebSocket socket, uint entityId)
|
||||
{
|
||||
for (var attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
var entities = await ReceiveSnapshotAsync(socket);
|
||||
if (Array.Exists(entities, entity => entity.Id == entityId))
|
||||
{
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Entity {entityId} never appeared in a snapshot.");
|
||||
}
|
||||
|
||||
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
|
||||
private static async Task<byte[]> ReceiveUntilAsync(WebSocket socket, MessageType expected)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
timeout.CancelAfter(DefaultTimeout);
|
||||
|
||||
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer, timeout.Token);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StatusResponse> GetStatusAsync(HttpClient client)
|
||||
{
|
||||
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
|
||||
return JsonSerializer.Deserialize<StatusResponse>(json, JsonSerializerOptions.Web)!;
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (await condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
Assert.Fail($"Condition was not met within {timeout}.");
|
||||
}
|
||||
|
||||
private sealed record StatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Players,
|
||||
int Connections,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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.
|
||||
/// The clock only moves while a connection has the school open, which is what these assert.
|
||||
/// </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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The main menu's HTTP surface: list, create, delete. Tests share one AppHost, so each of them
|
||||
/// starts from an empty list rather than assuming one.
|
||||
/// </summary>
|
||||
[Collection(AppHostCollection.Name)]
|
||||
public class SchoolApiTests(AppHostFixture fixture)
|
||||
{
|
||||
private static readonly DateTime ExpectedDefaultStart = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
|
||||
var state = await GetSchoolsAsync(client);
|
||||
|
||||
Assert.Equal(6, state.MaxSchools);
|
||||
Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate);
|
||||
|
||||
// Without the "Z" the browser would read the start date in its own time zone and the
|
||||
// creation form would offer the wrong hour.
|
||||
Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
|
||||
Assert.Equal(5d, state.GameMinutesPerRealSecond);
|
||||
Assert.Empty(state.Schools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSchool_StartsAtTheRequestedDateAndRunning()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
|
||||
var created = await CreateAsync(client, "Гимназия у моря", ExpectedDefaultStart);
|
||||
|
||||
Assert.Equal("Гимназия у моря", created.Name);
|
||||
Assert.Equal(ExpectedDefaultStart, created.GameTime);
|
||||
|
||||
// Schools live from the moment they exist, whether or not anybody is inside.
|
||||
Assert.True(created.Running);
|
||||
|
||||
var state = await GetSchoolsAsync(client);
|
||||
Assert.Contains(state.Schools, school => school.Id == created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSchool_BeyondTheLimit_IsRejected()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
|
||||
var state = await GetSchoolsAsync(client);
|
||||
for (var i = 0; i < state.MaxSchools; i++)
|
||||
{
|
||||
await CreateAsync(client, $"Школа {i + 1}", ExpectedDefaultStart);
|
||||
}
|
||||
|
||||
using var response = await PostAsync(client, "Лишняя", ExpectedDefaultStart);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal("school-limit-reached", await ProblemCodeAsync(response));
|
||||
Assert.Equal(state.MaxSchools, (await GetSchoolsAsync(client)).Schools.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateSchool_WithABlankName_IsRejected(string name)
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
|
||||
using var response = await PostAsync(client, name, ExpectedDefaultStart);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("invalid-name", await ProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSchool_WithAnImpossibleStartDate_IsRejected()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
|
||||
using var response = await PostAsync(client, "Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("invalid-start-date", await ProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSchool_RemovesItAndFreesASlot()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
var created = await CreateAsync(client, "На удаление", ExpectedDefaultStart);
|
||||
|
||||
using var response = await client.DeleteAsync($"/api/schools/{created.Id}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.DoesNotContain((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSchool_ThatDoesNotExist_IsNotFound()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RandomName_IsUsableAsIs()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await ResetAsync(client);
|
||||
|
||||
var suggestion = await client.GetFromJsonAsync<RandomNameResponse>(
|
||||
"/api/schools/random-name",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(suggestion);
|
||||
Assert.False(string.IsNullOrWhiteSpace(suggestion.Name));
|
||||
|
||||
var created = await CreateAsync(client, suggestion.Name, ExpectedDefaultStart);
|
||||
Assert.Equal(suggestion.Name, created.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_ReportsTheLoopAndTheLimit()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
|
||||
var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(20, status.TickRate);
|
||||
Assert.Equal(6, status.MaxSchools);
|
||||
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
|
||||
}
|
||||
|
||||
internal static async Task ResetAsync(HttpClient client)
|
||||
{
|
||||
var state = await GetSchoolsAsync(client);
|
||||
|
||||
foreach (var school in state.Schools)
|
||||
{
|
||||
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<SchoolsResponse> GetSchoolsAsync(HttpClient client)
|
||||
{
|
||||
var state = await client.GetFromJsonAsync<SchoolsResponse>("/api/schools", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
internal static async Task<SchoolResponse> CreateAsync(HttpClient client, string name, DateTime startDate)
|
||||
{
|
||||
using var response = await PostAsync(client, name, startDate);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) =>
|
||||
client.PostAsJsonAsync(
|
||||
"/api/schools",
|
||||
new { name, startDate },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
||||
return problem?.Code;
|
||||
}
|
||||
|
||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
|
||||
|
||||
internal sealed record SchoolsResponse(
|
||||
int MaxSchools,
|
||||
DateTime DefaultStartDate,
|
||||
double GameMinutesPerRealSecond,
|
||||
IReadOnlyList<SchoolResponse> Schools);
|
||||
|
||||
private sealed record RandomNameResponse(string Name);
|
||||
|
||||
private sealed record ProblemResponse(string? Code);
|
||||
|
||||
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
|
||||
}
|
||||
Reference in New Issue
Block a user