Files
h-school/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
T
Leonid PershinandCursor 8e7ab46e79 Sample outdoor weather from the climate preset so people can freeze and the clock can show it.
Protocol v8 adds tenths of a °C and precipitation to the clock frame; warmth drains from insulation versus place temperature.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 03:49:40 +03:00

389 lines
14 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.
namespace HSchool.Protocol.Tests;
/// <summary>
/// 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
/// <c>src/HSchool.Client/src/net/protocol.ts</c> honest.
/// </summary>
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTripsAndIsThreeBytes()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleEnglish);
Span<byte> 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<byte> 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<byte> 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<byte> 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<byte> 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<byte> 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<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()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 20, 6);
Span<byte> 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<byte> 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<byte> 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<byte> 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<byte> 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.Equal(PresenceState.Walking, read.People[1].State);
}
[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<MapSnapshotNode> { 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<byte> 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<byte> 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<byte> 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<ProtocolException>(() => ProtocolCodec.ReadClock(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadOpenSchool(frame));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var buffer = new byte[2];
Assert.Throws<ProtocolException>(() =>
ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, 0, false, 1)));
}
}