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/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 ad10072..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 @@ -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