diff --git a/src/main/java/com/flamingo/comm/llp/LLP.java b/src/main/java/com/flamingo/comm/llp/LLP.java
deleted file mode 100644
index 1150949..0000000
--- a/src/main/java/com/flamingo/comm/llp/LLP.java
+++ /dev/null
@@ -1,155 +0,0 @@
-package com.flamingo.comm.llp;
-
-/**
- * LLP Protocol facade.
- *
- * This class provides a simplified and unified entry point for working with
- * the LLP (Lightweight Link Protocol) library.
- *
- * It allows creating parsers, building frames, and accessing protocol constants
- * without needing to interact directly with low-level classes.
- *
- * Typical usage:
- *
- * LLPParser parser = LLP.newParser();
- *
- * byte[] frame = LLP.buildPing(1);
- *
- * LLPFrameBuilder.Builder = LLP.frameBuilder()
- * .type(LLP.MessageType.DATA)
- * .id(10)
- * .payload("Hello");
- *
- *
- * This class is a utility facade and should not be instantiated.
- */
-public final class LLP {
- public static final byte PROTOCOL_VERSION = 0x02;
-
- /**
- * Private constructor to prevent instantiation.
- */
- private LLP() {
- // Utility class
- }
-
- /**
- * Creates a new parser with default configuration.
- *
- * @return a new {@link LLPParser} instance
- */
- public static LLPParser newParser() {
- return new LLPParser();
- }
-
- /**
- * Creates a new parser with a custom maximum payload size.
- *
- * @param maxPayload maximum payload size in bytes
- * @return a new {@link LLPParser} instance
- */
- public static LLPParser newParser(int maxPayload) {
- return new LLPParser(maxPayload);
- }
-
- /**
- * Creates a new parser with custom payload size and timeout.
- *
- * @param maxPayload maximum payload size in bytes
- * @param timeoutMs frame timeout in milliseconds
- * @return a new {@link LLPParser} instance
- */
- public static LLPParser newParser(int maxPayload, long timeoutMs) {
- return new LLPParser(maxPayload, timeoutMs);
- }
-
- /**
- * Starts building a frame using a fluent API.
- *
- * @return a new {@link LLPFrameBuilder.Builder} instance
- */
- public static LLPFrameBuilder.Builder frameBuilder() {
- return new LLPFrameBuilder.Builder();
- }
-
- /**
- * Builds a raw LLP frame.
- *
- * @param type message type
- * @param id message identifier (0-65535)
- * @param payload payload data (nullable)
- * @return encoded frame as byte array
- */
- public static byte[] buildFrame(byte type, int id, byte[] payload) {
- return LLPFrameBuilder.build(type, id, payload);
- }
-
- /**
- * Builds a PING frame.
- *
- * @param id message identifier (0-65535)
- * @return encoded frame
- */
- public static byte[] buildPing(int id) {
- return buildFrame(LLPMessageType.PING.value(), id, null);
- }
-
- /**
- * Builds an ACK frame.
- *
- * @param id message identifier (0-65535)
- * @param code acknowledgment code
- * @return encoded frame
- */
- public static byte[] buildAck(int id, byte code) {
- return buildFrame(LLPMessageType.ACK.value(), id, new byte[]{code});
- }
-
- /**
- * Builds a DATA frame.
- *
- * @param id message identifier (0-65535)
- * @param data payload data
- * @return encoded frame
- */
- public static byte[] buildData(int id, byte[] data) {
- return buildFrame(LLPMessageType.DATA.value(), id, data);
- }
-
- /**
- * Message type constants for convenience.
- *
- * These values mirror {@link LLPMessageType} but are provided as raw bytes
- * for quick usage without enums.
- */
- public static final class MessageType {
- public static final byte PING = LLPMessageType.PING.value();
- public static final byte ACK = LLPMessageType.ACK.value();
- public static final byte NACK = LLPMessageType.NACK.value();
- public static final byte DATA = LLPMessageType.DATA.value();
- public static final byte CONFIG = LLPMessageType.CONFIG.value();
- public static final byte STATUS = LLPMessageType.STATUS.value();
- public static final byte COMMAND = LLPMessageType.COMMAND.value();
- public static final byte EVENT = LLPMessageType.EVENT.value();
- public static final byte ERROR = LLPMessageType.ERROR.value();
-
- private MessageType() {
- }
- }
-
- /**
- * Error code constants used in ACK/NACK responses.
- */
- public static final class ErrorCode {
- public static final byte OK = 0x00;
- public static final byte CHECKSUM = 0x01;
- public static final byte TYPE = 0x02;
- public static final byte PAYLOAD_LEN = 0x03;
- public static final byte TIMEOUT = 0x04;
- public static final byte SYNC = 0x05;
- public static final byte BUFFER_FULL = 0x06;
-
- private ErrorCode() {
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/LLPFrame.java b/src/main/java/com/flamingo/comm/llp/LLPFrame.java
deleted file mode 100644
index 6b33d4b..0000000
--- a/src/main/java/com/flamingo/comm/llp/LLPFrame.java
+++ /dev/null
@@ -1,91 +0,0 @@
-package com.flamingo.comm.llp;
-
-import java.util.Arrays;
-import java.util.Optional;
-
-/**
- * This represents a fully received and validated LLP frame.
- *
- * Immutable and thread-safe.
- */
-public class LLPFrame {
- public static final int DEFAULT_MAX_PAYLOAD = 512;
-
- private final byte type;
- private final int id;
- private final byte version;
- private final byte[] payload;
- private final int crc;
- private final long timestamp;
-
- public LLPFrame(byte type, int id, byte version, byte[] payload, int crc) {
- this(type, id, version, payload, crc, System.currentTimeMillis());
- }
-
- public LLPFrame(byte type, int id, byte version, byte[] payload, int crc, long timestamp) {
- this.type = type;
- this.id = id;
- this.version = version;
- this.payload = payload != null ? payload.clone() : new byte[0];
- this.crc = crc;
- this.timestamp = timestamp;
- }
-
- public byte type() {
- return type;
- }
-
- public Optional messageType() {
- return LLPMessageType.fromValue(type);
- }
-
- public int id() {
- return id;
- }
-
- public byte version() {
- return version;
- }
-
- public byte[] payload() {
- return payload.clone();
- }
-
- public int payloadLength() {
- return payload.length;
- }
-
- public int crc() {
- return crc;
- }
-
- public long timestamp() {
- return timestamp;
- }
-
- @Override
- public String toString() {
- return String.format(
- "LLPFrame{type=0x%02X, id=%d, version=0x%02X, payloadLen=%d, crc=0x%04X, timestamp=%d}",
- type, id, version, payload.length, crc, timestamp
- );
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
-
- LLPFrame frame = (LLPFrame) o;
- return type == frame.type &&
- id == frame.id &&
- version == frame.version &&
- crc == frame.crc &&
- Arrays.equals(payload, frame.payload);
- }
-
- @Override
- public int hashCode() {
- return java.util.Objects.hash(type, id, version, crc, Arrays.hashCode(payload));
- }
-}
diff --git a/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java b/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java
deleted file mode 100644
index 4d3100b..0000000
--- a/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java
+++ /dev/null
@@ -1,214 +0,0 @@
-package com.flamingo.comm.llp;
-
-import com.flamingo.comm.llp.crc.CRC16CCITT;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.ByteArrayOutputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-
-/**
- * LLP Frame Builder.
- *
- * This class provides utilities to construct valid LLP protocol frames
- * ready for transmission over any transport layer (TCP, UART, RF, etc).
- *
- * Frame format:
- *
- * [MAGIC1][MAGIC2][PROTOCOL_VERSION][TYPE][ID_L][ID_H][LEN_L][LEN_H][PAYLOAD...][CRC_L][CRC_H]
- *
- *
- * The CRC16-CCITT is calculated over all bytes except the CRC itself.
- */
-public class LLPFrameBuilder {
-
- private static final Logger logger = LoggerFactory.getLogger(LLPFrameBuilder.class);
-
- private static final byte MAGIC_1 = (byte) 0xAA;
- private static final byte MAGIC_2 = (byte) 0x55;
-
- /**
- * Builds a frame using the default maximum payload size.
- *
- * @param type message type
- * @param id message identifier (0-65535)
- * @param payload payload data (nullable)
- * @return byte array containing the encoded frame
- * @throws IllegalArgumentException if payload size or id is invalid
- */
- public static byte[] build(byte type, int id, byte[] payload) {
- return build(type, id, payload, LLPFrame.DEFAULT_MAX_PAYLOAD);
- }
-
- /**
- * Builds a frame with a custom maximum payload size.
- *
- * @param type message type
- * @param id message identifier (0-65535)
- * @param payload payload data (nullable)
- * @param maxPayload maximum allowed payload size
- * @return byte array containing the encoded frame
- */
- public static byte[] build(byte type, int id, byte[] payload, int maxPayload) {
-
- if (id < 0 || id > 0xFFFF) {
- throw new IllegalArgumentException("ID must be between 0 and 65535");
- }
-
- if (payload == null) {
- payload = new byte[0];
- }
-
- if (payload.length > maxPayload) {
- throw new IllegalArgumentException(
- String.format("Payload size %d exceeds maximum %d", payload.length, maxPayload)
- );
- }
-
- final int MAX_FRAME_SIZE = 8 + payload.length + 2;
- byte[] frame = new byte[MAX_FRAME_SIZE];
- int idx = 0;
-
- // Magic
- frame[idx++] = MAGIC_1;
- frame[idx++] = MAGIC_2;
-
- // Version
- frame[idx++] = LLP.PROTOCOL_VERSION;
-
- // Type
- frame[idx++] = type;
-
- // ID (Little Endian)
- frame[idx++] = (byte) (id & 0xFF);
- frame[idx++] = (byte) ((id >> 8) & 0xFF);
-
- // Length (Little Endian)
- frame[idx++] = (byte) (payload.length & 0xFF);
- frame[idx++] = (byte) ((payload.length >> 8) & 0xFF);
-
- // Payload
- if (payload.length > 0) {
- System.arraycopy(payload, 0, frame, idx, payload.length);
- idx += payload.length;
- }
-
- // CRC
- int crc = CRC16CCITT.calculate(frame, 0, idx);
-
- frame[idx++] = (byte) (crc & 0xFF);
- frame[idx++] = (byte) ((crc >> 8) & 0xFF);
-
- logger.debug("Built frame: type=0x{}, id={}, payload_len={}, total_len={}",
- Integer.toHexString(type & 0xFF), id, payload.length, frame.length);
-
- return stuffFrame(frame);
- }
-
- /**
- * Stuff bytes into a completed frame, excluding the header.
- * Add one byte to ensure the header is not included within the frame.
- *
- * @param frame original frame without any processing
- * @return Stuffed frame
- */
- private static byte[] stuffFrame(byte[] frame) {
- // After the byte stuff, in the worst-case scenario, it will take up twice the size of the actual frame
- ByteArrayOutputStream buffer = new ByteArrayOutputStream(frame.length * 2);
-
- // Copy MAGIC as-is
- buffer.write(frame, 0, 2);
-
- // Stuff everything else
- for (int i = 2; i < frame.length; i++) {
- byte b = frame[i];
- buffer.write(b);
-
- if (b == MAGIC_1) {
- buffer.write(0x00);
- }
- }
-
- return buffer.toByteArray();
- }
-
- /**
- * Fluent builder for constructing LLP frames.
- */
- public static class Builder {
-
- private byte type;
- private int id = 0;
- private byte[] payload = new byte[0];
- private int maxPayload = LLPFrame.DEFAULT_MAX_PAYLOAD;
-
- /**
- * Sets message type.
- */
- public Builder type(byte type) {
- this.type = type;
- return this;
- }
-
- /**
- * Sets message type using enum.
- */
- public Builder type(LLPMessageType type) {
- this.type = type.value();
- return this;
- }
-
- /**
- * Sets message ID (0-65535).
- */
- public Builder id(int id) {
- this.id = id;
- return this;
- }
-
- /**
- * Sets payload bytes.
- */
- public Builder payload(byte[] payload) {
- this.payload = (payload != null) ? Arrays.copyOf(payload, payload.length) : new byte[0];
- return this;
- }
-
- /**
- * Sets payload from string (UTF-8 encoded).
- */
- public Builder payload(String payload) {
- this.payload = (payload != null)
- ? payload.getBytes(StandardCharsets.UTF_8)
- : new byte[0];
- return this;
- }
-
- /**
- * Sets maximum payload size.
- */
- public Builder maxPayload(int maxPayload) {
- this.maxPayload = maxPayload;
- return this;
- }
-
- /**
- * Builds raw frame bytes.
- */
- public byte[] build() {
- return LLPFrameBuilder.build(type, id, payload, maxPayload);
- }
-
- /**
- * Builds an {@link LLPFrame} object representation.
- */
- public LLPFrame buildFrame() {
- byte[] data = build();
- int crc = (data[data.length - 2] & 0xFF) |
- ((data[data.length - 1] & 0xFF) << 8);
-
- return new LLPFrame(type, id, LLP.PROTOCOL_VERSION, payload, crc);
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/LLPMessageType.java b/src/main/java/com/flamingo/comm/llp/LLPMessageType.java
deleted file mode 100644
index 8b9a406..0000000
--- a/src/main/java/com/flamingo/comm/llp/LLPMessageType.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.flamingo.comm.llp;
-
-import java.util.Optional;
-
-/**
- * Message types supported by the LLP Protocol.
- *
- * Range 0x00-0x15: base types of the protocol
- * Range 0x16-0xFF: available for custom applications
- */
-public enum LLPMessageType {
- PING((byte) 0x01),
- ACK((byte) 0x02),
- NACK((byte) 0x03),
- DATA((byte) 0x10),
- CONFIG((byte) 0x11),
- STATUS((byte) 0x12),
- COMMAND((byte) 0x13),
- EVENT((byte) 0x14),
- ERROR((byte) 0x15);
-
- private final byte value;
-
- LLPMessageType(byte value) {
- this.value = value;
- }
-
- /**
- * Retrieves the message type from a byte value.
- *
- * @return an {@link Optional} containing the message type, or empty if the message type is not found
- */
- public static Optional fromValue(byte value) {
- for (LLPMessageType type : values()) {
- if (type.value == value) {
- return Optional.of(type);
- }
- }
- return Optional.empty(); // Custom type
- }
-
- public byte value() {
- return value;
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilder.java b/src/main/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilder.java
new file mode 100644
index 0000000..588eea0
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilder.java
@@ -0,0 +1,185 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerBuilder;
+import com.flamingo.comm.llp.spi.LayerBuildPayload;
+import com.flamingo.comm.llp.spi.LayerBuildResult;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.List;
+
+/**
+ * Default {@link LLPFrameBuilder} implementation that produces a contiguous {@code byte[]} frame.
+ *
+ * This builder applies a sequence of {@link LLPLayerBuilder} instances in order,
+ * wrapping the provided payload into successive layers. Each layer contributes
+ * metadata and may optionally transform the payload.
+ *
+ * Build Strategy
+ *
+ * - Layers are executed sequentially using the output payload of the previous layer.
+ * - Headers (ID + metadata) are collected separately to avoid unnecessary copies.
+ * - If a layer transforms the payload, previously accumulated headers are discarded,
+ * since they are assumed to be encapsulated within the transformed payload.
+ * - The final byte array is assembled in a single pass.
+ *
+ *
+ * Frame Format
+ *
+ * [LAYER_ID][META_LENGTH][METADATA] ... [FINAL_ID=0x00][PAYLOAD]
+ *
+ *
+ * Error Handling
+ * If any layer returns a {@link LayerBuildResult.Failure}, the build process is aborted
+ * and a {@link FrameBuildException} is thrown.
+ *
+ * Performance Notes
+ *
+ * - Avoids intermediate payload concatenation.
+ * - Performs a single allocation for the final byte array.
+ * - Uses {@link ByteBuffer#duplicate()} to prevent mutation of input buffers.
+ *
+ */
+public class ByteArrayFrameBuilder implements LLPFrameBuilder {
+
+ private final List layers;
+
+ /**
+ * Creates a new builder with the given ordered list of layers.
+ *
+ * @param layers the layers to apply during the build process
+ */
+ ByteArrayFrameBuilder(List layers) {
+ this.layers = List.copyOf(layers);
+ }
+
+ /**
+ * Builds the final LLP frame as a {@code byte[]}.
+ *
+ * @param payload the initial payload to be wrapped by the configured layers
+ * @return the fully assembled frame as a contiguous byte array
+ * @throws IllegalArgumentException if {@code payload} is {@code null}
+ * @throws FrameBuildException if any layer fails during the build process
+ */
+ @Override
+ public byte[] build(ByteBuffer payload) {
+ if (payload == null) {
+ throw new IllegalArgumentException("payload cannot be null");
+ }
+
+ ByteBuffer currentPayload = payload;
+
+ // Stack of headers (outermost first). Payload is handled separately.
+ Deque headersStack = new ArrayDeque<>();
+
+ for (LLPLayerBuilder layer : layers) {
+ LayerBuildResult result = layer.build(new DefaultLayerBuildPayload(currentPayload.asReadOnlyBuffer()));
+
+ switch (result) {
+ case LayerBuildResult.Failure failure -> throw new FrameBuildException(layer.getLayerId(), failure.errorReason());
+
+ case LayerBuildResult.Success success -> {
+ switch (success) {
+ case LayerBuildResult.Success.UnmodifiedPayload unmodified ->
+ headersStack.addFirst(new LayerHeader(layer.getLayerId(), unmodified.metadata()));
+
+ case LayerBuildResult.Success.TransformedPayload modified -> {
+ // The payload has been transformed (e.g., encryption/compression).
+ // Previous headers are assumed to be encapsulated in the new payload.
+ currentPayload = modified.modifiedPayload();
+ headersStack.clear();
+
+ headersStack.addFirst(new LayerHeader(layer.getLayerId(), modified.metadata()));
+ }
+ }
+ }
+ }
+ }
+
+ return assembleFinalArray(headersStack, currentPayload);
+ }
+
+ /**
+ * Assembles the final frame into a single byte array.
+ *
+ * @param headers the ordered headers (outermost first)
+ * @param finalPayload the final payload to append
+ * @return the serialized frame
+ */
+ private byte[] assembleFinalArray(Deque headers, ByteBuffer finalPayload) {
+ int totalSize = 0;
+
+ for (LayerHeader header : headers) {
+ totalSize += header.size();
+ }
+
+ totalSize += 1; // Final layer ID (0x00)
+ totalSize += finalPayload.remaining();
+
+ byte[] result = new byte[totalSize];
+ int offset = 0;
+
+ for (LayerHeader header : headers) {
+ offset = writeHeader(result, offset, header);
+ }
+
+ // Final layer marker
+ result[offset++] = 0x00;
+
+ // Payload (read-only copy)
+ finalPayload.duplicate().get(result, offset, finalPayload.remaining());
+
+ return result;
+ }
+
+ /**
+ * Writes a single layer header into the destination array.
+ *
+ * @param dest destination array
+ * @param offset current write offset
+ * @param header header to write
+ * @return updated offset after writing
+ */
+ private int writeHeader(byte[] dest, int offset, LayerHeader header) {
+ dest[offset++] = (byte) header.id();
+
+ int metaLen = header.metadata().remaining();
+
+ if (metaLen < 255) {
+ dest[offset++] = (byte) metaLen;
+ } else {
+ dest[offset++] = (byte) 0xFF;
+ dest[offset++] = (byte) ((metaLen >> 8) & 0xFF);
+ dest[offset++] = (byte) (metaLen & 0xFF);
+ }
+
+ if (metaLen > 0) {
+ header.metadata().duplicate().get(dest, offset, metaLen);
+ offset += metaLen;
+ }
+
+ return offset;
+ }
+
+ /**
+ * Lightweight structure representing a layer header (ID + metadata).
+ */
+ private record LayerHeader(int id, ByteBuffer metadata) {
+
+ /**
+ * Returns the total serialized size of this header.
+ */
+ int size() {
+ int metaLen = metadata.remaining();
+ return 1 + (metaLen < 255 ? 1 : 3) + metaLen;
+ }
+ }
+
+ /**
+ * Default implementation of {@link LayerBuildPayload}.
+ * Wraps the current payload passed between layers.
+ */
+ private record DefaultLayerBuildPayload(ByteBuffer payload) implements LayerBuildPayload {
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java b/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
new file mode 100644
index 0000000..771b7cd
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
@@ -0,0 +1,60 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.ParseErrorReason;
+
+/**
+ * Core-level parsing error reasons detected by the LLP parser.
+ *
+ *
+ * These errors represent structural inconsistencies in the frame
+ * that are independent of any specific layer implementation.
+ *
+ *
+ *
+ * This enum complements plugin-defined errors by covering cases
+ * detected by the core parser itself.
+ *
+ */
+public enum CoreParseErrorReason implements ParseErrorReason {
+
+ /**
+ * Metadata length exceeds available buffer.
+ */
+ METADATA_TRUNCATED("Metadata length exceeds available data"),
+
+ /**
+ * Frame ended before expected fields could be read.
+ */
+ LAYER_TOO_SHORT("Unexpected end of layer"),
+
+ /**
+ * Layer ID is invalid or out of range.
+ */
+ INVALID_LAYER_ID("Invalid layer identifier"),
+
+ /**
+ * A non-skippable layer could not be parsed.
+ */
+ NON_SKIPPABLE_LAYER_FAILED("Non-skippable layer parsing failed"),
+
+ /**
+ * A required layer parser was not found.
+ */
+ UNKNOWN_CRITICAL_LAYER("No parser found for non-skippable layer"),
+
+ /**
+ * A plugin threw an unexpected exception.
+ */
+ PLUGIN_EXCEPTION("Layer parser threw an exception");
+
+ private final String reason;
+
+ CoreParseErrorReason(String reason) {
+ this.reason = reason;
+ }
+
+ @Override
+ public String reason() {
+ return reason;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/FailureNode.java b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
new file mode 100644
index 0000000..6e62e22
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
@@ -0,0 +1,135 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+import com.flamingo.comm.llp.spi.ParseErrorReason;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents a layer that failed to be parsed.
+ *
+ *
+ * This node is created when a layer parser returns a failure result or
+ * throws an unexpected exception.
+ *
+ *
+ *
+ * It preserves the layer identifier, metadata, and error information,
+ * allowing the user to inspect and react to parsing issues without
+ * interrupting the entire parsing process.
+ *
+ *
+ *
+ * This class is immutable and thread-safe.
+ *
+ */
+public final class FailureNode implements LLPNode {
+
+ private static final byte[] EMPTY_ARRAY = new byte[0];
+
+ private final int id;
+ private final byte[] metadata;
+ private final ParseErrorReason errorReason;
+ private final Throwable cause;
+
+ /**
+ * Creates a FailureNode without a cause and metadata empty.
+ *
+ * @param id layer identifier
+ * @param errorReason reason for failure (non-null)
+ */
+ public FailureNode(int id, ParseErrorReason errorReason) {
+ this(id, null, errorReason, null);
+ }
+
+ /**
+ * Creates a FailureNode without a cause.
+ *
+ * @param id layer identifier
+ * @param metadata raw metadata (nullable)
+ * @param errorReason reason for failure (non-null)
+ */
+ public FailureNode(int id, byte[] metadata, ParseErrorReason errorReason) {
+ this(id, metadata, errorReason, null);
+ }
+
+ /**
+ * Creates a FailureNode.
+ *
+ * @param id layer identifier
+ * @param metadata raw metadata (nullable)
+ * @param errorReason reason for failure (non-null)
+ * @param cause optional exception cause (nullable)
+ */
+ public FailureNode(int id,
+ byte[] metadata,
+ ParseErrorReason errorReason,
+ Throwable cause) {
+
+ this.id = id;
+ this.metadata = (metadata != null) ? metadata.clone() : EMPTY_ARRAY;
+ this.errorReason = Objects.requireNonNull(errorReason, "errorReason cannot be null");
+ this.cause = cause;
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * Returns raw metadata associated with the failed layer.
+ *
+ * @return read-only metadata buffer (never null)
+ */
+ public ByteBuffer getMetadata() {
+ return ByteBuffer.wrap(metadata).asReadOnlyBuffer();
+ }
+
+ /**
+ * Returns the reason why parsing failed.
+ *
+ * @return parse error reason
+ */
+ public ParseErrorReason getErrorReason() {
+ return errorReason;
+ }
+
+ /**
+ * Returns the underlying exception cause, if any.
+ *
+ * This is typically set when a layer parser throws an unexpected exception.
+ *
+ * @return optional cause
+ */
+ public Optional getCause() {
+ return Optional.ofNullable(cause);
+ }
+
+ @Override
+ public String toString() {
+ return "FailureNode{" +
+ "id=" + id +
+ ", errorReason=" + errorReason +
+ ", metadataLength=" + metadata.length +
+ (cause != null ? ", cause=" + cause.getClass().getSimpleName() : "") +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof FailureNode that)) return false;
+ return id == that.id &&
+ Objects.equals(errorReason, that.errorReason) &&
+ Arrays.equals(metadata, that.metadata);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, errorReason, Arrays.hashCode(metadata));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
new file mode 100644
index 0000000..6c41a98
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
@@ -0,0 +1,101 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.Locale;
+
+/**
+ * Final LLP node (Layer ID = 0).
+ *
+ * This node represents the innermost payload of an LLP frame.
+ * It contains no metadata and cannot have child nodes.
+ *
+ * This class is immutable and thread-safe.
+ */
+public final class FinalNode implements LLPNode {
+ public static final int ID = 0;
+ /**
+ * Shared instance for empty payload (singleton).
+ */
+ private static final byte[] EMPTY_ARRAY = new byte[0];
+
+ public static final FinalNode EMPTY = new FinalNode(EMPTY_ARRAY);
+ private final byte[] payload;
+
+ /**
+ * Creates a FinalNode with payload.
+ * The `of()` factory method prevents it from being null or empty
+ *
+ * @param payload raw payload (nullable → treated as empty)
+ */
+ private FinalNode(byte[] payload) {
+ this.payload = payload.clone();
+ }
+
+ /**
+ * The `of()` factory method prevents it from being null or empty
+ */
+ private FinalNode(ByteBuffer payload) {
+ ByteBuffer readOnly = payload.asReadOnlyBuffer();
+ byte[] copy = new byte[readOnly.remaining()];
+ readOnly.get(copy);
+
+ this.payload = copy;
+ }
+
+ @Override
+ public int getId() {
+ return ID;
+ }
+
+ /**
+ * Factory method to reuse EMPTY instance when possible.
+ */
+ static FinalNode of(byte[] payload) {
+ if (payload == null || payload.length == 0) {
+ return EMPTY;
+ }
+ return new FinalNode(payload);
+ }
+
+ /**
+ * Factory method to reuse EMPTY instance when possible.
+ */
+ static FinalNode of(ByteBuffer payload) {
+ if (payload == null || !payload.hasRemaining()) {
+ return EMPTY;
+ }
+ return new FinalNode(payload);
+ }
+
+ /**
+ * Raw payload sent by the sender
+ *
+ * @return an immutable array of bytes containing the raw payload sent by the sender, or an empty array
+ */
+ public ByteBuffer getPayload() {
+ return ByteBuffer.wrap(payload).asReadOnlyBuffer();
+ }
+
+ @Override
+ public String toString() {
+ return "FinalNode{" +
+ "payloadHex=" + HexFormat.of().formatHex(payload).toUpperCase(Locale.ROOT) +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof FinalNode that)) return false;
+ return Arrays.equals(payload, that.payload);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(payload);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/FrameBuildException.java b/src/main/java/com/flamingo/comm/llp/core/FrameBuildException.java
new file mode 100644
index 0000000..82541bc
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/FrameBuildException.java
@@ -0,0 +1,109 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.BuildErrorReason;
+
+import java.util.Optional;
+
+/**
+ * Exception thrown when an error occurs during the LLP frame building process.
+ *
+ * This exception indicates that one or more layers failed to build correctly,
+ * or that the overall frame construction process could not be completed.
+ *
+ * Typical Causes
+ *
+ * - A {@link com.flamingo.comm.llp.spi.LLPLayerBuilder} returned a failure result.
+ * - A layer produced invalid metadata or payload.
+ * - An unexpected exception occurred within a layer implementation.
+ * - The configured layer chain is inconsistent or invalid.
+ *
+ *
+ * Additional Context
+ *
+ * - {@code layerId} identifies the layer where the failure occurred.
+ * - {@code errorReason} provides a structured reason when available.
+ *
+ *
+ * Usage Notes
+ *
+ * - This is an unchecked exception ({@link RuntimeException}) as build
+ * failures are typically unrecoverable within the same flow.
+ * - Callers may catch this exception to log or handle failures at a higher level.
+ *
+ */
+public class FrameBuildException extends RuntimeException {
+
+ private final int layerId;
+ private final BuildErrorReason errorReason;
+
+ /**
+ * Creates a new {@code FrameBuildException} with a message only.
+ * Layer information will be unavailable.
+ *
+ * @param message a human-readable description of the error
+ */
+ public FrameBuildException(String message) {
+ super(message);
+ this.layerId = -1;
+ this.errorReason = null;
+ }
+
+ /**
+ * Creates a new {@code FrameBuildException} with a message and cause.
+ *
+ * @param message a human-readable description of the error
+ * @param cause the underlying cause of the failure
+ */
+ public FrameBuildException(String message, Throwable cause) {
+ super(message, cause);
+ this.layerId = -1;
+ this.errorReason = null;
+ }
+
+ /**
+ * Creates a new {@code FrameBuildException} with full layer context.
+ *
+ * @param layerId the ID of the layer where the error occurred
+ * @param errorReason the structured error reason
+ */
+ public FrameBuildException(int layerId, BuildErrorReason errorReason) {
+ super(buildMessage(layerId, errorReason));
+ this.layerId = layerId;
+ this.errorReason = errorReason;
+ }
+
+ /**
+ * Creates a new {@code FrameBuildException} with full context and cause.
+ *
+ * @param layerId the ID of the layer where the error occurred
+ * @param errorReason the structured error reason
+ * @param cause the underlying cause
+ */
+ public FrameBuildException(int layerId, BuildErrorReason errorReason, Throwable cause) {
+ super(buildMessage(layerId, errorReason), cause);
+ this.layerId = layerId;
+ this.errorReason = errorReason;
+ }
+
+ private static String buildMessage(int layerId, BuildErrorReason reason) {
+ return "Layer [" + layerId + "] failed to build. Reason: " + reason;
+ }
+
+ /**
+ * Returns the layer ID where the failure occurred.
+ *
+ * @return the layer ID, or {@code -1} if not available
+ */
+ public int getLayerId() {
+ return layerId;
+ }
+
+ /**
+ * Returns the structured error reason, if available.
+ *
+ * @return an {@link Optional} containing the error reason
+ */
+ public Optional getErrorReason() {
+ return Optional.ofNullable(errorReason);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLP.java b/src/main/java/com/flamingo/comm/llp/core/LLP.java
new file mode 100644
index 0000000..74e837a
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLP.java
@@ -0,0 +1,153 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerBuilder;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Main entry point and factory facade for the LLP protocol library.
+ *
+ * This class provides access to builders for creating LLP frame builders,
+ * frame parsers, and incremental parsers.
+ *
+ * Provided Components
+ *
+ * - {@link LLPFrameBuilder} for outbound frame construction
+ * - {@link LLPFrameParser} for parsing complete frames
+ * - {@link LLPIncrementalParser} for streaming/incremental parsing
+ *
+ *
+ * Design Goals
+ *
+ * - Simple and minimal public API
+ * - Separation of inbound and outbound responsibilities
+ * - Immutable runtime components after construction
+ * - Support for plugin-based layer discovery via SPI
+ *
+ *
+ * Example Usage
+ * {@code
+ * LLPFrameBuilder builder = LLP.frameBuilder()
+ * .addLayer(new MyLayerBuilder())
+ * .build();
+ *
+ * byte[] frame = builder.build(payloadBuffer);
+ * }
+ *
+ * {@code
+ * LLPFrameParser parser = LLP.frameParser()
+ * .build();
+ * }
+ *
+ * {@code
+ * LLPIncrementalParser incremental = LLP.incrementalParser()
+ * .maxPayloadBytes(4096)
+ * .timeoutMs(1000)
+ * .build();
+ * }
+ */
+public final class LLP {
+
+ private LLP() {
+ // Utility class
+ }
+
+ /**
+ * Creates a new configurator for an {@link LLPFrameBuilder}.
+ */
+ public static FrameBuilderConfigurator frameBuilder() {
+ return new FrameBuilderConfigurator();
+ }
+
+ /**
+ * Creates a new builder for configuring an {@link LLPFrameParser}.
+ */
+ public static FrameParserBuilder frameParser() {
+ return new FrameParserBuilder();
+ }
+
+ /**
+ * Creates a new builder for configuring an {@link LLPIncrementalParser}.
+ */
+ public static IncrementalParserBuilder incrementalParser() {
+ return new IncrementalParserBuilder();
+ }
+
+ /**
+ * Configurator used to setup and create {@link LLPFrameBuilder} instances.
+ */
+ public static final class FrameBuilderConfigurator {
+
+ private final List layers = new ArrayList<>();
+
+ private FrameBuilderConfigurator() {}
+
+ public FrameBuilderConfigurator addLayer(LLPLayerBuilder layer) {
+ layers.add(Objects.requireNonNull(layer, "Layer cannot be null"));
+ return this;
+ }
+
+ public FrameBuilderConfigurator addLayers(List layers) {
+ Objects.requireNonNull(layers, "Layers list cannot be null");
+ layers.forEach(this::addLayer);
+ return this;
+ }
+
+ public LLPFrameBuilder build() {
+ // Se asume que ByteArrayFrameBuilder hace una copia defensiva de 'layers'
+ return new ByteArrayFrameBuilder(layers);
+ }
+ }
+
+ /**
+ * Builder used to configure and create {@link LLPFrameParser} instances.
+ */
+ public static final class FrameParserBuilder {
+
+ private LayerParserProvider provider = LayerParserRegistry.getInstance()::get;
+
+ private FrameParserBuilder() {}
+
+ public FrameParserBuilder parserProvider(LayerParserProvider provider) {
+ this.provider = Objects.requireNonNull(provider, "Provider cannot be null");
+ return this;
+ }
+
+ public LLPFrameParser build() {
+ return new SimpleFrameParser(provider);
+ }
+ }
+
+ /**
+ * Builder used to configure and create {@link LLPIncrementalParser} instances.
+ */
+ public static final class IncrementalParserBuilder {
+
+ private LayerParserProvider provider = LayerParserRegistry.getInstance()::get;
+ private int maxPayloadBytes = -1;
+ private long timeoutMs = -1;
+
+ private IncrementalParserBuilder() {}
+
+ public IncrementalParserBuilder parserProvider(LayerParserProvider provider) {
+ this.provider = Objects.requireNonNull(provider, "Provider cannot be null");
+ return this;
+ }
+
+ public IncrementalParserBuilder maxPayloadBytes(int bytes) {
+ this.maxPayloadBytes = bytes;
+ return this;
+ }
+
+ public IncrementalParserBuilder timeoutMs(long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ return this;
+ }
+
+ public LLPIncrementalParser build() {
+ return new LLPIncrementalParser(provider, maxPayloadBytes, timeoutMs);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
new file mode 100644
index 0000000..14f284d
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
@@ -0,0 +1,96 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.util.Objects;
+
+/**
+ * Represents a fully received and validated LLP frame.
+ *
+ * This class is immutable and thread-safe.
+ *
+ * An LLPFrame contains a hierarchy of {@link LLPNode} elements (layers),
+ * starting from the outermost layer down to the final payload node.
+ */
+public final class LLPFrame {
+
+ private final NodeChain nodeChain;
+ private final int crc;
+ private final long timestamp;
+
+ /**
+ * Creates a new frame with the current system timestamp.
+ *
+ * @param nodeChain nested nodes
+ * @param crc calculated CRC value
+ */
+ LLPFrame(NodeChain nodeChain, int crc) {
+ this(nodeChain, crc, System.currentTimeMillis());
+ }
+
+ /**
+ * Creates a new frame.
+ *
+ * @param nodeChain nested nodes
+ * @param crc calculated CRC value
+ * @param timestamp creation timestamp (milliseconds)
+ */
+ LLPFrame(NodeChain nodeChain, int crc, long timestamp) {
+ this.nodeChain = nodeChain;
+ this.crc = crc;
+ this.timestamp = timestamp;
+ }
+
+ /**
+ * Returns the CRC value of the frame.
+ */
+ public int crc() {
+ return crc;
+ }
+
+ /**
+ * Returns the timestamp when the frame was created.
+ */
+ public long timestamp() {
+ return timestamp;
+ }
+
+ public NodeChain chain() {
+ return nodeChain;
+ }
+
+ /**
+ * Returns a string representation of the frame.
+ */
+ @Override
+ public String toString() {
+ return "LLPFrame{" +
+ "crc=" + crc +
+ ", timestamp=" + timestamp +
+ ", nodes=" + nodeChain.size() +
+ '}';
+ }
+
+ /**
+ * Compares this frame with another object.
+ *
+ * Equality is based on content and CRC.
+ * Timestamp is intentionally ignored.
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof LLPFrame that)) return false;
+
+ return crc == that.crc &&
+ Objects.equals(nodeChain, that.nodeChain);
+ }
+
+ /**
+ * Returns the hash code of the frame.
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(nodeChain, crc);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPFrameBuilder.java b/src/main/java/com/flamingo/comm/llp/core/LLPFrameBuilder.java
new file mode 100644
index 0000000..c183007
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPFrameBuilder.java
@@ -0,0 +1,54 @@
+package com.flamingo.comm.llp.core;
+
+import java.nio.ByteBuffer;
+
+/**
+ * Core contract for building an LLP frame from a given payload.
+ *
+ * An {@code LLPFrameBuilder} is responsible for applying a sequence of
+ * {@link com.flamingo.comm.llp.spi.LLPLayerBuilder} instances in order,
+ * producing a fully serialized frame representation.
+ *
+ * Design Principles
+ *
+ * - Layered composition: Each configured layer contributes metadata
+ * and may optionally transform the payload.
+ * - Payload propagation: The output of one layer becomes the input
+ * of the next.
+ * - Single materialization: Implementations are encouraged to avoid
+ * intermediate copies and perform the final byte assembly in a single pass.
+ * - Pluggable output: The result type {@code T} allows different
+ * representations (e.g., {@code byte[]}, {@code ByteBuffer}, scatter/gather
+ * buffers, etc.).
+ *
+ *
+ * Error Handling
+ * If any layer fails during the build process, the implementation must
+ * abort and throw a {@link FrameBuildException}. Partial results must not
+ * be returned.
+ *
+ * Thread Safety
+ * Implementations are not required to be thread-safe unless explicitly stated.
+ * External synchronization may be required if reused across threads.
+ *
+ * @param the type of the final frame representation
+ */
+public interface LLPFrameBuilder {
+
+ /**
+ * Builds the final frame representation based on the configured layers
+ * and the provided initial payload.
+ *
+ * The given payload represents the innermost data. Each configured
+ * layer wraps this payload, optionally transforming it and attaching
+ * metadata, until the outermost frame is produced.
+ *
+ * @param payload the initial payload to be wrapped by the configured layers;
+ * must not be {@code null}
+ * @return the fully assembled frame in the configured output format
+ * @throws IllegalArgumentException if {@code payload} is {@code null}
+ * @throws FrameBuildException if any layer fails to build or if the layer
+ * chain produces an invalid frame
+ */
+ T build(ByteBuffer payload);
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPFrameParser.java b/src/main/java/com/flamingo/comm/llp/core/LLPFrameParser.java
new file mode 100644
index 0000000..cf8e5ef
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPFrameParser.java
@@ -0,0 +1,12 @@
+package com.flamingo.comm.llp.core;
+
+public interface LLPFrameParser {
+
+ /**
+ * Parses a validated transport frame into a structured LLPFrame.
+ *
+ * @param rawFrame validated raw frame from transport layer
+ * @return parse result containing parsed structure or error
+ */
+ LLPFrame parse(LLPRawFrame rawFrame);
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPIncrementalParser.java b/src/main/java/com/flamingo/comm/llp/core/LLPIncrementalParser.java
new file mode 100644
index 0000000..17013eb
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPIncrementalParser.java
@@ -0,0 +1,174 @@
+package com.flamingo.comm.llp.core;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Incremental LLP frame parser designed for streaming transports.
+ *
+ * This parser allows LLP frames to be processed progressively as bytes
+ * arrive from a transport such as TCP, serial ports, UART, Bluetooth,
+ * RF modules, or any other byte-oriented communication channel.
+ *
+ * The parser internally performs:
+ *
+ * - Transport deframing using {@link LLPTransportDeframer}
+ * - Layer parsing using {@link LLPFrameParser}
+ *
+ *
+ * Parsed frames and transport errors are accumulated internally and can be
+ * retrieved using the polling methods:
+ *
+ * - {@link #pollFrames()}
+ * - {@link #pollErrors()}
+ *
+ *
+ * This class follows a pull-based model:
+ *
+ * - Input bytes are pushed into the parser using {@code feed(...)} methods
+ * - Completed frames and errors are later retrieved by polling
+ *
+ *
+ * Instances of this class are not thread-safe.
+ */
+public final class LLPIncrementalParser {
+
+ private final LLPTransportDeframer deframer;
+ private final LLPFrameParser parser;
+
+ private final List completedFrames = new ArrayList<>();
+ private final List errors = new ArrayList<>();
+
+ /**
+ * Creates a new incremental LLP parser.
+ *
+ * @param provider provider used to resolve layer parsers
+ * @param maxPayload maximum allowed transport payload size in bytes,
+ * or a negative value to use the transport default
+ * @param timeoutMs timeout in milliseconds between received bytes before
+ * the transport parser resets, or a negative value to disable timeout handling
+ */
+ LLPIncrementalParser(LayerParserProvider provider,
+ int maxPayload,
+ long timeoutMs) {
+
+ this.deframer = new LLPTransportDeframer(maxPayload, timeoutMs);
+ this.parser = new SimpleFrameParser(provider);
+
+ this.deframer.addListener(new FrameListener());
+ }
+
+ // =====================
+ // INPUT
+ // =====================
+
+ /**
+ * Feeds transport bytes into the parser.
+ *
+ * The provided byte array may contain:
+ *
+ * - A partial LLP frame
+ * - A complete LLP frame
+ * - Multiple concatenated LLP frames
+ *
+ *
+ * Any completed frames can later be retrieved using
+ * {@link #pollFrames()}.
+ *
+ * @param data transport bytes to process
+ */
+ public void feed(byte[] data) {
+ deframer.processBytes(data);
+ }
+
+ /**
+ * Feeds bytes from the provided {@link ByteBuffer} into the parser.
+ *
+ * Bytes are consumed from the buffer starting at its current position
+ * until no remaining bytes are available.
+ *
+ * @param buffer buffer containing transport bytes
+ */
+ public void feed(ByteBuffer buffer) {
+ while (buffer.hasRemaining()) {
+ deframer.processByte(buffer.get());
+ }
+ }
+
+ /**
+ * Feeds a single transport byte into the parser.
+ *
+ * This method is useful for highly incremental or interrupt-driven
+ * transports where bytes arrive individually.
+ *
+ * @param b transport byte to process
+ */
+ public void feed(byte b) {
+ deframer.processByte(b);
+ }
+
+ // =====================
+ // OUTPUT (pull model)
+ // =====================
+
+ /**
+ * Returns all completed LLP frames accumulated since the previous poll.
+ *
+ * After this method returns, the internal completed-frame queue
+ * is cleared.
+ *
+ * @return immutable list of completed parsed frames;
+ * never {@code null}
+ */
+ public List pollFrames() {
+ List out = List.copyOf(completedFrames);
+ completedFrames.clear();
+ return out;
+ }
+
+ /**
+ * Returns all transport errors accumulated since the previous poll.
+ *
+ * After this method returns, the internal error queue is cleared.
+ *
+ * @return immutable list of transport error codes;
+ * never {@code null}
+ */
+ public List pollErrors() {
+ List out = List.copyOf(errors);
+ errors.clear();
+ return out;
+ }
+
+ // =====================
+ // CALLBACKS
+ // =====================
+
+ /**
+ * Internal listener used to receive callbacks from the transport deframer.
+ */
+ private class FrameListener implements LLPTransportDeframer.LLPFrameListener {
+
+ /**
+ * Called when a complete transport frame has been successfully received.
+ *
+ * @param rawFrame deframed transport frame
+ */
+ @Override
+ public void onFrameReceived(LLPRawFrame rawFrame) {
+ LLPFrame frame = parser.parse(rawFrame);
+ completedFrames.add(frame);
+ }
+
+ /**
+ * Called when a transport-level error occurs while processing bytes.
+ *
+ * @param code transport error code
+ */
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ errors.add(code);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java b/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
new file mode 100644
index 0000000..a9bacce
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
@@ -0,0 +1,104 @@
+package com.flamingo.comm.llp.core;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+/**
+ * Represents a raw LLP frame extracted at the transport layer.
+ *
+ * This object is produced by the {@link LLPTransportDeframer} after successful
+ * synchronization, byte unstuffing, and CRC validation.
+ *
+ * It contains only transport-level information and does not interpret
+ * the payload structure or protocol layers.
+ *
+ * This class is immutable and thread-safe.
+ */
+public final class LLPRawFrame {
+ private static final byte[] EMPTY_ARRAY = new byte[0];
+ private final byte[] payload;
+ private final int crc;
+ private final long timestamp;
+
+ /**
+ * Creates a new raw frame with the current system timestamp.
+ *
+ * @param payload raw payload bytes (contains encoded layers)
+ * @param crc validated CRC value
+ */
+ LLPRawFrame(byte[] payload, int crc) {
+ this(payload, crc, System.currentTimeMillis());
+ }
+
+ /**
+ * Creates a new raw frame.
+ *
+ * @param payload raw payload bytes (contains encoded layers)
+ * @param crc validated CRC value
+ * @param timestamp creation timestamp in milliseconds
+ */
+ LLPRawFrame(byte[] payload, int crc, long timestamp) {
+ this.crc = crc;
+ this.timestamp = timestamp;
+
+ if (payload == null || payload.length == 0) {
+ this.payload = EMPTY_ARRAY;
+ } else {
+ this.payload = payload.clone();
+ }
+ }
+
+ /**
+ * Creates a new raw frame.
+ *
+ * @param payload raw payload bytes (contains encoded layers)
+ * @param payloadLen length of payload
+ * @param crc validated CRC value
+ * @param timestamp creation timestamp in milliseconds
+ * @throws IllegalArgumentException if payloadLen is larger than the payload buffer
+ */
+ LLPRawFrame(byte[] payload, int payloadLen, int crc, long timestamp) throws IllegalArgumentException {
+ this.crc = crc;
+ this.timestamp = timestamp;
+
+ if (payload == null || payloadLen <= 0) {
+ this.payload = EMPTY_ARRAY;
+ } else if (payloadLen > payload.length) {
+ throw new IllegalArgumentException(
+ "Invalid payloadLen: exceeds actual payload size"
+ );
+ } else {
+ this.payload = Arrays.copyOf(payload, payloadLen);
+ }
+ }
+
+ /**
+ * Returns a read-only view of the payload.
+ *
+ * The returned buffer is a duplicate with independent position/limit,
+ * but shares the same underlying data.
+ *
+ * @return read-only ByteBuffer containing payload data
+ */
+ public ByteBuffer payload() {
+ return ByteBuffer.wrap(payload).asReadOnlyBuffer();
+ }
+
+ /**
+ * Returns the validated CRC value.
+ *
+ * @return CRC value
+ */
+ public int crc() {
+ return crc;
+ }
+
+ /**
+ * Returns the timestamp when the frame was created.
+ *
+ * @return timestamp in milliseconds
+ */
+ public long timestamp() {
+ return timestamp;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/LLPParser.java b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
similarity index 51%
rename from src/main/java/com/flamingo/comm/llp/LLPParser.java
rename to src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
index e6891f3..abaa50f 100644
--- a/src/main/java/com/flamingo/comm/llp/LLPParser.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
@@ -1,6 +1,6 @@
-package com.flamingo.comm.llp;
+package com.flamingo.comm.llp.core;
-import com.flamingo.comm.llp.crc.CRC16CCITT;
+import com.flamingo.comm.llp.util.CRC16CCITT;
import com.flamingo.comm.llp.util.Statistics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -11,26 +11,35 @@
import java.util.concurrent.ConcurrentLinkedQueue;
/**
- * LLP frame parser based on a byte-oriented state machine.
+ * Transport-layer state machine responsible for deframing LLP byte streams.
*
- * This parser processes incoming data byte-by-byte and reconstructs valid LLP frames.
- * It is designed to work with unreliable or noisy transport layers (e.g., RF, UART, TCP streams),
- * providing resynchronization, timeout handling, CRC validation, and Byte Stuffing.
+ * This component processes a continuous stream of bytes and extracts valid LLP frames by:
+ *
+ * - Synchronizing using magic bytes
+ * - Handling byte unstuffing (escape sequences)
+ * - Validating frame integrity using CRC16-CCITT
+ *
+ *
+ * The deframer is stateful and not thread-safe for concurrent byte ingestion,
+ * but supports concurrent listener notification and frame consumption.
+ *
+ * Valid frames are emitted as {@link LLPRawFrame} instances.
*/
-public class LLPParser {
- private static final Logger logger = LoggerFactory.getLogger(LLPParser.class);
+public final class LLPTransportDeframer {
+
+ private static final Logger logger = LoggerFactory.getLogger(LLPTransportDeframer.class);
private static final byte MAGIC_1 = (byte) 0xAA;
private static final byte MAGIC_2 = (byte) 0x55;
private static final long DEFAULT_TIMEOUT_MS = 2000;
+ private static final int DEFAULT_MAX_PAYLOAD_SIZE_BYTES = 1024 * 1024; // 1 MB
- private final byte[] headerBuf = new byte[8];
+ private final byte[] headerBuf = new byte[4];
private final byte[] payload;
private final long timeoutMs;
- private final Queue frameQueue = new ConcurrentLinkedQueue<>();
- private final Statistics statistics = new Statistics();
- // Listeners
+
private final Queue listeners = new ConcurrentLinkedQueue<>();
+ private final Statistics statistics = new Statistics();
private State state = State.WAIT_MAGIC1;
private boolean escapePending = false;
@@ -39,108 +48,116 @@ public class LLPParser {
private int payloadIdx = 0;
private int crcReceived = 0;
private int crcCalculated = 0xFFFF;
+
private long lastByteTime = System.currentTimeMillis();
/**
- * Creates a parser with default maximum payload size and timeout.
+ * Creates a deframer with default configuration.
*/
- public LLPParser() {
- this(LLPFrame.DEFAULT_MAX_PAYLOAD);
+ public LLPTransportDeframer() {
+ this(DEFAULT_MAX_PAYLOAD_SIZE_BYTES, DEFAULT_TIMEOUT_MS);
}
/**
- * Creates a parser with a custom maximum payload size.
+ * Creates a deframer with a custom maximum payload size.
*
- * @param maxPayload maximum payload size in bytes
+ * @param maxPayloadBytes maximum allowed payload size in bytes
*/
- public LLPParser(int maxPayload) {
- this(maxPayload, DEFAULT_TIMEOUT_MS);
+ public LLPTransportDeframer(int maxPayloadBytes) {
+ this(maxPayloadBytes, DEFAULT_TIMEOUT_MS);
}
/**
- * Creates a parser with custom payload size and timeout.
+ * Creates a deframer with custom configuration.
*
- * @param maxPayload maximum payload size in bytes
- * @param timeoutMs frame timeout in milliseconds
+ * @param maxPayloadBytes maximum allowed payload size in bytes
+ * @param timeoutMs timeout in milliseconds between bytes before resetting the parser
*/
- public LLPParser(int maxPayload, long timeoutMs) {
- if (maxPayload < 1) {
- maxPayload = LLPFrame.DEFAULT_MAX_PAYLOAD;
+ public LLPTransportDeframer(int maxPayloadBytes, long timeoutMs) {
+ if (maxPayloadBytes < 1) {
+ maxPayloadBytes = DEFAULT_MAX_PAYLOAD_SIZE_BYTES;
}
if (timeoutMs < 1) {
timeoutMs = DEFAULT_TIMEOUT_MS;
}
- this.payload = new byte[maxPayload];
+ this.payload = new byte[maxPayloadBytes];
this.timeoutMs = timeoutMs;
}
/**
- * Processes a single byte from the input stream, resolving byte stuffing transparently.
+ * Processes a single byte from the input stream.
+ *
+ * This method advances the internal state machine and may produce a complete frame.
*
* @param b incoming byte
- * @return a complete {@link LLPFrame} or {@code null} if not complete
+ * @return a completed {@link LLPRawFrame}, or {@code null} if the frame is not yet complete
*/
- public LLPFrame processByte(byte b) {
+ public LLPRawFrame processByte(byte b) {
+
// Timeout handling
if (state != State.WAIT_MAGIC1) {
if (System.currentTimeMillis() - lastByteTime > timeoutMs) {
logger.warn("Frame timeout - resetting parser");
statistics.recordTimeout();
reset();
- notifyError(LLPErrorCode.TIMEOUT);
+ notifyError(TransportErrorCode.TIMEOUT);
- // Allow a magic byte to restart the sequence immediately
+ // Allow immediate resync if current byte starts a new frame
if (b == MAGIC_1) {
state = State.WAIT_MAGIC2;
}
return null;
}
}
+
lastByteTime = System.currentTimeMillis();
- // ================= ESCAPE / STUFFING HANDLING =================
- // Only evaluate escape sequences if we are inside a frame
+ // ================= ESCAPE / BYTE UNSTUFFING =================
if (state != State.WAIT_MAGIC1 && state != State.WAIT_MAGIC2) {
+
if (escapePending) {
escapePending = false;
if (b == MAGIC_2) {
- // OVERLAPPED FRAME DETECTED! (0xAA 0x55 sequence found in data)
- logger.warn("Overlapped frame detected, aborting current and syncing new frame");
+ // Overlapped frame detected (0xAA 0x55 inside payload)
+ logger.warn("Overlapped frame detected, resynchronizing");
statistics.recordError();
- notifyError(LLPErrorCode.SYNC_ERROR);
+ notifyError(TransportErrorCode.SYNC_ERROR);
crcCalculated = 0xFFFF;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_1);
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_2);
+
headerBuf[0] = MAGIC_1;
headerBuf[1] = MAGIC_2;
- state = State.READ_TYPE;
- return null; // Consumed as MAGIC_2
+
+ state = State.READ_LEN_L;
+ return null;
} else if (b == 0x00) {
- // Escaped data byte recovered. Restore to MAGIC_1.
+ // Escaped MAGIC_1 restored
b = MAGIC_1;
+
} else {
- // Invalid sequence
- logger.error("Invalid sync sequence: 0xAA followed by 0x{}", Integer.toHexString(b & 0xFF));
+ logger.error("Invalid escape sequence: 0xAA followed by 0x{}",
+ Integer.toHexString(b & 0xFF));
statistics.recordError();
reset();
- notifyError(LLPErrorCode.SYNC_ERROR);
+ notifyError(TransportErrorCode.SYNC_ERROR);
return null;
}
+
} else if (b == MAGIC_1) {
- // Suspends processing to wait for the next byte to clarify the sequence
escapePending = true;
return null;
}
}
- // ==============================================================
+ // ============================================================
- // Standard State Machine (Operates on unstuffed bytes)
switch (state) {
+
case WAIT_MAGIC1:
if (b == MAGIC_1) {
headerBuf[0] = b;
@@ -151,81 +168,49 @@ public LLPFrame processByte(byte b) {
case WAIT_MAGIC2:
if (b == MAGIC_2) {
headerBuf[1] = b;
+
crcCalculated = 0xFFFF;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_1);
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_2);
- state = State.READ_VERSION;
+
+ state = State.READ_LEN_L;
+
} else if (b == MAGIC_1) {
- // RF robustness: another MAGIC_1 received
+ // Stay in sync (robustness against repeated MAGIC_1)
state = State.WAIT_MAGIC2;
+
} else {
state = State.WAIT_MAGIC1;
}
break;
- case READ_VERSION:
- headerBuf[2] = b;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
-
- if (b != LLP.PROTOCOL_VERSION) {
- logger.warn("Different protocol version: received={}, expected={}",
- b, LLP.PROTOCOL_VERSION);
- }
-
- state = State.READ_TYPE;
- break;
-
- case READ_TYPE:
- headerBuf[3] = b;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- state = State.READ_ID_L;
- break;
-
- case READ_ID_L:
- headerBuf[4] = b;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- state = State.READ_ID_H;
- break;
-
- case READ_ID_H:
- headerBuf[5] = b;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- state = State.READ_LEN_L;
- break;
-
case READ_LEN_L:
- headerBuf[6] = b;
+ headerBuf[2] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
state = State.READ_LEN_H;
break;
case READ_LEN_H:
- headerBuf[7] = b;
+ headerBuf[3] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- payloadLen = (headerBuf[6] & 0xFF) | ((headerBuf[7] & 0xFF) << 8);
+ payloadLen = (headerBuf[2] & 0xFF) | ((headerBuf[3] & 0xFF) << 8);
if (payloadLen > payload.length) {
logger.error("Payload length {} exceeds maximum {}", payloadLen, payload.length);
statistics.recordError();
reset();
- notifyError(LLPErrorCode.PAYLOAD_LEN_INVALID);
+ notifyError(TransportErrorCode.PAYLOAD_LEN_INVALID);
return null;
}
payloadIdx = 0;
-
- if (payloadLen == 0) {
- state = State.READ_CRC_L;
- } else {
- state = State.READ_PAYLOAD;
- }
+ state = (payloadLen == 0) ? State.READ_CRC_L : State.READ_PAYLOAD;
break;
case READ_PAYLOAD:
- payload[payloadIdx] = b;
+ payload[payloadIdx++] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- payloadIdx++;
if (payloadIdx == payloadLen) {
state = State.READ_CRC_L;
@@ -246,68 +231,78 @@ public LLPFrame processByte(byte b) {
Integer.toHexString(crcCalculated));
statistics.recordError();
reset();
- notifyError(LLPErrorCode.CHECKSUM_INVALID);
+ notifyError(TransportErrorCode.CHECKSUM_INVALID);
return null;
}
- // Full frame
- LLPFrame frame = createFrame();
+ LLPRawFrame frame = new LLPRawFrame(payload, payloadLen, crcCalculated, System.currentTimeMillis());
+
statistics.recordSuccess();
reset();
+
notifySuccess(frame);
- frameQueue.offer(frame);
+
return frame;
}
return null;
}
- public List processBytes(byte[] data) {
- List frames = new ArrayList<>();
+ /**
+ * Processes multiple bytes from the input stream.
+ *
+ * @param data input byte array
+ * @return list of completed frames (possibly empty)
+ */
+ public List processBytes(byte[] data) {
+ List frames = new ArrayList<>();
for (byte b : data) {
- LLPFrame f = processByte(b);
- if (f != null) frames.add(f);
+ LLPRawFrame frame = processByte(b);
+ if (frame != null) {
+ frames.add(frame);
+ }
}
return frames;
}
- private LLPFrame createFrame() {
- byte version = headerBuf[2];
- byte type = headerBuf[3];
- int id = (headerBuf[4] & 0xFF) | ((headerBuf[5] & 0xFF) << 8);
- byte[] payloadCopy = new byte[payloadLen];
- System.arraycopy(payload, 0, payloadCopy, 0, payloadLen);
-
- return new LLPFrame(type, id, version, payloadCopy, crcCalculated);
- }
-
/**
- * Resets parser state to initial synchronization state.
+ * Resets the internal state machine to its initial synchronization state.
*/
private void reset() {
state = State.WAIT_MAGIC1;
payloadIdx = 0;
crcCalculated = 0xFFFF;
- escapePending = false; // Reset escape flag
+ escapePending = false;
}
/**
- * Registers a frame listener.
+ * Registers a listener to receive frame events.
+ *
+ * @param listener listener to add
*/
public void addListener(LLPFrameListener listener) {
listeners.offer(listener);
}
/**
- * Removes a frame listener.
+ * Removes a previously registered listener.
+ *
+ * @param listener listener to remove
*/
public void removeListener(LLPFrameListener listener) {
listeners.remove(listener);
}
- // ============= LISTENER MANAGEMENT =============
+ /**
+ * Returns runtime statistics of the deframer.
+ *
+ * @return statistics instance
+ */
+ public Statistics getStatistics() {
+ return statistics;
+ }
- private void notifySuccess(LLPFrame frame) {
+ private void notifySuccess(LLPRawFrame frame) {
for (LLPFrameListener listener : listeners) {
try {
listener.onFrameReceived(frame);
@@ -317,50 +312,43 @@ private void notifySuccess(LLPFrame frame) {
}
}
- private void notifyError(LLPErrorCode errorCode) {
+ private void notifyError(TransportErrorCode transportErrorCode) {
for (LLPFrameListener listener : listeners) {
try {
- listener.onFrameError(errorCode);
+ listener.onFrameError(transportErrorCode);
} catch (Exception e) {
logger.error("Listener error", e);
}
}
}
- // ============= GETTERS =============
-
- /**
- * Returns parsed frames queue.
- */
- public Queue getFrameQueue() {
- return frameQueue;
- }
-
- /**
- * Returns parser statistics.
- */
- public Statistics getStatistics() {
- return statistics;
- }
-
private enum State {
- WAIT_MAGIC1, WAIT_MAGIC2, READ_VERSION, READ_TYPE, READ_ID_L, READ_ID_H,
- READ_LEN_L, READ_LEN_H, READ_PAYLOAD, READ_CRC_L, READ_CRC_H
+ WAIT_MAGIC1,
+ WAIT_MAGIC2,
+ READ_LEN_L,
+ READ_LEN_H,
+ READ_PAYLOAD,
+ READ_CRC_L,
+ READ_CRC_H
}
/**
- * Listener interface for receiving parser events.
+ * Listener interface for receiving deframer events.
*/
public interface LLPFrameListener {
/**
- * Called when a valid frame is received.
+ * Invoked when a valid frame is successfully parsed.
+ *
+ * @param frame parsed frame
*/
- void onFrameReceived(LLPFrame frame);
+ void onFrameReceived(LLPRawFrame frame);
/**
- * Called when a frame error occurs.
+ * Invoked when a frame parsing error occurs.
+ *
+ * @param transportErrorCode error type
*/
- void onFrameError(LLPErrorCode errorCode);
+ void onFrameError(TransportErrorCode transportErrorCode);
}
}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java b/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
new file mode 100644
index 0000000..6813884
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
@@ -0,0 +1,279 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.util.ByteWriter;
+import com.flamingo.comm.llp.util.CRC16CCITT;
+
+import java.util.Arrays;
+
+/**
+ * Pure logic transport framer for LLP protocol.
+ * Handles magic bytes, byte stuffing, and CRC calculation without allocating intermediate buffers.
+ */
+public final class LLPTransportFramer {
+
+ private static final byte MAGIC_1 = (byte) 0xAA;
+ private static final byte MAGIC_2 = (byte) 0x55;
+
+ private LLPTransportFramer() {
+ // Prevent instantiation
+ }
+
+ /**
+ * Builds a complete LLP transport frame and writes it into the provided {@link ByteWriter}.
+ *
+ * This method performs the full framing process including:
+ *
+ * - Writing magic bytes (frame header)
+ * - Encoding payload length (little-endian, with byte stuffing)
+ * - Writing payload with byte stuffing
+ * - Calculating and appending CRC16-CCITT (also stuffed)
+ *
+ *
+ * Performance considerations:
+ *
+ * - No internal buffers are allocated
+ * - No defensive copies are made over the provided payload
+ * - The payload array is consumed directly for maximum efficiency
+ *
+ *
+ * Thread-safety and immutability:
+ *
+ * - This method is not thread-safe by itself
+ * - The provided {@code payload} array MUST NOT be modified while this method is executing
+ * - No immutability guarantees are enforced internally
+ * - If immutability or multi-threaded safety is required, it must be handled externally
+ * (e.g., by copying the payload or using a higher-level wrapper)
+ *
+ *
+ * Important: The caller is responsible for ensuring that the provided
+ * {@link ByteWriter} has enough capacity to hold the resulting frame, including
+ * worst-case byte stuffing expansion.
+ *
+ * @param payload the payload to encode (may be {@code null}, treated as empty)
+ * @param writer destination writer where the encoded frame will be written
+ * @return the total number of bytes written to the writer
+ */
+ private static int buildInternal(byte[] payload, ByteWriter writer) {
+ if (payload == null) {
+ payload = new byte[0];
+ }
+
+ int written = 0;
+
+ // 1. Write Magic Bytes (Never stuffed)
+ writer.write(MAGIC_1);
+ written++;
+ writer.write(MAGIC_2);
+ written++;
+
+ // 2. Calculate Length
+ byte lenL = (byte) (payload.length & 0xFF);
+ byte lenH = (byte) ((payload.length >> 8) & 0xFF);
+
+ // 3. Write Length (Stuffed)
+ written += writeStuffed(writer, lenL);
+ written += writeStuffed(writer, lenH);
+
+ // 4. Initialize and compute CRC
+ int crc = 0xFFFF;
+ crc = CRC16CCITT.updateCRC(crc, MAGIC_1);
+ crc = CRC16CCITT.updateCRC(crc, MAGIC_2);
+ crc = CRC16CCITT.updateCRC(crc, lenL);
+ crc = CRC16CCITT.updateCRC(crc, lenH);
+
+ // 5. Write Payload and update CRC
+ for (byte b : payload) {
+ crc = CRC16CCITT.updateCRC(crc, b);
+ written += writeStuffed(writer, b);
+ }
+
+ // 6. Write CRC (Stuffed)
+ byte crcL = (byte) (crc & 0xFF);
+ byte crcH = (byte) ((crc >> 8) & 0xFF);
+
+ written += writeStuffed(writer, crcL);
+ written += writeStuffed(writer, crcH);
+
+ return written;
+ }
+
+ /**
+ * Builds an LLP frame into a pre-allocated byte array.
+ *
+ *
This method encodes the given payload into a complete LLP transport frame
+ * and writes it into the provided output buffer starting at the given offset.
+ *
+ *
Performance characteristics:
+ *
+ * - No internal allocations are performed
+ * - No defensive copies are made over the provided payload
+ * - Designed for high-performance and low-latency scenarios
+ *
+ *
+ * Thread-safety and immutability:
+ *
+ * - This method is not thread-safe
+ * - The provided {@code payload} array MUST NOT be modified while this method is executing
+ * - No immutability guarantees are enforced
+ * - If thread-safety or immutability is required, it must be handled externally
+ *
+ *
+ * Buffer requirements:
+ *
+ * - The {@code outBuffer} must have enough capacity for the worst-case frame size
+ * (including byte stuffing expansion)
+ * - This method validates the capacity using {@link #estimateMaxSize(int)}
+ *
+ *
+ * @param payload the raw payload to wrap (may be {@code null}, treated as empty)
+ * @param outBuffer the destination buffer
+ * @param offset the starting index in the destination buffer
+ * @return the total number of bytes written into {@code outBuffer}
+ * @throws IllegalArgumentException if {@code outBuffer} is too small for the worst-case scenario
+ */
+ public static int build(byte[] payload, byte[] outBuffer, int offset) {
+
+ int payloadLen = payload != null ? payload.length : 0;
+
+ // Worst-case calculation
+ int maxSize = offset + estimateMaxSize(payloadLen);
+
+ if (outBuffer.length < maxSize) {
+ throw new IllegalArgumentException(
+ "Output buffer too small. Required worst-case: " + maxSize + ", provided: " + outBuffer.length
+ );
+ }
+
+ ArrayByteWriter writer = new ArrayByteWriter(outBuffer, offset);
+ return buildInternal(payload, writer);
+ }
+
+ /**
+ * Builds an LLP frame using a custom {@link ByteWriter}.
+ *
+ * This method encodes the given payload into a complete LLP transport frame
+ * and writes it through the provided {@link ByteWriter}. This allows integration
+ * with different output targets such as {@code ByteBuffer}, streams, or custom
+ * high-performance buffers.
+ *
+ *
Performance characteristics:
+ *
+ * - No internal allocations are performed
+ * - No defensive copies are made over the provided payload
+ * - Designed for zero-copy and high-throughput scenarios
+ *
+ *
+ * Thread-safety and immutability:
+ *
+ * - This method is not thread-safe
+ * - The provided {@code payload} array MUST NOT be modified while this method is executing
+ * - The thread-safety of this method depends entirely on the provided {@code ByteWriter}
+ * - No immutability guarantees are enforced
+ * - If thread-safety or immutability is required, it must be handled externally
+ *
+ *
+ * Important:
+ *
+ * - The {@code ByteWriter} implementation is responsible for handling capacity, bounds,
+ * and any synchronization if required
+ *
+ *
+ * @param payload the raw payload (may be {@code null}, treated as empty)
+ * @param writer the destination writer
+ * @return the total number of bytes written
+ */
+ public static int build(byte[] payload, ByteWriter writer) {
+ return buildInternal(payload, writer);
+ }
+
+ /**
+ * Builds an LLP frame in a safe and self-contained manner.
+ *
+ * This method creates a new byte array containing the fully encoded LLP frame,
+ * including header, payload, CRC, and byte stuffing. The returned array is sized
+ * exactly to the number of bytes written.
+ *
+ *
Safety guarantees:
+ *
+ * - No internal buffers are exposed
+ * - The returned array is independent and can be freely modified by the caller
+ * - No shared mutable state is used
+ *
+ *
+ * Thread-safety:
+ *
+ * - This method is thread-safe for typical use cases
+ * - It does not rely on external mutable state
+ *
+ *
+ * Performance considerations:
+ *
+ * - Allocates a new buffer for each invocation
+ * - May perform an additional array copy to return a right-sized result
+ * - Less efficient than {@link #build(byte[], byte[], int)} but safer and easier to use
+ *
+ *
+ * The payload is defensively copied to prevent external modifications
+ * during frame construction, ensuring consistency even in concurrent environments.
+ *
+ * @param payload the payload to encode (may be {@code null}, treated as empty)
+ * @return a new byte array containing the encoded LLP frame
+ */
+ public static byte[] buildSafe(byte[] payload) {
+ byte[] safePayload = payload != null ? payload.clone() : new byte[0];
+ byte[] outBuffer = new byte[estimateMaxSize(safePayload.length)];
+ int written = build(safePayload, outBuffer, 0);
+
+ return Arrays.copyOf(outBuffer, written);
+ }
+
+ /**
+ * Writes a byte and applies byte stuffing if it matches MAGIC_1.
+ */
+ private static int writeStuffed(ByteWriter writer, byte b) {
+ writer.write(b);
+ if (b == MAGIC_1) {
+ writer.write((byte) 0x00);
+ return 2;
+ }
+ return 1;
+ }
+
+ /**
+ * Returns the maximum frame size based on the payload in the worst-case scenario (where all its bytes are padded)
+ *
+ * @param payloadLen Payload size. Must be greater than or equal to 0
+ * @return the size of the frame in the worst-case scenario.
+ */
+ public static int estimateMaxSize(int payloadLen) {
+ if (payloadLen < 0) {
+ throw new IllegalArgumentException("payloadLen must be a positive number");
+ }
+ // Worst-case calculation:
+ // Magic(2) + StuffedLen(4) + StuffedPayload(len*2) + StuffedCRC(4)
+ return 2 + 4 + (payloadLen * 2) + 4;
+ }
+
+ /**
+ * Lightweight internal ByteWriter for byte arrays.
+ */
+ static final class ArrayByteWriter implements ByteWriter {
+
+ private final byte[] buffer;
+ private int idx;
+
+ ArrayByteWriter(byte[] buffer, int offset) {
+ this.buffer = buffer;
+ this.idx = offset;
+ }
+
+ @Override
+ public void write(byte b) {
+ // Safety net added just in case logic changes in the future
+ if (idx >= buffer.length) {
+ throw new IndexOutOfBoundsException("Buffer overflow at index: " + idx);
+ }
+ buffer[idx++] = b;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
new file mode 100644
index 0000000..a98940d
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
@@ -0,0 +1,29 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerParser;
+
+import java.util.Optional;
+
+/**
+ * Functional interface used to resolve a {@link LLPLayerParser}
+ * for a given layer identifier.
+ *
+ *
This abstraction allows decoupling the core parser logic from
+ * the underlying mechanism used to discover or provide layer parsers.
+ * It can be backed by a registry, dependency injection, or custom logic.
+ *
+ * This interface is designed to be lightweight and easily replaceable,
+ * making it suitable for dependency injection and testing.
+ */
+@FunctionalInterface
+public interface LayerParserProvider {
+
+ /**
+ * Returns a parser for the given layer identifier.
+ *
+ * @param layerId the layer identifier (1-255)
+ * @return an {@link Optional} containing the corresponding parser if available,
+ * or empty if the layer is not recognized
+ */
+ Optional get(int layerId);
+}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LayerParserRegistry.java b/src/main/java/com/flamingo/comm/llp/core/LayerParserRegistry.java
new file mode 100644
index 0000000..1667366
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerParserRegistry.java
@@ -0,0 +1,115 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerParser;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ServiceLoader;
+
+/**
+ * Registry of {@link LLPLayerParser} implementations indexed by their layer ID.
+ *
+ * This registry is responsible for holding and providing access to all available
+ * {@link LLPLayerParser} instances. Parsers are indexed using their unique
+ * layer identifier, as defined by {@link LLPLayerParser#getLayerId()}.
+ *
+ * Initialization
+ *
+ * - In production, the default instance is lazily initialized using
+ * {@link ServiceLoader} to discover implementations via Java SPI.
+ * - For testing purposes, custom instances can be created using
+ * {@link #createForTest(Iterable)}.
+ *
+ *
+ * Constraints
+ *
+ * - Each parser must declare a unique layer ID.
+ * - If duplicate IDs are detected during initialization, an
+ * {@link IllegalStateException} is thrown.
+ *
+ *
+ * Thread Safety
+ * This class is immutable after construction and safe for concurrent access.
+ *
+ * Design Notes
+ *
+ * - The registry is intentionally decoupled from {@link ServiceLoader}
+ * by accepting an {@link Iterable}, improving testability and flexibility.
+ * - The internal storage is an immutable {@link Map}, ensuring that
+ * parser definitions cannot be modified after initialization.
+ *
+ */
+final class LayerParserRegistry {
+
+ private final Map parsers;
+
+ /**
+ * Creates a new registry from the provided parsers.
+ *
+ * This constructor is private to enforce controlled creation through
+ * factory methods.
+ *
+ * @param loadedParsers an iterable collection of parsers to register
+ * @throws IllegalStateException if two parsers declare the same layer ID
+ */
+ private LayerParserRegistry(Iterable loadedParsers) {
+ Map tempMap = new HashMap<>();
+
+ for (LLPLayerParser parser : loadedParsers) {
+ int parserId = parser.getLayerId();
+ if (tempMap.containsKey(parserId)) {
+ throw new IllegalStateException(
+ "Duplicate layer ID detected: " + parserId +
+ " for parsers [" + tempMap.get(parserId).getClass().getName() +
+ ", " + parser.getClass().getName() + "]"
+ );
+ }
+ tempMap.put(parserId, parser);
+ }
+
+ this.parsers = Map.copyOf(tempMap);
+ }
+
+ /**
+ * Returns the default registry instance initialized via Java SPI.
+ *
+ * This method uses a lazy-loaded singleton pattern to ensure that
+ * parsers are discovered only once and initialization is thread-safe.
+ *
+ * @return the singleton {@code LayerParserRegistry} instance
+ */
+ static LayerParserRegistry getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ /**
+ * Creates a registry instance using the provided parsers.
+ *
+ * This method is intended for testing purposes, allowing callers to
+ * bypass SPI discovery and provide controlled parser instances.
+ *
+ * @param parsers the parsers to register
+ * @return a new {@code LayerParserRegistry} instance
+ * @throws IllegalStateException if duplicate layer IDs are found
+ */
+ static LayerParserRegistry createForTest(Iterable parsers) {
+ return new LayerParserRegistry(parsers);
+ }
+
+ /**
+ * Returns the parser associated with the given layer ID.
+ *
+ * @param id the layer identifier
+ * @return an {@link Optional} containing the parser if present,
+ * or empty if no parser is registered for the given ID
+ */
+ Optional get(int id) {
+ return Optional.ofNullable(parsers.get(id));
+ }
+
+ private static final class Holder {
+ static final LayerParserRegistry INSTANCE =
+ new LayerParserRegistry(ServiceLoader.load(LLPLayerParser.class));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/NodeChain.java b/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
new file mode 100644
index 0000000..f0af20e
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
@@ -0,0 +1,178 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.util.*;
+import java.util.function.Consumer;
+
+/**
+ * Represents an immutable ordered chain of {@link LLPNode} elements.
+ *
+ * This structure models the layered composition of an LLP frame, where each node
+ * represents a protocol layer. The chain is ordered from outermost layer (index 0)
+ * to the innermost (deepest) layer.
+ *
+ * The class provides utility methods for querying, traversing, and visiting nodes
+ * without exposing internal mutability.
+ *
+ * This class is immutable and thread-safe.
+ */
+public final class NodeChain implements Iterable {
+ public static final NodeChain EMPTY = new NodeChain(Collections.emptyList());
+ private final List nodes;
+
+ /**
+ * Creates a new {@code LLPNodeChain} from the provided list of nodes.
+ *
+ * The input list is defensively copied to guarantee immutability.
+ *
+ * @param nodes ordered list of nodes (outer → inner)
+ */
+ private NodeChain(List nodes) {
+ this.nodes = List.copyOf(nodes);
+ }
+
+ /**
+ * Returns the number of nodes in the chain.
+ *
+ * @return total number of nodes
+ */
+ public int size() {
+ return nodes.size();
+ }
+
+ /**
+ * Returns an immutable view of the underlying node list.
+ *
+ * The returned list preserves the original order (outer → inner).
+ *
+ * @return immutable list of nodes
+ */
+ public List asList() {
+ return nodes;
+ }
+
+ /**
+ * Finds the first node of the given type in the chain.
+ *
+ * This method performs a linear search from outermost to innermost node.
+ *
+ * @param type the class type of the node
+ * @param the node subtype
+ * @return an {@link Optional} containing the node if found, otherwise empty
+ */
+ public Optional getNode(Class type) {
+ return nodes.stream()
+ .filter(type::isInstance)
+ .map(type::cast)
+ .findFirst();
+ }
+
+ /**
+ * Finds the first node with the given layer identifier.
+ *
+ * This method performs a linear search from outermost to innermost node.
+ *
+ * @param id the layer identifier
+ * @return an {@link Optional} containing the node if found, otherwise empty
+ */
+ public Optional getNode(int id) {
+ return nodes.stream()
+ .filter(n -> n.getId() == id)
+ .findFirst();
+ }
+
+ /**
+ * Returns the deepest (innermost) node in the chain.
+ *
+ * This is equivalent to the last element in the chain.
+ *
+ * @return the deepest node
+ * @throws java.util.NoSuchElementException if the chain is empty
+ */
+ public LLPNode getDeepestNode() {
+ return nodes.getLast();
+ }
+
+ /**
+ * Traverses all nodes using a visitor pattern.
+ *
+ * A {@link NodeVisitor} is configured using the provided consumer,
+ * and then applied to each node in order.
+ *
+ * @param consumer a function that configures the visitor handlers
+ */
+ public void visit(Consumer consumer) {
+ NodeVisitor visitor = new NodeVisitor();
+ consumer.accept(visitor);
+
+ for (LLPNode node : nodes) {
+ visitor.visit(node);
+ }
+ }
+
+ /**
+ * Returns an iterator over the nodes in this chain.
+ *
+ * The iteration order is from outermost to innermost node.
+ *
+ * @return an iterator over the nodes
+ */
+ @Override
+ public Iterator iterator() {
+ return nodes.iterator();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof NodeChain that)) return false;
+ return nodes.equals(that.nodes);
+ }
+
+ @Override
+ public int hashCode() {
+ return nodes.hashCode();
+ }
+
+ /**
+ * Builder for constructing {@link NodeChain} instances incrementally.
+ *
+ * This builder is mutable and intended to be used during parsing or frame construction.
+ * Once {@link #build()} is called, the resulting {@link NodeChain} is immutable.
+ */
+ public static class Builder {
+ private List nodes;
+
+ /**
+ * Adds a node to the chain.
+ *
+ * Nodes should be added in order from outermost to innermost layer.
+ *
+ * @param node the node to add
+ * @return this builder instance for chaining
+ */
+ public Builder add(LLPNode node) {
+ Objects.requireNonNull(node, "node cannot be null");
+
+ // Lazy array creation to avoid unnecessary array creation
+ if (nodes == null) { nodes = new ArrayList<>(); }
+
+ nodes.add(node);
+ return this;
+ }
+
+ /**
+ * Builds an immutable {@link NodeChain} from the current state.
+ *
+ * @return a new immutable node chain
+ */
+ public NodeChain build() {
+ // It would never be empty, but it is left as is for future compatibility
+ if (nodes == null || nodes.isEmpty()) {
+ return NodeChain.EMPTY;
+ }
+ return new NodeChain(nodes);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java b/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
new file mode 100644
index 0000000..51b6e6d
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
@@ -0,0 +1,64 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+/**
+ * Visitor for processing {@link LLPNode} instances based on their concrete type.
+ *
+ * This class allows registering handlers for specific node types and
+ * executing them when visiting nodes.
+ *
+ * Usage example:
+ * {@code
+ * frame.visitNodes(visitor -> visitor
+ * .on(EncryptionNode.class, node -> {
+ * // handle encryption layer
+ * })
+ * .on(CompressionNode.class, node -> {
+ * // handle compression layer
+ * })
+ * );
+ * }
+ *
+ * This implementation performs exact class matching (no inheritance lookup).
+ *
+ * Note: This is a lightweight alternative to the traditional Visitor pattern,
+ * designed to avoid boilerplate in extensible layer-based architectures.
+ */
+public class NodeVisitor {
+
+ private final Map, Consumer>> handlers = new HashMap<>();
+
+ /**
+ * Registers a handler for a specific node type.
+ *
+ * @param type node class
+ * @param handler handler to execute when a node of this type is visited
+ * @param node type
+ * @return this visitor instance (for chaining)
+ */
+ public NodeVisitor on(Class type, Consumer handler) {
+ handlers.put(type, handler);
+ return this;
+ }
+
+ /**
+ * Visits a node and executes the corresponding handler if registered.
+ *
+ * This method performs an exact class match using {@code node.getClass()}.
+ *
+ * @param node node to process
+ */
+ @SuppressWarnings("unchecked")
+ public void visit(LLPNode node) {
+ Consumer handler = handlers.get(node.getClass());
+
+ if (handler != null) {
+ handler.accept(node);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java b/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
new file mode 100644
index 0000000..5a3bbbd
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
@@ -0,0 +1,181 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerParser;
+import com.flamingo.comm.llp.spi.LayerParseInput;
+import com.flamingo.comm.llp.spi.LayerParseResult;
+import com.flamingo.comm.llp.spi.ParseErrorReason;
+import com.flamingo.comm.llp.util.LayerIds;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Optional;
+
+final class SimpleFrameParser implements LLPFrameParser {
+
+ private static final int EXTENDED_METADATA_FLAG = 255;
+
+ private final LayerParserProvider provider;
+
+ SimpleFrameParser(LayerParserProvider provider) {
+ this.provider = provider;
+ }
+
+ @Override
+ public LLPFrame parse(LLPRawFrame rawFrame) {
+ if (rawFrame == null) {
+ throw new IllegalArgumentException("rawFrame cannot be null");
+ }
+
+ ByteBuffer buffer = rawFrame.payload().asReadOnlyBuffer();
+ buffer.order(ByteOrder.BIG_ENDIAN);
+
+ NodeChain.Builder chainBuilder = new NodeChain.Builder();
+
+ loop:while (buffer.hasRemaining()) {
+
+ int layerId = Byte.toUnsignedInt(buffer.get());
+
+ // Final layer (ID = 0)
+ if (LayerIds.isFinal(layerId)) {
+ chainBuilder.add(FinalNode.of(buffer.slice()));
+ break loop;
+ }
+
+ // --- METADATA LENGTH ---
+ if (!buffer.hasRemaining()) {
+ chainBuilder.add(new FailureNode(
+ layerId,
+ CoreParseErrorReason.LAYER_TOO_SHORT
+ ));
+ break loop;
+ }
+
+ int metaLen = Byte.toUnsignedInt(buffer.get());
+
+ if (metaLen == EXTENDED_METADATA_FLAG) {
+ if (buffer.remaining() < 2) {
+ chainBuilder.add(new FailureNode(
+ layerId,
+ CoreParseErrorReason.LAYER_TOO_SHORT
+ ));
+ break loop;
+ }
+ metaLen = buffer.getShort() & 0xFFFF;
+ }
+
+ // --- METADATA ---
+ if (buffer.remaining() < metaLen) {
+ chainBuilder.add(new FailureNode(
+ layerId,
+ CoreParseErrorReason.METADATA_TRUNCATED
+ ));
+ break loop;
+ }
+
+ ByteBuffer metadata = buffer.slice();
+ metadata.limit(metaLen);
+ buffer.position(buffer.position() + metaLen);
+
+ // --- PAYLOAD ---
+ ByteBuffer layerPayload = buffer.slice();
+
+ Optional parserOpt = provider.get(layerId);
+
+ // --- UNKNOWN LAYER ---
+ if (parserOpt.isEmpty()) {
+
+ if (LayerIds.isNonSkippable(layerId)) {
+ chainBuilder.add(new FailureNode(
+ layerId,
+ CoreParseErrorReason.UNKNOWN_CRITICAL_LAYER
+ ));
+ break loop;
+ }
+
+ // skippable → we're still using the same payload
+ chainBuilder.add(new UnknownNode(layerId, toArray(metadata)));
+ buffer = layerPayload;
+ continue loop;
+ }
+
+ // --- PARSE LAYER ---
+ try {
+ LLPLayerParser parser = parserOpt.get();
+
+ LayerParseResult result = parser.parse(
+ new DefaultLayerParseInput(
+ metadata.asReadOnlyBuffer(),
+ layerPayload.asReadOnlyBuffer()
+ )
+ );
+
+ switch (result) {
+ case LayerParseResult.Success success -> {
+ chainBuilder.add(success.node());
+
+ ByteBuffer next = success.payload();
+ if (!next.hasRemaining()) {
+ break loop;
+ }
+
+ buffer = next.asReadOnlyBuffer().order(ByteOrder.BIG_ENDIAN);
+ }
+
+ case LayerParseResult.Failure failure -> {
+
+ ParseErrorReason reason = failure.errorReason();
+
+ chainBuilder.add(new FailureNode(layerId, toArray(metadata), reason));
+
+ if (LayerIds.isNonSkippable(layerId)) {
+ break loop;
+ }
+
+ // skippable → we'll stick with the original payload
+ buffer = layerPayload;
+ }
+ }
+
+ } catch (Exception e) {
+ // protection against faulty plugins
+ chainBuilder.add(new FailureNode(
+ layerId,
+ toArray(metadata),
+ CoreParseErrorReason.PLUGIN_EXCEPTION,
+ e
+ ));
+
+ if (LayerIds.isNonSkippable(layerId)) {
+ break loop;
+ }
+
+ buffer = layerPayload;
+ }
+ }
+
+ return new LLPFrame(
+ chainBuilder.build(),
+ rawFrame.crc(),
+ rawFrame.timestamp()
+ );
+ }
+
+ /**
+ * Internal LayerParseInput implementation.
+ */
+ private record DefaultLayerParseInput(
+ ByteBuffer metadata,
+ ByteBuffer payload
+ ) implements LayerParseInput {
+ }
+
+ /**
+ * Utility to convert metadata to byte[] only when needed.
+ * (Used for UnknownNode which is byte[] based)
+ */
+ private static byte[] toArray(ByteBuffer buffer) {
+ byte[] arr = new byte[buffer.remaining()];
+ buffer.duplicate().get(arr);
+ return arr;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java b/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
similarity index 57%
rename from src/main/java/com/flamingo/comm/llp/LLPErrorCode.java
rename to src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
index 29e494f..d6aa52b 100644
--- a/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
@@ -1,24 +1,22 @@
-package com.flamingo.comm.llp;
+package com.flamingo.comm.llp.core;
import java.util.Optional;
/**
* LLP Parser Error Codes
*/
-public enum LLPErrorCode {
+public enum TransportErrorCode {
OK((byte) 0x00, "No error"),
CHECKSUM_INVALID((byte) 0x01, "CRC checksum mismatch"),
- TYPE_INVALID((byte) 0x02, "Invalid message type"),
- PAYLOAD_LEN_INVALID((byte) 0x03, "Payload length exceeds maximum"),
- TIMEOUT((byte) 0x04, "Frame timeout - incomplete frame"),
- SYNC_ERROR((byte) 0x05, "Synchronization error"),
- BUFFER_FULL((byte) 0x06, "Buffer overflow"),
- UNSUPPORTED_VERSION((byte) 0x07, "Unknown or unsupported version");
+ PAYLOAD_LEN_INVALID((byte) 0x02, "Payload length exceeds maximum"),
+ TIMEOUT((byte) 0x03, "Frame timeout - incomplete frame"),
+ SYNC_ERROR((byte) 0x04, "Synchronization error"),
+ BUFFER_FULL((byte) 0x05, "Buffer overflow");
private final byte code;
private final String description;
- LLPErrorCode(byte code, String description) {
+ TransportErrorCode(byte code, String description) {
this.code = code;
this.description = description;
}
@@ -29,8 +27,8 @@ public enum LLPErrorCode {
* @param code byte received
* @return an {@link Optional} containing the error code, or empty if the error code is not found
*/
- public static Optional fromCode(byte code) {
- for (LLPErrorCode err : values()) {
+ public static Optional fromCode(byte code) {
+ for (TransportErrorCode err : values()) {
if (err.code == code) {
return Optional.of(err);
}
diff --git a/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
new file mode 100644
index 0000000..cca8c51
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
@@ -0,0 +1,63 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Represents an unknown or unsupported LLP layer.
+ *
+ * This node is created when the parser encounters a layer ID
+ * for which no registered parser is available.
+ *
+ * If the layer is marked as skippable, the parser will still
+ * continue parsing inner layers, allowing partial compatibility.
+ *
+ * This node preserves raw metadata for potential future use.
+ */
+public final class UnknownNode implements LLPNode {
+ private static final byte[] EMPTY_ARRAY = new byte[0];
+ private final int id;
+ private final byte[] metadata;
+
+ UnknownNode(int id, byte[] metadata) {
+ this.id = id;
+ this.metadata = (metadata != null) ? metadata.clone() : EMPTY_ARRAY;
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * Returns raw read-only metadata bytes associated with this unknown layer.
+ *
+ * @return metadata buffer as read-only
+ */
+ public ByteBuffer getMetadata() {
+ return ByteBuffer.wrap(metadata).asReadOnlyBuffer();
+ }
+
+ @Override
+ public String toString() {
+ return "UnknownNode{" +
+ "id=" + id +
+ ", metadataLength=" + metadata.length +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof UnknownNode that)) return false;
+ return id == that.id && Arrays.equals(metadata, that.metadata);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, Arrays.hashCode(metadata));
+ }
+}
diff --git a/src/main/java/com/flamingo/comm/llp/spi/BuildErrorReason.java b/src/main/java/com/flamingo/comm/llp/spi/BuildErrorReason.java
new file mode 100644
index 0000000..0ce9035
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/BuildErrorReason.java
@@ -0,0 +1,8 @@
+package com.flamingo.comm.llp.spi;
+
+public interface BuildErrorReason {
+ /**
+ * Returns a human-readable default message for the error.
+ */
+ String reason();
+}
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LLPLayerBuilder.java b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerBuilder.java
new file mode 100644
index 0000000..3a4bb1c
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerBuilder.java
@@ -0,0 +1,67 @@
+package com.flamingo.comm.llp.spi;
+
+/**
+ * Contract for building (serializing) a specific LLP layer.
+ *
+ * Implementations of this interface are responsible for constructing
+ * the metadata and optionally transforming the payload of a layer
+ * during the frame building process.
+ *
+ * Responsibilities
+ *
+ * - Provide the unique {@code layerId} that identifies the layer in the LLP protocol.
+ * - Generate the layer-specific metadata.
+ * - Optionally transform the payload (e.g., encryption, compression).
+ *
+ *
+ * Payload Handling
+ *
+ * - The input payload represents the output of the previous (inner) layer.
+ * - Implementations may either:
+ *
+ * - Leave the payload unchanged, returning {@link LayerBuildResult.Success.UnmodifiedPayload}, or
+ * - Return a modified payload using {@link LayerBuildResult.Success.TransformedPayload}.
+ *
+ *
+ * - When no transformation is needed, implementations should avoid creating new buffers
+ * and reuse the provided payload where possible.
+ *
+ *
+ * Error Handling
+ *
+ * - Logical or domain-specific failures should be reported using {@link LayerBuildResult.Failure}.
+ * - Unexpected exceptions should be avoided. If thrown, they will typically be handled by the core builder.
+ *
+ *
+ * Thread Safety
+ *
+ * - Implementations are expected to be stateless or thread-safe, as they may be reused
+ * across multiple build operations.
+ *
+ *
+ * @see LayerBuildPayload
+ * @see LayerBuildResult
+ */
+public interface LLPLayerBuilder {
+
+ /**
+ * Returns the unique identifier of the layer.
+ *
+ * This value will be serialized as the {@code LAYERID} byte
+ * in the LLP frame.
+ *
+ * @return the layer identifier (0-255)
+ */
+ int getLayerId();
+
+ /**
+ * Builds the current layer using the provided payload.
+ *
+ * The given {@link LayerBuildPayload} represents the input data
+ * produced by the previous layer in the build chain.
+ *
+ * @param payload the input payload (never {@code null})
+ * @return the result of the build operation (never {@code null})
+ */
+ LayerBuildResult build(LayerBuildPayload payload);
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
new file mode 100644
index 0000000..025ec40
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
@@ -0,0 +1,115 @@
+package com.flamingo.comm.llp.spi;
+
+import java.util.ServiceLoader;
+
+/**
+ * Service Provider Interface (SPI) for parsing LLP protocol layers.
+ *
+ *
+ * Implementations of this interface are responsible for interpreting
+ * a specific layer within the LLP protocol stack.
+ * Each layer is identified by a unique {@code layerId} and is parsed
+ * from its raw metadata and payload components.
+ *
+ *
+ *
+ * This interface is intended to be implemented by external libraries
+ * (plugins) that extend the LLP protocol with additional functionality
+ * such as encryption, compression, routing, etc.
+ *
+ *
+ *
+ * Implementations are typically discovered at runtime using Java's
+ * {@link ServiceLoader} mechanism.
+ *
+ *
+ * Responsibilities
+ *
+ * - Declare the layer identifier via {@link #getLayerId()}.
+ * - Parse raw layer data into a domain-specific {@link LLPNode}.
+ * - Interpret metadata according to the layer's internal specification.
+ *
+ *
+ * Contract
+ *
+ * - The {@code layerId} must be unique across all registered layers.
+ * - The core LLP parser guarantees that metadata and payload are already
+ * extracted according to the protocol format.
+ * - The provided {@link LayerParseInput} buffers must be treated as read-only.
+ * - Implementations must not rely on buffer mutability or shared state.
+ * - If parsing fails, implementations should return a {@link LayerParseResult.Failure}
+ * or throw an exception if the failure is unexpected.
+ *
+ *
+ * Performance Considerations
+ *
+ * - The use of {@link java.nio.ByteBuffer} allows zero-copy parsing.
+ * - Implementations should avoid copying data unless necessary.
+ * - If data needs to be retained, it must be explicitly copied.
+ *
+ *
+ * Example
+ * {@code
+ * public class EncryptionLayerParser implements LLPLayerParser {
+ *
+ * @Override
+ * public int getLayerId() {
+ * return 10;
+ * }
+ *
+ * @Override
+ * public LayerParseResult parse(LayerParseInput data) {
+ * ByteBuffer metadata = data.metadata();
+ * ByteBuffer payload = data.payload();
+ *
+ * // Interpret metadata (e.g., algorithm, IV, etc.)
+ * EncryptionNode node = new EncryptionNode(metadata, payload);
+ *
+ * return new LayerParseResult.Success(node, payload);
+ * }
+ * }
+ * }
+ *
+ *
+ * The resulting {@link LLPNode} will be integrated into the {@code NodeChain}
+ * by the core parser.
+ *
+ *
+ * @see LLPNode
+ * @see LayerParseInput
+ */
+public interface LLPLayerParser {
+
+ /**
+ * Returns the unique identifier of the layer handled by this parser.
+ *
+ *
+ * This value must match the {@code LAYER_ID} field present in the LLP frame.
+ *
+ *
+ * @return layer identifier (1-255)
+ */
+ int getLayerId();
+
+ /**
+ * Parses a layer from its raw metadata and payload.
+ *
+ *
+ * The core LLP parser provides a {@link LayerParseInput} instance containing
+ * the extracted metadata and payload buffers according to the protocol:
+ *
+ *
+ *
+ * [LAYER_ID][METADATA_LENGTH][METADATA][PAYLOAD]
+ *
+ *
+ *
+ * Implementations should interpret the metadata and construct an appropriate
+ * {@link LLPNode}, optionally transforming the payload for the next layer.
+ *
+ *
+ * @param layerParseInput container with metadata and payload buffers (never {@code null})
+ * @return a {@link LayerParseResult} describing the outcome of the parsing
+ */
+ LayerParseResult parse(LayerParseInput layerParseInput);
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LLPNode.java b/src/main/java/com/flamingo/comm/llp/spi/LLPNode.java
new file mode 100644
index 0000000..a3ac9e5
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LLPNode.java
@@ -0,0 +1,25 @@
+package com.flamingo.comm.llp.spi;
+
+import com.flamingo.comm.llp.core.FinalNode;
+
+/**
+ * Represents a single layer (node) within an LLP frame.
+ *
+ * An {@code LLPNode} forms part of a hierarchical structure where each node
+ * may contain another inner node, creating a chain of layers (similar to an onion model).
+ *
+ * Each node is responsible for interpreting its own metadata
+ *
+ * Implementations of this interface should be immutable and thread-safe.
+ */
+public interface LLPNode {
+ /**
+ * Returns the unique identifier of this layer.
+ *
+ * Layer ID {@code 0} is reserved for the {@link FinalNode},
+ * which represents the raw payload.
+ *
+ * @return layer identifier (1-255)
+ */
+ int getId();
+}
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LayerBuildPayload.java b/src/main/java/com/flamingo/comm/llp/spi/LayerBuildPayload.java
new file mode 100644
index 0000000..b0ac18f
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LayerBuildPayload.java
@@ -0,0 +1,7 @@
+package com.flamingo.comm.llp.spi;
+
+import java.nio.ByteBuffer;
+
+public interface LayerBuildPayload {
+ ByteBuffer payload();
+}
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LayerBuildResult.java b/src/main/java/com/flamingo/comm/llp/spi/LayerBuildResult.java
new file mode 100644
index 0000000..e597559
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LayerBuildResult.java
@@ -0,0 +1,47 @@
+package com.flamingo.comm.llp.spi;
+
+import java.nio.ByteBuffer;
+import java.util.Objects;
+
+/**
+ * Represents the comprehensive result of a layer build process.
+ */
+public sealed interface LayerBuildResult permits LayerBuildResult.Success, LayerBuildResult.Failure {
+
+ /**
+ * Represents a successful layer build.
+ * Can either leave the payload untouched or modify it.
+ */
+ sealed interface Success extends LayerBuildResult permits LayerBuildResult.Success.UnmodifiedPayload, LayerBuildResult.Success.TransformedPayload {
+
+ /**
+ * Used when the layer only appends metadata and leaves the payload untouched.
+ */
+ record UnmodifiedPayload(ByteBuffer metadata) implements Success {
+ public UnmodifiedPayload {
+ Objects.requireNonNull(metadata, "metadata cannot be null");
+ }
+ }
+
+ /**
+ * Used when the layer actively mutates the payload (e.g., encryption).
+ */
+ record TransformedPayload(ByteBuffer metadata, ByteBuffer modifiedPayload) implements Success {
+ public TransformedPayload {
+ Objects.requireNonNull(metadata, "metadata cannot be null");
+ Objects.requireNonNull(modifiedPayload, "modifiedPayload cannot be null");
+ }
+ }
+ }
+
+ /**
+ * Failed building result.
+ *
+ * @param errorReason reason for failure (never {@code null})
+ */
+ record Failure(BuildErrorReason errorReason) implements LayerBuildResult {
+ public Failure {
+ Objects.requireNonNull(errorReason, "errorReason cannot be null");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LayerParseInput.java b/src/main/java/com/flamingo/comm/llp/spi/LayerParseInput.java
new file mode 100644
index 0000000..7a4500a
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LayerParseInput.java
@@ -0,0 +1,55 @@
+package com.flamingo.comm.llp.spi;
+
+import java.nio.ByteBuffer;
+
+/**
+ * Represents the raw data of a single LLP layer during parsing.
+ *
+ * This interface provides access to the metadata and payload sections
+ * of a layer as {@link ByteBuffer} instances. It is used as the input
+ * for {@link LLPLayerParser} implementations.
+ *
+ * Design Goals
+ *
+ * - Avoid unnecessary copying of data (zero-copy where possible).
+ * - Provide a flexible and efficient way to access layer content.
+ * - Allow implementations to decide whether to copy or process data in-place.
+ *
+ *
+ * Buffer Characteristics
+ *
+ * - Buffers are never {@code null} but may be empty.
+ * - Buffers are typically provided as read-only views.
+ * - Implementations must treat buffers as immutable.
+ * - If data needs to be retained beyond parsing, it should be copied.
+ *
+ *
+ * Usage Notes
+ *
+ * - Calling {@link ByteBuffer#slice()} or {@link ByteBuffer#duplicate()}
+ * is recommended if position/limit changes are required.
+ * - Modifying the buffer (if not read-only) leads to undefined behavior.
+ *
+ *
+ * @see LLPLayerParser
+ */
+public interface LayerParseInput {
+
+ /**
+ * Returns the metadata buffer of the layer.
+ *
+ * The metadata contains layer-specific information and may be empty.
+ *
+ * @return a non-null {@link ByteBuffer} representing metadata
+ */
+ ByteBuffer metadata();
+
+ /**
+ * Returns the payload buffer of the layer.
+ *
+ * The payload may contain another nested LLP layer or the final raw payload.
+ *
+ * @return a non-null {@link ByteBuffer} representing payload
+ */
+ ByteBuffer payload();
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LayerParseResult.java b/src/main/java/com/flamingo/comm/llp/spi/LayerParseResult.java
new file mode 100644
index 0000000..b324113
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LayerParseResult.java
@@ -0,0 +1,70 @@
+package com.flamingo.comm.llp.spi;
+
+import java.nio.ByteBuffer;
+
+/**
+ * Represents the result of parsing a single LLP layer.
+ *
+ * This sealed interface defines the contract used by {@link LLPLayerParser}
+ * implementations to communicate the outcome of parsing a specific protocol layer.
+ *
+ * Result Types
+ *
+ * - {@link Success}: The layer was successfully parsed, producing an {@link LLPNode}
+ * and a payload (which may be transformed).
+ * - {@link Failure}: The layer could not be parsed due to a logical or structural error.
+ *
+ *
+ * Design Notes
+ *
+ * - This result operates at layer level, not at frame level.
+ * - The core parser aggregates results into an LLPFrame.
+ * - The payload in {@link Success} becomes the input for the next layer.
+ * - Implementations may return the same buffer (zero-copy) or a new one if transformed.
+ * - Buffers must be treated as read-only.
+ * - This interface is immutable and thread-safe.
+ *
+ */
+public sealed interface LayerParseResult
+ permits LayerParseResult.Success, LayerParseResult.Failure {
+
+ default boolean isSuccess() {
+ return this instanceof Success;
+ }
+
+ default boolean isFailure() {
+ return this instanceof Failure;
+ }
+
+ /**
+ * Successful parsing result for a layer.
+ *
+ * @param node parsed node (never {@code null})
+ * @param payload payload for next layer (never {@code null}, may be empty)
+ */
+ record Success(LLPNode node, ByteBuffer payload) implements LayerParseResult {
+
+ public Success {
+ if (node == null) {
+ throw new IllegalArgumentException("node cannot be null");
+ }
+ if (payload == null) {
+ throw new IllegalArgumentException("payload cannot be null");
+ }
+ }
+ }
+
+ /**
+ * Failed parsing result.
+ *
+ * @param errorReason reason for failure (never {@code null})
+ */
+ record Failure(ParseErrorReason errorReason) implements LayerParseResult {
+
+ public Failure {
+ if (errorReason == null) {
+ throw new IllegalArgumentException("errorReason cannot be null");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/ParseErrorReason.java b/src/main/java/com/flamingo/comm/llp/spi/ParseErrorReason.java
new file mode 100644
index 0000000..f812bd7
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/ParseErrorReason.java
@@ -0,0 +1,12 @@
+package com.flamingo.comm.llp.spi;
+
+/**
+ * Marker interface for all layer parsing errors.
+ * Plugins should implement this interface using their own enums.
+ */
+public interface ParseErrorReason {
+ /**
+ * Returns a human-readable default message for the error.
+ */
+ String reason();
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/util/ByteWriter.java b/src/main/java/com/flamingo/comm/llp/util/ByteWriter.java
new file mode 100644
index 0000000..0a12d03
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/util/ByteWriter.java
@@ -0,0 +1,6 @@
+package com.flamingo.comm.llp.util;
+
+@FunctionalInterface
+public interface ByteWriter {
+ void write(byte b);
+}
diff --git a/src/main/java/com/flamingo/comm/llp/crc/CRC16CCITT.java b/src/main/java/com/flamingo/comm/llp/util/CRC16CCITT.java
similarity index 98%
rename from src/main/java/com/flamingo/comm/llp/crc/CRC16CCITT.java
rename to src/main/java/com/flamingo/comm/llp/util/CRC16CCITT.java
index c76dbcf..b30f8ff 100644
--- a/src/main/java/com/flamingo/comm/llp/crc/CRC16CCITT.java
+++ b/src/main/java/com/flamingo/comm/llp/util/CRC16CCITT.java
@@ -1,4 +1,4 @@
-package com.flamingo.comm.llp.crc;
+package com.flamingo.comm.llp.util;
/**
* Implementation of CRC16-CCITT.
diff --git a/src/main/java/com/flamingo/comm/llp/util/LayerIds.java b/src/main/java/com/flamingo/comm/llp/util/LayerIds.java
new file mode 100644
index 0000000..fb2401d
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/util/LayerIds.java
@@ -0,0 +1,132 @@
+package com.flamingo.comm.llp.util;
+
+/**
+ * Utility class containing LLP protocol rules related to layer identifiers.
+ *
+ *
+ * In the LLP protocol, each layer is identified by an unsigned byte (0–255).
+ * The identifier encodes behavioral semantics used by the core parser.
+ *
+ *
+ * Layer Categories
+ *
+ * - Final Layer (ID = 0):
+ *
+ * - Represents the innermost payload.
+ * - Does not contain metadata length or metadata.
+ * - Terminates the parsing process.
+ * - Does not modify payload.
+ *
+ *
+ * - Skippable Layers (ID 1–127):
+ *
+ * - Do not modify the payload.
+ * - Can be safely skipped if no parser is available.
+ *
+ *
+ * - Non-skippable Layers (ID 128–255):
+ *
+ * - Modify the payload (e.g., encryption, compression).
+ * - Must be parsed to correctly interpret subsequent layers.
+ *
+ *
+ *
+ *
+ *
+ * This class centralizes protocol rules to avoid scattering "magic numbers"
+ * throughout the codebase and to improve readability and maintainability.
+ *
+ */
+public final class LayerIds {
+
+ /**
+ * Identifier of the final (innermost) layer.
+ */
+ static final int FINAL_LAYER_ID = 0;
+
+ /**
+ * Threshold from which layers are considered non-skippable.
+ */
+ private static final int NON_SKIPPABLE_THRESHOLD = 128;
+
+ private LayerIds() {
+ // Utility class (no instances)
+ }
+
+ /**
+ * Checks whether the given layer ID represents the final layer.
+ *
+ * The final layer terminates parsing and contains only raw payload.
+ *
+ * @param id layer identifier
+ * @return {@code true} if this is the final layer (ID = 0), otherwise {@code false}
+ */
+ public static boolean isFinal(int id) {
+ return id == FINAL_LAYER_ID;
+ }
+
+ /**
+ * Checks whether the given layer ID is skippable.
+ *
+ *
+ * Skippable layers do not modify the payload, meaning that parsing can continue
+ * even if no parser is available for this layer.
+ *
+ *
+ *
+ * Note: The final layer (ID = 0) is also considered non-modifying, but it is
+ * excluded from this method since it has special structural semantics.
+ *
+ *
+ * @param id layer identifier
+ * @return {@code true} if the layer is skippable (ID 1–127), otherwise {@code false}
+ */
+ public static boolean isSkippable(int id) {
+ return id > FINAL_LAYER_ID && id < NON_SKIPPABLE_THRESHOLD;
+ }
+
+ /**
+ * Checks whether the given layer ID is non-skippable.
+ *
+ *
+ * Non-skippable layers modify the payload (e.g., encryption or compression),
+ * therefore they must be successfully parsed before continuing to inner layers.
+ *
+ *
+ * @param id layer identifier
+ * @return {@code true} if the layer is non-skippable (ID ≥ 128), otherwise {@code false}
+ */
+ public static boolean isNonSkippable(int id) {
+ return id >= NON_SKIPPABLE_THRESHOLD;
+ }
+
+ /**
+ * Checks whether the given layer ID represents a layer that does not modify payload.
+ *
+ *
+ * This includes:
+ *
+ * - Final layer (ID = 0)
+ * - Skippable layers (ID 1–127)
+ *
+ *
+ *
+ * @param id layer identifier
+ * @return {@code true} if the layer does not modify payload, otherwise {@code false}
+ */
+ public static boolean doesNotModifyPayload(int id) {
+ return id < NON_SKIPPABLE_THRESHOLD;
+ }
+
+ /**
+ * Checks whether the given layer ID is within the valid LLP range.
+ *
+ * Valid values are unsigned byte range: 0–255.
+ *
+ * @param id layer identifier
+ * @return {@code true} if valid, otherwise {@code false}
+ */
+ public static boolean isValid(int id) {
+ return id >= 0 && id <= 255;
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java b/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java
deleted file mode 100644
index 20b90f9..0000000
--- a/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package com.flamingo.comm.llp;
-
-import com.flamingo.comm.llp.crc.CRC16CCITT;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-
-import java.io.ByteArrayOutputStream;
-import java.util.HexFormat;
-import java.util.List;
-import java.util.Locale;
-import java.util.Random;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class LLPFrameBuilderTest {
-
- @Test
- void testBuildSimpleFrame() {
- byte[] frame = LLP.buildPing(123);
-
- assertNotNull(frame);
- assertEquals((byte) 0xAA, frame[0]);
- assertEquals((byte) 0x55, frame[1]);
- assertEquals(frame[3], LLPMessageType.PING.value());
- }
-
- @Test
- void testBuildFrameStructure() {
- byte[] payload = {0x01, 0x02};
- int id = 0x1234;
-
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), id, payload);
-
- assertEquals((byte) 0xAA, frame[0]);
- assertEquals((byte) 0x55, frame[1]);
-
- assertEquals(LLP.PROTOCOL_VERSION, frame[2]);
-
- assertEquals(LLPMessageType.DATA.value(), frame[3]);
-
- // ID little endian
- assertEquals((byte) 0x34, frame[4]);
- assertEquals((byte) 0x12, frame[5]);
-
- // Length
- assertEquals((byte) 0x02, frame[6]);
- assertEquals((byte) 0x00, frame[7]);
-
- // Payload
- assertEquals(0x01, frame[8]);
- assertEquals(0x02, frame[9]);
- }
-
- @Test
- void testBuildDataFrame() {
- byte[] data = new byte[]{(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF};
- byte[] frame = LLP.buildData(42, data);
-
- assertNotNull(frame);
- assertTrue(frame.length > data.length); // Overhead included
- assertEquals(frame[3], LLPMessageType.DATA.value());
- }
-
- @Test
- void testFluentBuilder() {
- byte[] frame = LLP.frameBuilder()
- .type(LLPMessageType.COMMAND)
- .id(999)
- .payload("HELLO")
- .build();
-
- assertNotNull(frame);
- assertTrue(frame.length > 0);
- }
-
- @Test
- void testPayloadTooLarge() {
- byte[] largePayload = new byte[513]; // > 512
- assertThrows(IllegalArgumentException.class, () ->
- LLP.buildFrame(LLPMessageType.DATA.value(), 1, largePayload)
- );
- }
-
- @ParameterizedTest
- @ValueSource(ints = {0, 10, 100, 512})
- void testVariousPayloadSizes(int size) {
- byte[] payload = new byte[size];
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- assertNotNull(frame);
- assertTrue(frame.length >= 9 + size); // Min 9 bytes overhead
- }
-
- @Test
- void testCRCIsValid() {
- byte[] payload = {0x10, 0x20, 0x30};
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- int crcExpected = CRC16CCITT.calculate(frame, 0, frame.length - 2);
-
- int crcFrame =
- (frame[frame.length - 2] & 0xFF) |
- ((frame[frame.length - 1] & 0xFF) << 8);
-
- assertEquals(crcExpected, crcFrame);
- }
-
- @Test
- void testNullPayload() {
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, null);
-
- // Length = 0
- assertEquals(0, frame[6]);
- assertEquals(0, frame[7]);
-
- // Minimal Frame: 8 header + 2 CRC
- assertEquals(10, frame.length);
- }
-
- @Test
- void testBuilderEqualsDirectBuild() {
- byte[] payload = "HELLO".getBytes();
-
- byte[] f1 = LLP.buildFrame(LLPMessageType.DATA.value(), 10, payload);
-
- byte[] f2 = LLP.frameBuilder()
- .type(LLPMessageType.DATA)
- .id(10)
- .payload(payload)
- .build();
-
- assertArrayEquals(f1, f2);
- }
-
- @Test
- void testIdBoundaries() {
- byte[] frameMin = LLP.buildFrame(LLPMessageType.DATA.value(), 0, new byte[0]);
- byte[] frameMax = LLP.buildFrame(LLPMessageType.DATA.value(), 0xFFFF, new byte[0]);
-
- assertNotNull(frameMin);
- assertNotNull(frameMax);
- }
-
- @Test
- void testRandomPayload() {
- byte[] payload = new byte[100];
- new java.util.Random().nextBytes(payload);
-
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- assertNotNull(frame);
- }
-
- @Test
- void testBuildVersion() {
- byte[] payload = new byte[]{
- 0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
- };
- byte[] frame = LLP.buildData(1, payload);
-
- byte frameVersion = frame[2];
- assertEquals(LLP.PROTOCOL_VERSION, frameVersion);
- }
-
- @Test
- void testStuffingSingleAA() {
- byte[] payload = {(byte) 0xAA};
-
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- // Search for sequence AA 00 (stuffed)
- boolean found = false;
- for (int i = 2; i < frame.length - 1; i++) {
- if ((frame[i] == (byte) 0xAA) && (frame[i + 1] == 0x00)) {
- found = true;
- break;
- }
- }
-
- assertTrue(found, "Stuffed sequence AA 00 not found");
- }
-
- @Test
- void testStuffingMultipleAA() {
- byte[] payload = {(byte) 0xAA, (byte) 0xAA, (byte) 0xAA};
-
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- int countAA = 0;
- int countStuffed = 0;
-
- for (int i = 2; i < frame.length; i++) {
- if (frame[i] == (byte) 0xAA) {
- countAA++;
- if (i + 1 < frame.length && frame[i + 1] == 0x00) {
- countStuffed++;
- }
- }
- }
-
- assertEquals(countAA, countStuffed, "Every AA must be stuffed");
- }
-
- @Test
- void testNoFalseHeaderInsideFrame() {
- byte[] payload = new byte[100];
- new Random().nextBytes(payload);
-
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- for (int i = 2; i < frame.length - 1; i++) {
- if (frame[i] == (byte) 0xAA && frame[i + 1] == (byte) 0x55) {
- fail("Found forbidden sequence AA 55 inside stuffed frame");
- }
- }
- }
-
- @Test
- void testConsistency() {
- for (int i = 0; i < 10000; i++) {
- byte[] payload = new byte[100];
- new Random().nextBytes(payload);
-
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
- List result = LLP.newParser().processBytes(frame);
-
- if (result.isEmpty()) {
- throw new RuntimeException("The generated frame could not be parsed again: " + HexFormat.of().formatHex(frame).toUpperCase(Locale.ROOT));
- }
-
- assertEquals(1, result.size(), "The number of parsed frames must be 1");
- assertEquals(LLPMessageType.DATA.value(), result.getFirst().messageType().orElseThrow(() -> new IllegalArgumentException("The message type does not match the original frame: " + HexFormat.of().formatHex(frame).toUpperCase(Locale.ROOT))).value());
-
- String originalHexPayload = HexFormat.of().formatHex(payload).toUpperCase(Locale.ROOT);
- String parsedHexPayload = HexFormat.of().formatHex(result.getFirst().payload()).toUpperCase(Locale.ROOT);
- assertEquals(originalHexPayload, parsedHexPayload);
- }
- }
-
- @Test
- void testBuildFrameWithStuffedCRCAndPayload() {
- byte[] payload = HexFormat.of().parseHex("bf4008211f8191eca98c2ee3d01985d9858689fd571a2df4df41545eba69838d12da79b79de4f425a4596e5dd8f0de04fee43a0d71b4f0fdebce1274a66cac08459a1159f395b642afabe6bd3684193c5d5fbe1560428c6527aa21aa53233dba8932467f");
- byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
-
- // Check that there is stuffing in the frame
- boolean foundStuff = false;
- for (int i = 2; i < frame.length - 1; i++) {
- if (frame[i] == (byte) 0xAA && frame[i + 1] == 0x00) {
- foundStuff = true;
- break;
- }
- }
-
- assertTrue(foundStuff, "Expected stuffed bytes not found");
-
- // Destuff
- byte[] unstuffed = destuff(frame);
-
- // Validate the CRC manually
- int crcExpected = CRC16CCITT.calculate(unstuffed, 0, unstuffed.length - 2);
-
- int crcFrame =
- (unstuffed[unstuffed.length - 2] & 0xFF) |
- ((unstuffed[unstuffed.length - 1] & 0xFF) << 8);
-
- assertEquals(crcExpected, crcFrame);
-
- // Verify that there are NO fake headers
- for (int i = 2; i < frame.length - 1; i++) {
- assertFalse(
- frame[i] == (byte) 0xAA && frame[i + 1] == (byte) 0x55,
- "Found forbidden AA55 sequence"
- );
- }
- }
-
- private byte[] destuff(byte[] frame) {
- ByteArrayOutputStream out = new ByteArrayOutputStream(frame.length);
-
- // Copy MAGIC
- out.write(frame, 0, 2);
-
- for (int i = 2; i < frame.length; i++) {
- byte b = frame[i];
-
- if (b == (byte) 0xAA) {
- if (i + 1 < frame.length && frame[i + 1] == 0x00) {
- out.write(0xAA);
- i++;
- continue;
- }
- }
-
- out.write(b);
- }
-
- return out.toByteArray();
- }
-}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/LLPParserTest.java b/src/test/java/com/flamingo/comm/llp/LLPParserTest.java
deleted file mode 100644
index 2f03825..0000000
--- a/src/test/java/com/flamingo/comm/llp/LLPParserTest.java
+++ /dev/null
@@ -1,321 +0,0 @@
-package com.flamingo.comm.llp;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.util.Random;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class LLPParserTest {
-
- private LLPParser parser;
-
- @BeforeEach
- void setUp() {
- parser = LLP.newParser();
- }
-
- @Test
- void testParsePingFrame() {
- byte[] frame = LLP.buildPing(42);
-
- LLPFrame result = null;
- int count = 0;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) {
- result = f;
- count++;
- }
- }
-
- assertEquals(1, count);
-
- assertNotNull(result);
- assertEquals(result.type(), LLPMessageType.PING.value());
- assertEquals(42, result.id());
- assertEquals(0, result.payloadLength());
- }
-
- @Test
- void testParseDataFrame() {
- byte[] data = new byte[]{0x01, 0x02, 0x03};
- byte[] frame = LLP.buildData(123, data);
-
- LLPFrame result = null;
- int count = 0;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) {
- result = f;
- count++;
- }
- }
-
- assertEquals(1, count);
-
- assertNotNull(result);
- assertEquals(result.type(), LLPMessageType.DATA.value());
- assertEquals(123, result.id());
- assertArrayEquals(result.payload(), data);
- }
-
- @Test
- void testParseInvalidCRC() {
- byte[] frame = LLP.buildPing(1);
-
- // Corrupt CRC
- frame[frame.length - 1] ^= 0xFF;
-
- LLPFrame result = null;
-
- int count = 0;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) {
- result = f;
- count++;
- }
- }
-
- assertEquals(0, count);
-
- assertNull(result);
- assertEquals(1, parser.getStatistics().getFramesError());
- }
-
- @Test
- void testStatistics() {
- // Process 3 valid frames
- for (int i = 0; i < 3; i++) {
- byte[] frame = LLP.buildPing(i);
- for (byte b : frame) {
- parser.processByte(b);
- }
- }
-
- assertEquals(3, parser.getStatistics().getFramesOk());
- assertEquals(3, parser.getStatistics().getTotalFrames());
- assertEquals(100.0, parser.getStatistics().getSuccessRate());
- }
-
- @Test
- void testFragmentedFrame() {
- byte[] frame = LLP.buildPing(55);
-
- LLPFrame result = null;
-
- // Send in 2 parts
- for (int i = 0; i < frame.length / 2; i++) {
- parser.processByte(frame[i]);
- }
-
- for (int i = frame.length / 2; i < frame.length; i++) {
- LLPFrame f = parser.processByte(frame[i]);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertEquals(55, result.id());
- }
-
- @Test
- void testMultipleFramesBackToBack() {
- byte[] f1 = LLP.buildPing(1);
- byte[] f2 = LLP.buildPing(2);
-
- byte[] combined = new byte[f1.length + f2.length];
- System.arraycopy(f1, 0, combined, 0, f1.length);
- System.arraycopy(f2, 0, combined, f1.length, f2.length);
-
- int count = 0;
-
- for (byte b : combined) {
- LLPFrame f = parser.processByte(b);
- if (f != null) count++;
- }
-
- assertEquals(2, count);
- }
-
- @Test
- void testNoiseBeforeFrame() {
- byte[] frame = LLP.buildPing(77);
-
- byte[] noise = new byte[]{0x00, 0x13, 0x7F, 0x55};
-
- LLPFrame result = null;
-
- for (byte b : noise) {
- parser.processByte(b);
- }
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertEquals(77, result.id());
- }
-
- @Test
- void testTimeoutResetsParser() throws InterruptedException {
- byte[] frame = LLP.buildPing(10);
-
- // Send half the frame
- for (int i = 0; i < frame.length / 2; i++) {
- parser.processByte(frame[i]);
- }
-
- // Wait longer than the timeout
- Thread.sleep(2100);
-
- // Send remainder
- LLPFrame result = null;
- for (int i = frame.length / 2; i < frame.length; i++) {
- LLPFrame f = parser.processByte(frame[i]);
- if (f != null) result = f;
- }
-
- assertNull(result);
- assertTrue(parser.getStatistics().getTimeouts() > 0);
- }
-
- @Test
- void testMaxPayload() {
- byte[] payload = new byte[LLPFrame.DEFAULT_MAX_PAYLOAD];
- byte[] frame = LLP.buildData(1, payload);
-
- LLPFrame result = null;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertEquals(payload.length, result.payloadLength());
- }
-
- @Test
- void testParseStuffedPayload() {
- byte[] payload = new byte[]{
- 0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
- };
-
- byte[] frame = LLP.buildData(1, payload);
-
- LLPFrame result = null;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertArrayEquals(payload, result.payload());
- }
-
- @Test
- void testStuffingAcrossEntireFrame() {
- byte type = (byte) 0xAA; // force stuffing
- int id = 0xAA55;
- byte[] payload = new byte[]{(byte) 0xAA, (byte) 0xAA};
-
- byte[] frame = LLP.buildFrame(type, id, payload);
-
- LLPFrame result = null;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertEquals(type, result.type());
- assertEquals(id, result.id());
- assertArrayEquals(payload, result.payload());
- }
-
- @Test
- void testDoubleAASequence() {
- byte[] payload = new byte[]{
- (byte) 0xAA, (byte) 0xAA, (byte) 0xAA
- };
-
- byte[] frame = LLP.buildData(1, payload);
-
- LLPFrame result = null;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertArrayEquals(payload, result.payload());
- }
-
- @Test
- void testFakeHeaderInsidePayload() {
- byte[] payload = new byte[]{
- 0x10,
- (byte) 0xAA, 0x55, // It looks like a header, but it must be escaped
- 0x20
- };
-
- byte[] frame = LLP.buildData(1, payload);
-
- LLPFrame result = null;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertArrayEquals(payload, result.payload());
- }
-
- @Test
- void testInvalidEscapeSequence() {
- byte[] frame = LLP.buildPing(1);
-
- // We injected an invalid sequence: AA 99
- frame[5] = (byte) 0xAA;
- frame[6] = (byte) 0x99;
-
- for (byte b : frame) {
- parser.processByte(b);
- }
-
- assertTrue(parser.getStatistics().getFramesError() > 0);
- }
-
- @Test
- void testRandomFramesWithStuffing() {
- Random random = new Random();
-
- for (int i = 0; i < 1000; i++) {
- byte[] payload = new byte[50];
- random.nextBytes(payload);
-
- byte[] frame = LLP.buildData(i, payload);
-
- LLPFrame result = null;
-
- for (byte b : frame) {
- LLPFrame f = parser.processByte(b);
- if (f != null) result = f;
- }
-
- assertNotNull(result);
- assertArrayEquals(payload, result.payload());
- }
- }
-}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java b/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java
new file mode 100644
index 0000000..e81e114
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java
@@ -0,0 +1,740 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.BuildErrorReason;
+import com.flamingo.comm.llp.spi.LLPLayerBuilder;
+import com.flamingo.comm.llp.spi.LayerBuildPayload;
+import com.flamingo.comm.llp.spi.LayerBuildResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ByteArrayFrameBuilderTest {
+
+ private LLPLayerBuilder layer1;
+ private LLPLayerBuilder layer2;
+
+ private ByteArrayFrameBuilder builder;
+
+ @BeforeEach
+ void setup() {
+ layer1 = mock(LLPLayerBuilder.class);
+ layer2 = mock(LLPLayerBuilder.class);
+ }
+
+ @Test
+ void shouldThrowExceptionWhenPayloadIsNull() {
+ builder = new ByteArrayFrameBuilder(List.of());
+
+ assertThrows(IllegalArgumentException.class, () -> builder.build(null));
+ }
+
+ @Test
+ void shouldBuildFrameWithNoLayers() {
+ builder = new ByteArrayFrameBuilder(List.of());
+
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x11, 0x22});
+
+ byte[] result = builder.build(payload);
+
+ // Expected: [FINAL=0x00][payload]
+ assertArrayEquals(new byte[]{0x00, 0x11, 0x22}, result);
+ }
+
+ @Test
+ void shouldBuildSingleLayerUnmodifiedPayload() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ when(layer1.build(any())).thenAnswer(invocation -> {
+ LayerBuildPayload p = invocation.getArgument(0);
+
+ return new LayerBuildResult.Success.UnmodifiedPayload(
+ ByteBuffer.wrap(new byte[]{0x0A, 0x0B})
+ );
+ });
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x55}));
+
+ // [ID=1][LEN=2][0A 0B][FINAL][55]
+ assertArrayEquals(new byte[]{
+ 0x01, 0x02, 0x0A, 0x0B,
+ 0x00,
+ 0x55
+ }, result);
+ }
+
+ @Test
+ void shouldBuildMultipleLayersUnmodified() {
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer2.getLayerId()).thenReturn(2);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x11}))
+ );
+
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x22}))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x33}));
+
+ // Outer layer should be layer2
+ // [2][1][22][1][1][11][0][33]
+ assertArrayEquals(new byte[]{
+ 0x02, 0x01, 0x22,
+ 0x01, 0x01, 0x11,
+ 0x00,
+ 0x33
+ }, result);
+ }
+
+ @Test
+ void shouldResetHeadersWhenPayloadIsTransformed() {
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer2.getLayerId()).thenReturn(2);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x11}))
+ );
+
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x22}),
+ ByteBuffer.wrap(new byte[]{0x66})
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x33}));
+
+ // layer2 resets previous headers
+ // [2][1][22][0][66]
+ assertArrayEquals(new byte[]{
+ 0x02, 0x01, 0x22,
+ 0x00,
+ 0x66
+ }, result);
+ }
+
+ @Test
+ void shouldThrowFrameBuildExceptionOnFailure() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Failure(TestBuildErrorReason.TEST_ERROR)
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ FrameBuildException ex = assertThrows(
+ FrameBuildException.class,
+ () -> builder.build(ByteBuffer.wrap(new byte[]{0x01}))
+ );
+
+ assertEquals(1, ex.getLayerId());
+ assertTrue(ex.getErrorReason().isPresent());
+ assertEquals(TestBuildErrorReason.TEST_ERROR, ex.getErrorReason().get());
+ }
+
+ @Test
+ void shouldHandleLargeMetadataUsingExtendedLength() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ byte[] meta = new byte[300];
+ for (int i = 0; i < meta.length; i++) {
+ meta[i] = (byte) i;
+ }
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(meta))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ // Check header manually
+ assertEquals(0x01, result[0]); // ID
+ assertEquals((byte) 0xFF, result[1]); // extended flag
+
+ int len = ((result[2] & 0xFF) << 8) | (result[3] & 0xFF);
+ assertEquals(300, len);
+ }
+
+ @Test
+ void shouldPreservePayloadOrder() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x01}))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x10, 0x20, 0x30}));
+
+ assertArrayEquals(new byte[]{
+ 0x01, 0x01, 0x01,
+ 0x00,
+ 0x10, 0x20, 0x30
+ }, result);
+ }
+
+ @Test
+ void shouldPassPayloadToLayer() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ when(layer1.build(any())).thenAnswer(invocation -> {
+ LayerBuildPayload payload = invocation.getArgument(0);
+
+ ByteBuffer buffer = payload.payload();
+ ByteBuffer dup = buffer.duplicate();
+ byte[] data = new byte[dup.remaining()];
+ dup.get(data);
+
+ assertArrayEquals(new byte[]{0x55}, data);
+
+ return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0));
+ });
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ builder.build(ByteBuffer.wrap(new byte[]{0x55}));
+ }
+
+ @Test
+ void shouldSupportEmptyMetadata() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ assertArrayEquals(new byte[]{
+ 0x01, 0x00,
+ 0x00,
+ 0x01
+ }, result);
+ }
+
+ @Test
+ void shouldHandleMetadataLengthExactly254() {
+ // Boundary test: Maximum length before extended flag
+ when(layer1.getLayerId()).thenReturn(1);
+
+ byte[] meta = new byte[254];
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(meta))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ assertEquals(0x01, result[0]); // ID
+ assertEquals((byte) 254, result[1]); // normal length marker
+ // Total size = 1 (ID) + 1 (LEN) + 254 (META) + 1 (FINAL) + 1 (PAYLOAD) = 258
+ assertEquals(258, result.length);
+ }
+
+ @Test
+ void shouldHandleMetadataLengthExactly255() {
+ // Boundary test: Minimum length requiring extended flag
+ when(layer1.getLayerId()).thenReturn(1);
+
+ byte[] meta = new byte[255];
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(meta))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ assertEquals(0x01, result[0]); // ID
+ assertEquals((byte) 0xFF, result[1]); // extended flag
+ assertEquals((byte) 0x00, result[2]); // High byte (255 >> 8)
+ assertEquals((byte) 0xFF, result[3]); // Low byte (255 & 0xFF)
+ // Total size = 1 (ID) + 3 (LEN) + 255 (META) + 1 (FINAL) + 1 (PAYLOAD) = 261
+ assertEquals(261, result.length);
+ }
+
+ @Test
+ void shouldRespectByteBufferPositionAndRemaining() {
+ when(layer1.getLayerId()).thenReturn(1);
+
+ // Metadata buffer with offset
+ ByteBuffer metaBuffer = ByteBuffer.wrap(new byte[]{0x00, (byte) 0xAA, (byte) 0xBB, 0x00});
+ metaBuffer.position(1);
+ metaBuffer.limit(3); // Only exposes [0xAA, 0xBB]
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(metaBuffer)
+ );
+
+ // Payload buffer with offset
+ ByteBuffer payloadBuffer = ByteBuffer.wrap(new byte[]{(byte) 0xFF, 0x11, 0x22, 0x33, (byte) 0xFF});
+ payloadBuffer.position(1);
+ payloadBuffer.limit(4); // Only exposes [0x11, 0x22, 0x33]
+
+ int initialPayloadPos = payloadBuffer.position();
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+ byte[] result = builder.build(payloadBuffer);
+
+ assertArrayEquals(new byte[]{
+ 0x01, 0x02, (byte) 0xAA, (byte) 0xBB, // Layer 1
+ 0x00, // Final
+ 0x11, 0x22, 0x33 // Payload
+ }, result);
+
+ // Assert that the builder did not consume/mutate the original buffer's position
+ assertEquals(initialPayloadPos, payloadBuffer.position());
+ }
+
+ @Test
+ void shouldHandleMixedTransformationsCorrectly() {
+ // Layer 1 (Inner): Just metadata
+ LLPLayerBuilder layer3 = mock(LLPLayerBuilder.class);
+
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer2.getLayerId()).thenReturn(2);
+ when(layer3.getLayerId()).thenReturn(3);
+
+ // Layer 1 prepends [0x11]
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x11}))
+ );
+
+ // Layer 2 modifies payload (Simulates encryption, squashing Layer 1)
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x22}), // its own metadata
+ ByteBuffer.wrap(new byte[]{(byte) 0x99}) // The mutated payload (which conceptually contains layer1+payload)
+ )
+ );
+
+ // Layer 3 prepends [0x33] to the new payload
+ when(layer3.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x33}))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2, layer3));
+
+ // Original payload is 0x00, but it gets eaten/mutated by layer 2
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x00}));
+
+ // Expected Outer-to-Inner representation:
+ // [Layer 3 Header][Layer 2 Header][Final][Mutated Payload]
+ // Note: Layer 1 is GONE from the final headers because Layer 2 "ate" it.
+ assertArrayEquals(new byte[]{
+ 0x03, 0x01, 0x33, // Layer 3
+ 0x02, 0x01, 0x22, // Layer 2
+ 0x00, // Final
+ (byte) 0x99 // The Transformed Payload from Layer 2
+ }, result);
+ }
+
+ @Test
+ void shouldBuildFrameWithEmptyPayloadAndNoLayers() {
+ builder = new ByteArrayFrameBuilder(List.of());
+
+ byte[] result = builder.build(ByteBuffer.allocate(0));
+
+ // Only the final marker, without payload
+ assertArrayEquals(new byte[]{0x00}, result);
+ }
+
+ @Test
+ void shouldNotMutateOriginalLayersListAfterConstruction() {
+ // Verify that List.copyOf() isolates the builder from external modifications
+ List mutableList = new java.util.ArrayList<>();
+ builder = new ByteArrayFrameBuilder(mutableList);
+
+ // Add a layer AFTER building the builder
+ when(layer1.getLayerId()).thenReturn(1);
+ mutableList.add(layer1);
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ // The builder should not have processed layer1
+ assertArrayEquals(new byte[]{0x00, 0x42}, result);
+ verify(layer1, never()).build(any());
+ }
+
+ @Test
+ void shouldProduceSameOutputOnMultipleBuilds() {
+ // The builder should be reusable
+ when(layer1.getLayerId()).thenReturn(5);
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x0A}))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+ ByteBuffer input = ByteBuffer.wrap(new byte[]{0x55});
+
+ byte[] first = builder.build(input.duplicate());
+ byte[] second = builder.build(input.duplicate());
+
+ assertArrayEquals(first, second);
+ }
+
+ @Test
+ void shouldThrowFrameBuildExceptionWithCorrectLayerIdWhenSecondLayerFails() {
+ // Verify that the layerId in the exception corresponds to the failed layer,
+ // not always the first one
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer2.getLayerId()).thenReturn(99);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x11}))
+ );
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Failure(TestBuildErrorReason.TEST_ERROR)
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+
+ FrameBuildException ex = assertThrows(
+ FrameBuildException.class,
+ () -> builder.build(ByteBuffer.wrap(new byte[]{0x01}))
+ );
+
+ assertEquals(99, ex.getLayerId()); // should be layer2, not layer1
+ }
+
+ @Test
+ void shouldCallEachLayerExactlyOnce() {
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer2.getLayerId()).thenReturn(2);
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0))
+ );
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+ builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ verify(layer1, times(1)).build(any());
+ verify(layer2, times(1)).build(any());
+ }
+
+ @Test
+ void shouldPassReadOnlyOrDuplicatePayloadToLayer() {
+ // A plugin MUST NOT be able to mutate or consume the builder's currentPayload
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer1.build(any())).thenAnswer(invocation -> {
+ LayerBuildPayload p = invocation.getArgument(0);
+ ByteBuffer buf = p.payload();
+
+ // Attempt to consume the buffer
+ while (buf.hasRemaining()) buf.get();
+
+ return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0));
+ });
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ // If the builder does not protect currentPayload, the frame will have no payload
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x10, 0x20}));
+
+ // The final payload must be present even if the plugin consumed its view
+ byte[] expected = new byte[]{0x01, 0x00, 0x00, 0x10, 0x20};
+ assertArrayEquals(expected, result);
+ }
+
+ @Test
+ void shouldHandleFirstLayerAsTransformedPayload() {
+ // TransformedPayload as the first (and only) layer
+ when(layer1.getLayerId()).thenReturn(128);
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{(byte) 0xAA, (byte) 0xBB}), // metadata
+ ByteBuffer.wrap(new byte[]{(byte) 0xFF, (byte) 0xEE}) // transformed payload
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01, 0x02, 0x03}));
+
+ // [ID=128][LEN=2][AA BB][FINAL][FF EE]
+ assertArrayEquals(new byte[]{
+ (byte) 0x80, 0x02, (byte) 0xAA, (byte) 0xBB,
+ 0x00,
+ (byte) 0xFF, (byte) 0xEE
+ }, result);
+ }
+
+ @Test
+ void shouldHandleTwoConsecutiveTransformedPayloadLayers() {
+ // Two consecutive transforming layers — the second discards the first one's header
+ when(layer1.getLayerId()).thenReturn(130);
+ when(layer2.getLayerId()).thenReturn(131);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x01}), // layer1 metadata
+ ByteBuffer.wrap(new byte[]{(byte) 0xEE}) // encrypted payload
+ )
+ );
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x02}), // layer2 metadata
+ ByteBuffer.wrap(new byte[]{(byte) 0xFF}) // compressed payload
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ // Layer2 discards layer1's header, just as layer1 discarded the previous header
+ // [ID=131][LEN=1][02][FINAL][FF]
+ assertArrayEquals(new byte[]{
+ (byte) 0x83, 0x01, 0x02,
+ 0x00,
+ (byte) 0xFF
+ }, result);
+ }
+
+ @Test
+ void shouldHandleTransformedPayloadWithEmptyNewPayload() {
+ when(layer1.getLayerId()).thenReturn(128);
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{(byte) 0xAA}),
+ ByteBuffer.allocate(0) // empty transformed payload (edge case)
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0x99}));
+
+ // [ID=128][LEN=1][AA][FINAL] — no payload
+ assertArrayEquals(new byte[]{(byte) 0x80, 0x01, (byte) 0xAA, 0x00}, result);
+ }
+
+ @Test
+ void shouldHandleMaxExtendedMetadataLength() {
+ // 65535 bytes metadata (maximum of the 2-byte extended field)
+ when(layer1.getLayerId()).thenReturn(1);
+
+ byte[] meta = new byte[65535];
+ Arrays.fill(meta, (byte) 0x7A);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(meta))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ // Verify the extended header
+ assertEquals(0x01, result[0] & 0xFF); // ID
+ assertEquals(0xFF, result[1] & 0xFF); // extended flag
+ assertEquals(0xFF, result[2] & 0xFF); // high byte of 65535
+ assertEquals(0xFF, result[3] & 0xFF); // low byte of 65535
+
+ // Verify total size: 1(ID) + 3(LEN_EXT) + 65535(META) + 1(FINAL) + 1(PAYLOAD)
+ assertEquals(65541, result.length);
+
+ // Verify that metadata content is correct
+ for (int i = 4; i < 4 + 65535; i++) {
+ assertEquals((byte) 0x7A, result[i], "Metadata byte mismatch at index " + i);
+ }
+ }
+
+ @Test
+ void shouldPassTransformedPayloadToNextLayer() {
+ // Verify that the next layer receives the transformed payload, not the original one
+ LLPLayerBuilder layer3 = mock(LLPLayerBuilder.class);
+
+ when(layer1.getLayerId()).thenReturn(128);
+ when(layer2.getLayerId()).thenReturn(10);
+ when(layer3.getLayerId()).thenReturn(11);
+
+ byte[] transformedBytes = {(byte) 0xBE, (byte) 0xEF};
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x01}),
+ ByteBuffer.wrap(transformedBytes)
+ )
+ );
+
+ when(layer2.build(any())).thenAnswer(invocation -> {
+ LayerBuildPayload p = invocation.getArgument(0);
+ byte[] received = new byte[p.payload().remaining()];
+ p.payload().duplicate().get(received);
+
+ // Layer2 must receive the payload transformed by layer1
+ assertArrayEquals(transformedBytes, received,
+ "layer2 must receive the transformed payload, not the original");
+
+ return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x02}));
+ });
+
+ when(layer3.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x03}))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2, layer3));
+ builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ verify(layer2, times(1)).build(any());
+ }
+
+ @Test
+ void shouldCorrectlyOutputLayerOrderWithMixedUnmodifiedTransformed() {
+ // Full trace: verify exact position of each byte in the output
+ // layers: [routing(unmod), encryption(transform), compression(unmod)]
+ LLPLayerBuilder compressionLayer = mock(LLPLayerBuilder.class);
+
+ when(layer1.getLayerId()).thenReturn(45); // routing passthrough
+ when(layer2.getLayerId()).thenReturn(130); // encryption transform
+ when(compressionLayer.getLayerId()).thenReturn(20); // compression passthrough
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(
+ ByteBuffer.wrap(new byte[]{0x01, 0x02}) // routing meta
+ )
+ );
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x10}), // encryption meta
+ ByteBuffer.wrap(new byte[]{(byte) 0xC1, (byte) 0xC2}) // encrypted blob
+ )
+ );
+ when(compressionLayer.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(
+ ByteBuffer.wrap(new byte[]{0x30}) // compression meta
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2, compressionLayer));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0xFF}));
+
+ // layer1 header was discarded by layer2's transformation
+ // Outer → inner: compression → encryption → FINAL → encrypted blob
+ assertArrayEquals(new byte[]{
+ 0x14, 0x01, 0x30, // compression (ID=20, LEN=1, meta=[30])
+ (byte) 0x82, 0x01, 0x10, // encryption (ID=130, LEN=1, meta=[10])
+ 0x00, // FINAL
+ (byte) 0xC1, (byte) 0xC2 // encrypted payload
+ }, result);
+ }
+
+ @Test
+ void shouldHandleMetadataLengthExactly256WithExtendedFormat() {
+ // 256 = first value requiring high byte != 0x00 in extended
+ when(layer1.getLayerId()).thenReturn(1);
+
+ byte[] meta = new byte[256];
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(meta))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
+
+ assertEquals(0x01, result[0] & 0xFF); // ID
+ assertEquals(0xFF, result[1] & 0xFF); // extended flag
+ assertEquals(0x01, result[2] & 0xFF); // high byte of 256 (0x01)
+ assertEquals(0x00, result[3] & 0xFF); // low byte of 256 (0x00)
+
+ // Total: 1 + 3 + 256 + 1 + 1 = 262
+ assertEquals(262, result.length);
+ }
+
+ @Test
+ void shouldNotInvokeAnyLayerWhenLayerListIsEmpty() {
+ // With an empty list, no builder should be called
+ builder = new ByteArrayFrameBuilder(List.of());
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ assertArrayEquals(new byte[]{0x00, 0x42}, result);
+ // No mocks to verify, but the test confirms no exception is thrown
+ }
+
+ @Test
+ void shouldStopAtFirstFailureWithoutCallingSubsequentLayers() {
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer2.getLayerId()).thenReturn(2);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Failure(TestBuildErrorReason.TEST_ERROR)
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+
+ assertThrows(FrameBuildException.class,
+ () -> builder.build(ByteBuffer.wrap(new byte[]{0x01}))
+ );
+
+ // layer2 should never have been called
+ verify(layer2, never()).build(any());
+ }
+
+ @Test
+ void shouldHandleLayerWithExactly254ByteMetadataAndVerifyRoundTrip() {
+ // 254 is the last value without extended flag — test that the byte is not confused with 0xFF
+ when(layer1.getLayerId()).thenReturn(1);
+
+ byte[] meta = new byte[254];
+ for (int i = 0; i < 254; i++) meta[i] = (byte) (i & 0xFF);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(meta))
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0xAB}));
+
+ assertEquals(0x01, result[0] & 0xFF); // ID
+ assertEquals(254, result[1] & 0xFF); // LEN without extended
+ // Verify that byte 1 is NOT 0xFF (which would trigger extended in the parser)
+ assertNotEquals(0xFF, result[1] & 0xFF);
+
+ // Total: 1 + 1 + 254 + 1 + 1 = 258
+ assertEquals(258, result.length);
+
+ // Verify metadata content
+ for (int i = 0; i < 254; i++) {
+ assertEquals((byte)(i & 0xFF), result[2 + i]);
+ }
+ }
+
+ private enum TestBuildErrorReason implements BuildErrorReason {
+ TEST_ERROR("test-error");
+
+ private final String reason;
+
+ TestBuildErrorReason(String reason) {
+ this.reason = reason;
+ }
+
+ @Override
+ public String reason() {
+ return this.reason;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java b/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java
new file mode 100644
index 0000000..736879d
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java
@@ -0,0 +1,187 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FailureNodeTest {
+
+ private byte[] extractMetadata(FailureNode node) {
+ ByteBuffer buffer = node.getMetadata();
+ byte[] extracted = new byte[buffer.remaining()];
+ buffer.get(extracted);
+ return extracted;
+ }
+
+ @Test
+ void testConstructorAndGetId() {
+ FailureNode node = new FailureNode(42, new byte[]{1, 2}, CoreParseErrorReason.METADATA_TRUNCATED);
+
+ assertEquals(42, node.getId());
+ }
+
+ @Test
+ void testMetadataContent() {
+ byte[] metadata = {10, 20, 30};
+
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ byte[] extracted = extractMetadata(node);
+
+ assertArrayEquals(metadata, extracted);
+ }
+
+ @Test
+ void testMetadataIsDefensivelyCopiedInConstructor() {
+ byte[] metadata = {1, 2, 3};
+
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ metadata[0] = 99;
+
+ byte[] extracted = extractMetadata(node);
+
+ assertEquals(1, extracted[0], "Internal state should not be affected by external changes");
+ }
+
+ @Test
+ void testMetadataIsDefensivelyCopiedInGetter() {
+ byte[] metadata = {1, 2, 3};
+
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ byte[] extracted1 = extractMetadata(node);
+ byte[] extracted2 = extractMetadata(node);
+
+ extracted1[0] = 99;
+
+ assertEquals(1, extracted2[0], "Getter should return a defensive copy");
+ }
+
+ @Test
+ void testNullMetadataBecomesEmptyArray() {
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ byte[] metadata = extractMetadata(node);
+
+ assertNotNull(metadata);
+ assertEquals(0, metadata.length);
+ }
+
+ @Test
+ void testEmptyMetadata() {
+ FailureNode node = new FailureNode(1, new byte[0], CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ byte[] metadata = extractMetadata(node);
+
+ assertNotNull(metadata);
+ assertEquals(0, metadata.length);
+ }
+
+ @Test
+ void testErrorReasonIsStoredCorrectly() {
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.UNKNOWN_CRITICAL_LAYER);
+
+ assertEquals(CoreParseErrorReason.UNKNOWN_CRITICAL_LAYER, node.getErrorReason());
+ }
+
+ @Test
+ void testErrorReasonCannotBeNull() {
+ assertThrows(NullPointerException.class, () ->
+ new FailureNode(1, null, null)
+ );
+ }
+
+ @Test
+ void testCauseIsStored() {
+ RuntimeException ex = new RuntimeException("boom");
+
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT, ex);
+
+ assertSame(ex, node.getCause().orElseGet(() -> new IllegalStateException("The cause of the failure has not been saved")));
+ }
+
+ @Test
+ void testCauseCanBeNull() {
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT, null);
+
+ assertEquals(Optional.empty(), node.getCause());
+ }
+
+ @Test
+ void testToStringContainsBasicInfo() {
+ FailureNode node = new FailureNode(99, new byte[]{1, 2, 3}, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ String str = node.toString();
+
+ assertTrue(str.contains("id=99"));
+ assertTrue(str.contains("metadataLength=3"));
+ assertTrue(str.contains("LAYER_TOO_SHORT"));
+ }
+
+ @Test
+ void testToStringIncludesCauseWhenPresent() {
+ IllegalStateException ex = new IllegalStateException();
+
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT, ex);
+
+ String str = node.toString();
+
+ assertTrue(str.contains("IllegalStateException"));
+ }
+
+ @Test
+ void testToStringWithoutCauseDoesNotFail() {
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ String str = node.toString();
+
+ assertNotNull(str);
+ }
+
+ @Test
+ void testLargeMetadata() {
+ byte[] metadata = new byte[1024];
+ for (int i = 0; i < metadata.length; i++) {
+ metadata[i] = (byte) i;
+ }
+
+ FailureNode node = new FailureNode(5, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ byte[] extracted = extractMetadata(node);
+
+ assertArrayEquals(metadata, extracted);
+ }
+
+ @Test
+ void testIdBoundaries() {
+ FailureNode min = new FailureNode(0, new byte[0], CoreParseErrorReason.LAYER_TOO_SHORT);
+ FailureNode max = new FailureNode(255, new byte[0], CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ assertEquals(0, min.getId());
+ assertEquals(255, max.getId());
+ }
+
+ @Test
+ void testGetMetadataReturnsReadOnlyBuffer() {
+ FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ ByteBuffer buffer = node.getMetadata();
+
+ assertTrue(buffer.isReadOnly());
+ assertThrows(Exception.class, () -> buffer.put((byte) 1));
+ }
+
+ @Test
+ void testGetMetadataReturnsDifferentBufferInstances() {
+ FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.LAYER_TOO_SHORT);
+
+ ByteBuffer b1 = node.getMetadata();
+ ByteBuffer b2 = node.getMetadata();
+
+ assertNotSame(b1, b2);
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java b/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
new file mode 100644
index 0000000..545f6d9
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
@@ -0,0 +1,215 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.ReadOnlyBufferException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FinalNodeTest {
+
+ @Test
+ void testEmptySingleton() {
+ FinalNode node1 = FinalNode.of((byte[]) null);
+ FinalNode node2 = FinalNode.of(new byte[0]);
+ FinalNode node3 = FinalNode.of((ByteBuffer) null);
+
+ assertSame(FinalNode.EMPTY, node1);
+ assertSame(FinalNode.EMPTY, node2);
+ assertSame(FinalNode.EMPTY, node3);
+ }
+
+ @Test
+ void testNonEmptyCreatesNewInstance() {
+ FinalNode node = FinalNode.of(new byte[]{1, 2, 3});
+ FinalNode node2 = FinalNode.of(ByteBuffer.wrap(new byte[]{4, 5, 6}));
+
+ assertNotSame(FinalNode.EMPTY, node);
+ assertNotSame(FinalNode.EMPTY, node2);
+ }
+
+ @Test
+ void testPayloadContent() {
+ byte[] payload = {1, 2, 3};
+
+ FinalNode node = FinalNode.of(payload);
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testPayloadIsReadOnly() {
+ FinalNode node = FinalNode.of(ByteBuffer.wrap(new byte[]{1, 2, 3}));
+
+ ByteBuffer buf = node.getPayload();
+
+ assertTrue(buf.isReadOnly());
+ assertThrows(ReadOnlyBufferException.class, () -> buf.put((byte) 0xFF));
+ }
+
+ @Test
+ void testPayloadReturnsNewBufferInstance() {
+ FinalNode node = FinalNode.of(new byte[]{1, 2, 3});
+
+ ByteBuffer b1 = node.getPayload();
+ ByteBuffer b2 = node.getPayload();
+
+ assertNotSame(b1, b2);
+ }
+
+ @Test
+ void testOriginalArrayModificationDoesNotAffectNode() {
+ byte[] payload = {1, 2, 3};
+
+ FinalNode node = FinalNode.of(payload);
+
+ // Modify original array
+ payload[0] = 99;
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertNotEquals(99, extracted[0]);
+ }
+
+ @Test
+ void testIdIsZero() {
+ FinalNode node = FinalNode.of(new byte[]{1});
+
+ assertEquals(0, node.getId());
+ }
+
+ @Test
+ void testToStringContainsHexPayload() {
+ byte[] payload = {0x0A, 0x0B};
+
+ FinalNode node = FinalNode.of(payload);
+
+ String str = node.toString();
+
+ assertTrue(str.contains("0A0B"));
+ }
+
+ @Test
+ void testByteBufferModificationDoesNotAffectNode() {
+ ByteBuffer original = ByteBuffer.wrap(new byte[]{1, 2, 3});
+
+ FinalNode node = FinalNode.of(original);
+
+ // Modify original buffer content
+ original.put(0, (byte) 99);
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertEquals(1, extracted[0], "Node payload must be independent from original buffer");
+ }
+
+ @Test
+ void testByteBufferPositionLimitIndependence() {
+ ByteBuffer original = ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5});
+ original.position(2); // remaining = {3,4,5}
+
+ FinalNode node = FinalNode.of(original);
+
+ // Change original state after creation
+ original.position(0);
+ original.limit(1);
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(new byte[]{3, 4, 5}, extracted,
+ "Node must copy only remaining bytes at creation time");
+ }
+
+ @Test
+ void testReadOnlySourceBufferDoesNotAffectNode() {
+ ByteBuffer original = ByteBuffer.wrap(new byte[]{1, 2, 3}).asReadOnlyBuffer();
+
+ FinalNode node = FinalNode.of(original);
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(new byte[]{1, 2, 3}, extracted);
+ }
+
+ @Test
+ void testOriginalBufferConsumedAfterCreationDoesNotAffectNode() {
+ ByteBuffer original = ByteBuffer.wrap(new byte[]{1, 2, 3});
+
+ FinalNode node = FinalNode.of(original);
+
+ // Consume original buffer completely
+ while (original.hasRemaining()) {
+ original.get();
+ }
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(new byte[]{1, 2, 3}, extracted);
+ }
+
+ @Test
+ void testSharedBackingArrayDoesNotAffectNode() {
+ byte[] array = {1, 2, 3};
+ ByteBuffer buffer = ByteBuffer.wrap(array);
+
+ FinalNode node = FinalNode.of(buffer);
+
+ // Modify underlying array directly
+ array[1] = 99;
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertEquals(2, extracted[1], "Node must not share backing array");
+ }
+
+ @Test
+ void testSliceBufferIndependence() {
+ ByteBuffer original = ByteBuffer.wrap(new byte[]{1, 2, 3, 4});
+ original.position(1); // {2,3,4}
+ ByteBuffer slice = original.slice();
+
+ FinalNode node = FinalNode.of(slice);
+
+ // Modify original array
+ original.put(1, (byte) 99);
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(new byte[]{2, 3, 4}, extracted);
+ }
+
+ @Test
+ void testDuplicateBufferIndependence() {
+ ByteBuffer original = ByteBuffer.wrap(new byte[]{1, 2, 3});
+ ByteBuffer duplicate = original.duplicate();
+
+ FinalNode node = FinalNode.of(duplicate);
+
+ original.put(0, (byte) 99);
+
+ ByteBuffer buf = node.getPayload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertEquals(1, extracted[0]);
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
new file mode 100644
index 0000000..275c6b7
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
@@ -0,0 +1,191 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LLPFrameTest {
+
+ // ----------- Helper Node -----------
+
+ static class TestNode implements LLPNode {
+ private final int id;
+
+ TestNode(int id) {
+ this.id = id;
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+ }
+
+ private NodeChain createChain(int... ids) {
+ NodeChain.Builder builder = new NodeChain.Builder();
+ for (int id : ids) {
+ builder.add(new TestNode(id));
+ }
+ return builder.build();
+ }
+
+ // ----------- Basic behavior -----------
+
+ @Test
+ void testConstructorAndGetters() {
+ NodeChain chain = createChain(1, 2, 3);
+
+ LLPFrame frame = new LLPFrame(chain, 1234, 999L);
+
+ assertEquals(1234, frame.crc());
+ assertEquals(999L, frame.timestamp());
+ assertSame(chain, frame.chain());
+ }
+
+ @Test
+ void testConstructorWithCurrentTimestamp() {
+ NodeChain chain = createChain(1);
+
+ long before = System.currentTimeMillis();
+ LLPFrame frame = new LLPFrame(chain, 55);
+ long after = System.currentTimeMillis();
+
+ assertTrue(frame.timestamp() >= before);
+ assertTrue(frame.timestamp() <= after);
+ }
+
+ // ----------- toString -----------
+
+ @Test
+ void testToStringContainsImportantData() {
+ NodeChain chain = createChain(1, 2);
+
+ LLPFrame frame = new LLPFrame(chain, 999, 123L);
+
+ String str = frame.toString();
+
+ assertTrue(str.contains("crc=999"));
+ assertTrue(str.contains("timestamp=123"));
+ assertTrue(str.contains("nodes=2"));
+ }
+
+ // ----------- equals / hashCode -----------
+
+ @Test
+ void testEqualsSameInstance() {
+ NodeChain chain = createChain(1);
+
+ LLPFrame frame = new LLPFrame(chain, 10);
+ LLPFrame frame2 = new LLPFrame(chain, 10);
+
+ assertEquals(frame, frame2);
+ }
+
+ @Test
+ void testEqualsDifferentObjectsSameContent() {
+ NodeChain chain1 = new NodeChain.Builder()
+ .add(new SpecialNode(1))
+ .add(new SpecialNode(2))
+ .build();
+
+ NodeChain chain2 = new NodeChain.Builder()
+ .add(new SpecialNode(1))
+ .add(new SpecialNode(2))
+ .build();
+
+ LLPFrame f1 = new LLPFrame(chain1, 100, 1L);
+ LLPFrame f2 = new LLPFrame(chain2, 100, 999L); // different timestamp
+
+ assertEquals(f1, f2, "Timestamp should be ignored in equals");
+ assertEquals(f1.hashCode(), f2.hashCode());
+ }
+
+ @Test
+ void testNotEqualsDifferentCRC() {
+ NodeChain chain = createChain(1, 2);
+
+ LLPFrame f1 = new LLPFrame(chain, 100);
+ LLPFrame f2 = new LLPFrame(chain, 200);
+
+ assertNotEquals(f1, f2);
+ }
+
+ @Test
+ void testNotEqualsDifferentNodeChain() {
+ NodeChain chain1 = createChain(1, 2);
+ NodeChain chain2 = createChain(1, 3);
+
+ LLPFrame f1 = new LLPFrame(chain1, 100);
+ LLPFrame f2 = new LLPFrame(chain2, 100);
+
+ assertNotEquals(f1, f2);
+ }
+
+ @Test
+ void testNotEqualsNull() {
+ NodeChain chain = createChain(1);
+
+ LLPFrame frame = new LLPFrame(chain, 10);
+
+ assertNotEquals(frame, null);
+ }
+
+ @Test
+ void testNotEqualsDifferentType() {
+ NodeChain chain = createChain(1);
+
+ LLPFrame frame = new LLPFrame(chain, 10);
+
+ assertNotEquals(frame, "not a frame");
+ }
+
+ // ----------- HashCode consistency -----------
+
+ @Test
+ void testHashCodeConsistency() {
+ NodeChain chain = createChain(1, 2);
+
+ LLPFrame frame = new LLPFrame(chain, 123);
+
+ int h1 = frame.hashCode();
+ int h2 = frame.hashCode();
+
+ assertEquals(h1, h2);
+ }
+
+ // ----------- Chain reference behavior -----------
+
+ @Test
+ void testChainReferenceIsSameInstance() {
+ NodeChain chain = createChain(1, 2);
+
+ LLPFrame frame = new LLPFrame(chain, 1);
+
+ assertSame(chain, frame.chain(), "Frame should keep reference to NodeChain");
+ }
+
+ // ----------- Edge cases -----------
+
+ @Test
+ void testEmptyChain() {
+ NodeChain chain = new NodeChain.Builder().build();
+
+ LLPFrame frame = new LLPFrame(chain, 0);
+
+ assertEquals(0, frame.chain().size());
+ }
+
+ @Test
+ void testLargeChain() {
+ NodeChain.Builder builder = new NodeChain.Builder();
+
+ for (int i = 0; i < 1000; i++) {
+ builder.add(new TestNode(i));
+ }
+
+ LLPFrame frame = new LLPFrame(builder.build(), 123);
+
+ assertEquals(1000, frame.chain().size());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPIncrementalParserTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPIncrementalParserTest.java
new file mode 100644
index 0000000..a5c6171
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPIncrementalParserTest.java
@@ -0,0 +1,551 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Optional;
+import java.util.Queue;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LLPIncrementalParserTest {
+
+ // Shared provider that recognizes no layers — valid for transport-only tests
+ private static final LayerParserProvider EMPTY_PROVIDER = id -> Optional.empty();
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ /**
+ * Builds a complete, valid LLP transport frame whose LLP payload contains
+ * a FinalNode marker (0x00) followed by the given raw bytes.
+ *
+ * The result is suitable for feeding directly into an LLPIncrementalParser.
+ */
+ private static byte[] buildValidFrame(byte... rawData) {
+ byte[] llpPayload = new byte[1 + rawData.length];
+ llpPayload[0] = 0x00; // FinalNode marker
+ System.arraycopy(rawData, 0, llpPayload, 1, rawData.length);
+ return LLPTransportFramer.buildSafe(llpPayload);
+ }
+
+ /**
+ * Builds a transport-level byte sequence that passes magic/length parsing
+ * but carries a deliberately wrong CRC, triggering CHECKSUM_INVALID.
+ *
+ * Payload is a single byte (0x42). No byte in the sequence equals 0xAA,
+ * so no byte stuffing is needed and the structure is straightforward.
+ */
+ private static byte[] buildFrameWithBadCrc() {
+ return new byte[]{
+ (byte) 0xAA, 0x55, // magic
+ 0x01, 0x00, // length = 1
+ 0x42, // payload byte
+ 0x00, 0x00 // wrong CRC
+ };
+ }
+
+ /**
+ * Accesses the private FrameListener registered inside the parser's deframer
+ * via reflection, allowing direct unit testing of the listener callbacks
+ * without going through the full transport stack.
+ */
+ @SuppressWarnings("unchecked")
+ private static LLPTransportDeframer.LLPFrameListener extractListener(
+ LLPIncrementalParser parser) throws Exception {
+
+ Field deframerField = LLPIncrementalParser.class.getDeclaredField("deframer");
+ deframerField.setAccessible(true);
+ LLPTransportDeframer deframer = (LLPTransportDeframer) deframerField.get(parser);
+
+ Field listenersField = LLPTransportDeframer.class.getDeclaredField("listeners");
+ listenersField.setAccessible(true);
+ Queue listeners =
+ (Queue) listenersField.get(deframer);
+
+ assertFalse(listeners.isEmpty(), "FrameListener was not registered in the deframer");
+ return listeners.peek();
+ }
+
+ // =========================================================================
+ // Construction
+ // =========================================================================
+
+ @Test
+ void shouldCreateParserSuccessfully() {
+ assertNotNull(new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1));
+ }
+
+ @Test
+ void shouldCreateParserWithCustomConfiguration() {
+ assertNotNull(new LLPIncrementalParser(EMPTY_PROVIDER, 8192, 5000L));
+ }
+
+ // =========================================================================
+ // Initial state
+ // =========================================================================
+
+ @Test
+ void shouldReturnEmptyFramesWhenNothingWasFed() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ List frames = parser.pollFrames();
+ assertNotNull(frames);
+ assertTrue(frames.isEmpty());
+ }
+
+ @Test
+ void shouldReturnEmptyErrorsWhenNothingWasFed() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ List errors = parser.pollErrors();
+ assertNotNull(errors);
+ assertTrue(errors.isEmpty());
+ }
+
+ @Test
+ void shouldReturnImmutableFrameList() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ List frames = parser.pollFrames();
+ assertThrows(UnsupportedOperationException.class, () -> frames.add(null));
+ }
+
+ @Test
+ void shouldReturnImmutableErrorList() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ List errors = parser.pollErrors();
+ assertThrows(UnsupportedOperationException.class,
+ () -> errors.add(TransportErrorCode.CHECKSUM_INVALID));
+ }
+
+ // =========================================================================
+ // feed() — basic contracts
+ // =========================================================================
+
+ @Test
+ void feedSingleByteShouldNotThrow() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertDoesNotThrow(() -> parser.feed((byte) 0x01));
+ }
+
+ @Test
+ void feedByteArrayShouldNotThrow() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertDoesNotThrow(() -> parser.feed(new byte[]{0x01, 0x02, 0x03}));
+ }
+
+ @Test
+ void feedEmptyByteArrayShouldNotThrow() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertDoesNotThrow(() -> parser.feed(new byte[0]));
+ }
+
+ @Test
+ void feedByteBufferShouldNotThrow() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertDoesNotThrow(() -> parser.feed(ByteBuffer.wrap(new byte[]{0x01, 0x02})));
+ }
+
+ @Test
+ void feedEmptyByteBufferShouldNotThrow() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertDoesNotThrow(() -> parser.feed(ByteBuffer.allocate(0)));
+ }
+
+ @Test
+ void feedByteBufferShouldConsumeAllRemainingBytes() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ ByteBuffer buffer = ByteBuffer.wrap(new byte[]{0x01, 0x02, 0x03});
+ parser.feed(buffer);
+ assertEquals(0, buffer.remaining());
+ }
+
+ @Test
+ void feedNullByteArrayShouldThrowNullPointerException() {
+ // LLPTransportDeframer.processBytes() iterates with enhanced-for,
+ // which throws NPE for null input. This is an implicit contract.
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertThrows(NullPointerException.class, () -> parser.feed((byte[]) null));
+ }
+
+ @Test
+ void feedNullByteBufferShouldThrowNullPointerException() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ assertThrows(NullPointerException.class, () -> parser.feed((ByteBuffer) null));
+ }
+
+ // =========================================================================
+ // Integration — valid frame parsing
+ // =========================================================================
+
+ @Test
+ void shouldProduceOneFrameWhenFedCompleteFrameAsByteArray() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildValidFrame((byte) 0x11, (byte) 0x22));
+
+ List frames = parser.pollFrames();
+ assertEquals(1, frames.size());
+ }
+
+ @Test
+ void shouldProduceOneFrameWhenFedCompleteFrameByteByByte() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ byte[] frame = buildValidFrame((byte) 0x42);
+
+ for (byte b : frame) {
+ parser.feed(b);
+ }
+
+ assertEquals(1, parser.pollFrames().size());
+ }
+
+ @Test
+ void shouldProduceOneFrameWhenFedCompleteFrameViaByteBuffer() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(ByteBuffer.wrap(buildValidFrame((byte) 0x33)));
+
+ assertEquals(1, parser.pollFrames().size());
+ }
+
+ @Test
+ void allFeedMethodsShouldProduceEquivalentFrames() {
+ byte[] transportFrame = buildValidFrame((byte) 0x55);
+
+ LLPIncrementalParser parserArray = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPIncrementalParser parserBuffer = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPIncrementalParser parserBytes = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parserArray.feed(transportFrame);
+ parserBuffer.feed(ByteBuffer.wrap(transportFrame));
+ for (byte b : transportFrame) parserBytes.feed(b);
+
+ LLPFrame fromArray = parserArray.pollFrames().getFirst();
+ LLPFrame fromBuffer = parserBuffer.pollFrames().getFirst();
+ LLPFrame fromBytes = parserBytes.pollFrames().getFirst();
+
+ // CRC must be identical — it is computed by the transport layer from the raw bytes
+ assertEquals(fromArray.crc(), fromBuffer.crc(),
+ "CRC must match between byte[] and ByteBuffer feeds");
+ assertEquals(fromArray.crc(), fromBytes.crc(),
+ "CRC must match between byte[] and byte-by-byte feeds");
+
+ // Chain structure must be identical
+ assertEquals(fromArray.chain().size(), fromBuffer.chain().size(),
+ "Chain size must match between byte[] and ByteBuffer feeds");
+ assertEquals(fromArray.chain().size(), fromBytes.chain().size(),
+ "Chain size must match between byte[] and byte-by-byte feeds");
+
+ // All three must have produced a FinalNode
+ assertInstanceOf(FinalNode.class, fromArray.chain().asList().getFirst());
+ assertInstanceOf(FinalNode.class, fromBuffer.chain().asList().getFirst());
+ assertInstanceOf(FinalNode.class, fromBytes.chain().asList().getFirst());
+
+ // The raw payload bytes inside the FinalNode must be identical.
+ // We compare content explicitly because LLPNode is an SPI contract —
+ // external implementations are not guaranteed to provide value-based equals().
+ byte[] payloadFromArray = toByteArray(((FinalNode) fromArray.chain().asList().getFirst()).getPayload());
+ byte[] payloadFromBuffer = toByteArray(((FinalNode) fromBuffer.chain().asList().getFirst()).getPayload());
+ byte[] payloadFromBytes = toByteArray(((FinalNode) fromBytes.chain().asList().getFirst()).getPayload());
+
+ assertArrayEquals(payloadFromArray, payloadFromBuffer,
+ "Payload bytes must match between byte[] and ByteBuffer feeds");
+ assertArrayEquals(payloadFromArray, payloadFromBytes,
+ "Payload bytes must match between byte[] and byte-by-byte feeds");
+ }
+
+ // Helper — extracts bytes from a read-only ByteBuffer without mutating it
+ private static byte[] toByteArray(ByteBuffer buffer) {
+ byte[] bytes = new byte[buffer.remaining()];
+ buffer.duplicate().get(bytes);
+ return bytes;
+ }
+
+ @Test
+ void shouldNotProduceFrameWhenFedOnlyPartialFrame() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ byte[] fullFrame = buildValidFrame((byte) 0x01, (byte) 0x02, (byte) 0x03);
+
+ // Feed only the first half
+ byte[] partial = new byte[fullFrame.length / 2];
+ System.arraycopy(fullFrame, 0, partial, 0, partial.length);
+ parser.feed(partial);
+
+ assertTrue(parser.pollFrames().isEmpty());
+ }
+
+ @Test
+ void shouldProduceFrameAfterTwoPartialFeeds() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ byte[] fullFrame = buildValidFrame((byte) 0x01, (byte) 0x02, (byte) 0x03);
+ int half = fullFrame.length / 2;
+
+ byte[] firstHalf = new byte[half];
+ byte[] secondHalf = new byte[fullFrame.length - half];
+ System.arraycopy(fullFrame, 0, firstHalf, 0, half);
+ System.arraycopy(fullFrame, half, secondHalf, 0, secondHalf.length);
+
+ parser.feed(firstHalf);
+ assertTrue(parser.pollFrames().isEmpty(), "No frame expected after partial feed");
+
+ parser.feed(secondHalf);
+ assertEquals(1, parser.pollFrames().size(), "Frame expected after complete feed");
+ }
+
+ @Test
+ void shouldProduceTwoFramesWhenFedConcatenatedFrames() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ byte[] frame1 = buildValidFrame((byte) 0x11);
+ byte[] frame2 = buildValidFrame((byte) 0x22);
+ byte[] both = new byte[frame1.length + frame2.length];
+ System.arraycopy(frame1, 0, both, 0, frame1.length);
+ System.arraycopy(frame2, 0, both, frame1.length, frame2.length);
+
+ parser.feed(both);
+
+ assertEquals(2, parser.pollFrames().size());
+ }
+
+ @Test
+ void shouldProduceTwoFramesWhenFedInSeparateCalls() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildValidFrame((byte) 0x11));
+ parser.feed(buildValidFrame((byte) 0x22));
+
+ assertEquals(2, parser.pollFrames().size());
+ }
+
+ @Test
+ void parsedFrameShouldContainFinalNodeInChain() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildValidFrame((byte) 0xAB, (byte) 0xCD));
+
+ LLPFrame frame = parser.pollFrames().get(0);
+ assertEquals(1, frame.chain().size());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(0));
+ }
+
+ @Test
+ void parsedFrameShouldHaveNonZeroCrc() {
+ // The CRC is computed and validated by the transport layer.
+ // A valid frame will never carry CRC 0 in practice.
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildValidFrame((byte) 0x01, (byte) 0x02));
+
+ LLPFrame frame = parser.pollFrames().get(0);
+ assertNotEquals(0, frame.crc());
+ }
+
+ // =========================================================================
+ // Integration — transport errors
+ // =========================================================================
+
+ @Test
+ void shouldAccumulateChecksumInvalidErrorOnBadCrc() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildFrameWithBadCrc());
+
+ List errors = parser.pollErrors();
+ assertEquals(1, errors.size());
+ assertEquals(TransportErrorCode.CHECKSUM_INVALID, errors.get(0));
+ }
+
+ @Test
+ void shouldAccumulateMultipleErrorsFromMultipleInvalidFrames() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildFrameWithBadCrc());
+ parser.feed(buildFrameWithBadCrc());
+
+ assertEquals(2, parser.pollErrors().size());
+ }
+
+ @Test
+ void shouldAccumulateBothFramesAndErrorsIndependently() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildValidFrame((byte) 0x01));
+ parser.feed(buildFrameWithBadCrc());
+ parser.feed(buildValidFrame((byte) 0x02));
+
+ assertEquals(2, parser.pollFrames().size());
+ assertEquals(1, parser.pollErrors().size());
+ }
+
+ // =========================================================================
+ // pollFrames() / pollErrors() — clearing behavior with real data
+ // =========================================================================
+
+ @Test
+ void pollFramesShouldClearAccumulatedFrames() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ parser.feed(buildValidFrame((byte) 0x11));
+ parser.feed(buildValidFrame((byte) 0x22));
+
+ List firstPoll = parser.pollFrames();
+ assertEquals(2, firstPoll.size(), "Both frames must be present in first poll");
+
+ List secondPoll = parser.pollFrames();
+ assertTrue(secondPoll.isEmpty(), "Second poll must be empty after clearing");
+ }
+
+ @Test
+ void pollErrorsShouldClearAccumulatedErrors() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ parser.feed(buildFrameWithBadCrc());
+ parser.feed(buildFrameWithBadCrc());
+
+ List firstPoll = parser.pollErrors();
+ assertEquals(2, firstPoll.size(), "Both errors must be present in first poll");
+
+ List secondPoll = parser.pollErrors();
+ assertTrue(secondPoll.isEmpty(), "Second poll must be empty after clearing");
+ }
+
+ @Test
+ void pollShouldNotAffectFramesAccumulatedAfterClearing() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+
+ parser.feed(buildValidFrame((byte) 0x01));
+ parser.pollFrames(); // clears
+
+ // Feed again after clearing
+ parser.feed(buildValidFrame((byte) 0x02));
+ assertEquals(1, parser.pollFrames().size());
+ }
+
+ @Test
+ void pollFramesShouldReturnImmutableListEvenWhenNonEmpty() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ parser.feed(buildValidFrame((byte) 0x01));
+
+ List frames = parser.pollFrames();
+ assertThrows(UnsupportedOperationException.class, () -> frames.remove(0));
+ }
+
+ @Test
+ void pollErrorsShouldReturnImmutableListEvenWhenNonEmpty() {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ parser.feed(buildFrameWithBadCrc());
+
+ List errors = parser.pollErrors();
+ assertThrows(UnsupportedOperationException.class, () -> errors.remove(0));
+ }
+
+ // =========================================================================
+ // FrameListener — direct unit tests via reflection
+ // =========================================================================
+
+ @Test
+ void frameListenerShouldBeRegisteredInDeframer() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ // extractListener() asserts internally that the listener is present
+ assertNotNull(extractListener(parser));
+ }
+
+ @Test
+ void frameListenerOnFrameReceivedShouldAddParsedFrameToCompletedFrames() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ // Feed a valid raw frame directly to the listener, bypassing transport
+ LLPRawFrame rawFrame = new LLPRawFrame(new byte[]{0x00}, (byte) 0x1234);
+ listener.onFrameReceived(rawFrame);
+
+ List frames = parser.pollFrames();
+ assertEquals(1, frames.size());
+ }
+
+ @Test
+ void frameListenerOnFrameReceivedShouldProduceFrameWithExpectedCrc() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ LLPRawFrame rawFrame = new LLPRawFrame(new byte[]{0x00}, (byte) 0xABCD);
+ listener.onFrameReceived(rawFrame);
+
+ LLPFrame frame = parser.pollFrames().get(0);
+ assertEquals((byte) 0xABCD, frame.crc());
+ }
+
+ @Test
+ void frameListenerOnFrameReceivedShouldAccumulateMultipleFrames() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ listener.onFrameReceived(new LLPRawFrame(new byte[]{0x00}, (byte) 0x0001));
+ listener.onFrameReceived(new LLPRawFrame(new byte[]{0x00}, (byte) 0x0002));
+ listener.onFrameReceived(new LLPRawFrame(new byte[]{0x00}, (byte) 0x0003));
+
+ assertEquals(3, parser.pollFrames().size());
+ }
+
+ @Test
+ void frameListenerOnFrameErrorShouldAddErrorCodeToErrors() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ listener.onFrameError(TransportErrorCode.CHECKSUM_INVALID);
+
+ List errors = parser.pollErrors();
+ assertEquals(1, errors.size());
+ assertEquals(TransportErrorCode.CHECKSUM_INVALID, errors.get(0));
+ }
+
+ @Test
+ void frameListenerOnFrameErrorShouldAccumulateMultipleErrorCodes() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ listener.onFrameError(TransportErrorCode.CHECKSUM_INVALID);
+ listener.onFrameError(TransportErrorCode.TIMEOUT);
+ listener.onFrameError(TransportErrorCode.SYNC_ERROR);
+
+ List errors = parser.pollErrors();
+ assertEquals(3, errors.size());
+ assertEquals(TransportErrorCode.CHECKSUM_INVALID, errors.get(0));
+ assertEquals(TransportErrorCode.TIMEOUT, errors.get(1));
+ assertEquals(TransportErrorCode.SYNC_ERROR, errors.get(2));
+ }
+
+ @Test
+ void frameListenerOnFrameErrorShouldNotAffectFrameQueue() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ listener.onFrameError(TransportErrorCode.CHECKSUM_INVALID);
+
+ assertTrue(parser.pollFrames().isEmpty());
+ }
+
+ @Test
+ void frameListenerOnFrameReceivedShouldNotAffectErrorQueue() throws Exception {
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ listener.onFrameReceived(new LLPRawFrame(new byte[]{0x00}, 0));
+
+ assertTrue(parser.pollErrors().isEmpty());
+ }
+
+ @Test
+ void frameListenerShouldSurviveRawFrameWithEmptyPayload() throws Exception {
+ // Verifies the listener delegates to the parser without crashing
+ // even for a raw frame with no bytes — the parser should produce
+ // an LLPFrame with an empty node chain.
+ LLPIncrementalParser parser = new LLPIncrementalParser(EMPTY_PROVIDER, -1, -1);
+ LLPTransportDeframer.LLPFrameListener listener = extractListener(parser);
+
+ LLPRawFrame emptyRaw = new LLPRawFrame(new byte[0], 0);
+ assertDoesNotThrow(() -> listener.onFrameReceived(emptyRaw));
+
+ assertEquals(1, parser.pollFrames().size());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
new file mode 100644
index 0000000..673f0a8
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
@@ -0,0 +1,177 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.ReadOnlyBufferException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LLPRawFrameTest {
+
+ @Test
+ void testPayloadContent() {
+ byte[] payload = {1, 2, 3};
+
+ LLPRawFrame frame = new LLPRawFrame(payload, 0x1234);
+
+ ByteBuffer buf = frame.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testPayloadIsReadOnly() {
+ LLPRawFrame frame = new LLPRawFrame(new byte[]{1, 2, 3}, 0);
+
+ ByteBuffer buf = frame.payload();
+
+ assertTrue(buf.isReadOnly());
+ assertThrows(ReadOnlyBufferException.class, () -> buf.put((byte) 0xFF));
+ }
+
+ @Test
+ void testPayloadReturnsDuplicateBuffer() {
+ LLPRawFrame frame = new LLPRawFrame(new byte[]{1, 2, 3}, 0);
+
+ ByteBuffer b1 = frame.payload();
+ ByteBuffer b2 = frame.payload();
+
+ assertNotSame(b1, b2);
+ }
+
+ @Test
+ void testBufferPositionIndependence() {
+ LLPRawFrame frame = new LLPRawFrame(new byte[]{1, 2, 3}, 0);
+
+ ByteBuffer b1 = frame.payload();
+ ByteBuffer b2 = frame.payload();
+
+ b1.get(); // move position
+
+ assertEquals(1, b1.position());
+ assertEquals(0, b2.position(), "Buffers should have independent positions");
+ }
+
+ @Test
+ void testOriginalArrayModificationDoesNotAffectFrame() {
+ byte[] payload = {1, 2, 3};
+
+ LLPRawFrame frame = new LLPRawFrame(payload, 0);
+
+ // Modify original array AFTER construction
+ payload[0] = 99;
+
+ ByteBuffer buf = frame.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertEquals(1, extracted[0], "Internal payload must be protected from external changes");
+ }
+
+ @Test
+ void testPayloadLengthRespected() {
+ byte[] payload = {1, 2, 3, 4, 5};
+
+ LLPRawFrame frame = new LLPRawFrame(payload, 3, 0, 0);
+
+ ByteBuffer buf = frame.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(new byte[]{1, 2, 3}, extracted);
+ }
+
+ @Test
+ void testNullPayloadBecomesEmpty() {
+ LLPRawFrame frame = new LLPRawFrame(null, 0);
+
+ ByteBuffer buf = frame.payload();
+
+ assertNotNull(buf);
+ assertEquals(0, buf.remaining());
+ }
+
+ @Test
+ void testPayloadParameterConstructors() {
+ byte[] validPayload = {1, 2, 3, 4, 5};
+
+ LLPRawFrame normalFrame = new LLPRawFrame(validPayload, 0);
+ LLPRawFrame framePayloadNull = new LLPRawFrame(null, 10, 0, System.currentTimeMillis());
+ LLPRawFrame frameWithBadLength = new LLPRawFrame(validPayload, -2, 0, System.currentTimeMillis());
+ LLPRawFrame framePayloadEmpty = new LLPRawFrame(new byte[0], 0);
+
+ ByteBuffer buf1 = normalFrame.payload();
+ ByteBuffer buf2 = framePayloadNull.payload();
+ ByteBuffer buf3 = frameWithBadLength.payload();
+ ByteBuffer buf4 = framePayloadEmpty.payload();
+
+ // EMPTY cases → empty payload
+ assertEquals(0, buf2.remaining());
+ assertEquals(0, buf3.remaining());
+ assertEquals(0, buf4.remaining());
+
+ // NORMAL case → payload present
+ assertEquals(validPayload.length, buf1.remaining());
+
+ // Different buffers (defensive)
+ assertNotSame(buf2, buf3);
+ assertNotSame(buf1, buf2);
+ assertNotSame(buf1, buf4);
+ }
+
+ @Test
+ void testCrcValue() {
+ LLPRawFrame frame = new LLPRawFrame(new byte[]{1}, 0xABCD);
+
+ assertEquals(0xABCD, frame.crc());
+ }
+
+ @Test
+ void testTimestampAutoAssigned() {
+ long before = System.currentTimeMillis();
+
+ LLPRawFrame frame = new LLPRawFrame(new byte[]{1}, 0);
+
+ long after = System.currentTimeMillis();
+
+ assertTrue(frame.timestamp() >= before);
+ assertTrue(frame.timestamp() <= after);
+ }
+
+ @Test
+ void testTimestampCustom() {
+ long ts = 123456789L;
+
+ LLPRawFrame frame = new LLPRawFrame(new byte[]{1}, 0, ts);
+
+ assertEquals(ts, frame.timestamp());
+ }
+
+ @Test
+ void testLargePayload() {
+ byte[] payload = new byte[1024];
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) i;
+ }
+
+ LLPRawFrame frame = new LLPRawFrame(payload, 0);
+
+ ByteBuffer buf = frame.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testPayloadLenGreaterThanArrayThrows() {
+ byte[] payload = {1, 2, 3};
+
+ assertThrows(IllegalArgumentException.class, () ->
+ new LLPRawFrame(payload, 10, 0, System.currentTimeMillis())
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTest.java
new file mode 100644
index 0000000..62e07dd
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTest.java
@@ -0,0 +1,386 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Constructor;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+
+class LLPTest {
+
+ // =========================================================================
+ // Private constructor
+ // =========================================================================
+
+ @Test
+ void shouldHavePrivateConstructor() throws Exception {
+ Constructor constructor = LLP.class.getDeclaredConstructor();
+ assertTrue(java.lang.reflect.Modifier.isPrivate(constructor.getModifiers()));
+ }
+
+ // =========================================================================
+ // Factory methods return independent instances
+ // =========================================================================
+
+ @Test
+ void frameBuilderShouldReturnNewInstanceOnEachCall() {
+ var first = LLP.frameBuilder();
+ var second = LLP.frameBuilder();
+ assertNotSame(first, second);
+ }
+
+ @Test
+ void frameParserBuilderShouldReturnNewInstanceOnEachCall() {
+ var first = LLP.frameParser();
+ var second = LLP.frameParser();
+ assertNotSame(first, second);
+ }
+
+ @Test
+ void incrementalParserBuilderShouldReturnNewInstanceOnEachCall() {
+ var first = LLP.incrementalParser();
+ var second = LLP.incrementalParser();
+ assertNotSame(first, second);
+ }
+
+ // =========================================================================
+ // FrameBuilderConfigurator — build()
+ // =========================================================================
+
+ @Test
+ void shouldCreateFrameBuilderWithLayers() {
+ LLPLayerBuilder layer1 = mock(LLPLayerBuilder.class);
+ LLPLayerBuilder layer2 = mock(LLPLayerBuilder.class);
+
+ LLPFrameBuilder builder = LLP.frameBuilder()
+ .addLayer(layer1)
+ .addLayers(List.of(layer2))
+ .build();
+
+ assertNotNull(builder);
+ assertInstanceOf(ByteArrayFrameBuilder.class, builder);
+ }
+
+ @Test
+ void shouldCreateFrameBuilderWithEmptyLayers() {
+ LLPFrameBuilder builder = LLP.frameBuilder().build();
+ assertNotNull(builder);
+ assertInstanceOf(ByteArrayFrameBuilder.class, builder);
+ }
+
+ @Test
+ void buildShouldReturnDifferentFrameBuilderInstancesOnEachCall() {
+ // The same configurator produces independent builders on each build() call
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder()
+ .addLayer(mock(LLPLayerBuilder.class));
+
+ LLPFrameBuilder first = configurator.build();
+ LLPFrameBuilder second = configurator.build();
+
+ assertNotSame(first, second);
+ }
+
+ @Test
+ void buildShouldProduceIndependentBuildersFromSeparateConfigurators() {
+ LLPLayerBuilder layer = mock(LLPLayerBuilder.class);
+
+ LLPFrameBuilder first = LLP.frameBuilder().addLayer(layer).build();
+ LLPFrameBuilder second = LLP.frameBuilder().build();
+
+ assertNotSame(first, second);
+ }
+
+ // =========================================================================
+ // FrameBuilderConfigurator — addLayer / addLayers validation
+ // =========================================================================
+
+ @Test
+ void addLayerShouldThrowOnNullLayer() {
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder();
+
+ NullPointerException ex = assertThrows(
+ NullPointerException.class,
+ () -> configurator.addLayer(null)
+ );
+ assertEquals("Layer cannot be null", ex.getMessage());
+ }
+
+ @Test
+ void addLayersShouldThrowOnNullList() {
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder();
+
+ NullPointerException ex = assertThrows(
+ NullPointerException.class,
+ () -> configurator.addLayers(null)
+ );
+ assertEquals("Layers list cannot be null", ex.getMessage());
+ }
+
+ @Test
+ void addLayersShouldThrowWhenListContainsNullElement() {
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder();
+ List listWithNull = Arrays.asList(mock(LLPLayerBuilder.class), null);
+
+ NullPointerException ex = assertThrows(
+ NullPointerException.class,
+ () -> configurator.addLayers(listWithNull)
+ );
+ assertEquals("Layer cannot be null", ex.getMessage());
+ }
+
+ @Test
+ void addLayersWithEmptyListShouldBeValidNoOp() {
+ // An empty list is a legal argument — it simply adds nothing
+ LLPFrameBuilder builder = LLP.frameBuilder()
+ .addLayers(Collections.emptyList())
+ .build();
+
+ assertNotNull(builder);
+ assertInstanceOf(ByteArrayFrameBuilder.class, builder);
+ }
+
+ @Test
+ void addLayersWithSingleElementListShouldWork() {
+ LLPLayerBuilder layer = mock(LLPLayerBuilder.class);
+
+ assertDoesNotThrow(() ->
+ LLP.frameBuilder()
+ .addLayers(List.of(layer))
+ .build()
+ );
+ }
+
+ // =========================================================================
+ // FrameBuilderConfigurator — method chaining
+ // =========================================================================
+
+ @Test
+ void addLayerShouldReturnSameConfiguratorInstance() {
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder();
+ assertSame(configurator, configurator.addLayer(mock(LLPLayerBuilder.class)));
+ }
+
+ @Test
+ void addLayersShouldReturnSameConfiguratorInstance() {
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder();
+ assertSame(configurator, configurator.addLayers(List.of(mock(LLPLayerBuilder.class))));
+ }
+
+ @Test
+ void addLayersWithEmptyListShouldReturnSameConfiguratorInstance() {
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder();
+ assertSame(configurator, configurator.addLayers(Collections.emptyList()));
+ }
+
+ // =========================================================================
+ // FrameBuilderConfigurator — configurator isolation
+ // =========================================================================
+
+ @Test
+ void layersAddedAfterBuildShouldNotAffectAlreadyBuiltInstance() {
+ // Verifies that ByteArrayFrameBuilder performs a defensive copy of the layer list,
+ // so mutations to the configurator after build() do not leak into prior builds.
+ LLPLayerBuilder layer1 = mock(LLPLayerBuilder.class);
+ LLPLayerBuilder layer2 = mock(LLPLayerBuilder.class);
+
+ LLP.FrameBuilderConfigurator configurator = LLP.frameBuilder().addLayer(layer1);
+
+ LLPFrameBuilder builtFirst = configurator.build();
+
+ // Adding a layer after building should not affect the already-built instance
+ configurator.addLayer(layer2);
+
+ LLPFrameBuilder builtSecond = configurator.build();
+
+ // Both are valid and independent
+ assertNotSame(builtFirst, builtSecond);
+ }
+
+ @Test
+ void twoConfiguratorsWithSameLayersShouldProduceIndependentBuilders() {
+ LLPLayerBuilder layer = mock(LLPLayerBuilder.class);
+
+ LLPFrameBuilder first = LLP.frameBuilder().addLayer(layer).build();
+ LLPFrameBuilder second = LLP.frameBuilder().addLayer(layer).build();
+
+ assertNotSame(first, second);
+ }
+
+ // =========================================================================
+ // FrameParserBuilder
+ // =========================================================================
+
+ @Test
+ void shouldCreateFrameParserWithDefaultProvider() {
+ LLPFrameParser parser = LLP.frameParser().build();
+ assertNotNull(parser);
+ assertInstanceOf(SimpleFrameParser.class, parser);
+ }
+
+ @Test
+ void shouldCreateFrameParserWithCustomProvider() {
+ LayerParserProvider customProvider = id -> java.util.Optional.empty();
+
+ LLPFrameParser parser = LLP.frameParser()
+ .parserProvider(customProvider)
+ .build();
+
+ assertNotNull(parser);
+ assertInstanceOf(SimpleFrameParser.class, parser);
+ }
+
+ @Test
+ void frameParserBuilderShouldThrowOnNullProvider() {
+ NullPointerException ex = assertThrows(
+ NullPointerException.class,
+ () -> LLP.frameParser().parserProvider(null)
+ );
+ assertEquals("Provider cannot be null", ex.getMessage());
+ }
+
+ @Test
+ void frameParserBuilderParserProviderShouldReturnSameBuilderInstance() {
+ LLP.FrameParserBuilder builder = LLP.frameParser();
+ LayerParserProvider provider = id -> java.util.Optional.empty();
+ assertSame(builder, builder.parserProvider(provider));
+ }
+
+ @Test
+ void buildShouldReturnDifferentFrameParserInstancesOnEachCall() {
+ LLP.FrameParserBuilder builder = LLP.frameParser();
+
+ LLPFrameParser first = builder.build();
+ LLPFrameParser second = builder.build();
+
+ assertNotSame(first, second);
+ }
+
+ @Test
+ void settingCustomProviderShouldOverrideDefault() {
+ // The custom provider is different from the SPI default — the built parsers
+ // should be independent instances regardless of provider source.
+ LLPFrameParser withDefault = LLP.frameParser().build();
+ LLPFrameParser withCustom = LLP.frameParser()
+ .parserProvider(id -> java.util.Optional.empty())
+ .build();
+
+ assertNotSame(withDefault, withCustom);
+ }
+
+ // =========================================================================
+ // IncrementalParserBuilder
+ // =========================================================================
+
+ @Test
+ void shouldCreateIncrementalParserWithDefaultValues() {
+ LLPIncrementalParser parser = LLP.incrementalParser().build();
+ assertNotNull(parser);
+ assertInstanceOf(LLPIncrementalParser.class, parser);
+ }
+
+ @Test
+ void shouldCreateIncrementalParserWithCustomConfiguration() {
+ LLPIncrementalParser parser = LLP.incrementalParser()
+ .parserProvider(id -> java.util.Optional.empty())
+ .maxPayloadBytes(8192)
+ .timeoutMs(5000L)
+ .build();
+
+ assertNotNull(parser);
+ assertInstanceOf(LLPIncrementalParser.class, parser);
+ }
+
+ @Test
+ void incrementalParserBuilderShouldThrowOnNullProvider() {
+ NullPointerException ex = assertThrows(
+ NullPointerException.class,
+ () -> LLP.incrementalParser().parserProvider(null)
+ );
+ assertEquals("Provider cannot be null", ex.getMessage());
+ }
+
+ @Test
+ void incrementalParserBuilderParserProviderShouldReturnSameInstance() {
+ LLP.IncrementalParserBuilder builder = LLP.incrementalParser();
+ assertSame(builder, builder.parserProvider(id -> java.util.Optional.empty()));
+ }
+
+ @Test
+ void incrementalParserBuilderMaxPayloadBytesShouldReturnSameInstance() {
+ LLP.IncrementalParserBuilder builder = LLP.incrementalParser();
+ assertSame(builder, builder.maxPayloadBytes(4096));
+ }
+
+ @Test
+ void incrementalParserBuilderTimeoutMsShouldReturnSameInstance() {
+ LLP.IncrementalParserBuilder builder = LLP.incrementalParser();
+ assertSame(builder, builder.timeoutMs(1000L));
+ }
+
+ @Test
+ void incrementalParserBuilderShouldSupportFullMethodChain() {
+ // Verifies that all setters can be chained in a single expression
+ assertDoesNotThrow(() ->
+ LLP.incrementalParser()
+ .parserProvider(id -> java.util.Optional.empty())
+ .maxPayloadBytes(2048)
+ .timeoutMs(500L)
+ .build()
+ );
+ }
+
+ @Test
+ void buildShouldReturnDifferentIncrementalParserInstancesOnEachCall() {
+ LLP.IncrementalParserBuilder builder = LLP.incrementalParser();
+
+ LLPIncrementalParser first = builder.build();
+ LLPIncrementalParser second = builder.build();
+
+ assertNotSame(first, second);
+ }
+
+ @Test
+ void incrementalParserBuilderShouldAcceptNegativeMaxPayloadBytes() {
+ // Negative values are passed through to LLPTransportDeframer,
+ // which falls back to its internal default. The builder itself
+ // should not throw — validation is the deframer's responsibility.
+ assertDoesNotThrow(() ->
+ LLP.incrementalParser()
+ .maxPayloadBytes(-1)
+ .build()
+ );
+ }
+
+ @Test
+ void incrementalParserBuilderShouldAcceptNegativeTimeoutMs() {
+ // Same delegation contract as maxPayloadBytes above.
+ assertDoesNotThrow(() ->
+ LLP.incrementalParser()
+ .timeoutMs(-1L)
+ .build()
+ );
+ }
+
+ @Test
+ void incrementalParserBuilderShouldAcceptZeroMaxPayloadBytes() {
+ assertDoesNotThrow(() ->
+ LLP.incrementalParser()
+ .maxPayloadBytes(0)
+ .build()
+ );
+ }
+
+ @Test
+ void incrementalParserBuilderShouldAcceptZeroTimeoutMs() {
+ assertDoesNotThrow(() ->
+ LLP.incrementalParser()
+ .timeoutMs(0L)
+ .build()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
new file mode 100644
index 0000000..e66b4e9
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
@@ -0,0 +1,283 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LLPTransportDeframerTest {
+
+ private LLPTransportDeframer deframer;
+
+ @BeforeEach
+ void setUp() {
+ deframer = new LLPTransportDeframer(1024);
+ }
+
+ private byte[] buildFrame(byte[] payload) {
+ byte[] buffer = new byte[LLPTransportFramer.estimateMaxSize(payload.length)];
+ int len = LLPTransportFramer.build(payload, buffer, 0);
+ return Arrays.copyOf(buffer, len);
+ }
+
+ @Test
+ void testSingleFrame() {
+ byte[] payload = new byte[]{0x01, 0x02, 0x03};
+ byte[] frame = buildFrame(payload);
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testMultipleFramesBackToBack() {
+ byte[] f1 = buildFrame(new byte[]{0x01});
+ byte[] f2 = buildFrame(new byte[]{0x02});
+
+ byte[] combined = new byte[f1.length + f2.length];
+ System.arraycopy(f1, 0, combined, 0, f1.length);
+ System.arraycopy(f2, 0, combined, f1.length, f2.length);
+
+ int count = 0;
+
+ for (byte b : combined) {
+ if (deframer.processByte(b) != null) {
+ count++;
+ }
+ }
+
+ assertEquals(2, count);
+ }
+
+ @Test
+ void testFragmentedFrame() {
+ byte[] frame = buildFrame(new byte[]{42});
+
+ LLPRawFrame result = null;
+
+ for (int i = 0; i < frame.length / 2; i++) {
+ deframer.processByte(frame[i]);
+ }
+
+ for (int i = frame.length / 2; i < frame.length; i++) {
+ LLPRawFrame f = deframer.processByte(frame[i]);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void testNoiseBeforeFrame() {
+ byte[] noise = new byte[]{0x00, 0x13, 0x7F, 0x55};
+ byte[] frame = buildFrame(new byte[]{7});
+
+ for (byte b : noise) {
+ deframer.processByte(b);
+ }
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void testInvalidCRC() {
+ byte[] frame = buildFrame(new byte[]{1});
+
+ // Corrupt CRC
+ frame[frame.length - 1] ^= 0xFF;
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNull(result);
+ assertTrue(deframer.getStatistics().getFramesError() > 0);
+ }
+
+ @Test
+ void testTimeoutResetsParser() throws InterruptedException {
+ byte[] frame = buildFrame(new byte[]{10});
+
+ for (int i = 0; i < frame.length / 2; i++) {
+ deframer.processByte(frame[i]);
+ }
+
+ Thread.sleep(2100);
+
+ LLPRawFrame result = null;
+
+ for (int i = frame.length / 2; i < frame.length; i++) {
+ LLPRawFrame f = deframer.processByte(frame[i]);
+ if (f != null) result = f;
+ }
+
+ assertNull(result);
+ assertTrue(deframer.getStatistics().getTimeouts() > 0);
+ }
+
+ @Test
+ void testPayloadExceedsMaximum() {
+ byte[] payload = new byte[1025]; // max is 1024
+ byte[] frame = buildFrame(payload);
+
+ AtomicInteger payloadErrors = new AtomicInteger();
+
+ LLPTransportDeframer.LLPFrameListener listener = new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame frame) {
+ // ignored
+ }
+
+ @Override
+ public void onFrameError(TransportErrorCode errorCode) {
+ if (errorCode == TransportErrorCode.PAYLOAD_LEN_INVALID) {
+ payloadErrors.incrementAndGet();
+ }
+ }
+ };
+
+ deframer.addListener(listener);
+
+ try {
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNull(result);
+ assertEquals(1, payloadErrors.get());
+ assertEquals(1, deframer.getStatistics().getFramesError());
+
+ } finally {
+ deframer.removeListener(listener);
+ }
+ }
+
+ @Test
+ void testRecoveryAfterPayloadOverflow() {
+ byte[] invalid = buildFrame(new byte[1025]);
+ byte[] valid = buildFrame(new byte[]{1, 2, 3});
+
+ LLPRawFrame result = null;
+
+ for (byte b : invalid) {
+ deframer.processByte(b);
+ }
+
+ for (byte b : valid) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void testStuffedPayload() {
+ byte[] payload = new byte[]{
+ 0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
+ };
+
+ byte[] frame = buildFrame(payload);
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testInvalidEscapeSequence() {
+ byte[] frame = buildFrame(new byte[]{1});
+
+ frame[5] = (byte) 0xAA;
+ frame[6] = (byte) 0x99;
+
+ for (byte b : frame) {
+ deframer.processByte(b);
+ }
+
+ assertTrue(deframer.getStatistics().getFramesError() > 0);
+ }
+
+ @Test
+ void testProcessBytesBatch() {
+ byte[] f1 = buildFrame(new byte[]{1});
+ byte[] f2 = buildFrame(new byte[]{2});
+
+ byte[] combined = new byte[f1.length + f2.length];
+ System.arraycopy(f1, 0, combined, 0, f1.length);
+ System.arraycopy(f2, 0, combined, f1.length, f2.length);
+
+ List frames = deframer.processBytes(combined);
+
+ assertEquals(2, frames.size());
+ }
+
+ @Test
+ void testRandomFrames() {
+ Random random = new Random();
+
+ for (int i = 0; i < 500; i++) {
+ byte[] payload = new byte[32];
+ random.nextBytes(payload);
+
+ byte[] frame = buildFrame(payload);
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
new file mode 100644
index 0000000..24c809d
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
@@ -0,0 +1,529 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.util.ByteWriter;
+import com.flamingo.comm.llp.util.CRC16CCITT;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LLPTransportFramerTest {
+
+ private byte[] buildFrame(byte[] payload) {
+ byte[] buffer = new byte[LLPTransportFramer.estimateMaxSize(payload != null ? payload.length : 0)];
+ int len = LLPTransportFramer.build(payload, buffer, 0);
+ return java.util.Arrays.copyOf(buffer, len);
+ }
+
+ // ================= BASIC =================
+
+ @Test
+ void testHeaderIsCorrect() {
+ byte[] frame = buildFrame(new byte[]{1, 2, 3});
+
+ assertEquals((byte) 0xAA, frame[0]);
+ assertEquals((byte) 0x55, frame[1]);
+ }
+
+ @Test
+ void testNullPayload() {
+ byte[] frame = buildFrame(null);
+
+ // Length = 0
+ assertEquals(0, frame[2]);
+ assertEquals(0, frame[3]);
+
+ // CRC only frame
+ assertTrue(frame.length >= 6);
+ }
+
+ @Test
+ void testVariousPayloadSizes() {
+ for (int size : new int[]{0, 1, 10, 100, 512}) {
+ byte[] payload = new byte[size];
+ byte[] frame = buildFrame(payload);
+
+ assertNotNull(frame);
+ assertTrue(frame.length >= 4 + size);
+ }
+ }
+
+ @Test
+ void testEstimateMaxSize() {
+ int maxSizeWithoutPayload = LLPTransportFramer.estimateMaxSize(0);
+ int maxSize = LLPTransportFramer.estimateMaxSize(3);
+
+ assertEquals(10, maxSizeWithoutPayload);
+ assertEquals(16, maxSize);
+ }
+
+ @Test
+ void testEstimateMaxSizeWithNegativePayload() {
+ assertThrows(IllegalArgumentException.class, () -> LLPTransportFramer.estimateMaxSize(-1));
+ }
+
+ // ================= CRC =================
+
+ @Test
+ void testCRCIsValidAfterDestuff() {
+ byte[] payload = {0x10, 0x20, 0x30};
+ byte[] frame = buildFrame(payload);
+
+ byte[] unstuffed = destuff(frame);
+
+ int crcExpected = CRC16CCITT.calculate(unstuffed, 0, unstuffed.length - 2);
+
+ int crcFrame =
+ (unstuffed[unstuffed.length - 2] & 0xFF) |
+ ((unstuffed[unstuffed.length - 1] & 0xFF) << 8);
+
+ assertEquals(crcExpected, crcFrame);
+ }
+
+ // ================= STUFFING =================
+
+ @Test
+ void testStuffingSingleAA() {
+ byte[] payload = {(byte) 0xAA};
+
+ byte[] frame = buildFrame(payload);
+
+ boolean found = false;
+ for (int i = 2; i < frame.length - 1; i++) {
+ if (frame[i] == (byte) 0xAA && frame[i + 1] == 0x00) {
+ found = true;
+ break;
+ }
+ }
+
+ assertTrue(found);
+ }
+
+ @Test
+ void testStuffingMultipleAA() {
+ byte[] payload = {(byte) 0xAA, (byte) 0xAA, (byte) 0xAA};
+
+ byte[] frame = buildFrame(payload);
+
+ int stuffedCount = 0;
+
+ for (int i = 2; i < frame.length - 1; i++) {
+ if (frame[i] == (byte) 0xAA && frame[i + 1] == 0x00) {
+ stuffedCount++;
+ }
+ }
+
+ assertTrue(stuffedCount >= 3, "Should have at least 3 stuffed bytes");
+ }
+
+ @Test
+ void testNoFakeHeaderInsideFrame() {
+ byte[] payload = new byte[100];
+ new Random().nextBytes(payload);
+
+ byte[] frame = buildFrame(payload);
+
+ for (int i = 2; i < frame.length - 1; i++) {
+ assertFalse(
+ frame[i] == (byte) 0xAA && frame[i + 1] == (byte) 0x55,
+ "Forbidden AA55 sequence found"
+ );
+ }
+ }
+
+ // ================= INTEGRATION =================
+
+ @Test
+ void testFrameCanBeParsedByDeframer() {
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+
+ byte[] payload = new byte[50];
+ new Random().nextBytes(payload);
+
+ byte[] frame = buildFrame(payload);
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testRandomPayloads() {
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ Random random = new Random();
+
+ for (int i = 0; i < 1000; i++) {
+ byte[] payload = new byte[32];
+ random.nextBytes(payload);
+
+ byte[] frame = buildFrame(payload);
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+ }
+
+ // ================= SAFE API =================
+
+ @Test
+ void testBuildSafe() {
+ byte[] payload = new byte[]{1, 2, 3};
+
+ byte[] frame = LLPTransportFramer.buildSafe(payload);
+ byte[] frameWithoutPayload = LLPTransportFramer.buildSafe(new byte[0]);
+ byte[] frameWithPayloadNull = LLPTransportFramer.buildSafe(null);
+
+ assertNotNull(frame);
+ assertNotNull(frameWithoutPayload);
+ assertNotNull(frameWithPayloadNull);
+
+ assertTrue(frame.length > payload.length);
+ assertEquals(frameWithoutPayload.length, frameWithPayloadNull.length);
+
+ // Payload null and payload empty must be generated the same frame
+ for (int i = 0; i < frameWithoutPayload.length; i++) {
+ assertEquals(frameWithoutPayload[i], frameWithPayloadNull[i]);
+ }
+ }
+
+ // ================= UTILS =================
+
+ private byte[] destuff(byte[] frame) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(frame.length);
+
+ // Copy header
+ out.write(frame, 0, 2);
+
+ for (int i = 2; i < frame.length; i++) {
+ byte b = frame[i];
+
+ if (b == (byte) 0xAA && i + 1 < frame.length && frame[i + 1] == 0x00) {
+ out.write(0xAA);
+ i++;
+ } else {
+ out.write(b);
+ }
+ }
+
+ return out.toByteArray();
+ }
+
+ // ================= EDGE CASES & MEMORY =================
+
+ @Test
+ void testBufferOffsetIsRespected() {
+ byte[] payload = {1, 2, 3};
+ // Create a buffer larger than needed, filled with dummy data
+ byte[] outBuffer = new byte[50];
+ java.util.Arrays.fill(outBuffer, (byte) 0xFF);
+
+ int offset = 10;
+ int written = LLPTransportFramer.build(payload, outBuffer, offset);
+
+ // 1. Verify bytes before offset are untouched
+ for (int i = 0; i < offset; i++) {
+ assertEquals((byte) 0xFF, outBuffer[i], "Bytes before offset should not be modified");
+ }
+
+ // 2. Verify the frame started exactly at the offset
+ assertEquals((byte) 0xAA, outBuffer[offset], "Magic byte 1 should be at offset");
+ assertEquals((byte) 0x55, outBuffer[offset + 1], "Magic byte 2 should be after magic 1");
+
+ // 3. Verify bytes after the written frame are untouched
+ for (int i = offset + written; i < outBuffer.length; i++) {
+ assertEquals((byte) 0xFF, outBuffer[i], "Bytes after frame should not be modified");
+ }
+ }
+
+ @Test
+ void testBufferTooSmallThrowsException() {
+ byte[] payload = new byte[10];
+
+ // Intentionally create a buffer that is too small for the worst-case scenario
+ // Worst case:
+ // 2 (magic, not stuffed)
+ // + up to 4 (length, fully stuffed)
+ // + payloadLen * 2 (payload worst case)
+ // + 4 (CRC fully stuffed)
+ byte[] smallBuffer = new byte[29];
+
+ assertThrows(IllegalArgumentException.class, () -> {
+ LLPTransportFramer.build(payload, smallBuffer, 0);
+ }, "Should throw exception if buffer cannot hold the worst-case stuffed frame");
+ }
+
+ @Test
+ void testAbsoluteWorstCaseStuffingSize() {
+ // 170 bytes of 0xAA.
+ // Length will be 170 (0xAA). Length High will be 0x00.
+ // This forces maximum stuffing on both Length Low and Payload.
+ byte[] payload = new byte[170];
+ java.util.Arrays.fill(payload, (byte) 0xAA);
+
+ byte[] frame = buildFrame(payload);
+
+ // Verify frame was built successfully
+ assertNotNull(frame);
+
+ // Verify that every 0xAA is stuffed
+ for (int i = 2; i < frame.length - 1; i++) {
+ if (frame[i] == (byte) 0xAA) {
+ assertEquals(0x00, frame[i + 1], "Every 0xAA must be stuffed");
+ }
+ }
+ }
+
+ // ================= ENDIANNESS =================
+
+ @Test
+ void testLengthIsLittleEndian() {
+ // Create a payload of exactly 258 bytes (0x0102 in hex)
+ // Length Low should be 0x02, Length High should be 0x01
+ byte[] payload = new byte[258];
+
+ byte[] frame = buildFrame(payload);
+
+ // MAGIC_1(0), MAGIC_2(1), LEN_L(2), LEN_H(3)
+ assertEquals((byte) 0x02, frame[2], "Length Low byte should be 0x02");
+ assertEquals((byte) 0x01, frame[3], "Length High byte should be 0x01");
+ }
+
+ // ================= BYTE WRITER =================
+
+ @Test
+ void testBuildWithByteWriterSimple() {
+ byte[] payload = {0x01, 0x02, 0x03};
+
+ TestByteWriter writer = new TestByteWriter();
+
+ int written = LLPTransportFramer.build(payload, writer);
+ byte[] frame = writer.toByteArray();
+
+ assertEquals(written, frame.length);
+ assertEquals((byte) 0xAA, frame[0]);
+ assertEquals((byte) 0x55, frame[1]);
+ }
+
+ @Test
+ void testBuildWriterEqualsBufferBuild() {
+ byte[] payload = {0x11, 0x22, 0x33};
+
+ // Writer version
+ TestByteWriter writer = new TestByteWriter();
+ LLPTransportFramer.build(payload, writer);
+ byte[] frame1 = writer.toByteArray();
+
+ // Buffer version
+ byte[] buffer = new byte[LLPTransportFramer.estimateMaxSize(payload.length)];
+ int written = LLPTransportFramer.build(payload, buffer, 0);
+
+ byte[] frame2 = Arrays.copyOf(buffer, written);
+
+ assertArrayEquals(frame2, frame1);
+ }
+
+ @Test
+ void testBuildWriterStuffing() {
+ byte[] payload = {(byte) 0xAA};
+
+ TestByteWriter writer = new TestByteWriter();
+ LLPTransportFramer.build(payload, writer);
+
+ byte[] frame = writer.toByteArray();
+
+ boolean found = false;
+ for (int i = 2; i < frame.length - 1; i++) {
+ if (frame[i] == (byte) 0xAA && frame[i + 1] == 0x00) {
+ found = true;
+ break;
+ }
+ }
+
+ assertTrue(found);
+ }
+
+ @Test
+ void testBuildWriterNullPayload() {
+ TestByteWriter writer = new TestByteWriter();
+
+ int written = LLPTransportFramer.build(null, writer);
+ byte[] frame = writer.toByteArray();
+
+ assertEquals(written, frame.length);
+
+ // Solo header + len + crc
+ assertTrue(frame.length >= 6);
+ }
+
+ @Test
+ void testWriteOrderStartsWithMagic() {
+ RecordingWriter writer = new RecordingWriter();
+
+ LLPTransportFramer.build(new byte[]{1, 2}, writer);
+
+ assertEquals((byte) 0xAA, writer.getBytes().get(0));
+ assertEquals((byte) 0x55, writer.getBytes().get(1));
+ }
+
+ @Test
+ void testWriterFailurePropagates() {
+ FailingWriter writer = new FailingWriter();
+
+ assertThrows(RuntimeException.class, () ->
+ LLPTransportFramer.build(new byte[]{1, 2, 3}, writer)
+ );
+ }
+
+ @Test
+ void testWriterFrameIsParsable() {
+ byte[] payload = new byte[50];
+ new Random().nextBytes(payload);
+
+ TestByteWriter writer = new TestByteWriter();
+ LLPTransportFramer.build(payload, writer);
+
+ byte[] frame = writer.toByteArray();
+
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+
+ assertArrayEquals(payload, extracted);
+ }
+
+ @Test
+ void testBuildReturnsCorrectWrittenCount() {
+ // Payload that will force multiple stuffings
+ byte[] payload = {(byte) 0xAA, 0x01, (byte) 0xAA, 0x02};
+
+ TestByteWriter writer = new TestByteWriter(); // Assuming this tracks size
+ int bytesWritten = LLPTransportFramer.build(payload, writer);
+
+ byte[] frame = writer.toByteArray();
+
+ assertEquals(frame.length, bytesWritten, "The returned written count should match the actual bytes emitted");
+ }
+
+ @Test
+ void testBuildMaximumPayloadDoesNotCrash() {
+ // Max unsigned short is 65535
+ int maxPayloadSize = 65535;
+ byte[] massivePayload = new byte[maxPayloadSize];
+
+ // Fill with 0xAA to force maximum worst-case stuffing
+ java.util.Arrays.fill(massivePayload, (byte) 0xAA);
+
+ TestByteWriter writer = new TestByteWriter();
+
+ // We just want to ensure it completes successfully without memory errors or index bounds
+ assertDoesNotThrow(() -> {
+ int written = LLPTransportFramer.build(massivePayload, writer);
+
+ // 2 (Magic) + 4 (Len stuffed) + 131070 (Payload stuffed) + 4 (CRC stuffed)
+ assertTrue(written > 131000, "Frame should be written successfully even at massive sizes");
+ }, "Building a massive stuffed frame should not throw exceptions");
+ }
+
+ @Test
+ void testWriterFailsDuringStuffingInjection() {
+ byte[] payload = {(byte) 0xAA, 0x02, 0x03};
+
+ ByteWriter boundaryFailingWriter = new ByteWriter() {
+ private int count = 0;
+
+ @Override
+ public void write(byte b) {
+ count++;
+ // 1. MAGIC_1, 2. MAGIC_2, 3. LEN_L, 4. LEN_H
+ // 5. payload[0] (0xAA).
+ // 6. The stuffing byte (0x00) should trigger the failure!
+ if (count == 6) {
+ throw new IllegalStateException("Socket disconnected during stuffing!");
+ }
+ }
+ };
+
+ IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
+ LLPTransportFramer.build(payload, boundaryFailingWriter);
+ });
+
+ assertEquals("Socket disconnected during stuffing!", exception.getMessage());
+ }
+
+ class TestByteWriter implements ByteWriter {
+ private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ @Override
+ public void write(byte b) {
+ out.write(b);
+ }
+
+ public byte[] toByteArray() {
+ return out.toByteArray();
+ }
+ }
+
+ class RecordingWriter implements ByteWriter {
+ private final List bytes = new ArrayList<>();
+
+ @Override
+ public void write(byte b) {
+ bytes.add(b);
+ }
+
+ public List getBytes() {
+ return bytes;
+ }
+ }
+
+ class FailingWriter implements ByteWriter {
+ private int count = 0;
+
+ @Override
+ public void write(byte b) {
+ if (++count > 5) {
+ throw new RuntimeException("Writer failure");
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/LayerParserRegistryTest.java b/src/test/java/com/flamingo/comm/llp/core/LayerParserRegistryTest.java
new file mode 100644
index 0000000..6355ff7
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LayerParserRegistryTest.java
@@ -0,0 +1,157 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerParser;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class LayerParserRegistryTest {
+
+ @Test
+ void shouldInitializeEmptyWhenNoParsersProvided() {
+ // Arrange
+ Iterable emptyLoad = List.of();
+
+ // Act
+ LayerParserRegistry registry = LayerParserRegistry.createForTest(emptyLoad);
+
+ // Assert
+ assertTrue(registry.get(1).isEmpty());
+ }
+
+ @Test
+ void shouldRegisterAndRetrieveParsersSuccessfully() {
+ // Arrange
+ LLPLayerParser parser1 = mock(LLPLayerParser.class);
+ when(parser1.getLayerId()).thenReturn(10);
+
+ LLPLayerParser parser2 = mock(LLPLayerParser.class);
+ when(parser2.getLayerId()).thenReturn(20);
+
+ Iterable validLoad = List.of(parser1, parser2);
+
+ // Act
+ LayerParserRegistry registry = LayerParserRegistry.createForTest(validLoad);
+
+ // Assert
+ Optional retrieved1 = registry.get(10);
+ assertTrue(retrieved1.isPresent());
+ assertEquals(parser1, retrieved1.get());
+
+ Optional retrieved2 = registry.get(20);
+ assertTrue(retrieved2.isPresent());
+ assertEquals(parser2, retrieved2.get());
+
+ assertTrue(registry.get(99).isEmpty());
+ }
+
+ @Test
+ void shouldThrowExceptionWhenDuplicateLayerIdsAreDetected() {
+ // Arrange
+ LLPLayerParser parser1 = mock(LLPLayerParser.class);
+ when(parser1.getLayerId()).thenReturn(5);
+
+ LLPLayerParser parser2 = mock(LLPLayerParser.class);
+
+ when(parser2.getLayerId()).thenReturn(5);
+
+ Iterable duplicateLoad = List.of(parser1, parser2);
+
+ // Act & Assert
+ IllegalStateException exception = assertThrows(
+ IllegalStateException.class,
+ () -> LayerParserRegistry.createForTest(duplicateLoad)
+ );
+
+ assertTrue(exception.getMessage().contains("5"));
+ }
+
+ @Test
+ void shouldBeIndependentFromInputCollection() {
+ LLPLayerParser parser = mock(LLPLayerParser.class);
+ when(parser.getLayerId()).thenReturn(1);
+
+ List list = new java.util.ArrayList<>();
+ list.add(parser);
+
+ LayerParserRegistry registry = LayerParserRegistry.createForTest(list);
+
+ list.clear();
+
+ assertTrue(registry.get(1).isPresent());
+ }
+
+ @Test
+ void shouldThrowWhenParserIsNull() {
+ List list = new ArrayList<>();
+ list.add(null);
+
+ assertThrows(NullPointerException.class,
+ () -> LayerParserRegistry.createForTest(list));
+ }
+
+ @Test
+ void shouldHandleNegativeLayerIds() {
+ LLPLayerParser parser = mock(LLPLayerParser.class);
+ when(parser.getLayerId()).thenReturn(-1);
+
+ LayerParserRegistry registry =
+ LayerParserRegistry.createForTest(List.of(parser));
+
+ assertTrue(registry.get(-1).isPresent());
+ }
+
+ @Test
+ void shouldReturnSameInstanceForSameId() {
+ LLPLayerParser parser = mock(LLPLayerParser.class);
+ when(parser.getLayerId()).thenReturn(42);
+
+ LayerParserRegistry registry =
+ LayerParserRegistry.createForTest(List.of(parser));
+
+ assertSame(parser, registry.get(42).orElseThrow());
+ }
+
+ @Test
+ void shouldOnlyCallGetLayerIdDuringRegistration() {
+ LLPLayerParser parser = mock(LLPLayerParser.class);
+ when(parser.getLayerId()).thenReturn(1);
+
+ LayerParserRegistry.createForTest(List.of(parser));
+
+ verify(parser, times(1)).getLayerId();
+ verifyNoMoreInteractions(parser);
+ }
+
+ @Test
+ void shouldAllowMultipleIndependentRegistries() {
+ LLPLayerParser p1 = mock(LLPLayerParser.class);
+ when(p1.getLayerId()).thenReturn(1);
+
+ LLPLayerParser p2 = mock(LLPLayerParser.class);
+ when(p2.getLayerId()).thenReturn(2);
+
+ LayerParserRegistry r1 = LayerParserRegistry.createForTest(List.of(p1));
+ LayerParserRegistry r2 = LayerParserRegistry.createForTest(List.of(p2));
+
+ assertTrue(r1.get(1).isPresent());
+ assertTrue(r2.get(2).isPresent());
+
+ assertTrue(r1.get(2).isEmpty());
+ assertTrue(r2.get(1).isEmpty());
+ }
+
+ @Test
+ void shouldReturnSingletonInstance() {
+ LayerParserRegistry instance1 = LayerParserRegistry.getInstance();
+ LayerParserRegistry instance2 = LayerParserRegistry.getInstance();
+
+ assertNotNull(instance1);
+ assertSame(instance1, instance2);
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java b/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
new file mode 100644
index 0000000..80563eb
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
@@ -0,0 +1,389 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+import org.junit.jupiter.api.Test;
+
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class NodeChainTest {
+
+ @Test
+ void testBuildAndSize() {
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new TestNode(2))
+ .build();
+
+ assertEquals(2, chain.size());
+ }
+
+ @Test
+ void testAsListIsImmutable() {
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .build();
+
+ List list = chain.asList();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ list.add(new TestNode(2))
+ );
+ }
+
+ @Test
+ void testOrderIsPreserved() {
+ TestNode n1 = new TestNode(1);
+ TestNode n2 = new TestNode(2);
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(n1)
+ .add(n2)
+ .build();
+
+ List list = chain.asList();
+
+ assertSame(n1, list.get(0));
+ assertSame(n2, list.get(1));
+ }
+
+ @Test
+ void testGetNodeById() {
+ TestNode n1 = new TestNode(1);
+ TestNode n2 = new TestNode(2);
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(n1)
+ .add(n2)
+ .build();
+
+ Optional result = chain.getNode(2);
+
+ assertTrue(result.isPresent());
+ assertSame(n2, result.get());
+ }
+
+ @Test
+ void testGetNodeByIdNotFound() {
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .build();
+
+ assertTrue(chain.getNode(999).isEmpty());
+ }
+
+ @Test
+ void testGetNodeByType() {
+ TestNode normal = new TestNode(1);
+ SpecialNode special = new SpecialNode(2);
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(normal)
+ .add(special)
+ .build();
+
+ Optional result = chain.getNode(SpecialNode.class);
+
+ assertTrue(result.isPresent());
+ assertSame(special, result.get());
+ }
+
+ @Test
+ void testGetNodeByTypeNotFound() {
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .build();
+
+ assertTrue(chain.getNode(SpecialNode.class).isEmpty());
+ }
+
+ @Test
+ void testGetDeepestNode() {
+ TestNode n1 = new TestNode(1);
+ TestNode n2 = new TestNode(2);
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(n1)
+ .add(n2)
+ .build();
+
+ assertSame(n2, chain.getDeepestNode());
+ }
+
+ @Test
+ void testGetDeepestNodeEmptyThrows() {
+ NodeChain chain = new NodeChain.Builder().build();
+
+ assertThrows(NoSuchElementException.class, chain::getDeepestNode);
+ }
+
+ @Test
+ void testIterator() {
+ TestNode n1 = new TestNode(1);
+ TestNode n2 = new TestNode(2);
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(n1)
+ .add(n2)
+ .build();
+
+ Iterator it = chain.iterator();
+
+ assertTrue(it.hasNext());
+ assertSame(n1, it.next());
+ assertSame(n2, it.next());
+ assertFalse(it.hasNext());
+ }
+
+ @Test
+ void testVisitCallsAllNodes() {
+ List visited = new ArrayList<>();
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new TestNode(2))
+ .build();
+
+ chain.visit(visitor ->
+ visitor.on(TestNode.class, visited::add)
+ );
+
+ assertEquals(2, visited.size());
+ }
+
+ @Test
+ void testVisitOnlyMatchingType() {
+ List visited = new ArrayList<>();
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new SpecialNode(2))
+ .build();
+
+ chain.visit(visitor ->
+ visitor.on(SpecialNode.class, visited::add)
+ );
+
+ assertEquals(1, visited.size());
+ assertInstanceOf(SpecialNode.class, visited.getFirst());
+ }
+
+ @Test
+ void testVisitDoesNotMatchSuperclass() {
+ List visited = new ArrayList<>();
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(new SpecialNode(1))
+ .build();
+
+ chain.visit(visitor ->
+ visitor.on(TestNode.class, visited::add)
+ );
+
+ assertTrue(visited.isEmpty());
+ }
+
+ @Test
+ void testVisitMultipleHandlers() {
+ List visited = new ArrayList<>();
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new SpecialNode(2))
+ .build();
+
+ chain.visit(visitor -> visitor
+ .on(TestNode.class, visited::add)
+ .on(SpecialNode.class, visited::add)
+ );
+
+ assertEquals(2, visited.size());
+ }
+
+ @Test
+ void testVisitOrderIsPreserved() {
+ List order = new ArrayList<>();
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new TestNode(2))
+ .build();
+
+ chain.visit(visitor ->
+ visitor.on(TestNode.class, node -> order.add(node.getId()))
+ );
+
+ assertEquals(List.of(1, 2), order);
+ }
+
+ @Test
+ void testVisitPipelineStyle() {
+ StringBuilder result = new StringBuilder();
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new SpecialNode(2))
+ .build();
+
+ chain.visit(visitor -> visitor
+ .on(TestNode.class, n -> result.append("T"))
+ .on(SpecialNode.class, n -> result.append("S"))
+ );
+
+ assertEquals("TS", result.toString());
+ }
+
+ @Test
+ void testBuilderFluentApi() {
+ NodeChain.Builder builder = new NodeChain.Builder();
+
+ NodeChain chain = builder
+ .add(new TestNode(1))
+ .add(new TestNode(2))
+ .build();
+
+ assertEquals(2, chain.size());
+ }
+
+ @Test
+ void testBuilderDoesNotAffectBuiltChain() {
+ NodeChain.Builder builder = new NodeChain.Builder();
+
+ builder.add(new TestNode(1));
+ NodeChain chain = builder.build();
+
+ builder.add(new TestNode(2));
+
+ assertEquals(1, chain.size());
+ }
+
+ @Test
+ void testBuilderAddNullThrows() {
+ NodeChain.Builder builder = new NodeChain.Builder();
+
+ assertThrows(NullPointerException.class, () ->
+ builder.add(null)
+ );
+ }
+
+ @Test
+ void testEmptyBuilderReturnsSingleton() {
+ NodeChain chain1 = new NodeChain.Builder().build();
+ NodeChain chain2 = new NodeChain.Builder().build();
+
+ assertSame(chain1, chain2);
+ }
+
+ @Test
+ void testEqualsSameContent() {
+ // NOTE: They will only be equal if they contain the same LLPNode instances in the same order,
+ // unless the “equals” method is overridden in the specific LLPNode implement
+
+ LLPNode node1 = new TestNode(1);
+ LLPNode node2 = new TestNode(2);
+
+ NodeChain c1 = new NodeChain.Builder()
+ .add(node1)
+ .add(node2)
+ .build();
+
+ NodeChain c2 = new NodeChain.Builder()
+ .add(node1)
+ .add(node2)
+ .build();
+
+ NodeChain c3 = new NodeChain.Builder()
+ .add(new TestNode(3))
+ .add(new TestNode(4))
+ .build();
+
+ NodeChain c4 = new NodeChain.Builder()
+ .add(new TestNode(3))
+ .add(new TestNode(4))
+ .build();
+
+ NodeChain c5 = new NodeChain.Builder()
+ .add(new SpecialNode(5))
+ .add(new SpecialNode(6))
+ .build();
+
+ NodeChain c6 = new NodeChain.Builder()
+ .add(new SpecialNode(5))
+ .add(new SpecialNode(6))
+ .build();
+
+ // NodeChain with the same LLPNode instances
+ assertNotSame(c1, c2);
+ assertEquals(c1, c2);
+ assertEquals(c1.hashCode(), c2.hashCode());
+
+ // NodeChain with different LLPNode instances, but with the same internal value
+ assertNotSame(c3, c4);
+ assertNotEquals(c3, c4);
+ assertNotEquals(c3.hashCode(), c4.hashCode());
+
+ // NodeChain with different LLPNode instances that implement equals
+ assertNotSame(c5, c6);
+ assertEquals(c5, c6);
+ }
+
+ @Test
+ void testEqualsDifferentOrder() {
+ NodeChain c1 = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .add(new TestNode(2))
+ .build();
+
+ NodeChain c2 = new NodeChain.Builder()
+ .add(new TestNode(2))
+ .add(new TestNode(1))
+ .build();
+
+ assertNotEquals(c1, c2);
+ }
+
+ @Test
+ void testEqualsDifferentContent() {
+ NodeChain c1 = new NodeChain.Builder()
+ .add(new TestNode(1))
+ .build();
+
+ NodeChain c2 = new NodeChain.Builder()
+ .add(new TestNode(2))
+ .build();
+
+ assertNotEquals(c1, c2);
+ }
+
+ @Test
+ void testEmptyChainBehavior() {
+ NodeChain empty = NodeChain.EMPTY;
+
+ assertEquals(0, empty.size());
+ assertTrue(empty.asList().isEmpty());
+ assertTrue(empty.getNode(1).isEmpty());
+ assertTrue(empty.getNode(TestNode.class).isEmpty());
+ }
+
+ @Test
+ void testEmptyIdentity() {
+ NodeChain chain = new NodeChain.Builder().build();
+
+ assertSame(NodeChain.EMPTY, chain);
+ }
+
+ @Test
+ void testBuilderListIsolation() {
+ List original = new ArrayList<>();
+ original.add(new TestNode(1));
+
+ NodeChain chain = new NodeChain.Builder()
+ .add(original.getFirst())
+ .build();
+
+ original.clear();
+
+ assertEquals(1, chain.size(), "Chain should not be affected by external list changes");
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java b/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
new file mode 100644
index 0000000..4d6735f
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
@@ -0,0 +1,773 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPLayerParser;
+import com.flamingo.comm.llp.spi.LLPNode;
+import com.flamingo.comm.llp.spi.LayerParseInput;
+import com.flamingo.comm.llp.spi.LayerParseResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SimpleFrameParserTest {
+
+ @Mock
+ private LayerParserProvider provider;
+
+ @Mock
+ private LLPRawFrame rawFrame;
+
+ @Mock
+ private LLPLayerParser mockLayerParser;
+
+ @Mock
+ private LLPNode mockNode;
+
+ private SimpleFrameParser parser;
+
+ @BeforeEach
+ void setUp() {
+ parser = new SimpleFrameParser(provider);
+ }
+
+ @Test
+ void shouldThrowExceptionWhenRawFrameIsNull() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> parser.parse(null)
+ );
+ assertEquals("rawFrame cannot be null", exception.getMessage());
+ }
+
+ @Test
+ void shouldParseFrameWithOnlyFinalLayer() {
+ // Frame: [0x00 (Final ID), 0xAA, 0xBB (Payload)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x00, (byte) 0xAA, (byte) 0xBB});
+ setupRawFrame(payload, 1234, 1000L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertNotNull(frame);
+ assertEquals(1234, frame.crc());
+ assertEquals(1000L, frame.timestamp());
+
+ // Node chain should have exactly 1 node (FinalNode)
+ assertEquals(1, frame.chain().size());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().getFirst());
+ }
+
+ @Test
+ void shouldSkipUnknownNonCriticalLayer() {
+ // Frame: [0x01 (ID 1 < 128), 0x02 (Meta Len), 0xFF, 0xFF (Meta), 0x00 (Final), 0xDD]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x01, 0x02, (byte) 0xFF, (byte) 0xFF, 0x00, (byte) 0xDD});
+ setupRawFrame(payload, 0, 0L);
+
+ // Provider returns empty for ID 1
+ when(provider.get(1)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ assertInstanceOf(UnknownNode.class, frame.chain().asList().getFirst());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldAbortOnUnknownCriticalLayer() {
+ // Frame: [0x85 (ID 133 >= 128), 0x00 (Meta Len), 0x00 (Final ID)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{(byte) 0x85, 0x00, 0x00});
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(133)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode failureNode = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(133, failureNode.getId());
+ assertEquals(CoreParseErrorReason.UNKNOWN_CRITICAL_LAYER, failureNode.getErrorReason());
+ }
+
+ @Test
+ void shouldFailWhenMetadataLengthIsMalformed() {
+ // Frame: [0x05 (ID 5), 0x04 (Meta Len), 0xAA, 0xBB (Only 2 bytes of meta, malformed!)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x05, 0x04, (byte) 0xAA, (byte) 0xBB});
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode failureNode = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(5, failureNode.getId());
+ assertEquals(CoreParseErrorReason.METADATA_TRUNCATED, failureNode.getErrorReason());
+ }
+
+ @Test
+ void shouldParseExtendedMetadataLength() {
+ // Frame: [0x02 (ID), 0xFF (Ext Flag), 0x00, 0x03 (Len = 3), 0xAA, 0xBB, 0xCC, 0x00 (Final)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x02, (byte) 0xFF, 0x00, 0x03, (byte) 0xAA, (byte) 0xBB, (byte) 0xCC, 0x00
+ });
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(2)).thenReturn(Optional.empty()); // Just skip it to verify length logic
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ UnknownNode unknownNode = (UnknownNode) frame.chain().asList().getFirst();
+ assertEquals(2, unknownNode.getId());
+ assertEquals(3, unknownNode.getMetadata().remaining()); // Successfully parsed 3 bytes of metadata
+ }
+
+ @Test
+ void shouldProcessSuccessfulPluginParse() {
+ // Frame: [0x10 (ID 16), 0x01 (Meta Len), 0xAA (Meta), 0x00 (Final ID)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x10, 0x01, (byte) 0xAA, 0x00});
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+
+ // Plugin returns success and passes the remaining buffer (which is just 0x00)
+ LayerParseResult.Success successResult = new LayerParseResult.Success(
+ mockNode,
+ ByteBuffer.wrap(new byte[]{0x00})
+ );
+ when(mockLayerParser.parse(any(LayerParseInput.class))).thenReturn(successResult);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ assertEquals(mockNode, frame.chain().asList().getFirst());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldHandlePluginExceptionAndProtectCore() {
+ // Frame: [0x05 (ID 5 < 128), 0x00 (Meta Len), 0x00 (Final)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x05, 0x00, 0x00});
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(5)).thenReturn(Optional.of(mockLayerParser));
+ when(mockLayerParser.parse(any(LayerParseInput.class))).thenThrow(new RuntimeException("Simulated plugin crash"));
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+
+ // First node should be a Failure Node due to the crash
+ FailureNode failureNode = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(CoreParseErrorReason.PLUGIN_EXCEPTION, failureNode.getErrorReason());
+
+ // Since ID 5 is skippable, the parser should recover and parse the FinalNode
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldFailWhenExtendedMetadataLengthIsIncomplete() {
+ // [ID, 0xFF, only 1 byte instead of 2 for extended length]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x05, (byte) 0xFF, 0x01
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(5, node.getId());
+ assertEquals(CoreParseErrorReason.LAYER_TOO_SHORT, node.getErrorReason());
+ }
+
+ @Test
+ void shouldIncludeMetadataInUnknownNode() {
+ // [ID, metaLen=2, meta(AA BB), no payload]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x05, 0x02, (byte) 0xAA, (byte) 0xBB
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ UnknownNode node = (UnknownNode) frame.chain().asList().getFirst();
+
+ ByteBuffer metadata = node.getMetadata();
+ byte[] extracted = new byte[metadata.remaining()];
+ metadata.get(extracted);
+
+ assertArrayEquals(new byte[]{(byte) 0xAA, (byte) 0xBB}, extracted);
+ }
+
+ @Test
+ void shouldIncludeMetadataAndStopOnFailureInNonSkippableLayer() {
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ (byte) 0x85, 0x02, (byte) 0xAA, (byte) 0xBB, 0x00
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(133)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Failure(CoreParseErrorReason.METADATA_TRUNCATED)
+ );
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+
+ ByteBuffer metadata = node.getMetadata();
+ byte[] extracted = new byte[metadata.remaining()];
+ metadata.get(extracted);
+
+ assertArrayEquals(new byte[]{(byte) 0xAA, (byte) 0xBB}, extracted);
+ }
+
+ @Test
+ void shouldIncludeMetadataInFailureNodeWhenPluginFails() {
+ // Frame:
+ // [ID=5, metaLen=2, meta=AA BB, payload=00 (final)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x05, 0x02, (byte) 0xAA, (byte) 0xBB, 0x00
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(5)).thenReturn(Optional.of(mockLayerParser));
+
+ // Plugin fails
+ when(mockLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Failure(CoreParseErrorReason.METADATA_TRUNCATED)
+ );
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+
+ assertEquals(5, node.getId());
+ assertEquals(CoreParseErrorReason.METADATA_TRUNCATED, node.getErrorReason());
+
+ // Verify preserved metadata
+ ByteBuffer metadata = node.getMetadata();
+ byte[] extracted = new byte[metadata.remaining()];
+ metadata.get(extracted);
+
+ assertArrayEquals(new byte[]{(byte) 0xAA, (byte) 0xBB}, extracted);
+ }
+
+ @Test
+ void shouldStopParsingWhenPluginReturnsEmptyPayload() {
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x10, 0x00 // Layer 16
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Success(mockNode, ByteBuffer.allocate(0))
+ );
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ assertEquals(mockNode, frame.chain().asList().getFirst());
+ }
+
+ @Test
+ void shouldRejectNullPayloadInSuccess() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new LayerParseResult.Success(mockNode, null)
+ );
+ }
+
+ @Test
+ void shouldCapturePluginExceptionAsFailureNode() {
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x10, 0x00
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenThrow(IllegalArgumentException.class);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ assertEquals(FailureNode.class, frame.chain().asList().getFirst().getClass());
+
+ FailureNode failureNode = (FailureNode) frame.chain().asList().getFirst();
+
+ assertEquals(CoreParseErrorReason.PLUGIN_EXCEPTION, failureNode.getErrorReason());
+ assertEquals(IllegalArgumentException.class, failureNode.getCause().orElseThrow(() -> new IllegalStateException("NodeChain must have at least 1 failure node")).getClass());
+ }
+
+ @Test
+ void shouldStopOnFailureInNonSkippableLayer() {
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ (byte) 0x85, 0x00 // ID >= 128
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(133)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Failure(CoreParseErrorReason.METADATA_TRUNCATED)
+ );
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(133, node.getId());
+ }
+
+ @Test
+ void shouldContinueAfterFailureInSkippableLayer() {
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x05, 0x00, // Layer 5
+ 0x00 // Final
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(5)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Failure(CoreParseErrorReason.METADATA_TRUNCATED)
+ );
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+
+ assertInstanceOf(FailureNode.class, frame.chain().asList().getFirst());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldPassReadOnlyBuffersToPlugin() {
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x10, 0x01, 0x01, 0x00
+ });
+
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenAnswer(invocation -> {
+ LayerParseInput data = invocation.getArgument(0);
+
+ assertTrue(data.metadata().isReadOnly());
+ assertTrue(data.payload().isReadOnly());
+
+ return new LayerParseResult.Success(mockNode, ByteBuffer.wrap(new byte[]{0x00}));
+ });
+
+ parser.parse(rawFrame);
+ }
+
+ @Test
+ void shouldReturnEmptyChainWhenPayloadIsEmpty() {
+ setupRawFrame(ByteBuffer.allocate(0), 42, 999L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertNotNull(frame);
+ assertTrue(frame.chain().asList().isEmpty());
+ // CRC and timestamp are preserved even if the payload is empty
+ assertEquals(42, frame.crc());
+ assertEquals(999L, frame.timestamp());
+ }
+
+ @Test
+ void shouldReturnLayerTooShortWhenBufferEndsAfterLayerId() {
+ // Only the ID byte, without the metaLen byte
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x05});
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(5, node.getId());
+ assertEquals(CoreParseErrorReason.LAYER_TOO_SHORT, node.getErrorReason());
+ assertTrue(node.getCause().isEmpty());
+ }
+
+ @Test
+ void shouldReturnLayerTooShortWhenExtendedFlagHasZeroRemainingBytes() {
+ // [ID=5, 0xFF] — nothing after the extended flag
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x05, (byte) 0xFF});
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(5, node.getId());
+ assertEquals(CoreParseErrorReason.LAYER_TOO_SHORT, node.getErrorReason());
+ }
+
+ @Test
+ void shouldReturnMalformedWhenExtendedMetadataLengthExceedsAvailableBytes() {
+ // metaLen = 256 (0x01, 0x00 big-endian) but there are only 3 metadata bytes
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x02, (byte) 0xFF, 0x01, 0x00, // ID=2, extended, len=256
+ (byte) 0xAA, (byte) 0xBB, (byte) 0xCC // Only 3 bytes, 253 missing
+ });
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(2, node.getId());
+ assertEquals(CoreParseErrorReason.METADATA_TRUNCATED, node.getErrorReason());
+ }
+
+ @Test
+ void shouldHandleExtendedMetadataWithZeroLength() {
+ // [ID=2, 0xFF, 0x00, 0x00 (len=0), 0x00 (final)]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x02, (byte) 0xFF, 0x00, 0x00, 0x00
+ });
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(2)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ UnknownNode node = (UnknownNode) frame.chain().asList().getFirst();
+ assertEquals(2, node.getId());
+ assertEquals(0, node.getMetadata().remaining()); // Empty but valid metadata
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldParseMultipleConsecutiveUnknownSkippableLayers() {
+ // [ID=1, meta=AA, ID=2, meta=BB, ID=3, meta=CC, Final]
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x01, 0x01, (byte) 0xAA,
+ 0x02, 0x01, (byte) 0xBB,
+ 0x03, 0x01, (byte) 0xCC,
+ 0x00
+ });
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(1)).thenReturn(Optional.empty());
+ when(provider.get(2)).thenReturn(Optional.empty());
+ when(provider.get(3)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(4, frame.chain().size());
+ assertInstanceOf(UnknownNode.class, frame.chain().asList().get(0));
+ assertInstanceOf(UnknownNode.class, frame.chain().asList().get(1));
+ assertInstanceOf(UnknownNode.class, frame.chain().asList().get(2));
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(3));
+
+ assertEquals(1, ((UnknownNode) frame.chain().asList().get(0)).getId());
+ assertEquals(2, ((UnknownNode) frame.chain().asList().get(1)).getId());
+ assertEquals(3, ((UnknownNode) frame.chain().asList().get(2)).getId());
+ }
+
+ @Test
+ void shouldParseChainedKnownLayersViaPluginReturnedPayload() {
+ // Plugin A (ID=16) returns a payload containing another layer (ID=32)
+ LLPLayerParser secondLayerParser = mock(LLPLayerParser.class);
+ LLPNode secondNode = mock(LLPNode.class);
+
+ ByteBuffer framePayload = ByteBuffer.wrap(new byte[]{0x10, 0x00});
+ setupRawFrame(framePayload, 0, 0L);
+
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+ when(provider.get(32)).thenReturn(Optional.of(secondLayerParser));
+
+ // Plugin A processes its layer and returns the remaining payload (containing ID=32)
+ ByteBuffer innerPayload = ByteBuffer.wrap(new byte[]{0x20, 0x00, 0x00});
+ when(mockLayerParser.parse(any()))
+ .thenReturn(new LayerParseResult.Success(mockNode, innerPayload));
+
+ // Plugin B processes layer ID=32 and returns the FinalNode payload
+ when(secondLayerParser.parse(any()))
+ .thenReturn(new LayerParseResult.Success(secondNode, ByteBuffer.wrap(new byte[]{0x00})));
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(3, frame.chain().size());
+ assertEquals(mockNode, frame.chain().asList().get(0));
+ assertEquals(secondNode, frame.chain().asList().get(1));
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(2));
+ }
+
+ @Test
+ void shouldStopParsingAfterNonSkippableUnknownLayerIgnoringRemainingBytes() {
+ // [ID=133 (>=128, no handler), meta=0, ID=1 (skippable), meta=0, Final=0]
+ // The second layer and the final node should not appear in the chain
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ (byte) 0x85, 0x00, // ID=133, empty meta
+ 0x01, 0x00, // ID=1
+ 0x00 // Final
+ });
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(133)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(133, node.getId());
+ assertEquals(CoreParseErrorReason.UNKNOWN_CRITICAL_LAYER, node.getErrorReason());
+ }
+
+ @Test
+ void shouldHandleMaxNonExtendedMetadataLength() {
+ // metaLen = 254 (maximum without extended flag, since 255 triggers extended mode)
+ int metaLen = 254;
+ byte[] metaBytes = new byte[metaLen];
+ Arrays.fill(metaBytes, (byte) 0x7E);
+
+ ByteBuffer payload = ByteBuffer.allocate(2 + metaLen + 1);
+ payload.put((byte) 0x02); // ID=2
+ payload.put((byte) metaLen); // 254, does NOT trigger extended mode
+ payload.put(metaBytes);
+ payload.put((byte) 0x00); // Final
+ payload.flip();
+
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(2)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ UnknownNode node = (UnknownNode) frame.chain().asList().getFirst();
+ assertEquals(metaLen, node.getMetadata().remaining());
+
+ byte[] extractedMeta = new byte[metaLen];
+ node.getMetadata().duplicate().get(extractedMeta);
+ assertArrayEquals(metaBytes, extractedMeta);
+
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldHandleKnownLayerWithEmptyMetadata() {
+ // Valid plugin with metaLen=0 — empty metadata is legal
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x10, 0x00, 0x00});
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenAnswer(invocation -> {
+ LayerParseInput data = invocation.getArgument(0);
+ assertEquals(0, data.metadata().remaining()); // Empty metadata
+ return new LayerParseResult.Success(mockNode, ByteBuffer.wrap(new byte[]{0x00}));
+ });
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ assertEquals(mockNode, frame.chain().asList().getFirst());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldPreserveCorrectMetadataBytesInUnknownNode() {
+ // Ensures that UnknownNode stores exactly the metadata bytes, neither more nor less
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x01, 0x04, 0x11, 0x22, 0x33, 0x44, // ID=1, meta=[11 22 33 44]
+ 0x00 // Final
+ });
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(1)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ UnknownNode node = (UnknownNode) frame.chain().asList().getFirst();
+ byte[] extracted = new byte[node.getMetadata().remaining()];
+ node.getMetadata().duplicate().get(extracted);
+
+ assertArrayEquals(new byte[]{0x11, 0x22, 0x33, 0x44}, extracted);
+ }
+
+ @Test
+ void shouldPreserveMetadataBytesInFailureNodeWhenLayerTooShortAfterMetaLenRead() {
+ // When metaLen is read but there are not enough metadata bytes,
+ // the FailureNode MUST NOT have metadata (could not be read)
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x05, 0x05, // ID=5, metaLen=5
+ (byte) 0xAA, (byte) 0xBB // Only 2 bytes available, 3 missing
+ });
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(5, node.getId());
+ assertEquals(CoreParseErrorReason.METADATA_TRUNCATED, node.getErrorReason());
+ // No metadata available — the FailureNode metadata should be empty
+ assertEquals(0, node.getMetadata().remaining());
+ }
+
+ @Test
+ void shouldHandleBoundaryLayerId127AsSkippable() {
+ // ID=127 is the last passthrough value (< 128)
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x7F, 0x00, 0x00});
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(127)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ assertInstanceOf(UnknownNode.class, frame.chain().asList().getFirst());
+ assertEquals(127, ((UnknownNode) frame.chain().asList().getFirst()).getId());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ @Test
+ void shouldHandleBoundaryLayerId128AsNonSkippable() {
+ // ID=128 is the first non-skippable value (>= 128)
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{(byte) 0x80, 0x00, 0x00});
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(128)).thenReturn(Optional.empty());
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(128, node.getId());
+ assertEquals(CoreParseErrorReason.UNKNOWN_CRITICAL_LAYER, node.getErrorReason());
+ }
+
+ @Test
+ void shouldHandleFinalLayerAtStartWithNoRawBytes() {
+ // [0x00] only — FinalNode with empty payload
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{0x00});
+ setupRawFrame(payload, 0, 0L);
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().getFirst());
+ }
+
+ @Test
+ void shouldRecoverAndParseFinalNodeAfterMultipleSkippablePluginFailures() {
+ // Two plugins fail on consecutive skippable layers, then the FinalNode appears
+ LLPLayerParser secondLayerParser = mock(LLPLayerParser.class);
+
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x05, 0x00, // ID=5, empty meta
+ 0x06, 0x00, // ID=6, empty meta
+ 0x00 // Final
+ });
+ setupRawFrame(payload, 0, 0L);
+
+ when(provider.get(5)).thenReturn(Optional.of(mockLayerParser));
+ when(provider.get(6)).thenReturn(Optional.of(secondLayerParser));
+
+ when(mockLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Failure(CoreParseErrorReason.METADATA_TRUNCATED)
+ );
+ when(secondLayerParser.parse(any())).thenReturn(
+ new LayerParseResult.Failure(CoreParseErrorReason.METADATA_TRUNCATED)
+ );
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(3, frame.chain().size());
+ assertInstanceOf(FailureNode.class, frame.chain().asList().get(0));
+ assertInstanceOf(FailureNode.class, frame.chain().asList().get(1));
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(2));
+
+ assertEquals(5, ((FailureNode) frame.chain().asList().get(0)).getId());
+ assertEquals(6, ((FailureNode) frame.chain().asList().get(1)).getId());
+ }
+
+ @Test
+ void shouldProtectCoreFromPluginExceptionOnNonSkippableLayer() {
+ // Plugin in non-skippable layer throws exception — the core must stop
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ (byte) 0x85, 0x00, // ID=133, non-skippable
+ 0x01, 0x00, 0x00 // More layers that should NOT be processed
+ });
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(133)).thenReturn(Optional.of(mockLayerParser));
+ when(mockLayerParser.parse(any())).thenThrow(new RuntimeException("plugin crash"));
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(1, frame.chain().size());
+ FailureNode node = (FailureNode) frame.chain().asList().getFirst();
+ assertEquals(133, node.getId());
+ assertEquals(CoreParseErrorReason.PLUGIN_EXCEPTION, node.getErrorReason());
+ assertTrue(node.getCause().isPresent());
+ assertInstanceOf(RuntimeException.class, node.getCause().get());
+ }
+
+ @Test
+ void shouldPassMetadataWithCorrectBoundsToPlugin() {
+ // Verifies that the plugin receives exactly the specified metadata bytes
+ // and that the payload starts immediately after
+ ByteBuffer payload = ByteBuffer.wrap(new byte[]{
+ 0x10, // ID=16
+ 0x03, // metaLen=3
+ 0x11, 0x22, 0x33, // Metadata
+ 0x00, (byte) 0xAB, (byte) 0xCD // Final + raw bytes
+ });
+ setupRawFrame(payload, 0, 0L);
+ when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
+
+ when(mockLayerParser.parse(any())).thenAnswer(invocation -> {
+ LayerParseInput data = invocation.getArgument(0);
+
+ // Metadata must be exactly [11 22 33]
+ assertEquals(3, data.metadata().remaining());
+ byte[] meta = new byte[3];
+ data.metadata().duplicate().get(meta);
+ assertArrayEquals(new byte[]{0x11, 0x22, 0x33}, meta);
+
+ // Payload must start at [00 AB CD]
+ assertTrue(data.payload().hasRemaining());
+ assertEquals(0x00, data.payload().duplicate().get() & 0xFF);
+
+ return new LayerParseResult.Success(mockNode, data.payload().asReadOnlyBuffer());
+ });
+
+ LLPFrame frame = parser.parse(rawFrame);
+
+ assertEquals(2, frame.chain().size());
+ assertInstanceOf(FinalNode.class, frame.chain().asList().get(1));
+ }
+
+ /**
+ * Helper method to stub the raw frame safely.
+ */
+ private void setupRawFrame(ByteBuffer payload, int crc, long timestamp) {
+ when(rawFrame.payload()).thenReturn(payload);
+ when(rawFrame.crc()).thenReturn(crc);
+ when(rawFrame.timestamp()).thenReturn(timestamp);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java b/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
new file mode 100644
index 0000000..fbdaa90
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
@@ -0,0 +1,21 @@
+package com.flamingo.comm.llp.core;
+
+import java.util.Objects;
+
+class SpecialNode extends TestNode {
+ SpecialNode(int id) {
+ super(id);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SpecialNode that)) return false;
+ return getId() == that.getId();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getId());
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/TestNode.java b/src/test/java/com/flamingo/comm/llp/core/TestNode.java
new file mode 100644
index 0000000..44aebb0
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/TestNode.java
@@ -0,0 +1,16 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+class TestNode implements LLPNode {
+ private final int id;
+
+ TestNode(int id) {
+ this.id = id;
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/TransportErrorCodeTest.java b/src/test/java/com/flamingo/comm/llp/core/TransportErrorCodeTest.java
new file mode 100644
index 0000000..deff3ae
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/TransportErrorCodeTest.java
@@ -0,0 +1,94 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TransportErrorCodeTest {
+
+ @Test
+ void testFromCodeValidValues() {
+ for (TransportErrorCode error : TransportErrorCode.values()) {
+ Optional result = TransportErrorCode.fromCode(error.code());
+
+ assertTrue(result.isPresent(), "Expected code to be found: " + error);
+ assertEquals(error, result.get(), "Returned enum should match original");
+ }
+ }
+
+ @Test
+ void testFromCodeInvalidValue() {
+ byte invalidCode = (byte) 0x7F;
+
+ Optional result = TransportErrorCode.fromCode(invalidCode);
+
+ assertTrue(result.isEmpty(), "Invalid code should return empty Optional");
+ }
+
+ @Test
+ void testFromCodeBoundaryValues() {
+ // Extreme byte values
+ assertTrue(TransportErrorCode.fromCode(Byte.MIN_VALUE).isEmpty());
+ assertTrue(TransportErrorCode.fromCode(Byte.MAX_VALUE).isEmpty());
+ }
+
+ @Test
+ void testCodeGetter() {
+ assertEquals((byte) 0x00, TransportErrorCode.OK.code());
+ assertEquals((byte) 0x01, TransportErrorCode.CHECKSUM_INVALID.code());
+ assertEquals((byte) 0x02, TransportErrorCode.PAYLOAD_LEN_INVALID.code());
+ assertEquals((byte) 0x03, TransportErrorCode.TIMEOUT.code());
+ assertEquals((byte) 0x04, TransportErrorCode.SYNC_ERROR.code());
+ assertEquals((byte) 0x05, TransportErrorCode.BUFFER_FULL.code());
+ }
+
+ @Test
+ void testDescriptionGetter() {
+ assertEquals("No error", TransportErrorCode.OK.description());
+ assertEquals("CRC checksum mismatch", TransportErrorCode.CHECKSUM_INVALID.description());
+ assertEquals("Payload length exceeds maximum", TransportErrorCode.PAYLOAD_LEN_INVALID.description());
+ assertEquals("Frame timeout - incomplete frame", TransportErrorCode.TIMEOUT.description());
+ assertEquals("Synchronization error", TransportErrorCode.SYNC_ERROR.description());
+ assertEquals("Buffer overflow", TransportErrorCode.BUFFER_FULL.description());
+ }
+
+ @Test
+ void testCodesAreUnique() {
+ for (TransportErrorCode e1 : TransportErrorCode.values()) {
+ for (TransportErrorCode e2 : TransportErrorCode.values()) {
+ if (e1 != e2) {
+ assertNotEquals(
+ e1.code(),
+ e2.code(),
+ "Duplicate error code found between " + e1 + " and " + e2
+ );
+ }
+ }
+ }
+ }
+
+ @Test
+ void testFromCodeIsDeterministic() {
+ byte code = TransportErrorCode.TIMEOUT.code();
+
+ Optional r1 = TransportErrorCode.fromCode(code);
+ Optional r2 = TransportErrorCode.fromCode(code);
+
+ assertEquals(r1, r2, "fromCode should be deterministic");
+ }
+
+ @Test
+ void testEnumCoverage() {
+ // Force the execution of `values()` and ensure that there are elements
+ assertTrue(TransportErrorCode.values().length > 0);
+ }
+
+ @Test
+ void testToStringNotNull() {
+ for (TransportErrorCode error : TransportErrorCode.values()) {
+ assertNotNull(error.toString());
+ }
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java b/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
new file mode 100644
index 0000000..e1c7650
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
@@ -0,0 +1,120 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class UnknownNodeTest {
+
+ private byte[] extractData(UnknownNode node) {
+ ByteBuffer buffer = node.getMetadata();
+ byte[] extracted = new byte[buffer.remaining()];
+ buffer.get(extracted);
+ return extracted;
+ }
+
+ @Test
+ void testConstructorAndGetId() {
+ UnknownNode node = new UnknownNode(42, new byte[]{1, 2, 3});
+
+ assertEquals(42, node.getId());
+ }
+
+ @Test
+ void testMetadataContent() {
+ byte[] metadata = {10, 20, 30};
+
+ UnknownNode node = new UnknownNode(1, metadata);
+
+ byte[] extracted = extractData(node);
+
+ assertArrayEquals(metadata, extracted);
+ }
+
+ @Test
+ void testMetadataIsDefensivelyCopiedInConstructor() {
+ byte[] metadata = {1, 2, 3};
+
+ UnknownNode node = new UnknownNode(1, metadata);
+
+ // Modify original array
+ metadata[0] = 99;
+
+ byte[] extracted = extractData(node);
+
+ assertEquals(1, extracted[0], "Internal state should not be affected by external changes");
+ }
+
+ @Test
+ void testMetadataIsDefensivelyCopiedInGetter() {
+ byte[] metadata = {1, 2, 3};
+
+ UnknownNode node = new UnknownNode(1, metadata);
+
+ byte[] extracted1 = extractData(node);
+ byte[] extracted2 = extractData(node);
+
+ // Modify returned array
+ extracted1[0] = 99;
+
+ // Ensure second call is unaffected
+ assertEquals(1, extracted2[0], "Getter should return a defensive copy");
+ }
+
+ @Test
+ void testNullMetadataBecomesEmptyArray() {
+ UnknownNode node = new UnknownNode(1, null);
+
+ byte[] metadata = extractData(node);
+
+ assertNotNull(metadata);
+ assertEquals(0, metadata.length);
+ }
+
+ @Test
+ void testEmptyMetadata() {
+ UnknownNode node = new UnknownNode(1, new byte[0]);
+
+ byte[] metadata = extractData(node);
+
+ assertNotNull(metadata);
+ assertEquals(0, metadata.length);
+ }
+
+ @Test
+ void testToStringContainsIdAndLength() {
+ byte[] metadata = {1, 2, 3};
+
+ UnknownNode node = new UnknownNode(99, metadata);
+
+ String str = node.toString();
+
+ assertTrue(str.contains("id=99"));
+ assertTrue(str.contains("metadataLength=3"));
+ }
+
+ @Test
+ void testLargeMetadata() {
+ byte[] metadata = new byte[1024];
+ for (int i = 0; i < metadata.length; i++) {
+ metadata[i] = (byte) i;
+ }
+
+ UnknownNode node = new UnknownNode(5, metadata);
+
+ byte[] extracted = extractData(node);
+
+ assertArrayEquals(metadata, extracted);
+ }
+
+ @Test
+ void testIdBoundaries() {
+ UnknownNode min = new UnknownNode(0, new byte[0]);
+ UnknownNode max = new UnknownNode(255, new byte[0]);
+
+ assertEquals(0, min.getId());
+ assertEquals(255, max.getId());
+ }
+}