Files
h-school/tests/HSchool.AppHost.Tests/GameSocketTests.cs
T

597 lines
26 KiB
C#
Raw 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(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);
}