From 109456b302949c994705a7c7928bb5972c0108a2 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 28 Feb 2026 02:55:35 +0000 Subject: [PATCH 1/6] feat(wireframe_testing): add codec fixtures for Hotline protocol testing Introduce a new module providing fixture functions that generate valid, invalid, truncated, and correlated Hotline-framed wire bytes for use in tests. These fixtures allow test authors to easily produce raw wire data for codec exercise, including error conditions that are hard to manually craft. Includes: - Valid frames with specified payloads and transaction IDs - Oversized payload frames that trigger decoder errors - Mismatched total_size frames to test error handling - Truncated headers and payloads producing decode errors - Multi-frame sequences sharing or incrementing transaction IDs Supports comprehensive unit and BDD integration tests verifying correct codec behaviours when decoding these fixtures. Also adds documentation updates and marks roadmap item 9.7.2 as done. Co-authored-by: devboxerhub[bot] --- docs/adr-004-pluggable-protocol-codecs.md | 23 + ...7-2-codec-fixtures-in-wireframe-testing.md | 576 ++++++++++++++++++ docs/roadmap.md | 2 +- docs/users-guide.md | 44 ++ tests/codec_fixtures.rs | 184 ++++++ tests/features/codec_fixtures.feature | 23 + tests/fixtures/codec_fixtures.rs | 182 ++++++ tests/fixtures/mod.rs | 1 + tests/scenarios/codec_fixtures_scenarios.rs | 45 ++ tests/scenarios/mod.rs | 1 + tests/steps/codec_fixtures_steps.rs | 53 ++ tests/steps/mod.rs | 1 + wireframe_testing/src/helpers.rs | 11 + .../src/helpers/codec_fixtures.rs | 274 +++++++++ wireframe_testing/src/lib.rs | 8 + 15 files changed, 1427 insertions(+), 1 deletion(-) create mode 100644 docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md create mode 100644 tests/codec_fixtures.rs create mode 100644 tests/features/codec_fixtures.feature create mode 100644 tests/fixtures/codec_fixtures.rs create mode 100644 tests/scenarios/codec_fixtures_scenarios.rs create mode 100644 tests/steps/codec_fixtures_steps.rs create mode 100644 wireframe_testing/src/helpers/codec_fixtures.rs diff --git a/docs/adr-004-pluggable-protocol-codecs.md b/docs/adr-004-pluggable-protocol-codecs.md index 7d955c8f..0eafeb7e 100644 --- a/docs/adr-004-pluggable-protocol-codecs.md +++ b/docs/adr-004-pluggable-protocol-codecs.md @@ -367,6 +367,29 @@ Design choices: generic parameter. - A `codec()` accessor was added to `WireframeApp` for convenience. +### Codec test fixtures (resolved 2026-02-28) + +Roadmap item 9.7.2 added codec fixture functions to `wireframe_testing` for +generating valid, invalid, incomplete, and correlation-bearing Hotline-framed +wire bytes. These fixtures support test authors exercising error paths without +hand-crafting raw byte sequences. + +Design choices: + +- Fixtures produce raw `Vec` wire bytes rather than typed `HotlineFrame` + values. Invalid and malformed frames cannot be represented as valid typed + values, and wire bytes are what decoders and test drivers consume directly. A + `valid_hotline_frame` convenience function is also provided for tests needing + metadata inspection without a wire round-trip. +- Fixtures are Hotline-specific rather than generic over `FrameCodec`. + Generating invalid frames requires knowledge of the specific wire format + (header layout, field positions, size constraints). A generic approach would + need a `MalformedFrameGenerator` trait, which is over-engineering for a test + utility. If additional codecs need fixtures, they can follow the same pattern. +- Fixtures construct headers directly using big-endian `u32` writes, bypassing + the tokio-util encoder. This ensures fixtures are independent of encoder + implementation and can represent data the encoder would reject. + ## Architectural Rationale A dedicated `FrameCodec` abstraction aligns framing with the protocol boundary diff --git a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md new file mode 100644 index 00000000..a7b8db14 --- /dev/null +++ b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md @@ -0,0 +1,576 @@ +# 9.7.2 Add codec fixtures in wireframe\_testing + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: COMPLETE + +## Purpose / big picture + +The `wireframe_testing` crate already provides codec-aware driver functions +(added in 9.7.1) that encode payloads, transport them over an in-memory duplex +stream, and decode the response. However, test authors who need to exercise +error paths — malformed frames, oversized payloads, truncated headers, or +frames carrying specific correlation metadata — must hand-craft raw byte +sequences each time. This is tedious, error-prone, and leads to duplicated +setup across test suites. + +After this change, `wireframe_testing` exports a set of builder helpers that +produce ready-to-use byte sequences and typed frames for common codec test +scenarios. A test author can write: + +```rust +use wireframe_testing::{ + valid_hotline_wire, oversized_hotline_wire, + truncated_hotline_header, correlated_hotline_wire, +}; + +// Valid frame bytes ready for decode_frames_with_codec +let wire = valid_hotline_wire(b"hello", 7); + +// Oversized payload that should be rejected by the decoder +let too_big = oversized_hotline_wire(4096); + +// Frame with only a partial header — decoder should return Ok(None) +let partial = truncated_hotline_header(); + +// Sequence of frames sharing a correlation (transaction) ID +let correlated = correlated_hotline_wire(42, &[b"a", b"b", b"c"]); +``` + +Observable success: `make test` passes, including new unit tests in +`tests/codec_fixtures.rs` and new rstest-bdd behavioural scenarios in +`tests/scenarios/codec_fixtures_scenarios.rs` that exercise the fixture +functions with `HotlineFrameCodec`. The existing codec test harness tests +remain green. + +## Constraints + +- All existing `wireframe_testing` public APIs must remain source-compatible. + The new functions are purely additive. +- No file may exceed 400 lines. +- Use `rstest` for new unit tests and `rstest-bdd` v0.5.0 for new behavioural + tests. +- Use existing in-tree codecs (`HotlineFrameCodec` from + `wireframe::codec::examples`) rather than defining new codec types. +- en-GB-oxendict spelling in all comments and documentation. +- Strict Clippy with `-D warnings` on all targets including tests. +- Every module must begin with a `//!` doc comment. +- Public functions must have `///` doc comments with examples. +- No `assert!`/`panic!` in functions returning `Result` (clippy + `panic_in_result_fn`). +- No array indexing in tests — use `.get(n)` / `.first()` instead. +- `#[expect]` attributes must include `reason = "..."`. +- Quality gates (`make check-fmt`, `make lint`, `make test`, + `make markdownlint`) must pass before completion. +- Tests for `wireframe_testing` functionality must live in the main crate's + `tests/` directory as integration tests, because `wireframe_testing` is a + dev-dependency of the main crate, not a workspace member. Internal + `#[cfg(test)]` modules within `wireframe_testing` never execute via + `cargo test`. + +## Tolerances (exception triggers) + +- Scope: if implementation requires more than 12 new/modified files or 1,000 + net changed lines, stop and re-scope. +- Dependencies: if any new external crate is required beyond what is already in + `wireframe_testing/Cargo.toml`, stop and escalate. +- Interface: if a public API signature on the main `wireframe` crate must + change beyond an additive accessor, stop and escalate. +- Iterations: if the same failing root cause persists after 3 fix attempts, + stop and document options in the Decision Log. + +## Risks + +- Risk: the Hotline decoder returns `Ok(None)` for truncated data (insufficient + bytes for a header or payload) rather than `Err(...)`. This means + `decode_frames_with_codec` will treat truncated frames as "no more frames" + rather than an error, unless the trailing-bytes check catches it. Fixtures + producing truncated data must account for this: they will trigger the + trailing-bytes error path in `decode_frames_with_codec`, not a decoder error. + Severity: medium. Likelihood: certain. Mitigation: document this in the + fixture API and ensure BDD tests assert on trailing-bytes errors specifically. + +- Risk: the `codec_fixtures.rs` module might grow beyond 400 lines if too many + fixture variants are added. Severity: low. Likelihood: low. Mitigation: start + with the minimum required fixtures (valid, oversized, truncated, correlated); + split into submodules only if the 400-line cap is approached. + +- Risk: Hotline encoder rejects oversized payloads at encode time, so + "oversized" fixtures must produce raw bytes directly rather than going + through the encoder. Severity: low. Likelihood: certain. Mitigation: + oversized fixture hand-crafts the 20-byte Hotline header + oversized payload + bytes. + +## Progress + +- [x] (2026-02-28) Drafted ExecPlan. +- [x] (2026-02-28) Stage A: implement codec fixture module in + `wireframe_testing`. +- [x] (2026-02-28) Stage B: add rstest unit tests for the fixtures (8 tests, + all green). +- [x] (2026-02-28) Stage C: add rstest-bdd behavioural tests (4 scenarios, all + green). +- [x] (2026-02-28) Stage D: documentation, design doc, and roadmap updates. +- [x] (2026-02-28) Stage E: full validation and evidence capture. + +## Surprises & discoveries + +- The Hotline decoder does not override `decode_eof`, so the default + tokio-util implementation is used. When `decode()` returns `Ok(None)` but the + buffer is non-empty, `decode_eof` returns `Err("bytes remaining on stream")` + rather than `Ok(None)`. This means truncated fixtures trigger an error from + `decode_eof` (wrapped as + `"codec decode_eof failed: bytes remaining on stream"`) rather than from the + trailing-bytes check in `decode_frames_with_codec`. The docstrings and BDD + tests were updated to assert on `"bytes remaining"` instead of `"trailing"`. + +## Decision log + +- Decision: produce raw `Vec` wire bytes as the primary fixture output + format rather than typed `Frame` values. Rationale: invalid and malformed + frames cannot exist as valid typed values (e.g., `HotlineFrame` always has a + well-formed payload). Wire bytes are what decoders and test drivers consume + directly. For valid-frame fixtures, provide both raw bytes and a convenience + function returning the typed frame for metadata inspection. Date/Author: + 2026-02-28 / Plan phase. + +- Decision: provide Hotline-specific fixture functions rather than + `FrameCodec`-generic fixtures. Rationale: generating invalid frames requires + knowledge of the specific wire format (header layout, field positions, size + constraints). A generic approach would need a `MalformedFrameGenerator` trait + that every codec implements, which is over-engineering for a test utility. + `HotlineFrameCodec` is the project's canonical example codec used across all + codec tests. If additional codecs need fixtures in the future, they can be + added as separate fixture functions following the same pattern. Date/Author: + 2026-02-28 / Plan phase. + +- Decision: place fixtures in a new `codec_fixtures` submodule under + `wireframe_testing/src/helpers/` rather than a top-level module. Rationale: + this follows the existing pattern where all codec-related helpers live under + `helpers/` (codec.rs, codec\_ext.rs, codec\_drive.rs). It keeps the module + hierarchy consistent and the fixture functions close to the encode/decode + helpers they build upon. Date/Author: 2026-02-28 / Plan phase. + +## Outcomes & retrospective + +Completed 2026-02-28. All quality gates pass: + +- `make fmt` — clean (no formatting changes) +- `make check-fmt` — clean +- `make markdownlint` — clean (0 errors) +- `make lint` — clean (cargo doc + clippy with `-D warnings`) +- `make test` — all green (0 failures) + +New test coverage: + +- 8 integration tests in `tests/codec_fixtures.rs` (valid wire decode, valid + frame metadata, oversized rejection, mismatched total\_size rejection, + truncated header error, truncated payload error, correlated transaction IDs, + sequential transaction IDs). +- 4 BDD scenarios in `tests/scenarios/codec_fixtures_scenarios.rs` (valid + decode, oversized rejection, truncated error, correlated IDs). + +Files created: 6 (`codec_fixtures.rs` in wireframe\_testing helpers, +`codec_fixtures.rs` integration tests, `.feature` file, BDD fixture/steps/ +scenarios). +Files modified: 8 (helpers.rs, lib.rs, three BDD mod.rs files, users-guide.md, +roadmap.md, adr-004.md). + +Key discovery: truncated frame fixtures trigger the `decode_eof` "bytes +remaining on stream" error path rather than the trailing-bytes check in +`decode_frames_with_codec`. This is because the default tokio-util +`decode_eof` implementation detects leftover bytes before the explicit trailing +check runs. Future fixture work should account for this when designing error +assertions. + +## Context and orientation + +### Repository layout (relevant subset) + +```plaintext +src/ + codec.rs # FrameCodec trait (line 63-103) + codec/examples.rs # HotlineFrameCodec (line 21-131), MysqlFrameCodec + +wireframe_testing/ + Cargo.toml # dev-dependency on wireframe (path = "..") + src/lib.rs # public re-exports (68 lines) + src/helpers.rs # TestSerializer trait, module root, constants (71 lines) + src/helpers/codec.rs # new_test_codec, decode_frames, encode_frame (77 lines) + src/helpers/codec_ext.rs # encode_payloads_with_codec, decode_frames_with_codec, + # extract_payloads (144 lines) + src/helpers/codec_drive.rs # drive_with_codec_payloads/frames (279 lines) + src/helpers/drive.rs # drive_internal, drive_with_frame[s] (297 lines) + src/helpers/payloads.rs # drive_with_payloads, drive_with_bincode (152 lines) + src/helpers/runtime.rs # run_app, run_with_duplex_server (89 lines) + src/integration_helpers.rs # TestApp, TestError, TestResult, factory (294 lines) + +tests/ + codec_test_harness.rs # Integration tests for codec drivers (162 lines) + fixtures/mod.rs # BDD world fixtures (34 modules) + fixtures/codec_test_harness.rs # CodecTestHarnessWorld (132 lines) + steps/mod.rs # BDD step definitions (35 modules) + steps/codec_test_harness_steps.rs # Codec harness steps (41 lines) + scenarios/mod.rs # BDD scenario registrations (37 modules) + scenarios/codec_test_harness_scenarios.rs # 2 scenario tests (26 lines) + features/codec_test_harness.feature # 2 Gherkin scenarios (15 lines) + +docs/ + roadmap.md # Item 9.7.2 at line 435-436 + users-guide.md # Existing wireframe_testing section at line 224 + generic-message-fragmentation-and-re-assembly-design.md + hardening-wireframe-a-guide-to-production-resilience.md +``` + +### Key types + +`FrameCodec` (defined in `src/codec.rs:63-103`) is a trait requiring +`Send + Sync + Clone + 'static` with associated types `Frame`, `Decoder`, +`Encoder` and methods `decoder()`, `encoder()`, `frame_payload()`, +`wrap_payload()`, `correlation_id()`, and `max_frame_length()`. + +`HotlineFrameCodec` (defined in `src/codec/examples.rs:21-131`) uses a 20-byte +header: `data_size: u32`, `total_size: u32`, `transaction_id: u32`, +`reserved: [u8; 8]`, followed by payload bytes. The decoder returns `Ok(None)` +when fewer than 20 bytes are available or when the full frame has not arrived. +It returns `Err(InvalidData, "payload too large")` when +`data_size > max_frame_length` and `Err(InvalidData, "invalid total size")` +when `total_size != data_size + 20`. + +`decode_frames_with_codec` (in `wireframe_testing/src/helpers/codec_ext.rs:80`) +calls `decoder.decode()` in a loop, then `decoder.decode_eof()`, then checks +for trailing bytes. Trailing bytes produce +`Err(InvalidData, "trailing N byte(s) after decoding")`. + +Constants: `MIN_FRAME_LENGTH = 64`, `MAX_FRAME_LENGTH = 16 MiB`, +`LENGTH_HEADER_SIZE = 4`, `TEST_MAX_FRAME = 4096`. + +### BDD test pattern + +The project uses rstest-bdd v0.5.0 with a 4-file pattern per test domain: + +1. `tests/features/.feature` — Gherkin scenarios. +2. `tests/fixtures/.rs` — world struct with `#[fixture]` function. +3. `tests/steps/_steps.rs` — `#[given]`/`#[when]`/`#[then]` functions. +4. `tests/scenarios/_scenarios.rs` — `#[scenario]` functions. + +Steps are synchronous; async calls use +`tokio::runtime::Runtime::new()?.block_on(...)`. The fixture parameter name in +scenario functions must match the fixture function name exactly. All step +functions return `TestResult` (`Result<(), Box>`). + +Module wiring: `tests/scenarios/mod.rs` loads `tests/steps/mod.rs` via +`#[path = "../steps/mod.rs"] pub(crate) mod steps;`, then declares each +scenario submodule. + +## Plan of work + +### Stage A: implement codec fixture module in wireframe\_testing + +Create `wireframe_testing/src/helpers/codec_fixtures.rs` containing functions +that produce raw wire bytes for Hotline-framed test scenarios. The module +provides four categories of fixtures: + +**1. Valid frame fixtures** — properly encoded Hotline frames. + +`valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec` — builds a +single valid Hotline frame as raw wire bytes by writing the 20-byte header +(data\_size, total\_size, transaction\_id, 8 reserved zero bytes) followed by +the payload. This bypasses the tokio-util encoder so fixtures are independent +of the encoder implementation. + +`valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame` — +returns a typed `HotlineFrame` for tests needing metadata inspection. Delegates +to `HotlineFrameCodec::wrap_payload` but overrides the `transaction_id`. + +**2. Invalid frame fixtures** — wire bytes that should cause decode errors. + +`oversized_hotline_wire(max_frame_length: usize) -> Vec` — crafts a Hotline +header where `data_size` exceeds `max_frame_length` by 1 byte, followed by that +many payload bytes. The decoder should reject this with +`InvalidData("payload too large")`. + +`mismatched_total_size_wire(payload: &[u8]) -> Vec` — crafts a Hotline +header where `total_size` does not equal `data_size + 20`. The decoder should +reject this with `InvalidData("invalid total size")`. + +**3. Incomplete frame fixtures** — wire bytes that are valid prefixes but +insufficient for a complete frame. + +`truncated_hotline_header() -> Vec` — returns fewer than 20 bytes (e.g., 10 +bytes of zeroes). The decoder returns `Ok(None)`; when passed to +`decode_frames_with_codec` the trailing-bytes check produces an error. + +`truncated_hotline_payload(payload_len: usize) -> Vec` — writes a valid +20-byte header claiming `data_size = payload_len` but provides only half the +payload bytes. The decoder returns `Ok(None)` because the buffer is too short; +`decode_frames_with_codec` reports trailing bytes. + +**4. Correlation metadata fixtures** — frames with specific transaction IDs for +correlation testing. + +`correlated_hotline_wire(transaction_id: u32, payloads: &[&[u8]]) -> Vec` — +encodes multiple Hotline frames sharing the same `transaction_id`, suitable for +verifying correlation ID propagation across frame sequences. + +`sequential_hotline_wire(base_transaction_id: u32, payloads: &[&[u8]]) -> Vec` +— encodes multiple frames with incrementing transaction IDs (`base`, +`base + 1`, …), suitable for verifying frame ordering. + +Register the module in `wireframe_testing/src/helpers.rs` as +`mod codec_fixtures;` and add `pub use codec_fixtures::*;` exports. Add the +public re-exports to `wireframe_testing/src/lib.rs`. + +Stage A acceptance: `make check-fmt && make lint` pass. The new functions +compile and are accessible from the crate root via +`wireframe_testing::valid_hotline_wire` etc. + +### Stage B: add rstest unit tests for the fixtures + +Create `tests/codec_fixtures.rs` as an integration test file exercising every +fixture function. Tests live outside `wireframe_testing` because the crate is +not a workspace member. + +Tests to implement: + +1. `valid_hotline_wire_decodes_successfully` — encode a payload with + `valid_hotline_wire`, decode with `decode_frames_with_codec`, verify the + payload and transaction ID match. + +2. `valid_hotline_frame_has_correct_metadata` — call `valid_hotline_frame`, + verify `transaction_id` and payload bytes. + +3. `oversized_hotline_wire_rejected_by_decoder` — encode with + `oversized_hotline_wire(4096)`, attempt `decode_frames_with_codec` with a + codec capped at 4096, verify `Err` with `InvalidData`. + +4. `mismatched_total_size_rejected_by_decoder` — call + `mismatched_total_size_wire`, attempt decode, verify `Err` with + `InvalidData`. + +5. `truncated_header_produces_trailing_bytes_error` — call + `truncated_hotline_header`, attempt decode, verify `Err` mentioning + "trailing". + +6. `truncated_payload_produces_trailing_bytes_error` — call + `truncated_hotline_payload(100)`, attempt decode, verify `Err` mentioning + "trailing". + +7. `correlated_frames_share_transaction_id` — call `correlated_hotline_wire` + with `transaction_id = 42` and 3 payloads, decode, verify all 3 frames have + `transaction_id = 42`. + +8. `sequential_frames_have_incrementing_ids` — call `sequential_hotline_wire` + with `base = 10` and 3 payloads, decode, verify transaction IDs are 10, 11, + 12. + +Stage B acceptance: `make test` passes with all 8 new tests green. + +### Stage C: add rstest-bdd behavioural tests + +Create four files for the `codec_fixtures` BDD domain: + +**`tests/features/codec_fixtures.feature`**: + +```gherkin +Feature: Codec test fixtures + The wireframe_testing crate provides codec fixture functions for + generating valid and invalid Hotline-framed wire bytes for testing. + + Scenario: Valid fixture decodes to expected payload + Given a Hotline codec allowing frames up to 4096 bytes + When a valid fixture frame is decoded + Then the decoded payload matches the fixture input + + Scenario: Oversized fixture is rejected by decoder + Given a Hotline codec allowing frames up to 4096 bytes + When an oversized fixture frame is decoded + Then the decoder reports an invalid data error + + Scenario: Truncated fixture produces a trailing bytes error + Given a Hotline codec allowing frames up to 4096 bytes + When a truncated fixture frame is decoded + Then the decoder reports trailing bytes + + Scenario: Correlated fixtures share the same transaction identifier + Given a Hotline codec allowing frames up to 4096 bytes + When correlated fixture frames are decoded + Then all frames have the expected transaction identifier +``` + +**`tests/fixtures/codec_fixtures.rs`**: A `CodecFixturesWorld` struct holding +the codec, wire bytes, decoded frames, and any decode error. Methods: +`configure_codec(max_frame_length)`, `decode_valid_fixture()`, +`decode_oversized_fixture()`, `decode_truncated_fixture()`, +`decode_correlated_fixture()`, `verify_payload_matches()`, +`verify_invalid_data_error()`, `verify_trailing_bytes_error()`, +`verify_correlated_transaction_ids()`. + +**`tests/steps/codec_fixtures_steps.rs`**: Step functions matching the Gherkin +steps, delegating to world methods. + +**`tests/scenarios/codec_fixtures_scenarios.rs`**: Four `#[scenario]` functions. + +Wire into `tests/fixtures/mod.rs`, `tests/steps/mod.rs`, and +`tests/scenarios/mod.rs`. + +Stage C acceptance: `make test` passes with all 4 BDD scenarios green. + +### Stage D: documentation, design doc, and roadmap updates + +1. Update `docs/users-guide.md` — add a subsection under the existing "Testing + custom codecs with `wireframe_testing`" section (around line 224) + documenting the new codec fixture functions with a brief usage example + showing how to use `valid_hotline_wire`, `oversized_hotline_wire`, and + `correlated_hotline_wire`. + +2. Update the relevant design document + (`docs/hardening-wireframe-a-guide-to-production-resilience.md`) with a + short note recording the design decision to provide Hotline-specific + raw-byte fixtures rather than generic `FrameCodec`-parameterised generators, + and the rationale (invalid frames require format-specific knowledge). + +3. Mark roadmap item `9.7.2` as done in `docs/roadmap.md`: change + `- [ ] 9.7.2.` to `- [x] 9.7.2.` + +Stage D acceptance: `make markdownlint` passes. Documentation is internally +consistent. + +### Stage E: full validation and evidence capture + +Run all quality gates with logging: + + +```shell +set -o pipefail; make fmt 2>&1 | tee /tmp/9-7-2-fmt.log +set -o pipefail; make check-fmt 2>&1 | tee /tmp/9-7-2-check-fmt.log +set -o pipefail; make markdownlint MDLINT=/root/.bun/bin/markdownlint-cli2 2>&1 | tee /tmp/9-7-2-markdownlint.log +set -o pipefail; make lint 2>&1 | tee /tmp/9-7-2-lint.log +set -o pipefail; make test 2>&1 | tee /tmp/9-7-2-test.log +``` + + +Update the `Progress` and `Outcomes & Retrospective` sections with final +evidence and timestamps. + +Stage E acceptance: all commands exit 0. + +## Validation and acceptance + +Quality criteria (what "done" means): + +- Tests: `make test` passes, including 8 new unit tests in + `tests/codec_fixtures.rs` and 4 new BDD scenarios in + `tests/scenarios/codec_fixtures_scenarios.rs`. +- Lint: `make lint` passes (Clippy with `-D warnings` on all targets). +- Format: `make check-fmt` passes. +- Markdown: `make markdownlint` passes. +- Documentation: `docs/users-guide.md` documents the new fixture API. + `docs/roadmap.md` item 9.7.2 is marked done. + +Quality method: + +- Run the shell commands in Stage E and verify all exit 0. +- Verify the new BDD scenarios pass: `cargo test codec_fixtures`. +- Verify the new unit tests pass: `cargo test codec_fixtures`. + +## Idempotence and recovery + +All stages produce additive changes. If a stage fails partway through, the +incomplete changes can be reverted with `git checkout -- .` and the stage +retried from the beginning. No destructive operations are involved. + +## Interfaces and dependencies + +### New public functions (wireframe\_testing crate) + +In `wireframe_testing/src/helpers/codec_fixtures.rs`: + +```rust +/// Build a single valid Hotline frame as raw wire bytes. +/// +/// Writes the 20-byte Hotline header (data_size, total_size, +/// transaction_id, 8 reserved zero bytes) followed by the payload. +pub fn valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec; + +/// Return a typed `HotlineFrame` with the given payload and transaction ID. +pub fn valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame; + +/// Build a Hotline frame whose data_size exceeds `max_frame_length` by one +/// byte. The decoder should reject this with `InvalidData`. +pub fn oversized_hotline_wire(max_frame_length: usize) -> Vec; + +/// Build a Hotline frame with a mismatched total_size field. +/// The decoder should reject this with `InvalidData`. +pub fn mismatched_total_size_wire(payload: &[u8]) -> Vec; + +/// Return fewer than 20 bytes — a truncated Hotline header. +pub fn truncated_hotline_header() -> Vec; + +/// Return a valid Hotline header claiming `payload_len` bytes of payload, +/// but provide only half the payload bytes. +pub fn truncated_hotline_payload(payload_len: usize) -> Vec; + +/// Encode multiple Hotline frames sharing the same `transaction_id`. +pub fn correlated_hotline_wire( + transaction_id: u32, + payloads: &[&[u8]], +) -> Vec; + +/// Encode multiple Hotline frames with incrementing transaction IDs. +pub fn sequential_hotline_wire( + base_transaction_id: u32, + payloads: &[&[u8]], +) -> Vec; +``` + +### Files to create + +| File | Purpose | Est. lines | +| ------------------------------------------------- | ------------------------------ | ---------- | +| `wireframe_testing/src/helpers/codec_fixtures.rs` | Codec fixture functions | ~180 | +| `tests/codec_fixtures.rs` | Integration tests for fixtures | ~180 | +| `tests/features/codec_fixtures.feature` | BDD feature file | ~25 | +| `tests/fixtures/codec_fixtures.rs` | BDD world fixture | ~130 | +| `tests/steps/codec_fixtures_steps.rs` | BDD step definitions | ~60 | +| `tests/scenarios/codec_fixtures_scenarios.rs` | BDD scenario registrations | ~40 | + +### Files to modify + +| File | Change | +| -------------------------------------------------------------- | ---------------------------------------- | +| `wireframe_testing/src/helpers.rs` | Add `mod codec_fixtures;` and re-exports | +| `wireframe_testing/src/lib.rs` | Add re-exports for new public functions | +| `tests/fixtures/mod.rs` | Add `pub mod codec_fixtures;` | +| `tests/steps/mod.rs` | Add `mod codec_fixtures_steps;` | +| `tests/scenarios/mod.rs` | Add `mod codec_fixtures_scenarios;` | +| `docs/users-guide.md` | Document new fixture API | +| `docs/roadmap.md` | Mark 9.7.2 done | +| `docs/hardening-wireframe-a-guide-to-production-resilience.md` | Record design decision | + +### Artifacts and notes + +Reference pattern for raw Hotline header construction (from +`src/codec/examples.rs:84-103`, the encoder): + +```rust +const HEADER_LEN: usize = 20; +// data_size: u32 (payload length) +// total_size: u32 (data_size + 20) +// transaction_id: u32 +// reserved: 8 zero bytes +// payload bytes +``` + +Reference pattern for the BDD world: `tests/fixtures/codec_test_harness.rs` +(132 lines) — `CodecTestHarnessWorld` with manual `Debug` impl (because +`WireframeApp` does not implement `Debug`). The new `CodecFixturesWorld` does +not hold a `WireframeApp`, so `#[derive(Debug)]` can be used directly. + +Reference for trailing-bytes behaviour: `decode_frames_with_codec` in +`wireframe_testing/src/helpers/codec_ext.rs:106-113` — if `buf` is non-empty +after all decode loops, returns +`Err(InvalidData, "trailing N byte(s) after decoding")`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 444fd3de..de998abd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -432,7 +432,7 @@ integration boundaries. - [x] 9.7.1. Extend `wireframe_testing` with codec-aware drivers that can run `WireframeApp` instances configured with custom `FrameCodec` values. -- [ ] 9.7.2. Add codec fixtures in `wireframe_testing` for generating valid and +- [x] 9.7.2. Add codec fixtures in `wireframe_testing` for generating valid and invalid frames, including oversized payloads and correlation metadata. - [ ] 9.7.3. Introduce a test observability harness in `wireframe_testing` that captures logs and metrics per test run for asserting codec failures and diff --git a/docs/users-guide.md b/docs/users-guide.md index daa3d917..0c9c5c58 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -261,6 +261,50 @@ Supporting helpers for composing custom test patterns: - `decode_frames_with_codec` — decode wire bytes to frames. - `extract_payloads` — extract payload bytes from decoded frames. +#### Codec test fixtures + +The `wireframe_testing` crate provides fixture functions for generating +Hotline-framed wire bytes covering common test scenarios — valid frames, +invalid frames, incomplete (truncated) frames, and frames with correlation +metadata. These fixtures construct raw bytes directly, so they can represent +malformed data that the encoder would reject: + +```rust,no_run +use wireframe::codec::examples::HotlineFrameCodec; +use wireframe_testing::{ + valid_hotline_wire, oversized_hotline_wire, + truncated_hotline_header, correlated_hotline_wire, + decode_frames_with_codec, +}; + +let codec = HotlineFrameCodec::new(4096); + +// Valid frame — decodes cleanly. +let wire = valid_hotline_wire(b"hello", 7); +let frames = decode_frames_with_codec(&codec, wire).unwrap(); + +// Oversized frame — rejected with "payload too large". +let wire = oversized_hotline_wire(4096); +assert!(decode_frames_with_codec(&codec, wire).is_err()); + +// Truncated header — rejected with "bytes remaining on stream". +let wire = truncated_hotline_header(); +assert!(decode_frames_with_codec(&codec, wire).is_err()); + +// Correlated frames — all share the same transaction ID. +let wire = correlated_hotline_wire(42, &[b"a", b"b"]); +let frames = decode_frames_with_codec(&codec, wire).unwrap(); +``` + +Available fixture functions: + +- `valid_hotline_wire` / `valid_hotline_frame` — well-formed frames. +- `oversized_hotline_wire` — payload exceeds `max_frame_length`. +- `mismatched_total_size_wire` — header with incorrect `total_size`. +- `truncated_hotline_header` / `truncated_hotline_payload` — incomplete data. +- `correlated_hotline_wire` — frames sharing a transaction ID. +- `sequential_hotline_wire` — frames with incrementing transaction IDs. + #### Zero-copy payload extraction For performance-critical codecs, use `Bytes` instead of `Vec` for payload diff --git a/tests/codec_fixtures.rs b/tests/codec_fixtures.rs new file mode 100644 index 00000000..3ff40498 --- /dev/null +++ b/tests/codec_fixtures.rs @@ -0,0 +1,184 @@ +//! Integration tests for the codec fixture functions in `wireframe_testing`. +//! +//! These tests verify that each fixture category produces wire bytes with the +//! expected decoding behaviour when used with `HotlineFrameCodec`. +#![cfg(not(loom))] + +use std::io; + +use wireframe::codec::examples::HotlineFrameCodec; +use wireframe_testing::{ + correlated_hotline_wire, + decode_frames_with_codec, + mismatched_total_size_wire, + oversized_hotline_wire, + sequential_hotline_wire, + truncated_hotline_header, + truncated_hotline_payload, + valid_hotline_frame, + valid_hotline_wire, +}; + +fn hotline_codec() -> HotlineFrameCodec { HotlineFrameCodec::new(4096) } + +// ── Valid frame fixtures ──────────────────────────────────────────────── + +#[test] +fn valid_hotline_wire_decodes_successfully() -> io::Result<()> { + let wire = valid_hotline_wire(b"hello", 7); + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, wire)?; + + let frame = frames + .first() + .ok_or_else(|| io::Error::other("expected one decoded frame"))?; + + if frames.len() != 1 { + return Err(io::Error::other(format!( + "expected 1 frame, got {}", + frames.len() + ))); + } + if frame.transaction_id != 7 { + return Err(io::Error::other(format!( + "expected transaction_id 7, got {}", + frame.transaction_id + ))); + } + if frame.payload.as_ref() != b"hello" { + return Err(io::Error::other("payload mismatch")); + } + Ok(()) +} + +#[test] +fn valid_hotline_frame_has_correct_metadata() { + let frame = valid_hotline_frame(b"data", 42); + assert_eq!(frame.transaction_id, 42); + assert_eq!(frame.payload.as_ref(), b"data"); +} + +// ── Invalid frame fixtures ────────────────────────────────────────────── + +#[test] +fn oversized_hotline_wire_rejected_by_decoder() -> io::Result<()> { + let wire = oversized_hotline_wire(4096); + let codec = hotline_codec(); + let result = decode_frames_with_codec(&codec, wire); + + let err = result + .err() + .ok_or_else(|| io::Error::other("expected decode to fail for oversized frame"))?; + if !err.to_string().contains("payload too large") { + return Err(io::Error::other(format!( + "expected 'payload too large' error, got: {err}" + ))); + } + Ok(()) +} + +#[test] +fn mismatched_total_size_rejected_by_decoder() -> io::Result<()> { + let wire = mismatched_total_size_wire(b"test"); + let codec = hotline_codec(); + let result = decode_frames_with_codec(&codec, wire); + + let err = result + .err() + .ok_or_else(|| io::Error::other("expected decode to fail for mismatched total_size"))?; + if !err.to_string().contains("invalid total size") { + return Err(io::Error::other(format!( + "expected 'invalid total size' error, got: {err}" + ))); + } + Ok(()) +} + +// ── Incomplete frame fixtures ─────────────────────────────────────────── + +#[test] +fn truncated_header_produces_decode_error() -> io::Result<()> { + let wire = truncated_hotline_header(); + let codec = hotline_codec(); + let result = decode_frames_with_codec(&codec, wire); + + let err = result + .err() + .ok_or_else(|| io::Error::other("expected decode to fail for truncated header"))?; + // The default decode_eof sees non-empty buf and returns + // "bytes remaining on stream", which decode_frames_with_codec wraps. + if !err.to_string().contains("bytes remaining") { + return Err(io::Error::other(format!( + "expected 'bytes remaining' error, got: {err}" + ))); + } + Ok(()) +} + +#[test] +fn truncated_payload_produces_decode_error() -> io::Result<()> { + let wire = truncated_hotline_payload(100); + let codec = hotline_codec(); + let result = decode_frames_with_codec(&codec, wire); + + let err = result + .err() + .ok_or_else(|| io::Error::other("expected decode to fail for truncated payload"))?; + // The default decode_eof sees non-empty buf and returns + // "bytes remaining on stream", which decode_frames_with_codec wraps. + if !err.to_string().contains("bytes remaining") { + return Err(io::Error::other(format!( + "expected 'bytes remaining' error, got: {err}" + ))); + } + Ok(()) +} + +// ── Correlation metadata fixtures ─────────────────────────────────────── + +#[test] +fn correlated_frames_share_transaction_id() -> io::Result<()> { + let wire = correlated_hotline_wire(42, &[b"a", b"b", b"c"]); + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, wire)?; + + if frames.len() != 3 { + return Err(io::Error::other(format!( + "expected 3 frames, got {}", + frames.len() + ))); + } + for (i, frame) in frames.iter().enumerate() { + if frame.transaction_id != 42 { + return Err(io::Error::other(format!( + "frame {i}: expected transaction_id 42, got {}", + frame.transaction_id + ))); + } + } + Ok(()) +} + +#[test] +fn sequential_frames_have_incrementing_ids() -> io::Result<()> { + let wire = sequential_hotline_wire(10, &[b"x", b"y", b"z"]); + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, wire)?; + + if frames.len() != 3 { + return Err(io::Error::other(format!( + "expected 3 frames, got {}", + frames.len() + ))); + } + let expected_ids: &[u32] = &[10, 11, 12]; + for (i, (frame, expected_id)) in frames.iter().zip(expected_ids.iter()).enumerate() { + if frame.transaction_id != *expected_id { + return Err(io::Error::other(format!( + "frame {i}: expected transaction_id {expected_id}, got {}", + frame.transaction_id + ))); + } + } + Ok(()) +} diff --git a/tests/features/codec_fixtures.feature b/tests/features/codec_fixtures.feature new file mode 100644 index 00000000..00cbc4c3 --- /dev/null +++ b/tests/features/codec_fixtures.feature @@ -0,0 +1,23 @@ +Feature: Codec test fixtures + The wireframe_testing crate provides codec fixture functions for + generating valid and invalid Hotline-framed wire bytes for testing. + + Scenario: Valid fixture decodes to expected payload + Given a Hotline codec allowing fixtures up to 4096 bytes + When a valid fixture frame is decoded + Then the decoded payload matches the fixture input + + Scenario: Oversized fixture is rejected by decoder + Given a Hotline codec allowing fixtures up to 4096 bytes + When an oversized fixture frame is decoded + Then the decoder reports an invalid data error + + Scenario: Truncated fixture produces a decode error + Given a Hotline codec allowing fixtures up to 4096 bytes + When a truncated fixture frame is decoded + Then the decoder reports bytes remaining on stream + + Scenario: Correlated fixtures share the same transaction identifier + Given a Hotline codec allowing fixtures up to 4096 bytes + When correlated fixture frames are decoded + Then all frames have the expected transaction identifier diff --git a/tests/fixtures/codec_fixtures.rs b/tests/fixtures/codec_fixtures.rs new file mode 100644 index 00000000..70d12d4c --- /dev/null +++ b/tests/fixtures/codec_fixtures.rs @@ -0,0 +1,182 @@ +//! Test world for codec fixture behavioural scenarios. +//! +//! Exercises the `wireframe_testing` codec fixture functions with +//! `HotlineFrameCodec` to verify that each fixture category produces +//! wire bytes with the expected decoding behaviour. + +use std::io; + +use rstest::fixture; +use wireframe::codec::{ + FrameCodec, + examples::{HotlineFrame, HotlineFrameCodec}, +}; +/// Re-export `TestResult` from `wireframe_testing` for use in steps. +pub use wireframe_testing::TestResult; + +/// BDD world holding the codec, decoded frames, and any decode error. +#[derive(Debug, Default)] +pub struct CodecFixturesWorld { + codec: Option, + decoded_frames: Vec, + decode_error: Option, +} + +/// Fixture for codec fixture scenarios used by rstest-bdd steps. +/// +/// Note: `rustfmt` collapses simple fixtures into one line, which triggers +/// `unused_braces`, so keep `rustfmt::skip`. +#[rustfmt::skip] +#[fixture] +pub fn codec_fixtures_world() -> CodecFixturesWorld { + CodecFixturesWorld::default() +} + +impl CodecFixturesWorld { + /// Configure the Hotline codec with the given maximum frame length. + /// + /// # Errors + /// Returns an error if the codec is already configured. + pub fn configure_codec(&mut self, max_frame_length: usize) -> TestResult { + if self.codec.is_some() { + return Err("codec already configured".into()); + } + self.codec = Some(HotlineFrameCodec::new(max_frame_length)); + Ok(()) + } + + /// Decode a valid fixture frame and store the results. + /// + /// # Errors + /// Returns an error if the codec is not configured or decoding fails + /// unexpectedly. + pub fn decode_valid_fixture(&mut self) -> TestResult { + let codec = self.codec.as_ref().ok_or("codec not configured")?; + let wire = wireframe_testing::valid_hotline_wire(b"fixture-payload", 7); + match wireframe_testing::decode_frames_with_codec(codec, wire) { + Ok(frames) => self.decoded_frames = frames, + Err(e) => self.decode_error = Some(e), + } + Ok(()) + } + + /// Decode an oversized fixture frame and store the error. + /// + /// # Errors + /// Returns an error if the codec is not configured. + pub fn decode_oversized_fixture(&mut self) -> TestResult { + let codec = self.codec.as_ref().ok_or("codec not configured")?; + let wire = wireframe_testing::oversized_hotline_wire(codec.max_frame_length()); + match wireframe_testing::decode_frames_with_codec(codec, wire) { + Ok(frames) => self.decoded_frames = frames, + Err(e) => self.decode_error = Some(e), + } + Ok(()) + } + + /// Decode a truncated fixture frame and store the error. + /// + /// # Errors + /// Returns an error if the codec is not configured. + pub fn decode_truncated_fixture(&mut self) -> TestResult { + let codec = self.codec.as_ref().ok_or("codec not configured")?; + let wire = wireframe_testing::truncated_hotline_header(); + match wireframe_testing::decode_frames_with_codec(codec, wire) { + Ok(frames) => self.decoded_frames = frames, + Err(e) => self.decode_error = Some(e), + } + Ok(()) + } + + /// Decode correlated fixture frames and store the results. + /// + /// # Errors + /// Returns an error if the codec is not configured or decoding fails + /// unexpectedly. + pub fn decode_correlated_fixture(&mut self) -> TestResult { + let codec = self.codec.as_ref().ok_or("codec not configured")?; + let wire = wireframe_testing::correlated_hotline_wire(42, &[b"a", b"b", b"c"]); + match wireframe_testing::decode_frames_with_codec(codec, wire) { + Ok(frames) => self.decoded_frames = frames, + Err(e) => self.decode_error = Some(e), + } + Ok(()) + } + + /// Verify the decoded payload matches the expected fixture input. + /// + /// # Errors + /// Returns an error if the assertion fails. + pub fn verify_payload_matches(&self) -> TestResult { + if self.decoded_frames.len() != 1 { + return Err(format!("expected 1 frame, got {}", self.decoded_frames.len()).into()); + } + let frame = self + .decoded_frames + .first() + .ok_or("expected at least one frame")?; + if frame.payload.as_ref() != b"fixture-payload" { + return Err(format!( + "payload mismatch: expected b\"fixture-payload\", got {:?}", + frame.payload.as_ref() + ) + .into()); + } + Ok(()) + } + + /// Verify the decoder produced an `InvalidData` error for oversized + /// frames. + /// + /// # Errors + /// Returns an error if no decode error was captured or it does not + /// contain the expected message. + pub fn verify_invalid_data_error(&self) -> TestResult { + let err = self + .decode_error + .as_ref() + .ok_or("expected a decode error but decoding succeeded")?; + if !err.to_string().contains("payload too large") { + return Err(format!("expected 'payload too large' error, got: {err}").into()); + } + Ok(()) + } + + /// Verify the decoder produced a "bytes remaining" error for truncated + /// frames. + /// + /// # Errors + /// Returns an error if no decode error was captured or it does not + /// contain the expected message. + pub fn verify_bytes_remaining_error(&self) -> TestResult { + let err = self + .decode_error + .as_ref() + .ok_or("expected a decode error but decoding succeeded")?; + if !err.to_string().contains("bytes remaining") { + return Err(format!("expected 'bytes remaining' error, got: {err}").into()); + } + Ok(()) + } + + /// Verify all decoded frames share the expected transaction identifier. + /// + /// # Errors + /// Returns an error if no frames were decoded or any frame has a + /// different transaction ID. + pub fn verify_correlated_transaction_ids(&self) -> TestResult { + if self.decoded_frames.len() != 3 { + return Err(format!("expected 3 frames, got {}", self.decoded_frames.len()).into()); + } + for (i, frame) in self.decoded_frames.iter().enumerate() { + if frame.transaction_id != 42 { + return Err(format!( + "frame {i}: expected transaction_id 42, got {}", + frame.transaction_id + ) + .into()); + } + } + Ok(()) + } +} diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 304623fd..4ecbe014 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -11,6 +11,7 @@ pub mod client_request_hooks; pub mod client_runtime; pub mod client_streaming; pub mod codec_error; +pub mod codec_fixtures; pub mod codec_performance_benchmarks; pub mod codec_property_roundtrip; pub mod codec_stateful; diff --git a/tests/scenarios/codec_fixtures_scenarios.rs b/tests/scenarios/codec_fixtures_scenarios.rs new file mode 100644 index 00000000..5d4ff875 --- /dev/null +++ b/tests/scenarios/codec_fixtures_scenarios.rs @@ -0,0 +1,45 @@ +//! Scenario tests for codec fixture behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_fixtures::*; + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Valid fixture decodes to expected payload" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn valid_fixture_decodes(codec_fixtures_world: CodecFixturesWorld) {} + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Oversized fixture is rejected by decoder" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn oversized_fixture_rejected(codec_fixtures_world: CodecFixturesWorld) {} + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Truncated fixture produces a decode error" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn truncated_fixture_error(codec_fixtures_world: CodecFixturesWorld) {} + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Correlated fixtures share the same transaction identifier" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn correlated_fixture_ids(codec_fixtures_world: CodecFixturesWorld) {} diff --git a/tests/scenarios/mod.rs b/tests/scenarios/mod.rs index 29d769b0..a25fc68e 100644 --- a/tests/scenarios/mod.rs +++ b/tests/scenarios/mod.rs @@ -15,6 +15,7 @@ mod client_request_hooks_scenarios; mod client_runtime_scenarios; mod client_streaming_scenarios; mod codec_error_scenarios; +mod codec_fixtures_scenarios; mod codec_performance_benchmarks_scenarios; mod codec_property_roundtrip_scenarios; mod codec_stateful_scenarios; diff --git a/tests/steps/codec_fixtures_steps.rs b/tests/steps/codec_fixtures_steps.rs new file mode 100644 index 00000000..76551576 --- /dev/null +++ b/tests/steps/codec_fixtures_steps.rs @@ -0,0 +1,53 @@ +//! Step definitions for codec fixture behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::codec_fixtures::{CodecFixturesWorld, TestResult}; + +#[given("a Hotline codec allowing fixtures up to {max_frame_length:usize} bytes")] +fn given_codec( + codec_fixtures_world: &mut CodecFixturesWorld, + max_frame_length: usize, +) -> TestResult { + codec_fixtures_world.configure_codec(max_frame_length) +} + +#[when("a valid fixture frame is decoded")] +fn when_valid_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_valid_fixture() +} + +#[when("an oversized fixture frame is decoded")] +fn when_oversized_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_oversized_fixture() +} + +#[when("a truncated fixture frame is decoded")] +fn when_truncated_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_truncated_fixture() +} + +#[when("correlated fixture frames are decoded")] +fn when_correlated_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_correlated_fixture() +} + +#[then("the decoded payload matches the fixture input")] +fn then_payload_matches(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_payload_matches() +} + +#[then("the decoder reports an invalid data error")] +fn then_invalid_data(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_invalid_data_error() +} + +#[then("the decoder reports bytes remaining on stream")] +fn then_bytes_remaining(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_bytes_remaining_error() +} + +#[then("all frames have the expected transaction identifier")] +fn then_correlated_ids(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_correlated_transaction_ids() +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index ef74c69f..726f89f0 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -11,6 +11,7 @@ mod client_request_hooks_steps; mod client_runtime_steps; mod client_streaming_steps; mod codec_error_steps; +mod codec_fixtures_steps; mod codec_performance_benchmarks_steps; mod codec_property_roundtrip_steps; mod codec_stateful_steps; diff --git a/wireframe_testing/src/helpers.rs b/wireframe_testing/src/helpers.rs index 2178c48e..544b692c 100644 --- a/wireframe_testing/src/helpers.rs +++ b/wireframe_testing/src/helpers.rs @@ -12,6 +12,7 @@ use wireframe::{ mod codec; mod codec_drive; mod codec_ext; +mod codec_fixtures; mod drive; mod payloads; mod runtime; @@ -56,6 +57,16 @@ pub use codec_drive::{ drive_with_codec_payloads_with_capacity_mut, }; pub use codec_ext::{decode_frames_with_codec, encode_payloads_with_codec, extract_payloads}; +pub use codec_fixtures::{ + correlated_hotline_wire, + mismatched_total_size_wire, + oversized_hotline_wire, + sequential_hotline_wire, + truncated_hotline_header, + truncated_hotline_payload, + valid_hotline_frame, + valid_hotline_wire, +}; pub use drive::{ drive_with_frame, drive_with_frame_mut, diff --git a/wireframe_testing/src/helpers/codec_fixtures.rs b/wireframe_testing/src/helpers/codec_fixtures.rs new file mode 100644 index 00000000..a8f04e00 --- /dev/null +++ b/wireframe_testing/src/helpers/codec_fixtures.rs @@ -0,0 +1,274 @@ +//! Codec fixture functions for generating valid and invalid Hotline-framed +//! wire bytes. +//! +//! These helpers produce raw byte sequences suitable for feeding to +//! [`decode_frames_with_codec`](super::decode_frames_with_codec) or directly +//! to a `HotlineAdapter` decoder. Four categories of fixtures are provided: +//! +//! - **Valid frames** — well-formed wire bytes that decode cleanly. +//! - **Invalid frames** — wire bytes triggering decoder errors (oversized payloads, mismatched +//! sizes). +//! - **Incomplete frames** — truncated data that causes trailing-byte errors. +//! - **Correlation metadata** — multi-frame sequences with specific transaction IDs for correlation +//! testing. +//! +//! Fixtures construct raw bytes directly rather than using the tokio-util +//! encoder, ensuring they are independent of the encoder implementation and +//! can represent malformed data that the encoder would reject. + +use bytes::Bytes; +use wireframe::codec::{ + FrameCodec, + examples::{HotlineFrame, HotlineFrameCodec}, +}; + +/// Hotline header length in bytes: `data_size` (4) + `total_size` (4) + +/// `transaction_id` (4) + reserved (8). +const HEADER_LEN: usize = 20; + +/// Build a single valid Hotline frame as raw wire bytes. +/// +/// Writes the 20-byte Hotline header (`data_size`, `total_size`, +/// `transaction_id`, 8 reserved zero bytes) followed by `payload`. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, valid_hotline_wire}; +/// +/// let wire = valid_hotline_wire(b"hello", 7); +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = decode_frames_with_codec(&codec, wire).expect("valid fixture should decode"); +/// assert_eq!(frames.len(), 1); +/// ``` +#[must_use] +pub fn valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec { + build_hotline_wire(payload, transaction_id, payload.len(), true) +} + +/// Return a typed [`HotlineFrame`] with the given payload and transaction ID. +/// +/// Useful when a test needs to inspect frame metadata without going through +/// the wire-encode/decode cycle. +/// +/// # Examples +/// +/// ```rust +/// use wireframe_testing::valid_hotline_frame; +/// +/// let frame = valid_hotline_frame(b"data", 42); +/// assert_eq!(frame.transaction_id, 42); +/// assert_eq!(&*frame.payload, b"data"); +/// ``` +#[must_use] +pub fn valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame { + let codec = HotlineFrameCodec::new(payload.len()); + let mut frame = codec.wrap_payload(Bytes::copy_from_slice(payload)); + frame.transaction_id = transaction_id; + frame +} + +/// Build a Hotline frame whose `data_size` exceeds `max_frame_length` by one +/// byte. +/// +/// The Hotline decoder rejects frames where `data_size > max_frame_length` +/// with an `InvalidData("payload too large")` error. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, oversized_hotline_wire}; +/// +/// let wire = oversized_hotline_wire(64); +/// let codec = HotlineFrameCodec::new(64); +/// let err = +/// decode_frames_with_codec(&codec, wire).expect_err("oversized frame should be rejected"); +/// assert!(err.to_string().contains("payload too large")); +/// ``` +#[must_use] +pub fn oversized_hotline_wire(max_frame_length: usize) -> Vec { + let oversized_len = max_frame_length + 1; + build_hotline_wire(&vec![0xab; oversized_len], 0, oversized_len, true) +} + +/// Build a Hotline frame with a mismatched `total_size` field. +/// +/// The header's `total_size` is set to `data_size + 21` (one byte more than +/// the correct value of `data_size + 20`). The Hotline decoder rejects this +/// with an `InvalidData("invalid total size")` error. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, mismatched_total_size_wire}; +/// +/// let wire = mismatched_total_size_wire(b"test"); +/// let codec = HotlineFrameCodec::new(4096); +/// let err = decode_frames_with_codec(&codec, wire) +/// .expect_err("mismatched total_size should be rejected"); +/// assert!(err.to_string().contains("invalid total size")); +/// ``` +#[must_use] +pub fn mismatched_total_size_wire(payload: &[u8]) -> Vec { + build_hotline_wire(payload, 0, payload.len(), false) +} + +/// Return fewer than 20 bytes — a truncated Hotline header. +/// +/// The Hotline decoder returns `Ok(None)` for this input (not enough bytes +/// to parse the header). When passed to +/// [`decode_frames_with_codec`](super::decode_frames_with_codec), the +/// `decode_eof` call detects unconsumed bytes and produces an +/// `InvalidData` error containing "bytes remaining". +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, truncated_hotline_header}; +/// +/// let wire = truncated_hotline_header(); +/// let codec = HotlineFrameCodec::new(4096); +/// let err = decode_frames_with_codec(&codec, wire) +/// .expect_err("truncated header should cause a decode error"); +/// assert!(err.to_string().contains("bytes remaining")); +/// ``` +#[must_use] +pub fn truncated_hotline_header() -> Vec { + // 10 bytes — enough to look like the start of a header but too short + // for the decoder to extract the full 20-byte header. + vec![0; 10] +} + +/// Return a valid Hotline header claiming `payload_len` bytes of payload, +/// but provide only half the payload bytes. +/// +/// The Hotline decoder returns `Ok(None)` because the buffer is shorter than +/// `total_size`. When passed to +/// [`decode_frames_with_codec`](super::decode_frames_with_codec), the +/// `decode_eof` call detects unconsumed bytes and produces an +/// `InvalidData` error containing "bytes remaining". +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, truncated_hotline_payload}; +/// +/// let wire = truncated_hotline_payload(100); +/// let codec = HotlineFrameCodec::new(4096); +/// let err = decode_frames_with_codec(&codec, wire) +/// .expect_err("truncated payload should cause a decode error"); +/// assert!(err.to_string().contains("bytes remaining")); +/// ``` +#[must_use] +pub fn truncated_hotline_payload(payload_len: usize) -> Vec { + let data_size = u32_from_usize(payload_len); + let total_size = u32_from_usize(payload_len + HEADER_LEN); + + let half_payload = payload_len / 2; + let mut buf = Vec::with_capacity(HEADER_LEN + half_payload); + buf.extend_from_slice(&data_size.to_be_bytes()); + buf.extend_from_slice(&total_size.to_be_bytes()); + buf.extend_from_slice(&0u32.to_be_bytes()); // transaction_id + buf.extend_from_slice(&[0u8; 8]); // reserved + buf.extend_from_slice(&vec![0xcc; half_payload]); + buf +} + +/// Encode multiple Hotline frames sharing the same `transaction_id`. +/// +/// Suitable for verifying that correlation ID propagation works across frame +/// sequences. All frames in the returned byte vector carry the same +/// transaction identifier. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{correlated_hotline_wire, decode_frames_with_codec}; +/// +/// let wire = correlated_hotline_wire(42, &[b"a", b"b"]); +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = decode_frames_with_codec(&codec, wire).expect("correlated fixtures should decode"); +/// assert_eq!(frames.len(), 2); +/// assert!(frames.iter().all(|f| f.transaction_id == 42)); +/// ``` +#[must_use] +pub fn correlated_hotline_wire(transaction_id: u32, payloads: &[&[u8]]) -> Vec { + let mut buf = Vec::new(); + for payload in payloads { + buf.extend_from_slice(&valid_hotline_wire(payload, transaction_id)); + } + buf +} + +/// Encode multiple Hotline frames with incrementing transaction IDs. +/// +/// The first frame carries `base_transaction_id`, the second +/// `base_transaction_id + 1`, and so on. Suitable for verifying frame +/// ordering or sequential correlation. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, sequential_hotline_wire}; +/// +/// let wire = sequential_hotline_wire(10, &[b"x", b"y", b"z"]); +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = decode_frames_with_codec(&codec, wire).expect("sequential fixtures should decode"); +/// assert_eq!(frames.len(), 3); +/// ``` +#[must_use] +pub fn sequential_hotline_wire(base_transaction_id: u32, payloads: &[&[u8]]) -> Vec { + let mut buf = Vec::new(); + for (i, payload) in payloads.iter().enumerate() { + #[expect( + clippy::cast_possible_truncation, + reason = "fixture payloads slice length will not exceed u32::MAX" + )] + let tid = base_transaction_id + i as u32; + buf.extend_from_slice(&valid_hotline_wire(payload, tid)); + } + buf +} + +// ── Internal helpers ──────────────────────────────────────────────────── + +/// Construct a Hotline wire frame with explicit control over header fields. +/// +/// When `correct_total_size` is `true`, `total_size` is set to +/// `data_size + HEADER_LEN`. When `false`, `total_size` is set to +/// `data_size + HEADER_LEN + 1` (deliberately wrong). +fn build_hotline_wire( + payload: &[u8], + transaction_id: u32, + data_size: usize, + correct_total_size: bool, +) -> Vec { + let data_size_u32 = u32_from_usize(data_size); + let total_size = if correct_total_size { + u32_from_usize(data_size + HEADER_LEN) + } else { + // Off by one — triggers "invalid total size" in the decoder. + u32_from_usize(data_size + HEADER_LEN + 1) + }; + + let mut buf = Vec::with_capacity(HEADER_LEN + payload.len()); + buf.extend_from_slice(&data_size_u32.to_be_bytes()); + buf.extend_from_slice(&total_size.to_be_bytes()); + buf.extend_from_slice(&transaction_id.to_be_bytes()); + buf.extend_from_slice(&[0u8; 8]); // reserved + buf.extend_from_slice(payload); + buf +} + +/// Convert a `usize` to `u32`, saturating at `u32::MAX`. +/// +/// Fixture payloads are always small enough to fit in `u32`, but we avoid a +/// truncating cast to satisfy Clippy. +fn u32_from_usize(value: usize) -> u32 { u32::try_from(value).unwrap_or(u32::MAX) } diff --git a/wireframe_testing/src/lib.rs b/wireframe_testing/src/lib.rs index 9575a06f..d91fe4b0 100644 --- a/wireframe_testing/src/lib.rs +++ b/wireframe_testing/src/lib.rs @@ -29,6 +29,7 @@ pub use echo_server::{ServerMode, process_frame}; pub use helpers::{ TEST_MAX_FRAME, TestSerializer, + correlated_hotline_wire, decode_frames, decode_frames_with_codec, decode_frames_with_max, @@ -50,9 +51,16 @@ pub use helpers::{ encode_frame, encode_payloads_with_codec, extract_payloads, + mismatched_total_size_wire, new_test_codec, + oversized_hotline_wire, run_app, run_with_duplex_server, + sequential_hotline_wire, + truncated_hotline_header, + truncated_hotline_payload, + valid_hotline_frame, + valid_hotline_wire, }; pub use integration_helpers::{ CommonTestEnvelope, From 146f0deb5049867a0f348bcc0a2ae753b6760b21 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 1 Mar 2026 03:52:27 +0000 Subject: [PATCH 2/6] refactor(codec-fixtures): introduce typed wrappers and helper functions - Added `TransactionId`, `MaxFrameLength`, and `PayloadLength` newtypes for hotline frame parameters for improved type safety and clarity. - Updated fixture functions to accept these newtypes instead of raw primitives. - Refactored decode test helpers to use assertion helper functions simplifying test code and error validation. - Cleaned up decoding tests by consolidating repeated error string checks into reusable assertions. - Updated exports to include new helper types. - Improved documentation and consistency in wireframe testing helpers. These changes consolidate frame codec fixture logic, improve readability, and ease future maintenance and extension of tests and helpers. Co-authored-by: devboxerhub[bot] --- ...7-2-codec-fixtures-in-wireframe-testing.md | 12 +- tests/codec_fixtures.rs | 111 +++++++----------- tests/fixtures/codec_fixtures.rs | 18 ++- wireframe_testing/src/helpers.rs | 3 + .../src/helpers/codec_fixtures.rs | 54 +++++++-- wireframe_testing/src/lib.rs | 3 + 6 files changed, 107 insertions(+), 94 deletions(-) diff --git a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md index a7b8db14..9a6bb9e0 100644 --- a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md +++ b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md @@ -175,16 +175,14 @@ New test coverage: Files created: 6 (`codec_fixtures.rs` in wireframe\_testing helpers, `codec_fixtures.rs` integration tests, `.feature` file, BDD fixture/steps/ -scenarios). -Files modified: 8 (helpers.rs, lib.rs, three BDD mod.rs files, users-guide.md, -roadmap.md, adr-004.md). +scenarios). Files modified: 8 (helpers.rs, lib.rs, three BDD mod.rs files, +users-guide.md, roadmap.md, adr-004.md). Key discovery: truncated frame fixtures trigger the `decode_eof` "bytes remaining on stream" error path rather than the trailing-bytes check in -`decode_frames_with_codec`. This is because the default tokio-util -`decode_eof` implementation detects leftover bytes before the explicit trailing -check runs. Future fixture work should account for this when designing error -assertions. +`decode_frames_with_codec`. This is because the default tokio-util `decode_eof` +implementation detects leftover bytes before the explicit trailing check runs. +Future fixture work should account for this when designing error assertions. ## Context and orientation diff --git a/tests/codec_fixtures.rs b/tests/codec_fixtures.rs index 3ff40498..8f64b5fa 100644 --- a/tests/codec_fixtures.rs +++ b/tests/codec_fixtures.rs @@ -21,6 +21,37 @@ use wireframe_testing::{ fn hotline_codec() -> HotlineFrameCodec { HotlineFrameCodec::new(4096) } +/// Decode `wire` with a fresh `HotlineFrameCodec` and verify that decoding +/// fails with an error message containing `expected_error_substring`. +fn assert_decode_fails_with(wire: Vec, expected_error_substring: &str) -> io::Result<()> { + let codec = hotline_codec(); + let result = decode_frames_with_codec(&codec, wire); + + let err = result + .err() + .ok_or_else(|| io::Error::other("expected decode to fail but it succeeded"))?; + if !err.to_string().contains(expected_error_substring) { + return Err(io::Error::other(format!( + "expected error containing '{expected_error_substring}', got: {err}" + ))); + } + Ok(()) +} + +/// Assert that `frames` contains exactly `expected` elements. +fn assert_frame_count( + frames: &[wireframe::codec::examples::HotlineFrame], + expected: usize, +) -> io::Result<()> { + if frames.len() != expected { + return Err(io::Error::other(format!( + "expected {expected} frame(s), got {}", + frames.len() + ))); + } + Ok(()) +} + // ── Valid frame fixtures ──────────────────────────────────────────────── #[test] @@ -29,16 +60,12 @@ fn valid_hotline_wire_decodes_successfully() -> io::Result<()> { let codec = hotline_codec(); let frames = decode_frames_with_codec(&codec, wire)?; + assert_frame_count(&frames, 1)?; + let frame = frames .first() .ok_or_else(|| io::Error::other("expected one decoded frame"))?; - if frames.len() != 1 { - return Err(io::Error::other(format!( - "expected 1 frame, got {}", - frames.len() - ))); - } if frame.transaction_id != 7 { return Err(io::Error::other(format!( "expected transaction_id 7, got {}", @@ -63,35 +90,13 @@ fn valid_hotline_frame_has_correct_metadata() { #[test] fn oversized_hotline_wire_rejected_by_decoder() -> io::Result<()> { let wire = oversized_hotline_wire(4096); - let codec = hotline_codec(); - let result = decode_frames_with_codec(&codec, wire); - - let err = result - .err() - .ok_or_else(|| io::Error::other("expected decode to fail for oversized frame"))?; - if !err.to_string().contains("payload too large") { - return Err(io::Error::other(format!( - "expected 'payload too large' error, got: {err}" - ))); - } - Ok(()) + assert_decode_fails_with(wire, "payload too large") } #[test] fn mismatched_total_size_rejected_by_decoder() -> io::Result<()> { let wire = mismatched_total_size_wire(b"test"); - let codec = hotline_codec(); - let result = decode_frames_with_codec(&codec, wire); - - let err = result - .err() - .ok_or_else(|| io::Error::other("expected decode to fail for mismatched total_size"))?; - if !err.to_string().contains("invalid total size") { - return Err(io::Error::other(format!( - "expected 'invalid total size' error, got: {err}" - ))); - } - Ok(()) + assert_decode_fails_with(wire, "invalid total size") } // ── Incomplete frame fixtures ─────────────────────────────────────────── @@ -99,39 +104,13 @@ fn mismatched_total_size_rejected_by_decoder() -> io::Result<()> { #[test] fn truncated_header_produces_decode_error() -> io::Result<()> { let wire = truncated_hotline_header(); - let codec = hotline_codec(); - let result = decode_frames_with_codec(&codec, wire); - - let err = result - .err() - .ok_or_else(|| io::Error::other("expected decode to fail for truncated header"))?; - // The default decode_eof sees non-empty buf and returns - // "bytes remaining on stream", which decode_frames_with_codec wraps. - if !err.to_string().contains("bytes remaining") { - return Err(io::Error::other(format!( - "expected 'bytes remaining' error, got: {err}" - ))); - } - Ok(()) + assert_decode_fails_with(wire, "bytes remaining") } #[test] fn truncated_payload_produces_decode_error() -> io::Result<()> { let wire = truncated_hotline_payload(100); - let codec = hotline_codec(); - let result = decode_frames_with_codec(&codec, wire); - - let err = result - .err() - .ok_or_else(|| io::Error::other("expected decode to fail for truncated payload"))?; - // The default decode_eof sees non-empty buf and returns - // "bytes remaining on stream", which decode_frames_with_codec wraps. - if !err.to_string().contains("bytes remaining") { - return Err(io::Error::other(format!( - "expected 'bytes remaining' error, got: {err}" - ))); - } - Ok(()) + assert_decode_fails_with(wire, "bytes remaining") } // ── Correlation metadata fixtures ─────────────────────────────────────── @@ -142,12 +121,8 @@ fn correlated_frames_share_transaction_id() -> io::Result<()> { let codec = hotline_codec(); let frames = decode_frames_with_codec(&codec, wire)?; - if frames.len() != 3 { - return Err(io::Error::other(format!( - "expected 3 frames, got {}", - frames.len() - ))); - } + assert_frame_count(&frames, 3)?; + for (i, frame) in frames.iter().enumerate() { if frame.transaction_id != 42 { return Err(io::Error::other(format!( @@ -165,12 +140,8 @@ fn sequential_frames_have_incrementing_ids() -> io::Result<()> { let codec = hotline_codec(); let frames = decode_frames_with_codec(&codec, wire)?; - if frames.len() != 3 { - return Err(io::Error::other(format!( - "expected 3 frames, got {}", - frames.len() - ))); - } + assert_frame_count(&frames, 3)?; + let expected_ids: &[u32] = &[10, 11, 12]; for (i, (frame, expected_id)) in frames.iter().zip(expected_ids.iter()).enumerate() { if frame.transaction_id != *expected_id { diff --git a/tests/fixtures/codec_fixtures.rs b/tests/fixtures/codec_fixtures.rs index 70d12d4c..da17f994 100644 --- a/tests/fixtures/codec_fixtures.rs +++ b/tests/fixtures/codec_fixtures.rs @@ -132,14 +132,7 @@ impl CodecFixturesWorld { /// Returns an error if no decode error was captured or it does not /// contain the expected message. pub fn verify_invalid_data_error(&self) -> TestResult { - let err = self - .decode_error - .as_ref() - .ok_or("expected a decode error but decoding succeeded")?; - if !err.to_string().contains("payload too large") { - return Err(format!("expected 'payload too large' error, got: {err}").into()); - } - Ok(()) + self.verify_error_message_contains("payload too large") } /// Verify the decoder produced a "bytes remaining" error for truncated @@ -149,12 +142,17 @@ impl CodecFixturesWorld { /// Returns an error if no decode error was captured or it does not /// contain the expected message. pub fn verify_bytes_remaining_error(&self) -> TestResult { + self.verify_error_message_contains("bytes remaining") + } + + /// Verify the captured decode error contains `expected_substring`. + fn verify_error_message_contains(&self, expected_substring: &str) -> TestResult { let err = self .decode_error .as_ref() .ok_or("expected a decode error but decoding succeeded")?; - if !err.to_string().contains("bytes remaining") { - return Err(format!("expected 'bytes remaining' error, got: {err}").into()); + if !err.to_string().contains(expected_substring) { + return Err(format!("expected '{expected_substring}' error, got: {err}").into()); } Ok(()) } diff --git a/wireframe_testing/src/helpers.rs b/wireframe_testing/src/helpers.rs index 544b692c..516970ee 100644 --- a/wireframe_testing/src/helpers.rs +++ b/wireframe_testing/src/helpers.rs @@ -58,6 +58,9 @@ pub use codec_drive::{ }; pub use codec_ext::{decode_frames_with_codec, encode_payloads_with_codec, extract_payloads}; pub use codec_fixtures::{ + MaxFrameLength, + PayloadLength, + TransactionId, correlated_hotline_wire, mismatched_total_size_wire, oversized_hotline_wire, diff --git a/wireframe_testing/src/helpers/codec_fixtures.rs b/wireframe_testing/src/helpers/codec_fixtures.rs index a8f04e00..b377a887 100644 --- a/wireframe_testing/src/helpers/codec_fixtures.rs +++ b/wireframe_testing/src/helpers/codec_fixtures.rs @@ -22,6 +22,30 @@ use wireframe::codec::{ examples::{HotlineFrame, HotlineFrameCodec}, }; +/// A transaction identifier for Hotline protocol frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransactionId(pub u32); + +impl From for TransactionId { + fn from(value: u32) -> Self { Self(value) } +} + +/// Maximum permitted frame length for a Hotline codec. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxFrameLength(pub usize); + +impl From for MaxFrameLength { + fn from(value: usize) -> Self { Self(value) } +} + +/// Payload length in bytes for a Hotline frame fixture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PayloadLength(pub usize); + +impl From for PayloadLength { + fn from(value: usize) -> Self { Self(value) } +} + /// Hotline header length in bytes: `data_size` (4) + `total_size` (4) + /// `transaction_id` (4) + reserved (8). const HEADER_LEN: usize = 20; @@ -43,7 +67,8 @@ const HEADER_LEN: usize = 20; /// assert_eq!(frames.len(), 1); /// ``` #[must_use] -pub fn valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec { +pub fn valid_hotline_wire(payload: &[u8], transaction_id: impl Into) -> Vec { + let transaction_id = transaction_id.into().0; build_hotline_wire(payload, transaction_id, payload.len(), true) } @@ -62,7 +87,11 @@ pub fn valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec { /// assert_eq!(&*frame.payload, b"data"); /// ``` #[must_use] -pub fn valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame { +pub fn valid_hotline_frame( + payload: &[u8], + transaction_id: impl Into, +) -> HotlineFrame { + let transaction_id = transaction_id.into().0; let codec = HotlineFrameCodec::new(payload.len()); let mut frame = codec.wrap_payload(Bytes::copy_from_slice(payload)); frame.transaction_id = transaction_id; @@ -88,7 +117,8 @@ pub fn valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame /// assert!(err.to_string().contains("payload too large")); /// ``` #[must_use] -pub fn oversized_hotline_wire(max_frame_length: usize) -> Vec { +pub fn oversized_hotline_wire(max_frame_length: impl Into) -> Vec { + let max_frame_length = max_frame_length.into().0; let oversized_len = max_frame_length + 1; build_hotline_wire(&vec![0xab; oversized_len], 0, oversized_len, true) } @@ -165,7 +195,8 @@ pub fn truncated_hotline_header() -> Vec { /// assert!(err.to_string().contains("bytes remaining")); /// ``` #[must_use] -pub fn truncated_hotline_payload(payload_len: usize) -> Vec { +pub fn truncated_hotline_payload(payload_len: impl Into) -> Vec { + let payload_len = payload_len.into().0; let data_size = u32_from_usize(payload_len); let total_size = u32_from_usize(payload_len + HEADER_LEN); @@ -198,7 +229,11 @@ pub fn truncated_hotline_payload(payload_len: usize) -> Vec { /// assert!(frames.iter().all(|f| f.transaction_id == 42)); /// ``` #[must_use] -pub fn correlated_hotline_wire(transaction_id: u32, payloads: &[&[u8]]) -> Vec { +pub fn correlated_hotline_wire( + transaction_id: impl Into, + payloads: &[&[u8]], +) -> Vec { + let transaction_id = transaction_id.into().0; let mut buf = Vec::new(); for payload in payloads { buf.extend_from_slice(&valid_hotline_wire(payload, transaction_id)); @@ -224,7 +259,11 @@ pub fn correlated_hotline_wire(transaction_id: u32, payloads: &[&[u8]]) -> Vec Vec { +pub fn sequential_hotline_wire( + base_transaction_id: impl Into, + payloads: &[&[u8]], +) -> Vec { + let base_transaction_id = base_transaction_id.into().0; let mut buf = Vec::new(); for (i, payload) in payloads.iter().enumerate() { #[expect( @@ -246,10 +285,11 @@ pub fn sequential_hotline_wire(base_transaction_id: u32, payloads: &[&[u8]]) -> /// `data_size + HEADER_LEN + 1` (deliberately wrong). fn build_hotline_wire( payload: &[u8], - transaction_id: u32, + transaction_id: impl Into, data_size: usize, correct_total_size: bool, ) -> Vec { + let transaction_id = transaction_id.into().0; let data_size_u32 = u32_from_usize(data_size); let total_size = if correct_total_size { u32_from_usize(data_size + HEADER_LEN) diff --git a/wireframe_testing/src/lib.rs b/wireframe_testing/src/lib.rs index d91fe4b0..150bafd8 100644 --- a/wireframe_testing/src/lib.rs +++ b/wireframe_testing/src/lib.rs @@ -27,8 +27,11 @@ pub mod multi_packet; pub use echo_server::{ServerMode, process_frame}; pub use helpers::{ + MaxFrameLength, + PayloadLength, TEST_MAX_FRAME, TestSerializer, + TransactionId, correlated_hotline_wire, decode_frames, decode_frames_with_codec, From 4b33d12e0ff1b5e9e18e0e0472a1c0ed8dcf393f Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 1 Mar 2026 10:41:30 +0000 Subject: [PATCH 3/6] test(codec_fixtures): refactor frame transaction ID checks into helper function Introduced `assert_transaction_ids` helper to verify transaction IDs in frames. Replaced repetitive individual frame ID assertions with this helper in existing tests for improved code clarity and reuse. Co-authored-by: devboxerhub[bot] --- tests/codec_fixtures.rs | 46 ++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/tests/codec_fixtures.rs b/tests/codec_fixtures.rs index 8f64b5fa..459d3dfc 100644 --- a/tests/codec_fixtures.rs +++ b/tests/codec_fixtures.rs @@ -113,6 +113,29 @@ fn truncated_payload_produces_decode_error() -> io::Result<()> { assert_decode_fails_with(wire, "bytes remaining") } +/// Verify each frame carries the expected transaction ID. +fn assert_transaction_ids( + frames: &[wireframe::codec::examples::HotlineFrame], + expected_ids: &[u32], +) -> io::Result<()> { + if frames.len() != expected_ids.len() { + return Err(io::Error::other(format!( + "expected {} frame(s), got {}", + expected_ids.len(), + frames.len() + ))); + } + for (i, (frame, expected_id)) in frames.iter().zip(expected_ids.iter()).enumerate() { + if frame.transaction_id != *expected_id { + return Err(io::Error::other(format!( + "frame {i}: expected transaction_id {expected_id}, got {}", + frame.transaction_id + ))); + } + } + Ok(()) +} + // ── Correlation metadata fixtures ─────────────────────────────────────── #[test] @@ -122,16 +145,7 @@ fn correlated_frames_share_transaction_id() -> io::Result<()> { let frames = decode_frames_with_codec(&codec, wire)?; assert_frame_count(&frames, 3)?; - - for (i, frame) in frames.iter().enumerate() { - if frame.transaction_id != 42 { - return Err(io::Error::other(format!( - "frame {i}: expected transaction_id 42, got {}", - frame.transaction_id - ))); - } - } - Ok(()) + assert_transaction_ids(&frames, &[42, 42, 42]) } #[test] @@ -141,15 +155,5 @@ fn sequential_frames_have_incrementing_ids() -> io::Result<()> { let frames = decode_frames_with_codec(&codec, wire)?; assert_frame_count(&frames, 3)?; - - let expected_ids: &[u32] = &[10, 11, 12]; - for (i, (frame, expected_id)) in frames.iter().zip(expected_ids.iter()).enumerate() { - if frame.transaction_id != *expected_id { - return Err(io::Error::other(format!( - "frame {i}: expected transaction_id {expected_id}, got {}", - frame.transaction_id - ))); - } - } - Ok(()) + assert_transaction_ids(&frames, &[10, 11, 12]) } From fc6a653aea00c45791ee2b6d2cb4c190dec0d727 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 1 Mar 2026 15:18:04 +0000 Subject: [PATCH 4/6] refactor(tests,codec-fixtures): consolidate codec fixture decoding logic Refactored the codec fixture decoding methods in tests/fixtures/codec_fixtures.rs to delegate to a single helper method decode_fixture, reducing code duplication and improving clarity. Also fixed frame count assertion in tests/codec_fixtures.rs by using assert_frame_count helper. Minor fixes include using wrapping_add for transaction ID calculation in wireframe_testing helpers and a typo correction in docs. This change improves maintainability without altering functionality. Co-authored-by: devboxerhub[bot] --- ...7-2-codec-fixtures-in-wireframe-testing.md | 2 +- tests/codec_fixtures.rs | 8 +----- tests/fixtures/codec_fixtures.rs | 27 +++++++------------ .../src/helpers/codec_fixtures.rs | 2 +- 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md index 9a6bb9e0..f58927bf 100644 --- a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md +++ b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md @@ -428,7 +428,7 @@ Stage C acceptance: `make test` passes with all 4 BDD scenarios green. 2. Update the relevant design document (`docs/hardening-wireframe-a-guide-to-production-resilience.md`) with a short note recording the design decision to provide Hotline-specific - raw-byte fixtures rather than generic `FrameCodec`-parameterised generators, + raw-byte fixtures rather than generic `FrameCodec`-parameterized generators, and the rationale (invalid frames require format-specific knowledge). 3. Mark roadmap item `9.7.2` as done in `docs/roadmap.md`: change diff --git a/tests/codec_fixtures.rs b/tests/codec_fixtures.rs index 459d3dfc..d0d52083 100644 --- a/tests/codec_fixtures.rs +++ b/tests/codec_fixtures.rs @@ -118,13 +118,7 @@ fn assert_transaction_ids( frames: &[wireframe::codec::examples::HotlineFrame], expected_ids: &[u32], ) -> io::Result<()> { - if frames.len() != expected_ids.len() { - return Err(io::Error::other(format!( - "expected {} frame(s), got {}", - expected_ids.len(), - frames.len() - ))); - } + assert_frame_count(frames, expected_ids.len())?; for (i, (frame, expected_id)) in frames.iter().zip(expected_ids.iter()).enumerate() { if frame.transaction_id != *expected_id { return Err(io::Error::other(format!( diff --git a/tests/fixtures/codec_fixtures.rs b/tests/fixtures/codec_fixtures.rs index da17f994..1b7609e2 100644 --- a/tests/fixtures/codec_fixtures.rs +++ b/tests/fixtures/codec_fixtures.rs @@ -51,13 +51,8 @@ impl CodecFixturesWorld { /// Returns an error if the codec is not configured or decoding fails /// unexpectedly. pub fn decode_valid_fixture(&mut self) -> TestResult { - let codec = self.codec.as_ref().ok_or("codec not configured")?; let wire = wireframe_testing::valid_hotline_wire(b"fixture-payload", 7); - match wireframe_testing::decode_frames_with_codec(codec, wire) { - Ok(frames) => self.decoded_frames = frames, - Err(e) => self.decode_error = Some(e), - } - Ok(()) + self.decode_fixture(wire) } /// Decode an oversized fixture frame and store the error. @@ -67,11 +62,7 @@ impl CodecFixturesWorld { pub fn decode_oversized_fixture(&mut self) -> TestResult { let codec = self.codec.as_ref().ok_or("codec not configured")?; let wire = wireframe_testing::oversized_hotline_wire(codec.max_frame_length()); - match wireframe_testing::decode_frames_with_codec(codec, wire) { - Ok(frames) => self.decoded_frames = frames, - Err(e) => self.decode_error = Some(e), - } - Ok(()) + self.decode_fixture(wire) } /// Decode a truncated fixture frame and store the error. @@ -79,13 +70,8 @@ impl CodecFixturesWorld { /// # Errors /// Returns an error if the codec is not configured. pub fn decode_truncated_fixture(&mut self) -> TestResult { - let codec = self.codec.as_ref().ok_or("codec not configured")?; let wire = wireframe_testing::truncated_hotline_header(); - match wireframe_testing::decode_frames_with_codec(codec, wire) { - Ok(frames) => self.decoded_frames = frames, - Err(e) => self.decode_error = Some(e), - } - Ok(()) + self.decode_fixture(wire) } /// Decode correlated fixture frames and store the results. @@ -94,8 +80,13 @@ impl CodecFixturesWorld { /// Returns an error if the codec is not configured or decoding fails /// unexpectedly. pub fn decode_correlated_fixture(&mut self) -> TestResult { - let codec = self.codec.as_ref().ok_or("codec not configured")?; let wire = wireframe_testing::correlated_hotline_wire(42, &[b"a", b"b", b"c"]); + self.decode_fixture(wire) + } + + /// Decode `wire` with the configured codec, storing frames or error. + fn decode_fixture(&mut self, wire: Vec) -> TestResult { + let codec = self.codec.as_ref().ok_or("codec not configured")?; match wireframe_testing::decode_frames_with_codec(codec, wire) { Ok(frames) => self.decoded_frames = frames, Err(e) => self.decode_error = Some(e), diff --git a/wireframe_testing/src/helpers/codec_fixtures.rs b/wireframe_testing/src/helpers/codec_fixtures.rs index b377a887..aeb1d7ac 100644 --- a/wireframe_testing/src/helpers/codec_fixtures.rs +++ b/wireframe_testing/src/helpers/codec_fixtures.rs @@ -270,7 +270,7 @@ pub fn sequential_hotline_wire( clippy::cast_possible_truncation, reason = "fixture payloads slice length will not exceed u32::MAX" )] - let tid = base_transaction_id + i as u32; + let tid = base_transaction_id.wrapping_add(i as u32); buf.extend_from_slice(&valid_hotline_wire(payload, tid)); } buf From a366bbdc39f0e35da7099db65e514af27e183883 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 2 Mar 2026 00:16:59 +0000 Subject: [PATCH 5/6] refactor(codec-fixtures, wireframe-testing): generalize fixture helpers and improve test state reset - Changed multiple fixture helper function parameters to accept `impl Into` for flexibility. - Updated decoding fixture method to clear previous frames and errors before decoding. - Adjusted documentation and comments for clarity, including error messages wording in scenarios. - Ensured truncated payload fixture always claims at least 1 byte to guarantee truncation. These improvements enhance API ergonomics and test reliability. Co-authored-by: devboxerhub[bot] --- ...7-2-codec-fixtures-in-wireframe-testing.md | 26 +++++++++---------- tests/fixtures/codec_fixtures.rs | 2 ++ .../src/helpers/codec_fixtures.rs | 4 ++- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md index f58927bf..6d37c7e0 100644 --- a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md +++ b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md @@ -349,13 +349,13 @@ Tests to implement: `mismatched_total_size_wire`, attempt decode, verify `Err` with `InvalidData`. -5. `truncated_header_produces_trailing_bytes_error` — call - `truncated_hotline_header`, attempt decode, verify `Err` mentioning - "trailing". +5. `truncated_header_produces_decode_error` — call + `truncated_hotline_header`, attempt decode, verify `Err` mentioning "bytes + remaining". -6. `truncated_payload_produces_trailing_bytes_error` — call +6. `truncated_payload_produces_decode_error` — call `truncated_hotline_payload(100)`, attempt decode, verify `Err` mentioning - "trailing". + "bytes remaining". 7. `correlated_frames_share_transaction_id` — call `correlated_hotline_wire` with `transaction_id = 42` and 3 payloads, decode, verify all 3 frames have @@ -388,10 +388,10 @@ Feature: Codec test fixtures When an oversized fixture frame is decoded Then the decoder reports an invalid data error - Scenario: Truncated fixture produces a trailing bytes error + Scenario: Truncated fixture produces a bytes remaining error Given a Hotline codec allowing frames up to 4096 bytes When a truncated fixture frame is decoded - Then the decoder reports trailing bytes + Then the decoder reports bytes remaining Scenario: Correlated fixtures share the same transaction identifier Given a Hotline codec allowing frames up to 4096 bytes @@ -492,14 +492,14 @@ In `wireframe_testing/src/helpers/codec_fixtures.rs`: /// /// Writes the 20-byte Hotline header (data_size, total_size, /// transaction_id, 8 reserved zero bytes) followed by the payload. -pub fn valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec; +pub fn valid_hotline_wire(payload: &[u8], transaction_id: impl Into) -> Vec; /// Return a typed `HotlineFrame` with the given payload and transaction ID. -pub fn valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame; +pub fn valid_hotline_frame(payload: &[u8], transaction_id: impl Into) -> HotlineFrame; /// Build a Hotline frame whose data_size exceeds `max_frame_length` by one /// byte. The decoder should reject this with `InvalidData`. -pub fn oversized_hotline_wire(max_frame_length: usize) -> Vec; +pub fn oversized_hotline_wire(max_frame_length: impl Into) -> Vec; /// Build a Hotline frame with a mismatched total_size field. /// The decoder should reject this with `InvalidData`. @@ -510,17 +510,17 @@ pub fn truncated_hotline_header() -> Vec; /// Return a valid Hotline header claiming `payload_len` bytes of payload, /// but provide only half the payload bytes. -pub fn truncated_hotline_payload(payload_len: usize) -> Vec; +pub fn truncated_hotline_payload(payload_len: impl Into) -> Vec; /// Encode multiple Hotline frames sharing the same `transaction_id`. pub fn correlated_hotline_wire( - transaction_id: u32, + transaction_id: impl Into, payloads: &[&[u8]], ) -> Vec; /// Encode multiple Hotline frames with incrementing transaction IDs. pub fn sequential_hotline_wire( - base_transaction_id: u32, + base_transaction_id: impl Into, payloads: &[&[u8]], ) -> Vec; ``` diff --git a/tests/fixtures/codec_fixtures.rs b/tests/fixtures/codec_fixtures.rs index 1b7609e2..9e07c203 100644 --- a/tests/fixtures/codec_fixtures.rs +++ b/tests/fixtures/codec_fixtures.rs @@ -86,6 +86,8 @@ impl CodecFixturesWorld { /// Decode `wire` with the configured codec, storing frames or error. fn decode_fixture(&mut self, wire: Vec) -> TestResult { + self.decoded_frames.clear(); + self.decode_error = None; let codec = self.codec.as_ref().ok_or("codec not configured")?; match wireframe_testing::decode_frames_with_codec(codec, wire) { Ok(frames) => self.decoded_frames = frames, diff --git a/wireframe_testing/src/helpers/codec_fixtures.rs b/wireframe_testing/src/helpers/codec_fixtures.rs index aeb1d7ac..cc9eab59 100644 --- a/wireframe_testing/src/helpers/codec_fixtures.rs +++ b/wireframe_testing/src/helpers/codec_fixtures.rs @@ -196,7 +196,9 @@ pub fn truncated_hotline_header() -> Vec { /// ``` #[must_use] pub fn truncated_hotline_payload(payload_len: impl Into) -> Vec { - let payload_len = payload_len.into().0; + // Clamp to at least 1 so the header always claims more payload than + // the buffer provides, guaranteeing a genuinely truncated frame. + let payload_len = payload_len.into().0.max(1); let data_size = u32_from_usize(payload_len); let total_size = u32_from_usize(payload_len + HEADER_LEN); From 622f828a673feb1011baa3e19390713275ca1806 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 2 Mar 2026 15:10:46 +0000 Subject: [PATCH 6/6] docs(codec_fixtures): update codec_fixtures docs for API and error handling Revise documentation in codec_fixtures to reflect changes in error handling, parameter types (using Into traits), and fixture API descriptions. Clarify behavior on truncated frame decoding and error reporting via decode_eof. Improve explanations of fixture functions and update terminology for error paths to enhance clarity and accuracy in the docs. Co-authored-by: devboxerhub[bot] --- ...7-2-codec-fixtures-in-wireframe-testing.md | 64 +++++++++++-------- .../src/helpers/codec_fixtures.rs | 8 +-- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md index 6d37c7e0..3914f7e8 100644 --- a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md +++ b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md @@ -87,11 +87,12 @@ remain green. - Risk: the Hotline decoder returns `Ok(None)` for truncated data (insufficient bytes for a header or payload) rather than `Err(...)`. This means `decode_frames_with_codec` will treat truncated frames as "no more frames" - rather than an error, unless the trailing-bytes check catches it. Fixtures + rather than an error, unless `decode_eof` detects unconsumed bytes. Fixtures producing truncated data must account for this: they will trigger the - trailing-bytes error path in `decode_frames_with_codec`, not a decoder error. - Severity: medium. Likelihood: certain. Mitigation: document this in the - fixture API and ensure BDD tests assert on trailing-bytes errors specifically. + `decode_eof` "bytes remaining on stream" error path in + `decode_frames_with_codec`, not a decoder error. Severity: medium. + Likelihood: certain. Mitigation: document this in the fixture API and ensure + BDD tests assert on "bytes remaining" errors specifically. - Risk: the `codec_fixtures.rs` module might grow beyond 400 lines if too many fixture variants are added. Severity: low. Likelihood: low. Mitigation: start @@ -274,21 +275,23 @@ provides four categories of fixtures: **1. Valid frame fixtures** — properly encoded Hotline frames. -`valid_hotline_wire(payload: &[u8], transaction_id: u32) -> Vec` — builds a -single valid Hotline frame as raw wire bytes by writing the 20-byte header -(data\_size, total\_size, transaction\_id, 8 reserved zero bytes) followed by -the payload. This bypasses the tokio-util encoder so fixtures are independent -of the encoder implementation. +`valid_hotline_wire(payload, transaction_id) -> Vec` where +`transaction_id: impl Into` — builds a single valid Hotline +frame as raw wire bytes by writing the 20-byte header (data\_size, total\_size, +transaction\_id, 8 reserved zero bytes) followed by the payload. This bypasses +the tokio-util encoder so fixtures are independent of the encoder +implementation. -`valid_hotline_frame(payload: &[u8], transaction_id: u32) -> HotlineFrame` — -returns a typed `HotlineFrame` for tests needing metadata inspection. Delegates -to `HotlineFrameCodec::wrap_payload` but overrides the `transaction_id`. +`valid_hotline_frame(payload, transaction_id) -> HotlineFrame` where +`transaction_id: impl Into` — returns a typed `HotlineFrame` for +tests needing metadata inspection. Delegates to +`HotlineFrameCodec::wrap_payload` but overrides the `transaction_id`. **2. Invalid frame fixtures** — wire bytes that should cause decode errors. -`oversized_hotline_wire(max_frame_length: usize) -> Vec` — crafts a Hotline -header where `data_size` exceeds `max_frame_length` by 1 byte, followed by that -many payload bytes. The decoder should reject this with +`oversized_hotline_wire(max_frame_length: impl Into) -> Vec` +— crafts a Hotline header where `data_size` exceeds `max_frame_length` by 1 +byte, followed by that many payload bytes. The decoder should reject this with `InvalidData("payload too large")`. `mismatched_total_size_wire(payload: &[u8]) -> Vec` — crafts a Hotline @@ -299,24 +302,29 @@ reject this with `InvalidData("invalid total size")`. insufficient for a complete frame. `truncated_hotline_header() -> Vec` — returns fewer than 20 bytes (e.g., 10 -bytes of zeroes). The decoder returns `Ok(None)`; when passed to -`decode_frames_with_codec` the trailing-bytes check produces an error. +bytes of zeroes). The Hotline decoder returns `Ok(None)` (insufficient bytes); +when passed to `decode_frames_with_codec` the `decode_eof` call detects +unconsumed bytes and produces an `InvalidData` error containing "bytes +remaining". -`truncated_hotline_payload(payload_len: usize) -> Vec` — writes a valid -20-byte header claiming `data_size = payload_len` but provides only half the -payload bytes. The decoder returns `Ok(None)` because the buffer is too short; -`decode_frames_with_codec` reports trailing bytes. +`truncated_hotline_payload(payload_len: impl Into) -> Vec` — +writes a valid 20-byte header claiming `data_size = payload_len` but provides +only half the payload bytes. The Hotline decoder returns `Ok(None)` because the +buffer is too short; `decode_frames_with_codec` surfaces this via `decode_eof` +with an error containing "bytes remaining". **4. Correlation metadata fixtures** — frames with specific transaction IDs for correlation testing. -`correlated_hotline_wire(transaction_id: u32, payloads: &[&[u8]]) -> Vec` — -encodes multiple Hotline frames sharing the same `transaction_id`, suitable for -verifying correlation ID propagation across frame sequences. +`correlated_hotline_wire(transaction_id, payloads) -> Vec` where +`transaction_id: impl Into` — encodes multiple Hotline frames +sharing the same `transaction_id`, suitable for verifying correlation ID +propagation across frame sequences. -`sequential_hotline_wire(base_transaction_id: u32, payloads: &[&[u8]]) -> Vec` -— encodes multiple frames with incrementing transaction IDs (`base`, -`base + 1`, …), suitable for verifying frame ordering. +`sequential_hotline_wire(base_transaction_id, payloads) -> Vec` where +`base_transaction_id: impl Into` — encodes multiple frames with +incrementing transaction IDs (`base`, `base + 1`, …), suitable for verifying +frame ordering. Register the module in `wireframe_testing/src/helpers.rs` as `mod codec_fixtures;` and add `pub use codec_fixtures::*;` exports. Add the @@ -404,7 +412,7 @@ the codec, wire bytes, decoded frames, and any decode error. Methods: `configure_codec(max_frame_length)`, `decode_valid_fixture()`, `decode_oversized_fixture()`, `decode_truncated_fixture()`, `decode_correlated_fixture()`, `verify_payload_matches()`, -`verify_invalid_data_error()`, `verify_trailing_bytes_error()`, +`verify_invalid_data_error()`, `verify_bytes_remaining_error()`, `verify_correlated_transaction_ids()`. **`tests/steps/codec_fixtures_steps.rs`**: Step functions matching the Gherkin diff --git a/wireframe_testing/src/helpers/codec_fixtures.rs b/wireframe_testing/src/helpers/codec_fixtures.rs index cc9eab59..fb4fd903 100644 --- a/wireframe_testing/src/helpers/codec_fixtures.rs +++ b/wireframe_testing/src/helpers/codec_fixtures.rs @@ -119,7 +119,7 @@ pub fn valid_hotline_frame( #[must_use] pub fn oversized_hotline_wire(max_frame_length: impl Into) -> Vec { let max_frame_length = max_frame_length.into().0; - let oversized_len = max_frame_length + 1; + let oversized_len = max_frame_length.saturating_add(1); build_hotline_wire(&vec![0xab; oversized_len], 0, oversized_len, true) } @@ -200,7 +200,7 @@ pub fn truncated_hotline_payload(payload_len: impl Into) -> Vec