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);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,17 @@ public class ProtocolCodecTests
|
||||
Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipEmpty_IsASingleByte()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||
|
||||
var length = ProtocolCodec.WriteSkipEmpty(buffer);
|
||||
|
||||
Assert.Equal(1, length);
|
||||
Assert.Equal(MessageType.ClientSkipEmpty, ProtocolCodec.PeekMessageType(buffer[..length]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Welcome_RoundTripsAndIsFourBytes()
|
||||
{
|
||||
@@ -110,17 +121,44 @@ public class ProtocolCodecTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clock_RoundTripsAndIsFifteenBytes()
|
||||
public void Clock_RoundTripsAndIsTwentyFourBytes()
|
||||
{
|
||||
var message = new ServerClockMessage(7, 1_333_432_800_000, Running: true, SpeedIndex: 2);
|
||||
var message = new ServerClockMessage(
|
||||
7,
|
||||
1_333_432_800_000,
|
||||
Running: true,
|
||||
SpeedIndex: 2,
|
||||
SkipAllowed: true,
|
||||
SkipTargetUnixMs: 1_333_516_800_000);
|
||||
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||
|
||||
var length = ProtocolCodec.WriteClock(buffer, message);
|
||||
|
||||
Assert.Equal(15, length);
|
||||
Assert.Equal(24, length);
|
||||
Assert.Equal((byte)MessageType.ServerClock, buffer[0]);
|
||||
Assert.Equal(7, BitConverter.ToInt32(buffer[1..5]));
|
||||
Assert.Equal(1_333_432_800_000, BitConverter.ToInt64(buffer[5..13]));
|
||||
Assert.Equal(1, buffer[13]);
|
||||
Assert.Equal(2, buffer[14]);
|
||||
Assert.Equal(1, buffer[15]);
|
||||
Assert.Equal(1_333_516_800_000, BitConverter.ToInt64(buffer[16..24]));
|
||||
Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clock_WithoutSkip_WritesZeroTarget()
|
||||
{
|
||||
var message = new ServerClockMessage(1, 0, Running: false, SpeedIndex: 1);
|
||||
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||
|
||||
var length = ProtocolCodec.WriteClock(buffer, message);
|
||||
var read = ProtocolCodec.ReadClock(buffer[..length]);
|
||||
|
||||
Assert.Equal(24, length);
|
||||
Assert.False(read.SkipAllowed);
|
||||
Assert.Equal(0, read.SkipTargetUnixMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchoolGone_RoundTripsAndIsFiveBytes()
|
||||
{
|
||||
@@ -164,34 +202,45 @@ public class ProtocolCodecTests
|
||||
Assert.Equal(0, read.Nodes[0].PupilSlots);
|
||||
Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items);
|
||||
Assert.Equal(["Директор"], read.Nodes[1].Positions);
|
||||
Assert.Equal("", read.Nodes[0].ActivitySubject);
|
||||
Assert.Empty(read.Nodes[0].Present);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSnapshot_RoundTripsOccupancyAfterPositions()
|
||||
public void Presence_RoundTripsAndWritesHeaderOffsets()
|
||||
{
|
||||
var message = new ServerMapSnapshotMessage(3, [
|
||||
new MapSnapshotNode(
|
||||
3,
|
||||
"classroom-101",
|
||||
"floor-1",
|
||||
"Класс 101",
|
||||
16,
|
||||
[new MapSnapshotItem("Парта", 16)],
|
||||
[],
|
||||
"Математика",
|
||||
"5А",
|
||||
["Иванова Ольга Михайловна", "Соколов Иван Петрович"]),
|
||||
]);
|
||||
var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)];
|
||||
var message = new ServerPresenceMessage(
|
||||
7,
|
||||
[
|
||||
new PresenceNode("classroom-101", 18, "Математика", "5А"),
|
||||
new PresenceNode("corridor-1", 4),
|
||||
],
|
||||
[
|
||||
new PresencePerson("f0.c0", "classroom-101", PresenceState.Here),
|
||||
new PresencePerson("f3.p1", "corridor-1", PresenceState.Walking),
|
||||
]);
|
||||
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
|
||||
|
||||
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
|
||||
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
|
||||
var length = ProtocolCodec.WritePresence(buffer, message);
|
||||
|
||||
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
|
||||
Assert.Equal(7, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
|
||||
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
|
||||
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
|
||||
|
||||
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||
Assert.Equal(message.SchoolId, read.SchoolId);
|
||||
Assert.Equal(2, read.Nodes.Count);
|
||||
Assert.Equal("classroom-101", read.Nodes[0].Id);
|
||||
Assert.Equal(18, read.Nodes[0].Count);
|
||||
Assert.Equal("Математика", read.Nodes[0].ActivitySubject);
|
||||
Assert.Equal("5А", read.Nodes[0].ActivityClass);
|
||||
Assert.Equal(["Иванова Ольга Михайловна", "Соколов Иван Петрович"], read.Nodes[0].Present);
|
||||
Assert.Equal("corridor-1", read.Nodes[1].Id);
|
||||
Assert.Equal(4, read.Nodes[1].Count);
|
||||
Assert.Equal("", read.Nodes[1].ActivitySubject);
|
||||
Assert.Equal(2, read.People.Count);
|
||||
Assert.Equal("f0.c0", read.People[0].Id);
|
||||
Assert.Equal("classroom-101", read.People[0].NodeId);
|
||||
Assert.Equal(PresenceState.Here, read.People[0].State);
|
||||
Assert.Equal(PresenceState.Walking, read.People[1].State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -287,7 +336,9 @@ public class ProtocolCodecTests
|
||||
{
|
||||
// Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize.
|
||||
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||
var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));
|
||||
var clock = ProtocolCodec.WriteClock(
|
||||
buffer,
|
||||
new ServerClockMessage(1, long.MaxValue, true, 4, SkipAllowed: true, SkipTargetUnixMs: long.MaxValue));
|
||||
|
||||
Assert.True(clock <= ProtocolCodec.MaxFrameSize);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user