Update wire protocol to version 7 and enhance presence management features
- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure. - Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states. - Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution. - Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream. - Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity. - Updated documentation to reflect the new protocol features and changes in presence management. - Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
This commit is contained in:
@@ -111,8 +111,6 @@ public class GameSocketTests(AppHostFixture fixture)
|
||||
Assert.Equal(16, classroom.PupilSlots);
|
||||
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
|
||||
Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул");
|
||||
Assert.Equal("", classroom.ActivitySubject);
|
||||
Assert.Empty(classroom.Present);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -161,13 +159,21 @@ public class GameSocketTests(AppHostFixture fixture)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnTheRoom()
|
||||
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);
|
||||
@@ -184,14 +190,49 @@ public class GameSocketTests(AppHostFixture fixture)
|
||||
TestContext.Current.CancellationToken);
|
||||
assign.EnsureSuccessStatusCode();
|
||||
|
||||
using var socket = await OpenSchoolAsync(school.Id);
|
||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||
var occupied = Assert.Single(snapshot.Nodes, node => node.ActivitySubject.Length > 0);
|
||||
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);
|
||||
Assert.Contains(applicant.FullName, occupied.Present);
|
||||
Assert.True(occupied.Present.Count > 1);
|
||||
|
||||
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);
|
||||
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
|
||||
presence = await ReceivePresenceWhereAsync(
|
||||
socket,
|
||||
frame => frame.People.Any(person => person.Id == applicant.Id));
|
||||
Assert.Contains(presence.People, person => person.Id == applicant.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]
|
||||
@@ -465,6 +506,22 @@ public class GameSocketTests(AppHostFixture fixture)
|
||||
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)
|
||||
{
|
||||
@@ -523,4 +580,8 @@ public class GameSocketTests(AppHostFixture fixture)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,32 @@ public class PeopleApiTests(AppHostFixture fixture)
|
||||
Assert.Empty(staff.People);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Directory_ListsRosterIdsAndNames()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var school = await SchoolApiTests.CreateAsync(client, "Справочник", Start);
|
||||
|
||||
var directory = await client.GetFromJsonAsync<DirectoryResponse>(
|
||||
$"/api/schools/{school.Id}/directory",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(directory);
|
||||
Assert.NotEmpty(directory.People);
|
||||
Assert.All(directory.People, person =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(person.Id));
|
||||
Assert.False(string.IsNullOrWhiteSpace(person.FullName));
|
||||
});
|
||||
|
||||
var page = await GetPeopleAsync(client, school.Id, "pageSize=10");
|
||||
Assert.Contains(directory.People, person => person.Id == page.People[0].Id && person.FullName == page.People[0].FullName);
|
||||
|
||||
using var missing = await client.GetAsync("/api/schools/999999/directory", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
Assert.Equal("unknown-school", await ProblemCodeAsync(missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_UnknownSchool_IsNotFound()
|
||||
{
|
||||
@@ -204,4 +230,8 @@ public class PeopleApiTests(AppHostFixture fixture)
|
||||
IReadOnlyList<PersonRelResponse> Partners);
|
||||
|
||||
private sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
||||
|
||||
private sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
|
||||
|
||||
private sealed record DirectoryPersonResponse(string Id, string FullName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user