From 5d3fb78aef15c283b8e30cace3b6595a0c85ab7f Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sat, 16 May 2026 23:37:40 -0300
Subject: [PATCH 1/2] Creados tests de vectores para especificacion 3.1.0 de
llp
---
.github/workflows/maven-verify.yml | 4 +-
pom.xml | 30 +
.../comm/llp/core/CoreParseErrorReason.java | 7 +-
.../comm/llp/core/SimpleFrameParser.java | 25 +-
.../comm/llp/core/TransportErrorCode.java | 4 +-
.../com/flamingo/comm/llp/util/LayerIds.java | 44 +-
.../comm/llp/core/SimpleFrameParserTest.java | 7 +-
.../llp/spec/LayerTraversalVectorTest.java | 130 +++
.../comm/llp/spec/ParserVectorTest.java | 133 +++
.../flamingo/comm/llp/spec/TestVector.java | 15 +
.../llp/spec/TransportDecodeVectorTest.java | 175 ++++
.../llp/spec/TransportEncodeVectorTest.java | 32 +
.../llp/spec/TransportStreamVectorTest.java | 94 ++
.../llp/spec/TransportTimingVectorTest.java | 110 ++
.../flamingo/comm/llp/spec/VectorLoader.java | 126 +++
src/test/resources/llp-vectors/crc.json | 399 +++++++
.../resources/llp-vectors/fragmented.json | 153 +++
.../resources/llp-vectors/incremental.json | 182 ++++
src/test/resources/llp-vectors/malformed.json | 65 ++
.../resources/llp-vectors/passthrough.json | 460 +++++++++
src/test/resources/llp-vectors/recovery.json | 140 +++
src/test/resources/llp-vectors/resync.json | 183 ++++
src/test/resources/llp-vectors/stuffing.json | 119 +++
src/test/resources/llp-vectors/timeout.json | 322 ++++++
src/test/resources/llp-vectors/transform.json | 183 ++++
src/test/resources/llp-vectors/traversal.json | 81 ++
.../resources/llp-vectors/truncation.json | 146 +++
src/test/resources/llp-vectors/valid.json | 974 ++++++++++++++++++
28 files changed, 4318 insertions(+), 25 deletions(-)
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/LayerTraversalVectorTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/ParserVectorTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/TestVector.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/TransportDecodeVectorTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/TransportEncodeVectorTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/TransportStreamVectorTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/TransportTimingVectorTest.java
create mode 100644 src/test/java/com/flamingo/comm/llp/spec/VectorLoader.java
create mode 100644 src/test/resources/llp-vectors/crc.json
create mode 100644 src/test/resources/llp-vectors/fragmented.json
create mode 100644 src/test/resources/llp-vectors/incremental.json
create mode 100644 src/test/resources/llp-vectors/malformed.json
create mode 100644 src/test/resources/llp-vectors/passthrough.json
create mode 100644 src/test/resources/llp-vectors/recovery.json
create mode 100644 src/test/resources/llp-vectors/resync.json
create mode 100644 src/test/resources/llp-vectors/stuffing.json
create mode 100644 src/test/resources/llp-vectors/timeout.json
create mode 100644 src/test/resources/llp-vectors/transform.json
create mode 100644 src/test/resources/llp-vectors/traversal.json
create mode 100644 src/test/resources/llp-vectors/truncation.json
create mode 100644 src/test/resources/llp-vectors/valid.json
diff --git a/.github/workflows/maven-verify.yml b/.github/workflows/maven-verify.yml
index 897ec34..a9b0ac3 100644
--- a/.github/workflows/maven-verify.yml
+++ b/.github/workflows/maven-verify.yml
@@ -2,9 +2,9 @@ name: Maven Verify
on:
pull_request:
- branches: [main, develop]
+ branches: [main, dev]
push:
- branches: [main, develop]
+ branches: [main, dev]
jobs:
verify:
diff --git a/pom.xml b/pom.xml
index ad10072..0e5aa69 100644
--- a/pom.xml
+++ b/pom.xml
@@ -88,6 +88,13 @@
5.23.0
test
+
+
+ tools.jackson.core
+ jackson-databind
+ 3.1.3
+ test
+
@@ -201,4 +208,27 @@
+
+
+ slow-tests
+
+ true
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ ${maven-surefire-plugin.version}
+
+
+ ${llp.test.includeSlow}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java b/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
index 771b7cd..6f3834a 100644
--- a/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
+++ b/src/main/java/com/flamingo/comm/llp/core/CoreParseErrorReason.java
@@ -45,7 +45,12 @@ public enum CoreParseErrorReason implements ParseErrorReason {
/**
* A plugin threw an unexpected exception.
*/
- PLUGIN_EXCEPTION("Layer parser threw an exception");
+ PLUGIN_EXCEPTION("Layer parser threw an exception"),
+
+ /**
+ * Layer chain ended without a FinalNode (ID 0x00).
+ */
+ MISSING_FINAL_NODE("Layer chain ended without a FinalNode");
private final String reason;
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 5a3bbbd..b0c426c 100644
--- a/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
+++ b/src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
@@ -1,13 +1,11 @@
package com.flamingo.comm.llp.core;
-import com.flamingo.comm.llp.spi.LLPLayerParser;
-import com.flamingo.comm.llp.spi.LayerParseInput;
-import com.flamingo.comm.llp.spi.LayerParseResult;
-import com.flamingo.comm.llp.spi.ParseErrorReason;
+import com.flamingo.comm.llp.spi.*;
import com.flamingo.comm.llp.util.LayerIds;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.util.List;
import java.util.Optional;
final class SimpleFrameParser implements LLPFrameParser {
@@ -30,6 +28,7 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
buffer.order(ByteOrder.BIG_ENDIAN);
NodeChain.Builder chainBuilder = new NodeChain.Builder();
+ boolean foundFinal = false;
loop:while (buffer.hasRemaining()) {
@@ -38,6 +37,7 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
// Final layer (ID = 0)
if (LayerIds.isFinal(layerId)) {
chainBuilder.add(FinalNode.of(buffer.slice()));
+ foundFinal = true;
break loop;
}
@@ -153,6 +153,23 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
}
}
+ if (!foundFinal && !chainBuilder.build().asList().isEmpty()) {
+ List nodes = chainBuilder.build().asList();
+ LLPNode last = nodes.getLast();
+ if (last instanceof FailureNode) {
+ // Chain already terminated with a failure — no need to add MISSING_FINAL_NODE
+ } else if (last instanceof FinalNode) {
+ // Should not happen since foundFinal == false, but guard anyway
+ } else {
+ // Chain ended because buffer ran out, but not due to a parse error.
+ // The layer chain is missing a FinalNode.
+ chainBuilder.add(new FailureNode(
+ 0,
+ CoreParseErrorReason.MISSING_FINAL_NODE
+ ));
+ }
+ }
+
return new LLPFrame(
chainBuilder.build(),
rawFrame.crc(),
diff --git a/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java b/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
index d6aa52b..b17e973 100644
--- a/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
+++ b/src/main/java/com/flamingo/comm/llp/core/TransportErrorCode.java
@@ -11,7 +11,9 @@ public enum TransportErrorCode {
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");
+ BUFFER_FULL((byte) 0x05, "Buffer overflow"),
+ LAYER_MALFORMED((byte) 0x06, "Layer chain malformed"),
+ TRANSFORM_NO_HANDLER((byte) 0x07, "No handler registered for transform layer");
private final byte code;
private final String description;
diff --git a/src/main/java/com/flamingo/comm/llp/util/LayerIds.java b/src/main/java/com/flamingo/comm/llp/util/LayerIds.java
index fb2401d..5f437ef 100644
--- a/src/main/java/com/flamingo/comm/llp/util/LayerIds.java
+++ b/src/main/java/com/flamingo/comm/llp/util/LayerIds.java
@@ -24,12 +24,18 @@
* Can be safely skipped if no parser is available.
*
*
- * Non-skippable Layers (ID 128–255):
+ * Non-skippable / Transform Layers (ID 128–254):
*
* - Modify the payload (e.g., encryption, compression).
* - Must be parsed to correctly interpret subsequent layers.
*
*
+ * Reserved Layer (ID = 255):
+ *
+ * - Reserved for future use.
+ * - Parsers should treat as unknown and skip if possible.
+ *
+ *
*
*
*
@@ -45,10 +51,16 @@ public final class LayerIds {
static final int FINAL_LAYER_ID = 0;
/**
- * Threshold from which layers are considered non-skippable.
+ * Threshold from which layers are considered non-skippable (transform).
*/
private static final int NON_SKIPPABLE_THRESHOLD = 128;
+ /**
+ * Reserved layer identifier. Per the LLP specification, parsers should
+ * treat this ID as unknown and skip if possible.
+ */
+ static final int RESERVED_LAYER_ID = 255;
+
private LayerIds() {
// Utility class (no instances)
}
@@ -76,13 +88,15 @@ public static boolean isFinal(int id) {
*
* Note: The final layer (ID = 0) is also considered non-modifying, but it is
* excluded from this method since it has special structural semantics.
+ * The reserved ID (255) is considered skippable per the LLP specification.
*
*
* @param id layer identifier
- * @return {@code true} if the layer is skippable (ID 1–127), otherwise {@code false}
+ * @return {@code true} if the layer is skippable (ID 1–127 or 255), otherwise {@code false}
*/
public static boolean isSkippable(int id) {
- return id > FINAL_LAYER_ID && id < NON_SKIPPABLE_THRESHOLD;
+ return (id > FINAL_LAYER_ID && id < NON_SKIPPABLE_THRESHOLD)
+ || id == RESERVED_LAYER_ID;
}
/**
@@ -91,31 +105,31 @@ public static boolean isSkippable(int id) {
*
* Non-skippable layers modify the payload (e.g., encryption or compression),
* therefore they must be successfully parsed before continuing to inner layers.
+ * The reserved ID (255) is not considered non-skippable per the
+ * LLP specification, which states it should be skipped if possible.
*
*
* @param id layer identifier
- * @return {@code true} if the layer is non-skippable (ID ≥ 128), otherwise {@code false}
+ * @return {@code true} if the layer is non-skippable (ID 128–254), otherwise {@code false}
*/
public static boolean isNonSkippable(int id) {
- return id >= NON_SKIPPABLE_THRESHOLD;
+ return id >= NON_SKIPPABLE_THRESHOLD && id != RESERVED_LAYER_ID;
}
/**
- * Checks whether the given layer ID represents a layer that does not modify payload.
+ * Checks whether the given layer ID is reserved.
*
*
- * This includes:
- *
- * - Final layer (ID = 0)
- * - Skippable layers (ID 1–127)
- *
+ * The reserved ID (255) is set aside for future use per the LLP specification.
+ * Parsers should treat it as unknown and skip if possible — it is not
+ * considered a transform (non-skippable) layer.
*
*
* @param id layer identifier
- * @return {@code true} if the layer does not modify payload, otherwise {@code false}
+ * @return {@code true} if the layer ID is reserved (255), otherwise {@code false}
*/
- public static boolean doesNotModifyPayload(int id) {
- return id < NON_SKIPPABLE_THRESHOLD;
+ public static boolean isReserved(int id) {
+ return id == RESERVED_LAYER_ID;
}
/**
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 4d6735f..e8f70a7 100644
--- a/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
+++ b/src/test/java/com/flamingo/comm/llp/core/SimpleFrameParserTest.java
@@ -62,7 +62,7 @@ void shouldParseFrameWithOnlyFinalLayer() {
assertEquals(1234, frame.crc());
assertEquals(1000L, frame.timestamp());
- // Node chain should have exactly 1 node (FinalNode)
+ // Node chain should have exactly 1 node (FinalNode)00
assertEquals(1, frame.chain().size());
assertInstanceOf(FinalNode.class, frame.chain().asList().getFirst());
}
@@ -289,8 +289,11 @@ void shouldStopParsingWhenPluginReturnsEmptyPayload() {
LLPFrame frame = parser.parse(rawFrame);
- assertEquals(1, frame.chain().size());
+ assertEquals(2, frame.chain().size());
assertEquals(mockNode, frame.chain().asList().getFirst());
+ assertInstanceOf(FailureNode.class, frame.chain().asList().get(1));
+ FailureNode fn = (FailureNode) frame.chain().asList().get(1);
+ assertEquals(CoreParseErrorReason.MISSING_FINAL_NODE, fn.getErrorReason());
}
@Test
diff --git a/src/test/java/com/flamingo/comm/llp/spec/LayerTraversalVectorTest.java b/src/test/java/com/flamingo/comm/llp/spec/LayerTraversalVectorTest.java
new file mode 100644
index 0000000..bab410d
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/LayerTraversalVectorTest.java
@@ -0,0 +1,130 @@
+package com.flamingo.comm.llp.spec;
+
+import com.flamingo.comm.llp.core.FailureNode;
+import com.flamingo.comm.llp.core.FinalNode;
+import com.flamingo.comm.llp.core.LLP;
+import com.flamingo.comm.llp.core.LLPFrame;
+import com.flamingo.comm.llp.core.LLPFrameParser;
+import com.flamingo.comm.llp.core.LLPRawFrame;
+import com.flamingo.comm.llp.core.LLPTransportDeframer;
+import com.flamingo.comm.llp.core.TransportErrorCode;
+import tools.jackson.databind.JsonNode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LayerTraversalVectorTest {
+
+ private static final HexFormat HEX = HexFormat.of();
+
+ static List loadVectors() throws IOException {
+ return VectorLoader.loadByNames("llp-vectors", "traversal.json");
+ }
+
+ @ParameterizedTest
+ @MethodSource("loadVectors")
+ void testTraversal(TestVector vector) throws Exception {
+ String frameHex = vector.input().get("frame_hex").asString();
+ byte[] frame = HEX.parseHex(frameHex);
+
+ JsonNode expected = vector.expected();
+ String outcome = expected.get("outcome").asString();
+ String expectedFinalHex = expected.has("final_payload_hex")
+ ? expected.get("final_payload_hex").asString()
+ : "";
+
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ LLPFrameParser frameParser = LLP.frameParser()
+ .parserProvider(id -> Optional.empty())
+ .build();
+
+ List rawFrames = new ArrayList<>();
+ List transportErrors = new ArrayList<>();
+ deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame rawFrame) {
+ rawFrames.add(rawFrame);
+ }
+
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ transportErrors.add(code);
+ }
+ });
+
+ deframer.processBytes(frame);
+
+ if ("FRAME".equals(outcome)) {
+ assertFalse(rawFrames.isEmpty(), "[" + vector.name() + "] expected at least 1 raw frame");
+ assertTrue(transportErrors.isEmpty(),
+ "[" + vector.name() + "] expected no transport errors");
+
+ LLPFrame parsed = frameParser.parse(rawFrames.getFirst());
+ byte[] finalPayload = extractFinalPayload(parsed);
+ byte[] expectedFinal = expectedFinalHex.isEmpty()
+ ? new byte[0]
+ : HEX.parseHex(expectedFinalHex);
+ assertArrayEquals(expectedFinal, finalPayload,
+ "[" + vector.name() + "] final payload mismatch");
+ } else if ("ERROR".equals(outcome)) {
+ String expectedErrorCode = expected.get("error_code").asString();
+
+ if ("TRANSFORM_NO_HANDLER".equals(expectedErrorCode)) {
+ assertFalse(rawFrames.isEmpty(), "[" + vector.name() + "] expected at least 1 raw frame");
+ assertTrue(transportErrors.isEmpty(),
+ "[" + vector.name() + "] expected no transport errors");
+
+ LLPFrame parsed = frameParser.parse(rawFrames.getFirst());
+ boolean hasFailureNode = parsed.chain().asList().stream()
+ .anyMatch(node -> node instanceof FailureNode);
+ assertTrue(hasFailureNode,
+ "[" + vector.name() + "] expected FailureNode for TRANSFORM_NO_HANDLER");
+ } else {
+ assertFalse(transportErrors.isEmpty(), "[" + vector.name() + "] expected at least 1 transport error");
+ TransportErrorCode expectedError = mapErrorCode(expectedErrorCode);
+ assertEquals(expectedError, transportErrors.getFirst(),
+ "[" + vector.name() + "] error code mismatch");
+ }
+ }
+ }
+
+ private static byte[] extractFinalPayload(LLPFrame frame) {
+ List> nodes = frame.chain().asList();
+ FinalNode fn = null;
+ for (Object node : nodes) {
+ if (node instanceof FinalNode) {
+ fn = (FinalNode) node;
+ break;
+ }
+ }
+ if (fn == null) {
+ return new byte[0];
+ }
+ ByteBuffer buf = fn.getPayload();
+ byte[] result = new byte[buf.remaining()];
+ buf.get(result);
+ return result;
+ }
+
+ private static TransportErrorCode mapErrorCode(String specCode) {
+ return switch (specCode) {
+ case "CHECKSUM" -> TransportErrorCode.CHECKSUM_INVALID;
+ case "TIMEOUT" -> TransportErrorCode.TIMEOUT;
+ case "SYNC_ERROR" -> TransportErrorCode.SYNC_ERROR;
+ case "PAYLOAD_LEN_INVALID" -> TransportErrorCode.PAYLOAD_LEN_INVALID;
+ case "BUFFER_FULL" -> TransportErrorCode.BUFFER_FULL;
+ case "LAYER_MALFORMED" -> TransportErrorCode.LAYER_MALFORMED;
+ case "TRANSFORM_NO_HANDLER" -> TransportErrorCode.TRANSFORM_NO_HANDLER;
+ default -> throw new IllegalArgumentException(
+ "Unknown spec error code: " + specCode);
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/spec/ParserVectorTest.java b/src/test/java/com/flamingo/comm/llp/spec/ParserVectorTest.java
new file mode 100644
index 0000000..69452e8
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/ParserVectorTest.java
@@ -0,0 +1,133 @@
+package com.flamingo.comm.llp.spec;
+
+import com.flamingo.comm.llp.core.FinalNode;
+import com.flamingo.comm.llp.core.LLP;
+import com.flamingo.comm.llp.core.LLPFrame;
+import com.flamingo.comm.llp.core.LLPFrameParser;
+import com.flamingo.comm.llp.core.LLPRawFrame;
+import com.flamingo.comm.llp.core.LLPTransportDeframer;
+import com.flamingo.comm.llp.core.TransportErrorCode;
+import tools.jackson.databind.JsonNode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ParserVectorTest {
+
+ private static final HexFormat HEX = HexFormat.of();
+
+ private record Event(String type, byte[] payload, TransportErrorCode errorCode) {}
+
+ static List loadVectors() throws IOException {
+ return VectorLoader.loadByNames("llp-vectors",
+ "incremental.json", "fragmented.json", "recovery.json");
+ }
+
+ @ParameterizedTest
+ @MethodSource("loadVectors")
+ void testParser(TestVector vector) throws Exception {
+ JsonNode chunks = vector.input().get("chunks_hex");
+ JsonNode expectedEvents = vector.expected().get("events");
+
+ boolean expectsTimeout = false;
+ for (JsonNode ev : expectedEvents) {
+ JsonNode ec = ev.get("error_code");
+ if (ec != null && "TIMEOUT".equals(ec.asString())) {
+ expectsTimeout = true;
+ break;
+ }
+ }
+
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ LLPFrameParser frameParser = LLP.frameParser()
+ .parserProvider(id -> Optional.empty())
+ .build();
+
+ List actualEvents = new ArrayList<>();
+ deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame rawFrame) {
+ LLPFrame frame = frameParser.parse(rawFrame);
+ actualEvents.add(new Event("FRAME", reconstructChain(frame), null));
+ }
+
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ actualEvents.add(new Event("ERROR", null, code));
+ }
+ });
+
+ List boundaries = new ArrayList<>();
+ StringBuilder hexBuf = new StringBuilder();
+ int cumBytes = 0;
+ for (JsonNode chunk : chunks) {
+ String hex = chunk.asString();
+ hexBuf.append(hex);
+ cumBytes += (hex.length() + 1) / 2;
+ boundaries.add(cumBytes);
+ }
+ byte[] allData = parseHexLenient(hexBuf.toString());
+
+ for (int i = 0; i < allData.length; i++) {
+ if (expectsTimeout && boundaries.contains(i) && i > 0) {
+ Thread.sleep(2100);
+ }
+ deframer.processByte(allData[i]);
+ }
+
+ assertEquals(expectedEvents.size(), actualEvents.size(),
+ "[" + vector.name() + "] event count mismatch");
+
+ for (int i = 0; i < expectedEvents.size(); i++) {
+ JsonNode expected = expectedEvents.get(i);
+ Event actual = actualEvents.get(i);
+ String expectedType = expected.get("type").asString();
+
+ assertEquals(expectedType, actual.type(),
+ "[" + vector.name() + "] event " + i + " type mismatch");
+
+ if ("FRAME".equals(expectedType)) {
+ byte[] expectedPayload = parseHexLenient(
+ expected.get("payload_hex").asString());
+ assertArrayEquals(expectedPayload, actual.payload(),
+ "[" + vector.name() + "] event " + i + " payload mismatch");
+ } else if ("ERROR".equals(expectedType)) {
+ String expectedError = expected.get("error_code").asString();
+ assertEquals(expectedError, mapErrorCode(actual.errorCode()),
+ "[" + vector.name() + "] event " + i + " error mismatch");
+ }
+ }
+ }
+
+ private static byte[] parseHexLenient(String hex) {
+ return HEX.parseHex(hex.length() % 2 == 0 ? hex : "0" + hex);
+ }
+
+ private static byte[] reconstructChain(LLPFrame frame) {
+ FinalNode fn = (FinalNode) frame.chain().asList().getFirst();
+ ByteBuffer payload = fn.getPayload();
+ byte[] chain = new byte[1 + payload.remaining()];
+ chain[0] = 0x00;
+ payload.get(chain, 1, payload.remaining());
+ return chain;
+ }
+
+ private static String mapErrorCode(TransportErrorCode code) {
+ return switch (code) {
+ case CHECKSUM_INVALID -> "CHECKSUM";
+ case TIMEOUT -> "TIMEOUT";
+ case SYNC_ERROR -> "SYNC_ERROR";
+ case PAYLOAD_LEN_INVALID -> "PAYLOAD_LEN_INVALID";
+ case BUFFER_FULL -> "BUFFER_FULL";
+ default -> code.name();
+ };
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/spec/TestVector.java b/src/test/java/com/flamingo/comm/llp/spec/TestVector.java
new file mode 100644
index 0000000..5b5e699
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/TestVector.java
@@ -0,0 +1,15 @@
+package com.flamingo.comm.llp.spec;
+
+import tools.jackson.databind.JsonNode;
+
+import java.util.List;
+
+public record TestVector(
+ String specVersion,
+ String type,
+ String name,
+ String description,
+ JsonNode input,
+ JsonNode expected,
+ List flags
+) {}
diff --git a/src/test/java/com/flamingo/comm/llp/spec/TransportDecodeVectorTest.java b/src/test/java/com/flamingo/comm/llp/spec/TransportDecodeVectorTest.java
new file mode 100644
index 0000000..46d70db
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/TransportDecodeVectorTest.java
@@ -0,0 +1,175 @@
+package com.flamingo.comm.llp.spec;
+
+import com.flamingo.comm.llp.core.FailureNode;
+import com.flamingo.comm.llp.core.LLP;
+import com.flamingo.comm.llp.core.LLPFrame;
+import com.flamingo.comm.llp.core.LLPFrameParser;
+import com.flamingo.comm.llp.core.LLPRawFrame;
+import com.flamingo.comm.llp.core.LLPTransportDeframer;
+import com.flamingo.comm.llp.core.TransportErrorCode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TransportDecodeVectorTest {
+
+ private static final List LAYER_ERROR_CODES = List.of(
+ "LAYER_MALFORMED", "TRANSFORM_NO_HANDLER");
+
+ static List loadVectors() throws IOException {
+ return VectorLoader.loadByType("llp-vectors", "decode");
+ }
+
+ @ParameterizedTest
+ @MethodSource("loadVectors")
+ void testDecode(TestVector vector) {
+ if (!"decode".equals(vector.type())) return;
+
+ String frameHex = vector.input().has("frame_hex")
+ ? vector.input().get("frame_hex").asString()
+ : "";
+ byte[] frame = frameHex.isEmpty() ? new byte[0]
+ : HexFormat.of().parseHex(frameHex);
+
+ String outcome = vector.expected().has("outcome")
+ ? vector.expected().get("outcome").asString()
+ : "";
+
+ if (isLayerError(vector)) {
+ testLayerError(vector, frame, outcome);
+ return;
+ }
+
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ List errors = new ArrayList<>();
+ deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame f) {}
+
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ errors.add(code);
+ }
+ });
+
+ List frames = deframer.processBytes(frame);
+
+ switch (outcome) {
+ case "FRAME" -> {
+ assertFalse(frames.isEmpty(), "[" + vector.name() + "] expected at least 1 frame");
+ byte[] expectedPayload = HexFormat.of().parseHex(
+ vector.expected().get("payload_hex").asString());
+ ByteBuffer buf = frames.getFirst().payload();
+ byte[] extracted = new byte[buf.remaining()];
+ buf.get(extracted);
+ assertArrayEquals(expectedPayload, extracted,
+ "[" + vector.name() + "] payload mismatch");
+ assertTrue(errors.isEmpty(),
+ "[" + vector.name() + "] expected no errors");
+ }
+ case "ERROR" -> {
+ String errorCodeStr = vector.expected().get("error_code").asString();
+ if ("TIMEOUT".equals(errorCodeStr)) {
+ assertTrue(frames.isEmpty(),
+ "[" + vector.name() + "] expected no frames (truncated)");
+ } else {
+ assertTrue(frames.isEmpty(),
+ "[" + vector.name() + "] expected no frames");
+ assertEquals(1, errors.size(),
+ "[" + vector.name() + "] expected 1 error");
+ TransportErrorCode expectedError = mapErrorCode(errorCodeStr);
+ assertEquals(expectedError, errors.getFirst(),
+ "[" + vector.name() + "] error code mismatch");
+ }
+ }
+ case "NONE" -> {
+ assertTrue(frames.isEmpty(),
+ "[" + vector.name() + "] expected no frames");
+ assertTrue(errors.isEmpty(),
+ "[" + vector.name() + "] expected no errors");
+ }
+ default -> throw new IllegalArgumentException(
+ "Unknown outcome: " + outcome);
+ }
+ }
+
+ private void testLayerError(TestVector vector, byte[] frame, String outcome) {
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ LLPFrameParser frameParser = LLP.frameParser()
+ .parserProvider(id -> Optional.empty())
+ .build();
+ List parsedFrames = new ArrayList<>();
+ List transportErrors = new ArrayList<>();
+
+ deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame rawFrame) {
+ parsedFrames.add(frameParser.parse(rawFrame));
+ }
+
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ transportErrors.add(code);
+ }
+ });
+
+ deframer.processBytes(frame);
+
+ String errorCodeStr = vector.expected().get("error_code").asString();
+
+ switch (errorCodeStr) {
+ case "LAYER_MALFORMED" -> {
+ assertFalse(parsedFrames.isEmpty(), "[" + vector.name() + "] expected at least 1 parsed frame");
+ LLPFrame parsed = parsedFrames.getFirst();
+ boolean hasFailure = parsed.chain().asList().stream()
+ .anyMatch(node -> node instanceof FailureNode);
+ assertTrue(hasFailure,
+ "[" + vector.name() + "] expected FailureNode in parsed frame (LAYER_MALFORMED)");
+ assertTrue(transportErrors.isEmpty(),
+ "[" + vector.name() + "] expected no transport errors");
+ }
+ case "TRANSFORM_NO_HANDLER" -> {
+ assertFalse(parsedFrames.isEmpty(), "[" + vector.name() + "] expected at least 1 parsed frame");
+ LLPFrame parsed = parsedFrames.getFirst();
+ boolean hasFailure = parsed.chain().asList().stream()
+ .anyMatch(node -> node instanceof FailureNode);
+ assertTrue(hasFailure,
+ "[" + vector.name() + "] expected FailureNode in parsed frame (TRANSFORM_NO_HANDLER)");
+ assertTrue(transportErrors.isEmpty(),
+ "[" + vector.name() + "] expected no transport errors");
+ }
+ default -> throw new IllegalArgumentException(
+ "Unhandled layer error code: " + errorCodeStr);
+ }
+ }
+
+ private static boolean isLayerError(TestVector vector) {
+ if (vector.expected() == null || !vector.expected().has("error_code")) {
+ return false;
+ }
+ String ec = vector.expected().get("error_code").asString();
+ return LAYER_ERROR_CODES.contains(ec);
+ }
+
+ private static TransportErrorCode mapErrorCode(String specCode) {
+ return switch (specCode) {
+ case "CHECKSUM" -> TransportErrorCode.CHECKSUM_INVALID;
+ case "TIMEOUT" -> TransportErrorCode.TIMEOUT;
+ case "SYNC_ERROR" -> TransportErrorCode.SYNC_ERROR;
+ case "PAYLOAD_LEN_INVALID" -> TransportErrorCode.PAYLOAD_LEN_INVALID;
+ case "BUFFER_FULL" -> TransportErrorCode.BUFFER_FULL;
+ case "LAYER_MALFORMED" -> TransportErrorCode.LAYER_MALFORMED;
+ case "TRANSFORM_NO_HANDLER" -> TransportErrorCode.TRANSFORM_NO_HANDLER;
+ default -> throw new IllegalArgumentException(
+ "Unknown spec error code: " + specCode);
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/spec/TransportEncodeVectorTest.java b/src/test/java/com/flamingo/comm/llp/spec/TransportEncodeVectorTest.java
new file mode 100644
index 0000000..e7a8437
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/TransportEncodeVectorTest.java
@@ -0,0 +1,32 @@
+package com.flamingo.comm.llp.spec;
+
+import com.flamingo.comm.llp.core.LLPTransportFramer;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.util.HexFormat;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+class TransportEncodeVectorTest {
+
+ static List loadVectors() throws IOException {
+ return VectorLoader.loadByType("llp-vectors", "encode");
+ }
+
+ @ParameterizedTest
+ @MethodSource("loadVectors")
+ void testEncode(TestVector vector) {
+ byte[] llpPayload = HexFormat.of().parseHex(
+ vector.input().get("llp_payload_hex").asString());
+ byte[] expectedFrame = HexFormat.of().parseHex(
+ vector.expected().get("frame_hex").asString());
+
+ byte[] actualFrame = LLPTransportFramer.buildSafe(llpPayload);
+
+ assertArrayEquals(expectedFrame, actualFrame,
+ "[" + vector.name() + "] frame mismatch");
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/spec/TransportStreamVectorTest.java b/src/test/java/com/flamingo/comm/llp/spec/TransportStreamVectorTest.java
new file mode 100644
index 0000000..84bfa3b
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/TransportStreamVectorTest.java
@@ -0,0 +1,94 @@
+package com.flamingo.comm.llp.spec;
+
+import com.flamingo.comm.llp.core.LLPRawFrame;
+import com.flamingo.comm.llp.core.LLPTransportDeframer;
+import com.flamingo.comm.llp.core.TransportErrorCode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import tools.jackson.databind.JsonNode;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class TransportStreamVectorTest {
+
+ private record Event(String type, byte[] payload, TransportErrorCode errorCode) {}
+
+ private static final HexFormat HEX = HexFormat.of();
+
+ static List loadVectors() throws IOException {
+ return VectorLoader.loadByType("llp-vectors", "stream");
+ }
+
+ @ParameterizedTest
+ @MethodSource("loadVectors")
+ void testStream(TestVector vector) {
+ JsonNode chunks = vector.input().get("chunks_hex");
+ List chunkList = new ArrayList<>();
+ for (JsonNode chunk : chunks) {
+ chunkList.add(HEX.parseHex(chunk.asString()));
+ }
+
+ JsonNode expectedEvents = vector.expected().get("events");
+
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ List actualEvents = new ArrayList<>();
+ deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame frame) {
+ ByteBuffer buf = frame.payload();
+ byte[] payload = new byte[buf.remaining()];
+ buf.get(payload);
+ actualEvents.add(new Event("FRAME", payload, null));
+ }
+
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ actualEvents.add(new Event("ERROR", null, code));
+ }
+ });
+
+ for (byte[] chunk : chunkList) {
+ deframer.processBytes(chunk);
+ }
+
+ assertEquals(expectedEvents.size(), actualEvents.size(),
+ "[" + vector.name() + "] event count mismatch");
+
+ for (int i = 0; i < expectedEvents.size(); i++) {
+ JsonNode expected = expectedEvents.get(i);
+ Event actual = actualEvents.get(i);
+ String expectedType = expected.get("type").asString();
+ assertEquals(expectedType, actual.type(),
+ "[" + vector.name() + "] event " + i + " type mismatch");
+
+ if ("FRAME".equals(expectedType)) {
+ byte[] expectedPayload = HEX.parseHex(
+ expected.get("payload_hex").asString());
+ assertArrayEquals(expectedPayload, actual.payload(),
+ "[" + vector.name() + "] event " + i + " payload mismatch");
+ } else if ("ERROR".equals(expectedType)) {
+ String expectedError = expected.get("error_code").asString();
+ assertEquals(expectedError, mapErrorCode(actual.errorCode()),
+ "[" + vector.name() + "] event " + i + " error mismatch");
+ }
+ }
+ }
+
+ private static String mapErrorCode(TransportErrorCode code) {
+ return switch (code) {
+ case CHECKSUM_INVALID -> "CHECKSUM";
+ case TIMEOUT -> "TIMEOUT";
+ case SYNC_ERROR -> "SYNC_ERROR";
+ case PAYLOAD_LEN_INVALID -> "PAYLOAD_LEN_INVALID";
+ case BUFFER_FULL -> "BUFFER_FULL";
+ default -> code.name();
+ };
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/spec/TransportTimingVectorTest.java b/src/test/java/com/flamingo/comm/llp/spec/TransportTimingVectorTest.java
new file mode 100644
index 0000000..8357cc1
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/TransportTimingVectorTest.java
@@ -0,0 +1,110 @@
+package com.flamingo.comm.llp.spec;
+
+import com.flamingo.comm.llp.core.LLPRawFrame;
+import com.flamingo.comm.llp.core.LLPTransportDeframer;
+import com.flamingo.comm.llp.core.TransportErrorCode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import tools.jackson.databind.JsonNode;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class TransportTimingVectorTest {
+
+ private record Event(String type, byte[] payload, TransportErrorCode errorCode) {}
+
+ private static final HexFormat HEX = HexFormat.of();
+
+ static List loadVectors() throws IOException {
+ return VectorLoader.loadByType("llp-vectors", "timing");
+ }
+
+ @ParameterizedTest
+ @MethodSource("loadVectors")
+ void testTiming(TestVector vector) throws Exception {
+ JsonNode inputEvents = vector.input().get("events");
+ List byteEvents = new ArrayList<>();
+ for (JsonNode ev : inputEvents) {
+ byte b = HEX.parseHex(ev.get("byte_hex").asString())[0];
+ long timeMs = ev.get("time_ms").asLong();
+ byteEvents.add(new ByteEvent(b, timeMs));
+ }
+
+ JsonNode expectedEvents = vector.expected().get("events");
+
+ LLPTransportDeframer deframer = new LLPTransportDeframer();
+ List actualEvents = new ArrayList<>();
+ deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
+ @Override
+ public void onFrameReceived(LLPRawFrame frame) {
+ ByteBuffer buf = frame.payload();
+ byte[] payload = new byte[buf.remaining()];
+ buf.get(payload);
+ actualEvents.add(new Event("FRAME", payload, null));
+ }
+
+ @Override
+ public void onFrameError(TransportErrorCode code) {
+ actualEvents.add(new Event("ERROR", null, code));
+ }
+ });
+
+ long startTime = System.currentTimeMillis();
+ long lastRelativeMs = 0;
+
+ for (ByteEvent ev : byteEvents) {
+ long sleepMs = ev.timeMs() - lastRelativeMs;
+ if (sleepMs > 0) {
+ Thread.sleep(sleepMs);
+ }
+ lastRelativeMs = ev.timeMs();
+ long elapsed = System.currentTimeMillis() - startTime;
+ if (ev.timeMs() > elapsed) {
+ Thread.sleep(ev.timeMs() - elapsed);
+ }
+ deframer.processByte(ev.byteValue());
+ }
+
+ assertEquals(expectedEvents.size(), actualEvents.size(),
+ "[" + vector.name() + "] event count mismatch");
+
+ for (int i = 0; i < expectedEvents.size(); i++) {
+ JsonNode expected = expectedEvents.get(i);
+ Event actual = actualEvents.get(i);
+ String expectedType = expected.get("type").asString();
+ assertEquals(expectedType, actual.type(),
+ "[" + vector.name() + "] event " + i + " type mismatch");
+
+ if ("FRAME".equals(expectedType)) {
+ byte[] expectedPayload = HEX.parseHex(
+ expected.get("payload_hex").asString());
+ assertArrayEquals(expectedPayload, actual.payload(),
+ "[" + vector.name() + "] event " + i + " payload mismatch");
+ } else if ("ERROR".equals(expectedType)) {
+ String expectedError = expected.get("error_code").asString();
+ assertEquals(expectedError, mapErrorCode(actual.errorCode()),
+ "[" + vector.name() + "] event " + i + " error mismatch");
+ }
+ }
+ }
+
+ private record ByteEvent(byte byteValue, long timeMs) {}
+
+ private static String mapErrorCode(TransportErrorCode code) {
+ return switch (code) {
+ case CHECKSUM_INVALID -> "CHECKSUM";
+ case TIMEOUT -> "TIMEOUT";
+ case SYNC_ERROR -> "SYNC_ERROR";
+ case PAYLOAD_LEN_INVALID -> "PAYLOAD_LEN_INVALID";
+ case BUFFER_FULL -> "BUFFER_FULL";
+ default -> code.name();
+ };
+ }
+}
diff --git a/src/test/java/com/flamingo/comm/llp/spec/VectorLoader.java b/src/test/java/com/flamingo/comm/llp/spec/VectorLoader.java
new file mode 100644
index 0000000..16ed77f
--- /dev/null
+++ b/src/test/java/com/flamingo/comm/llp/spec/VectorLoader.java
@@ -0,0 +1,126 @@
+package com.flamingo.comm.llp.spec;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+public final class VectorLoader {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final boolean INCLUDE_SLOW =
+ Boolean.parseBoolean(System.getProperty("llp.test.includeSlow", "false"));
+
+ private VectorLoader() {
+ }
+
+ public static List loadAll(String resourcePath) throws IOException {
+ return loadAll(resourcePath, false);
+ }
+
+ public static List loadAll(String resourcePath, boolean includeSlow) throws IOException {
+ List result = new ArrayList<>();
+ URL url = Thread.currentThread().getContextClassLoader().getResource(resourcePath);
+ if (url == null) {
+ throw new IOException("Resource not found on classpath: " + resourcePath);
+ }
+ Path root;
+ try {
+ root = Path.of(url.toURI());
+ } catch (URISyntaxException e) {
+ throw new IOException("Invalid resource URI: " + url, e);
+ }
+ if (!Files.isDirectory(root)) {
+ throw new IOException("Not a directory: " + root);
+ }
+ try (Stream walk = Files.walk(root)) {
+ List jsonFiles = walk
+ .filter(p -> p.toString().endsWith(".json"))
+ .toList();
+ for (Path file : jsonFiles) {
+ loadGroupedFile(file, result, includeSlow);
+ }
+ }
+ return result;
+ }
+
+ public static List loadByType(String resourcePath, String type) throws IOException {
+ return loadByType(resourcePath, type, false);
+ }
+
+ public static List loadByType(String resourcePath, String type, boolean includeSlow) throws IOException {
+ return loadAll(resourcePath, includeSlow).stream()
+ .filter(v -> v.type().equals(type))
+ .toList();
+ }
+
+ public static List loadByNames(String resourcePath, String... filenames) throws IOException {
+ return loadByNames(resourcePath, false, filenames);
+ }
+
+ public static List loadByNames(String resourcePath, boolean includeSlow, String... filenames) throws IOException {
+ List result = new ArrayList<>();
+ String prefix = resourcePath.endsWith("/") ? resourcePath : resourcePath + "/";
+ for (String fn : filenames) {
+ URL url = Thread.currentThread().getContextClassLoader().getResource(prefix + fn);
+ if (url == null) continue;
+ try {
+ Path path = Path.of(url.toURI());
+ loadGroupedFile(path, result, includeSlow);
+ } catch (URISyntaxException ignored) {
+ }
+ }
+ return result;
+ }
+
+ public static int countSlowVectors(String resourcePath) throws IOException {
+ List all = loadAll(resourcePath, true);
+ int count = 0;
+ for (TestVector v : all) {
+ if (v.flags().contains("Slow")) count++;
+ }
+ return count;
+ }
+
+ private static void loadGroupedFile(Path file, List out, boolean includeSlow) throws IOException {
+ boolean effectiveIncludeSlow = includeSlow || INCLUDE_SLOW;
+ JsonNode rootNode = MAPPER.readTree(file.toFile());
+ String specVersion = rootNode.get("spec_version").asString();
+ JsonNode vectors = rootNode.get("vectors");
+ if (vectors == null || !vectors.isArray()) return;
+ for (JsonNode vec : vectors) {
+ if (vec.get("type") == null) continue;
+
+ JsonNode flagsNode = vec.get("flags");
+ boolean hasSlow = false;
+ List flags = new ArrayList<>();
+ if (flagsNode != null && flagsNode.isArray()) {
+ for (JsonNode flag : flagsNode) {
+ String flagVal = flag.asString();
+ flags.add(flagVal);
+ if ("Slow".equals(flagVal)) hasSlow = true;
+ }
+ }
+
+ if (!effectiveIncludeSlow && hasSlow) continue;
+
+ JsonNode expected = vec.get("expected");
+ out.add(new TestVector(
+ specVersion,
+ vec.get("type").asString(),
+ vec.get("name").asString(),
+ vec.get("description").asString(),
+ vec.get("input"),
+ expected,
+ flags
+ ));
+ }
+ }
+}
diff --git a/src/test/resources/llp-vectors/crc.json b/src/test/resources/llp-vectors/crc.json
new file mode 100644
index 0000000..c8e8966
--- /dev/null
+++ b/src/test/resources/llp-vectors/crc.json
@@ -0,0 +1,399 @@
+{
+ "spec_version": "3.1.0",
+ "category": "transport_crc",
+ "description": "Invalid CRC: bit flips, byte swaps, all-zeros/ones, wrong frame CRC, payload corruption",
+ "vectors": [
+ {
+ "name": "corrupted_last_byte_empty_payload",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Empty application payload — only FinalNode marker (0x00)",
+ "input": {
+ "frame_hex": "AA55010000887C"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_single_byte_42",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Single byte payload (0x42) — minimum non-empty frame",
+ "input": {
+ "frame_hex": "AA5502000042B125"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_hello_world",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — 5-byte ASCII payload 'Hello'",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3767"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_null_byte",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Payload containing a single 0x00 byte",
+ "input": {
+ "frame_hex": "AA5502000000374D"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_ff_byte",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Payload containing a single 0xFF byte",
+ "input": {
+ "frame_hex": "AA55020000FFC753"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_aa_byte",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Payload with single 0xAA — triggers byte stuffing",
+ "input": {
+ "frame_hex": "AA55020000AA009759"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_aa55",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Payload with 0xAA 0x55 — magic sequence in payload",
+ "input": {
+ "frame_hex": "AA55030000AA00552D1D"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_triple_aa",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Three consecutive 0xAA bytes",
+ "input": {
+ "frame_hex": "AA55040000AA00AA00AA0072D0"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_mixed_aa",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — Scattered 0xAA bytes with normal data",
+ "input": {
+ "frame_hex": "AA5506000001AA0002AA0003DB28"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_zeros_16",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — 16 zero bytes",
+ "input": {
+ "frame_hex": "AA5511000000000000000000000000000000000000365C"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_ones_16",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — 16 bytes of 0x01",
+ "input": {
+ "frame_hex": "AA55110000010101010101010101010101010101016149"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_incremental",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — 16 sequential bytes 0x00..0x0F",
+ "input": {
+ "frame_hex": "AA55110000000102030405060708090A0B0C0D0E0F0B0D"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_all_ff_16",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — 16 bytes of 0xFF",
+ "input": {
+ "frame_hex": "AA55110000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF775C"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_last_byte_payload_aa_prefix",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC last byte flipped — 0xAA at start of payload",
+ "input": {
+ "frame_hex": "AA55030000AA0042FB7F"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_both_crc_bytes",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Both CRC bytes flipped",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6FC867"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_all_zero",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC field set to 0x0000",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F0000"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_all_ones",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC field set to 0xFFFF",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6FFFFF"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_0",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 0 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3698"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_1",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 1 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3598"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_2",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 2 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3398"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_3",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 3 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3F98"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_4",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 4 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F2798"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_5",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 5 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F1798"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_6",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 6 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F7798"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_bit_flip_pos_7",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Single bit flip at position 7 in CRC low byte",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6FB798"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_from_different_frame",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC bytes copied from a different frame",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6FB1DA"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "crc_swapped_bytes",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "CRC bytes in wrong byte order",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F9837"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ },
+ {
+ "name": "corrupted_payload_byte",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "One payload byte flipped — CRC mismatch",
+ "input": {
+ "frame_hex": "AA55060000489A6C6C6F3798"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "CHECKSUM"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/fragmented.json b/src/test/resources/llp-vectors/fragmented.json
new file mode 100644
index 0000000..1d4a9e5
--- /dev/null
+++ b/src/test/resources/llp-vectors/fragmented.json
@@ -0,0 +1,153 @@
+{
+ "spec_version": "3.1.0",
+ "category": "parser_fragmented",
+ "description": "Fragmented frames: split at every field and stuffing boundary",
+ "vectors": [
+ {
+ "name": "after_magic1",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Split between the two magic bytes",
+ "input": {
+ "chunks_hex": [
+ "AA",
+ "55020000AA0097A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "after_magic_both",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Split after both magic bytes",
+ "input": {
+ "chunks_hex": [
+ "AA55",
+ "020000AA0097A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "at_length_boundary",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Split between length bytes",
+ "input": {
+ "chunks_hex": [
+ "AA5502",
+ "0000AA0097A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "mid_stuffing",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Split in middle of stuffed sequence",
+ "input": {
+ "chunks_hex": [
+ "AA55020000AA",
+ "0097A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "at_crc_boundary",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Split between CRC bytes",
+ "input": {
+ "chunks_hex": [
+ "AA55020000AA0097",
+ "A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "many_small_chunks",
+ "type": "stream",
+ "result": "valid",
+ "flags": [
+ "EdgeCase"
+ ],
+ "description": "Two frames in many 1-byte chunks",
+ "input": {
+ "chunks_hex": [
+ "AA",
+ "55",
+ "01",
+ "00",
+ "00",
+ "88",
+ "83",
+ "AA",
+ "55",
+ "02",
+ "00",
+ "00",
+ "AA",
+ "00",
+ "97",
+ "A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/incremental.json b/src/test/resources/llp-vectors/incremental.json
new file mode 100644
index 0000000..2ff439c
--- /dev/null
+++ b/src/test/resources/llp-vectors/incremental.json
@@ -0,0 +1,182 @@
+{
+ "spec_version": "3.1.0",
+ "category": "parser_incremental",
+ "description": "Incremental parsing: one byte, two bytes, varied chunks",
+ "vectors": [
+ {
+ "name": "byte_by_byte_hello",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Hello frame one byte at a time",
+ "input": {
+ "chunks_hex": [
+ "AA",
+ "55",
+ "06",
+ "00",
+ "00",
+ "48",
+ "65",
+ "6C",
+ "6C",
+ "6F",
+ "37",
+ "98"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "two_bytes_empty_plus_aa",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Empty + aa_byte frames two bytes at a time",
+ "input": {
+ "chunks_hex": [
+ "AA55",
+ "0100",
+ "0088",
+ "83AA",
+ "5502",
+ "0000",
+ "AA00",
+ "97A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "mixed_chunks",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Two frames with varied chunk sizes",
+ "input": {
+ "chunks_hex": [
+ "AA5501",
+ "00008883",
+ "AA55060000",
+ "48656C6C6F3798"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "large_frame_byte_by_byte",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "32-byte payload frame one byte at a time",
+ "input": {
+ "chunks_hex": [
+ "AA",
+ "55",
+ "21",
+ "00",
+ "00",
+ "00",
+ "01",
+ "02",
+ "03",
+ "04",
+ "05",
+ "06",
+ "07",
+ "08",
+ "09",
+ "0A",
+ "0B",
+ "0C",
+ "0D",
+ "0E",
+ "0F",
+ "10",
+ "11",
+ "12",
+ "13",
+ "14",
+ "15",
+ "16",
+ "17",
+ "18",
+ "19",
+ "1A",
+ "1B",
+ "1C",
+ "1D",
+ "1E",
+ "1F",
+ "98",
+ "45"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "stuffed_frame_byte_by_byte",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Stuffed payload (0xAA) fed one byte at a time",
+ "input": {
+ "chunks_hex": [
+ "AA",
+ "55",
+ "02",
+ "00",
+ "00",
+ "AA",
+ "00",
+ "97",
+ "A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/malformed.json b/src/test/resources/llp-vectors/malformed.json
new file mode 100644
index 0000000..6e45ee4
--- /dev/null
+++ b/src/test/resources/llp-vectors/malformed.json
@@ -0,0 +1,65 @@
+{
+ "spec_version": "3.1.0",
+ "category": "layers_malformed",
+ "description": "Malformed layer chains: truncated metadata, empty payload, invalid structures",
+ "vectors": [
+ {
+ "name": "truncated_metadata",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Metadata length 10 but only 6 bytes available — layer is malformed",
+ "input": {
+ "frame_hex": "AA550800010A10203000486535BD"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "LAYER_MALFORMED"
+ }
+ },
+ {
+ "name": "empty_payload",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Zero-length payload — only FinalNode (0x00)",
+ "input": {
+ "frame_hex": "AA550100008883"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00"
+ }
+ },
+ {
+ "name": "extended_meta_truncated",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Extended meta length 0xFF but only 1 byte follows — truncated",
+ "input": {
+ "frame_hex": "AA55040001FF0000ED0A"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "LAYER_MALFORMED"
+ }
+ },
+ {
+ "name": "reserved_id_FF",
+ "type": "decode",
+ "result": "valid",
+ "flags": [
+ "OptionalBehavior"
+ ],
+ "description": "Layer ID 0xFF (reserved) with metadata — parsers may skip or report",
+ "input": {
+ "frame_hex": "AA550800FF010000646174615B24"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "FF01000064617461"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/passthrough.json b/src/test/resources/llp-vectors/passthrough.json
new file mode 100644
index 0000000..5baddc7
--- /dev/null
+++ b/src/test/resources/llp-vectors/passthrough.json
@@ -0,0 +1,460 @@
+{
+ "spec_version": "3.1.0",
+ "category": "layers_passthrough",
+ "description": "Passthrough layer chains: FinalNode, single/multiple passthrough, metadata variants, unknown IDs",
+ "vectors": [
+ {
+ "name": "encode_empty_chain",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Only FinalNode, no raw data",
+ "input": {
+ "llp_payload_hex": "00"
+ },
+ "expected": {
+ "frame_hex": "AA550100008883"
+ }
+ },
+ {
+ "name": "decode_empty_chain",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Only FinalNode, no raw data",
+ "input": {
+ "frame_hex": "AA550100008883"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00"
+ }
+ },
+ {
+ "name": "encode_final_then_ff",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — FinalNode then a single 0xFF byte",
+ "input": {
+ "llp_payload_hex": "00FF"
+ },
+ "expected": {
+ "frame_hex": "AA55020000FFC7AC"
+ }
+ },
+ {
+ "name": "decode_final_then_ff",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — FinalNode then a single 0xFF byte",
+ "input": {
+ "frame_hex": "AA55020000FFC7AC"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00FF"
+ }
+ },
+ {
+ "name": "encode_final_then_aa55",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — FinalNode then 0xAA 0x55 (magic overlap)",
+ "input": {
+ "llp_payload_hex": "00AA55"
+ },
+ "expected": {
+ "frame_hex": "AA55030000AA00552DE2"
+ }
+ },
+ {
+ "name": "decode_final_then_aa55",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — FinalNode then 0xAA 0x55 (magic overlap)",
+ "input": {
+ "frame_hex": "AA55030000AA00552DE2"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA55"
+ }
+ },
+ {
+ "name": "encode_final_hello",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — FinalNode then 'Hello'",
+ "input": {
+ "llp_payload_hex": "0048656C6C6F"
+ },
+ "expected": {
+ "frame_hex": "AA5506000048656C6C6F3798"
+ }
+ },
+ {
+ "name": "decode_final_hello",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — FinalNode then 'Hello'",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3798"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ },
+ {
+ "name": "encode_single_passthrough",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Passthrough (0x01) with 3 bytes metadata + FinalNode + 'Hello'",
+ "input": {
+ "llp_payload_hex": "01031020300048656C6C6F"
+ },
+ "expected": {
+ "frame_hex": "AA550B0001031020300048656C6C6F6191"
+ }
+ },
+ {
+ "name": "decode_single_passthrough",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Passthrough (0x01) with 3 bytes metadata + FinalNode + 'Hello'",
+ "input": {
+ "frame_hex": "AA550B0001031020300048656C6C6F6191"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "01031020300048656C6C6F"
+ }
+ },
+ {
+ "name": "encode_two_passthrough",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Two passthrough layers (0x01, 0x02) with metadata containing 0xAA",
+ "input": {
+ "llp_payload_hex": "0101AA0202BBCC0042"
+ },
+ "expected": {
+ "frame_hex": "AA5509000101AA000202BBCC0042822E"
+ }
+ },
+ {
+ "name": "decode_two_passthrough",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Two passthrough layers (0x01, 0x02) with metadata containing 0xAA",
+ "input": {
+ "frame_hex": "AA5509000101AA000202BBCC0042822E"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0101AA0202BBCC0042"
+ }
+ },
+ {
+ "name": "encode_unknown_layer_id",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Unknown layer ID (0xFF) with metadata + FinalNode + 'data'",
+ "input": {
+ "llp_payload_hex": "FF01000064617461"
+ },
+ "expected": {
+ "frame_hex": "AA550800FF010000646174615B24"
+ }
+ },
+ {
+ "name": "decode_unknown_layer_id",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Unknown layer ID (0xFF) with metadata + FinalNode + 'data'",
+ "input": {
+ "frame_hex": "AA550800FF010000646174615B24"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "FF01000064617461"
+ }
+ },
+ {
+ "name": "encode_max_passthrough",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Max passthrough layer ID (0x7F) with metadata + FinalNode + 'xyz'",
+ "input": {
+ "llp_payload_hex": "7F02F00F0078797A"
+ },
+ "expected": {
+ "frame_hex": "AA5508007F02F00F0078797ACA6E"
+ }
+ },
+ {
+ "name": "decode_max_passthrough",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Max passthrough layer ID (0x7F) with metadata + FinalNode + 'xyz'",
+ "input": {
+ "frame_hex": "AA5508007F02F00F0078797ACA6E"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "7F02F00F0078797A"
+ }
+ },
+ {
+ "name": "encode_max_transform",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Max transform layer ID (0xFE) with metadata + FinalNode + raw bytes",
+ "input": {
+ "llp_payload_hex": "FE01A500010203"
+ },
+ "expected": {
+ "frame_hex": "AA550700FE01A5000102030750"
+ }
+ },
+ {
+ "name": "decode_max_transform",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Max transform layer ID (0xFE) with metadata + FinalNode + raw bytes",
+ "input": {
+ "frame_hex": "AA550700FE01A5000102030750"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "FE01A500010203"
+ }
+ },
+ {
+ "name": "encode_three_nested",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Three nested passthrough layers before FinalNode + 'deep'",
+ "input": {
+ "llp_payload_hex": "0101010201020301030064656570"
+ },
+ "expected": {
+ "frame_hex": "AA550E000101010201020301030064656570F451"
+ }
+ },
+ {
+ "name": "decode_three_nested",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Three nested passthrough layers before FinalNode + 'deep'",
+ "input": {
+ "frame_hex": "AA550E000101010201020301030064656570F451"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0101010201020301030064656570"
+ }
+ },
+ {
+ "name": "encode_stuffing_metadata",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Layer metadata containing 0xAA and 0x55",
+ "input": {
+ "llp_payload_hex": "0104AA00AA55004F4B"
+ },
+ "expected": {
+ "frame_hex": "AA5509000104AA0000AA0055004F4BC83C"
+ }
+ },
+ {
+ "name": "decode_stuffing_metadata",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Layer metadata containing 0xAA and 0x55",
+ "input": {
+ "frame_hex": "AA5509000104AA0000AA0055004F4BC83C"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0104AA00AA55004F4B"
+ }
+ },
+ {
+ "name": "encode_zero_meta_len",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Passthrough layer with meta_len=0 + FinalNode + 'data'",
+ "input": {
+ "llp_payload_hex": "01000064617461"
+ },
+ "expected": {
+ "frame_hex": "AA55070001000064617461B65F"
+ }
+ },
+ {
+ "name": "decode_zero_meta_len",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Passthrough layer with meta_len=0 + FinalNode + 'data'",
+ "input": {
+ "frame_hex": "AA55070001000064617461B65F"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "01000064617461"
+ }
+ },
+ {
+ "name": "encode_four_nested",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Four nested passthrough layers + FinalNode + 'end'",
+ "input": {
+ "llp_payload_hex": "010002000300040000656E64"
+ },
+ "expected": {
+ "frame_hex": "AA550C00010002000300040000656E643755"
+ }
+ },
+ {
+ "name": "decode_four_nested",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Four nested passthrough layers + FinalNode + 'end'",
+ "input": {
+ "frame_hex": "AA550C00010002000300040000656E643755"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "010002000300040000656E64"
+ }
+ },
+ {
+ "name": "encode_passthrough_7F_zero",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Passthrough 0x7F with meta_len=0 + FinalNode + 'AB'",
+ "input": {
+ "llp_payload_hex": "7F00004142"
+ },
+ "expected": {
+ "frame_hex": "AA5505007F00004142DD3B"
+ }
+ },
+ {
+ "name": "decode_passthrough_7F_zero",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Passthrough 0x7F with meta_len=0 + FinalNode + 'AB'",
+ "input": {
+ "frame_hex": "AA5505007F00004142DD3B"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "7F00004142"
+ }
+ },
+ {
+ "name": "encode_missing_final_node",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Layer 0x01 + raw bytes (42 43), no FinalNode — parser emits entire chain as payload",
+ "input": {
+ "llp_payload_hex": "014243"
+ },
+ "expected": {
+ "frame_hex": "AA550300014243F13E"
+ }
+ },
+ {
+ "name": "decode_missing_final_node",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Layer 0x01 + raw bytes (42 43), no FinalNode — parser emits entire chain as payload",
+ "input": {
+ "frame_hex": "AA550300014243F13E"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "014243"
+ }
+ },
+ {
+ "name": "extended_meta_zero",
+ "type": "decode",
+ "result": "valid",
+ "flags": [
+ "EdgeCase"
+ ],
+ "description": "Extended meta length 0xFF 0x00 0x00 = 0 bytes metadata",
+ "input": {
+ "frame_hex": "AA55090001FF000000646174612057"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "01FF00000064617461"
+ }
+ },
+ {
+ "name": "extended_meta_255",
+ "type": "decode",
+ "result": "valid",
+ "flags": [
+ "EdgeCase"
+ ],
+ "description": "Extended meta length 0xFF 0x00 0xFF = 255 bytes of 0xAA",
+ "input": {
+ "frame_hex": "AA55060101FF00FFAA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA00AA000061620E4F"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "01FF00FFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA006162"
+ }
+ },
+ {
+ "name": "extended_meta_256",
+ "type": "decode",
+ "result": "valid",
+ "flags": [
+ "EdgeCase"
+ ],
+ "description": "Extended meta length 0xFF 0x01 0x00 = 256 bytes of 0xBB",
+ "input": {
+ "frame_hex": "AA55070101FF0100BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB00616208A4"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "01FF0100BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB006162"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/recovery.json b/src/test/resources/llp-vectors/recovery.json
new file mode 100644
index 0000000..816a5ca
--- /dev/null
+++ b/src/test/resources/llp-vectors/recovery.json
@@ -0,0 +1,140 @@
+{
+ "spec_version": "3.1.0",
+ "category": "parser_recovery",
+ "description": "Error recovery: CRC, timeout, sync error, garbage, and multiple errors",
+ "vectors": [
+ {
+ "name": "after_crc_error",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "CRC error then valid frame — recovers",
+ "input": {
+ "chunks_hex": [
+ "AA55010000887C",
+ "AA5506000048656C6C6F3798"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "CHECKSUM"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "after_truncation",
+ "type": "stream",
+ "result": "valid",
+ "flags": [
+ "Slow"
+ ],
+ "description": "Truncated mid-length then valid frame — parser recovers with SYNC_ERROR",
+ "input": {
+ "chunks_hex": [
+ "AA5501",
+ "AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "SYNC_ERROR"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "after_sync_error",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Invalid escape then valid — recovers",
+ "input": {
+ "chunks_hex": [
+ "AA55020000AA9997A6",
+ "AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "SYNC_ERROR"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "garbage_then_two_frames",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Garbage then two valid frames",
+ "input": {
+ "chunks_hex": [
+ "DEADBEEF",
+ "AA550100008883",
+ "AA5506000048656C6C6F3798"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "multiple_errors_then_valid",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Two CRC errors then a valid frame — stress recovery",
+ "input": {
+ "chunks_hex": [
+ "AA55010000887C",
+ "AA5506000048656C6C6F3767",
+ "AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "CHECKSUM"
+ },
+ {
+ "type": "ERROR",
+ "error_code": "CHECKSUM"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/resync.json b/src/test/resources/llp-vectors/resync.json
new file mode 100644
index 0000000..c5f79d0
--- /dev/null
+++ b/src/test/resources/llp-vectors/resync.json
@@ -0,0 +1,183 @@
+{
+ "spec_version": "3.1.0",
+ "category": "transport_resync",
+ "description": "Resynchronisation: noise, corruption, invalid escapes, and recovery",
+ "vectors": [
+ {
+ "name": "noise_before_frame",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Noise (0xFF) before valid frame — parser discards noise",
+ "input": {
+ "chunks_hex": [
+ "FFFFFFAA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "noise_between_frames",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Two valid frames with noise between them",
+ "input": {
+ "chunks_hex": [
+ "AA550100008883DEADAA5506000048656C6C6F3798"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "corrupt_magic1",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "First magic byte corrupted — parser resyncs",
+ "input": {
+ "chunks_hex": [
+ "BB550100008883AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "corrupt_magic2",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Second magic byte corrupted — parser resyncs",
+ "input": {
+ "chunks_hex": [
+ "AA440100008883AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "aa_in_payload_no_false_resync",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Stuffing hides 0xAA bytes — parser does not false-resync",
+ "input": {
+ "chunks_hex": [
+ "AA55020000AA0097A6"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ }
+ ]
+ }
+ },
+ {
+ "name": "aa55_in_payload_no_false_resync",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "0xAA 0x55 in payload is stuffed — parser does not split frame",
+ "input": {
+ "chunks_hex": [
+ "AA55030000AA00552DE2"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA55"
+ }
+ ]
+ }
+ },
+ {
+ "name": "invalid_escape_then_valid",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Invalid escape then valid frame — parser recovers with SYNC_ERROR",
+ "input": {
+ "chunks_hex": [
+ "AA55020000AA9997A6AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "SYNC_ERROR"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "garbage_three_frames",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Garbage between three valid frames — stress resync",
+ "input": {
+ "chunks_hex": [
+ "AA550100008883FFAA5506000048656C6C6F3798AABBAA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/stuffing.json b/src/test/resources/llp-vectors/stuffing.json
new file mode 100644
index 0000000..0aa9ca0
--- /dev/null
+++ b/src/test/resources/llp-vectors/stuffing.json
@@ -0,0 +1,119 @@
+{
+ "spec_version": "3.1.0",
+ "category": "transport_stuffing",
+ "description": "Byte stuffing: valid stuffed frames, magic overlap, invalid escape sequences",
+ "vectors": [
+ {
+ "name": "valid_single_aa",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Valid stuffed frame — Single 0xAA stuffed byte",
+ "input": {
+ "frame_hex": "AA55020000AA0097A6"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA"
+ }
+ },
+ {
+ "name": "valid_magic_overlap",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Valid stuffed frame — 0xAA 0x55 in payload — stuffed, no false resync",
+ "input": {
+ "frame_hex": "AA55030000AA00552DE2"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA55"
+ }
+ },
+ {
+ "name": "valid_triple_aa",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Valid stuffed frame — Three consecutive 0xAA bytes",
+ "input": {
+ "frame_hex": "AA55040000AA00AA00AA00722F"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AAAAAA"
+ }
+ },
+ {
+ "name": "valid_mixed_aa",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Valid stuffed frame — Scattered 0xAA bytes",
+ "input": {
+ "frame_hex": "AA5506000001AA0002AA0003DBD7"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0001AA02AA03"
+ }
+ },
+ {
+ "name": "invalid_escape_0x01",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Invalid escape sequence — 0xAA followed by 0x01",
+ "input": {
+ "frame_hex": "AA55020000AA0197A6"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "SYNC_ERROR"
+ }
+ },
+ {
+ "name": "invalid_escape_0xFF",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Invalid escape sequence — 0xAA followed by 0xFF",
+ "input": {
+ "frame_hex": "AA55020000AAFF97A6"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "SYNC_ERROR"
+ }
+ },
+ {
+ "name": "invalid_escape_0xAA",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Invalid escape sequence — 0xAA followed by another 0xAA",
+ "input": {
+ "frame_hex": "AA55020000AAAA97A6"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "SYNC_ERROR"
+ }
+ },
+ {
+ "name": "raw_aa_unescaped",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Raw 0xAA without 0x00 escape byte",
+ "input": {
+ "frame_hex": "AA55020000AA97A6"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "SYNC_ERROR"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/timeout.json b/src/test/resources/llp-vectors/timeout.json
new file mode 100644
index 0000000..9a523ec
--- /dev/null
+++ b/src/test/resources/llp-vectors/timeout.json
@@ -0,0 +1,322 @@
+{
+ "spec_version": "3.1.0",
+ "category": "transport_timeout",
+ "description": "Timeout behaviour: mid-frame, between frames, and recovery after timeout",
+ "vectors": [
+ {
+ "name": "timeout_mid_frame",
+ "type": "timing",
+ "result": "invalid",
+ "flags": [],
+ "description": "Timeout in middle of receiving a frame",
+ "config": {
+ "timeout_ms": 2000
+ },
+ "input": {
+ "events": [
+ {
+ "byte_hex": "AA",
+ "time_ms": 0
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 1
+ },
+ {
+ "byte_hex": "06",
+ "time_ms": 2
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5000
+ }
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ ]
+ }
+ },
+ {
+ "name": "timeout_then_valid_frame",
+ "type": "timing",
+ "result": "valid",
+ "flags": [
+ "Slow"
+ ],
+ "description": "Timeout then complete valid frame arrives after reset",
+ "config": {
+ "timeout_ms": 2000
+ },
+ "input": {
+ "events": [
+ {
+ "byte_hex": "AA",
+ "time_ms": 0
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 1
+ },
+ {
+ "byte_hex": "AA",
+ "time_ms": 5000
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 5001
+ },
+ {
+ "byte_hex": "01",
+ "time_ms": 5002
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5003
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5004
+ },
+ {
+ "byte_hex": "88",
+ "time_ms": 5005
+ },
+ {
+ "byte_hex": "83",
+ "time_ms": 5006
+ }
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "TIMEOUT"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "timeout_between_frames",
+ "type": "timing",
+ "result": "valid",
+ "flags": [
+ "Slow"
+ ],
+ "description": "Timeout gap between two valid frames — both received",
+ "config": {
+ "timeout_ms": 2000
+ },
+ "input": {
+ "events": [
+ {
+ "byte_hex": "AA",
+ "time_ms": 0
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 1
+ },
+ {
+ "byte_hex": "01",
+ "time_ms": 2
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 3
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 4
+ },
+ {
+ "byte_hex": "88",
+ "time_ms": 5
+ },
+ {
+ "byte_hex": "83",
+ "time_ms": 6
+ },
+ {
+ "byte_hex": "AA",
+ "time_ms": 5000
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 5001
+ },
+ {
+ "byte_hex": "01",
+ "time_ms": 5002
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5003
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5004
+ },
+ {
+ "byte_hex": "88",
+ "time_ms": 5005
+ },
+ {
+ "byte_hex": "83",
+ "time_ms": 5006
+ }
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "timeout_during_second_of_two",
+ "type": "timing",
+ "result": "invalid",
+ "flags": [
+ "Slow"
+ ],
+ "description": "First frame OK, then timeout during second frame",
+ "config": {
+ "timeout_ms": 2000
+ },
+ "input": {
+ "events": [
+ {
+ "byte_hex": "AA",
+ "time_ms": 0
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 1
+ },
+ {
+ "byte_hex": "01",
+ "time_ms": 2
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 3
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 4
+ },
+ {
+ "byte_hex": "88",
+ "time_ms": 5
+ },
+ {
+ "byte_hex": "83",
+ "time_ms": 6
+ },
+ {
+ "byte_hex": "AA",
+ "time_ms": 7
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 8000
+ }
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ ]
+ }
+ },
+ {
+ "name": "timeout_then_aa_triggers_resync",
+ "type": "timing",
+ "result": "acceptable",
+ "flags": [
+ "OptionalBehavior",
+ "Slow"
+ ],
+ "description": "Timeout on 0xAA triggers optimistic resync instead of discarding byte",
+ "config": {
+ "timeout_ms": 2000
+ },
+ "input": {
+ "events": [
+ {
+ "byte_hex": "AA",
+ "time_ms": 0
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 1
+ },
+ {
+ "byte_hex": "AA",
+ "time_ms": 5000
+ },
+ {
+ "byte_hex": "55",
+ "time_ms": 5001
+ },
+ {
+ "byte_hex": "01",
+ "time_ms": 5002
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5003
+ },
+ {
+ "byte_hex": "00",
+ "time_ms": 5004
+ },
+ {
+ "byte_hex": "88",
+ "time_ms": 5005
+ },
+ {
+ "byte_hex": "83",
+ "time_ms": 5006
+ }
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "ERROR",
+ "error_code": "TIMEOUT"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/transform.json b/src/test/resources/llp-vectors/transform.json
new file mode 100644
index 0000000..9f604c1
--- /dev/null
+++ b/src/test/resources/llp-vectors/transform.json
@@ -0,0 +1,183 @@
+{
+ "spec_version": "3.1.0",
+ "category": "layers_transform",
+ "description": "Transform layer chains: transform (0x80-0xFE), mixed passthrough+transform, max IDs",
+ "vectors": [
+ {
+ "name": "encode_transform_FE_meta5",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Transform 0xFE with 5 bytes metadata + FinalNode + 0xFF",
+ "input": {
+ "llp_payload_hex": "FE05010203040500FF"
+ },
+ "expected": {
+ "frame_hex": "AA550900FE05010203040500FF8C62"
+ }
+ },
+ {
+ "name": "decode_transform_FE_meta5",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Transform 0xFE with 5 bytes metadata + FinalNode + 0xFF",
+ "input": {
+ "frame_hex": "AA550900FE05010203040500FF8C62"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "FE05010203040500FF"
+ }
+ },
+ {
+ "name": "encode_layers_aa_meta",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Two layers with 0xAA in metadata",
+ "input": {
+ "llp_payload_hex": "0102AA00AA0203AABBCC006465616462656566"
+ },
+ "expected": {
+ "frame_hex": "AA5513000102AA0000AA000203AA00BBCC0064656164626565661481"
+ }
+ },
+ {
+ "name": "decode_layers_aa_meta",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Two layers with 0xAA in metadata",
+ "input": {
+ "frame_hex": "AA5513000102AA0000AA000203AA00BBCC0064656164626565661481"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0102AA00AA0203AABBCC006465616462656566"
+ }
+ },
+ {
+ "name": "encode_deep_nested_5",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Five passthrough layers with zero metadata + FinalNode + 'end'",
+ "input": {
+ "llp_payload_hex": "0100010001000100010000656E64"
+ },
+ "expected": {
+ "frame_hex": "AA550E000100010001000100010000656E64A8D3"
+ }
+ },
+ {
+ "name": "decode_deep_nested_5",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Five passthrough layers with zero metadata + FinalNode + 'end'",
+ "input": {
+ "frame_hex": "AA550E000100010001000100010000656E64A8D3"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0100010001000100010000656E64"
+ }
+ },
+ {
+ "name": "encode_zero_meta_then_final",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Passthrough (meta=0) + FinalNode + 'ab'",
+ "input": {
+ "llp_payload_hex": "010000006162"
+ },
+ "expected": {
+ "frame_hex": "AA550600010000006162BE62"
+ }
+ },
+ {
+ "name": "decode_zero_meta_then_final",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Passthrough (meta=0) + FinalNode + 'ab'",
+ "input": {
+ "frame_hex": "AA550600010000006162BE62"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "010000006162"
+ }
+ },
+ {
+ "name": "encode_transform_layer",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Transform layer (0x80) with 4-byte metadata + FinalNode + 'OK'",
+ "input": {
+ "llp_payload_hex": "8004DEADBEEF004F4B"
+ },
+ "expected": {
+ "frame_hex": "AA5509008004DEADBEEF004F4BB396"
+ }
+ },
+ {
+ "name": "decode_transform_layer",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Transform layer (0x80) with 4-byte metadata + FinalNode + 'OK'",
+ "input": {
+ "frame_hex": "AA5509008004DEADBEEF004F4BB396"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "8004DEADBEEF004F4B"
+ }
+ },
+ {
+ "name": "encode_mixed_layers",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Encode — Passthrough (0x01) then transform (0x81) then FinalNode",
+ "input": {
+ "llp_payload_hex": "010211228101FF0055AA01"
+ },
+ "expected": {
+ "frame_hex": "AA550B00010211228101FF0055AA0001C753"
+ }
+ },
+ {
+ "name": "decode_mixed_layers",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse — Passthrough (0x01) then transform (0x81) then FinalNode",
+ "input": {
+ "frame_hex": "AA550B00010211228101FF0055AA0001C753"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "010211228101FF0055AA01"
+ }
+ },
+ {
+ "name": "transform_no_handler_decode",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Decode transform layer (0x80) with no handler — transport parse succeeds, traversal blocked",
+ "input": {
+ "frame_hex": "AA5509008004DEADBEEF004F4BB396"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "8004DEADBEEF004F4B"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/traversal.json b/src/test/resources/llp-vectors/traversal.json
new file mode 100644
index 0000000..f77fbc1
--- /dev/null
+++ b/src/test/resources/llp-vectors/traversal.json
@@ -0,0 +1,81 @@
+{
+ "spec_version": "3.1.0",
+ "category": "layers_traversal",
+ "description": "Traverse layer chains to extract raw final payload",
+ "vectors": [
+ {
+ "name": "three_passthrough_get_deep",
+ "type": "traversal",
+ "result": "valid",
+ "flags": [],
+ "description": "Extract from three nested passthrough → 'deep'",
+ "input": {
+ "frame_hex": "AA550E000101010201020301030064656570F451"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "final_payload_hex": "64656570"
+ }
+ },
+ {
+ "name": "single_passthrough_get_hello",
+ "type": "traversal",
+ "result": "valid",
+ "flags": [],
+ "description": "Extract from single passthrough + FinalNode + 'Hello'",
+ "input": {
+ "frame_hex": "AA550B0001031020300048656C6C6F6191"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "final_payload_hex": "48656C6C6F"
+ }
+ },
+ {
+ "name": "direct_finalnode",
+ "type": "traversal",
+ "result": "valid",
+ "flags": [],
+ "description": "Bare FinalNode — no layers, just raw 0x42",
+ "input": {
+ "frame_hex": "AA5502000042B1DA"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "final_payload_hex": "42"
+ }
+ },
+ {
+ "name": "empty_final_payload",
+ "type": "traversal",
+ "result": "valid",
+ "flags": [
+ "EdgeCase"
+ ],
+ "description": "FinalNode with 0 bytes of raw application data",
+ "input": {
+ "frame_hex": "AA550100008883"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "final_payload_hex": ""
+ }
+ },
+ {
+ "name": "single_transform_blocked",
+ "type": "traversal",
+ "result": "invalid",
+ "flags": [
+ "ImplementationDefined"
+ ],
+ "description": "Transform layer (0x80) blocks traversal — no handler registered",
+ "input": {
+ "frame_hex": "AA5509008004DEADBEEF004F4BB396"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TRANSFORM_NO_HANDLER"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/truncation.json b/src/test/resources/llp-vectors/truncation.json
new file mode 100644
index 0000000..df03939
--- /dev/null
+++ b/src/test/resources/llp-vectors/truncation.json
@@ -0,0 +1,146 @@
+{
+ "spec_version": "3.1.0",
+ "category": "transport_truncation",
+ "description": "Truncated frames: each field boundary — tests timeout detection",
+ "vectors": [
+ {
+ "name": "truncated_after_magic1",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "First magic byte only — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_after_magic2",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Both magic bytes — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA55"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_after_len_l",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "After first length byte — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA5506"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_after_len_h",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "After complete length field — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA550600"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_mid_payload_1",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "After one payload byte — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA5506000048"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_mid_payload_2",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "After two payload bytes — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA550600004865"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_mid_payload_4",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "After four payload bytes — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA5506000048656C6C"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "truncated_mid_crc_low",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "After first CRC byte — frame incomplete, timeout expected",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F37"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ },
+ {
+ "name": "empty_stream",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Empty byte stream — no frame",
+ "input": {
+ "frame_hex": ""
+ },
+ "expected": {
+ "outcome": "NONE"
+ }
+ },
+ {
+ "name": "magic_only",
+ "type": "decode",
+ "result": "invalid",
+ "flags": [],
+ "description": "Only AA55, no length or payload",
+ "input": {
+ "frame_hex": "AA55"
+ },
+ "expected": {
+ "outcome": "ERROR",
+ "error_code": "TIMEOUT"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/llp-vectors/valid.json b/src/test/resources/llp-vectors/valid.json
new file mode 100644
index 0000000..843c923
--- /dev/null
+++ b/src/test/resources/llp-vectors/valid.json
@@ -0,0 +1,974 @@
+{
+ "spec_version": "3.1.0",
+ "category": "transport_valid",
+ "description": "Valid LLP frames: encoding, decoding round-trips, and multi-frame streams",
+ "vectors": [
+ {
+ "name": "encode_empty_payload",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Empty application payload — only FinalNode marker (0x00)",
+ "input": {
+ "llp_payload_hex": "00"
+ },
+ "expected": {
+ "frame_hex": "AA550100008883"
+ }
+ },
+ {
+ "name": "encode_single_byte_42",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Single byte payload (0x42) — minimum non-empty frame",
+ "input": {
+ "llp_payload_hex": "0042"
+ },
+ "expected": {
+ "frame_hex": "AA5502000042B1DA"
+ }
+ },
+ {
+ "name": "encode_hello_world",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "5-byte ASCII payload 'Hello'",
+ "input": {
+ "llp_payload_hex": "0048656C6C6F"
+ },
+ "expected": {
+ "frame_hex": "AA5506000048656C6C6F3798"
+ }
+ },
+ {
+ "name": "encode_payload_null_byte",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Payload containing a single 0x00 byte",
+ "input": {
+ "llp_payload_hex": "0000"
+ },
+ "expected": {
+ "frame_hex": "AA550200000037B2"
+ }
+ },
+ {
+ "name": "encode_payload_ff_byte",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Payload containing a single 0xFF byte",
+ "input": {
+ "llp_payload_hex": "00FF"
+ },
+ "expected": {
+ "frame_hex": "AA55020000FFC7AC"
+ }
+ },
+ {
+ "name": "encode_payload_aa_byte",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Payload with single 0xAA — triggers byte stuffing",
+ "input": {
+ "llp_payload_hex": "00AA"
+ },
+ "expected": {
+ "frame_hex": "AA55020000AA0097A6"
+ }
+ },
+ {
+ "name": "encode_payload_aa55",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Payload with 0xAA 0x55 — magic sequence in payload",
+ "input": {
+ "llp_payload_hex": "00AA55"
+ },
+ "expected": {
+ "frame_hex": "AA55030000AA00552DE2"
+ }
+ },
+ {
+ "name": "encode_payload_triple_aa",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Three consecutive 0xAA bytes",
+ "input": {
+ "llp_payload_hex": "00AAAAAA"
+ },
+ "expected": {
+ "frame_hex": "AA55040000AA00AA00AA00722F"
+ }
+ },
+ {
+ "name": "encode_payload_mixed_aa",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Scattered 0xAA bytes with normal data",
+ "input": {
+ "llp_payload_hex": "0001AA02AA03"
+ },
+ "expected": {
+ "frame_hex": "AA5506000001AA0002AA0003DBD7"
+ }
+ },
+ {
+ "name": "encode_payload_zeros_16",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "16 zero bytes",
+ "input": {
+ "llp_payload_hex": "0000000000000000000000000000000000"
+ },
+ "expected": {
+ "frame_hex": "AA551100000000000000000000000000000000000036A3"
+ }
+ },
+ {
+ "name": "encode_payload_ones_16",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "16 bytes of 0x01",
+ "input": {
+ "llp_payload_hex": "0001010101010101010101010101010101"
+ },
+ "expected": {
+ "frame_hex": "AA551100000101010101010101010101010101010161B6"
+ }
+ },
+ {
+ "name": "encode_payload_incremental",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "16 sequential bytes 0x00..0x0F",
+ "input": {
+ "llp_payload_hex": "00000102030405060708090A0B0C0D0E0F"
+ },
+ "expected": {
+ "frame_hex": "AA55110000000102030405060708090A0B0C0D0E0F0BF2"
+ }
+ },
+ {
+ "name": "encode_payload_all_ff_16",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "16 bytes of 0xFF",
+ "input": {
+ "llp_payload_hex": "00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ },
+ "expected": {
+ "frame_hex": "AA55110000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF77A3"
+ }
+ },
+ {
+ "name": "encode_payload_aa_prefix",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "0xAA at start of payload",
+ "input": {
+ "llp_payload_hex": "00AA42"
+ },
+ "expected": {
+ "frame_hex": "AA55030000AA0042FB80"
+ }
+ },
+ {
+ "name": "encode_payload_aa_suffix",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "0xAA at end of payload (right before CRC)",
+ "input": {
+ "llp_payload_hex": "0042AA"
+ },
+ "expected": {
+ "frame_hex": "AA5503000042AA00C665"
+ }
+ },
+ {
+ "name": "encode_payload_aa_boundary",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "0xAA sequences with 0x00 interleaved",
+ "input": {
+ "llp_payload_hex": "00AAAA00AA"
+ },
+ "expected": {
+ "frame_hex": "AA55050000AA00AA0000AA00F9F9"
+ }
+ },
+ {
+ "name": "encode_payload_alternating",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Alternating 0xAA / 0x00 pattern",
+ "input": {
+ "llp_payload_hex": "00AA00AA00AA"
+ },
+ "expected": {
+ "frame_hex": "AA55060000AA0000AA0000AA00D651"
+ }
+ },
+ {
+ "name": "encode_payload_seq_32",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "32 sequential bytes",
+ "input": {
+ "llp_payload_hex": "00000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F"
+ },
+ "expected": {
+ "frame_hex": "AA55210000000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F9845"
+ }
+ },
+ {
+ "name": "encode_payload_55_byte",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Single 0x55 byte (second magic byte in data)",
+ "input": {
+ "llp_payload_hex": "0055"
+ },
+ "expected": {
+ "frame_hex": "AA550200005567B8"
+ }
+ },
+ {
+ "name": "encode_payload_byte_0x7F",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Single 0x7F byte (max passthrough layer ID)",
+ "input": {
+ "llp_payload_hex": "007F"
+ },
+ "expected": {
+ "frame_hex": "AA550200007F4F3D"
+ }
+ },
+ {
+ "name": "encode_payload_byte_0x80",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Single 0x80 byte (min transform layer ID)",
+ "input": {
+ "llp_payload_hex": "0080"
+ },
+ "expected": {
+ "frame_hex": "AA5502000080BF23"
+ }
+ },
+ {
+ "name": "encode_payload_byte_0xFE",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Single 0xFE byte (max transform layer ID)",
+ "input": {
+ "llp_payload_hex": "00FE"
+ },
+ "expected": {
+ "frame_hex": "AA55020000FEE6BC"
+ }
+ },
+ {
+ "name": "encode_payload_ascii_test",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "4-byte ASCII 'Test'",
+ "input": {
+ "llp_payload_hex": "0054657374"
+ },
+ "expected": {
+ "frame_hex": "AA550500005465737491B9"
+ }
+ },
+ {
+ "name": "encode_payload_ascii_fox",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "The quick brown fox (20 bytes)",
+ "input": {
+ "llp_payload_hex": "0054686520717569636B2062726F776E20666F78"
+ },
+ "expected": {
+ "frame_hex": "AA5514000054686520717569636B2062726F776E20666F78BB2C"
+ }
+ },
+ {
+ "name": "encode_payload_eight_bytes",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "8 sequential bytes",
+ "input": {
+ "llp_payload_hex": "000001020304050607"
+ },
+ "expected": {
+ "frame_hex": "AA5509000000010203040506079D89"
+ }
+ },
+ {
+ "name": "encode_payload_fifteen_null",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "15 null bytes",
+ "input": {
+ "llp_payload_hex": "00000000000000000000000000000000"
+ },
+ "expected": {
+ "frame_hex": "AA55100000000000000000000000000000000000EC09"
+ }
+ },
+ {
+ "name": "encode_payload_thirty_null",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "30 null bytes",
+ "input": {
+ "llp_payload_hex": "00000000000000000000000000000000000000000000000000000000000000"
+ },
+ "expected": {
+ "frame_hex": "AA551F00000000000000000000000000000000000000000000000000000000000000006AC1"
+ }
+ },
+ {
+ "name": "encode_payload_sixtyfour_seq",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "64 sequential bytes",
+ "input": {
+ "llp_payload_hex": "00000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F"
+ },
+ "expected": {
+ "frame_hex": "AA55410000000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F07DF"
+ }
+ },
+ {
+ "name": "encode_payload_repeated_55",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "8 bytes of 0x55",
+ "input": {
+ "llp_payload_hex": "005555555555555555"
+ },
+ "expected": {
+ "frame_hex": "AA5509000055555555555555556E3D"
+ }
+ },
+ {
+ "name": "encode_payload_aa_55_pairs",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Alternating 0xAA 0x55 pairs",
+ "input": {
+ "llp_payload_hex": "00AA55AA55AA55"
+ },
+ "expected": {
+ "frame_hex": "AA55070000AA0055AA0055AA0055FCFE"
+ }
+ },
+ {
+ "name": "encode_payload_mixed_55_aa",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Alternating 0x55 0xAA pattern",
+ "input": {
+ "llp_payload_hex": "0055AA55AA55AA55AA"
+ },
+ "expected": {
+ "frame_hex": "AA5509000055AA0055AA0055AA0055AA002313"
+ }
+ },
+ {
+ "name": "encode_payload_double_zero",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "Two consecutive 0x00 bytes",
+ "input": {
+ "llp_payload_hex": "000000"
+ },
+ "expected": {
+ "frame_hex": "AA550300000000C81A"
+ }
+ },
+ {
+ "name": "encode_payload_ten_AS",
+ "type": "encode",
+ "result": "valid",
+ "flags": [],
+ "description": "10 bytes of ASCII 'A'",
+ "input": {
+ "llp_payload_hex": "0041414141414141414141"
+ },
+ "expected": {
+ "frame_hex": "AA550B00004141414141414141414125FF"
+ }
+ },
+ {
+ "name": "decode_empty_payload",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Empty application payload — only FinalNode marker (0x00)",
+ "input": {
+ "frame_hex": "AA550100008883"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00"
+ }
+ },
+ {
+ "name": "decode_single_byte_42",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Single byte payload (0x42) — minimum non-empty frame",
+ "input": {
+ "frame_hex": "AA5502000042B1DA"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0042"
+ }
+ },
+ {
+ "name": "decode_hello_world",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 5-byte ASCII payload 'Hello'",
+ "input": {
+ "frame_hex": "AA5506000048656C6C6F3798"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ },
+ {
+ "name": "decode_payload_null_byte",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Payload containing a single 0x00 byte",
+ "input": {
+ "frame_hex": "AA550200000037B2"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0000"
+ }
+ },
+ {
+ "name": "decode_payload_ff_byte",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Payload containing a single 0xFF byte",
+ "input": {
+ "frame_hex": "AA55020000FFC7AC"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00FF"
+ }
+ },
+ {
+ "name": "decode_payload_aa_byte",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Payload with single 0xAA — triggers byte stuffing",
+ "input": {
+ "frame_hex": "AA55020000AA0097A6"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA"
+ }
+ },
+ {
+ "name": "decode_payload_aa55",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Payload with 0xAA 0x55 — magic sequence in payload",
+ "input": {
+ "frame_hex": "AA55030000AA00552DE2"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA55"
+ }
+ },
+ {
+ "name": "decode_payload_triple_aa",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Three consecutive 0xAA bytes",
+ "input": {
+ "frame_hex": "AA55040000AA00AA00AA00722F"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AAAAAA"
+ }
+ },
+ {
+ "name": "decode_payload_mixed_aa",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Scattered 0xAA bytes with normal data",
+ "input": {
+ "frame_hex": "AA5506000001AA0002AA0003DBD7"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0001AA02AA03"
+ }
+ },
+ {
+ "name": "decode_payload_zeros_16",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 16 zero bytes",
+ "input": {
+ "frame_hex": "AA551100000000000000000000000000000000000036A3"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0000000000000000000000000000000000"
+ }
+ },
+ {
+ "name": "decode_payload_ones_16",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 16 bytes of 0x01",
+ "input": {
+ "frame_hex": "AA551100000101010101010101010101010101010161B6"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0001010101010101010101010101010101"
+ }
+ },
+ {
+ "name": "decode_payload_incremental",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 16 sequential bytes 0x00..0x0F",
+ "input": {
+ "frame_hex": "AA55110000000102030405060708090A0B0C0D0E0F0BF2"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00000102030405060708090A0B0C0D0E0F"
+ }
+ },
+ {
+ "name": "decode_payload_all_ff_16",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 16 bytes of 0xFF",
+ "input": {
+ "frame_hex": "AA55110000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF77A3"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ }
+ },
+ {
+ "name": "decode_payload_aa_prefix",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 0xAA at start of payload",
+ "input": {
+ "frame_hex": "AA55030000AA0042FB80"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA42"
+ }
+ },
+ {
+ "name": "decode_payload_aa_suffix",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 0xAA at end of payload (right before CRC)",
+ "input": {
+ "frame_hex": "AA5503000042AA00C665"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0042AA"
+ }
+ },
+ {
+ "name": "decode_payload_aa_boundary",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 0xAA sequences with 0x00 interleaved",
+ "input": {
+ "frame_hex": "AA55050000AA00AA0000AA00F9F9"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AAAA00AA"
+ }
+ },
+ {
+ "name": "decode_payload_alternating",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Alternating 0xAA / 0x00 pattern",
+ "input": {
+ "frame_hex": "AA55060000AA0000AA0000AA00D651"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA00AA00AA"
+ }
+ },
+ {
+ "name": "decode_payload_seq_32",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 32 sequential bytes",
+ "input": {
+ "frame_hex": "AA55210000000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F9845"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F"
+ }
+ },
+ {
+ "name": "decode_payload_55_byte",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Single 0x55 byte (second magic byte in data)",
+ "input": {
+ "frame_hex": "AA550200005567B8"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0055"
+ }
+ },
+ {
+ "name": "decode_payload_byte_0x7F",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Single 0x7F byte (max passthrough layer ID)",
+ "input": {
+ "frame_hex": "AA550200007F4F3D"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "007F"
+ }
+ },
+ {
+ "name": "decode_payload_byte_0x80",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Single 0x80 byte (min transform layer ID)",
+ "input": {
+ "frame_hex": "AA5502000080BF23"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0080"
+ }
+ },
+ {
+ "name": "decode_payload_byte_0xFE",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Single 0xFE byte (max transform layer ID)",
+ "input": {
+ "frame_hex": "AA55020000FEE6BC"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00FE"
+ }
+ },
+ {
+ "name": "decode_payload_ascii_test",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 4-byte ASCII 'Test'",
+ "input": {
+ "frame_hex": "AA550500005465737491B9"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0054657374"
+ }
+ },
+ {
+ "name": "decode_payload_ascii_fox",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — The quick brown fox (20 bytes)",
+ "input": {
+ "frame_hex": "AA5514000054686520717569636B2062726F776E20666F78BB2C"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0054686520717569636B2062726F776E20666F78"
+ }
+ },
+ {
+ "name": "decode_payload_eight_bytes",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 8 sequential bytes",
+ "input": {
+ "frame_hex": "AA5509000000010203040506079D89"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "000001020304050607"
+ }
+ },
+ {
+ "name": "decode_payload_fifteen_null",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 15 null bytes",
+ "input": {
+ "frame_hex": "AA55100000000000000000000000000000000000EC09"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00000000000000000000000000000000"
+ }
+ },
+ {
+ "name": "decode_payload_thirty_null",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 30 null bytes",
+ "input": {
+ "frame_hex": "AA551F00000000000000000000000000000000000000000000000000000000000000006AC1"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00000000000000000000000000000000000000000000000000000000000000"
+ }
+ },
+ {
+ "name": "decode_payload_sixtyfour_seq",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 64 sequential bytes",
+ "input": {
+ "frame_hex": "AA55410000000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F07DF"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F"
+ }
+ },
+ {
+ "name": "decode_payload_repeated_55",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 8 bytes of 0x55",
+ "input": {
+ "frame_hex": "AA5509000055555555555555556E3D"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "005555555555555555"
+ }
+ },
+ {
+ "name": "decode_payload_aa_55_pairs",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Alternating 0xAA 0x55 pairs",
+ "input": {
+ "frame_hex": "AA55070000AA0055AA0055AA0055FCFE"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "00AA55AA55AA55"
+ }
+ },
+ {
+ "name": "decode_payload_mixed_55_aa",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Alternating 0x55 0xAA pattern",
+ "input": {
+ "frame_hex": "AA5509000055AA0055AA0055AA0055AA002313"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0055AA55AA55AA55AA"
+ }
+ },
+ {
+ "name": "decode_payload_double_zero",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — Two consecutive 0x00 bytes",
+ "input": {
+ "frame_hex": "AA550300000000C81A"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "000000"
+ }
+ },
+ {
+ "name": "decode_payload_ten_AS",
+ "type": "decode",
+ "result": "valid",
+ "flags": [],
+ "description": "Parse valid frame — 10 bytes of ASCII 'A'",
+ "input": {
+ "frame_hex": "AA550B00004141414141414141414125FF"
+ },
+ "expected": {
+ "outcome": "FRAME",
+ "payload_hex": "0041414141414141414141"
+ }
+ },
+ {
+ "name": "stream_two_empty",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Two empty frames back-to-back",
+ "input": {
+ "chunks_hex": [
+ "AA550100008883AA550100008883"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ }
+ ]
+ }
+ },
+ {
+ "name": "stream_empty_then_hello",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Empty then hello frame concatenated",
+ "input": {
+ "chunks_hex": [
+ "AA550100008883AA5506000048656C6C6F3798"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ },
+ {
+ "name": "stream_three_mixed",
+ "type": "stream",
+ "result": "valid",
+ "flags": [],
+ "description": "Three frames: aa_byte, empty, hello",
+ "input": {
+ "chunks_hex": [
+ "AA55020000AA0097A6AA550100008883AA5506000048656C6C6F3798"
+ ]
+ },
+ "expected": {
+ "events": [
+ {
+ "type": "FRAME",
+ "payload_hex": "00AA"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "00"
+ },
+ {
+ "type": "FRAME",
+ "payload_hex": "0048656C6C6F"
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
From 742316fdbef06c66e1b242f61f9603bbfef9aa74 Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Sun, 17 May 2026 17:39:49 -0300
Subject: [PATCH 2/2] Actualizada version en pom y actualizada documentacion de
README
---
README.md | 153 ++++++++++++++++++++++++++++++------------------------
pom.xml | 2 +-
2 files changed, 85 insertions(+), 70 deletions(-)
diff --git a/README.md b/README.md
index bda8211..4dfad69 100644
--- a/README.md
+++ b/README.md
@@ -36,10 +36,9 @@ Añade la dependencia a tu `pom.xml`:
```xml
com.flamingo
- llp-protocol
- 3.0.1
+ llp-core
+ 3.1.0
-
```
### Autenticación en GitHub Packages
@@ -52,7 +51,7 @@ Ve a: GitHub → Settings → Developer Settings → Personal Access Tokens
Crea un token con el permiso `read:packages`.
-**2. Configura `~/.m2/settings.xml**`**
+**2. Configura `~/.m2/settings.xml`**
```xml
@@ -62,10 +61,9 @@ Crea un token con el permiso `read:packages`.
TU_TOKEN
-
```
-**3. Añade el repositorio a tu `pom.xml**`
+**3. Añade el repositorio a tu `pom.xml`**
```xml
@@ -75,14 +73,12 @@ Crea un token con el permiso `read:packages`.
https://maven.pkg.github.com/flamicomm/llp-protocol-java
-
```
**4. Verifica**
```bash
mvn clean install
-
```
---
@@ -102,16 +98,18 @@ LLPFrameBuilder builder = LLP.frameBuilder().build();
byte[] frame = builder.build(ByteBuffer.wrap("hello device".getBytes()));
// uart.write(frame); // Example: send via your preferred transport
-
```
### Análisis incremental de un flujo de datos (entrada / inbound)
```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.LLPIncrementalParser;
import com.flamingo.comm.llp.core.FinalNode;
+import com.flamingo.comm.llp.core.FailureNode;
+import com.flamingo.comm.llp.core.UnknownNode;
+import com.flamingo.comm.llp.core.TransportErrorCode;
LLPIncrementalParser parser = LLP.incrementalParser()
.maxPayloadBytes(4096)
@@ -125,26 +123,25 @@ 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 -> {
+ // Navigate the node chain using the visitor pattern
+ frame.chain().visit(visitor -> visitor
+ .on(FinalNode.class, 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 ->
+ })
+ .on(UnknownNode.class, node ->
+ System.out.println("Unknown layer skipped: ID=" + node.getId()))
+ .on(FailureNode.class, node ->
System.err.println("Layer failed: ID=" + node.getId()
- + " reason=" + node.getErrorReason()));
- });
+ + " reason=" + node.getErrorReason()))
+ );
}
for (TransportErrorCode error : parser.pollErrors()) {
System.err.println("Transport error: " + error);
}
}
-
```
### Análisis de tramas completas (no streaming)
@@ -154,7 +151,6 @@ LLPFrameParser parser = LLP.frameParser().build();
// rawFrame is an LLPRawFrame produced by LLPTransportDeframer
LLPFrame frame = parser.parse(rawFrame);
-
```
### Uso de plugins de capas
@@ -181,7 +177,6 @@ parsed.chain().getNode(RoutingNode.class).ifPresent(node -> {
System.out.println("Device: " + node.getMetadata().deviceId());
System.out.println("Group: " + node.getMetadata().group());
});
-
```
---
@@ -194,7 +189,6 @@ El envoltorio más externo validado por la capa de transporte:
```
[MAGIC 2B][LENGTH 2B][PAYLOAD NB][CRC16 2B]
-
```
| Campo | Tamaño | Valor / Descripción |
@@ -214,7 +208,6 @@ El payload contiene una cadena recursiva de capas opcionales seguida de los dato
[LAYER_ID][META_LENGTH][METADATA ...][ next layer or final ]
↓
[0x00][RAW PAYLOAD BYTES]
-
```
| Campo | Tamaño | Descripción |
@@ -231,7 +224,7 @@ El payload contiene una cadena recursiva de capas opcionales seguida de los dato
| `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 |
+| `255` | **Reservado** — reservado para uso futuro; los parsers deben tratarlo como desconocido y saltarlo si es posible. |
---
@@ -250,19 +243,34 @@ com.flamingo.comm.llp/
│ ├── LLPFrame.java # Parsed frame with node chain
│ ├── LLPRawFrame.java # Transport-level validated frame
│ ├── NodeChain.java # Immutable ordered chain of nodes
+│ ├── NodeVisitor.java # Type-safe visitor for node traversal
│ ├── FinalNode.java # Terminal node (raw payload)
│ ├── UnknownNode.java # Skipped unknown passthrough layer
-│ └── FailureNode.java # Failed-to-parse layer node
+│ ├── FailureNode.java # Failed-to-parse layer node
+│ ├── ByteArrayFrameBuilder.java # Default frame builder (byte[] output)
+│ ├── CoreParseErrorReason.java # Core-level parse error reasons
+│ ├── TransportErrorCode.java # Transport-level error codes
+│ ├── FrameBuildException.java # Exception for frame build failures
+│ ├── LayerParserProvider.java # Functional interface for parser lookup
+│ ├── LayerParserRegistry.java # SPI-based layer parser registry
+│ └── SimpleFrameParser.java # Internal default frame parser
│
-└── 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
-
+├── 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 (Success.UnmodifiedPayload | Success.TransformedPayload | Failure)
+│ ├── LayerParseInput.java # Read-only metadata + payload for parsing
+│ ├── LayerBuildPayload.java # Read-only payload for building
+│ ├── ParseErrorReason.java # Interface for parse error reasons
+│ └── BuildErrorReason.java # Interface for build error reasons
+│
+└── util/ # Internal utilities
+ ├── ByteWriter.java # Utility for writing byte sequences
+ ├── CRC16CCITT.java # CRC16-CCITT calculation
+ ├── LayerIds.java # Layer ID rules and classification
+ └── Statistics.java # Transport statistics tracking
```
### Puntos de entrada
@@ -285,7 +293,6 @@ LLPIncrementalParser incremental = LLP.incrementalParser()
.maxPayloadBytes(8192)
.timeoutMs(1000)
.build();
-
```
### Tubería de entrada (Inbound pipeline)
@@ -296,7 +303,6 @@ byte stream
└── LLPRawFrame
└── SimpleFrameParser (layer chain parsing via SPI registry)
└── LLPFrame → NodeChain → [Node, Node, ..., FinalNode]
-
```
### Tubería de salida (Outbound pipeline)
@@ -307,7 +313,6 @@ ByteBuffer payload
└── byte[] (layered payload)
└── LLPTransportFramer (magic · length · stuffing · CRC)
└── byte[] (transport frame ready to transmit)
-
```
---
@@ -323,10 +328,9 @@ Las nuevas capas de protocolo se implementan como módulos de Maven independient
```xml
com.flamingo
- llp-protocol
- 3.0.1
+ llp-core
+ 3.1.0
-
```
**2. Implementa `LLPLayerParser` (entrada)**
@@ -347,18 +351,21 @@ public class RoutingLayerParser implements LLPLayerParser {
String group = reader.readUtf8(reader.readUInt8());
int ttl = reader.readUInt8();
- return LayerParseResult.success(
+ return new LayerParseResult.Success(
new RoutingNode(new RoutingMetadata(deviceId, group, ttl)),
input.payload() // passthrough: payload unchanged
);
} catch (Exception e) {
- return LayerParseResult.failure(ParseErrorReason.INVALID_METADATA);
+ return new LayerParseResult.Failure(
+ MyErrorReason.INVALID_METADATA
+ );
}
}
}
-
```
+> **Nota:** `LayerParseResult.Success` y `LayerParseResult.Failure` son records internos de la sealed interface `LayerParseResult`. Usa sus constructores directamente. Para errores, puedes implementar `ParseErrorReason` con tus propios enums, o usar `CoreParseErrorReason` del paquete `core`.
+
**3. Implementa `LLPLayerBuilder` (salida)**
```java
@@ -387,10 +394,9 @@ public class RoutingLayerBuilder implements LLPLayerBuilder {
.toBytes();
// Passthrough: payload is not modified
- return LayerBuildResult.unmodified(ByteBuffer.wrap(metadata));
+ return new LayerBuildResult.Success.UnmodifiedPayload(ByteBuffer.wrap(metadata));
}
}
-
```
**4. Registro vía SPI**
@@ -399,7 +405,6 @@ Crea el archivo `src/main/resources/META-INF/services/com.flamingo.comm.llp.spi.
```
com.example.llp.routing.RoutingLayerParser
-
```
La librería base lo descubrirá y registrará automáticamente al inicio.
@@ -410,6 +415,7 @@ La librería base lo descubrirá y registrará automáticamente al inicio.
| --- | --- | --- | --- |
| `1–127` | Passthrough | Sin cambios — puede omitirse | No |
| `128–254` | Transform | Modificado (encriptado/comprimido) | Sí |
+| `255` | Reservado | Tratado como desconocido y saltado | No |
---
@@ -421,18 +427,21 @@ Tras el análisis, el `NodeChain` de la trama contiene una secuencia ordenada de
| --- | --- | --- |
| `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()` |
+| `UnknownNode` | El Layer ID no tiene manejador y es de tipo passthrough o reservado | `getId()`, `getMetadata()` |
+| `FailureNode` | Falló el análisis de la capa (error de plugin, metadatos corruptos o falta de FinalNode) | `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 A — visitor pattern (recommended for production code)
+frame.chain().visit(visitor -> visitor
+ .on(FinalNode.class, node -> handlePayload(node.getPayload()))
+ .on(UnknownNode.class, node ->
+ System.out.println("Skipped layer " + node.getId()))
+ .on(FailureNode.class, node ->
+ System.err.println("Failed layer " + node.getId()
+ + ": " + node.getErrorReason()))
+);
// Option B — find a specific node type
frame.chain().getNode(RoutingNode.class)
@@ -447,7 +456,6 @@ LLPNode deepest = frame.chain().getDeepestNode();
if (deepest instanceof FinalNode final) {
process(final.getPayload());
}
-
```
---
@@ -456,15 +464,15 @@ if (deepest instanceof FinalNode final) {
La versión 3 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.x |
-| --- |-----------------------------------------------------------------------|
-| `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 |
+| v2.x | v3.x |
+| --- | --- |
+| `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)**.
@@ -480,6 +488,8 @@ mvn test
# Run tests with coverage report
mvn verify
+# Run tests including slow timing vectors
+mvn test -Pslow-tests
```
La suite de pruebas cubre:
@@ -490,6 +500,7 @@ La suite de pruebas cubre:
* 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).
+* Vectores de prueba de especificación LLP: 199 vectores oficiales que validan conformancia wire-compatible.
---
@@ -518,18 +529,22 @@ Todo el código, comentarios, Javadoc y nombres de variables deben escribirse en
## 📜 Licencia
-Licencia MIT — ver [LICENSE](https://www.google.com/search?q=LICENSE)
+Licencia MIT — ver [LICENSE](LICENSE)
+
+LLP Specification v3.1.0 — Copyright © 2026 Flamingo Communications
+
+This specification is maintained as the authoritative reference for the LLP protocol. All implementations should reference this document as the canonical behaviour definition.
---
## ✍️ Autor
-Creado por **flamicomm**
+Creado por **Famingo Communications**
---
-**Versión:** 3.0.1
+**Versión:** 3.1.0
-**Última actualización:** 2026-05-13
+**Última actualización:** 2026-05-17
**Objetivo Java:** 21+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 0e5aa69..9ef05df 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
com.flamingo
llp-core
- 3.0.1
+ 3.1.0
jar
LLP Core