using System.Net.Http.Json; 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. /// [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(2, 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); // 1 game minute 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, 0.5, 2); } [Fact] public async Task OpeningASchool_SendsAMapSnapshot() { 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 snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot)); Assert.Equal(school.Id, snapshot.SchoolId); Assert.Contains(snapshot.Nodes, node => node.Id == "yard" && node.Kind == 0); var office = Assert.Single(snapshot.Nodes, node => node.Id == "principals-office"); Assert.Equal("Кабинет директора", office.Name); Assert.Equal("floor-1", office.ParentId); Assert.Contains("Директор", office.Positions); Assert.NotEmpty(office.Items); var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-101"); Assert.Equal(16, classroom.PupilSlots); Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16); Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул"); } [Fact] public async Task OpeningASchool_LabelsTheSnapshotInTheHelloLocale() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate); using var socket = await OpenSchoolAsync(school.Id, ProtocolConstants.LocaleEnglish); var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot)); Assert.Equal("Principal's office", Assert.Single(snapshot.Nodes, node => node.Id == "principals-office").Name); } [Fact] public async Task OpeningASchool_WithACustomMap_ReturnsThatLayout() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap); using var socket = await OpenSchoolAsync(school.Id); var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot)); Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1"); var office = Assert.Single(snapshot.Nodes, node => node.Id == "office"); Assert.Equal("floor-1", office.ParentId); } [Fact] public async Task ReloadFromDisk_RestoresACustomMap() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateWithMapAsync(client, "Карта с диска", StartDate, SchoolApiTests.SimpleCustomMap); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); using var socket = await OpenSchoolAsync(school.Id); var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot)); Assert.Contains(snapshot.Nodes, node => node.Id == "office"); Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1"); } [Fact] public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnPresenceNotTheSnapshot() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc); var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start); using var socket = await OpenSchoolAsync(school.Id); var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot)); Assert.Contains(snapshot.Nodes, node => node.Id == "classroom-101"); await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false))); await ReceiveClockWhereAsync(socket, clock => !clock.Running); var staffing = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/staffing", TestContext.Current.CancellationToken); Assert.NotNull(staffing); var applicant = staffing.Applicants[0]; using var hire = await client.PostAsJsonAsync( $"/api/schools/{school.Id}/staff/hire", new { personId = applicant.Id, position = "Teacher" }, TestContext.Current.CancellationToken); hire.EnsureSuccessStatusCode(); using var assign = await client.PostAsJsonAsync( $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(applicant.Id)}/subjects", new { subject = "Mathematics" }, TestContext.Current.CancellationToken); assign.EnsureSuccessStatusCode(); var presence = await ReceivePresenceWhereAsync( socket, frame => frame.Nodes.Any(node => node.ActivitySubject.Length > 0)); var occupied = Assert.Single(presence.Nodes, node => node.ActivitySubject.Length > 0); Assert.Equal("Математика", occupied.ActivitySubject); Assert.NotEmpty(occupied.ActivityClass); var directory = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/directory", TestContext.Current.CancellationToken); Assert.NotNull(directory); Assert.Contains(directory.People, person => person.Id == applicant.Id && person.FullName == applicant.FullName); // Resume and check that people ride the frame at all — that is what "occupancy is on // presence, not the snapshot" means. Do not wait for the freshly hired teacher in // particular: whether somebody hired mid-day comes in today depends on their day plan, // and the frozen moment drifts with however long this test's own HTTP calls took (the // school runs at one game minute per real second until it is paused). Waiting for that // one person made this test fail under load. await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); // Presence still goes out while paused (empty people, lesson labels from the table). // Waiting for people first drains those frames and can spend the 40-frame budget // before the resume even lands. Clock.Running is the signal that time is moving. await ReceiveClockWhereAsync(socket, clock => clock.Running); presence = await ReceivePresenceWhereAsync(socket, frame => frame.People.Count > 0); Assert.All(presence.People, person => Assert.False(string.IsNullOrWhiteSpace(person.NodeId))); Assert.Contains(presence.People, person => directory.People.Any(row => row.Id == person.Id)); } [Fact] public async Task SkipEmpty_DuringWorkHours_IsIgnored() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc); var school = await SchoolApiTests.CreateAsync(client, "Пропуск в учебное время", start); using var socket = await OpenSchoolAsync(school.Id); await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false))); var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running); Assert.False(paused.SkipAllowed); Assert.Equal(0, paused.SkipTargetUnixMs); await SendAsync(socket, buffer => ProtocolCodec.WriteSkipEmpty(buffer)); var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1)); Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs); Assert.False(later.SkipAllowed); } [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); // ×10 means 10 game minutes per real second. Assert.InRange(elapsed.TotalMinutes, 6, 14); } [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 Guest_SetRunning_DoesNotChangeOwnerRunning() { using var ownerClient = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "GuestClockOwner"); await SchoolApiTests.WipeAllSavesAsync(ownerClient); var school = await SchoolApiTests.CreateAsync(ownerClient, "Гостевые часы", StartDate); var guestCookie = await SchoolApiTests.LoginAndGetCookieAsync( fixture.App.CreateHttpClient("server"), "GuestClockViewer"); using var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Cookie", guestCookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); await SendAsync(socket, buffer => ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleRussian))); await ReceiveUntilAsync(socket, MessageType.ServerWelcome); await SendAsync(socket, buffer => ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id))); _ = await ReceiveClockAsync(socket); await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false))); await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); var ownerView = await FindAsync(ownerClient, school.Id); Assert.True(ownerView.Running); using var delete = await ownerClient.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); delete.EnsureSuccessStatusCode(); } [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 OldProtocolVersion_IsRejected() { using var httpClient = fixture.App.CreateHttpClient("server"); var cookie = await SchoolApiTests.LoginAndGetCookieAsync(httpClient); using var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Cookie", cookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); await SendAsync(socket, buffer => ProtocolCodec.WriteHello( buffer, new ClientHelloMessage((byte)(ProtocolConstants.Version - 1), ProtocolConstants.LocaleRussian))); 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); } [Fact] public async Task VersionMismatch_IsRejected() { using var httpClient = fixture.App.CreateHttpClient("server"); var cookie = await SchoolApiTests.LoginAndGetCookieAsync(httpClient); using var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Cookie", cookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); await SendAsync(socket, buffer => ProtocolCodec.WriteHello( buffer, new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), ProtocolConstants.LocaleRussian))); 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, byte locale = ProtocolConstants.LocaleRussian) { using var httpClient = fixture.App.CreateHttpClient("server"); var socket = await ConnectAsync(httpClient, locale); await ReceiveUntilAsync(socket, MessageType.ServerWelcome); await SendAsync(socket, buffer => ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId))); return socket; } private async Task ConnectAsync(byte locale = ProtocolConstants.LocaleRussian) { using var httpClient = fixture.App.CreateHttpClient("server"); var userName = $"Ws-{Guid.NewGuid():N}"[..12]; var cookie = await SchoolApiTests.LoginAndGetCookieAsync(httpClient, userName); var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Cookie", cookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); await SendAsync(socket, buffer => ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale))); return socket; } private async Task ConnectAsync(HttpClient client, byte locale = ProtocolConstants.LocaleRussian) { var userName = $"Ws-{Guid.NewGuid():N}"[..12]; var cookie = await SchoolApiTests.LoginAndGetCookieAsync(client, userName); var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Cookie", cookie); var http = fixture.App.GetEndpoint("server", "http"); var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri; await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout); await SendAsync(socket, buffer => ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale))); 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."); } private static async Task ReceivePresenceWhereAsync( WebSocket socket, Func predicate) { for (var attempt = 0; attempt < 40; attempt++) { var presence = ProtocolCodec.ReadPresence(await ReceiveUntilAsync(socket, MessageType.ServerPresence)); if (predicate(presence)) { return presence; } } throw new InvalidOperationException("No presence 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[64 * 1024]; var chunks = new List(); 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}."); } chunks.AddRange(buffer.AsSpan(0, result.Count).ToArray()); if (!result.EndOfMessage) { continue; } var frame = chunks.ToArray(); chunks.Clear(); if (ProtocolCodec.PeekMessageType(frame) == expected) { return frame; } } } private sealed record StaffingSnapshot(IReadOnlyList Applicants); private sealed record ApplicantSnapshot(string Id, string FullName); private sealed record DirectorySnapshot(IReadOnlyList People); private sealed record DirectoryPersonSnapshot(string Id, string FullName); }