597 lines
26 KiB
C#
597 lines
26 KiB
C#
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(6, welcome.MaxSchools);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ChangingSpeed_DoesNotResumeAPausedSchool()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await SchoolApiTests.ResetAsync(client);
|
||
var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
|
||
|
||
using var socket = await OpenSchoolAsync(school.Id);
|
||
await ReceiveClockAsync(socket);
|
||
|
||
await SendAsync(socket, buffer =>
|
||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
|
||
|
||
await SendAsync(socket, buffer =>
|
||
ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 3)));
|
||
var afterSpeedChange = await ReceiveClockWhereAsync(socket, clock => clock.SpeedIndex == 3);
|
||
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
|
||
|
||
Assert.False(afterSpeedChange.Running);
|
||
Assert.False(later.Running);
|
||
Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task NoSchoolOpen_MeansNoClockFramesButTheCalendarKeepsRunning()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await SchoolApiTests.ResetAsync(client);
|
||
var school = await SchoolApiTests.CreateAsync(client, "Живёт сама", StartDate);
|
||
|
||
using var socket = await ConnectAsync();
|
||
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
|
||
|
||
// A connection that opened nothing gets no clock frames…
|
||
await Assert.ThrowsAsync<TimeoutException>(() =>
|
||
ReceiveUntilAsync(socket, MessageType.ServerClock, TimeSpan.FromSeconds(1)));
|
||
|
||
// …but the school moved on anyway, which is what the menu cards show.
|
||
var reloaded = await FindAsync(client, school.Id);
|
||
Assert.True(reloaded.Running);
|
||
Assert.True(reloaded.GameTime > StartDate, $"The calendar stood still at {reloaded.GameTime:O}.");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task OpeningASchool_StreamsItsClock()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await SchoolApiTests.ResetAsync(client);
|
||
var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
|
||
|
||
using var socket = await OpenSchoolAsync(school.Id);
|
||
var first = await ReceiveClockAsync(socket);
|
||
|
||
Assert.Equal(school.Id, first.SchoolId);
|
||
Assert.True(first.Running);
|
||
Assert.Equal(1, first.SpeedIndex);
|
||
|
||
// 5 game minutes per real second at ×1, so one second of ticks has to move the calendar.
|
||
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
|
||
var elapsed = ToDate(later) - ToDate(first);
|
||
|
||
Assert.InRange(elapsed.TotalMinutes, 3, 8);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task 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<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 five game minutes 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);
|
||
|
||
// ×4 means 20 game minutes per real second.
|
||
Assert.InRange(elapsed.TotalMinutes, 12, 30);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task LeavingASchool_KeepsItsClockRunning()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await SchoolApiTests.ResetAsync(client);
|
||
var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
|
||
|
||
using (var socket = await OpenSchoolAsync(school.Id))
|
||
{
|
||
await ReceiveClockAsync(socket);
|
||
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
|
||
}
|
||
|
||
var afterLeaving = await FindAsync(client, school.Id);
|
||
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||
var later = await FindAsync(client, school.Id);
|
||
|
||
Assert.True(later.Running);
|
||
Assert.True(later.GameTime > afterLeaving.GameTime, "The calendar stopped when the client left.");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task APausedSchool_StaysPausedAfterLeaving()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await SchoolApiTests.ResetAsync(client);
|
||
var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
|
||
|
||
using (var socket = await OpenSchoolAsync(school.Id))
|
||
{
|
||
await ReceiveClockAsync(socket);
|
||
await SendAsync(socket, buffer =>
|
||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
|
||
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
|
||
}
|
||
|
||
var afterLeaving = await FindAsync(client, school.Id);
|
||
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||
var later = await FindAsync(client, school.Id);
|
||
|
||
Assert.False(later.Running);
|
||
Assert.Equal(afterLeaving.GameTime, later.GameTime);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task DeletingTheOpenSchool_TellsTheClient()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await SchoolApiTests.ResetAsync(client);
|
||
var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
|
||
|
||
using var socket = await OpenSchoolAsync(school.Id);
|
||
await ReceiveClockAsync(socket);
|
||
|
||
using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||
deleted.EnsureSuccessStatusCode();
|
||
|
||
var gone = ProtocolCodec.ReadSchoolGone(await ReceiveUntilAsync(socket, MessageType.ServerSchoolGone));
|
||
|
||
Assert.Equal(school.Id, gone.SchoolId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task OpeningASchoolThatDoesNotExist_IsAnsweredWithSchoolGone()
|
||
{
|
||
using var socket = await ConnectAsync();
|
||
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
|
||
|
||
await SendAsync(socket, buffer =>
|
||
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(999999)));
|
||
|
||
var gone = ProtocolCodec.ReadSchoolGone(await ReceiveUntilAsync(socket, MessageType.ServerSchoolGone));
|
||
|
||
Assert.Equal(999999, gone.SchoolId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task 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 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 VersionMismatch_IsRejected()
|
||
{
|
||
using var socket = await ConnectRawAsync();
|
||
|
||
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<SchoolApiTests.SchoolResponse> FindAsync(HttpClient client, int schoolId)
|
||
{
|
||
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||
var school = state.Schools.SingleOrDefault(candidate => candidate.Id == schoolId);
|
||
|
||
Assert.NotNull(school);
|
||
return school;
|
||
}
|
||
|
||
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian)
|
||
{
|
||
var socket = await ConnectAsync(locale);
|
||
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
|
||
await SendAsync(socket, buffer =>
|
||
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
|
||
return socket;
|
||
}
|
||
|
||
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
|
||
{
|
||
var socket = await ConnectRawAsync();
|
||
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 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);
|
||
}
|