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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
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 = await CreateOwnerHttpClientAsync();
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
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 OpenSchool_AfterMorning_GetsNotice_AndReopenDoesNotReplayInfo()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var beforeMorning = new DateTime(2012, 4, 3, 5, 55, 0, DateTimeKind.Utc);
var school = await SchoolApiTests.CreateAsync(client, "Утро тост", beforeMorning);
using var socket = await OpenSchoolAsync(school.Id, client);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 4)));
var notice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(20)));
Assert.Equal("DayStarted", notice.DefName);
Assert.Equal(NoticeSeverity.Info, notice.Severity);
Assert.False(notice.Pause);
Assert.Equal(8000u, notice.TtlMs);
Assert.Equal(0u, notice.PersonId);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id)));
var replayed = await TryReceiveNoticeAsync(socket, TimeSpan.FromSeconds(2));
Assert.Null(replayed);
}
[Fact]
public async Task OpeningASchool_SendsAMapSnapshot()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок карты", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client, 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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap);
using var socket = await OpenSchoolAsync(school.Id, client);
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 = await CreateOwnerHttpClientAsync();
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, client);
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 = await CreateOwnerHttpClientAsync();
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, client);
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 = await CreateOwnerHttpClientAsync();
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, client);
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Быстрая", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
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 = await CreateOwnerHttpClientAsync();
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, client);
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 = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок паузы", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
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);
if (school is not null)
{
return school;
}
var other = state.Others.SingleOrDefault(candidate => candidate.Id == schoolId);
Assert.NotNull(other);
return new SchoolApiTests.SchoolResponse(
other.Id,
other.Name,
other.GameTime,
other.Running,
other.SpeedIndex,
other.Seed,
Mine: false,
other.ModIds);
}
private async Task CreateOwnerHttpClientAsync()
{
using var template = fixture.App.CreateHttpClient("server");
return await SchoolApiTests.CreateIsolatedClientAsync(template, SchoolApiTests.TestUserName);
}
private async Task OpenSchoolAsync(
int schoolId,
HttpClient ownerClient,
byte locale = ProtocolConstants.LocaleRussian)
{
var cookie = ownerClient.DefaultRequestHeaders.TryGetValues("Cookie", out var values)
? values.First()
: await SchoolApiTests.WebSocketCookieAsync(ownerClient);
var socket = await ConnectWithCookieAsync(cookie, locale);
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
return socket;
}
private async Task ConnectWithCookieAsync(string cookie, byte locale = ProtocolConstants.LocaleRussian)
{
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(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 static async Task TryReceiveNoticeAsync(WebSocket socket, TimeSpan duration)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(duration);
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)
{
return null;
}
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
chunks.AddRange(buffer.AsSpan(0, result.Count).ToArray());
if (!result.EndOfMessage)
{
continue;
}
var frame = chunks.ToArray();
chunks.Clear();
if (ProtocolCodec.PeekMessageType(frame) == MessageType.ServerNotice)
{
return ProtocolCodec.ReadNotice(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);
}