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/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/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..63887df
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
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
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/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/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/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();
+ }
+}
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.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
{
}
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);
}
}