using System.Net.WebSockets; using HSchool.Protocol; namespace HSchool.AppHost.Tests; /// /// 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. /// [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(() => 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 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 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 ConnectAsync() { var socket = await ConnectRawAsync(); await SendAsync(socket, buffer => ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version))); return socket; } private async Task 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 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 ReceiveClockAsync(WebSocket socket) => ProtocolCodec.ReadClock(await ReceiveUntilAsync(socket, MessageType.ServerClock)); /// Drains clock frames until one satisfies . private static async Task ReceiveClockWhereAsync( WebSocket socket, Func 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."); } /// Keeps reading clock frames for and returns the last one. private static async Task 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; } /// Reads frames until one of shows up. private static async Task 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; } } } }