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.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
@@ -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);
}
+189 -157
View File
@@ -1,157 +1,189 @@
namespace HSchool.Protocol.Tests;
/// <summary>
/// The wire format is a contract with the browser client. Round-trips prove the C# side is
/// self-consistent; the explicit byte-layout tests are what keeps
/// <c>src/HSchool.Client/src/net/protocol.ts</c> honest.
/// </summary>
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTrips()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, "ada");
Span<byte> buffer = stackalloc byte[64];
var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
}
[Fact]
public void Input_RoundTrips()
{
var message = new ClientInputMessage(0x01020304, InputButtons.Up | InputButtons.Right);
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, message);
Assert.Equal(6, length);
Assert.Equal(message, ProtocolCodec.ReadInput(buffer[..length]));
}
[Fact]
public void Ping_RoundTrips()
{
var message = new ClientPingMessage(1_700_000_000_123);
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WritePing(buffer, message);
Assert.Equal(9, length);
Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
}
[Fact]
public void Welcome_RoundTripsAndIsFifteenBytes()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 42, 20, 1600f, 900f);
Span<byte> buffer = stackalloc byte[32];
var length = ProtocolCodec.WriteWelcome(buffer, message);
Assert.Equal(15, length);
Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
}
[Fact]
public void Pong_RoundTrips()
{
var message = new ServerPongMessage(5, 99);
Span<byte> buffer = stackalloc byte[32];
var length = ProtocolCodec.WritePong(buffer, message);
Assert.Equal(13, length);
Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
}
[Fact]
public void Snapshot_RoundTripsEveryEntityField()
{
ReadOnlySpan<EntitySnapshot> entities =
[
new EntitySnapshot(7, EntityKind.Player, 100f, 200f, 18f, 0x4CC9F0),
new EntitySnapshot(8, EntityKind.Obstacle, 800f, 450f, 70f, 0x3A4553),
];
var buffer = new byte[ProtocolCodec.SnapshotSize(entities.Length)];
var length = ProtocolCodec.WriteSnapshot(buffer, 1234, entities);
Assert.Equal(buffer.Length, length);
var decoded = new EntitySnapshot[entities.Length];
var count = ProtocolCodec.ReadSnapshot(buffer, decoded, out var tick);
Assert.Equal(entities.Length, count);
Assert.Equal(1234u, tick);
Assert.Equal(entities[0], decoded[0]);
Assert.Equal(entities[1], decoded[1]);
}
[Fact]
public void SnapshotSize_MatchesTheLayoutTheClientAssumes()
{
// 1 type + 4 tick + 2 count, then 21 bytes per entity.
Assert.Equal(7, ProtocolCodec.SnapshotSize(0));
Assert.Equal(7 + 21, ProtocolCodec.SnapshotSize(1));
Assert.Equal(21, ProtocolConstants.EntitySnapshotSize);
}
[Fact]
public void Numbers_AreLittleEndian()
{
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, new ClientInputMessage(0x01020304, InputButtons.None));
Assert.Equal((byte)MessageType.ClientInput, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
Assert.Equal(6, length);
}
[Fact]
public void PeekMessageType_ReadsTheFirstByte()
{
Span<byte> buffer = stackalloc byte[16];
ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
}
[Fact]
public void TruncatedFrame_Throws()
{
byte[] frame = [(byte)MessageType.ServerWelcome, ProtocolConstants.Version];
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadWelcome(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadInput(frame));
}
[Fact]
public void OversizedName_Throws()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, new string('x', 100));
var buffer = new byte[256];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteHello(buffer, message));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 1, 20, 1f, 1f);
var buffer = new byte[4];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteWelcome(buffer, message));
}
}
namespace HSchool.Protocol.Tests;
/// <summary>
/// The wire format is a contract with the browser client. Round-trips prove the C# side is
/// self-consistent; the explicit byte-layout tests are what keeps
/// <c>src/HSchool.Client/src/net/protocol.ts</c> honest.
/// </summary>
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTripsAndIsTwoBytes()
{
var message = new ClientHelloMessage(ProtocolConstants.Version);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(2, length);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
}
[Fact]
public void Ping_RoundTripsAndIsNineBytes()
{
var message = new ClientPingMessage(1_700_000_000_123);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, message);
Assert.Equal(9, length);
Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
}
[Fact]
public void OpenSchool_RoundTripsAndIsFiveBytes()
{
var message = new ClientOpenSchoolMessage(0x01020304);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteOpenSchool(buffer, message);
Assert.Equal(5, length);
Assert.Equal(message, ProtocolCodec.ReadOpenSchool(buffer[..length]));
}
[Fact]
public void CloseSchool_IsASingleByte()
{
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteCloseSchool(buffer);
Assert.Equal(1, length);
Assert.Equal(MessageType.ClientCloseSchool, ProtocolCodec.PeekMessageType(buffer[..length]));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void SetRunning_RoundTripsAndIsTwoBytes(bool running)
{
var message = new ClientSetRunningMessage(running);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSetRunning(buffer, message);
Assert.Equal(2, length);
Assert.Equal(message, ProtocolCodec.ReadSetRunning(buffer[..length]));
}
[Theory]
[InlineData(0)]
[InlineData(4)]
public void SetSpeed_RoundTripsAndIsTwoBytes(byte speedIndex)
{
var message = new ClientSetSpeedMessage(speedIndex);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSetSpeed(buffer, message);
Assert.Equal(2, length);
Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length]));
}
[Fact]
public void Welcome_RoundTripsAndIsFourBytes()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 20, 6);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteWelcome(buffer, message);
Assert.Equal(4, length);
Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
}
[Fact]
public void Pong_RoundTripsAndIsThirteenBytes()
{
var message = new ServerPongMessage(5, 99);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePong(buffer, message);
Assert.Equal(13, length);
Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
}
[Fact]
public void Clock_RoundTripsAndIsFifteenBytes()
{
var message = new ServerClockMessage(7, 1_333_432_800_000, Running: true, SpeedIndex: 2);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(buffer, message);
Assert.Equal(15, length);
Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
}
[Fact]
public void SchoolGone_RoundTripsAndIsFiveBytes()
{
var message = new ServerSchoolGoneMessage(3);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(buffer, message);
Assert.Equal(5, length);
Assert.Equal(message, ProtocolCodec.ReadSchoolGone(buffer[..length]));
}
[Fact]
public void Numbers_AreLittleEndian()
{
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(0x01020304));
Assert.Equal((byte)MessageType.ClientOpenSchool, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
}
[Fact]
public void MaxFrameSize_FitsEveryMessage()
{
// The handlers size their buffers from this constant; the clock frame is the largest one.
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));
Assert.True(clock <= ProtocolCodec.MaxFrameSize);
}
[Fact]
public void PeekMessageType_ReadsTheFirstByte()
{
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
}
[Fact]
public void TruncatedFrame_Throws()
{
byte[] frame = [(byte)MessageType.ServerClock, 1, 2];
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadClock(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadOpenSchool(frame));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var buffer = new byte[2];
Assert.Throws<ProtocolException>(() =>
ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, 0, false, 1)));
}
}
@@ -0,0 +1,128 @@
namespace HSchool.Simulation.Tests;
public class GameClockTests
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private const double OneTwentiethOfASecond = 1d / 20d;
private const double GameMinutesPerRealSecond = 5d;
[Fact]
public void NewClock_StartsRunningAtTheStartDate()
{
var clock = new GameClock(Start);
Assert.Equal(Start, clock.Time);
Assert.True(clock.IsRunning);
Assert.Equal(ClockSpeed.DefaultIndex, clock.SpeedIndex);
Assert.Equal(1d, clock.Multiplier);
}
[Fact]
public void PausedClock_DoesNotMove()
{
var clock = new GameClock(Start) { IsRunning = false };
for (var i = 0; i < 100; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start, clock.Time);
}
[Fact]
public void OneRealSecond_AdvancesFiveGameMinutes()
{
var clock = new GameClock(Start);
// One real second at 20 Hz.
for (var i = 0; i < 20; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start.AddMinutes(5), clock.Time);
}
[Theory]
[InlineData(0, 2.5)]
[InlineData(1, 5)]
[InlineData(2, 10)]
[InlineData(3, 15)]
[InlineData(4, 20)]
public void SpeedIndex_ScalesTheGameMinutesPerSecond(int speedIndex, double expectedMinutes)
{
var clock = new GameClock(Start) { SpeedIndex = speedIndex };
for (var i = 0; i < 20; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(Start.AddMinutes(expectedMinutes), clock.Time);
}
[Theory]
[InlineData(-1)]
[InlineData(5)]
[InlineData(200)]
public void InvalidSpeedIndex_IsIgnored(int speedIndex)
{
var clock = new GameClock(Start) { SpeedIndex = 2 };
clock.SpeedIndex = speedIndex;
Assert.Equal(2, clock.SpeedIndex);
}
[Fact]
public void Pausing_FreezesTimeWhereItStopped()
{
var clock = new GameClock(Start);
for (var i = 0; i < 20; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
var paused = clock.Time;
clock.IsRunning = false;
for (var i = 0; i < 100; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
Assert.Equal(paused, clock.Time);
}
[Fact]
public void SameTickCount_AlwaysProducesTheSameDate()
{
Assert.Equal(RunOneHourOfTicks(), RunOneHourOfTicks());
static DateTime RunOneHourOfTicks()
{
var clock = new GameClock(Start) { SpeedIndex = 0 };
for (var i = 0; i < 20 * 60 * 60; i++)
{
clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
}
return clock.Time;
}
}
[Fact]
public void Time_IsUtcSoSerializationIsUnambiguous()
{
var clock = new GameClock(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Unspecified));
Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
}
[Fact]
public void StartDateOutsideTheSupportedRange_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameClock(new DateTime(1800, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
}
}
@@ -1,224 +0,0 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Tests;
public class GameWorldTests
{
private static SimulationOptions Options() => new()
{
TickRate = 20,
WorldWidth = 1000f,
WorldHeight = 1000f,
PlayerSpeed = 200f,
PlayerRadius = 10f,
};
private static EntitySnapshot Entity(GameWorld world, uint networkId)
{
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
return buffer.Single(entity => entity.Id == networkId);
}
[Fact]
public void Tick_AdvancesTheTickCounter()
{
using var world = new GameWorld(Options());
world.Tick();
world.Tick();
Assert.Equal(2u, world.CurrentTick);
}
[Fact]
public void SpawnPlayer_AddsAPlayerEntityToSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
Assert.Equal(1, world.PlayerCount);
Assert.Equal(EntityKind.Player, Entity(world, networkId).Kind);
}
[Fact]
public void SpawnPlayer_Twice_Throws()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
Assert.Throws<InvalidOperationException>(() => world.SpawnPlayer(playerId: 1));
}
[Fact]
public void Input_MovesThePlayerAtExactlySpeedTimesDelta()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.Tick();
var expected = startX + (options.PlayerSpeed * options.FixedDeltaTime);
Assert.Equal(expected, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void DiagonalInput_IsNotFasterThanCardinal()
{
var options = Options();
using var world = new GameWorld(options);
var straight = world.SpawnPlayer(playerId: 1);
var diagonal = world.SpawnPlayer(playerId: 2);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.ApplyInput(playerId: 2, InputButtons.Right | InputButtons.Down, sequence: 1);
world.Tick();
var straightBefore = Entity(world, straight);
var diagonalBefore = Entity(world, diagonal);
world.Tick();
var straightStep = Distance(straightBefore, Entity(world, straight));
var diagonalStep = Distance(diagonalBefore, Entity(world, diagonal));
Assert.Equal(straightStep, diagonalStep, tolerance: 0.01f);
}
[Fact]
public void Player_StopsAtTheWorldBounds()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
world.ApplyInput(playerId: 1, InputButtons.Left, sequence: 1);
for (var i = 0; i < 200; i++)
{
world.Tick();
}
Assert.Equal(options.PlayerRadius, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void StaleInput_IsIgnored()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.None, sequence: 10);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 2);
world.Tick();
Assert.Equal(startX, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void InputForAnUnknownPlayer_IsIgnored()
{
using var world = new GameWorld(Options());
world.ApplyInput(playerId: 999, InputButtons.Right, sequence: 1);
world.Tick();
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void DespawnPlayer_RemovesItFromSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.Equal(0, world.PlayerCount);
Assert.DoesNotContain(buffer, entity => entity.Id == networkId);
Assert.Null(world.GetNetworkId(playerId: 1));
}
[Fact]
public void DespawnPlayer_Twice_IsHarmless()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void NetworkIds_AreNotRecycledAfterDespawn()
{
using var world = new GameWorld(Options());
var first = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var second = world.SpawnPlayer(playerId: 1);
Assert.NotEqual(first, second);
}
[Fact]
public void EmptyWorld_StillContainsTheStaticObstacles()
{
using var world = new GameWorld(Options());
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.NotEmpty(buffer);
Assert.All(buffer, entity => Assert.Equal(EntityKind.Obstacle, entity.Kind));
}
[Fact]
public void Simulation_IsDeterministicForTheSameInputs()
{
var first = Run();
var second = Run();
Assert.Equal(first, second);
static (float X, float Y) Run()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 3);
for (var i = 0; i < 25; i++)
{
world.ApplyInput(playerId: 3, i % 2 == 0 ? InputButtons.Right : InputButtons.Down, (uint)i + 1);
world.Tick();
}
var entity = Entity(world, networkId);
return (entity.X, entity.Y);
}
}
[Fact]
public void UsingADisposedWorld_Throws()
{
var world = new GameWorld(Options());
world.Dispose();
Assert.Throws<ObjectDisposedException>(world.Tick);
}
private static float Distance(EntitySnapshot from, EntitySnapshot to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return MathF.Sqrt((dx * dx) + (dy * dy));
}
}
@@ -0,0 +1,179 @@
namespace HSchool.Simulation.Tests;
public class SchoolRegistryTests
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private static SchoolRegistry NewRegistry(int maxSchools = 6) =>
new(new SimulationOptions { MaxSchools = maxSchools, TickRate = 20, GameMinutesPerRealSecond = 5 });
[Fact]
public void NewRegistry_IsEmpty()
{
using var registry = NewRegistry();
Assert.Equal(0, registry.Count);
Assert.Equal(6, registry.MaxSchools);
Assert.False(registry.IsFull);
}
[Fact]
public void Create_AddsASchoolAtTheGivenStartDate()
{
using var registry = NewRegistry();
var result = registry.Create("Гимназия №1", Start);
Assert.True(result.Succeeded);
Assert.Equal("Гимназия №1", result.School!.Name);
Assert.Equal(Start, result.School.Clock.Time);
// A new school starts living straight away; only the pause button stops it.
Assert.True(result.School.Clock.IsRunning);
Assert.Equal(1, registry.Count);
}
[Fact]
public void Create_BeyondTheLimit_Fails()
{
using var registry = NewRegistry(maxSchools: 2);
registry.Create("Первая", Start);
registry.Create("Вторая", Start);
var result = registry.Create("Третья", Start);
Assert.False(result.Succeeded);
Assert.Equal(SchoolCreationError.LimitReached, result.Error);
Assert.True(registry.IsFull);
Assert.Equal(2, registry.Count);
}
[Fact]
public void Delete_FreesASlot()
{
using var registry = NewRegistry(maxSchools: 1);
var first = registry.Create("Первая", Start).School!;
Assert.False(registry.Create("Вторая", Start).Succeeded);
Assert.True(registry.Delete(first.Id));
Assert.True(registry.Create("Вторая", Start).Succeeded);
}
[Fact]
public void Delete_UnknownId_ReportsFailure()
{
using var registry = NewRegistry();
Assert.False(registry.Delete(42));
}
[Fact]
public void Ids_AreNotReusedAfterDeletion()
{
using var registry = NewRegistry();
var first = registry.Create("Первая", Start).School!;
registry.Delete(first.Id);
var second = registry.Create("Вторая", Start).School!;
Assert.NotEqual(first.Id, second.Id);
Assert.Null(registry.Find(first.Id));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Create_RejectsBlankNames(string name)
{
using var registry = NewRegistry();
Assert.Equal(SchoolCreationError.InvalidName, registry.Create(name, Start).Error);
}
[Fact]
public void Create_RejectsOverlongNames()
{
using var registry = NewRegistry();
var result = registry.Create(new string('ш', School.MaxNameLength + 1), Start);
Assert.Equal(SchoolCreationError.InvalidName, result.Error);
}
[Fact]
public void Create_TrimsAndStripsControlCharacters()
{
using var registry = NewRegistry();
var result = registry.Create(" Лицей ", Start);
Assert.Equal("Лицей", result.School!.Name);
}
[Fact]
public void Create_RejectsStartDatesOutsideTheSupportedRange()
{
using var registry = NewRegistry();
var result = registry.Create("Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(SchoolCreationError.InvalidStartDate, result.Error);
}
[Fact]
public void Tick_AdvancesOnlyRunningSchools()
{
using var registry = NewRegistry();
var running = registry.Create("Идёт", Start).School!;
var paused = registry.Create("Стоит", Start).School!;
paused.Clock.IsRunning = false;
for (var i = 0; i < 20; i++)
{
registry.Tick();
}
Assert.Equal(Start.AddMinutes(5), running.Clock.Time);
Assert.Equal(Start, paused.Clock.Time);
}
[Fact]
public void SuggestName_NeverRepeatsAnExistingName()
{
using var registry = NewRegistry(maxSchools: 20);
for (var i = 0; i < 20; i++)
{
var suggestion = registry.SuggestName();
Assert.True(registry.Create(suggestion, Start).Succeeded, $"\"{suggestion}\" was rejected.");
}
var names = registry.Schools.Select(school => school.Name).ToArray();
Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
[Fact]
public void SuggestedNames_FitTheNameLimit()
{
var generator = new SchoolNameGenerator(new Random(1234));
for (var i = 0; i < 200; i++)
{
var name = generator.Next([]);
Assert.InRange(name.Length, 1, School.MaxNameLength);
}
}
[Fact]
public void Dispose_DropsEverySchool()
{
var registry = NewRegistry();
registry.Create("Школа", Start);
registry.Dispose();
Assert.Equal(0, registry.Count);
}
}