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 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.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 { 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))); } }