From 314e19bd043473137db56c5f4c143b622e06d123 Mon Sep 17 00:00:00 2001 From: Tomi Date: Fri, 8 May 2026 21:36:13 +0800 Subject: [PATCH 1/4] chore: unblock local build for MVP development Five small changes to get the Phase 0 skeleton building cleanly on a developer machine that has both nuget.org and a corporate feed configured, and to stop opinionated analyzer rules from blocking walking-skeleton work: - NuGet.config: + nuget.org only, fixes NU1507 caused by Central Package Management with multiple inherited sources. - Directory.Build.props: AnalysisLevel latest-recommended -> latest-default during MVP. TODO marker to tighten back before Phase 5 / first NuGet release. - samples/Geode.Client.Sample/Program.cs: add missing 'using Microsoft.Extensions.DependencyInjection;' so GetRequiredService resolves (it is an extension method on IServiceProvider in that ns). - tests/.../GeodeFixture.cs: SuppressMessage CA1711 on GeodeCollection. xUnit's [CollectionDefinition(nameof(...))] convention uses the class name as the collection identifier; renaming would break the call sites. - .gitignore: ignore .cr/ (Visual Studio extension cache). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 ++ Directory.Build.props | 6 +++- NuGet.config | 30 +++++++++++++++++++ samples/Geode.Client.Sample/Program.cs | 1 + .../GeodeFixture.cs | 5 ++++ 5 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 NuGet.config diff --git a/.gitignore b/.gitignore index 9bfe57b..5950941 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ Thumbs.db ## Claude Code (per-machine settings, transcripts, etc.) .claude/ + +## Visual Studio extension cache (per-user, e.g. CodeRush / similar) +.cr/ diff --git a/Directory.Build.props b/Directory.Build.props index a475f99..25470dd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,11 @@ enable enable true - latest-recommended + + latest-default true diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..63887df --- /dev/null +++ b/NuGet.config @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/samples/Geode.Client.Sample/Program.cs b/samples/Geode.Client.Sample/Program.cs index 1cbb68c..62ab482 100644 --- a/samples/Geode.Client.Sample/Program.cs +++ b/samples/Geode.Client.Sample/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/tests/Geode.Client.IntegrationTests/GeodeFixture.cs b/tests/Geode.Client.IntegrationTests/GeodeFixture.cs index 0ac58c9..be23667 100644 --- a/tests/Geode.Client.IntegrationTests/GeodeFixture.cs +++ b/tests/Geode.Client.IntegrationTests/GeodeFixture.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Xunit; @@ -51,6 +52,10 @@ public async ValueTask DisposeAsync() } [CollectionDefinition(nameof(GeodeCollection))] +[SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "xUnit [CollectionDefinition] uses the class name as the collection identifier; renaming away from the 'Collection' suffix would break the [Collection(nameof(GeodeCollection))] usage convention.")] public sealed class GeodeCollection : ICollectionFixture { } From 3d9a0e423f694fc0a7bbfb423560200af7fd335f Mon Sep 17 00:00:00 2001 From: Tomi Date: Fri, 8 May 2026 21:37:34 +0800 Subject: [PATCH 2/4] chore: organise solution with src / test / sample folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual Studio 2022 added these on first open: - VisualStudioVersion / MinimumVisualStudioVersion stamps. - 'src', 'test', 'sample' solution folders nesting the four projects for a tidier Solution Explorer view. - Solution items entries re-sorted alphabetically. Pure IDE metadata — dotnet CLI ignores solution folders, so no effect on build, restore, or CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- geode-dotnet.sln | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/geode-dotnet.sln b/geode-dotnet.sln index b57b530..4ff7471 100644 --- a/geode-dotnet.sln +++ b/geode-dotnet.sln @@ -1,5 +1,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 +VisualStudioVersion = 17.14.37216.2 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geode.Client", "src\Geode.Client\Geode.Client.csproj", "{11111111-1111-1111-1111-111111111111}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geode.Client.Tests", "tests\Geode.Client.Tests\Geode.Client.Tests.csproj", "{22222222-2222-2222-2222-222222222222}" @@ -10,15 +12,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geode.Client.Sample", "samp EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution items", "solution items", "{55555555-5555-5555-5555-555555555555}" ProjectSection(SolutionItems) = preProject - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props .editorconfig = .editorconfig .gitignore = .gitignore CLAUDE.md = CLAUDE.md - README.md = README.md + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props docker-compose.yml = docker-compose.yml + README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{6FB7AB5D-1656-469E-B35C-566371134193}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{5CF6504F-03FC-4A37-A2A9-32A9547B6D5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,4 +50,13 @@ Global {44444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU {44444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {11111111-1111-1111-1111-111111111111} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {22222222-2222-2222-2222-222222222222} = {6FB7AB5D-1656-469E-B35C-566371134193} + {33333333-3333-3333-3333-333333333333} = {6FB7AB5D-1656-469E-B35C-566371134193} + {44444444-4444-4444-4444-444444444444} = {5CF6504F-03FC-4A37-A2A9-32A9547B6D5A} + EndGlobalSection EndGlobal From 5fba637644f7a581655edf32c489982537a54d09 Mon Sep 17 00:00:00 2001 From: Tomi Date: Fri, 8 May 2026 22:22:13 +0800 Subject: [PATCH 3/4] feat(phase-1): frame-codec primitives and TcrPart record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation layer for the Phase 1 frame codec. - MessageType: full enum mirroring cppcache TcrMessage.hpp::MsgType (99 values incl. -2/-1 sentinels and the 4 numeric gaps preserved). Naming: SCREAMING_SNAKE_CASE -> PascalCase, _MSG_TYPE / _TYPE redundant suffixes dropped (e.g. EXECUTECQ_MSG_TYPE -> ExecuteCq). - BigEndianBinaryWriter: sequential big-endian writer over an internal MemoryStream. C# counterpart of cppcache DataOutput. Phase 1 implements WriteByte / WriteBool / WriteInt32 / WriteInt64 / WriteBytesOnly / ToArray / Length; the rest (WriteSByte, WriteInt16, WriteUInt16/32/64, WriteFloat, WriteDouble, WriteBytes, WriteArrayLen, WriteJavaModifiedUtf8, WriteUtf16Huge) are prototype stubs that throw NotImplementedException so the API surface is stable across phases. - BigEndianBinaryReader: sequential big-endian reader over a ReadOnlyMemory. Symmetric stub set. ReadBytesOnly returns a zero-copy slice. EndOfStreamException on overrun. - TcrPart: record (i32 length + u8 isObject + raw payload) modelling the inline 3-step encoding used by every cppcache TcrMessage::write*Part helper. Equals / GetHashCode overridden so equality is byte-content based (record default would be reference-based on ReadOnlyMemory). Buffer-based design (reader takes ReadOnlyMemory, writer owns internal buffer) committed as the long-term shape — matches modern .NET codec patterns (System.Text.Json, MessagePack-CSharp, Pipelines) where async lives at the I/O boundary and the codec itself is sync over Memory/Span. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Protocol/BigEndianBinaryReader.cs | 169 ++++++++++++++++++ .../Protocol/BigEndianBinaryWriter.cs | 131 ++++++++++++++ src/Geode.Client/Protocol/MessageType.cs | 147 +++++++++++++++ src/Geode.Client/Protocol/TcrPart.cs | 61 +++++++ 4 files changed, 508 insertions(+) create mode 100644 src/Geode.Client/Protocol/BigEndianBinaryReader.cs create mode 100644 src/Geode.Client/Protocol/BigEndianBinaryWriter.cs create mode 100644 src/Geode.Client/Protocol/MessageType.cs create mode 100644 src/Geode.Client/Protocol/TcrPart.cs diff --git a/src/Geode.Client/Protocol/BigEndianBinaryReader.cs b/src/Geode.Client/Protocol/BigEndianBinaryReader.cs new file mode 100644 index 0000000..047ad7b --- /dev/null +++ b/src/Geode.Client/Protocol/BigEndianBinaryReader.cs @@ -0,0 +1,169 @@ +using System.Buffers.Binary; + +namespace Geode.Client.Protocol; + +/// +/// Sequential big-endian reader over an in-memory buffer. +/// C# counterpart of cppcache DataInput / java.io.DataInput: +/// every multi-byte primitive is decoded from network byte order, matching +/// what a Geode server sends. +/// +/// +/// Not thread-safe. Single consumer, read-only. Throws +/// when a read would go past the end of +/// the buffer. +/// +/// BCL's System.IO.BinaryReader is little-endian, hence the explicit +/// "BigEndian" prefix on this type — do not confuse the two. +/// +/// Methods marked "prototype" throw +/// and will be filled in as later phases need them. +/// +internal sealed class BigEndianBinaryReader +{ + private readonly ReadOnlyMemory _buffer; + private int _position; + + public BigEndianBinaryReader(ReadOnlyMemory buffer) + { + _buffer = buffer; + } + + /// Current byte offset within the buffer. + public int Position => _position; + + /// Total length of the underlying buffer. + public int Length => _buffer.Length; + + /// Bytes left to read from the current . + public int Remaining => _buffer.Length - _position; + + // ====================================================================== + // Implemented (Phase 1 — frame codec) + // ====================================================================== + + /// Read a single unsigned byte (u8). + public byte ReadByte() + { + EnsureAvailable(sizeof(byte)); + var value = _buffer.Span[_position]; + _position += sizeof(byte); + return value; + } + + /// Read a single byte and interpret it as a boolean (0 = false, anything else = true). + public bool ReadBool() => ReadByte() != 0; + + /// Read a 32-bit signed integer in big-endian byte order. + public int ReadInt32() + { + EnsureAvailable(sizeof(int)); + var value = BinaryPrimitives.ReadInt32BigEndian(_buffer.Span.Slice(_position, sizeof(int))); + _position += sizeof(int); + return value; + } + + /// Read a 64-bit signed integer in big-endian byte order. + public long ReadInt64() + { + EnsureAvailable(sizeof(long)); + var value = BinaryPrimitives.ReadInt64BigEndian(_buffer.Span.Slice(_position, sizeof(long))); + _position += sizeof(long); + return value; + } + + /// + /// Read a raw byte sequence of the given length. Returns a zero-copy slice + /// of the underlying buffer; do not retain it past the buffer's lifetime. + /// Mirrors cppcache DataInput::readBytesOnly. + /// + public ReadOnlyMemory ReadBytesOnly(int count) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Length must be non-negative."); + EnsureAvailable(count); + var slice = _buffer.Slice(_position, count); + _position += count; + return slice; + } + + // ====================================================================== + // Prototype — additional primitives, fill in when first needed + // ====================================================================== + + /// Read a signed 8-bit integer (i8). + public sbyte ReadSByte() => + throw new NotImplementedException("Phase 2 handshake."); + + /// Read a 16-bit signed integer in big-endian byte order. + public short ReadInt16() => + throw new NotImplementedException("Phase 2 handshake / Phase 4 typed values."); + + /// Read a 16-bit unsigned integer in big-endian byte order. Mirrors cppcache readChar. + public ushort ReadUInt16() => + throw new NotImplementedException("Phase 4 typed values."); + + /// Read a 32-bit unsigned integer in big-endian byte order. + public uint ReadUInt32() => + throw new NotImplementedException("Phase 4 typed values."); + + /// Read a 64-bit unsigned integer in big-endian byte order. + public ulong ReadUInt64() => + throw new NotImplementedException("Phase 4 typed values."); + + /// Read an IEEE 754 single-precision float in big-endian byte order. + public float ReadFloat() => + throw new NotImplementedException("Phase 4 typed values."); + + /// Read an IEEE 754 double-precision float in big-endian byte order. + public double ReadDouble() => + throw new NotImplementedException("Phase 4 typed values."); + + /// + /// Read a length-prefixed byte sequence: i32 length followed by the bytes. + /// Returns null if the length sentinel is -1. + /// Mirrors cppcache DataInput::readBytes. + /// + public byte[]? ReadBytes() => + throw new NotImplementedException("Phase 3 Put/Get value parts."); + + /// + /// Read Geode's variable-length array length encoding (1, 2, or 4 bytes). + /// Mirrors cppcache DataInput::readArrayLen. + /// + public int ReadArrayLen() => + throw new NotImplementedException("Phase 4 collection-bearing parts."); + + /// + /// Read a Java modified UTF-8 string with a u16 byte-length prefix. + /// Mirrors cppcache DataInput::readUTF. + /// + /// + /// Modified UTF-8 differs from standard UTF-8: 0xC0 0x80 decodes + /// to \0, and supplementary codepoints arrive as a surrogate pair + /// of two 3-byte sequences (6 bytes total) rather than the 4-byte UTF-8 + /// form. + /// + public string? ReadJavaModifiedUtf8() => + throw new NotImplementedException("Phase 4 string values."); + + /// + /// Read a UTF-16 big-endian string with an i32 byte-length prefix. + /// Mirrors cppcache DataInput::readUtf16Huge. + /// + public string? ReadUtf16Huge() => + throw new NotImplementedException("Phase 4 large string values."); + + // ====================================================================== + // Internal helpers + // ====================================================================== + + private void EnsureAvailable(int needed) + { + if (_position + needed > _buffer.Length) + { + throw new EndOfStreamException( + $"Tried to read {needed} byte(s) at position {_position}, but only {_buffer.Length - _position} remain."); + } + } +} diff --git a/src/Geode.Client/Protocol/BigEndianBinaryWriter.cs b/src/Geode.Client/Protocol/BigEndianBinaryWriter.cs new file mode 100644 index 0000000..0835c47 --- /dev/null +++ b/src/Geode.Client/Protocol/BigEndianBinaryWriter.cs @@ -0,0 +1,131 @@ +using System.Buffers.Binary; + +namespace Geode.Client.Protocol; + +/// +/// Sequential big-endian writer over an in-memory buffer. +/// C# counterpart of cppcache DataOutput / java.io.DataOutput: +/// every multi-byte primitive is written in network byte order so the bytes +/// match what a Geode server expects. +/// +/// +/// Not thread-safe. Single producer, write-only. Call +/// once you are done to get the encoded payload. +/// +/// BCL's System.IO.BinaryWriter is little-endian, hence the explicit +/// "BigEndian" prefix on this type — do not confuse the two. +/// +/// Methods marked "prototype" throw +/// and will be filled in as later phases need them. +/// +internal sealed class BigEndianBinaryWriter +{ + private readonly MemoryStream _buffer = new(); + + /// Bytes written so far. + public int Length => (int)_buffer.Length; + + // ====================================================================== + // Implemented (Phase 1 — frame codec) + // ====================================================================== + + /// Write a single unsigned byte (u8). + public void WriteByte(byte value) => _buffer.WriteByte(value); + + /// Write a boolean as a single byte (1 = true, 0 = false). + public void WriteBool(bool value) => _buffer.WriteByte(value ? (byte)1 : (byte)0); + + /// Write a 32-bit signed integer in big-endian byte order. + public void WriteInt32(int value) + { + Span tmp = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32BigEndian(tmp, value); + _buffer.Write(tmp); + } + + /// Write a 64-bit signed integer in big-endian byte order. + public void WriteInt64(long value) + { + Span tmp = stackalloc byte[sizeof(long)]; + BinaryPrimitives.WriteInt64BigEndian(tmp, value); + _buffer.Write(tmp); + } + + /// + /// Write a raw byte sequence verbatim (no length prefix, no transformation). + /// Mirrors cppcache DataOutput::writeBytesOnly. + /// + public void WriteBytesOnly(ReadOnlySpan bytes) => _buffer.Write(bytes); + + /// Return a copy of all bytes written so far. + public byte[] ToArray() => _buffer.ToArray(); + + // ====================================================================== + // Prototype — additional primitives, fill in when first needed + // ====================================================================== + + /// Write a signed 8-bit integer (i8). + public void WriteSByte(sbyte value) => + throw new NotImplementedException("Phase 2 handshake."); + + /// Write a 16-bit signed integer in big-endian byte order. + public void WriteInt16(short value) => + throw new NotImplementedException("Phase 2 handshake / Phase 4 typed values."); + + /// Write a 16-bit unsigned integer in big-endian byte order. Mirrors cppcache writeChar. + public void WriteUInt16(ushort value) => + throw new NotImplementedException("Phase 4 typed values."); + + /// Write a 32-bit unsigned integer in big-endian byte order. + public void WriteUInt32(uint value) => + throw new NotImplementedException("Phase 4 typed values."); + + /// Write a 64-bit unsigned integer in big-endian byte order. + public void WriteUInt64(ulong value) => + throw new NotImplementedException("Phase 4 typed values."); + + /// Write an IEEE 754 single-precision float in big-endian byte order. + public void WriteFloat(float value) => + throw new NotImplementedException("Phase 4 typed values."); + + /// Write an IEEE 754 double-precision float in big-endian byte order. + public void WriteDouble(double value) => + throw new NotImplementedException("Phase 4 typed values."); + + /// + /// Write a length-prefixed byte sequence: i32 length followed by the bytes, + /// or i32 -1 if is null. + /// Mirrors cppcache DataOutput::writeBytes. + /// + public void WriteBytes(byte[]? bytes) => + throw new NotImplementedException("Phase 3 Put/Get value parts."); + + /// + /// Write Geode's variable-length array length encoding (1, 2, or 4 bytes + /// depending on magnitude). Mirrors cppcache DataOutput::writeArrayLen. + /// + public void WriteArrayLen(int length) => + throw new NotImplementedException("Phase 4 collection-bearing parts."); + + /// + /// Write a string in Java modified UTF-8 with a u16 byte-length prefix. + /// Mirrors cppcache DataOutput::writeUTF / writeJavaModifiedUtf8. + /// + /// + /// Modified UTF-8 differs from standard UTF-8 in two places: \0 is + /// encoded as the two bytes 0xC0 0x80 (never a single zero byte), + /// and characters above U+FFFF are encoded as a surrogate pair, each + /// surrogate written as a 3-byte sequence (so a single supplementary + /// codepoint takes 6 bytes, not 4 as in standard UTF-8). + /// + public void WriteJavaModifiedUtf8(string? value) => + throw new NotImplementedException("Phase 4 string values."); + + /// + /// Write a string as UTF-16 big-endian with an i32 byte-length prefix. + /// Used for strings whose modified-UTF-8 length would exceed 65535 bytes. + /// Mirrors cppcache DataOutput::writeUtf16Huge. + /// + public void WriteUtf16Huge(string? value) => + throw new NotImplementedException("Phase 4 large string values."); +} diff --git a/src/Geode.Client/Protocol/MessageType.cs b/src/Geode.Client/Protocol/MessageType.cs new file mode 100644 index 0000000..bf7b876 --- /dev/null +++ b/src/Geode.Client/Protocol/MessageType.cs @@ -0,0 +1,147 @@ +namespace Geode.Client.Protocol; + +/// +/// TCR message-type identifier (i32 big-endian on the wire). +/// Mirrors enum MsgType in +/// cppcache/src/TcrMessage.hpp (apache/geode-native). +/// +/// +/// Negative-valued entries (, +/// ) are sentinels used by the C++ +/// client internally and never appear on the wire. We keep them so the +/// numeric-to-name mapping is exhaustive when debugging. +/// Numeric gaps (57, 95, 101, 102, 104) are preserved as-is from the +/// upstream enum. +/// +internal enum MessageType +{ + // --- sentinels (not on the wire) --- + NotPublicApiWithTimeout = -2, + Invalid = -1, + + // --- core CRUD + lifecycle --- + Request = 0, // GET + Response = 1, // reply to Request + Exception = 2, // server-side error + RequestDataError = 3, + DataNotFoundError = 4, // not in use + Ping = 5, + Reply = 6, // generic ack + Put = 7, + PutDataError = 8, + Destroy = 9, // remove single key + DestroyDataError = 10, + DestroyRegion = 11, + DestroyRegionDataError = 12, + ClientNotification = 13, + UpdateClientNotification = 14, + LocalInvalidate = 15, + LocalDestroy = 16, + LocalDestroyRegion = 17, + CloseConnection = 18, // graceful disconnect + ProcessBatch = 19, + RegisterInterest = 20, + RegisterInterestDataError = 21, + UnregisterInterest = 22, + UnregisterInterestDataError = 23, + RegisterInterestList = 24, + UnregisterInterestList = 25, + UnknownMessageTypeError = 26, + LocalCreate = 27, + LocalUpdate = 28, + CreateRegion = 29, + CreateRegionDataError = 30, + MakePrimary = 31, + ResponseFromPrimary = 32, + ResponseFromSecondary = 33, + Query = 34, // OQL + QueryDataError = 35, + ClearRegion = 36, + ClearRegionDataError = 37, + ContainsKey = 38, + ContainsKeyDataError = 39, + KeySet = 40, + KeySetDataError = 41, + + // --- continuous queries (CQ) --- + ExecuteCq = 42, + ExecuteCqWithIr = 43, + StopCq = 44, + CloseCq = 45, + CloseClientCqs = 46, + CqDataError = 47, + GetCqStats = 48, + MonitorCq = 49, + CqException = 50, + + // --- registration / lifecycle (continued) --- + RegisterInstantiators = 51, + PeriodicAck = 52, + ClientReady = 53, + ClientMarker = 54, + InvalidateRegion = 55, + PutAll = 56, // bulk PUT + // 57 — not assigned upstream + GetAllDataError = 58, + + // --- function execution --- + ExecuteRegionFunction = 59, + ExecuteRegionFunctionResult = 60, + ExecuteRegionFunctionError = 61, + ExecuteFunction = 62, + ExecuteFunctionResult = 63, + ExecuteFunctionError = 64, + + // --- client interest / metadata --- + ClientRegisterInterest = 65, + ClientUnregisterInterest = 66, + RegisterDataSerializers = 67, + RequestEventValue = 68, + RequestEventValueError = 69, + PutDeltaError = 70, + GetClientPrMetadata = 71, + ResponseClientPrMetadata = 72, + GetClientPartitionAttributes = 73, + ResponseClientPartitionAttributes = 74, + GetClientPrMetadataError = 75, + GetClientPartitionAttributesError = 76, + + // --- auth --- + UserCredentialMessage = 77, + RemoveUserAuth = 78, + + ExecuteRegionFunctionSingleHop = 79, + QueryWithParameters = 80, + Size = 81, + SizeError = 82, + Invalidate = 83, + InvalidateError = 84, + + // --- transactions --- + Commit = 85, + CommitError = 86, + Rollback = 87, + TxFailover = 88, + GetEntry = 89, + TxSynchronization = 90, + GetFunctionAttributes = 91, + + // --- PDX --- + GetPdxTypeById = 92, + GetPdxIdForType = 93, + AddPdxType = 94, + // 95 — not assigned upstream + AddPdxEnum = 96, + GetPdxIdForEnum = 97, + GetPdxEnumById = 98, + + ServerToClientPing = 99, // server-initiated keepalive + GetAll70 = 100, // bulk GET (Geode 7.0+ wire) + // 101, 102, 104 — not assigned upstream + TombstoneOperation = 103, + GetDurableCqs = 105, + GetDurableCqsDataError = 106, + GetAllWithCallback = 107, + PutAllWithCallback = 108, + RemoveAll = 109, +} diff --git a/src/Geode.Client/Protocol/TcrPart.cs b/src/Geode.Client/Protocol/TcrPart.cs new file mode 100644 index 0000000..b57a595 --- /dev/null +++ b/src/Geode.Client/Protocol/TcrPart.cs @@ -0,0 +1,61 @@ +namespace Geode.Client.Protocol; + +/// +/// One TCR message Part on the wire: i32 length + u8 IsObject +/// + raw payload bytes. Wire-format building block only; semantics of the +/// payload (typed value, region name, serialised object, etc.) live in +/// higher layers. +/// +/// +/// Mirrors the inline 3-step encoding used throughout +/// cppcache/src/TcrMessage.cpp (writeBytePart, +/// writeIntPart, writeRegionPart, ...): every typed helper +/// there writes i32 length + i8 isObject + payload. +/// +/// Equality is content-based: two values with the +/// same flag and the same payload bytes compare +/// equal regardless of which underlying buffer they slice into. +/// +internal sealed record TcrPart(bool IsObject, ReadOnlyMemory Payload) +{ + /// Serialise this Part onto . + public void Encode(BigEndianBinaryWriter writer) + { + writer.WriteInt32(Payload.Length); + writer.WriteBool(IsObject); + writer.WriteBytesOnly(Payload.Span); + } + + /// Read one Part from . + /// + /// The decoded length is negative. + /// + /// + /// The reader does not contain enough bytes for the encoded length. + /// + public static TcrPart Decode(BigEndianBinaryReader reader) + { + var length = reader.ReadInt32(); + if (length < 0) + { + throw new FormatException( + $"TcrPart length must be non-negative, got {length}."); + } + var isObject = reader.ReadBool(); + var payload = reader.ReadBytesOnly(length); + return new TcrPart(isObject, payload); + } + + public bool Equals(TcrPart? other) => + other is not null + && IsObject == other.IsObject + && Payload.Span.SequenceEqual(other.Payload.Span); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(IsObject); + hash.AddBytes(Payload.Span); + return hash.ToHashCode(); + } +} From 1fc394258a7e644bc780ec895f1fae8c0e23ebc8 Mon Sep 17 00:00:00 2001 From: Tomi Date: Fri, 8 May 2026 22:44:34 +0800 Subject: [PATCH 4/4] feat(phase-1): TcrMessage + frame-codec tests; drop FluentAssertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: - TcrMessage record (header + Parts) with two-pass Encode and strict Decode validation. Mirrors cppcache TcrMessage::writeHeader / handleByteArrayResponse / writeMessageLength. - Custom Equals/GetHashCode so Parts list compares element-wise (record default would be reference equality on the list). - Drop _Phase1Placeholder.cs now that the real Protocol/ files exist. Tests (xUnit native Assert, no FluentAssertions): - Protocol/BigEndianBinaryWriterTests — 8 facts: primitives, concat, length tracking. - Protocol/BigEndianBinaryReaderTests — 9 facts: primitives, zero-copy slice (proven by mutating source), bounds, position tracking. - Protocol/TcrPartTests — 7 facts: round-trip (simple / empty / isObject), validation, content-based equality. - Protocol/TcrMessageTests — 10 facts: round-trip, Ping and Put-with-byte-part byte fixtures derived from cppcache wire format, malformed-frame validation, element-wise Parts equality. FluentAssertions removed: - v8.x switched to a custom (non-OSI) Xceed license; rather than audit the new terms for our Apache-2.0 use case, drop the dependency entirely. xUnit native Assert.* covers everything we used. - Existing Phase 0 tests (SmokeTests, GeodeContainerSmokeTests) also converted from .Should() to Assert.*, so the codebase has zero FA references. - Removed from Directory.Packages.props and from both test csprojs. Routine dependency bumps (accepted while VS auto-updated them): - Microsoft.NET.Test.Sdk 17.12.0 -> 18.5.1 - xunit.v3 1.0.0 -> 3.2.2 - xunit.runner.visualstudio 3.0.0 -> 3.1.5 - coverlet.collector 6.0.2 -> 10.0.0 Geode.Client.Tests.csproj also picked up all + on xunit.runner.visualstudio and coverlet.collector — the standard NuGet pattern for dev-only packages, kept as-is. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Packages.props | 16 +- src/Geode.Client/Protocol/TcrMessage.cs | 128 ++++++++++ .../Protocol/_Phase1Placeholder.cs | 9 - .../Geode.Client.IntegrationTests.csproj | 1 - .../GeodeContainerSmokeTests.cs | 5 +- .../Geode.Client.Tests.csproj | 11 +- .../Protocol/BigEndianBinaryReaderTests.cs | 93 ++++++++ .../Protocol/BigEndianBinaryWriterTests.cs | 89 +++++++ .../Protocol/TcrMessageTests.cs | 220 ++++++++++++++++++ .../Protocol/TcrPartTests.cs | 84 +++++++ tests/Geode.Client.Tests/SmokeTests.cs | 3 +- 11 files changed, 630 insertions(+), 29 deletions(-) create mode 100644 src/Geode.Client/Protocol/TcrMessage.cs delete mode 100644 src/Geode.Client/Protocol/_Phase1Placeholder.cs create mode 100644 tests/Geode.Client.Tests/Protocol/BigEndianBinaryReaderTests.cs create mode 100644 tests/Geode.Client.Tests/Protocol/BigEndianBinaryWriterTests.cs create mode 100644 tests/Geode.Client.Tests/Protocol/TcrMessageTests.cs create mode 100644 tests/Geode.Client.Tests/Protocol/TcrPartTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f439383..99235b9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,6 @@ true true - @@ -13,24 +12,19 @@ - - - - - - - + + + + - - - + \ No newline at end of file diff --git a/src/Geode.Client/Protocol/TcrMessage.cs b/src/Geode.Client/Protocol/TcrMessage.cs new file mode 100644 index 0000000..ccf8029 --- /dev/null +++ b/src/Geode.Client/Protocol/TcrMessage.cs @@ -0,0 +1,128 @@ +namespace Geode.Client.Protocol; + +/// +/// One TCR (Thin-Client Request / Response) message frame on the wire. +/// +/// +/// Wire layout (all multi-byte fields big-endian): +/// +/// offset 0 : i32 MessageType +/// offset 4 : i32 MessageLength // bytes occupied by the Parts (header excluded) +/// offset 8 : i32 NumParts +/// offset 12 : i32 TransactionId +/// offset 16 : u8 EarlyAck // bit-flags (security, retry, ...) +/// offset 17 : Part[NumParts] // each Part = i32 len + u8 isObject + payload +/// +/// +/// Mirrors TcrMessage::writeHeader / +/// TcrMessage::handleByteArrayResponse / +/// TcrMessage::writeMessageLength in +/// cppcache/src/TcrMessage.cpp. +/// +/// +/// Cppcache writes a dummy 0 for MessageLength at encode time +/// and patches offset 4 once the parts are written. We use a two-pass encode +/// instead (parts first to learn their byte length, then header + parts) — +/// simpler given our writer does not expose a seek/patch API. The output +/// bytes are identical. +/// +/// +internal sealed record TcrMessage( + MessageType MessageType, + int TransactionId, + byte EarlyAck, + IReadOnlyList Parts) +{ + /// Fixed-size frame header: four i32 fields + one u8. + public const int HeaderLength = 17; + + /// Encode this message to a freshly-allocated byte array. + public byte[] Encode() + { + // Pass 1: encode parts to learn their total byte length. + var partsWriter = new BigEndianBinaryWriter(); + foreach (var part in Parts) + { + part.Encode(partsWriter); + } + var partsBytes = partsWriter.ToArray(); + + // Pass 2: write header followed by the parts payload. + var w = new BigEndianBinaryWriter(); + w.WriteInt32((int)MessageType); + w.WriteInt32(partsBytes.Length); // MessageLength = bytes occupied by Parts + w.WriteInt32(Parts.Count); + w.WriteInt32(TransactionId); + w.WriteByte(EarlyAck); + w.WriteBytesOnly(partsBytes); + return w.ToArray(); + } + + /// Decode one message from . + /// + /// The frame is malformed (negative NumParts or + /// MessageLength disagrees with the bytes occupied by the parts). + /// + /// + /// The buffer is shorter than the frame claims. + /// + public static TcrMessage Decode(ReadOnlyMemory bytes) + { + var reader = new BigEndianBinaryReader(bytes); + + var messageType = (MessageType)reader.ReadInt32(); + var messageLength = reader.ReadInt32(); + var numParts = reader.ReadInt32(); + var transactionId = reader.ReadInt32(); + var earlyAck = reader.ReadByte(); + + if (numParts < 0) + { + throw new FormatException( + $"NumParts must be non-negative, got {numParts}."); + } + + var parts = new List(numParts); + var partsStart = reader.Position; + for (var i = 0; i < numParts; i++) + { + parts.Add(TcrPart.Decode(reader)); + } + var partsConsumed = reader.Position - partsStart; + + if (partsConsumed != messageLength) + { + throw new FormatException( + $"Header MessageLength={messageLength} does not match the {partsConsumed} bytes consumed by the parts."); + } + + return new TcrMessage(messageType, transactionId, earlyAck, parts); + } + + public bool Equals(TcrMessage? other) + { + if (other is null) return false; + if (MessageType != other.MessageType) return false; + if (TransactionId != other.TransactionId) return false; + if (EarlyAck != other.EarlyAck) return false; + if (Parts.Count != other.Parts.Count) return false; + for (var i = 0; i < Parts.Count; i++) + { + if (!Parts[i].Equals(other.Parts[i])) return false; + } + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(MessageType); + hash.Add(TransactionId); + hash.Add(EarlyAck); + foreach (var part in Parts) + { + hash.Add(part); + } + return hash.ToHashCode(); + } +} diff --git a/src/Geode.Client/Protocol/_Phase1Placeholder.cs b/src/Geode.Client/Protocol/_Phase1Placeholder.cs deleted file mode 100644 index cf59142..0000000 --- a/src/Geode.Client/Protocol/_Phase1Placeholder.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Geode.Client.Protocol; - -// Phase 1 lives here: -// - BigEndianBinaryReader / BigEndianBinaryWriter -// - TcrPart, TcrMessage records -// - IFrameCodec, TcrFrameCodec -// -// See CLAUDE.md "Protocol 三層架構" for the wire format spec extracted from -// cppcache/src/TcrMessage.{cpp,hpp}. diff --git a/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj b/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj index 2e2c2b0..937d63c 100644 --- a/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj +++ b/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj @@ -11,7 +11,6 @@ - diff --git a/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs b/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs index 1234784..85b2a75 100644 --- a/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs +++ b/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs @@ -1,4 +1,3 @@ -using FluentAssertions; using Xunit; namespace Geode.Client.IntegrationTests; @@ -11,7 +10,7 @@ public void ContainerStartsAndExposesEndpoints() { // Phase 0: only verifies Testcontainers + Geode image work in this env. // Replace once Phase 2 (Ping) brings real client connectivity. - fx.LocatorPort.Should().BeGreaterThan(0); - fx.ServerPort.Should().BeGreaterThan(0); + Assert.True(fx.LocatorPort > 0); + Assert.True(fx.ServerPort > 0); } } diff --git a/tests/Geode.Client.Tests/Geode.Client.Tests.csproj b/tests/Geode.Client.Tests/Geode.Client.Tests.csproj index 5af277b..7e8f6f8 100644 --- a/tests/Geode.Client.Tests/Geode.Client.Tests.csproj +++ b/tests/Geode.Client.Tests/Geode.Client.Tests.csproj @@ -10,9 +10,14 @@ - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Geode.Client.Tests/Protocol/BigEndianBinaryReaderTests.cs b/tests/Geode.Client.Tests/Protocol/BigEndianBinaryReaderTests.cs new file mode 100644 index 0000000..d75d8e3 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/BigEndianBinaryReaderTests.cs @@ -0,0 +1,93 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol; + +public class BigEndianBinaryReaderTests +{ + [Fact] + public void ReadInt32_decodes_big_endian_bytes() + { + var r = new BigEndianBinaryReader(new byte[] { 0x01, 0x02, 0x03, 0x04 }); + Assert.Equal(0x01020304, r.ReadInt32()); + } + + [Fact] + public void ReadInt32_decodes_negative_value() + { + var r = new BigEndianBinaryReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }); + Assert.Equal(-1, r.ReadInt32()); + } + + [Fact] + public void ReadInt64_decodes_big_endian_bytes() + { + var r = new BigEndianBinaryReader(new byte[] + { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + }); + Assert.Equal(0x0102030405060708L, r.ReadInt64()); + } + + [Fact] + public void ReadByte_decodes_single_byte() + { + var r = new BigEndianBinaryReader(new byte[] { 0xAB }); + Assert.Equal(0xAB, r.ReadByte()); + } + + [Theory] + [InlineData((byte)0x00, false)] + [InlineData((byte)0x01, true)] + [InlineData((byte)0xFF, true)] // any non-zero is "true" — symmetric with Java + public void ReadBool_treats_zero_as_false_anything_else_as_true(byte raw, bool expected) + { + var r = new BigEndianBinaryReader(new byte[] { raw }); + Assert.Equal(expected, r.ReadBool()); + } + + [Fact] + public void ReadBytesOnly_returns_zero_copy_slice() + { + var source = new byte[] { 0x10, 0x20, 0x30, 0x40, 0x50 }; + var r = new BigEndianBinaryReader(source); + + Assert.Equal(0x10, r.ReadByte()); // advance past first byte + var slice = r.ReadBytesOnly(3); + Assert.Equal(new byte[] { 0x20, 0x30, 0x40 }, slice.ToArray()); + + // Mutating the underlying source mutates the slice — proves zero-copy. + source[2] = 0xFF; + Assert.Equal(0xFF, slice.Span[1]); + } + + [Fact] + public void ReadBytesOnly_with_negative_count_throws() + { + var r = new BigEndianBinaryReader(new byte[] { 0x01 }); + Assert.Throws(() => r.ReadBytesOnly(-1)); + } + + [Fact] + public void ReadInt32_past_end_throws_EndOfStreamException() + { + var r = new BigEndianBinaryReader(new byte[] { 0x01, 0x02 }); + Assert.Throws(() => r.ReadInt32()); + } + + [Fact] + public void Position_and_Remaining_track_correctly() + { + var r = new BigEndianBinaryReader(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }); + Assert.Equal(0, r.Position); + Assert.Equal(5, r.Remaining); + + r.ReadByte(); + Assert.Equal(1, r.Position); + Assert.Equal(4, r.Remaining); + + r.ReadInt32(); + Assert.Equal(5, r.Position); + Assert.Equal(0, r.Remaining); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/BigEndianBinaryWriterTests.cs b/tests/Geode.Client.Tests/Protocol/BigEndianBinaryWriterTests.cs new file mode 100644 index 0000000..e567015 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/BigEndianBinaryWriterTests.cs @@ -0,0 +1,89 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol; + +public class BigEndianBinaryWriterTests +{ + [Fact] + public void WriteInt32_emits_big_endian_bytes() + { + var w = new BigEndianBinaryWriter(); + w.WriteInt32(0x01020304); + Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0x04 }, w.ToArray()); + } + + [Fact] + public void WriteInt32_emits_negative_value_as_two_complement_big_endian() + { + var w = new BigEndianBinaryWriter(); + w.WriteInt32(-1); + Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, w.ToArray()); + } + + [Fact] + public void WriteInt64_emits_big_endian_bytes() + { + var w = new BigEndianBinaryWriter(); + w.WriteInt64(0x0102030405060708L); + Assert.Equal( + new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 }, + w.ToArray()); + } + + [Fact] + public void WriteByte_emits_single_byte() + { + var w = new BigEndianBinaryWriter(); + w.WriteByte(0xAB); + Assert.Equal(new byte[] { 0xAB }, w.ToArray()); + } + + [Theory] + [InlineData(true, 0x01)] + [InlineData(false, 0x00)] + public void WriteBool_emits_one_or_zero(bool value, byte expected) + { + var w = new BigEndianBinaryWriter(); + w.WriteBool(value); + Assert.Equal(new byte[] { expected }, w.ToArray()); + } + + [Fact] + public void WriteBytesOnly_emits_raw_bytes_without_length_prefix() + { + var w = new BigEndianBinaryWriter(); + w.WriteBytesOnly(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); + Assert.Equal(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, w.ToArray()); + } + + [Fact] + public void Length_tracks_total_bytes_written() + { + var w = new BigEndianBinaryWriter(); + Assert.Equal(0, w.Length); + w.WriteByte(0x01); + Assert.Equal(1, w.Length); + w.WriteInt32(0); + Assert.Equal(5, w.Length); + w.WriteInt64(0); + Assert.Equal(13, w.Length); + } + + [Fact] + public void Multiple_writes_concatenate_in_order() + { + var w = new BigEndianBinaryWriter(); + w.WriteInt32(0x01020304); + w.WriteByte(0xFF); + w.WriteBytesOnly(new byte[] { 0xAA, 0xBB }); + Assert.Equal( + new byte[] + { + 0x01, 0x02, 0x03, 0x04, + 0xFF, + 0xAA, 0xBB, + }, + w.ToArray()); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/TcrMessageTests.cs b/tests/Geode.Client.Tests/Protocol/TcrMessageTests.cs new file mode 100644 index 0000000..9e09bae --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/TcrMessageTests.cs @@ -0,0 +1,220 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol; + +public class TcrMessageTests +{ + // ==================================================================== + // Round-trip tests + // ==================================================================== + + [Fact] + public void Round_trip_Ping_with_no_parts() + { + var original = new TcrMessage( + MessageType: MessageType.Ping, + TransactionId: 42, + EarlyAck: 0, + Parts: Array.Empty()); + + var bytes = original.Encode(); + var decoded = TcrMessage.Decode(bytes); + + Assert.Equal(original, decoded); + } + + [Fact] + public void Round_trip_Put_with_one_byte_part() + { + var original = new TcrMessage( + MessageType: MessageType.Put, + TransactionId: 99, + EarlyAck: 0, + Parts: new[] + { + new TcrPart(IsObject: false, Payload: new byte[] { 0xAB }), + }); + + var bytes = original.Encode(); + var decoded = TcrMessage.Decode(bytes); + + Assert.Equal(original, decoded); + } + + [Fact] + public void Round_trip_with_multiple_mixed_parts() + { + var original = new TcrMessage( + MessageType: MessageType.Query, + TransactionId: 1234, + EarlyAck: 0x02, + Parts: new[] + { + new TcrPart(IsObject: false, Payload: new byte[] { 0x01, 0x02 }), + new TcrPart(IsObject: true, Payload: new byte[] { 0x57, 0x05, 0xAA, 0xBB }), + new TcrPart(IsObject: false, Payload: ReadOnlyMemory.Empty), + }); + + var decoded = TcrMessage.Decode(original.Encode()); + Assert.Equal(original, decoded); + } + + // ==================================================================== + // Byte-fixture tests + // + // Fixtures are derived from cppcache wire format: + // - Header layout: TcrMessage::writeHeader (TcrMessage.cpp line 767) + // - MessageLength = totalBytes - kHeaderLength + // (TcrMessage::writeMessageLength, line 844-854) + // - Part layout: writeBytePart, writeIntPart, ... (line 328-) + // each writes i32 length | u8 isObject | payload + // - Header is 17 bytes (4×i32 + 1×u8); EarlyAck sits at offset 16 + // (line 794-798). + // ==================================================================== + + /// + /// Ping (msgType=5), no parts, txId=42, earlyAck=0. + /// + /// 17 bytes: + /// 00 00 00 05 | i32 MessageType = 5 (Ping) + /// 00 00 00 00 | i32 MessageLength = 0 (no parts) + /// 00 00 00 00 | i32 NumParts = 0 + /// 00 00 00 2A | i32 TransactionId = 42 + /// 00 | u8 EarlyAck = 0 + /// + private static readonly byte[] PingFixture = + { + 0x00, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x2A, + 0x00, + }; + + /// + /// Put (msgType=7), txId=99, earlyAck=0, one part with payload [0xAB]. + /// + /// 23 bytes (17 header + 6 part): + /// 00 00 00 07 | i32 MessageType = 7 (Put) + /// 00 00 00 06 | i32 MessageLength = 6 (one part: 4+1+1) + /// 00 00 00 01 | i32 NumParts = 1 + /// 00 00 00 63 | i32 TransactionId = 99 + /// 00 | u8 EarlyAck = 0 + /// 00 00 00 01 | i32 Part0.PartLength = 1 + /// 00 | u8 Part0.IsObject = false + /// AB | u8 Part0.payload[0] + /// + private static readonly byte[] PutWithBytePartFixture = + { + 0x00, 0x00, 0x00, 0x07, + 0x00, 0x00, 0x00, 0x06, + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x63, + 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0xAB, + }; + + [Fact] + public void Encode_Ping_produces_expected_byte_fixture() + { + var msg = new TcrMessage( + MessageType: MessageType.Ping, + TransactionId: 42, + EarlyAck: 0, + Parts: Array.Empty()); + + Assert.Equal(PingFixture, msg.Encode()); + } + + [Fact] + public void Decode_Ping_byte_fixture_reproduces_message() + { + var decoded = TcrMessage.Decode(PingFixture); + + Assert.Equal(MessageType.Ping, decoded.MessageType); + Assert.Equal(42, decoded.TransactionId); + Assert.Equal(0, decoded.EarlyAck); + Assert.Empty(decoded.Parts); + } + + [Fact] + public void Encode_Put_with_byte_part_produces_expected_byte_fixture() + { + var msg = new TcrMessage( + MessageType: MessageType.Put, + TransactionId: 99, + EarlyAck: 0, + Parts: new[] + { + new TcrPart(IsObject: false, Payload: new byte[] { 0xAB }), + }); + + Assert.Equal(PutWithBytePartFixture, msg.Encode()); + } + + [Fact] + public void Decode_Put_byte_fixture_reproduces_message() + { + var decoded = TcrMessage.Decode(PutWithBytePartFixture); + + Assert.Equal(MessageType.Put, decoded.MessageType); + Assert.Equal(99, decoded.TransactionId); + Assert.Single(decoded.Parts); + Assert.False(decoded.Parts[0].IsObject); + Assert.Equal(new byte[] { 0xAB }, decoded.Parts[0].Payload.ToArray()); + } + + // ==================================================================== + // Validation tests + // ==================================================================== + + [Fact] + public void Decode_negative_NumParts_throws_FormatException() + { + var bytes = new byte[] + { + 0x00, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, // NumParts = -1 + 0x00, 0x00, 0x00, 0x00, + 0x00, + }; + Assert.Throws(() => TcrMessage.Decode(bytes)); + } + + [Fact] + public void Decode_MessageLength_disagreeing_with_actual_parts_throws() + { + // Header claims MessageLength=10 but the single part is only 5 bytes + // (4 length + 1 isObject + 0 payload). + var bytes = new byte[] + { + 0x00, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x0A, // MessageLength = 10 (wrong) + 0x00, 0x00, 0x00, 0x01, // NumParts = 1 + 0x00, 0x00, 0x00, 0x00, + 0x00, + 0x00, 0x00, 0x00, 0x00, // Part: length=0 + 0x00, // isObject=false + }; + var ex = Assert.Throws(() => TcrMessage.Decode(bytes)); + Assert.Contains("MessageLength", ex.Message); + } + + [Fact] + public void Equality_compares_parts_element_wise() + { + var a = new TcrMessage(MessageType.Put, 1, 0, new[] + { + new TcrPart(false, new byte[] { 0xAA }), + }); + var b = new TcrMessage(MessageType.Put, 1, 0, new[] + { + new TcrPart(false, new byte[] { 0xAA }), + }); + + Assert.Equal(b, a); + Assert.Equal(b.GetHashCode(), a.GetHashCode()); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/TcrPartTests.cs b/tests/Geode.Client.Tests/Protocol/TcrPartTests.cs new file mode 100644 index 0000000..1a52d47 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/TcrPartTests.cs @@ -0,0 +1,84 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol; + +public class TcrPartTests +{ + [Fact] + public void Round_trip_with_simple_payload() + { + var original = new TcrPart(IsObject: false, Payload: new byte[] { 0xDE, 0xAD }); + + var w = new BigEndianBinaryWriter(); + original.Encode(w); + var decoded = TcrPart.Decode(new BigEndianBinaryReader(w.ToArray())); + + Assert.Equal(original, decoded); + } + + [Fact] + public void Round_trip_with_empty_payload() + { + var original = new TcrPart(IsObject: false, Payload: ReadOnlyMemory.Empty); + + var w = new BigEndianBinaryWriter(); + original.Encode(w); + // Encoded bytes: 4 (length=0) + 1 (isObject=0) = 5 bytes. + Assert.Equal(5, w.ToArray().Length); + + var decoded = TcrPart.Decode(new BigEndianBinaryReader(w.ToArray())); + Assert.Equal(original, decoded); + } + + [Fact] + public void Round_trip_with_isObject_true() + { + var original = new TcrPart(IsObject: true, Payload: new byte[] { 0x57 /* DSCode for String */, 0x42 }); + + var w = new BigEndianBinaryWriter(); + original.Encode(w); + var decoded = TcrPart.Decode(new BigEndianBinaryReader(w.ToArray())); + + Assert.True(decoded.IsObject); + Assert.Equal(original, decoded); + } + + [Fact] + public void Decode_negative_length_throws_FormatException() + { + // PartLength = -1 (0xFFFFFFFF) is invalid. + var bytes = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x00 }; + Assert.Throws( + () => TcrPart.Decode(new BigEndianBinaryReader(bytes))); + } + + [Fact] + public void Decode_truncated_buffer_throws_EndOfStreamException() + { + // Says PartLength = 10 but only 5 bytes follow the header. + var bytes = new byte[] { 0x00, 0x00, 0x00, 0x0A, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 }; + Assert.Throws( + () => TcrPart.Decode(new BigEndianBinaryReader(bytes))); + } + + [Fact] + public void Equality_is_content_based_not_reference_based() + { + // Two parts with identical content but distinct backing arrays must compare equal. + var a = new TcrPart(IsObject: false, Payload: new byte[] { 0x01, 0x02, 0x03 }); + var b = new TcrPart(IsObject: false, Payload: new byte[] { 0x01, 0x02, 0x03 }); + + Assert.Equal(b, a); + Assert.Equal(b.GetHashCode(), a.GetHashCode()); + } + + [Fact] + public void Different_payload_compares_not_equal() + { + var a = new TcrPart(IsObject: false, Payload: new byte[] { 0x01 }); + var b = new TcrPart(IsObject: false, Payload: new byte[] { 0x02 }); + + Assert.NotEqual(b, a); + } +} diff --git a/tests/Geode.Client.Tests/SmokeTests.cs b/tests/Geode.Client.Tests/SmokeTests.cs index 0337b22..39e3c9b 100644 --- a/tests/Geode.Client.Tests/SmokeTests.cs +++ b/tests/Geode.Client.Tests/SmokeTests.cs @@ -1,4 +1,3 @@ -using FluentAssertions; using Xunit; namespace Geode.Client.Tests; @@ -9,6 +8,6 @@ public class SmokeTests public void TestInfrastructureWorks() { // Phase 0 sanity check. Replace once Phase 1 codec tests are added. - (1 + 1).Should().Be(2); + Assert.Equal(2, 1 + 1); } }