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:

    + *
      + *
    1. Transport deframing using {@link LLPTransportDeframer}
    2. + *
    3. Layer parsing using {@link LLPFrameParser}
    4. + *
    + * + *

    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 -[![Maven Package](https://github.com/EnzoLeonel/llp-protocol-java/actions/workflows/maven-publish.yml/badge.svg)](https://github.com/EnzoLeonel/llp-protocol-java/actions/workflows/maven-publish.yml) -[![Licencia: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Java 21](https://img.shields.io/badge/Java-21-blue)](https://www.oracle.com/java/) -[![codecov](https://codecov.io/github/EnzoLeonel/llp-protocol/graph/badge.svg?token=A6Q68GQDBJ)](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