namespace HSchool.Protocol.Tests;
///
/// The wire format is a contract with the browser client. Round-trips prove the C# side is
/// self-consistent; the explicit byte-layout tests are what keeps
/// src/HSchool.Client/src/net/protocol.ts honest.
///
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTripsAndIsThreeBytes()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleEnglish);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(3, length);
Assert.Equal((byte)MessageType.ClientHello, buffer[0]);
Assert.Equal(ProtocolConstants.Version, buffer[1]);
Assert.Equal(ProtocolConstants.LocaleEnglish, buffer[2]);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
}
[Fact]
public void Ping_RoundTripsAndIsNineBytes()
{
var message = new ClientPingMessage(1_700_000_000_123);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, message);
Assert.Equal(9, length);
Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
}
[Fact]
public void OpenSchool_RoundTripsAndIsFiveBytes()
{
var message = new ClientOpenSchoolMessage(0x01020304);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteOpenSchool(buffer, message);
Assert.Equal(5, length);
Assert.Equal(message, ProtocolCodec.ReadOpenSchool(buffer[..length]));
}
[Fact]
public void CloseSchool_IsASingleByte()
{
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteCloseSchool(buffer);
Assert.Equal(1, length);
Assert.Equal(MessageType.ClientCloseSchool, ProtocolCodec.PeekMessageType(buffer[..length]));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void SetRunning_RoundTripsAndIsTwoBytes(bool running)
{
var message = new ClientSetRunningMessage(running);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSetRunning(buffer, message);
Assert.Equal(2, length);
Assert.Equal(message, ProtocolCodec.ReadSetRunning(buffer[..length]));
}
[Theory]
[InlineData(0)]
[InlineData(4)]
public void SetSpeed_RoundTripsAndIsTwoBytes(byte speedIndex)
{
var message = new ClientSetSpeedMessage(speedIndex);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSetSpeed(buffer, message);
Assert.Equal(2, length);
Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length]));
}
[Fact]
public void SkipEmpty_IsASingleByte()
{
Span 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 DismissNotice_RoundTripsAndIsFiveBytes()
{
var message = new ClientDismissNoticeMessage(0x01020304);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteDismissNotice(buffer, message);
Assert.Equal(5, length);
Assert.Equal((byte)MessageType.ClientDismissNotice, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
Assert.Equal(message, ProtocolCodec.ReadDismissNotice(buffer[..length]));
}
[Fact]
public void Notice_RoundTripsAndMatchesByteLayout()
{
var message = new ServerNoticeMessage(0x0A0B0C0D, "DayStarted", NoticeSeverity.Info, Pause: false, TtlMs: 8000, PersonId: 0);
var size = ProtocolCodec.NoticeSize(message);
Span buffer = stackalloc byte[size];
var length = ProtocolCodec.WriteNotice(buffer, message);
Assert.Equal(size, length);
Assert.Equal((byte)MessageType.ServerNotice, buffer[0]);
Assert.Equal(new byte[] { 0x0D, 0x0C, 0x0B, 0x0A }, buffer[1..5].ToArray());
Assert.Equal((ushort)10, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer[5..7]));
Assert.Equal("DayStarted"u8.ToArray(), buffer[7..17].ToArray());
Assert.Equal(NoticeSeverity.Info, buffer[17]);
Assert.Equal(0, buffer[18]);
Assert.Equal(8000u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[19..23]));
Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[23..27]));
Assert.Equal(message, ProtocolCodec.ReadNotice(buffer[..length]));
}
[Fact]
public void Welcome_RoundTripsAndIsFourBytes()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 20, 6);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteWelcome(buffer, message);
Assert.Equal(4, length);
Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
}
[Fact]
public void Pong_RoundTripsAndIsThirteenBytes()
{
var message = new ServerPongMessage(5, 99);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePong(buffer, message);
Assert.Equal(13, length);
Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
}
[Fact]
public void Clock_RoundTripsAndIsTwentySevenBytes()
{
var message = new ServerClockMessage(
7,
1_333_432_800_000,
Running: true,
SpeedIndex: 2,
SkipAllowed: true,
SkipTargetUnixMs: 1_333_516_800_000,
TemperatureTenths: -50,
Precipitation: 2);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(buffer, message);
Assert.Equal(27, 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(-50, BitConverter.ToInt16(buffer[24..26]));
Assert.Equal(2, buffer[26]);
Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
}
[Fact]
public void Clock_WithoutSkip_WritesZeroTarget()
{
var message = new ServerClockMessage(1, 0, Running: false, SpeedIndex: 1);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(buffer, message);
var read = ProtocolCodec.ReadClock(buffer[..length]);
Assert.Equal(27, length);
Assert.False(read.SkipAllowed);
Assert.Equal(0, read.SkipTargetUnixMs);
Assert.Equal(0, read.TemperatureTenths);
Assert.Equal(PrecipitationKind.None, read.Precipitation);
}
[Fact]
public void SchoolGone_RoundTripsAndIsFiveBytes()
{
var message = new ServerSchoolGoneMessage(3);
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(buffer, message);
Assert.Equal(5, length);
Assert.Equal(message, ProtocolCodec.ReadSchoolGone(buffer[..length]));
}
[Fact]
public void MapSnapshot_RoundTripsAndWritesHeaderOffsets()
{
var message = new ServerMapSnapshotMessage(7, [
new MapSnapshotNode(0, "yard", "", "Двор", 0, [], []),
new MapSnapshotNode(
3,
"office",
"floor-1",
"Кабинет директора",
0,
[new MapSnapshotItem("Стул", 2)],
["Директор"]),
]);
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
Assert.Equal((byte)MessageType.ServerMapSnapshot, buffer[0]);
Assert.Equal(7, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
Assert.Equal(message.SchoolId, read.SchoolId);
Assert.Equal(2, read.Nodes.Count);
Assert.Equal("yard", read.Nodes[0].Id);
Assert.Equal("", read.Nodes[0].ParentId);
Assert.Equal("Двор", read.Nodes[0].Name);
Assert.Equal(0, read.Nodes[0].PupilSlots);
Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items);
Assert.Equal(["Директор"], read.Nodes[1].Positions);
}
[Fact]
public void Presence_RoundTripsAndWritesHeaderOffsets()
{
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.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("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.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId);
Assert.Equal(PresenceState.Walking, read.People[1].State);
Assert.Empty(read.People[1].TalkMemberIds);
}
[Fact]
public void Presence_PersonWithoutCircle_WritesEmptyMemberListAndEmptyTopic()
{
var message = new ServerPresenceMessage(
1,
[],
[new PresencePerson("f0.c0", "corridor-1", PresenceState.Here)]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(buffer, message);
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)0, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(7, 2)));
var offset = 9;
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "corridor-1");
Assert.Equal(PresenceState.Here, buffer[offset]);
offset += 1;
Assert.Equal(0, buffer[offset]);
offset += 1;
offset = AssertWireString(buffer, offset, "");
Assert.Equal(length, offset);
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId);
Assert.Equal(message.People[0].Id, read.People[0].Id);
Assert.Equal(message.People[0].NodeId, read.People[0].NodeId);
Assert.Equal(message.People[0].State, read.People[0].State);
}
[Fact]
public void Presence_PersonWithCircle_RoundTripsMemberIdsAndTopic()
{
var members = new[] { "f0.c0", "f0.c1" };
var message = new ServerPresenceMessage(
1,
[new PresenceNode("corridor-1", 2)],
[new PresencePerson("f0.c0", "corridor-1", PresenceState.Here, members, "TopicSport")]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(buffer, message);
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
var offset = 7;
offset = AssertWireString(buffer, offset, "corridor-1");
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
offset += 2;
Assert.Equal(0, buffer[offset]);
offset += 1;
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
offset += 2;
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "corridor-1");
Assert.Equal(PresenceState.Here, buffer[offset]);
offset += 1;
Assert.Equal(2, buffer[offset]);
offset += 1;
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "f0.c1");
offset = AssertWireString(buffer, offset, "TopicSport");
Assert.Equal(length, offset);
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds);
Assert.Equal("TopicSport", read.People[0].TalkTopicId);
Assert.Equal(message.SchoolId, read.SchoolId);
Assert.Equal(message.People[0].Id, read.People[0].Id);
}
[Fact]
public void Presence_DoesNotContainPersonDisplayName()
{
var message = new ServerPresenceMessage(
1,
[],
[new PresencePerson(
"f0.c0",
"corridor-1",
PresenceState.Here,
["f0.c0", "f0.c1"],
"TopicSport")]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
ProtocolCodec.WritePresence(buffer, message);
var text = System.Text.Encoding.UTF8.GetString(buffer);
Assert.DoesNotContain("Мария", text);
Assert.DoesNotContain("Иванова", text);
Assert.DoesNotContain("Маша", text);
Assert.Contains("f0.c0", text);
Assert.Contains("TopicSport", text);
}
private static int AssertWireString(byte[] buffer, int offset, string expected)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(expected);
Assert.Equal((ushort)bytes.Length, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
Assert.Equal(bytes, buffer.AsSpan(offset + 2, bytes.Length).ToArray());
return offset + 2 + bytes.Length;
}
[Fact]
public void MapSnapshot_WritesPupilSlotsAndStackedItemCount()
{
var message = new ServerMapSnapshotMessage(1, [
new MapSnapshotNode(
3,
"classroom-1a",
"floor-1",
"Класс 1A",
16,
[new MapSnapshotItem("Парта", 16)],
[]),
]);
var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)];
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
Assert.Equal(16, read.Nodes[0].PupilSlots);
Assert.Equal([new MapSnapshotItem("Парта", 16)], read.Nodes[0].Items);
}
[Fact]
public void MapSnapshotSize_IsExactlyWhatTheWriterProduces()
{
var message = new ServerMapSnapshotMessage(7, [
new MapSnapshotNode(0, "yard", "", "Двор", 0, [], []),
new MapSnapshotNode(2, "floor-1", "main", "Этаж 1", 0, [], []),
new MapSnapshotNode(
3,
"office",
"floor-1",
"Кабинет директора",
0,
[new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)],
["Директор"]),
]);
var size = ProtocolCodec.MapSnapshotSize(message);
var buffer = new byte[size];
Assert.Equal(size, ProtocolCodec.WriteMapSnapshot(buffer, message));
}
[Fact]
public void MapSnapshot_OfALargeSchoolSurvivesTheEightKilobyteLimit()
{
// A player who keeps clicking "Add room" in the create editor gets past 8 KiB somewhere
// around sixty furnished rooms. Sizing the buffer from the message is what keeps that
// school openable instead of killing its worker thread on the first snapshot.
var nodes = new List { new(0, "yard", "", "Двор", 0, [], []) };
for (var i = 1; i <= 200; i++)
{
nodes.Add(new MapSnapshotNode(
3,
$"principals-office-{i}",
"floor-1",
"Кабинет директора",
0,
[new MapSnapshotItem("Кресло директора", 1), new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)],
["Директор"]));
}
var message = new ServerMapSnapshotMessage(1, nodes);
var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)];
Assert.True(buffer.Length > ProtocolConstants.MaxMessageSize);
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
Assert.Equal(nodes.Count, read.Nodes.Count);
Assert.Equal("principals-office-200", read.Nodes[^1].Id);
Assert.Equal(
[new MapSnapshotItem("Кресло директора", 1), new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)],
read.Nodes[^1].Items);
}
[Fact]
public void Numbers_AreLittleEndian()
{
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(0x01020304));
Assert.Equal((byte)MessageType.ClientOpenSchool, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
}
[Fact]
public void MaxFrameSize_FitsEveryFixedSizeMessage()
{
// Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize.
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var clock = ProtocolCodec.WriteClock(
buffer,
new ServerClockMessage(1, long.MaxValue, true, 4, SkipAllowed: true, SkipTargetUnixMs: long.MaxValue));
Assert.True(clock <= ProtocolCodec.MaxFrameSize);
}
[Fact]
public void PeekMessageType_ReadsTheFirstByte()
{
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
}
[Fact]
public void TruncatedFrame_Throws()
{
byte[] frame = [(byte)MessageType.ServerClock, 1, 2];
Assert.Throws(() => ProtocolCodec.ReadClock(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws(() => ProtocolCodec.ReadOpenSchool(frame));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var buffer = new byte[2];
Assert.Throws(() =>
ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, 0, false, 1)));
}
}