Files

973 lines
44 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Http.Json;
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.
/// </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(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<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 = 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 PausingNotice_SetsRunningFalse_AndIgnoresSetRunningTrue()
{
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 ReceiveClockWhereAsync(socket, clock => clock.Running);
var posted = await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
var notice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal(posted.Id, notice.Id);
Assert.Equal("GenerationFailed", notice.DefName);
Assert.True(notice.Pause);
Assert.Equal(NoticeSeverity.Error, notice.Severity);
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
Assert.False(later.Running);
Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
}
[Fact]
public async Task DismissLastPausing_AllowsSetRunningTrue_DoesNotAutoPlay()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Dismiss не Play", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockWhereAsync(socket, clock => clock.Running);
var posted = await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10));
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id)));
var stillPaused = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
Assert.False(stillPaused.Running);
Assert.Equal(paused.GameTimeUnixMs, stillPaused.GameTimeUnixMs);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
var resumed = await ReceiveClockWhereAsync(socket, clock => clock.Running);
Assert.True(resumed.Running);
}
[Fact]
public async Task SaveLoad_KeepsSticky_DropsInfo()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Sticky сейв", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await PostDevNoticeAsync(client, school.Id, "DayStarted");
var info = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal("DayStarted", info.DefName);
Assert.False(info.Pause);
await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
var sticky = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal("GenerationFailed", sticky.DefName);
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
}
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.False(restored.Running);
using var reopened = await OpenSchoolAsync(school.Id, client);
var replayed = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(reopened, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal("GenerationFailed", replayed.DefName);
Assert.True(replayed.Pause);
Assert.False((await ReceiveClockAsync(reopened)).Running);
var extra = await TryReceiveNoticeAsync(reopened, TimeSpan.FromSeconds(2));
Assert.True(extra is not { DefName: "DayStarted" });
}
[Fact]
public async Task Guest_Dismiss_DoesNotClearPause_OwnerDoes()
{
using var ownerClient = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticePauseOwner");
await SchoolApiTests.WipeAllSavesAsync(ownerClient);
var school = await SchoolApiTests.CreateAsync(ownerClient, "Гость не закрывает", StartDate);
using var ownerSocket = await OpenSchoolAsync(school.Id, ownerClient);
await ReceiveClockWhereAsync(ownerSocket, clock => clock.Running);
var posted = await PostDevNoticeAsync(ownerClient, school.Id, "GenerationFailed");
await ReceiveUntilAsync(ownerSocket, MessageType.ServerNotice, TimeSpan.FromSeconds(10));
await ReceiveClockWhereAsync(ownerSocket, clock => !clock.Running);
using var guestHttp = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticePauseGuest");
using var forbidden = await guestHttp.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/dismiss",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(System.Net.HttpStatusCode.Forbidden, forbidden.StatusCode);
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(forbidden));
var guestCookie = guestHttp.DefaultRequestHeaders.TryGetValues("Cookie", out var guestCookies)
? guestCookies.First()
: await SchoolApiTests.WebSocketCookieAsync(guestHttp);
using var guestSocket = await ConnectWithCookieAsync(guestCookie);
await ReceiveUntilAsync(guestSocket, MessageType.ServerWelcome);
await SendAsync(guestSocket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id)));
var guestNotice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(guestSocket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal(posted.Id, guestNotice.Id);
await SendAsync(guestSocket, buffer =>
ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id)));
await Task.Delay(TimeSpan.FromMilliseconds(400), TestContext.Current.CancellationToken);
Assert.False((await FindAsync(ownerClient, school.Id)).Running);
await SendAsync(ownerSocket, buffer =>
ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id)));
await SendAsync(ownerSocket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
var resumed = await ReceiveClockWhereAsync(ownerSocket, clock => clock.Running);
Assert.True(resumed.Running);
using var delete = await ownerClient.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
delete.EnsureSuccessStatusCode();
}
[Fact]
public async Task OpenAfterReload_ReplaysSticky_ClockPaused()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "F5 sticky", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
}
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
using var reopened = await OpenSchoolAsync(school.Id, client);
var notice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(reopened, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
var clock = await ReceiveClockWhereAsync(reopened, frame => !frame.Running);
Assert.Equal("GenerationFailed", notice.DefName);
Assert.True(notice.Pause);
Assert.False(clock.Running);
Assert.False((await FindAsync(client, school.Id)).Running);
}
[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<StaffingSnapshot>(
$"/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<DirectorySnapshot>(
$"/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 static async Task<DevNoticeResponse> PostDevNoticeAsync(HttpClient client, int schoolId, string defName)
{
using var response = await client.PostAsJsonAsync(
$"/api/dev/schools/{schoolId}/notices",
new { defName, personId = 0u },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<DevNoticeResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
return body;
}
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);
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<HttpClient> CreateOwnerHttpClientAsync()
{
using var template = fixture.App.CreateHttpClient("server");
return await SchoolApiTests.CreateIsolatedClientAsync(template, SchoolApiTests.TestUserName);
}
private async Task<ClientWebSocket> 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<ClientWebSocket> 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<ClientWebSocket> 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<ClientWebSocket> 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<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.");
}
private static async Task<ServerPresenceMessage> ReceivePresenceWhereAsync(
WebSocket socket,
Func<ServerPresenceMessage, bool> 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.");
}
/// <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[64 * 1024];
var chunks = new List<byte>();
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<ServerNoticeMessage?> 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<byte>();
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<ApplicantSnapshot> Applicants);
private sealed record ApplicantSnapshot(string Id, string FullName);
private sealed record DirectorySnapshot(IReadOnlyList<DirectoryPersonSnapshot> People);
private sealed record DirectoryPersonSnapshot(string Id, string FullName);
private sealed record DevNoticeResponse(uint Id, string DefName, bool Pause);
}