From 34a5972a5ddfe4b483d5e67eaea0770b53ef3002 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 10 Apr 2026 21:42:22 -0300
Subject: [PATCH 01/30] =?UTF-8?q?Creadas=20nuevas=20clases=20base=20para?=
=?UTF-8?q?=20versi=C3=B3n=203.0.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pom.xml | 15 +-
src/main/java/com/flamingo/comm/llp/LLP.java | 155 --------
.../com/flamingo/comm/llp/LLPErrorCode.java | 48 ---
.../java/com/flamingo/comm/llp/LLPFrame.java | 91 -----
.../flamingo/comm/llp/LLPFrameBuilder.java | 214 ----------
.../com/flamingo/comm/llp/LLPMessageType.java | 45 ---
.../java/com/flamingo/comm/llp/LLPParser.java | 366 ------------------
.../com/flamingo/comm/llp/core/FinalNode.java | 73 ++++
.../com/flamingo/comm/llp/core/LLPFrame.java | 181 +++++++++
.../com/flamingo/comm/llp/core/LLPNode.java | 65 ++++
.../comm/llp/core/LLPNodeVisitor.java | 62 +++
.../com/flamingo/comm/llp/crc/CRC16CCITT.java | 65 ----
.../flamingo/comm/llp/util/Statistics.java | 65 ----
13 files changed, 388 insertions(+), 1057 deletions(-)
delete mode 100644 src/main/java/com/flamingo/comm/llp/LLP.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/LLPErrorCode.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/LLPFrame.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/LLPMessageType.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/LLPParser.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/FinalNode.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPNode.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/crc/CRC16CCITT.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/util/Statistics.java
diff --git a/pom.xml b/pom.xml
index 180c21d..ae8e865 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,12 +6,12 @@
4.0.0
com.flamingo
- llp-protocol
- 2.0.0
+ llp-core
+ 3.0.0
jar
- LLP Protocol - Java
- Lightweight Link Protocol implementation for Java. A robust, extensible serial protocol for microcontroller communication.
+ LLP Core
+ LLP transport layer core with plugin-based extensibility via SPI
https://github.com/EnzoLeonel/llp-protocol-java
@@ -39,8 +39,7 @@
- 21
- 21
+ 21
UTF-8
@@ -49,7 +48,7 @@
3.11.0
- 3.0.0
+ 3.2.5
3.3.0
3.5.0
3.5.0
@@ -149,7 +148,7 @@
org.apache.maven.plugins
maven-failsafe-plugin
- 3.0.0
+ 3.2.5
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/LLPErrorCode.java b/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java
deleted file mode 100644
index 29e494f..0000000
--- a/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.flamingo.comm.llp;
-
-import java.util.Optional;
-
-/**
- * LLP Parser Error Codes
- */
-public enum LLPErrorCode {
- 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");
-
- private final byte code;
- private final String description;
-
- LLPErrorCode(byte code, String description) {
- this.code = code;
- this.description = description;
- }
-
- /**
- * Retrieve the error code from a byte
- *
- * @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()) {
- if (err.code == code) {
- return Optional.of(err);
- }
- }
- return Optional.empty();
- }
-
- public byte code() {
- return code;
- }
-
- public String description() {
- return description;
- }
-}
\ 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/LLPParser.java b/src/main/java/com/flamingo/comm/llp/LLPParser.java
deleted file mode 100644
index e6891f3..0000000
--- a/src/main/java/com/flamingo/comm/llp/LLPParser.java
+++ /dev/null
@@ -1,366 +0,0 @@
-package com.flamingo.comm.llp;
-
-import com.flamingo.comm.llp.crc.CRC16CCITT;
-import com.flamingo.comm.llp.util.Statistics;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Queue;
-import java.util.concurrent.ConcurrentLinkedQueue;
-
-/**
- * LLP frame parser based on a byte-oriented state machine.
- *
- * 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.
- */
-public class LLPParser {
- private static final Logger logger = LoggerFactory.getLogger(LLPParser.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 final byte[] headerBuf = new byte[8];
- 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 State state = State.WAIT_MAGIC1;
- private boolean escapePending = false;
-
- private int payloadLen = 0;
- 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.
- */
- public LLPParser() {
- this(LLPFrame.DEFAULT_MAX_PAYLOAD);
- }
-
- /**
- * Creates a parser with a custom maximum payload size.
- *
- * @param maxPayload maximum payload size in bytes
- */
- public LLPParser(int maxPayload) {
- this(maxPayload, DEFAULT_TIMEOUT_MS);
- }
-
- /**
- * Creates a parser with custom payload size and timeout.
- *
- * @param maxPayload maximum payload size in bytes
- * @param timeoutMs frame timeout in milliseconds
- */
- public LLPParser(int maxPayload, long timeoutMs) {
- if (maxPayload < 1) {
- maxPayload = LLPFrame.DEFAULT_MAX_PAYLOAD;
- }
-
- if (timeoutMs < 1) {
- timeoutMs = DEFAULT_TIMEOUT_MS;
- }
-
- this.payload = new byte[maxPayload];
- this.timeoutMs = timeoutMs;
- }
-
- /**
- * Processes a single byte from the input stream, resolving byte stuffing transparently.
- *
- * @param b incoming byte
- * @return a complete {@link LLPFrame} or {@code null} if not complete
- */
- public LLPFrame 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);
-
- // Allow a magic byte to restart the sequence immediately
- 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
- 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");
- statistics.recordError();
- notifyError(LLPErrorCode.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
-
- } else if (b == 0x00) {
- // Escaped data byte recovered. Restore to MAGIC_1.
- b = MAGIC_1;
- } else {
- // Invalid sequence
- logger.error("Invalid sync sequence: 0xAA followed by 0x{}", Integer.toHexString(b & 0xFF));
- statistics.recordError();
- reset();
- notifyError(LLPErrorCode.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;
- state = State.WAIT_MAGIC2;
- }
- break;
-
- 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;
- } else if (b == MAGIC_1) {
- // RF robustness: another MAGIC_1 received
- 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;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- state = State.READ_LEN_H;
- break;
-
- case READ_LEN_H:
- headerBuf[7] = b;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
-
- payloadLen = (headerBuf[6] & 0xFF) | ((headerBuf[7] & 0xFF) << 8);
-
- if (payloadLen > payload.length) {
- logger.error("Payload length {} exceeds maximum {}", payloadLen, payload.length);
- statistics.recordError();
- reset();
- notifyError(LLPErrorCode.PAYLOAD_LEN_INVALID);
- return null;
- }
-
- payloadIdx = 0;
-
- if (payloadLen == 0) {
- state = State.READ_CRC_L;
- } else {
- state = State.READ_PAYLOAD;
- }
- break;
-
- case READ_PAYLOAD:
- payload[payloadIdx] = b;
- crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- payloadIdx++;
-
- if (payloadIdx == payloadLen) {
- state = State.READ_CRC_L;
- }
- break;
-
- case READ_CRC_L:
- crcReceived = (b & 0xFF);
- state = State.READ_CRC_H;
- break;
-
- case READ_CRC_H:
- crcReceived |= ((b & 0xFF) << 8);
-
- if (crcReceived != crcCalculated) {
- logger.error("CRC mismatch: received=0x{}, calculated=0x{}",
- Integer.toHexString(crcReceived),
- Integer.toHexString(crcCalculated));
- statistics.recordError();
- reset();
- notifyError(LLPErrorCode.CHECKSUM_INVALID);
- return null;
- }
-
- // Full frame
- LLPFrame frame = createFrame();
- statistics.recordSuccess();
- reset();
- notifySuccess(frame);
- frameQueue.offer(frame);
- return frame;
- }
-
- return null;
- }
-
- public List processBytes(byte[] data) {
- List frames = new ArrayList<>();
- for (byte b : data) {
- LLPFrame f = processByte(b);
- if (f != null) frames.add(f);
- }
- 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.
- */
- private void reset() {
- state = State.WAIT_MAGIC1;
- payloadIdx = 0;
- crcCalculated = 0xFFFF;
- escapePending = false; // Reset escape flag
- }
-
- /**
- * Registers a frame listener.
- */
- public void addListener(LLPFrameListener listener) {
- listeners.offer(listener);
- }
-
- /**
- * Removes a frame listener.
- */
- public void removeListener(LLPFrameListener listener) {
- listeners.remove(listener);
- }
-
- // ============= LISTENER MANAGEMENT =============
-
- private void notifySuccess(LLPFrame frame) {
- for (LLPFrameListener listener : listeners) {
- try {
- listener.onFrameReceived(frame);
- } catch (Exception e) {
- logger.error("Listener error", e);
- }
- }
- }
-
- private void notifyError(LLPErrorCode errorCode) {
- for (LLPFrameListener listener : listeners) {
- try {
- listener.onFrameError(errorCode);
- } 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
- }
-
- /**
- * Listener interface for receiving parser events.
- */
- public interface LLPFrameListener {
-
- /**
- * Called when a valid frame is received.
- */
- void onFrameReceived(LLPFrame frame);
-
- /**
- * Called when a frame error occurs.
- */
- void onFrameError(LLPErrorCode errorCode);
- }
-}
\ 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..bff136d
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
@@ -0,0 +1,73 @@
+package com.flamingo.comm.llp.core;
+
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.Arrays;
+
+/**
+ * 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;
+ private static final byte[] EMPTY_ARRAY = new byte[0];
+
+ /**
+ * Shared instance for empty payload (singleton).
+ */
+ public static final FinalNode EMPTY = new FinalNode(EMPTY_ARRAY);
+
+ private final byte[] payload;
+
+ /**
+ * Creates a FinalNode with payload.
+ *
+ * @param payload raw payload (nullable → treated as empty)
+ */
+ public FinalNode(byte[] payload) {
+ this.payload = (payload == null || payload.length == 0)
+ ? EMPTY_ARRAY
+ : Arrays.copyOf(payload, payload.length);
+ }
+
+ @Override
+ public int getId() {
+ return ID;
+ }
+
+ @Override
+ public Optional getInnerNode() {
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean isSkippable() {
+ return true; // Always skippable by definition
+ }
+
+ public byte[] getPayload() {
+ return payload;
+ }
+
+ /**
+ * Factory method to reuse EMPTY instance when possible.
+ */
+ public static FinalNode of(byte[] payload) {
+ if (payload == null || payload.length == 0) {
+ return EMPTY;
+ }
+ return new FinalNode(payload);
+ }
+
+ @Override
+ public String toString() {
+ return "FinalNode{" +
+ "payloadHex=" + HexFormat.of().formatHex(payload).toUpperCase(Locale.ROOT) +
+ '}';
+ }
+}
\ 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..a2ea181
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
@@ -0,0 +1,181 @@
+package com.flamingo.comm.llp.core;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+/**
+ * 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 LLPNode content;
+ private final int crc;
+ private final long timestamp;
+
+ /**
+ * Creates a new frame with the current system timestamp.
+ *
+ * @param content root node (outermost layer)
+ * @param crc calculated CRC value
+ */
+ LLPFrame(LLPNode content, int crc) {
+ this(content, crc, System.currentTimeMillis());
+ }
+
+ /**
+ * Creates a new frame.
+ *
+ * @param content root node (outermost layer)
+ * @param crc calculated CRC value
+ * @param timestamp creation timestamp (milliseconds)
+ */
+ LLPFrame(LLPNode content, int crc, long timestamp) {
+ this.content = (content != null) ? content : FinalNode.EMPTY;
+ 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;
+ }
+
+ /**
+ * Finds the first node of the given type in the frame hierarchy.
+ *
+ * @param type node class
+ * @param node type
+ * @return optional node instance
+ */
+ public Optional getNode(Class type) {
+ LLPNode current = this.content;
+
+ while (current != null) {
+ if (type.isInstance(current)) {
+ return Optional.of(type.cast(current));
+ }
+ current = current.getInnerNode().orElse(null);
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Finds the first node with the given layer ID.
+ *
+ * @param id layer identifier
+ * @return optional node
+ */
+ public Optional getNode(int id) {
+ LLPNode current = this.content;
+
+ while (current != null) {
+ if (current.getId() == id) {
+ return Optional.of(current);
+ }
+ current = current.getInnerNode().orElse(null);
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Returns the deepest (last) node in the hierarchy.
+ *
+ * @return last node
+ */
+ public LLPNode getDeepestNode() {
+ LLPNode current = this.content;
+
+ while (current.getInnerNode().isPresent()) {
+ current = current.getInnerNode().get();
+ }
+
+ return current;
+ }
+
+ /**
+ * Returns all nodes in traversal order (outer → inner).
+ *
+ * @return immutable list of nodes
+ */
+ public List getNodes() {
+ List nodes = new ArrayList<>();
+ LLPNode current = this.content;
+
+ while (current != null) {
+ nodes.add(current);
+ current = current.getInnerNode().orElse(null);
+ }
+
+ return List.copyOf(nodes);
+ }
+
+ /**
+ * Traverses all nodes using a visitor.
+ *
+ * @param consumer visitor builder
+ */
+ public void visitNodes(Consumer consumer) {
+ LLPNodeVisitor visitor = new LLPNodeVisitor();
+ consumer.accept(visitor);
+ LLPNode current = content;
+
+ while (current != null) {
+ visitor.visit(current);
+ current = current.getInnerNode().orElse(null);
+ }
+ }
+
+ /**
+ * Returns a string representation of the frame.
+ */
+ @Override
+ public String toString() {
+ return "LLPFrame{" +
+ "crc=" + crc +
+ ", timestamp=" + timestamp +
+ ", nodes=" + getNodes() +
+ '}';
+ }
+
+ /**
+ * 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(content, that.content);
+ }
+
+ /**
+ * Returns the hash code of the frame.
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(content, crc);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNode.java b/src/main/java/com/flamingo/comm/llp/core/LLPNode.java
new file mode 100644
index 0000000..308f266
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPNode.java
@@ -0,0 +1,65 @@
+package com.flamingo.comm.llp.core;
+
+import java.util.Optional;
+
+/**
+ * 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 and delegating
+ * further parsing to its inner node if present.
+ *
+ * Implementations of this interface should be immutable and thread-safe.
+ */
+public interface LLPNode {
+
+ /**
+ * Determines whether this node is terminal.
+ *
+ * A node is considered terminal if:
+ *
+ * - It has no inner node
+ * - Its inner node is the {@link FinalNode}
+ *
+ *
+ * @return {@code true} if this is the last meaningful layer, {@code false} otherwise
+ */
+ default boolean isTerminal() {
+ return getInnerNode().isEmpty() ||
+ getInnerNode().get().getId() == FinalNode.ID;
+ }
+
+ /**
+ * 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 (0-255)
+ */
+ int getId();
+
+ /**
+ * Indicates whether this layer can be safely skipped if not recognized.
+ *
+ * If {@code true}, a parser that does not support this layer may ignore it
+ * and continue processing the inner payload.
+ *
+ * If {@code false}, the parser should fail if the layer is not supported,
+ * as it may alter the payload semantics.
+ *
+ * @return {@code true} if skippable, {@code false} otherwise
+ */
+ boolean isSkippable();
+
+ /**
+ * Returns the inner node (next layer).
+ *
+ * If present, the inner node represents the next layer in the hierarchy.
+ *
+ * @return optional inner node
+ */
+ Optional getInnerNode();
+}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java b/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
new file mode 100644
index 0000000..39a38c7
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
@@ -0,0 +1,62 @@
+package com.flamingo.comm.llp.core;
+
+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 LLPNodeVisitor {
+
+ 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 LLPNodeVisitor 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/crc/CRC16CCITT.java b/src/main/java/com/flamingo/comm/llp/crc/CRC16CCITT.java
deleted file mode 100644
index c76dbcf..0000000
--- a/src/main/java/com/flamingo/comm/llp/crc/CRC16CCITT.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.flamingo.comm.llp.crc;
-
-/**
- * Implementation of CRC16-CCITT.
- *
- * Polynomial: 0x1021
- * Initial value: 0xFFFF
- *
- * Optimized calculation using a pre-calculated table (optional).
- */
-public class CRC16CCITT {
- private static final int POLYNOMIAL = 0x1021;
- private static final int INITIAL_VALUE = 0xFFFF;
- private static final int[] CRC_TABLE = buildTable();
-
- /**
- * Build a precomputed CRC16 table for better performance
- */
- private static int[] buildTable() {
- int[] table = new int[256];
- for (int i = 0; i < 256; i++) {
- int crc = i << 8;
- for (int j = 0; j < 8; j++) {
- crc = (crc & 0x8000) != 0
- ? ((crc << 1) ^ POLYNOMIAL) & 0xFFFF
- : (crc << 1) & 0xFFFF;
- }
- table[i] = crc;
- }
- return table;
- }
-
- /**
- * Calculate the CRC16 of an entire buffer
- */
- public static int calculate(byte[] data) {
- return calculate(data, 0, data.length);
- }
-
- /**
- * Calculate the CRC16 of a range of bytes
- */
- public static int calculate(byte[] data, int offset, int length) {
- int crc = INITIAL_VALUE;
- for (int i = 0; i < length; i++) {
- crc = updateCRC(crc, data[offset + i]);
- }
- return crc;
- }
-
- /**
- * Update CRC16 with an additional byte (for incremental calculation)
- */
- public static int updateCRC(int crc, byte data) {
- int index = ((crc >> 8) ^ (data & 0xFF)) & 0xFF;
- return ((crc << 8) ^ CRC_TABLE[index]) & 0xFFFF;
- }
-
- /**
- * Check whether the data's CRC matches the expected value
- */
- public static boolean verify(byte[] data, int expectedCRC) {
- return calculate(data) == expectedCRC;
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/util/Statistics.java b/src/main/java/com/flamingo/comm/llp/util/Statistics.java
deleted file mode 100644
index fa59751..0000000
--- a/src/main/java/com/flamingo/comm/llp/util/Statistics.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.flamingo.comm.llp.util;
-
-import java.util.concurrent.atomic.AtomicLong;
-
-/**
- * LLP parser statistics.
- * Thread-safe with AtomicLong.
- */
-public class Statistics {
- private final AtomicLong framesOk = new AtomicLong(0);
- private final AtomicLong framesError = new AtomicLong(0);
- private final AtomicLong timeouts = new AtomicLong(0);
- private final long createdAt = System.currentTimeMillis();
-
- public void recordSuccess() {
- framesOk.incrementAndGet();
- }
-
- public void recordError() {
- framesError.incrementAndGet();
- }
-
- public void recordTimeout() {
- timeouts.incrementAndGet();
- }
-
- public long getFramesOk() {
- return framesOk.get();
- }
-
- public long getFramesError() {
- return framesError.get();
- }
-
- public long getTimeouts() {
- return timeouts.get();
- }
-
- public long getTotalFrames() {
- return framesOk.get() + framesError.get();
- }
-
- public double getSuccessRate() {
- long total = getTotalFrames();
- return total == 0 ? 0.0 : (double) framesOk.get() / total * 100.0;
- }
-
- public long getUptimeMs() {
- return System.currentTimeMillis() - createdAt;
- }
-
- public void reset() {
- framesOk.set(0);
- framesError.set(0);
- timeouts.set(0);
- }
-
- @Override
- public String toString() {
- return String.format(
- "Statistics{framesOk=%d, framesError=%d, timeouts=%d, successRate=%.2f%%, uptimeMs=%d}",
- getFramesOk(), getFramesError(), getTimeouts(), getSuccessRate(), getUptimeMs()
- );
- }
-}
\ No newline at end of file
From b9e78398a0851a4d45ac49e67df6b5ae2cffe368 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sat, 11 Apr 2026 04:11:04 -0300
Subject: [PATCH 02/30] Agregado LLPNodeChain para listar nodos encadenados,
interfaz spi LLPLayerParser y registro LLPLayerRegistry
---
.../com/flamingo/comm/llp/core/FinalNode.java | 32 ++--
.../com/flamingo/comm/llp/core/LLPFrame.java | 113 ++----------
.../comm/llp/core/LLPLayerRegistry.java | 23 +++
.../com/flamingo/comm/llp/core/LLPNode.java | 65 -------
.../flamingo/comm/llp/core/LLPNodeChain.java | 161 ++++++++++++++++++
.../comm/llp/core/LLPNodeVisitor.java | 2 +
.../flamingo/comm/llp/core/UnknownNode.java | 49 ++++++
.../flamingo/comm/llp/spi/LLPLayerParser.java | 101 +++++++++++
.../com/flamingo/comm/llp/spi/LLPNode.java | 25 +++
9 files changed, 389 insertions(+), 182 deletions(-)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPNode.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LLPNode.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
index bff136d..2b6f154 100644
--- a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
@@ -1,9 +1,10 @@
package com.flamingo.comm.llp.core;
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.util.Arrays;
import java.util.HexFormat;
import java.util.Locale;
-import java.util.Optional;
-import java.util.Arrays;
/**
* Final LLP node (Layer ID = 0).
@@ -29,7 +30,7 @@ public final class FinalNode implements LLPNode {
*
* @param payload raw payload (nullable → treated as empty)
*/
- public FinalNode(byte[] payload) {
+ FinalNode(byte[] payload) {
this.payload = (payload == null || payload.length == 0)
? EMPTY_ARRAY
: Arrays.copyOf(payload, payload.length);
@@ -40,30 +41,25 @@ public int getId() {
return ID;
}
- @Override
- public Optional getInnerNode() {
- return Optional.empty();
- }
-
- @Override
- public boolean isSkippable() {
- return true; // Always skippable by definition
- }
-
- public byte[] getPayload() {
- return payload;
- }
-
/**
* Factory method to reuse EMPTY instance when possible.
*/
- public static FinalNode of(byte[] payload) {
+ static FinalNode of(byte[] payload) {
if (payload == null || payload.length == 0) {
return EMPTY;
}
return new FinalNode(payload);
}
+ /**
+ * Raw payload sent by the sender
+ *
+ * @return an array of bytes containing the raw payload sent by the sender, or an empty array
+ */
+ public byte[] getPayload() {
+ return payload;
+ }
+
@Override
public String toString() {
return "FinalNode{" +
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
index a2ea181..d3a0577 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
@@ -1,10 +1,8 @@
package com.flamingo.comm.llp.core;
-import java.util.ArrayList;
-import java.util.List;
+import com.flamingo.comm.llp.spi.LLPNode;
+
import java.util.Objects;
-import java.util.Optional;
-import java.util.function.Consumer;
/**
* Represents a fully received and validated LLP frame.
@@ -16,29 +14,29 @@
*/
public final class LLPFrame {
- private final LLPNode content;
+ private final LLPNodeChain nodeChain;
private final int crc;
private final long timestamp;
/**
* Creates a new frame with the current system timestamp.
*
- * @param content root node (outermost layer)
+ * @param nodeChain nested nodes
* @param crc calculated CRC value
*/
- LLPFrame(LLPNode content, int crc) {
- this(content, crc, System.currentTimeMillis());
+ LLPFrame(LLPNodeChain nodeChain, int crc) {
+ this(nodeChain, crc, System.currentTimeMillis());
}
/**
* Creates a new frame.
*
- * @param content root node (outermost layer)
+ * @param nodeChain nested nodes
* @param crc calculated CRC value
* @param timestamp creation timestamp (milliseconds)
*/
- LLPFrame(LLPNode content, int crc, long timestamp) {
- this.content = (content != null) ? content : FinalNode.EMPTY;
+ LLPFrame(LLPNodeChain nodeChain, int crc, long timestamp) {
+ this.nodeChain = nodeChain;
this.crc = crc;
this.timestamp = timestamp;
}
@@ -57,91 +55,8 @@ public long timestamp() {
return timestamp;
}
- /**
- * Finds the first node of the given type in the frame hierarchy.
- *
- * @param type node class
- * @param node type
- * @return optional node instance
- */
- public Optional getNode(Class type) {
- LLPNode current = this.content;
-
- while (current != null) {
- if (type.isInstance(current)) {
- return Optional.of(type.cast(current));
- }
- current = current.getInnerNode().orElse(null);
- }
-
- return Optional.empty();
- }
-
- /**
- * Finds the first node with the given layer ID.
- *
- * @param id layer identifier
- * @return optional node
- */
- public Optional getNode(int id) {
- LLPNode current = this.content;
-
- while (current != null) {
- if (current.getId() == id) {
- return Optional.of(current);
- }
- current = current.getInnerNode().orElse(null);
- }
-
- return Optional.empty();
- }
-
- /**
- * Returns the deepest (last) node in the hierarchy.
- *
- * @return last node
- */
- public LLPNode getDeepestNode() {
- LLPNode current = this.content;
-
- while (current.getInnerNode().isPresent()) {
- current = current.getInnerNode().get();
- }
-
- return current;
- }
-
- /**
- * Returns all nodes in traversal order (outer → inner).
- *
- * @return immutable list of nodes
- */
- public List getNodes() {
- List nodes = new ArrayList<>();
- LLPNode current = this.content;
-
- while (current != null) {
- nodes.add(current);
- current = current.getInnerNode().orElse(null);
- }
-
- return List.copyOf(nodes);
- }
-
- /**
- * Traverses all nodes using a visitor.
- *
- * @param consumer visitor builder
- */
- public void visitNodes(Consumer consumer) {
- LLPNodeVisitor visitor = new LLPNodeVisitor();
- consumer.accept(visitor);
- LLPNode current = content;
-
- while (current != null) {
- visitor.visit(current);
- current = current.getInnerNode().orElse(null);
- }
+ public LLPNodeChain chain() {
+ return nodeChain;
}
/**
@@ -152,7 +67,7 @@ public String toString() {
return "LLPFrame{" +
"crc=" + crc +
", timestamp=" + timestamp +
- ", nodes=" + getNodes() +
+ ", nodes=" + nodeChain.size() +
'}';
}
@@ -168,7 +83,7 @@ public boolean equals(Object o) {
if (!(o instanceof LLPFrame that)) return false;
return crc == that.crc &&
- Objects.equals(content, that.content);
+ Objects.equals(nodeChain, that.nodeChain);
}
/**
@@ -176,6 +91,6 @@ public boolean equals(Object o) {
*/
@Override
public int hashCode() {
- return Objects.hash(content, crc);
+ return Objects.hash(nodeChain, crc);
}
}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java b/src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java
new file mode 100644
index 0000000..60e905b
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java
@@ -0,0 +1,23 @@
+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;
+
+public final class LLPLayerRegistry {
+ private static final Map parsers = new HashMap<>();
+
+ static {
+ ServiceLoader loader = ServiceLoader.load(LLPLayerParser.class);
+ for (LLPLayerParser parser : loader) {
+ parsers.put(parser.getLayerId(), parser);
+ }
+ }
+
+ public static Optional get(int id) {
+ return Optional.ofNullable(parsers.get(id));
+ }
+}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNode.java b/src/main/java/com/flamingo/comm/llp/core/LLPNode.java
deleted file mode 100644
index 308f266..0000000
--- a/src/main/java/com/flamingo/comm/llp/core/LLPNode.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.flamingo.comm.llp.core;
-
-import java.util.Optional;
-
-/**
- * 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 and delegating
- * further parsing to its inner node if present.
- *
- * Implementations of this interface should be immutable and thread-safe.
- */
-public interface LLPNode {
-
- /**
- * Determines whether this node is terminal.
- *
- * A node is considered terminal if:
- *
- * - It has no inner node
- * - Its inner node is the {@link FinalNode}
- *
- *
- * @return {@code true} if this is the last meaningful layer, {@code false} otherwise
- */
- default boolean isTerminal() {
- return getInnerNode().isEmpty() ||
- getInnerNode().get().getId() == FinalNode.ID;
- }
-
- /**
- * 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 (0-255)
- */
- int getId();
-
- /**
- * Indicates whether this layer can be safely skipped if not recognized.
- *
- * If {@code true}, a parser that does not support this layer may ignore it
- * and continue processing the inner payload.
- *
- * If {@code false}, the parser should fail if the layer is not supported,
- * as it may alter the payload semantics.
- *
- * @return {@code true} if skippable, {@code false} otherwise
- */
- boolean isSkippable();
-
- /**
- * Returns the inner node (next layer).
- *
- * If present, the inner node represents the next layer in the hierarchy.
- *
- * @return optional inner node
- */
- Optional getInnerNode();
-}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java b/src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java
new file mode 100644
index 0000000..f766546
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java
@@ -0,0 +1,161 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+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 LLPNodeChain implements Iterable {
+
+ 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)
+ */
+ LLPNodeChain(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 LLPNodeVisitor} 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) {
+ LLPNodeVisitor visitor = new LLPNodeVisitor();
+ 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();
+ }
+
+ /**
+ * Builder for constructing {@link LLPNodeChain} instances incrementally.
+ *
+ * This builder is mutable and intended to be used during parsing or frame construction.
+ * Once {@link #build()} is called, the resulting {@link LLPNodeChain} is immutable.
+ */
+ public static class Builder {
+
+ private final List nodes = new ArrayList<>();
+
+ /**
+ * 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) {
+ nodes.add(node);
+ return this;
+ }
+
+ /**
+ * Builds an immutable {@link LLPNodeChain} from the current state.
+ *
+ * @return a new immutable node chain
+ */
+ public LLPNodeChain build() {
+ return new LLPNodeChain(nodes);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java b/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
index 39a38c7..7747265 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
@@ -1,5 +1,7 @@
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;
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..adec99f
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
@@ -0,0 +1,49 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.spi.LLPNode;
+
+import java.util.Arrays;
+
+/**
+ * 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 final int id;
+ private final byte[] metadata;
+
+ UnknownNode(int id, byte[] metadata) {
+ this.id = id;
+ this.metadata = (metadata != null) ? Arrays.copyOf(metadata, metadata.length) : new byte[0];
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * Returns raw metadata bytes associated with this unknown layer.
+ *
+ * @return metadata copy (never null)
+ */
+ public byte[] getMetadata() {
+ return Arrays.copyOf(metadata, metadata.length);
+ }
+
+ @Override
+ public String toString() {
+ return "UnknownNode{" +
+ "id=" + id +
+ ", metadataLength=" + metadata.length +
+ '}';
+ }
+}
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..0da91f8
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
@@ -0,0 +1,101 @@
+package com.flamingo.comm.llp.spi;
+
+/**
+ * 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 java.util.ServiceLoader} mechanism.
+ *
+ *
+ * Responsibilities
+ *
+ * - Declare the layer identifier via {@link #getLayerId()}.
+ * - Parse raw metadata and payload 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
+ * correctly extracted according to the protocol format.
+ * - The implementation must not modify the provided byte arrays.
+ * - If parsing fails, the implementation should throw a runtime exception
+ * or return a fallback node, depending on the design choice.
+ *
+ *
+ * Example
+ * {@code
+ * public class EncryptionLayerParser implements LLPLayerParser {
+ *
+ * @Override
+ * public int getLayerId() {
+ * return 10;
+ * }
+ *
+ * @Override
+ * public LLPNode parse(byte[] metadata, byte[] payload) {
+ * // Interpret metadata (e.g., algorithm, IV, etc.)
+ * return new EncryptionNode(metadata, payload);
+ * }
+ * }
+ * }
+ *
+ *
+ * The returned {@link LLPNode} will be integrated into the {@code LLPNodeChain}
+ * by the core parser.
+ *
+ *
+ * @see LLPNode
+ * @see java.util.ServiceLoader
+ */
+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 is responsible for extracting the metadata and payload
+ * based on the protocol specification:
+ *
+ *
+ *
+ * [LAYER_ID][METADATA_LENGTH][METADATA][PAYLOAD]
+ *
+ *
+ *
+ * This method should interpret the metadata and construct an appropriate
+ * {@link LLPNode} implementation.
+ *
+ *
+ * @param metadata raw metadata bytes (never {@code null}, may be empty)
+ * @param payload raw payload bytes (never {@code null}, may be empty)
+ * @return parsed {@link LLPNode} representing this layer
+ */
+ LLPNode parse(byte[] metadata, byte[] payload);
+}
\ 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();
+}
From 650ab77fe8e22eb3b094fb8e09e380cc516f4e4b Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 16:02:49 -0300
Subject: [PATCH 03/30] Renombradas clases que no necesitan prefijo LLP
---
.../comm/llp/core/LLPLayerRegistry.java | 23 --
.../flamingo/comm/llp/core/LayerRegistry.java | 63 ++++
.../{LLPNodeChain.java => NodeChain.java} | 20 +-
.../{LLPNodeVisitor.java => NodeVisitor.java} | 0
.../comm/llp/LLPFrameBuilderTest.java | 300 ----------------
.../com/flamingo/comm/llp/LLPParserTest.java | 321 ------------------
6 files changed, 73 insertions(+), 654 deletions(-)
delete mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
rename src/main/java/com/flamingo/comm/llp/core/{LLPNodeChain.java => NodeChain.java} (86%)
rename src/main/java/com/flamingo/comm/llp/core/{LLPNodeVisitor.java => NodeVisitor.java} (100%)
delete mode 100644 src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java
delete mode 100644 src/test/java/com/flamingo/comm/llp/LLPParserTest.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java b/src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java
deleted file mode 100644
index 60e905b..0000000
--- a/src/main/java/com/flamingo/comm/llp/core/LLPLayerRegistry.java
+++ /dev/null
@@ -1,23 +0,0 @@
-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;
-
-public final class LLPLayerRegistry {
- private static final Map parsers = new HashMap<>();
-
- static {
- ServiceLoader loader = ServiceLoader.load(LLPLayerParser.class);
- for (LLPLayerParser parser : loader) {
- parsers.put(parser.getLayerId(), parser);
- }
- }
-
- public static Optional get(int id) {
- return Optional.ofNullable(parsers.get(id));
- }
-}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java b/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
new file mode 100644
index 0000000..45a444a
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
@@ -0,0 +1,63 @@
+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;
+
+/**
+ * Default registry of {@link LLPLayerParser} implementations discovered via Java SPI.
+ *
+ * This class uses {@link ServiceLoader} to automatically load all available
+ * implementations of {@link LLPLayerParser} present on the classpath at runtime.
+ *
+ * Each parser is indexed by its unique layer identifier, as defined by
+ * {@link LLPLayerParser#getLayerId()}.
+ *
+ * This registry is typically used as the default {@link LayerParserProvider}
+ * in the LLP core parser, enabling a plugin-based architecture where external
+ * libraries can contribute new protocol layers.
+ *
+ * Important considerations:
+ *
+ * - Layer IDs must be unique across all loaded parsers
+ * - If multiple parsers declare the same ID, the last one loaded will overwrite the previous
+ * - Parsers are loaded once at class initialization time
+ *
+ *
+ * This class is thread-safe for read operations after initialization.
+ */
+public final class LayerRegistry {
+
+ private static final Map parsers = new HashMap<>();
+
+ static {
+ ServiceLoader loader = ServiceLoader.load(LLPLayerParser.class);
+ for (LLPLayerParser parser : loader) {
+
+ int parserId = parser.getLayerId();
+ if (parsers.containsKey(parserId)) {
+ throw new IllegalStateException("Duplicate layer ID: " + parserId);
+ }
+
+ parsers.put(parserId, parser);
+ }
+ }
+
+ private LayerRegistry() {
+ // Utility class - no instances allowed
+ }
+
+ /**
+ * Returns the parser associated with the given layer ID.
+ *
+ * @param id the layer identifier
+ * @return an {@link Optional} containing the parser if found,
+ * or empty if no parser is registered for the given ID
+ */
+ public static Optional get(int id) {
+ return Optional.ofNullable(parsers.get(id));
+ }
+}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java b/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
similarity index 86%
rename from src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java
rename to src/main/java/com/flamingo/comm/llp/core/NodeChain.java
index f766546..61c2f40 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPNodeChain.java
+++ b/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
@@ -20,7 +20,7 @@
*
* This class is immutable and thread-safe.
*/
-public final class LLPNodeChain implements Iterable {
+public final class NodeChain implements Iterable {
private final List nodes;
@@ -31,7 +31,7 @@ public final class LLPNodeChain implements Iterable {
*
* @param nodes ordered list of nodes (outer → inner)
*/
- LLPNodeChain(List nodes) {
+ NodeChain(List nodes) {
this.nodes = List.copyOf(nodes);
}
@@ -100,13 +100,13 @@ public LLPNode getDeepestNode() {
/**
* Traverses all nodes using a visitor pattern.
*
- * A {@link LLPNodeVisitor} is configured using the provided consumer,
+ *
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) {
- LLPNodeVisitor visitor = new LLPNodeVisitor();
+ public void visit(Consumer consumer) {
+ NodeVisitor visitor = new NodeVisitor();
consumer.accept(visitor);
for (LLPNode node : nodes) {
@@ -127,10 +127,10 @@ public Iterator iterator() {
}
/**
- * Builder for constructing {@link LLPNodeChain} instances incrementally.
+ * 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 LLPNodeChain} is immutable.
+ * Once {@link #build()} is called, the resulting {@link NodeChain} is immutable.
*/
public static class Builder {
@@ -150,12 +150,12 @@ public Builder add(LLPNode node) {
}
/**
- * Builds an immutable {@link LLPNodeChain} from the current state.
+ * Builds an immutable {@link NodeChain} from the current state.
*
* @return a new immutable node chain
*/
- public LLPNodeChain build() {
- return new LLPNodeChain(nodes);
+ public NodeChain build() {
+ return new NodeChain(nodes);
}
}
}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java b/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
similarity index 100%
rename from src/main/java/com/flamingo/comm/llp/core/LLPNodeVisitor.java
rename to src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
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
From ba331ce8a42e11a4828f05554eb7d825d03c58bf Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 16:03:19 -0300
Subject: [PATCH 04/30] Renombradas clases que no necesitan prefijo LLP
---
src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java b/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
index 7747265..51b6e6d 100644
--- a/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
+++ b/src/main/java/com/flamingo/comm/llp/core/NodeVisitor.java
@@ -29,7 +29,7 @@
* Note: This is a lightweight alternative to the traditional Visitor pattern,
* designed to avoid boilerplate in extensible layer-based architectures.
*/
-public class LLPNodeVisitor {
+public class NodeVisitor {
private final Map, Consumer>> handlers = new HashMap<>();
@@ -41,7 +41,7 @@ public class LLPNodeVisitor {
* @param node type
* @return this visitor instance (for chaining)
*/
- public LLPNodeVisitor on(Class type, Consumer handler) {
+ public NodeVisitor on(Class type, Consumer handler) {
handlers.put(type, handler);
return this;
}
From fe89667048210ed8cb4525b216fbc1f59e868e02 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 16:05:05 -0300
Subject: [PATCH 05/30] Creado parseador de capa de transporte principal
LLPTransportDeframer y LLPRawFrame para trama sin terminar de parsear
---
.../com/flamingo/comm/llp/core/ErrorCode.java | 46 +++
.../java/com/flamingo/comm/llp/core/LLP.java | 5 +
.../com/flamingo/comm/llp/core/LLPFrame.java | 8 +-
.../flamingo/comm/llp/core/LLPRawFrame.java | 90 +++++
.../comm/llp/core/LLPTransportDeframer.java | 364 ++++++++++++++++++
.../comm/llp/core/LayerParserProvider.java | 35 ++
.../flamingo/comm/llp/util/CRC16CCITT.java | 65 ++++
.../flamingo/comm/llp/util/Statistics.java | 65 ++++
.../llp/core/LLPTransportDeframerTest.java | 235 +++++++++++
9 files changed, 909 insertions(+), 4 deletions(-)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/ErrorCode.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLP.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
create mode 100644 src/main/java/com/flamingo/comm/llp/util/CRC16CCITT.java
create mode 100644 src/main/java/com/flamingo/comm/llp/util/Statistics.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/ErrorCode.java b/src/main/java/com/flamingo/comm/llp/core/ErrorCode.java
new file mode 100644
index 0000000..17bd837
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/ErrorCode.java
@@ -0,0 +1,46 @@
+package com.flamingo.comm.llp.core;
+
+import java.util.Optional;
+
+/**
+ * LLP Parser Error Codes
+ */
+public enum ErrorCode {
+ OK((byte) 0x00, "No error"),
+ CHECKSUM_INVALID((byte) 0x01, "CRC checksum mismatch"),
+ 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;
+
+ ErrorCode(byte code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * Retrieve the error code from a byte
+ *
+ * @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 (ErrorCode err : values()) {
+ if (err.code == code) {
+ return Optional.of(err);
+ }
+ }
+ return Optional.empty();
+ }
+
+ public byte code() {
+ return code;
+ }
+
+ public String description() {
+ return description;
+ }
+}
\ 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..218f12b
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLP.java
@@ -0,0 +1,5 @@
+package com.flamingo.comm.llp.core;
+
+public final class LLP {
+ public static final int MAX_PAYLOAD_SIZE_BYTES = 1024 * 1024; // 1 MB
+}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
index d3a0577..14f284d 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPFrame.java
@@ -14,7 +14,7 @@
*/
public final class LLPFrame {
- private final LLPNodeChain nodeChain;
+ private final NodeChain nodeChain;
private final int crc;
private final long timestamp;
@@ -24,7 +24,7 @@ public final class LLPFrame {
* @param nodeChain nested nodes
* @param crc calculated CRC value
*/
- LLPFrame(LLPNodeChain nodeChain, int crc) {
+ LLPFrame(NodeChain nodeChain, int crc) {
this(nodeChain, crc, System.currentTimeMillis());
}
@@ -35,7 +35,7 @@ public final class LLPFrame {
* @param crc calculated CRC value
* @param timestamp creation timestamp (milliseconds)
*/
- LLPFrame(LLPNodeChain nodeChain, int crc, long timestamp) {
+ LLPFrame(NodeChain nodeChain, int crc, long timestamp) {
this.nodeChain = nodeChain;
this.crc = crc;
this.timestamp = timestamp;
@@ -55,7 +55,7 @@ public long timestamp() {
return timestamp;
}
- public LLPNodeChain chain() {
+ public NodeChain chain() {
return nodeChain;
}
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..578d2c3
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
@@ -0,0 +1,90 @@
+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 final ByteBuffer 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(payload, payload.length, crc, timestamp);
+ }
+
+ /**
+ * 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
+ */
+ LLPRawFrame(byte[] payload, int payloadLen, int crc, long timestamp) {
+ byte[] safePayload = payload != null ? Arrays.copyOf(payload, payloadLen) : new byte[0];
+
+ // Wrap + read-only view
+ this.payload = ByteBuffer.wrap(safePayload).asReadOnlyBuffer();
+ this.crc = crc;
+ this.timestamp = timestamp;
+ }
+
+ /**
+ * 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 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/core/LLPTransportDeframer.java b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
new file mode 100644
index 0000000..0466416
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
@@ -0,0 +1,364 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.util.CRC16CCITT;
+import com.flamingo.comm.llp.util.Statistics;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Transport-layer state machine responsible for deframing LLP byte streams.
+ *
+ * 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 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 final byte[] headerBuf = new byte[4];
+ private final byte[] payload;
+ private final long timeoutMs;
+
+ private final Queue frameQueue = new ConcurrentLinkedQueue<>();
+ private final Queue listeners = new ConcurrentLinkedQueue<>();
+ private final Statistics statistics = new Statistics();
+
+ private State state = State.WAIT_MAGIC1;
+ private boolean escapePending = false;
+
+ private int payloadLen = 0;
+ private int payloadIdx = 0;
+ private int crcReceived = 0;
+ private int crcCalculated = 0xFFFF;
+
+ private long lastByteTime = System.currentTimeMillis();
+
+ /**
+ * Creates a deframer with default configuration.
+ */
+ public LLPTransportDeframer() {
+ this(LLP.MAX_PAYLOAD_SIZE_BYTES, DEFAULT_TIMEOUT_MS);
+ }
+
+ /**
+ * Creates a deframer with a custom maximum payload size.
+ *
+ * @param maxPayload maximum allowed payload size in bytes
+ */
+ public LLPTransportDeframer(int maxPayload) {
+ this(maxPayload, DEFAULT_TIMEOUT_MS);
+ }
+
+ /**
+ * Creates a deframer with custom configuration.
+ *
+ * @param maxPayload maximum allowed payload size in bytes
+ * @param timeoutMs timeout in milliseconds between bytes before resetting the parser
+ */
+ public LLPTransportDeframer(int maxPayload, long timeoutMs) {
+ if (maxPayload < 1) {
+ maxPayload = LLP.MAX_PAYLOAD_SIZE_BYTES;
+ }
+
+ if (timeoutMs < 1) {
+ timeoutMs = DEFAULT_TIMEOUT_MS;
+ }
+
+ this.payload = new byte[maxPayload];
+ this.timeoutMs = timeoutMs;
+ }
+
+ /**
+ * 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 completed {@link LLPRawFrame}, or {@code null} if the frame is not yet complete
+ */
+ 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(ErrorCode.TIMEOUT);
+
+ // Allow immediate resync if current byte starts a new frame
+ if (b == MAGIC_1) {
+ state = State.WAIT_MAGIC2;
+ }
+ return null;
+ }
+ }
+
+ lastByteTime = System.currentTimeMillis();
+
+ // ================= 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 inside payload)
+ logger.warn("Overlapped frame detected, resynchronizing");
+ statistics.recordError();
+ notifyError(ErrorCode.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_LEN_L;
+ return null;
+
+ } else if (b == 0x00) {
+ // Escaped MAGIC_1 restored
+ b = MAGIC_1;
+
+ } else {
+ logger.error("Invalid escape sequence: 0xAA followed by 0x{}",
+ Integer.toHexString(b & 0xFF));
+ statistics.recordError();
+ reset();
+ notifyError(ErrorCode.SYNC_ERROR);
+ return null;
+ }
+
+ } else if (b == MAGIC_1) {
+ escapePending = true;
+ return null;
+ }
+ }
+ // ============================================================
+
+ switch (state) {
+
+ case WAIT_MAGIC1:
+ if (b == MAGIC_1) {
+ headerBuf[0] = b;
+ state = State.WAIT_MAGIC2;
+ }
+ break;
+
+ 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_LEN_L;
+
+ } else if (b == MAGIC_1) {
+ // Stay in sync (robustness against repeated MAGIC_1)
+ state = State.WAIT_MAGIC2;
+
+ } else {
+ state = State.WAIT_MAGIC1;
+ }
+ break;
+
+ case READ_LEN_L:
+ headerBuf[2] = b;
+ crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
+ state = State.READ_LEN_H;
+ break;
+
+ case READ_LEN_H:
+ headerBuf[3] = b;
+ crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
+
+ 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(ErrorCode.PAYLOAD_LEN_INVALID);
+ return null;
+ }
+
+ payloadIdx = 0;
+ state = (payloadLen == 0) ? State.READ_CRC_L : State.READ_PAYLOAD;
+ break;
+
+ case READ_PAYLOAD:
+ payload[payloadIdx++] = b;
+ crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
+
+ if (payloadIdx == payloadLen) {
+ state = State.READ_CRC_L;
+ }
+ break;
+
+ case READ_CRC_L:
+ crcReceived = (b & 0xFF);
+ state = State.READ_CRC_H;
+ break;
+
+ case READ_CRC_H:
+ crcReceived |= ((b & 0xFF) << 8);
+
+ if (crcReceived != crcCalculated) {
+ logger.error("CRC mismatch: received=0x{}, calculated=0x{}",
+ Integer.toHexString(crcReceived),
+ Integer.toHexString(crcCalculated));
+ statistics.recordError();
+ reset();
+ notifyError(ErrorCode.CHECKSUM_INVALID);
+ return null;
+ }
+
+ LLPRawFrame frame = new LLPRawFrame(payload, payloadLen, crcCalculated, System.currentTimeMillis());
+
+ statistics.recordSuccess();
+ reset();
+
+ notifySuccess(frame);
+ frameQueue.offer(frame);
+
+ return frame;
+ }
+
+ return null;
+ }
+
+ /**
+ * 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) {
+ LLPRawFrame frame = processByte(b);
+ if (frame != null) {
+ frames.add(frame);
+ }
+ }
+ return frames;
+ }
+
+ /**
+ * Resets the internal state machine to its initial synchronization state.
+ */
+ private void reset() {
+ state = State.WAIT_MAGIC1;
+ payloadIdx = 0;
+ crcCalculated = 0xFFFF;
+ escapePending = false;
+ }
+
+ /**
+ * Registers a listener to receive frame events.
+ *
+ * @param listener listener to add
+ */
+ public void addListener(LLPFrameListener listener) {
+ listeners.offer(listener);
+ }
+
+ /**
+ * Removes a previously registered listener.
+ *
+ * @param listener listener to remove
+ */
+ public void removeListener(LLPFrameListener listener) {
+ listeners.remove(listener);
+ }
+
+ /**
+ * Returns the queue containing parsed frames.
+ *
+ * @return concurrent queue of frames
+ */
+ public Queue getFrameQueue() {
+ return frameQueue;
+ }
+
+ /**
+ * Returns runtime statistics of the deframer.
+ *
+ * @return statistics instance
+ */
+ public Statistics getStatistics() {
+ return statistics;
+ }
+
+ private void notifySuccess(LLPRawFrame frame) {
+ for (LLPFrameListener listener : listeners) {
+ try {
+ listener.onFrameReceived(frame);
+ } catch (Exception e) {
+ logger.error("Listener error", e);
+ }
+ }
+ }
+
+ private void notifyError(ErrorCode errorCode) {
+ for (LLPFrameListener listener : listeners) {
+ try {
+ listener.onFrameError(errorCode);
+ } catch (Exception e) {
+ logger.error("Listener error", e);
+ }
+ }
+ }
+
+ private enum State {
+ WAIT_MAGIC1,
+ WAIT_MAGIC2,
+ READ_LEN_L,
+ READ_LEN_H,
+ READ_PAYLOAD,
+ READ_CRC_L,
+ READ_CRC_H
+ }
+
+ /**
+ * Listener interface for receiving deframer events.
+ */
+ public interface LLPFrameListener {
+
+ /**
+ * Invoked when a valid frame is successfully parsed.
+ *
+ * @param frame parsed frame
+ */
+ void onFrameReceived(LLPRawFrame frame);
+
+ /**
+ * Invoked when a frame parsing error occurs.
+ *
+ * @param errorCode error type
+ */
+ void onFrameError(ErrorCode errorCode);
+ }
+}
\ 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..4ba6dca
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
@@ -0,0 +1,35 @@
+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.
+ *
+ * Typical usage includes:
+ *
+ * - Default SPI-based lookup using {@link LayerRegistry}
+ * - Custom providers for testing or controlled environments
+ *
+ *
+ * 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/util/CRC16CCITT.java b/src/main/java/com/flamingo/comm/llp/util/CRC16CCITT.java
new file mode 100644
index 0000000..b30f8ff
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/util/CRC16CCITT.java
@@ -0,0 +1,65 @@
+package com.flamingo.comm.llp.util;
+
+/**
+ * Implementation of CRC16-CCITT.
+ *
+ * Polynomial: 0x1021
+ * Initial value: 0xFFFF
+ *
+ * Optimized calculation using a pre-calculated table (optional).
+ */
+public class CRC16CCITT {
+ private static final int POLYNOMIAL = 0x1021;
+ private static final int INITIAL_VALUE = 0xFFFF;
+ private static final int[] CRC_TABLE = buildTable();
+
+ /**
+ * Build a precomputed CRC16 table for better performance
+ */
+ private static int[] buildTable() {
+ int[] table = new int[256];
+ for (int i = 0; i < 256; i++) {
+ int crc = i << 8;
+ for (int j = 0; j < 8; j++) {
+ crc = (crc & 0x8000) != 0
+ ? ((crc << 1) ^ POLYNOMIAL) & 0xFFFF
+ : (crc << 1) & 0xFFFF;
+ }
+ table[i] = crc;
+ }
+ return table;
+ }
+
+ /**
+ * Calculate the CRC16 of an entire buffer
+ */
+ public static int calculate(byte[] data) {
+ return calculate(data, 0, data.length);
+ }
+
+ /**
+ * Calculate the CRC16 of a range of bytes
+ */
+ public static int calculate(byte[] data, int offset, int length) {
+ int crc = INITIAL_VALUE;
+ for (int i = 0; i < length; i++) {
+ crc = updateCRC(crc, data[offset + i]);
+ }
+ return crc;
+ }
+
+ /**
+ * Update CRC16 with an additional byte (for incremental calculation)
+ */
+ public static int updateCRC(int crc, byte data) {
+ int index = ((crc >> 8) ^ (data & 0xFF)) & 0xFF;
+ return ((crc << 8) ^ CRC_TABLE[index]) & 0xFFFF;
+ }
+
+ /**
+ * Check whether the data's CRC matches the expected value
+ */
+ public static boolean verify(byte[] data, int expectedCRC) {
+ return calculate(data) == expectedCRC;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/util/Statistics.java b/src/main/java/com/flamingo/comm/llp/util/Statistics.java
new file mode 100644
index 0000000..fa59751
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/util/Statistics.java
@@ -0,0 +1,65 @@
+package com.flamingo.comm.llp.util;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * LLP parser statistics.
+ * Thread-safe with AtomicLong.
+ */
+public class Statistics {
+ private final AtomicLong framesOk = new AtomicLong(0);
+ private final AtomicLong framesError = new AtomicLong(0);
+ private final AtomicLong timeouts = new AtomicLong(0);
+ private final long createdAt = System.currentTimeMillis();
+
+ public void recordSuccess() {
+ framesOk.incrementAndGet();
+ }
+
+ public void recordError() {
+ framesError.incrementAndGet();
+ }
+
+ public void recordTimeout() {
+ timeouts.incrementAndGet();
+ }
+
+ public long getFramesOk() {
+ return framesOk.get();
+ }
+
+ public long getFramesError() {
+ return framesError.get();
+ }
+
+ public long getTimeouts() {
+ return timeouts.get();
+ }
+
+ public long getTotalFrames() {
+ return framesOk.get() + framesError.get();
+ }
+
+ public double getSuccessRate() {
+ long total = getTotalFrames();
+ return total == 0 ? 0.0 : (double) framesOk.get() / total * 100.0;
+ }
+
+ public long getUptimeMs() {
+ return System.currentTimeMillis() - createdAt;
+ }
+
+ public void reset() {
+ framesOk.set(0);
+ framesError.set(0);
+ timeouts.set(0);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Statistics{framesOk=%d, framesError=%d, timeouts=%d, successRate=%.2f%%, uptimeMs=%d}",
+ getFramesOk(), getFramesError(), getTimeouts(), getSuccessRate(), getUptimeMs()
+ );
+ }
+}
\ 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..b38e1f5
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
@@ -0,0 +1,235 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.LLP;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LLPTransportDeframerTest {
+
+ private LLPTransportDeframer deframer;
+
+ @BeforeEach
+ void setUp() {
+ deframer = new LLPTransportDeframer();
+ }
+
+ @Test
+ void testSingleFrame() {
+ byte[] payload = new byte[]{0x01, 0x02, 0x03};
+ byte[] frame = LLP.buildData(1, 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 = 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) {
+ if (deframer.processByte(b) != null) {
+ count++;
+ }
+ }
+
+ assertEquals(2, count);
+ }
+
+ @Test
+ void testFragmentedFrame() {
+ byte[] frame = LLP.buildPing(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 = LLP.buildPing(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 = LLP.buildPing(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 = LLP.buildPing(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 testMaxPayload() {
+ byte[] payload = new byte[LLP.MAX_PAYLOAD_SIZE_BYTES];
+ byte[] frame = LLP.buildData(1, payload);
+
+ LLPRawFrame result = null;
+
+ for (byte b : frame) {
+ LLPRawFrame f = deframer.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+
+ ByteBuffer buf = result.payload();
+ assertEquals(payload.length, buf.remaining());
+ }
+
+ @Test
+ void testStuffedPayload() {
+ byte[] payload = new byte[]{
+ 0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
+ };
+
+ byte[] frame = LLP.buildData(1, 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 = LLP.buildPing(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 = 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);
+
+ 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 = LLP.buildData(i, 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
From 6e8f44219249bb47fe1c2147dcf803cb79afe80e Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 19:37:35 -0300
Subject: [PATCH 06/30] Creada clase LLPTransportFramer para logica de armado
de tramas con stuff y crc
---
.../comm/llp/core/LLPTransportFramer.java | 136 ++++++++++++++++++
.../flamingo/comm/llp/util/ByteWriter.java | 6 +
2 files changed, 142 insertions(+)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
create mode 100644 src/main/java/com/flamingo/comm/llp/util/ByteWriter.java
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..860235c
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
@@ -0,0 +1,136 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.util.ByteWriter;
+import com.flamingo.comm.llp.util.CRC16CCITT;
+
+/**
+ * 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
+ }
+
+ 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 the LLP frame into a pre-allocated byte array.
+ *
+ * @param payload The raw payload to wrap (can be null for empty payload).
+ * @param outBuffer The destination buffer.
+ * @param offset The starting index in the destination buffer.
+ * @return The total number of bytes written to the outBuffer.
+ * @throws IllegalArgumentException if the 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:
+ // Magic(2) + StuffedLen(4) + StuffedPayload(len*2) + StuffedCRC(4)
+ int maxSize = offset + 2 + 4 + (payloadLen * 2) + 4;
+
+ 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 the LLP frame using a custom ByteWriter implementation.
+ * Useful for direct writes to ByteBuffers or streams.
+ *
+ * @param payload The raw payload.
+ * @param writer The custom writer interface.
+ * @return The total number of bytes written.
+ */
+ public static int build(byte[] payload, ByteWriter writer) {
+ return buildInternal(payload, writer);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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/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);
+}
From ee2a73135d011842861168fd580d35f90de60966 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 21:54:50 -0300
Subject: [PATCH 07/30] Mejorado Javadoc de framer y creado metodo buildSafe.
Eliminada cola de frames en deframer. Actualizado test de deframer
---
.../java/com/flamingo/comm/llp/core/LLP.java | 2 +-
.../comm/llp/core/LLPTransportDeframer.java | 30 +--
.../comm/llp/core/LLPTransportFramer.java | 171 ++++++++++++++++--
.../llp/core/LLPTransportDeframerTest.java | 90 ++++++---
4 files changed, 237 insertions(+), 56 deletions(-)
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLP.java b/src/main/java/com/flamingo/comm/llp/core/LLP.java
index 218f12b..9cda66a 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLP.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLP.java
@@ -1,5 +1,5 @@
package com.flamingo.comm.llp.core;
public final class LLP {
- public static final int MAX_PAYLOAD_SIZE_BYTES = 1024 * 1024; // 1 MB
+
}
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
index 0466416..a58c843 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
@@ -32,12 +32,12 @@ public final class LLPTransportDeframer {
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[4];
private final byte[] payload;
private final long timeoutMs;
- private final Queue frameQueue = new ConcurrentLinkedQueue<>();
private final Queue listeners = new ConcurrentLinkedQueue<>();
private final Statistics statistics = new Statistics();
@@ -55,34 +55,34 @@ public final class LLPTransportDeframer {
* Creates a deframer with default configuration.
*/
public LLPTransportDeframer() {
- this(LLP.MAX_PAYLOAD_SIZE_BYTES, DEFAULT_TIMEOUT_MS);
+ this(DEFAULT_MAX_PAYLOAD_SIZE_BYTES, DEFAULT_TIMEOUT_MS);
}
/**
* Creates a deframer with a custom maximum payload size.
*
- * @param maxPayload maximum allowed payload size in bytes
+ * @param maxPayloadBytes maximum allowed payload size in bytes
*/
- public LLPTransportDeframer(int maxPayload) {
- this(maxPayload, DEFAULT_TIMEOUT_MS);
+ public LLPTransportDeframer(int maxPayloadBytes) {
+ this(maxPayloadBytes, DEFAULT_TIMEOUT_MS);
}
/**
* Creates a deframer with custom configuration.
*
- * @param maxPayload maximum allowed payload size in bytes
+ * @param maxPayloadBytes maximum allowed payload size in bytes
* @param timeoutMs timeout in milliseconds between bytes before resetting the parser
*/
- public LLPTransportDeframer(int maxPayload, long timeoutMs) {
- if (maxPayload < 1) {
- maxPayload = LLP.MAX_PAYLOAD_SIZE_BYTES;
+ 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;
}
@@ -241,7 +241,6 @@ public LLPRawFrame processByte(byte b) {
reset();
notifySuccess(frame);
- frameQueue.offer(frame);
return frame;
}
@@ -294,15 +293,6 @@ public void removeListener(LLPFrameListener listener) {
listeners.remove(listener);
}
- /**
- * Returns the queue containing parsed frames.
- *
- * @return concurrent queue of frames
- */
- public Queue getFrameQueue() {
- return frameQueue;
- }
-
/**
* Returns runtime statistics of the deframer.
*
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java b/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
index 860235c..6813884 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportFramer.java
@@ -3,6 +3,8 @@
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.
@@ -16,6 +18,41 @@ 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];
@@ -61,21 +98,45 @@ private static int buildInternal(byte[] payload, ByteWriter writer) {
}
/**
- * Builds the LLP frame into a pre-allocated byte array.
+ * 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
+ *
*
- * @param payload The raw payload to wrap (can be null for empty payload).
- * @param outBuffer The destination buffer.
- * @param offset The starting index in the destination buffer.
- * @return The total number of bytes written to the outBuffer.
- * @throws IllegalArgumentException if the outBuffer is too small for the worst-case scenario.
+ * 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:
- // Magic(2) + StuffedLen(4) + StuffedPayload(len*2) + StuffedCRC(4)
- int maxSize = offset + 2 + 4 + (payloadLen * 2) + 4;
+ // Worst-case calculation
+ int maxSize = offset + estimateMaxSize(payloadLen);
if (outBuffer.length < maxSize) {
throw new IllegalArgumentException(
@@ -88,17 +149,84 @@ public static int build(byte[] payload, byte[] outBuffer, int offset) {
}
/**
- * Builds the LLP frame using a custom ByteWriter implementation.
- * Useful for direct writes to ByteBuffers or streams.
+ * 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.
*
- * @param payload The raw payload.
- * @param writer The custom writer interface.
- * @return The total number of bytes written.
+ *
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.
*/
@@ -111,6 +239,21 @@ private static int writeStuffed(ByteWriter writer, byte b) {
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.
*/
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
index b38e1f5..b495a9a 100644
--- a/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
@@ -1,12 +1,13 @@
package com.flamingo.comm.llp.core;
-import com.flamingo.comm.llp.LLP;
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.*;
@@ -16,13 +17,19 @@ class LLPTransportDeframerTest {
@BeforeEach
void setUp() {
- deframer = new LLPTransportDeframer();
+ 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 = LLP.buildData(1, payload);
+ byte[] frame = buildFrame(payload);
LLPRawFrame result = null;
@@ -42,8 +49,8 @@ void testSingleFrame() {
@Test
void testMultipleFramesBackToBack() {
- byte[] f1 = LLP.buildPing(1);
- byte[] f2 = LLP.buildPing(2);
+ 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);
@@ -62,7 +69,7 @@ void testMultipleFramesBackToBack() {
@Test
void testFragmentedFrame() {
- byte[] frame = LLP.buildPing(42);
+ byte[] frame = buildFrame(new byte[]{42});
LLPRawFrame result = null;
@@ -81,7 +88,7 @@ void testFragmentedFrame() {
@Test
void testNoiseBeforeFrame() {
byte[] noise = new byte[]{0x00, 0x13, 0x7F, 0x55};
- byte[] frame = LLP.buildPing(7);
+ byte[] frame = buildFrame(new byte[]{7});
for (byte b : noise) {
deframer.processByte(b);
@@ -99,7 +106,7 @@ void testNoiseBeforeFrame() {
@Test
void testInvalidCRC() {
- byte[] frame = LLP.buildPing(1);
+ byte[] frame = buildFrame(new byte[]{1});
// Corrupt CRC
frame[frame.length - 1] ^= 0xFF;
@@ -117,7 +124,7 @@ void testInvalidCRC() {
@Test
void testTimeoutResetsParser() throws InterruptedException {
- byte[] frame = LLP.buildPing(10);
+ byte[] frame = buildFrame(new byte[]{10});
for (int i = 0; i < frame.length / 2; i++) {
deframer.processByte(frame[i]);
@@ -137,21 +144,62 @@ void testTimeoutResetsParser() throws InterruptedException {
}
@Test
- void testMaxPayload() {
- byte[] payload = new byte[LLP.MAX_PAYLOAD_SIZE_BYTES];
- byte[] frame = LLP.buildData(1, payload);
+ 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(ErrorCode errorCode) {
+ if (errorCode == ErrorCode.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 : frame) {
+ for (byte b : invalid) {
+ deframer.processByte(b);
+ }
+
+ for (byte b : valid) {
LLPRawFrame f = deframer.processByte(b);
if (f != null) result = f;
}
assertNotNull(result);
-
- ByteBuffer buf = result.payload();
- assertEquals(payload.length, buf.remaining());
}
@Test
@@ -160,7 +208,7 @@ void testStuffedPayload() {
0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
};
- byte[] frame = LLP.buildData(1, payload);
+ byte[] frame = buildFrame(payload);
LLPRawFrame result = null;
@@ -180,7 +228,7 @@ void testStuffedPayload() {
@Test
void testInvalidEscapeSequence() {
- byte[] frame = LLP.buildPing(1);
+ byte[] frame = buildFrame(new byte[]{1});
frame[5] = (byte) 0xAA;
frame[6] = (byte) 0x99;
@@ -194,8 +242,8 @@ void testInvalidEscapeSequence() {
@Test
void testProcessBytesBatch() {
- byte[] f1 = LLP.buildPing(1);
- byte[] f2 = LLP.buildPing(2);
+ 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);
@@ -214,7 +262,7 @@ void testRandomFrames() {
byte[] payload = new byte[32];
random.nextBytes(payload);
- byte[] frame = LLP.buildData(i, payload);
+ byte[] frame = buildFrame(payload);
LLPRawFrame result = null;
From 1cf8331ff7a9d59019425f3553a5c4eef320c14d Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 23:00:20 -0300
Subject: [PATCH 08/30] Creado test case para LLPTransportFramer
---
.../comm/llp/core/LLPTransportFramerTest.java | 290 ++++++++++++++++++
1 file changed, 290 insertions(+)
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
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..24749e5
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
@@ -0,0 +1,290 @@
+package com.flamingo.comm.llp.core;
+
+import com.flamingo.comm.llp.util.CRC16CCITT;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+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);
+ }
+ }
+
+ // ================= 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);
+
+ assertNotNull(frame);
+ assertTrue(frame.length > payload.length);
+ }
+
+ // ================= 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");
+ }
+}
From 08c1cb108ac5798a759e75029ba7974b8acc8926 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 12 Apr 2026 23:18:55 -0300
Subject: [PATCH 09/30] =?UTF-8?q?A=C3=B1adidos=20nuevos=20casos=20de=20tes?=
=?UTF-8?q?teo=20de=20ByteWriter=20en=20LLPTransportFramerTest?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../comm/llp/core/LLPTransportFramerTest.java | 214 ++++++++++++++++++
1 file changed, 214 insertions(+)
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
index 24749e5..95ed2ae 100644
--- a/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
@@ -1,10 +1,14 @@
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.*;
@@ -287,4 +291,214 @@ void testLengthIsLittleEndian() {
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");
+ }
+ }
+ }
}
From da530fa73e589fd5cde8594fa4e0e3d0c5b14c54 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Mon, 13 Apr 2026 05:50:49 -0300
Subject: [PATCH 10/30] =?UTF-8?q?A=C3=B1adidos=20tests=20para=20NodeChain?=
=?UTF-8?q?=20y=20FinalNode.=20Mejorados=20tests=20de=20LLPTransportFramer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/flamingo/comm/llp/core/FinalNode.java | 31 ++-
.../flamingo/comm/llp/core/FinalNodeTest.java | 94 +++++++
.../comm/llp/core/LLPTransportFramerTest.java | 25 ++
.../flamingo/comm/llp/core/NodeChainTest.java | 260 ++++++++++++++++++
.../flamingo/comm/llp/core/SpecialNode.java | 7 +
.../com/flamingo/comm/llp/core/TestNode.java | 16 ++
6 files changed, 420 insertions(+), 13 deletions(-)
create mode 100644 src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/TestNode.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
index 2b6f154..135bf00 100644
--- a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
@@ -2,7 +2,7 @@
import com.flamingo.comm.llp.spi.LLPNode;
-import java.util.Arrays;
+import java.nio.ByteBuffer;
import java.util.HexFormat;
import java.util.Locale;
@@ -16,24 +16,26 @@
*/
public final class FinalNode implements LLPNode {
public static final int ID = 0;
- private static final byte[] EMPTY_ARRAY = new byte[0];
-
/**
* Shared instance for empty payload (singleton).
*/
- public static final FinalNode EMPTY = new FinalNode(EMPTY_ARRAY);
+ private static final ByteBuffer EMPTY_ARRAY =
+ ByteBuffer.wrap(new byte[0]).asReadOnlyBuffer();
- private final byte[] payload;
+ public static final FinalNode EMPTY = new FinalNode(EMPTY_ARRAY);
+ private final ByteBuffer payload;
/**
* Creates a FinalNode with payload.
*
* @param payload raw payload (nullable → treated as empty)
*/
- FinalNode(byte[] payload) {
- this.payload = (payload == null || payload.length == 0)
- ? EMPTY_ARRAY
- : Arrays.copyOf(payload, payload.length);
+ private FinalNode(byte[] payload) {
+ this.payload = ByteBuffer.wrap(payload.clone()).asReadOnlyBuffer();
+ }
+
+ private FinalNode(ByteBuffer payload) {
+ this.payload = payload;
}
@Override
@@ -54,16 +56,19 @@ static FinalNode of(byte[] payload) {
/**
* Raw payload sent by the sender
*
- * @return an array of bytes containing the raw payload sent by the sender, or an empty array
+ * @return an immutable array of bytes containing the raw payload sent by the sender, or an empty array
*/
- public byte[] getPayload() {
- return payload;
+ public ByteBuffer getPayload() {
+ return payload.asReadOnlyBuffer();
}
@Override
public String toString() {
+ byte[] bytes = new byte[payload.remaining()];
+ payload.get(bytes);
+
return "FinalNode{" +
- "payloadHex=" + HexFormat.of().formatHex(payload).toUpperCase(Locale.ROOT) +
+ "payloadHex=" + HexFormat.of().formatHex(bytes).toUpperCase(Locale.ROOT) +
'}';
}
}
\ No newline at end of file
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..3b62aee
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
@@ -0,0 +1,94 @@
+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(null);
+ FinalNode node2 = FinalNode.of(new byte[0]);
+
+ assertSame(FinalNode.EMPTY, node1);
+ assertSame(FinalNode.EMPTY, node2);
+ }
+
+ @Test
+ void testNonEmptyCreatesNewInstance() {
+ FinalNode node = FinalNode.of(new byte[]{1, 2, 3});
+
+ assertNotSame(FinalNode.EMPTY, node);
+ }
+
+ @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(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"));
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
index 95ed2ae..24c809d 100644
--- a/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportFramerTest.java
@@ -54,6 +54,20 @@ void testVariousPayloadSizes() {
}
}
+ @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
@@ -185,9 +199,20 @@ 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 =================
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..32e985f
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
@@ -0,0 +1,260 @@
+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());
+ }
+}
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..aa87076
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
@@ -0,0 +1,7 @@
+package com.flamingo.comm.llp.core;
+
+class SpecialNode extends TestNode {
+ SpecialNode(int id) {
+ super(id);
+ }
+}
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;
+ }
+}
From 11d7747dc9b56c0e7d9cd030921937bfb1bce23e Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Mon, 13 Apr 2026 21:44:46 -0300
Subject: [PATCH 11/30] Optimizada clase NodeChain, implementado equals y
hashcode. Actualizados test cases
---
.../com/flamingo/comm/llp/core/NodeChain.java | 33 +++--
.../flamingo/comm/llp/core/NodeChainTest.java | 129 ++++++++++++++++++
2 files changed, 154 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/flamingo/comm/llp/core/NodeChain.java b/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
index 61c2f40..f0af20e 100644
--- a/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
+++ b/src/main/java/com/flamingo/comm/llp/core/NodeChain.java
@@ -2,10 +2,7 @@
import com.flamingo.comm.llp.spi.LLPNode;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
import java.util.function.Consumer;
/**
@@ -21,7 +18,7 @@
* 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;
/**
@@ -31,7 +28,7 @@ public final class NodeChain implements Iterable {
*
* @param nodes ordered list of nodes (outer → inner)
*/
- NodeChain(List nodes) {
+ private NodeChain(List nodes) {
this.nodes = List.copyOf(nodes);
}
@@ -126,6 +123,18 @@ 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.
*
@@ -133,8 +142,7 @@ public Iterator iterator() {
* Once {@link #build()} is called, the resulting {@link NodeChain} is immutable.
*/
public static class Builder {
-
- private final List nodes = new ArrayList<>();
+ private List nodes;
/**
* Adds a node to the chain.
@@ -145,6 +153,11 @@ public static class Builder {
* @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;
}
@@ -155,6 +168,10 @@ public Builder add(LLPNode node) {
* @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);
}
}
diff --git a/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java b/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
index 32e985f..80563eb 100644
--- a/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/NodeChainTest.java
@@ -257,4 +257,133 @@ void testBuilderDoesNotAffectBuiltChain() {
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");
+ }
}
From b896b38e4d782b113d389f6d4a21aeecb322c220 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Mon, 13 Apr 2026 21:46:22 -0300
Subject: [PATCH 12/30] Creados tests ErrorCodeTest y UnknownNodeTest
---
.../flamingo/comm/llp/core/ErrorCodeTest.java | 94 +++++++++++++++
.../comm/llp/core/UnknownNodeTest.java | 111 ++++++++++++++++++
2 files changed, 205 insertions(+)
create mode 100644 src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
diff --git a/src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java b/src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java
new file mode 100644
index 0000000..40465d2
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.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 ErrorCodeTest {
+
+ @Test
+ void testFromCodeValidValues() {
+ for (ErrorCode error : ErrorCode.values()) {
+ Optional result = ErrorCode.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 = ErrorCode.fromCode(invalidCode);
+
+ assertTrue(result.isEmpty(), "Invalid code should return empty Optional");
+ }
+
+ @Test
+ void testFromCodeBoundaryValues() {
+ // Extreme byte values
+ assertTrue(ErrorCode.fromCode(Byte.MIN_VALUE).isEmpty());
+ assertTrue(ErrorCode.fromCode(Byte.MAX_VALUE).isEmpty());
+ }
+
+ @Test
+ void testCodeGetter() {
+ assertEquals((byte) 0x00, ErrorCode.OK.code());
+ assertEquals((byte) 0x01, ErrorCode.CHECKSUM_INVALID.code());
+ assertEquals((byte) 0x02, ErrorCode.PAYLOAD_LEN_INVALID.code());
+ assertEquals((byte) 0x03, ErrorCode.TIMEOUT.code());
+ assertEquals((byte) 0x04, ErrorCode.SYNC_ERROR.code());
+ assertEquals((byte) 0x05, ErrorCode.BUFFER_FULL.code());
+ }
+
+ @Test
+ void testDescriptionGetter() {
+ assertEquals("No error", ErrorCode.OK.description());
+ assertEquals("CRC checksum mismatch", ErrorCode.CHECKSUM_INVALID.description());
+ assertEquals("Payload length exceeds maximum", ErrorCode.PAYLOAD_LEN_INVALID.description());
+ assertEquals("Frame timeout - incomplete frame", ErrorCode.TIMEOUT.description());
+ assertEquals("Synchronization error", ErrorCode.SYNC_ERROR.description());
+ assertEquals("Buffer overflow", ErrorCode.BUFFER_FULL.description());
+ }
+
+ @Test
+ void testCodesAreUnique() {
+ for (ErrorCode e1 : ErrorCode.values()) {
+ for (ErrorCode e2 : ErrorCode.values()) {
+ if (e1 != e2) {
+ assertNotEquals(
+ e1.code(),
+ e2.code(),
+ "Duplicate error code found between " + e1 + " and " + e2
+ );
+ }
+ }
+ }
+ }
+
+ @Test
+ void testFromCodeIsDeterministic() {
+ byte code = ErrorCode.TIMEOUT.code();
+
+ Optional r1 = ErrorCode.fromCode(code);
+ Optional r2 = ErrorCode.fromCode(code);
+
+ assertEquals(r1, r2, "fromCode should be deterministic");
+ }
+
+ @Test
+ void testEnumCoverage() {
+ // Force the execution of `values()` and ensure that there are elements
+ assertTrue(ErrorCode.values().length > 0);
+ }
+
+ @Test
+ void testToStringNotNull() {
+ for (ErrorCode error : ErrorCode.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..b164858
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
@@ -0,0 +1,111 @@
+package com.flamingo.comm.llp.core;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class UnknownNodeTest {
+
+ @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 = node.getMetadata();
+
+ 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 = node.getMetadata();
+
+ 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 = node.getMetadata();
+ byte[] extracted2 = node.getMetadata();
+
+ // 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 = node.getMetadata();
+
+ assertNotNull(metadata);
+ assertEquals(0, metadata.length);
+ }
+
+ @Test
+ void testEmptyMetadata() {
+ UnknownNode node = new UnknownNode(1, new byte[0]);
+
+ byte[] metadata = node.getMetadata();
+
+ 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 = node.getMetadata();
+
+ 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());
+ }
+}
From aa5d2ad54cc4d6ce0e19a8a5e1e8f0a38bf2bc78 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Mon, 13 Apr 2026 21:48:12 -0300
Subject: [PATCH 13/30] Optimizada clase LLPRawFrame y actualizados test cases
---
.../flamingo/comm/llp/core/LLPRawFrame.java | 21 +-
.../flamingo/comm/llp/core/LLPFrameTest.java | 184 ++++++++++++++++++
.../comm/llp/core/LLPRawFrameTest.java | 168 ++++++++++++++++
.../flamingo/comm/llp/core/SpecialNode.java | 7 +
4 files changed, 374 insertions(+), 6 deletions(-)
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java b/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
index 578d2c3..fe16503 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
@@ -15,7 +15,7 @@
* This class is immutable and thread-safe.
*/
public final class LLPRawFrame {
-
+ private static final ByteBuffer EMPTY_ARRAY = ByteBuffer.wrap(new byte[0]).asReadOnlyBuffer();
private final ByteBuffer payload;
private final int crc;
private final long timestamp;
@@ -38,7 +38,14 @@ public final class LLPRawFrame {
* @param timestamp creation timestamp in milliseconds
*/
LLPRawFrame(byte[] payload, int crc, long timestamp) {
- this(payload, payload.length, crc, timestamp);
+ this.crc = crc;
+ this.timestamp = timestamp;
+
+ if (payload == null || payload.length == 0) {
+ this.payload = EMPTY_ARRAY;
+ } else {
+ this.payload = ByteBuffer.wrap(payload.clone()).asReadOnlyBuffer();
+ }
}
/**
@@ -50,12 +57,14 @@ public final class LLPRawFrame {
* @param timestamp creation timestamp in milliseconds
*/
LLPRawFrame(byte[] payload, int payloadLen, int crc, long timestamp) {
- byte[] safePayload = payload != null ? Arrays.copyOf(payload, payloadLen) : new byte[0];
-
- // Wrap + read-only view
- this.payload = ByteBuffer.wrap(safePayload).asReadOnlyBuffer();
this.crc = crc;
this.timestamp = timestamp;
+
+ if (payload == null || payloadLen <= 0) {
+ this.payload = EMPTY_ARRAY;
+ } else {
+ this.payload = ByteBuffer.wrap(Arrays.copyOf(payload, payloadLen)).asReadOnlyBuffer();
+ }
}
/**
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..f020508
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
@@ -0,0 +1,184 @@
+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 = createChain(1, 2);
+ NodeChain chain2 = createChain(1, 2);
+
+ 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/LLPRawFrameTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
new file mode 100644
index 0000000..7edff91
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
@@ -0,0 +1,168 @@
+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);
+ }
+}
\ 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
index aa87076..2708e5e 100644
--- a/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
+++ b/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
@@ -4,4 +4,11 @@ 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();
+ }
}
From 288d287148e7a7b0d6dba5f27738ef0810de565a Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Mon, 13 Apr 2026 22:01:33 -0300
Subject: [PATCH 14/30] Corregido tests de LLPFrameTest
---
.../java/com/flamingo/comm/llp/core/LLPFrameTest.java | 11 +++++++++--
.../java/com/flamingo/comm/llp/core/SpecialNode.java | 7 +++++++
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
index f020508..275c6b7 100644
--- a/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPFrameTest.java
@@ -84,8 +84,15 @@ void testEqualsSameInstance() {
@Test
void testEqualsDifferentObjectsSameContent() {
- NodeChain chain1 = createChain(1, 2);
- NodeChain chain2 = createChain(1, 2);
+ 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
diff --git a/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java b/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
index 2708e5e..fbdaa90 100644
--- a/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
+++ b/src/test/java/com/flamingo/comm/llp/core/SpecialNode.java
@@ -1,5 +1,7 @@
package com.flamingo.comm.llp.core;
+import java.util.Objects;
+
class SpecialNode extends TestNode {
SpecialNode(int id) {
super(id);
@@ -11,4 +13,9 @@ public boolean equals(Object o) {
if (!(o instanceof SpecialNode that)) return false;
return getId() == that.getId();
}
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getId());
+ }
}
From 180ce5e4a44bfd33a3f35956f0cefb34f9bf3131 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Thu, 23 Apr 2026 01:23:33 -0300
Subject: [PATCH 15/30] Correcciones en tratamiendo de datos inmutables de
clases FinalNode, UnknownNode y LLPRawFrame con sus tests
---
.../com/flamingo/comm/llp/core/FinalNode.java | 34 +++--
.../flamingo/comm/llp/core/LLPRawFrame.java | 17 ++-
.../flamingo/comm/llp/core/UnknownNode.java | 14 +-
.../flamingo/comm/llp/core/FinalNodeTest.java | 125 +++++++++++++++++-
.../comm/llp/core/LLPRawFrameTest.java | 9 ++
.../comm/llp/core/UnknownNodeTest.java | 23 +++-
6 files changed, 190 insertions(+), 32 deletions(-)
diff --git a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
index 135bf00..c6c21d4 100644
--- a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
@@ -19,23 +19,30 @@ public final class FinalNode implements LLPNode {
/**
* Shared instance for empty payload (singleton).
*/
- private static final ByteBuffer EMPTY_ARRAY =
- ByteBuffer.wrap(new byte[0]).asReadOnlyBuffer();
+ private static final byte[] EMPTY_ARRAY = new byte[0];
public static final FinalNode EMPTY = new FinalNode(EMPTY_ARRAY);
- private final ByteBuffer payload;
+ 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 = ByteBuffer.wrap(payload.clone()).asReadOnlyBuffer();
+ this.payload = payload.clone();
}
+ /**
+ * The `of()` factory method prevents it from being null or empty
+ */
private FinalNode(ByteBuffer payload) {
- this.payload = payload;
+ ByteBuffer readOnly = payload.asReadOnlyBuffer();
+ byte[] copy = new byte[readOnly.remaining()];
+ readOnly.get(copy);
+
+ this.payload = copy;
}
@Override
@@ -53,22 +60,29 @@ static FinalNode of(byte[] payload) {
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 payload.asReadOnlyBuffer();
+ return ByteBuffer.wrap(payload).asReadOnlyBuffer();
}
@Override
public String toString() {
- byte[] bytes = new byte[payload.remaining()];
- payload.get(bytes);
-
return "FinalNode{" +
- "payloadHex=" + HexFormat.of().formatHex(bytes).toUpperCase(Locale.ROOT) +
+ "payloadHex=" + HexFormat.of().formatHex(payload).toUpperCase(Locale.ROOT) +
'}';
}
}
\ 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
index fe16503..a9bacce 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPRawFrame.java
@@ -15,8 +15,8 @@
* This class is immutable and thread-safe.
*/
public final class LLPRawFrame {
- private static final ByteBuffer EMPTY_ARRAY = ByteBuffer.wrap(new byte[0]).asReadOnlyBuffer();
- private final ByteBuffer payload;
+ private static final byte[] EMPTY_ARRAY = new byte[0];
+ private final byte[] payload;
private final int crc;
private final long timestamp;
@@ -44,7 +44,7 @@ public final class LLPRawFrame {
if (payload == null || payload.length == 0) {
this.payload = EMPTY_ARRAY;
} else {
- this.payload = ByteBuffer.wrap(payload.clone()).asReadOnlyBuffer();
+ this.payload = payload.clone();
}
}
@@ -55,15 +55,20 @@ public final class LLPRawFrame {
* @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) {
+ 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 = ByteBuffer.wrap(Arrays.copyOf(payload, payloadLen)).asReadOnlyBuffer();
+ this.payload = Arrays.copyOf(payload, payloadLen);
}
}
@@ -76,7 +81,7 @@ public final class LLPRawFrame {
* @return read-only ByteBuffer containing payload data
*/
public ByteBuffer payload() {
- return payload.asReadOnlyBuffer();
+ return ByteBuffer.wrap(payload).asReadOnlyBuffer();
}
/**
diff --git a/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
index adec99f..63b1745 100644
--- a/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
@@ -2,7 +2,7 @@
import com.flamingo.comm.llp.spi.LLPNode;
-import java.util.Arrays;
+import java.nio.ByteBuffer;
/**
* Represents an unknown or unsupported LLP layer.
@@ -16,13 +16,13 @@
* 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) ? Arrays.copyOf(metadata, metadata.length) : new byte[0];
+ this.metadata = (metadata != null) ? metadata.clone() : EMPTY_ARRAY;
}
@Override
@@ -31,12 +31,12 @@ public int getId() {
}
/**
- * Returns raw metadata bytes associated with this unknown layer.
+ * Returns raw read-only metadata bytes associated with this unknown layer.
*
- * @return metadata copy (never null)
+ * @return metadata buffer as read-only
*/
- public byte[] getMetadata() {
- return Arrays.copyOf(metadata, metadata.length);
+ public ByteBuffer getMetadata() {
+ return ByteBuffer.wrap(metadata).asReadOnlyBuffer();
}
@Override
diff --git a/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java b/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
index 3b62aee..545f6d9 100644
--- a/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/FinalNodeTest.java
@@ -11,18 +11,22 @@ class FinalNodeTest {
@Test
void testEmptySingleton() {
- FinalNode node1 = FinalNode.of(null);
+ 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
@@ -40,7 +44,7 @@ void testPayloadContent() {
@Test
void testPayloadIsReadOnly() {
- FinalNode node = FinalNode.of(new byte[]{1, 2, 3});
+ FinalNode node = FinalNode.of(ByteBuffer.wrap(new byte[]{1, 2, 3}));
ByteBuffer buf = node.getPayload();
@@ -91,4 +95,121 @@ void testToStringContainsHexPayload() {
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/LLPRawFrameTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
index 7edff91..673f0a8 100644
--- a/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPRawFrameTest.java
@@ -165,4 +165,13 @@ void testLargePayload() {
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/UnknownNodeTest.java b/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
index b164858..e1c7650 100644
--- a/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/UnknownNodeTest.java
@@ -2,10 +2,19 @@
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});
@@ -19,7 +28,7 @@ void testMetadataContent() {
UnknownNode node = new UnknownNode(1, metadata);
- byte[] extracted = node.getMetadata();
+ byte[] extracted = extractData(node);
assertArrayEquals(metadata, extracted);
}
@@ -33,7 +42,7 @@ void testMetadataIsDefensivelyCopiedInConstructor() {
// Modify original array
metadata[0] = 99;
- byte[] extracted = node.getMetadata();
+ byte[] extracted = extractData(node);
assertEquals(1, extracted[0], "Internal state should not be affected by external changes");
}
@@ -44,8 +53,8 @@ void testMetadataIsDefensivelyCopiedInGetter() {
UnknownNode node = new UnknownNode(1, metadata);
- byte[] extracted1 = node.getMetadata();
- byte[] extracted2 = node.getMetadata();
+ byte[] extracted1 = extractData(node);
+ byte[] extracted2 = extractData(node);
// Modify returned array
extracted1[0] = 99;
@@ -58,7 +67,7 @@ void testMetadataIsDefensivelyCopiedInGetter() {
void testNullMetadataBecomesEmptyArray() {
UnknownNode node = new UnknownNode(1, null);
- byte[] metadata = node.getMetadata();
+ byte[] metadata = extractData(node);
assertNotNull(metadata);
assertEquals(0, metadata.length);
@@ -68,7 +77,7 @@ void testNullMetadataBecomesEmptyArray() {
void testEmptyMetadata() {
UnknownNode node = new UnknownNode(1, new byte[0]);
- byte[] metadata = node.getMetadata();
+ byte[] metadata = extractData(node);
assertNotNull(metadata);
assertEquals(0, metadata.length);
@@ -95,7 +104,7 @@ void testLargeMetadata() {
UnknownNode node = new UnknownNode(5, metadata);
- byte[] extracted = node.getMetadata();
+ byte[] extracted = extractData(node);
assertArrayEquals(metadata, extracted);
}
From a41097e85f3e97b4c5712aa912c8903bff030895 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Thu, 23 Apr 2026 18:07:39 -0300
Subject: [PATCH 16/30] Renombrado ErrorCode por TransportErrorCode
---
.../comm/llp/core/LLPTransportDeframer.java | 18 ++--
...ErrorCode.java => TransportErrorCode.java} | 8 +-
.../flamingo/comm/llp/core/ErrorCodeTest.java | 94 -------------------
.../comm/llp/core/TransportErrorCodeTest.java | 94 +++++++++++++++++++
4 files changed, 107 insertions(+), 107 deletions(-)
rename src/main/java/com/flamingo/comm/llp/core/{ErrorCode.java => TransportErrorCode.java} (83%)
delete mode 100644 src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/TransportErrorCodeTest.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
index a58c843..abaa50f 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLPTransportDeframer.java
@@ -102,7 +102,7 @@ public LLPRawFrame processByte(byte b) {
logger.warn("Frame timeout - resetting parser");
statistics.recordTimeout();
reset();
- notifyError(ErrorCode.TIMEOUT);
+ notifyError(TransportErrorCode.TIMEOUT);
// Allow immediate resync if current byte starts a new frame
if (b == MAGIC_1) {
@@ -124,7 +124,7 @@ public LLPRawFrame processByte(byte b) {
// Overlapped frame detected (0xAA 0x55 inside payload)
logger.warn("Overlapped frame detected, resynchronizing");
statistics.recordError();
- notifyError(ErrorCode.SYNC_ERROR);
+ notifyError(TransportErrorCode.SYNC_ERROR);
crcCalculated = 0xFFFF;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_1);
@@ -145,7 +145,7 @@ public LLPRawFrame processByte(byte b) {
Integer.toHexString(b & 0xFF));
statistics.recordError();
reset();
- notifyError(ErrorCode.SYNC_ERROR);
+ notifyError(TransportErrorCode.SYNC_ERROR);
return null;
}
@@ -200,7 +200,7 @@ public LLPRawFrame processByte(byte b) {
logger.error("Payload length {} exceeds maximum {}", payloadLen, payload.length);
statistics.recordError();
reset();
- notifyError(ErrorCode.PAYLOAD_LEN_INVALID);
+ notifyError(TransportErrorCode.PAYLOAD_LEN_INVALID);
return null;
}
@@ -231,7 +231,7 @@ public LLPRawFrame processByte(byte b) {
Integer.toHexString(crcCalculated));
statistics.recordError();
reset();
- notifyError(ErrorCode.CHECKSUM_INVALID);
+ notifyError(TransportErrorCode.CHECKSUM_INVALID);
return null;
}
@@ -312,10 +312,10 @@ private void notifySuccess(LLPRawFrame frame) {
}
}
- private void notifyError(ErrorCode 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);
}
@@ -347,8 +347,8 @@ public interface LLPFrameListener {
/**
* Invoked when a frame parsing error occurs.
*
- * @param errorCode error type
+ * @param transportErrorCode error type
*/
- void onFrameError(ErrorCode errorCode);
+ void onFrameError(TransportErrorCode transportErrorCode);
}
}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/ErrorCode.java b/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
similarity index 83%
rename from src/main/java/com/flamingo/comm/llp/core/ErrorCode.java
rename to src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
index 17bd837..d6aa52b 100644
--- a/src/main/java/com/flamingo/comm/llp/core/ErrorCode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
@@ -5,7 +5,7 @@
/**
* LLP Parser Error Codes
*/
-public enum ErrorCode {
+public enum TransportErrorCode {
OK((byte) 0x00, "No error"),
CHECKSUM_INVALID((byte) 0x01, "CRC checksum mismatch"),
PAYLOAD_LEN_INVALID((byte) 0x02, "Payload length exceeds maximum"),
@@ -16,7 +16,7 @@ public enum ErrorCode {
private final byte code;
private final String description;
- ErrorCode(byte code, String description) {
+ TransportErrorCode(byte code, String description) {
this.code = code;
this.description = description;
}
@@ -27,8 +27,8 @@ public enum ErrorCode {
* @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 (ErrorCode err : values()) {
+ public static Optional fromCode(byte code) {
+ for (TransportErrorCode err : values()) {
if (err.code == code) {
return Optional.of(err);
}
diff --git a/src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java b/src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java
deleted file mode 100644
index 40465d2..0000000
--- a/src/test/java/com/flamingo/comm/llp/core/ErrorCodeTest.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.flamingo.comm.llp.core;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.Optional;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class ErrorCodeTest {
-
- @Test
- void testFromCodeValidValues() {
- for (ErrorCode error : ErrorCode.values()) {
- Optional result = ErrorCode.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 = ErrorCode.fromCode(invalidCode);
-
- assertTrue(result.isEmpty(), "Invalid code should return empty Optional");
- }
-
- @Test
- void testFromCodeBoundaryValues() {
- // Extreme byte values
- assertTrue(ErrorCode.fromCode(Byte.MIN_VALUE).isEmpty());
- assertTrue(ErrorCode.fromCode(Byte.MAX_VALUE).isEmpty());
- }
-
- @Test
- void testCodeGetter() {
- assertEquals((byte) 0x00, ErrorCode.OK.code());
- assertEquals((byte) 0x01, ErrorCode.CHECKSUM_INVALID.code());
- assertEquals((byte) 0x02, ErrorCode.PAYLOAD_LEN_INVALID.code());
- assertEquals((byte) 0x03, ErrorCode.TIMEOUT.code());
- assertEquals((byte) 0x04, ErrorCode.SYNC_ERROR.code());
- assertEquals((byte) 0x05, ErrorCode.BUFFER_FULL.code());
- }
-
- @Test
- void testDescriptionGetter() {
- assertEquals("No error", ErrorCode.OK.description());
- assertEquals("CRC checksum mismatch", ErrorCode.CHECKSUM_INVALID.description());
- assertEquals("Payload length exceeds maximum", ErrorCode.PAYLOAD_LEN_INVALID.description());
- assertEquals("Frame timeout - incomplete frame", ErrorCode.TIMEOUT.description());
- assertEquals("Synchronization error", ErrorCode.SYNC_ERROR.description());
- assertEquals("Buffer overflow", ErrorCode.BUFFER_FULL.description());
- }
-
- @Test
- void testCodesAreUnique() {
- for (ErrorCode e1 : ErrorCode.values()) {
- for (ErrorCode e2 : ErrorCode.values()) {
- if (e1 != e2) {
- assertNotEquals(
- e1.code(),
- e2.code(),
- "Duplicate error code found between " + e1 + " and " + e2
- );
- }
- }
- }
- }
-
- @Test
- void testFromCodeIsDeterministic() {
- byte code = ErrorCode.TIMEOUT.code();
-
- Optional r1 = ErrorCode.fromCode(code);
- Optional r2 = ErrorCode.fromCode(code);
-
- assertEquals(r1, r2, "fromCode should be deterministic");
- }
-
- @Test
- void testEnumCoverage() {
- // Force the execution of `values()` and ensure that there are elements
- assertTrue(ErrorCode.values().length > 0);
- }
-
- @Test
- void testToStringNotNull() {
- for (ErrorCode error : ErrorCode.values()) {
- assertNotNull(error.toString());
- }
- }
-}
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());
+ }
+ }
+}
From 5c1f0e35f5e2b72ec8ed13dc403ffaf3751b3e6d Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Thu, 23 Apr 2026 18:08:16 -0300
Subject: [PATCH 17/30] Renombrado ErrorCode por TransportErrorCode
---
.../com/flamingo/comm/llp/core/LLPTransportDeframerTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
index b495a9a..e66b4e9 100644
--- a/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/LLPTransportDeframerTest.java
@@ -157,8 +157,8 @@ public void onFrameReceived(LLPRawFrame frame) {
}
@Override
- public void onFrameError(ErrorCode errorCode) {
- if (errorCode == ErrorCode.PAYLOAD_LEN_INVALID) {
+ public void onFrameError(TransportErrorCode errorCode) {
+ if (errorCode == TransportErrorCode.PAYLOAD_LEN_INVALID) {
payloadErrors.incrementAndGet();
}
}
From 570a1b2a57209e32177468c7be9596d2322c2649 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Thu, 23 Apr 2026 18:09:31 -0300
Subject: [PATCH 18/30] Creada interfaz ParseErrorReason para representar
errores en parseo de tramas
---
.../comm/llp/core/CoreParseErrorReason.java | 60 +++++++++++++++++++
.../comm/llp/spi/ParseErrorReason.java | 12 ++++
2 files changed, 72 insertions(+)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/ParseErrorReason.java
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..044b927
--- /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.
+ */
+ MALFORMED_METADATA_LENGTH("Metadata length exceeds available data"),
+
+ /**
+ * Frame ended before expected fields could be read.
+ */
+ PAYLOAD_TOO_SHORT("Unexpected end of payload"),
+
+ /**
+ * 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/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
From 59fbbaf25357319bd7bce7b4b348215f32c664e3 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Thu, 23 Apr 2026 18:11:09 -0300
Subject: [PATCH 19/30] Creado nodo FailureNode para almacenar casos de nodos
fallidos
---
.../flamingo/comm/llp/core/FailureNode.java | 110 +++++++++++
.../comm/llp/core/FailureNodeTest.java | 187 ++++++++++++++++++
2 files changed, 297 insertions(+)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/FailureNode.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java
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..8a9b09a
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
@@ -0,0 +1,110 @@
+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.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.
+ *
+ * @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() : "") +
+ '}';
+ }
+}
\ 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..28f61d9
--- /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.MALFORMED_METADATA_LENGTH);
+
+ assertEquals(42, node.getId());
+ }
+
+ @Test
+ void testMetadataContent() {
+ byte[] metadata = {10, 20, 30};
+
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+
+ byte[] extracted = extractMetadata(node);
+
+ assertArrayEquals(metadata, extracted);
+ }
+
+ @Test
+ void testMetadataIsDefensivelyCopiedInConstructor() {
+ byte[] metadata = {1, 2, 3};
+
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.PAYLOAD_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.PAYLOAD_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.PAYLOAD_TOO_SHORT);
+
+ byte[] metadata = extractMetadata(node);
+
+ assertNotNull(metadata);
+ assertEquals(0, metadata.length);
+ }
+
+ @Test
+ void testEmptyMetadata() {
+ FailureNode node = new FailureNode(1, new byte[0], CoreParseErrorReason.PAYLOAD_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.PAYLOAD_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.PAYLOAD_TOO_SHORT, null);
+
+ assertEquals(Optional.empty(), node.getCause());
+ }
+
+ @Test
+ void testToStringContainsBasicInfo() {
+ FailureNode node = new FailureNode(99, new byte[]{1, 2, 3}, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+
+ String str = node.toString();
+
+ assertTrue(str.contains("id=99"));
+ assertTrue(str.contains("metadataLength=3"));
+ assertTrue(str.contains("PAYLOAD_TOO_SHORT"));
+ }
+
+ @Test
+ void testToStringIncludesCauseWhenPresent() {
+ IllegalStateException ex = new IllegalStateException();
+
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.PAYLOAD_TOO_SHORT, ex);
+
+ String str = node.toString();
+
+ assertTrue(str.contains("IllegalStateException"));
+ }
+
+ @Test
+ void testToStringWithoutCauseDoesNotFail() {
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.PAYLOAD_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.PAYLOAD_TOO_SHORT);
+
+ byte[] extracted = extractMetadata(node);
+
+ assertArrayEquals(metadata, extracted);
+ }
+
+ @Test
+ void testIdBoundaries() {
+ FailureNode min = new FailureNode(0, new byte[0], CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode max = new FailureNode(255, new byte[0], CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+
+ assertEquals(0, min.getId());
+ assertEquals(255, max.getId());
+ }
+
+ @Test
+ void testGetMetadataReturnsReadOnlyBuffer() {
+ FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.PAYLOAD_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.PAYLOAD_TOO_SHORT);
+
+ ByteBuffer b1 = node.getMetadata();
+ ByteBuffer b2 = node.getMetadata();
+
+ assertNotSame(b1, b2);
+ }
+}
From 06c9810d70d828845fd34ed1dece49959b720250 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Thu, 23 Apr 2026 18:12:43 -0300
Subject: [PATCH 20/30] Creada clase utilitaria LayerIds que define logica de
protocolo para capas skippables
---
.../com/flamingo/comm/llp/util/LayerIds.java | 132 ++++++++++++++++++
1 file changed, 132 insertions(+)
create mode 100644 src/main/java/com/flamingo/comm/llp/util/LayerIds.java
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;
+ }
+}
From 04a17e0b7b211da09cf44b6f6fce1516ef1a5fa8 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 24 Apr 2026 18:28:21 -0300
Subject: [PATCH 21/30] Se cambia descripcion de errores en
CoreParseErrorReason
---
.../comm/llp/core/CoreParseErrorReason.java | 4 +--
.../flamingo/comm/llp/core/FailureNode.java | 10 ++++++
.../comm/llp/core/FailureNodeTest.java | 34 +++++++++----------
3 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java b/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
index 044b927..771b7cd 100644
--- a/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
+++ b/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
@@ -20,12 +20,12 @@ public enum CoreParseErrorReason implements ParseErrorReason {
/**
* Metadata length exceeds available buffer.
*/
- MALFORMED_METADATA_LENGTH("Metadata length exceeds available data"),
+ METADATA_TRUNCATED("Metadata length exceeds available data"),
/**
* Frame ended before expected fields could be read.
*/
- PAYLOAD_TOO_SHORT("Unexpected end of payload"),
+ LAYER_TOO_SHORT("Unexpected end of layer"),
/**
* Layer ID is invalid or out of range.
diff --git a/src/main/java/com/flamingo/comm/llp/core/FailureNode.java b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
index 8a9b09a..07f62b8 100644
--- a/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
@@ -34,6 +34,16 @@ public final class FailureNode implements LLPNode {
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.
*
diff --git a/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java b/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java
index 28f61d9..736879d 100644
--- a/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/FailureNodeTest.java
@@ -18,7 +18,7 @@ private byte[] extractMetadata(FailureNode node) {
@Test
void testConstructorAndGetId() {
- FailureNode node = new FailureNode(42, new byte[]{1, 2}, CoreParseErrorReason.MALFORMED_METADATA_LENGTH);
+ FailureNode node = new FailureNode(42, new byte[]{1, 2}, CoreParseErrorReason.METADATA_TRUNCATED);
assertEquals(42, node.getId());
}
@@ -27,7 +27,7 @@ void testConstructorAndGetId() {
void testMetadataContent() {
byte[] metadata = {10, 20, 30};
- FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
byte[] extracted = extractMetadata(node);
@@ -38,7 +38,7 @@ void testMetadataContent() {
void testMetadataIsDefensivelyCopiedInConstructor() {
byte[] metadata = {1, 2, 3};
- FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
metadata[0] = 99;
@@ -51,7 +51,7 @@ void testMetadataIsDefensivelyCopiedInConstructor() {
void testMetadataIsDefensivelyCopiedInGetter() {
byte[] metadata = {1, 2, 3};
- FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
byte[] extracted1 = extractMetadata(node);
byte[] extracted2 = extractMetadata(node);
@@ -63,7 +63,7 @@ void testMetadataIsDefensivelyCopiedInGetter() {
@Test
void testNullMetadataBecomesEmptyArray() {
- FailureNode node = new FailureNode(1, null, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT);
byte[] metadata = extractMetadata(node);
@@ -73,7 +73,7 @@ void testNullMetadataBecomesEmptyArray() {
@Test
void testEmptyMetadata() {
- FailureNode node = new FailureNode(1, new byte[0], CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, new byte[0], CoreParseErrorReason.LAYER_TOO_SHORT);
byte[] metadata = extractMetadata(node);
@@ -99,34 +99,34 @@ void testErrorReasonCannotBeNull() {
void testCauseIsStored() {
RuntimeException ex = new RuntimeException("boom");
- FailureNode node = new FailureNode(1, null, CoreParseErrorReason.PAYLOAD_TOO_SHORT, ex);
+ 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.PAYLOAD_TOO_SHORT, null);
+ 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.PAYLOAD_TOO_SHORT);
+ 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("PAYLOAD_TOO_SHORT"));
+ assertTrue(str.contains("LAYER_TOO_SHORT"));
}
@Test
void testToStringIncludesCauseWhenPresent() {
IllegalStateException ex = new IllegalStateException();
- FailureNode node = new FailureNode(1, null, CoreParseErrorReason.PAYLOAD_TOO_SHORT, ex);
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT, ex);
String str = node.toString();
@@ -135,7 +135,7 @@ void testToStringIncludesCauseWhenPresent() {
@Test
void testToStringWithoutCauseDoesNotFail() {
- FailureNode node = new FailureNode(1, null, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, null, CoreParseErrorReason.LAYER_TOO_SHORT);
String str = node.toString();
@@ -149,7 +149,7 @@ void testLargeMetadata() {
metadata[i] = (byte) i;
}
- FailureNode node = new FailureNode(5, metadata, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(5, metadata, CoreParseErrorReason.LAYER_TOO_SHORT);
byte[] extracted = extractMetadata(node);
@@ -158,8 +158,8 @@ void testLargeMetadata() {
@Test
void testIdBoundaries() {
- FailureNode min = new FailureNode(0, new byte[0], CoreParseErrorReason.PAYLOAD_TOO_SHORT);
- FailureNode max = new FailureNode(255, new byte[0], CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ 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());
@@ -167,7 +167,7 @@ void testIdBoundaries() {
@Test
void testGetMetadataReturnsReadOnlyBuffer() {
- FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.LAYER_TOO_SHORT);
ByteBuffer buffer = node.getMetadata();
@@ -177,7 +177,7 @@ void testGetMetadataReturnsReadOnlyBuffer() {
@Test
void testGetMetadataReturnsDifferentBufferInstances() {
- FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.PAYLOAD_TOO_SHORT);
+ FailureNode node = new FailureNode(1, new byte[]{1, 2, 3}, CoreParseErrorReason.LAYER_TOO_SHORT);
ByteBuffer b1 = node.getMetadata();
ByteBuffer b2 = node.getMetadata();
From 8c593a1e1cb341fd791db673dfea1d699641ceec Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 24 Apr 2026 18:30:07 -0300
Subject: [PATCH 22/30] Creada interfaz LLPFrameParser e implentacion de parseo
SimpleFrameParser
---
pom.xml | 7 +
.../comm/llp/core/LLPFrameParser.java | 12 +
.../comm/llp/core/SimpleFrameParser.java | 181 ++++
.../flamingo/comm/llp/spi/LLPLayerParser.java | 50 +-
.../com/flamingo/comm/llp/spi/LayerData.java | 55 ++
.../comm/llp/spi/LayerParseResult.java | 70 ++
.../comm/llp/core/SimpleFrameParserTest.java | 773 ++++++++++++++++++
7 files changed, 1130 insertions(+), 18 deletions(-)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPFrameParser.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LayerData.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LayerParseResult.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
diff --git a/pom.xml b/pom.xml
index ae8e865..feb003e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -81,6 +81,13 @@
${junit.version}
test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.23.0
+ test
+
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/SimpleFrameParser.java b/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
new file mode 100644
index 0000000..dd7e660
--- /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.LayerData;
+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 DefaultLayerData(
+ 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 LayerData implementation.
+ */
+ private record DefaultLayerData(
+ ByteBuffer metadata,
+ ByteBuffer payload
+ ) implements LayerData {
+ }
+
+ /**
+ * 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/spi/LLPLayerParser.java b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
index 0da91f8..8d7b795 100644
--- a/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
+++ b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
@@ -1,5 +1,7 @@
package com.flamingo.comm.llp.spi;
+import java.util.ServiceLoader;
+
/**
* Service Provider Interface (SPI) for parsing LLP protocol layers.
*
@@ -18,13 +20,13 @@
*
*
* Implementations are typically discovered at runtime using Java's
- * {@link java.util.ServiceLoader} mechanism.
+ * {@link ServiceLoader} mechanism.
*
*
* Responsibilities
*
* - Declare the layer identifier via {@link #getLayerId()}.
- * - Parse raw metadata and payload into a domain-specific {@link LLPNode}.
+ * - Parse raw layer data into a domain-specific {@link LLPNode}.
* - Interpret metadata according to the layer's internal specification.
*
*
@@ -32,10 +34,18 @@
*
* - The {@code layerId} must be unique across all registered layers.
* - The core LLP parser guarantees that metadata and payload are already
- * correctly extracted according to the protocol format.
- * - The implementation must not modify the provided byte arrays.
- * - If parsing fails, the implementation should throw a runtime exception
- * or return a fallback node, depending on the design choice.
+ * extracted according to the protocol format.
+ * - The provided {@link LayerData} 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
@@ -48,20 +58,25 @@
* }
*
* @Override
- * public LLPNode parse(byte[] metadata, byte[] payload) {
+ * public LayerParseResult parse(LayerData data) {
+ * ByteBuffer metadata = data.metadata();
+ * ByteBuffer payload = data.payload();
+ *
* // Interpret metadata (e.g., algorithm, IV, etc.)
- * return new EncryptionNode(metadata, payload);
+ * EncryptionNode node = new EncryptionNode(metadata, payload);
+ *
+ * return new LayerParseResult.Success(node, payload);
* }
* }
* }
*
*
- * The returned {@link LLPNode} will be integrated into the {@code LLPNodeChain}
+ * The resulting {@link LLPNode} will be integrated into the {@code NodeChain}
* by the core parser.
*
*
* @see LLPNode
- * @see java.util.ServiceLoader
+ * @see LayerData
*/
public interface LLPLayerParser {
@@ -80,8 +95,8 @@ public interface LLPLayerParser {
* Parses a layer from its raw metadata and payload.
*
*
- * The core LLP parser is responsible for extracting the metadata and payload
- * based on the protocol specification:
+ * The core LLP parser provides a {@link LayerData} instance containing
+ * the extracted metadata and payload buffers according to the protocol:
*
*
*
@@ -89,13 +104,12 @@ public interface LLPLayerParser {
*
*
*
- * This method should interpret the metadata and construct an appropriate
- * {@link LLPNode} implementation.
+ * Implementations should interpret the metadata and construct an appropriate
+ * {@link LLPNode}, optionally transforming the payload for the next layer.
*
*
- * @param metadata raw metadata bytes (never {@code null}, may be empty)
- * @param payload raw payload bytes (never {@code null}, may be empty)
- * @return parsed {@link LLPNode} representing this layer
+ * @param layerData container with metadata and payload buffers (never {@code null})
+ * @return a {@link LayerParseResult} describing the outcome of the parsing
*/
- LLPNode parse(byte[] metadata, byte[] payload);
+ LayerParseResult parse(LayerData layerData);
}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LayerData.java b/src/main/java/com/flamingo/comm/llp/spi/LayerData.java
new file mode 100644
index 0000000..e78f4c6
--- /dev/null
+++ b/src/main/java/com/flamingo/comm/llp/spi/LayerData.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 LayerData {
+
+ /**
+ * 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/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..1f5b0ea
--- /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.LayerData;
+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(LayerData.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(LayerData.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 -> {
+ LayerData 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 -> {
+ LayerData 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 -> {
+ LayerData 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
From 0a6bd541764657d08efa0b62f862b7bae0ee1b4b Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Mon, 27 Apr 2026 20:38:39 -0300
Subject: [PATCH 23/30] Renombrada interfaz LayerData por LayerParseInput para
expresar su uso en el parseo
---
.../flamingo/comm/llp/core/SimpleFrameParser.java | 10 +++++-----
.../com/flamingo/comm/llp/spi/LLPLayerParser.java | 12 ++++++------
.../llp/spi/{LayerData.java => LayerParseInput.java} | 2 +-
.../comm/llp/core/SimpleFrameParserTest.java | 12 ++++++------
4 files changed, 18 insertions(+), 18 deletions(-)
rename src/main/java/com/flamingo/comm/llp/spi/{LayerData.java => LayerParseInput.java} (98%)
diff --git a/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java b/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
index dd7e660..5a3bbbd 100644
--- a/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
+++ b/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
@@ -1,7 +1,7 @@
package com.flamingo.comm.llp.core;
import com.flamingo.comm.llp.spi.LLPLayerParser;
-import com.flamingo.comm.llp.spi.LayerData;
+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;
@@ -103,7 +103,7 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
LLPLayerParser parser = parserOpt.get();
LayerParseResult result = parser.parse(
- new DefaultLayerData(
+ new DefaultLayerParseInput(
metadata.asReadOnlyBuffer(),
layerPayload.asReadOnlyBuffer()
)
@@ -161,12 +161,12 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
}
/**
- * Internal LayerData implementation.
+ * Internal LayerParseInput implementation.
*/
- private record DefaultLayerData(
+ private record DefaultLayerParseInput(
ByteBuffer metadata,
ByteBuffer payload
- ) implements LayerData {
+ ) implements LayerParseInput {
}
/**
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
index 8d7b795..025ec40 100644
--- a/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
+++ b/src/main/java/com/flamingo/comm/llp/spi/LLPLayerParser.java
@@ -35,7 +35,7 @@
* 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 LayerData} buffers must be treated as read-only.
+ * 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.
@@ -58,7 +58,7 @@
* }
*
* @Override
- * public LayerParseResult parse(LayerData data) {
+ * public LayerParseResult parse(LayerParseInput data) {
* ByteBuffer metadata = data.metadata();
* ByteBuffer payload = data.payload();
*
@@ -76,7 +76,7 @@
*
*
* @see LLPNode
- * @see LayerData
+ * @see LayerParseInput
*/
public interface LLPLayerParser {
@@ -95,7 +95,7 @@ public interface LLPLayerParser {
* Parses a layer from its raw metadata and payload.
*
*
- * The core LLP parser provides a {@link LayerData} instance containing
+ * The core LLP parser provides a {@link LayerParseInput} instance containing
* the extracted metadata and payload buffers according to the protocol:
*
*
@@ -108,8 +108,8 @@ public interface LLPLayerParser {
* {@link LLPNode}, optionally transforming the payload for the next layer.
*
*
- * @param layerData container with metadata and payload buffers (never {@code null})
+ * @param layerParseInput container with metadata and payload buffers (never {@code null})
* @return a {@link LayerParseResult} describing the outcome of the parsing
*/
- LayerParseResult parse(LayerData layerData);
+ LayerParseResult parse(LayerParseInput layerParseInput);
}
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/spi/LayerData.java b/src/main/java/com/flamingo/comm/llp/spi/LayerParseInput.java
similarity index 98%
rename from src/main/java/com/flamingo/comm/llp/spi/LayerData.java
rename to src/main/java/com/flamingo/comm/llp/spi/LayerParseInput.java
index e78f4c6..7a4500a 100644
--- a/src/main/java/com/flamingo/comm/llp/spi/LayerData.java
+++ b/src/main/java/com/flamingo/comm/llp/spi/LayerParseInput.java
@@ -33,7 +33,7 @@
*
* @see LLPLayerParser
*/
-public interface LayerData {
+public interface LayerParseInput {
/**
* Returns the metadata buffer of the layer.
diff --git a/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java b/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
index 1f5b0ea..4d6735f 100644
--- a/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
@@ -2,7 +2,7 @@
import com.flamingo.comm.llp.spi.LLPLayerParser;
import com.flamingo.comm.llp.spi.LLPNode;
-import com.flamingo.comm.llp.spi.LayerData;
+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;
@@ -144,7 +144,7 @@ void shouldProcessSuccessfulPluginParse() {
mockNode,
ByteBuffer.wrap(new byte[]{0x00})
);
- when(mockLayerParser.parse(any(LayerData.class))).thenReturn(successResult);
+ when(mockLayerParser.parse(any(LayerParseInput.class))).thenReturn(successResult);
LLPFrame frame = parser.parse(rawFrame);
@@ -160,7 +160,7 @@ void shouldHandlePluginExceptionAndProtectCore() {
setupRawFrame(payload, 0, 0L);
when(provider.get(5)).thenReturn(Optional.of(mockLayerParser));
- when(mockLayerParser.parse(any(LayerData.class))).thenThrow(new RuntimeException("Simulated plugin crash"));
+ when(mockLayerParser.parse(any(LayerParseInput.class))).thenThrow(new RuntimeException("Simulated plugin crash"));
LLPFrame frame = parser.parse(rawFrame);
@@ -379,7 +379,7 @@ void shouldPassReadOnlyBuffersToPlugin() {
when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
when(mockLayerParser.parse(any())).thenAnswer(invocation -> {
- LayerData data = invocation.getArgument(0);
+ LayerParseInput data = invocation.getArgument(0);
assertTrue(data.metadata().isReadOnly());
assertTrue(data.payload().isReadOnly());
@@ -581,7 +581,7 @@ void shouldHandleKnownLayerWithEmptyMetadata() {
when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
when(mockLayerParser.parse(any())).thenAnswer(invocation -> {
- LayerData data = invocation.getArgument(0);
+ LayerParseInput data = invocation.getArgument(0);
assertEquals(0, data.metadata().remaining()); // Empty metadata
return new LayerParseResult.Success(mockNode, ByteBuffer.wrap(new byte[]{0x00}));
});
@@ -741,7 +741,7 @@ void shouldPassMetadataWithCorrectBoundsToPlugin() {
when(provider.get(16)).thenReturn(Optional.of(mockLayerParser));
when(mockLayerParser.parse(any())).thenAnswer(invocation -> {
- LayerData data = invocation.getArgument(0);
+ LayerParseInput data = invocation.getArgument(0);
// Metadata must be exactly [11 22 33]
assertEquals(3, data.metadata().remaining());
From 092c62d88d5b5072520d5cdbc7c0d122e5ad604b Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 1 May 2026 22:01:58 -0300
Subject: [PATCH 24/30] LayerParserProvider y LayerRegistry se convierten a
package-private
---
.../java/com/flamingo/comm/llp/core/LayerParserProvider.java | 2 +-
src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
index 4ba6dca..25a0d9d 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
@@ -22,7 +22,7 @@
* making it suitable for dependency injection and testing.
*/
@FunctionalInterface
-public interface LayerParserProvider {
+interface LayerParserProvider {
/**
* Returns a parser for the given layer identifier.
diff --git a/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java b/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
index 45a444a..fd7143d 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
@@ -29,7 +29,7 @@
*
* This class is thread-safe for read operations after initialization.
*/
-public final class LayerRegistry {
+final class LayerRegistry {
private static final Map parsers = new HashMap<>();
@@ -57,7 +57,7 @@ private LayerRegistry() {
* @return an {@link Optional} containing the parser if found,
* or empty if no parser is registered for the given ID
*/
- public static Optional get(int id) {
+ static Optional get(int id) {
return Optional.ofNullable(parsers.get(id));
}
}
From 163a6ff3343e5b2a0cd2b28600e5214e8c68fbe0 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 1 May 2026 22:05:26 -0300
Subject: [PATCH 25/30] Creadas interfaces, implementaciones y test para
builders de frame
---
.../comm/llp/core/ByteArrayFrameBuilder.java | 185 +++++
.../comm/llp/core/FrameBuildException.java | 109 +++
.../comm/llp/core/LLPFrameBuilder.java | 54 ++
.../comm/llp/spi/BuildErrorReason.java | 8 +
.../comm/llp/spi/LLPLayerBuilder.java | 67 ++
.../comm/llp/spi/LayerBuildPayload.java | 7 +
.../comm/llp/spi/LayerBuildResult.java | 47 ++
.../llp/core/ByteArrayFrameBuilderTest.java | 740 ++++++++++++++++++
8 files changed, 1217 insertions(+)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilder.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/FrameBuildException.java
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPFrameBuilder.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/BuildErrorReason.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LLPLayerBuilder.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LayerBuildPayload.java
create mode 100644 src/main/java/com/flamingo/comm/llp/spi/LayerBuildResult.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java
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/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/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/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/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/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..1547f36
--- /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));
+
+ // Solo el marcador final, sin payload
+ assertArrayEquals(new byte[]{0x00}, result);
+ }
+
+ @Test
+ void shouldNotMutateOriginalLayersListAfterConstruction() {
+ // Verificar que List.copyOf() aísla al builder de modificaciones externas
+ List mutableList = new java.util.ArrayList<>();
+ builder = new ByteArrayFrameBuilder(mutableList);
+
+ // Agregar una capa DESPUÉS de construir el builder
+ when(layer1.getLayerId()).thenReturn(1);
+ mutableList.add(layer1);
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ // El builder no debe haber procesado layer1
+ assertArrayEquals(new byte[]{0x00, 0x42}, result);
+ verify(layer1, never()).build(any());
+ }
+
+ @Test
+ void shouldProduceSameOutputOnMultipleBuilds() {
+ // El builder debe ser reutilizable
+ 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() {
+ // Verificar que el layerId en la excepción corresponde a la capa que falló,
+ // no siempre a la primera
+ 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()); // debe ser layer2, no 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() {
+ // Un plugin NO debe poder mutar o consumir el currentPayload del builder
+ when(layer1.getLayerId()).thenReturn(1);
+ when(layer1.build(any())).thenAnswer(invocation -> {
+ LayerBuildPayload p = invocation.getArgument(0);
+ ByteBuffer buf = p.payload();
+
+ // Intentar consumir el buffer
+ while (buf.hasRemaining()) buf.get();
+
+ return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0));
+ });
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ // Si el builder no protege currentPayload, el frame no tendrá payload
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x10, 0x20}));
+
+ // El payload final debe estar presente aunque el plugin haya consumido su vista
+ byte[] expected = new byte[]{0x01, 0x00, 0x00, 0x10, 0x20};
+ assertArrayEquals(expected, result);
+ }
+
+ @Test
+ void shouldHandleFirstLayerAsTransformedPayload() {
+ // TransformedPayload como primera (y única) capa
+ 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}) // payload transformado
+ )
+ );
+
+ 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() {
+ // Dos capas transformadoras seguidas — la segunda descarta el header de la primera
+ when(layer1.getLayerId()).thenReturn(130);
+ when(layer2.getLayerId()).thenReturn(131);
+
+ when(layer1.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x01}), // metadata layer1
+ ByteBuffer.wrap(new byte[]{(byte) 0xEE}) // payload cifrado
+ )
+ );
+ when(layer2.build(any())).thenReturn(
+ new LayerBuildResult.Success.TransformedPayload(
+ ByteBuffer.wrap(new byte[]{0x02}), // metadata layer2
+ ByteBuffer.wrap(new byte[]{(byte) 0xFF}) // payload comprimido
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1, layer2));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ // Layer2 descarta el header de layer1, igual que layer1 descartó el header previo
+ // [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) // payload transformado vacío (caso extremo)
+ )
+ );
+
+ builder = new ByteArrayFrameBuilder(List.of(layer1));
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0x99}));
+
+ // [ID=128][LEN=1][AA][FINAL] — sin payload
+ assertArrayEquals(new byte[]{(byte) 0x80, 0x01, (byte) 0xAA, 0x00}, result);
+ }
+
+ @Test
+ void shouldHandleMaxExtendedMetadataLength() {
+ // Metadata de 65535 bytes (máximo del campo extended de 2 bytes)
+ 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}));
+
+ // Verificar el header extendido
+ assertEquals(0x01, result[0] & 0xFF); // ID
+ assertEquals(0xFF, result[1] & 0xFF); // extended flag
+ assertEquals(0xFF, result[2] & 0xFF); // high byte de 65535
+ assertEquals(0xFF, result[3] & 0xFF); // low byte de 65535
+
+ // Verificar tamaño total: 1(ID) + 3(LEN_EXT) + 65535(META) + 1(FINAL) + 1(PAYLOAD)
+ assertEquals(65541, result.length);
+
+ // Verificar que el contenido de metadata es correcto
+ for (int i = 4; i < 4 + 65535; i++) {
+ assertEquals((byte) 0x7A, result[i], "Metadata byte mismatch at index " + i);
+ }
+ }
+
+ @Test
+ void shouldPassTransformedPayloadToNextLayer() {
+ // Verificar que la capa siguiente recibe el payload transformado, no el original
+ 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 debe recibir el payload transformado por layer1
+ assertArrayEquals(transformedBytes, received,
+ "layer2 debe recibir el payload transformado, no el 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() {
+ // Traza completa: verificar posición exacta de cada byte en el 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 fue descartado por la transformación de layer2
+ // 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 = primer valor que necesita el byte high != 0x00 en 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 de 256 (0x01)
+ assertEquals(0x00, result[3] & 0xFF); // low byte de 256 (0x00)
+
+ // Total: 1 + 3 + 256 + 1 + 1 = 262
+ assertEquals(262, result.length);
+ }
+
+ @Test
+ void shouldNotInvokeAnyLayerWhenLayerListIsEmpty() {
+ // Con lista vacía no se debe llamar a ningún builder
+ builder = new ByteArrayFrameBuilder(List.of());
+
+ byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
+
+ assertArrayEquals(new byte[]{0x00, 0x42}, result);
+ // No hay mocks que verificar, pero el test confirma que no lanza excepción
+ }
+
+ @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 nunca debe haberse llamado
+ verify(layer2, never()).build(any());
+ }
+
+ @Test
+ void shouldHandleLayerWithExactly254ByteMetadataAndVerifyRoundTrip() {
+ // 254 es el último valor sin flag extendida — probar que el byte no se confunde con 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 sin extended
+ // Verificar que el byte 1 NO es 0xFF (lo que activaría extended en el parser)
+ assertNotEquals(0xFF, result[1] & 0xFF);
+
+ // Total: 1 + 1 + 254 + 1 + 1 = 258
+ assertEquals(258, result.length);
+
+ // Verificar contenido de metadata
+ 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
From baf65ad9575f4505bcbd49523f2bf0f40c7932a8 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 1 May 2026 22:28:44 -0300
Subject: [PATCH 26/30] Corregidos comentarios en ByteArrayFrameBuilderTest
---
.../llp/core/ByteArrayFrameBuilderTest.java | 86 +++++++++----------
1 file changed, 43 insertions(+), 43 deletions(-)
diff --git a/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java b/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java
index 1547f36..e81e114 100644
--- a/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/ByteArrayFrameBuilderTest.java
@@ -353,30 +353,30 @@ void shouldBuildFrameWithEmptyPayloadAndNoLayers() {
byte[] result = builder.build(ByteBuffer.allocate(0));
- // Solo el marcador final, sin payload
+ // Only the final marker, without payload
assertArrayEquals(new byte[]{0x00}, result);
}
@Test
void shouldNotMutateOriginalLayersListAfterConstruction() {
- // Verificar que List.copyOf() aísla al builder de modificaciones externas
+ // Verify that List.copyOf() isolates the builder from external modifications
List mutableList = new java.util.ArrayList<>();
builder = new ByteArrayFrameBuilder(mutableList);
- // Agregar una capa DESPUÉS de construir el builder
+ // Add a layer AFTER building the builder
when(layer1.getLayerId()).thenReturn(1);
mutableList.add(layer1);
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
- // El builder no debe haber procesado layer1
+ // The builder should not have processed layer1
assertArrayEquals(new byte[]{0x00, 0x42}, result);
verify(layer1, never()).build(any());
}
@Test
void shouldProduceSameOutputOnMultipleBuilds() {
- // El builder debe ser reutilizable
+ // The builder should be reusable
when(layer1.getLayerId()).thenReturn(5);
when(layer1.build(any())).thenReturn(
new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x0A}))
@@ -393,8 +393,8 @@ void shouldProduceSameOutputOnMultipleBuilds() {
@Test
void shouldThrowFrameBuildExceptionWithCorrectLayerIdWhenSecondLayerFails() {
- // Verificar que el layerId en la excepción corresponde a la capa que falló,
- // no siempre a la primera
+ // 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);
@@ -412,7 +412,7 @@ void shouldThrowFrameBuildExceptionWithCorrectLayerIdWhenSecondLayerFails() {
() -> builder.build(ByteBuffer.wrap(new byte[]{0x01}))
);
- assertEquals(99, ex.getLayerId()); // debe ser layer2, no layer1
+ assertEquals(99, ex.getLayerId()); // should be layer2, not layer1
}
@Test
@@ -435,13 +435,13 @@ void shouldCallEachLayerExactlyOnce() {
@Test
void shouldPassReadOnlyOrDuplicatePayloadToLayer() {
- // Un plugin NO debe poder mutar o consumir el currentPayload del builder
+ // 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();
- // Intentar consumir el buffer
+ // Attempt to consume the buffer
while (buf.hasRemaining()) buf.get();
return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.allocate(0));
@@ -449,22 +449,22 @@ void shouldPassReadOnlyOrDuplicatePayloadToLayer() {
builder = new ByteArrayFrameBuilder(List.of(layer1));
- // Si el builder no protege currentPayload, el frame no tendrá payload
+ // If the builder does not protect currentPayload, the frame will have no payload
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x10, 0x20}));
- // El payload final debe estar presente aunque el plugin haya consumido su vista
+ // 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 como primera (y única) capa
+ // 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}) // payload transformado
+ ByteBuffer.wrap(new byte[]{(byte) 0xFF, (byte) 0xEE}) // transformed payload
)
);
@@ -482,20 +482,20 @@ void shouldHandleFirstLayerAsTransformedPayload() {
@Test
void shouldHandleTwoConsecutiveTransformedPayloadLayers() {
- // Dos capas transformadoras seguidas — la segunda descarta el header de la primera
+ // 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}), // metadata layer1
- ByteBuffer.wrap(new byte[]{(byte) 0xEE}) // payload cifrado
+ 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}), // metadata layer2
- ByteBuffer.wrap(new byte[]{(byte) 0xFF}) // payload comprimido
+ ByteBuffer.wrap(new byte[]{0x02}), // layer2 metadata
+ ByteBuffer.wrap(new byte[]{(byte) 0xFF}) // compressed payload
)
);
@@ -503,7 +503,7 @@ void shouldHandleTwoConsecutiveTransformedPayloadLayers() {
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x42}));
- // Layer2 descarta el header de layer1, igual que layer1 descartó el header previo
+ // 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,
@@ -518,7 +518,7 @@ void shouldHandleTransformedPayloadWithEmptyNewPayload() {
when(layer1.build(any())).thenReturn(
new LayerBuildResult.Success.TransformedPayload(
ByteBuffer.wrap(new byte[]{(byte) 0xAA}),
- ByteBuffer.allocate(0) // payload transformado vacío (caso extremo)
+ ByteBuffer.allocate(0) // empty transformed payload (edge case)
)
);
@@ -526,13 +526,13 @@ void shouldHandleTransformedPayloadWithEmptyNewPayload() {
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0x99}));
- // [ID=128][LEN=1][AA][FINAL] — sin payload
+ // [ID=128][LEN=1][AA][FINAL] — no payload
assertArrayEquals(new byte[]{(byte) 0x80, 0x01, (byte) 0xAA, 0x00}, result);
}
@Test
void shouldHandleMaxExtendedMetadataLength() {
- // Metadata de 65535 bytes (máximo del campo extended de 2 bytes)
+ // 65535 bytes metadata (maximum of the 2-byte extended field)
when(layer1.getLayerId()).thenReturn(1);
byte[] meta = new byte[65535];
@@ -546,16 +546,16 @@ void shouldHandleMaxExtendedMetadataLength() {
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{0x01}));
- // Verificar el header extendido
+ // Verify the extended header
assertEquals(0x01, result[0] & 0xFF); // ID
assertEquals(0xFF, result[1] & 0xFF); // extended flag
- assertEquals(0xFF, result[2] & 0xFF); // high byte de 65535
- assertEquals(0xFF, result[3] & 0xFF); // low byte de 65535
+ assertEquals(0xFF, result[2] & 0xFF); // high byte of 65535
+ assertEquals(0xFF, result[3] & 0xFF); // low byte of 65535
- // Verificar tamaño total: 1(ID) + 3(LEN_EXT) + 65535(META) + 1(FINAL) + 1(PAYLOAD)
+ // Verify total size: 1(ID) + 3(LEN_EXT) + 65535(META) + 1(FINAL) + 1(PAYLOAD)
assertEquals(65541, result.length);
- // Verificar que el contenido de metadata es correcto
+ // 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);
}
@@ -563,7 +563,7 @@ void shouldHandleMaxExtendedMetadataLength() {
@Test
void shouldPassTransformedPayloadToNextLayer() {
- // Verificar que la capa siguiente recibe el payload transformado, no el original
+ // Verify that the next layer receives the transformed payload, not the original one
LLPLayerBuilder layer3 = mock(LLPLayerBuilder.class);
when(layer1.getLayerId()).thenReturn(128);
@@ -584,9 +584,9 @@ void shouldPassTransformedPayloadToNextLayer() {
byte[] received = new byte[p.payload().remaining()];
p.payload().duplicate().get(received);
- // Layer2 debe recibir el payload transformado por layer1
+ // Layer2 must receive the payload transformed by layer1
assertArrayEquals(transformedBytes, received,
- "layer2 debe recibir el payload transformado, no el original");
+ "layer2 must receive the transformed payload, not the original");
return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(new byte[]{0x02}));
});
@@ -603,7 +603,7 @@ void shouldPassTransformedPayloadToNextLayer() {
@Test
void shouldCorrectlyOutputLayerOrderWithMixedUnmodifiedTransformed() {
- // Traza completa: verificar posición exacta de cada byte en el output
+ // Full trace: verify exact position of each byte in the output
// layers: [routing(unmod), encryption(transform), compression(unmod)]
LLPLayerBuilder compressionLayer = mock(LLPLayerBuilder.class);
@@ -632,7 +632,7 @@ void shouldCorrectlyOutputLayerOrderWithMixedUnmodifiedTransformed() {
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0xFF}));
- // layer1 header fue descartado por la transformación de layer2
+ // 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])
@@ -644,7 +644,7 @@ void shouldCorrectlyOutputLayerOrderWithMixedUnmodifiedTransformed() {
@Test
void shouldHandleMetadataLengthExactly256WithExtendedFormat() {
- // 256 = primer valor que necesita el byte high != 0x00 en extended
+ // 256 = first value requiring high byte != 0x00 in extended
when(layer1.getLayerId()).thenReturn(1);
byte[] meta = new byte[256];
@@ -657,8 +657,8 @@ void shouldHandleMetadataLengthExactly256WithExtendedFormat() {
assertEquals(0x01, result[0] & 0xFF); // ID
assertEquals(0xFF, result[1] & 0xFF); // extended flag
- assertEquals(0x01, result[2] & 0xFF); // high byte de 256 (0x01)
- assertEquals(0x00, result[3] & 0xFF); // low byte de 256 (0x00)
+ 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);
@@ -666,13 +666,13 @@ void shouldHandleMetadataLengthExactly256WithExtendedFormat() {
@Test
void shouldNotInvokeAnyLayerWhenLayerListIsEmpty() {
- // Con lista vacía no se debe llamar a ningún builder
+ // 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 hay mocks que verificar, pero el test confirma que no lanza excepción
+ // No mocks to verify, but the test confirms no exception is thrown
}
@Test
@@ -690,13 +690,13 @@ void shouldStopAtFirstFailureWithoutCallingSubsequentLayers() {
() -> builder.build(ByteBuffer.wrap(new byte[]{0x01}))
);
- // layer2 nunca debe haberse llamado
+ // layer2 should never have been called
verify(layer2, never()).build(any());
}
@Test
void shouldHandleLayerWithExactly254ByteMetadataAndVerifyRoundTrip() {
- // 254 es el último valor sin flag extendida — probar que el byte no se confunde con 0xFF
+ // 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];
@@ -710,14 +710,14 @@ void shouldHandleLayerWithExactly254ByteMetadataAndVerifyRoundTrip() {
byte[] result = builder.build(ByteBuffer.wrap(new byte[]{(byte) 0xAB}));
assertEquals(0x01, result[0] & 0xFF); // ID
- assertEquals(254, result[1] & 0xFF); // LEN sin extended
- // Verificar que el byte 1 NO es 0xFF (lo que activaría extended en el parser)
+ 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);
- // Verificar contenido de metadata
+ // Verify metadata content
for (int i = 0; i < 254; i++) {
assertEquals((byte)(i & 0xFF), result[2 + i]);
}
From 86b19a78b4b4d0a851d9006ec394c03beb6cb87c Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sat, 2 May 2026 02:22:07 -0300
Subject: [PATCH 27/30] Se renombra LayerRegistry por LayerParserRegistry y se
crean tests
---
.../comm/llp/core/LayerParserProvider.java | 6 -
.../comm/llp/core/LayerParserRegistry.java | 115 +++++++++++++
.../flamingo/comm/llp/core/LayerRegistry.java | 63 -------
.../llp/core/LayerParserRegistryTest.java | 157 ++++++++++++++++++
4 files changed, 272 insertions(+), 69 deletions(-)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LayerParserRegistry.java
delete mode 100644 src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LayerParserRegistryTest.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
index 25a0d9d..dfdf86f 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
@@ -12,12 +12,6 @@
* the underlying mechanism used to discover or provide layer parsers.
* It can be backed by a registry, dependency injection, or custom logic.
*
- * Typical usage includes:
- *
- * - Default SPI-based lookup using {@link LayerRegistry}
- * - Custom providers for testing or controlled environments
- *
- *
* This interface is designed to be lightweight and easily replaceable,
* making it suitable for dependency injection and testing.
*/
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/LayerRegistry.java b/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
deleted file mode 100644
index fd7143d..0000000
--- a/src/main/java/com/flamingo/comm/llp/core/LayerRegistry.java
+++ /dev/null
@@ -1,63 +0,0 @@
-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;
-
-/**
- * Default registry of {@link LLPLayerParser} implementations discovered via Java SPI.
- *
- * This class uses {@link ServiceLoader} to automatically load all available
- * implementations of {@link LLPLayerParser} present on the classpath at runtime.
- *
- * Each parser is indexed by its unique layer identifier, as defined by
- * {@link LLPLayerParser#getLayerId()}.
- *
- * This registry is typically used as the default {@link LayerParserProvider}
- * in the LLP core parser, enabling a plugin-based architecture where external
- * libraries can contribute new protocol layers.
- *
- * Important considerations:
- *
- * - Layer IDs must be unique across all loaded parsers
- * - If multiple parsers declare the same ID, the last one loaded will overwrite the previous
- * - Parsers are loaded once at class initialization time
- *
- *
- * This class is thread-safe for read operations after initialization.
- */
-final class LayerRegistry {
-
- private static final Map parsers = new HashMap<>();
-
- static {
- ServiceLoader loader = ServiceLoader.load(LLPLayerParser.class);
- for (LLPLayerParser parser : loader) {
-
- int parserId = parser.getLayerId();
- if (parsers.containsKey(parserId)) {
- throw new IllegalStateException("Duplicate layer ID: " + parserId);
- }
-
- parsers.put(parserId, parser);
- }
- }
-
- private LayerRegistry() {
- // Utility class - no instances allowed
- }
-
- /**
- * Returns the parser associated with the given layer ID.
- *
- * @param id the layer identifier
- * @return an {@link Optional} containing the parser if found,
- * or empty if no parser is registered for the given ID
- */
- static Optional get(int id) {
- return Optional.ofNullable(parsers.get(id));
- }
-}
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);
+ }
+}
From 7cd2e46ba5b00af9471a3c30a40808db650272a9 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 8 May 2026 17:02:45 -0300
Subject: [PATCH 28/30] =?UTF-8?q?a=C3=B1adido=20equals()=20y=20hashcode()?=
=?UTF-8?q?=20en=20implementaciones=20LLPNode=20del=20core?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/flamingo/comm/llp/core/FailureNode.java | 15 +++++++++++++++
.../com/flamingo/comm/llp/core/FinalNode.java | 13 +++++++++++++
.../com/flamingo/comm/llp/core/UnknownNode.java | 14 ++++++++++++++
3 files changed, 42 insertions(+)
diff --git a/src/main/java/com/flamingo/comm/llp/core/FailureNode.java b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
index 07f62b8..6e62e22 100644
--- a/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/FailureNode.java
@@ -4,6 +4,7 @@
import com.flamingo.comm.llp.spi.ParseErrorReason;
import java.nio.ByteBuffer;
+import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
@@ -117,4 +118,18 @@ public String toString() {
(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
index c6c21d4..6c41a98 100644
--- a/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/FinalNode.java
@@ -3,6 +3,7 @@
import com.flamingo.comm.llp.spi.LLPNode;
import java.nio.ByteBuffer;
+import java.util.Arrays;
import java.util.HexFormat;
import java.util.Locale;
@@ -85,4 +86,16 @@ public String toString() {
"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/UnknownNode.java b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
index 63b1745..cca8c51 100644
--- a/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/UnknownNode.java
@@ -3,6 +3,8 @@
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.
@@ -46,4 +48,16 @@ public String toString() {
", 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));
+ }
}
From 17e6b026d085916c69c8621210719362c062e826 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 8 May 2026 17:03:58 -0300
Subject: [PATCH 29/30] Creado LLP facade y LLPIncrementalParser con tests
cases
---
.../java/com/flamingo/comm/llp/core/LLP.java | 150 ++++-
.../comm/llp/core/LLPIncrementalParser.java | 174 ++++++
.../comm/llp/core/LayerParserProvider.java | 2 +-
.../llp/core/LLPIncrementalParserTest.java | 551 ++++++++++++++++++
.../com/flamingo/comm/llp/core/LLPTest.java | 386 ++++++++++++
5 files changed, 1261 insertions(+), 2 deletions(-)
create mode 100644 src/main/java/com/flamingo/comm/llp/core/LLPIncrementalParser.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LLPIncrementalParserTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/core/LLPTest.java
diff --git a/src/main/java/com/flamingo/comm/llp/core/LLP.java b/src/main/java/com/flamingo/comm/llp/core/LLP.java
index 9cda66a..74e837a 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LLP.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LLP.java
@@ -1,5 +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/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/LayerParserProvider.java b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
index dfdf86f..a98940d 100644
--- a/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
+++ b/src/main/java/com/flamingo/comm/llp/core/LayerParserProvider.java
@@ -16,7 +16,7 @@
* making it suitable for dependency injection and testing.
*/
@FunctionalInterface
-interface LayerParserProvider {
+public interface LayerParserProvider {
/**
* Returns a parser for the given layer identifier.
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/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
From 3fdccfd32bc5af755b50246a71ea64e5cb850112 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 8 May 2026 21:10:18 -0300
Subject: [PATCH 30/30] Actualizado README.md con la version 3.0.0 del
protocolo
---
README.md | 558 +++++++++++++++++++++++++++++++++++++++---------------
1 file changed, 401 insertions(+), 157 deletions(-)
diff --git a/README.md b/README.md
index 284e51b..c127f98 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,19 @@
-# LLP Protocol - Implementación en Java
+# Protocolo LLP — Implementación en Java
-[](https://github.com/EnzoLeonel/llp-protocol-java/actions/workflows/maven-publish.yml)
-[](LICENSE)
-[](https://www.oracle.com/java/)
-[](https://codecov.io/github/EnzoLeonel/llp-protocol)
-
-Implementación en **Java 21** del protocolo **LLP (Lightweight Link Protocol)** para comunicación robusta, eficiente y extensible entre microcontroladores y aplicaciones Java.
+Implementación en **Java 21** de **LLP (Layered Link Protocol)** — un protocolo de comunicación robusto, eficiente y extensible diseñado para la comunicación de dispositivos IoT. LLP está construido en torno a un modelo de cebolla en capas (layered onion model), donde cada trama puede transportar capas de protocolo opcionales sobre la capa de transporte obligatoria.
---
## 🚀 Características
-* ✅ **Liviano:** Optimizado para microcontroladores y entornos con recursos limitados
-* 🛡️ **Robusto:** CRC16-CCITT, sincronización tolerante a ruido, timeouts
-* 🔧 **Extensible:** Hasta 256 tipos de mensaje personalizables
-* ⚡ **Bidireccional:** Soporta request-response y eventos asíncronos
-* 📦 **Agnóstico al transporte:** Funciona sobre UART, RF, RS485, TCP/IP, etc.
-* 🧵 **Preparado para concurrencia:** Diseñado para procesamiento en un solo hilo con manejo de eventos
-* 📚 **Documentado:** Javadoc, ejemplos y tests incluidos
+* ✅ **Ligero:** Optimizado para entornos limitados y comunicación con microcontroladores.
+* 🧅 **Arquitectura en capas:** Las tramas transportan capas anidadas opcionales (enrutamiento, encriptación, compresión, etc.).
+* 🔌 **Sistema de plugins:** Las nuevas capas se añaden como bibliotecas independientes descubiertas automáticamente vía Java SPI.
+* 🛡️ **Transporte robusto:** Validación CRC16-CCITT, sincronización tolerante al ruido, *byte stuffing* (relleno de bytes), tiempos de espera configurables.
+* 📡 **Agnóstico al transporte:** Funciona sobre UART, RF, RS485, TCP/IP, Bluetooth y cualquier flujo de bytes.
+* ⚡ **Preparado para Streaming:** Análisis incremental byte por byte para transportes basados en interrupciones o streaming.
+* 🧱 **Separación de responsabilidades:** Tuberías independientes de construcción y análisis de tramas — usa solo lo que necesites.
+* 📚 **Completamente documentado:** Javadoc, ejemplos y suite completa de pruebas incluidas.
---
@@ -30,42 +26,28 @@ Implementación en **Java 21** del protocolo **LLP (Lightweight Link Protocol)**
## 📦 Instalación
-### Usando Maven (GitHub Packages)
-
-Agregá la dependencia en tu `pom.xml`:
+Añade la dependencia a tu `pom.xml`:
```xml
com.flamingo
llp-protocol
- 2.0.0
+ 3.0.0
-```
-
----
-### ⚠️ Requisito: Configurar acceso a GitHub Packages
-
-Esta librería está publicada en GitHub Packages, por lo que es necesario autenticarse.
+```
-#### 1. Crear un Personal Access Token
+### Autenticación en GitHub Packages
-En GitHub:
+Esta librería está publicada en GitHub Packages. Se requiere autenticación.
-* Ir a: Settings → Developer Settings → Personal Access Tokens
-* Crear un token con permisos:
+**1. Crea un Token de Acceso Personal (Personal Access Token)**
- * `read:packages`
+Ve a: GitHub → Settings → Developer Settings → Personal Access Tokens
----
+Crea un token con el permiso `read:packages`.
-#### 2. Configurar `settings.xml`
-
-Editar o crear:
-
-```id="settings"
-~/.m2/settings.xml
-```
+**2. Configura `~/.m2/settings.xml**`**
```xml
@@ -75,11 +57,10 @@ Editar o crear:
TU_TOKEN
-```
----
+```
-#### 3. Agregar repositorio en `pom.xml`
+**3. Añade el repositorio a tu `pom.xml**`
```xml
@@ -89,189 +70,450 @@ Editar o crear:
https://maven.pkg.github.com/EnzoLeonel/llp-protocol-java
-```
----
-
-### ✅ Verificación
+```
-Luego de configurar todo:
+**4. Verifica**
```bash
mvn clean install
+
```
---
-## 🏃 Inicio Rápido
+## 🏃 Inicio Rápido (Quick Start)
+
+### Construcción de una trama (salida / outbound)
```java
-import com.flamingo.comm.llp.*;
+import com.flamingo.comm.llp.core.LLP;
+import com.flamingo.comm.llp.core.LLPFrameBuilder;
-LLPParser parser = LLP.newParser();
+import java.nio.ByteBuffer;
-// Listener de eventos
-parser.addListener(new LLPParser.LLPFrameListener() {
- @Override
- public void onFrameReceived(LLPFrame frame) {
- System.out.println("Frame recibido: " + frame);
- }
+// Minimal frame — transport layer only, no additional layers
+LLPFrameBuilder builder = LLP.frameBuilder().build();
- @Override
- public void onFrameError(LLPErrorCode errorCode) {
- System.out.println("Error: 0x" + Integer.toHexString(errorCode.code()));
- }
-});
+byte[] frame = builder.build(ByteBuffer.wrap("hello device".getBytes()));
+// uart.write(frame); // Example: send via your preferred transport
+
+```
-// Simulación de lectura desde un stream (UART, TCP, etc.)
-InputStream in = ...;
+### Análisis incremental de un flujo de datos (entrada / inbound)
-int data;
-while ((data = in.read()) != -1) {
- try {
- LLPFrame frame = parser.processByte((byte) data);
- if (frame != null) {
- // Procesar frame completo
- }
- } catch (Exception e) {
- System.err.println("Error: " + e.getMessage());
+```java
+import com.flamingo.comm.llp.core.LLP;
+import com.flamingo.comm.llp.core.LLPIncrementalParser;
+import com.flamingo.comm.llp.core.LLPFrame;
+import com.flamingo.comm.llp.core.FinalNode;
+
+LLPIncrementalParser parser = LLP.incrementalParser()
+ .maxPayloadBytes(4096)
+ .timeoutMs(2000)
+ .build();
+
+// Feed bytes as they arrive from the transport (UART, TCP, etc.)
+// InputStream in = serialPort.getInputStream(); // Example
+int b;
+while ((b = in.read()) != -1) {
+ parser.feed((byte) b);
+
+ for (LLPFrame frame : parser.pollFrames()) {
+ // Navigate the node chain
+ frame.chain().visit(visitor -> {
+ visitor.onFinalNode(node -> {
+ byte[] payload = new byte[node.getPayload().remaining()];
+ node.getPayload().get(payload);
+ System.out.println("Received: " + new String(payload));
+ });
+ visitor.onUnknownNode(node ->
+ System.out.println("Unknown layer skipped: ID=" + node.getId()));
+ visitor.onFailureNode(node ->
+ System.err.println("Layer failed: ID=" + node.getId()
+ + " reason=" + node.getErrorReason()));
+ });
+ }
+
+ for (TransportErrorCode error : parser.pollErrors()) {
+ System.err.println("Transport error: " + error);
}
}
-// Enviar un frame
-byte[] payload = "Hello".getBytes();
-byte[] frame = LLP.buildData(1, payload);
-outputStream.write(frame);
```
----
+### Análisis de tramas completas (no streaming)
-## 📦 Estructura del Frame
+```java
+LLPFrameParser parser = LLP.frameParser().build();
-| Campo | Tamaño | Descripción |
-|---------|---------|---------------------------|
-| Magic | 2 bytes | 0xAA 0x55 |
-| Version | 1 bytes | Version del protocolo LLP |
-| Type | 1 byte | Tipo de mensaje |
-| ID | 2 bytes | ID de transacción (LE) |
-| Length | 2 bytes | Tamaño del payload (LE) |
-| Payload | N bytes | Datos |
-| CRC16 | 2 bytes | CRC-CCITT (LE) |
+// rawFrame is an LLPRawFrame produced by LLPTransportDeframer
+LLPFrame frame = parser.parse(rawFrame);
----
+```
-## 🔌 Tipos de Mensaje
+### Uso de plugins de capas
-| Tipo | Valor | Descripción |
-| --------- | ----- | ---------------------- |
-| `PING` | 0x01 | Prueba de enlace |
-| `ACK` | 0x02 | Confirmación positiva |
-| `NACK` | 0x03 | Confirmación negativa |
-| `DATA` | 0x10 | Datos genéricos |
-| `CONFIG` | 0x11 | Configuración |
-| `STATUS` | 0x12 | Estado del dispositivo |
-| `COMMAND` | 0x13 | Comando a ejecutar |
-| `EVENT` | 0x14 | Evento |
-| `ERROR` | 0x15 | Error |
+Cuando una librería de capa (ej. `llp-layer-routing`) está presente en el classpath, es descubierta automáticamente vía SPI — no se requiere configuración.
+
+```java
+// Frame building with layers
+LLPFrameBuilder builder = LLP.frameBuilder()
+ .addLayer(new RoutingLayerBuilder("sensor-42", "zone-north")) // inner layer
+ .addLayer(new EncryptionLayerBuilder(Algorithm.AES_256_GCM, key)) // outer layer
+ .build();
-👉 Tipos personalizados: `0x16` a `0xFF`
+byte[] frame = builder.build(ByteBuffer.wrap(telemetryData));
+
+// Parsing with layers (handlers discovered automatically via SPI)
+LLPIncrementalParser parser = LLP.incrementalParser().build();
+
+parser.feed(frame);
+LLPFrame parsed = parser.pollFrames().getFirst();
+
+// Access metadata from a specific layer
+parsed.chain().getNode(RoutingNode.class).ifPresent(node -> {
+ System.out.println("Device: " + node.getMetadata().deviceId());
+ System.out.println("Group: " + node.getMetadata().group());
+});
+
+```
---
-## 🛠️ Arquitectura
+## 📦 Estructura de la Trama
+
+### Trama de transporte
+
+El envoltorio más externo validado por la capa de transporte:
+
+```
+[MAGIC 2B][LENGTH 2B][PAYLOAD NB][CRC16 2B]
```
-com.flamingo.comm
- └── llp
- ├── LLP.java # Facade principal
- ├── LLPParser.java # Parser (state machine)
- ├── LLPFrame.java # Modelo de datos
- ├── LLPFrameBuilder.java # Builder de frames
- ├── LLPMessageType.java # Enum tipos
- ├── LLPErrorCode.java # Enum errores
- ├── crc/
- │ └── CRC16CCITT.java # CRC
- └── util/
- └── Statistics.java # Métricas
+
+| Campo | Tamaño | Valor / Descripción |
+| --- | --- | --- |
+| Magic | 2 bytes | `0xAA 0x55` — delimitador de trama |
+| Length | 2 bytes | Tamaño del payload en bytes (little-endian) |
+| Payload | N bytes | Cadena de capas codificada (ver abajo) |
+| CRC16 | 2 bytes | CRC16-CCITT sobre Magic + Length + Payload (LE) |
+
+Se aplica *byte stuffing* a todos los campos excepto Magic: cualquier byte `0xAA` en el flujo se escapa como `0xAA 0x00`. Una secuencia inesperada `0xAA 0x55` dentro de una trama señala un evento de resincronización.
+
+### Payload de capa (dentro de la trama de transporte)
+
+El payload contiene una cadena recursiva de capas opcionales seguida de los datos crudos finales:
+
+```
+[LAYER_ID][META_LENGTH][METADATA ...][ next layer or final ]
+ ↓
+ [0x00][RAW PAYLOAD BYTES]
+
```
+| Campo | Tamaño | Descripción |
+| --- | --- | --- |
+| Layer ID | 1 byte | Identifica la capa. `0` = payload final. Ver reglas abajo. |
+| Meta length | 1–3 bytes | Tamaño de los metadatos. Valores `0–254` usan 1 byte; `≥255` usan `0xFF` + 2 bytes (big-endian) |
+| Metadata | N bytes | Metadatos específicos de la capa (definidos por cada librería de capa) |
+| Payload | Resto | Siguiente capa o bytes crudos finales |
+
+#### Reglas de Layer ID (ID de Capa)
+
+| Rango de ID | Significado |
+| --- | --- |
+| `0` | **Final node (Nodo final)** — no hay más capas; los bytes restantes son el payload crudo de la aplicación. |
+| `1–127` | **Passthrough layer (Capa de paso)** — los metadatos pueden omitirse; el contenido del payload no cambia. |
+| `128–254` | **Transform layer (Capa de transformación)** — el payload fue modificado (encriptado, comprimido, etc.); no puede omitirse sin la librería de la capa. |
+| `255` | Reservado |
+
---
-## ⚠️ Alcance del Protocolo
+## 🏛️ Arquitectura
+
+```
+com.flamingo.comm.llp/
+│
+├── core/ # Core library — transport + layer parsing
+│ ├── LLP.java # Static entry point and factory
+│ ├── LLPFrameBuilder.java # Outbound frame builder interface
+│ ├── LLPFrameParser.java # Inbound frame parser interface
+│ ├── LLPIncrementalParser.java # Streaming/incremental inbound parser
+│ ├── LLPTransportFramer.java # Transport framing (magic, CRC, stuffing)
+│ ├── LLPTransportDeframer.java # Transport deframing state machine
+│ ├── LLPFrame.java # Parsed frame with node chain
+│ ├── LLPRawFrame.java # Transport-level validated frame
+│ ├── NodeChain.java # Immutable ordered chain of nodes
+│ ├── FinalNode.java # Terminal node (raw payload)
+│ ├── UnknownNode.java # Skipped unknown passthrough layer
+│ └── FailureNode.java # Failed-to-parse layer node
+│
+└── spi/ # SPI contracts for layer plugins
+ ├── LLPLayerParser.java # Interface for inbound layer parsing
+ ├── LLPLayerBuilder.java # Interface for outbound layer building
+ ├── LLPNode.java # Base node interface
+ ├── LayerParseResult.java # Sealed result type (Success | Failure)
+ ├── LayerBuildResult.java # Sealed result type (UnmodifiedPayload | TransformedPayload | Failure)
+ ├── LayerParseInput.java # Read-only metadata + payload for parsing
+ └── LayerBuildPayload.java # Read-only payload for building
+
+```
+
+### Puntos de entrada
+
+`LLP` expone tres métodos de fábrica independientes — usa solo lo que tu caso de uso requiera:
+
+```java
+// Outbound only — build and serialize frames
+LLPFrameBuilder builder = LLP.frameBuilder()
+ .addLayer(...)
+ .build();
+
+// Inbound only — parse complete LLPRawFrame objects
+LLPFrameParser parser = LLP.frameParser()
+ .parserProvider(customProvider) // optional; defaults to SPI discovery
+ .build();
+
+// Inbound only — streaming, byte-by-byte, pull-based
+LLPIncrementalParser incremental = LLP.incrementalParser()
+ .maxPayloadBytes(8192)
+ .timeoutMs(1000)
+ .build();
-LLP es un protocolo de transporte liviano. **NO incluye:**
+```
-* Identificación de dispositivos
-* Routing
-* Manejo de sesiones
-* Encriptación
+### Tubería de entrada (Inbound pipeline)
-👉 Estas funcionalidades deben implementarse en capas superiores si son necesarias.
+```
+byte stream
+ └── LLPTransportDeframer (sync · unstuffing · CRC validation)
+ └── LLPRawFrame
+ └── SimpleFrameParser (layer chain parsing via SPI registry)
+ └── LLPFrame → NodeChain → [Node, Node, ..., FinalNode]
+
+```
+
+### Tubería de salida (Outbound pipeline)
+
+```
+ByteBuffer payload
+ └── ByteArrayFrameBuilder (applies layer builders in order)
+ └── byte[] (layered payload)
+ └── LLPTransportFramer (magic · length · stuffing · CRC)
+ └── byte[] (transport frame ready to transmit)
+
+```
---
-## 🔌 Ejemplo con TCP
+## 🔌 Sistema de Plugins (SPI)
+
+Las nuevas capas de protocolo se implementan como módulos de Maven independientes. La librería base las descubre en tiempo de ejecución utilizando el `ServiceLoader` de Java — no se requiere registro manual.
+
+### Creación de un plugin de capa
+
+**1. Añade la dependencia base (core)**
+
+```xml
+
+ com.flamingo
+ llp-protocol
+ 3.0.0
+
+
+```
+
+**2. Implementa `LLPLayerParser` (entrada)**
```java
-Socket socket = new Socket("192.168.1.10", 23);
-
-InputStream in = socket.getInputStream();
-OutputStream out = socket.getOutputStream();
-
-LLPParser parser = LLP.newParser();
-
-// Hilo de recepción
-new Thread(() -> {
- try {
- int b;
- while ((b = in.read()) != -1) {
- LLPFrame frame = parser.processByte((byte) b);
- if (frame != null) {
- System.out.println("RX: " + frame);
- }
+public class RoutingLayerParser implements LLPLayerParser {
+
+ public static final int LAYER_ID = 45; // 1–127: passthrough
+
+ @Override
+ public int getLayerId() { return LAYER_ID; }
+
+ @Override
+ public LayerParseResult parse(LayerParseInput input) {
+ MetadataReader reader = MetadataReader.wrap(input.metadata());
+ try {
+ String deviceId = reader.readUtf8(reader.readUInt8());
+ String group = reader.readUtf8(reader.readUInt8());
+ int ttl = reader.readUInt8();
+
+ return LayerParseResult.success(
+ new RoutingNode(new RoutingMetadata(deviceId, group, ttl)),
+ input.payload() // passthrough: payload unchanged
+ );
+ } catch (Exception e) {
+ return LayerParseResult.failure(ParseErrorReason.INVALID_METADATA);
}
- } catch (Exception e) {
- e.printStackTrace();
}
-}).start();
+}
+
+```
+
+**3. Implementa `LLPLayerBuilder` (salida)**
+
+```java
+public class RoutingLayerBuilder implements LLPLayerBuilder {
+
+ private final String deviceId;
+ private final String group;
+
+ public RoutingLayerBuilder(String deviceId, String group) {
+ this.deviceId = deviceId;
+ this.group = group;
+ }
+
+ @Override
+ public int getLayerId() { return RoutingLayerParser.LAYER_ID; }
+
+ @Override
+ public LayerBuildResult build(LayerBuildPayload payload) {
+ byte[] deviceIdBytes = deviceId.getBytes(StandardCharsets.UTF_8);
+ byte[] groupBytes = group.getBytes(StandardCharsets.UTF_8);
+
+ byte[] metadata = MetadataWriter.create()
+ .writeUInt8(deviceIdBytes.length).writeBytes(deviceIdBytes)
+ .writeUInt8(groupBytes.length).writeBytes(groupBytes)
+ .writeUInt8(3) // TTL default
+ .toBytes();
+
+ // Passthrough: payload is not modified
+ return LayerBuildResult.unmodified(ByteBuffer.wrap(metadata));
+ }
+}
+
+```
+
+**4. Registro vía SPI**
+
+Crea el archivo `src/main/resources/META-INF/services/com.flamingo.comm.llp.spi.LLPLayerParser`:
+
+```
+com.example.llp.routing.RoutingLayerParser
+
+```
+
+La librería base lo descubrirá y registrará automáticamente al inicio.
+
+### Asignación de Layer ID
+
+| Rango | Tipo | Comportamiento del Payload | Requiere SPI para decodificar |
+| --- | --- | --- | --- |
+| `1–127` | Passthrough | Sin cambios — puede omitirse | No |
+| `128–254` | Transform | Modificado (encriptado/comprimido) | Sí |
+
+---
+
+## 🧩 Tipos de Nodos
+
+Tras el análisis, el `NodeChain` de la trama contiene una secuencia ordenada de nodos desde el más externo al más interno:
+
+| Tipo de nodo | Cuándo se crea | Métodos clave |
+| --- | --- | --- |
+| `LLPNode` | Implementaciones SPI (capas personalizadas) | `getId()` |
+| `FinalNode` | Siempre — marca el final de la cadena con bytes crudos | `getId()`, `getPayload()` |
+| `UnknownNode` | El Layer ID no tiene manejador y es de tipo passthrough | `getId()`, `getMetadata()` |
+| `FailureNode` | Falló el análisis de la capa (error de plugin o metadatos corruptos) | `getId()`, `getErrorReason()`, `getCause()`, `getMetadata()` |
+
+### Navegación por la cadena
+
+```java
+// Option A — visitor (recommended for production code)
+frame.chain().visit(visitor -> {
+ visitor.onFinalNode(node -> handlePayload(node.getPayload()));
+ visitor.onUnknownNode(node -> log.debug("Skipped layer {}", node.getId()));
+ visitor.onFailureNode(node -> log.warn("Failed layer {}: {}", node.getId(), node.getErrorReason()));
+});
+
+// Option B — find a specific node type
+frame.chain().getNode(RoutingNode.class)
+ .ifPresent(n -> route(n.getMetadata().deviceId()));
+
+// Option C — find a node by layer ID
+frame.chain().getNode(45)
+ .ifPresent(n -> System.out.println("Routing node: " + n));
+
+// Option D — access the raw payload directly (innermost node)
+LLPNode deepest = frame.chain().getDeepestNode();
+if (deepest instanceof FinalNode final) {
+ process(final.getPayload());
+}
-// Enviar PING
-byte[] frame = LLP.buildPing(1);
-out.write(frame);
```
---
-## 🧪 Tests
+## 🔄 Migración desde v2.x
+
+La versión 3.0 introduce un nuevo modelo de tramas en capas y una API pública rediseñada. La API de la v2 ha sido eliminada.
+
+| v2.x | v3.0 |
+| --- | --- |
+| `LLP.newParser()` | `LLP.incrementalParser().build()` |
+| `parser.processByte(b)` | `parser.feed(b)` + `parser.pollFrames()` |
+| `parser.addListener(...)` | Maneja los resultados desde `pollFrames()` / `pollErrors()` |
+| `LLP.buildData(type, payload)` | `LLP.frameBuilder().build()` + `.build(payload)` |
+| `LLPFrame.getType()` | Eliminado — el tipo de mensaje es ahora un asunto de la capa |
+| `LLPFrame.getId()` | Eliminado — el ID de transacción es ahora un asunto de la capa |
+| `LLPMessageType` | Eliminado — define los tipos de mensajes en tu capa |
+| Formato de trama única | Modelo de cebolla en capas (layered onion model) con capas opcionales |
+
+El formato de la trama cambió significativamente en la v3 para soportar el modelo de capas. Las tramas v2 y v3 **no son compatibles a nivel de red (wire-compatible)**.
+
+---
+
+## 🧪 Pruebas
```bash
+# Run unit and integration tests
mvn test
+
+# Run tests with coverage report
mvn verify
+
```
-## 📊 Rendimiento
+La suite de pruebas cubre:
-* Procesamiento eficiente byte a byte
-* Bajo overhead de memoria
-* Implementación de CRC optimizada
+* *Framing* y *deframing* de transporte (incluyendo byte stuffing, CRC, timeouts, recuperación de sincronización).
+* Análisis de cadena de capas (capas conocidas, desconocidas y con fallos).
+* Construcción de tramas con una o múltiples capas.
+* Análisis incremental (streaming) a través de los tres métodos `feed()`.
+* Registro SPI (detección de duplicados, registro manual, descubrimiento SPI).
+* Casos límite (edge cases): payloads vacíos, longitudes de metadatos extendidas, fallos no omitibles (non-skippable).
---
-## 🤝 Contribuciones
+## 📊 Notas de rendimiento
+
+* Cero copias intermedias durante la creación de tramas de transporte (`LLPTransportFramer`).
+* Una única asignación de memoria para el array final de la trama en `ByteArrayFrameBuilder`.
+* Uso intensivo de `ByteBuffer.slice()` y `duplicate()` para evitar copias de datos durante el análisis (parsing).
+* `NodeChain.Builder` perezoso (lazy) — sin asignación de memoria hasta que se añade el primer nodo.
+* Resultados inmutables — todos los objetos analizados son seguros para compartir entre hilos (threads) después de su creación.
+
+---
+
+## 🤝 Contribuir
Las contribuciones son bienvenidas:
-1. Fork del repositorio
-2. Crear rama (`feature/nueva-funcionalidad`)
-3. Commit
-4. Push
-5. Pull Request
+1. Haz un Fork del repositorio.
+2. Crea una rama (`feature/nueva-caracteristica`).
+3. Haz un Commit con tus cambios.
+4. Haz Push y abre un Pull Request.
+
+Todo el código, comentarios, Javadoc y nombres de variables deben escribirse en **Inglés**.
---
## 📜 Licencia
-MIT License - ver [LICENSE](LICENSE)
+Licencia MIT — ver [LICENSE](https://www.google.com/search?q=LICENSE)
---
@@ -281,6 +523,8 @@ Creado por **EnzoLeonel**
---
-**Versión:** 1.0.0
-**Última actualización:** 2026-03-31
-**Java Target:** 21+
+**Versión:** 3.0.0
+
+**Última actualización:** 2026-05-08
+
+**Objetivo Java:** 21+
\ No newline at end of file