From 0643627bfb8e6e47ea5df5e82f1dff51cac180be Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 22:21:16 +0000 Subject: [PATCH 01/45] Update rstest-bdd user's guide --- docs/rstest-bdd-users-guide.md | 403 ++++++++++++++++++++++++++++++--- 1 file changed, 377 insertions(+), 26 deletions(-) diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index a35bc1fe..b2e0baed 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -23,19 +23,20 @@ owner, the developer, and the tester. ## Toolchain requirements -`rstest-bdd` targets Rust 1.75 or newer across every crate in the workspace. -Each `Cargo.toml` declares `rust-version = "1.75"`, so `cargo` will refuse to -compile the project on older stable compilers. The workspace now settles on the -Rust 2021 edition to keep the declared Minimum Supported Rust Version (MSRV) -and edition compatible. The repository still pins a nightly toolchain for -development because the runtime uses auto traits and negative impls. Those -nightly-only features remain behind the existing `rust-toolchain.toml` pin and -do not alter the public MSRV. Step definitions and writers remain synchronous -functions; the framework no longer depends on the `async-trait` crate to -express async methods in traits. Projects that previously relied on -`#[async_trait]` in helper traits should replace those methods with ordinary -functions—`StepFn` continues to execute synchronously and exposes results via -`StepExecution`. +`rstest-bdd` targets Rust 1.85 or newer across every crate in the workspace. +Each `Cargo.toml` declares `rust-version = "1.85"`, so `cargo` will refuse to +compile the project on older compilers. The workspace uses the Rust 2024 +edition. + +`rstest-bdd` builds on stable Rust. The repository pins a stable toolchain for +development via `rust-toolchain.toml` so contributors get consistent `rustfmt` +and `clippy` behaviour. + +Step definitions and writers remain synchronous functions; the framework no +longer depends on the `async-trait` crate to express async methods in traits. +Projects that previously relied on `#[async_trait]` in helper traits should +replace those methods with ordinary functions—`StepFn` continues to execute +synchronously and exposes results via `StepExecution`. ## The three amigos @@ -64,7 +65,7 @@ Scenarios follow the simple `Given‑When‑Then` pattern. Support for **Scenari Outline** is available, enabling a single scenario to run with multiple sets of data from an `Examples` table. A `Background` section defines steps that run before each `Scenario` in a feature file, enabling shared setup across -scenarios. Advanced constructs such as data tables and Docstrings provide +scenarios. Advanced constructs such as data tables and doc strings provide structured or free‑form arguments to steps. ### Example feature file @@ -287,7 +288,27 @@ missing matches leave fixtures untouched, keeping scenarios predictable while still allowing a functional style without mutable fixtures. Steps may also return `Result`. An `Err` aborts the scenario, while an -`Ok` value is injected as above. Type aliases to `Result` behave identically. +`Ok` value is injected as above. + +The step macros recognize these `Result` shapes during expansion: + +- `Result<..>`, `std::result::Result<..>`, and `core::result::Result<..>` +- `rstest_bdd::StepResult<..>` (an alias provided by the runtime crate) + +When inference cannot determine whether a return type is a `Result` (for +example, when returning a type alias), prefer returning +`rstest_bdd::StepResult` or spelling out `Result<..>` in the signature. +Alternatively, add an explicit return-kind hint: `#[when(result)]` / +`#[when(value)]`. + +The `result`/`value` hints are validated for obvious misconfigurations. +`result` is rejected for primitive return types. For aliases, the macro cannot +validate the underlying definition and assumes `Result<..>` semantics. + +Use `#[when("...", value)]` (or `#[when(value)]` when using the inferred +pattern) to force treating the return value as a payload even when it is +`Result<..>`. + Returning `()` or `Ok(())` produces no stored value, so fixtures of `()` are not overwritten. @@ -635,15 +656,20 @@ substring matching to confirm that a message contains the expected reason. ```rust,no_run use rstest_bdd::{assert_scenario_skipped, assert_step_skipped, StepExecution}; -use rstest_bdd::reporting::{ScenarioRecord, ScenarioStatus, SkippedScenario}; +use rstest_bdd::reporting::{ScenarioMetadata, ScenarioRecord, ScenarioStatus, SkippedScenario}; let outcome = StepExecution::skipped(Some("maintenance pending".into())); let message = assert_step_skipped!(outcome, message = "maintenance"); assert_eq!(message, Some("maintenance pending".into())); -let record = ScenarioRecord::new( +let metadata = ScenarioMetadata::new( "features/unhappy.feature", "pending work", + 12, + vec!["@allow_skipped".into()], +); +let record = ScenarioRecord::from_metadata( + metadata, ScenarioStatus::Skipped(SkippedScenario::new(None, true, false)), ); let details = assert_scenario_skipped!( @@ -682,8 +708,115 @@ union of feature, scenario, and example tags described above. Scenarios that do not match simply do not generate a test, and outline examples drop unmatched rows. -Generated tests cannot currently accept fixtures; use `#[scenario]` when -fixture injection or custom assertions are required. +### Fixture injection with `scenarios!` + +The `fixtures = [name: Type, ...]` parameter injects fixtures into all +generated scenario tests. Fixtures are bound via rstest and inserted into the +step context, making them available to step functions that declare the +corresponding parameter. + +```rust,no_run +use rstest::fixture; +use rstest_bdd_macros::{given, scenarios}; + +struct TestWorld { value: i32 } + +#[fixture] +fn world() -> TestWorld { TestWorld { value: 42 } } + +#[given("a precondition")] +fn step_uses_world(world: &TestWorld) { + assert_eq!(world.value, 42); +} + +scenarios!("tests/features/auto", fixtures = [world: TestWorld]); +``` + +The macro adds `#[expect(unused_variables)]` to generated test functions when +fixtures are present, preventing lint warnings since fixture parameters are +consumed via `StepContext` rather than referenced directly in the test body. + +## Async scenario execution + +Scenarios can run asynchronously under Tokio's current-thread runtime. This +enables test code to `.await` async operations while preserving the +`RefCell`-backed fixture model for mutable borrows across await points. + +### Using `#[scenario]` with async + +Declare the test function as `async fn` and add +`#[tokio::test(flavor = "current_thread")]` before the `#[scenario]` attribute. +The macro detects the async signature and generates an async step executor: + +```rust,no_run +use rstest_bdd_macros::{given, scenario, then, when}; +use rstest::fixture; + +#[derive(Default)] +struct Counter { + value: i32, +} + +#[fixture] +fn counter() -> Counter { + Counter::default() +} + +#[given("a counter initialised to 0")] +fn init(counter: &mut Counter) { + counter.value = 0; +} + +#[when("the counter is incremented")] +fn increment(counter: &mut Counter) { + counter.value += 1; +} + +#[then(expr = "the counter value is {n}")] +fn check_value(counter: &Counter, n: i32) { + assert_eq!(counter.value, n); +} + +#[scenario(path = "tests/features/counter.feature", name = "Increment counter")] +#[tokio::test(flavor = "current_thread")] +async fn increment_counter(counter: Counter) {} +``` + +The macro generates `#[rstest::rstest]` without duplicating +`#[tokio::test(flavor = "current_thread")]` when the user already supplies it. + +### Using `scenarios!` with async + +The `scenarios!` macro accepts a `runtime` argument to generate async tests for +all discovered scenarios: + +```rust,no_run +use rstest_bdd_macros::{given, then, when, scenarios}; + +#[given("a precondition")] fn precondition() {} +#[when("an action occurs")] fn action() {} +#[then("events are recorded")] fn events() {} + +scenarios!("tests/features/auto", runtime = "tokio-current-thread"); +``` + +When `runtime = "tokio-current-thread"` is specified: + +- Generated test functions are `async fn`. +- Each test is annotated with `#[tokio::test(flavor = "current_thread")]`. +- Steps execute sequentially within the single-threaded Tokio runtime. + +### Current limitations + +- **Sync step definitions only:** The async executor currently calls the sync + `run` handler directly rather than `run_async`. This avoids higher-ranked + trait bound (HRTB) lifetime issues but means steps cannot `.await` + internally. True async step definitions (with `async fn` bodies) are planned + for a future release. +- **Current-thread mode only:** Multi-threaded Tokio mode would require `Send` + futures, which conflicts with the `RefCell`-backed fixture storage. See + [ADR-001](adr-001-async-fixtures-and-test.md) for the full design rationale. +- **No `async_std` runtime:** Only Tokio is supported at present. ## Running and maintaining tests @@ -705,14 +838,14 @@ To enable validation, pin a feature in the project's `dev-dependencies`: ```toml [dev-dependencies] -rstest-bdd-macros = { version = "0.2.0", features = ["compile-time-validation"] } +rstest-bdd-macros = { version = "0.4.0", features = ["compile-time-validation"] } ``` For strict checking use: ```toml [dev-dependencies] -rstest-bdd-macros = { version = "0.2.0", features = ["strict-compile-time-validation"] } +rstest-bdd-macros = { version = "0.4.0", features = ["strict-compile-time-validation"] } ``` Steps are only validated when one of these features is enabled. @@ -762,7 +895,7 @@ Best practices for writing effective scenarios include: treated as generic placeholders and capture any non-newline text using a non-greedy match. -## Data tables and Docstrings +## Data tables and doc strings Steps may supply structured or free-form data via a trailing argument. A data table is received by including a parameter annotated with `#[datatable]` or @@ -1029,8 +1162,8 @@ fn capture_both(datatable: Vec>, docstring: String) { At runtime, the generated wrapper converts the table cells or copies the block text and passes them to the step function. It panics if the step declares -`datatable` or `docstring` but the feature omits the content. Docstrings may be -delimited by triple double-quotes or triple backticks. +`datatable` or `docstring` but the feature omits the content. These doc strings +may be delimited by triple double-quotes or triple backticks. ## Limitations and roadmap @@ -1105,7 +1238,7 @@ Localization tooling can be added to `Cargo.toml` as follows: ```toml [dependencies] -rstest-bdd = "0.2.0" +rstest-bdd = "0.4.0" i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester"] } unic-langid = "0.9" ``` @@ -1145,24 +1278,34 @@ https://docs.rs/i18n-embed/latest/i18n_embed/fluent/struct.FluentLanguageLoader. Synopsis - `cargo bdd steps` +- `cargo bdd steps --skipped` - `cargo bdd unused` - `cargo bdd duplicates` +- `cargo bdd skipped` Examples - `cargo bdd steps` +- `cargo bdd steps --skipped --json` - `cargo bdd unused --quiet` - `cargo bdd duplicates --json` +- `cargo bdd skipped --reasons` +- `cargo bdd steps --skipped --json` must be paired; using `--json` without + `--skipped` is rejected by the CLI, so invalid combinations fail fast. -The tool inspects the runtime step registry and offers three commands: +The tool inspects the runtime step registry and offers four commands: - `cargo bdd steps` prints every registered step with its source location and appends any skipped scenario outcomes using lowercase status labels whilst preserving long messages. +- `cargo bdd steps --skipped` limits the listing to step definitions that were + bypassed after a scenario requested a skip, preserving the scenario context. - `cargo bdd unused` lists steps that were never executed in the current process. - `cargo bdd duplicates` groups step definitions that share the same keyword and pattern, helping to identify accidental copies. +- `cargo bdd skipped` lists skipped scenarios and supports `--reasons` to show + file and line numbers alongside the explanatory message. The subcommand builds each test target in the workspace and runs the resulting binary with `RSTEST_BDD_DUMP_STEPS=1` and a private `--dump-steps` flag to @@ -1172,6 +1315,12 @@ during that same execution. The merged output powers the commands above and the skip status summary, helping to keep the step library tidy and discover dead code early in the development cycle. +`steps --skipped` and `skipped` accept `--json` and emit objects that always +include `feature`, `scenario`, `line`, `tags`, and `reason` fields. The former +adds an embedded `step` object describing each bypassed definition (keyword, +pattern, file, and line) to help trace which definitions were sidelined by a +runtime skip. + ### Scenario report writers Projects that need to persist scenario results outside the CLI can rely on the @@ -1197,6 +1346,208 @@ rstest_bdd::reporting::junit::write_snapshot(&mut xml)?; Both writers accept explicit `&[ScenarioRecord]` slices when callers want to serialize a custom selection of outcomes rather than the full snapshot. +## Language server + +The `rstest-bdd-server` crate provides a Language Server Protocol (LSP) +implementation that bridges Gherkin `.feature` files and Rust step definitions. +The binary is named `rstest-bdd-lsp` and communicates over stdin/stdout using +JSON-RPC, making it compatible with any editor supporting the LSP (VS Code, +Neovim, Zed, Helix, etc.). + +### Installation + +Build and install the language server from the workspace: + +```bash +cargo install --path crates/rstest-bdd-server +``` + +The binary `rstest-bdd-lsp` is placed in the Cargo bin directory. + +### Configuration + +The server reads configuration from environment variables: + +| Variable | Description | Default | +| ---------------------------- | --------------------------------------------------- | ------- | +| `RSTEST_BDD_LSP_LOG_LEVEL` | Logging verbosity (trace, debug, info, warn, error) | `info` | +| `RSTEST_BDD_LSP_DEBOUNCE_MS` | Delay (ms) before processing file changes | `300` | + +Example: + +```bash +RSTEST_BDD_LSP_LOG_LEVEL=debug rstest-bdd-lsp +``` + +### Editor integration + +#### VS Code + +Add a configuration in the `settings.json` file or use an extension that allows +custom LSP servers. A minimal example using the +[LSP-client](https://marketplace.visualstudio.com/items?itemName=ACharLuk.easy-lsp-client) + extension: + +```json +{ + "easylsp.servers": [ + { + "language": ["rust", "gherkin"], + "command": "rstest-bdd-lsp" + } + ] +} +``` + +#### Neovim (nvim-lspconfig) + +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig.configs') + +if not configs.rstest_bdd then + configs.rstest_bdd = { + default_config = { + cmd = { 'rstest-bdd-lsp' }, + filetypes = { 'rust', 'cucumber' }, + root_dir = lspconfig.util.root_pattern('Cargo.toml'), + }, + } +end + +lspconfig.rstest_bdd.setup({}) +``` + +### Current capabilities + +The language server provides the following capabilities: + +- **Lifecycle handlers**: Responds to `initialize`, `initialized`, and + `shutdown` requests per the LSP specification. +- **Workspace discovery**: Uses `cargo metadata` to locate the workspace root + and enumerate packages. +- **Feature indexing (on save)**: Parses saved `.feature` files using the + `gherkin` parser and records steps, doc strings, data tables, and Examples + header columns with byte offsets. Parse failures are logged. +- **Rust step indexing (on save)**: Parses saved `.rs` files with `syn` and + records `#[given]`, `#[when]`, and `#[then]` functions, including the step + keyword, pattern string (including inferred patterns when the attribute has + no arguments), the parameter list, and whether the step expects a data table + or doc string. +- **Step pattern registry (on save)**: Compiles the indexed step patterns with + `rstest-bdd-patterns` and caches compiled regex matchers in a keyword-keyed + in-memory registry. The registry is updated incrementally per file save, so + removed steps do not linger. + - **API note (embedding)**: `StepDefinitionRegistry::{steps_for_keyword, + steps_for_file}` returns `Arc` entries so the + compiled matcher and metadata are shared between the per-file and + per-keyword indices. +- **Structured logging**: Configurable via environment variables; logs are + written to stderr using the `tracing` framework. + +### Navigation (Go to Definition) + +The language server supports navigation from Rust step definitions to matching +feature steps. This enables developers to quickly find all usages of a step +definition across feature files. + +**Usage:** + +1. Place the cursor on a Rust function annotated with `#[given]`, `#[when]`, or + `#[then]`. +2. Invoke "Go to Definition" (typically F12 or Ctrl+Click in most editors). +3. The editor navigates to all matching steps in `.feature` files. + +When multiple feature files contain matching steps, the editor presents a list +of locations to choose from. + +**How matching works:** + +- Matching is keyword-aware: a `#[given]` step only matches `Given` steps in + feature files. The parser correctly handles `And` and `But` keywords by + resolving them to their contextual step type. +- Patterns with placeholders (e.g., `"I have {count:u32} items"`) match feature + steps using the same regex semantics as the runtime. + +#### Go to Implementation (Feature → Rust) + +The inverse navigation—from feature steps to Rust implementations—is provided +via the `textDocument/implementation` handler. This enables developers to jump +from a step line in a `.feature` file directly to the Rust function(s) that +implement it. + +**Usage:** + +1. Place the cursor on a step line in a `.feature` file (e.g., `Given a user + exists`). +2. Invoke "Go to Implementation" (typically Ctrl+F12 or a similar keybinding in + most editors). +3. The editor navigates to all matching Rust step functions. + +When multiple implementations match (duplicate step patterns), the editor +presents a list of locations to choose from. + +**How matching works:** + +- Matching is keyword-aware: a `Given` step in a feature file only matches + `#[given]` implementations in Rust. +- The step text is matched against the compiled regex patterns from the step + registry, ensuring consistency with the runtime. + +### Diagnostics (on save) + +The language server publishes diagnostics when files are saved, helping +developers identify consistency issues between feature files and Rust step +definitions: + +- **Unimplemented feature steps** (`unimplemented-step`): When a step in a + `.feature` file has no matching Rust implementation, a warning diagnostic is + published at the step location. The message indicates the step keyword and + text that needs an implementation. + +- **Unused step definitions** (`unused-step-definition`): When a Rust step + definition (annotated with `#[given]`, `#[when]`, or `#[then]`) is not + matched by any feature step, a warning diagnostic is published at the + function definition. This helps identify dead code or typos in step patterns. + +- **Placeholder count mismatch** (`placeholder-count-mismatch`): When a step + pattern contains a different number of placeholder occurrences than the + function has step arguments, a warning diagnostic is published on the Rust + step definition. Each placeholder occurrence is counted separately (e.g., + `{x} and {x}` counts as two placeholders), matching the macro's capture + semantics. A step argument is a function parameter whose normalized name + matches a placeholder name in the pattern; `datatable`, `docstring`, and + fixture parameters are excluded from the count. + +- **Data table expected** (`table-expected`): When a Rust step expects a data + table (has a `datatable` parameter) but the matching feature step does not + provide one, a warning diagnostic is published on the feature step. + +- **Data table not expected** (`table-not-expected`): When a feature step + provides a data table but the matching Rust implementation does not expect + one, a warning diagnostic is published on the data table in the feature file. + +- **Doc string expected** (`docstring-expected`): When a Rust step expects a doc + string (has a `docstring: String` parameter) but the matching feature step + does not provide one, a warning diagnostic is published on the feature step. + +- **Doc string not expected** (`docstring-not-expected`): When a feature step + provides a doc string but the matching Rust implementation does not expect + one, a warning diagnostic is published on the doc string in the feature file. + +Diagnostics are updated incrementally: + +- Saving a `.feature` file recomputes diagnostics for that file, including + unimplemented steps and table/docstring expectation mismatches. +- Saving a `.rs` file recomputes diagnostics for all feature files (since new + or removed step definitions may affect which steps are implemented) and + checks for unused definitions and placeholder count mismatches in the saved + file. + +Diagnostics appear in the editor's Problems panel and as inline warnings, +similar to compiler diagnostics. They use the source `rstest-bdd` and the codes +listed above for filtering. + ## Summary `rstest‑bdd` seeks to bring the collaborative clarity of Behaviour‑Driven From 8ce5b554b32db61df6354394f0e39a25bd4a324c Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 22:22:23 +0000 Subject: [PATCH 02/45] Ignore grepai data --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 221eb3b3..cb6cc5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ **/*.rs.bk .crush +.grepai/ From 613ffd97894135e1b96f6af5f67a3d981eae2af2 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 23:17:40 +0000 Subject: [PATCH 03/45] Fix Makefile build target dependencies The build and release targets incorrectly referenced target files without the .rlib extension, causing make target enumeration to fail. Update to match the actual pattern rule that creates .rlib files. --- Makefile | 4 +- .../migrate-from-cucumber-to-rstest-bdd.md | 625 ++++++++++++++++++ 2 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 docs/execplans/migrate-from-cucumber-to-rstest-bdd.md diff --git a/Makefile b/Makefile index bfde1b59..ee7cfedc 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,8 @@ RUSTDOC_FLAGS ?= --cfg docsrs -D warnings MDLINT ?= markdownlint-cli2 NIXIE ?= nixie -build: target/debug/lib$(CRATE) ## Build debug binary -release: target/release/lib$(CRATE) ## Build release binary +build: target/debug/lib$(CRATE).rlib ## Build debug binary +release: target/release/lib$(CRATE).rlib ## Build release binary all: release ## Default target builds release binary diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md new file mode 100644 index 00000000..cdac8f62 --- /dev/null +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -0,0 +1,625 @@ +# Migration Plan: Cucumber to rstest-bdd v0.4.0 + +**Branch**: `migrate-from-cucumber-to-rstest-bdd` + +**Duration**: 9 weeks (phased incremental migration) + +**Status**: Planning + +**Last Updated**: 2026-01-22 + +## Executive Summary + +Migrate Wireframe's 14 Cucumber-based BDD test suites (~3,941 lines of +world code, ~1,330 lines of steps, 60+ scenarios) to rstest-bdd v0.4.0. +The migration leverages rstest-bdd's new async scenario support while +maintaining test coverage through parallel execution during migration. + +**Key Strategy**: Async scenarios with sync steps calling async helpers +via `tokio::task::block_in_place`. + +## Current State Analysis + +### Infrastructure Inventory + +- **14 World structs** across 15+ files (~3,941 lines total) +- **14 .feature files** with 60+ scenarios +- **~1,330 lines** of async step definitions +- **100% async steps** (Cucumber framework requirement) +- **Complex async operations**: TCP servers, client connections, actor + processing, timeout handling + +### World Complexity Classification + +#### Tier 1 - Simple (115-200 lines) + +- `CorrelationWorld` (115 lines): Simple state + 2 async methods +- `RequestPartsWorld` (~150 lines): Basic state validation + +#### Tier 2 - Medium (200-400 lines) + +- `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, + `MessageAssemblerWorld`, `CodecStatefulWorld` + +#### Tier 3 - High Complexity (400+ lines) + +- `ClientMessagingWorld` (302 lines): Server spawning, client + connections, envelope handling +- `ClientLifecycleWorld`, `ClientPreambleWorld` (~400 lines): Lifecycle + hooks, callbacks +- `MessageAssemblyWorld`, `CodecErrorWorld`, `FragmentWorld` + (multi-file, 11 scenarios) + +## Implementation Big Picture + +### Async Handling Model + +rstest-bdd v0.4.0 supports **async scenarios** with **sync step +definitions**: + +```rust +// Scenario function is async +#[scenario(path = "tests/features/client_messaging.feature", + name = "Client sends envelope")] +#[tokio::test(flavor = "current_thread")] +async fn client_sends_envelope_scenario(world: ClientMessagingWorld) {} + +// Steps are sync, call async helpers +#[when("the client sends the envelope")] +fn when_client_sends_envelope(world: &mut ClientMessagingWorld) + -> TestResult +{ + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + world.send_envelope() + ) + }) +} +``` + +**Why this works**: `#[tokio::test(flavor = "current_thread")]` creates +async runtime, `block_in_place` allows sync steps to await, +current-thread flavor avoids `Send` bounds with `&mut` fixtures. + +### World-to-Fixture Conversion + +**Use `&mut Fixture` when**: + +- Simple owned fields mutated directly +- Complex objects with Drop semantics +- Direct ownership desired + +**Use `Slot` when**: + +- Optional state populated conditionally +- Late-bound values (set during test) +- State reset between steps needed +- Mix of required + optional state + +**Example Pattern**: + +```rust +use rstest_bdd::{Slot, ScenarioState}; +use rstest_bdd_macros::ScenarioState; + +#[derive(Debug, ScenarioState)] +pub struct ClientMessagingWorld { + // Slots for optional/late-bound state + addr: Slot, + server: Slot>, + client: Slot>, + envelope: Slot, + + // Direct fields for always-present state + sent_correlation_ids: Vec, + + // Slots for conditional outcomes + response: Slot, + last_error: Slot, +} + +#[fixture] +fn client_messaging_world() -> ClientMessagingWorld { + // ScenarioState auto-derives Default + ClientMessagingWorld::default() +} +``` + +### Feature File Changes + +**NONE REQUIRED** - rstest-bdd uses same Gherkin parser as Cucumber. +All existing `.feature` files are 100% compatible. + +## Phase Breakdown + +### Phase 0: Foundation (Week 1) + +**Objective**: Set up parallel infrastructure without disrupting +existing tests. + +**Tasks**: + +1. Add rstest-bdd dependencies to `Cargo.toml`: + + ```toml + [dev-dependencies] + rstest-bdd = "0.4.0" + rstest-bdd-macros = { version = "0.4.0", + features = ["compile-time-validation"] } + ``` + +2. Create directory structure: + + ```text + tests/ + bdd/ # NEW: rstest-bdd tests + mod.rs + fixtures/ + mod.rs + steps/ + mod.rs + scenarios/ + mod.rs + cucumber.rs # KEEP: existing runner + features/ # KEEP: shared .feature files + worlds/ # KEEP: existing Cucumber worlds + steps/ # KEEP: existing Cucumber steps + ``` + +3. Update `Cargo.toml` test configuration: + + ```toml + [[test]] + name = "bdd" + path = "tests/bdd/mod.rs" + required-features = ["advanced-tests"] + + [[test]] + name = "cucumber" + path = "tests/cucumber.rs" + required-features = ["advanced-tests", "cucumber-tests"] + ``` + +4. Update Makefile: + + ```makefile + test-bdd: ## Run rstest-bdd tests only + RUSTFLAGS="-D warnings" $(CARGO) test --test bdd \ + --all-features $(BUILD_JOBS) + + test-cucumber: ## Run Cucumber tests only + RUSTFLAGS="-D warnings" $(CARGO) test --test cucumber \ + --features cucumber-tests $(BUILD_JOBS) + + test: test-bdd test-cucumber ## Run all tests + ``` + +**Validation**: `make test-cucumber` still works, `make test-bdd` runs +(empty at first). + +**Commit**: "Set up parallel rstest-bdd infrastructure" + +### Phase 1: Pilot Migration - Simple Worlds (Weeks 2-3) + +**Objective**: Validate approach with 2 simple worlds, establish +conversion patterns. + +**Selected Worlds**: + +1. `CorrelationWorld` (115 lines, 3 scenarios) +2. `RequestPartsWorld` (~150 lines, basic validation) + +**Per-World Steps**: + +1. Convert World struct → fixture +2. Migrate step definitions (remove `async`, add `block_in_place`) +3. Create scenario tests with `#[scenario]` + `#[tokio::test]` +4. Run and validate against Cucumber + +**Example - CorrelationWorld**: + +```rust +// tests/bdd/fixtures/correlation.rs +use rstest::fixture; + +#[derive(Debug, Default)] +pub struct CorrelationWorld { + expected: Option, + frames: Vec, +} + +#[fixture] +pub fn correlation_world() -> CorrelationWorld { + CorrelationWorld::default() +} + +// Methods stay async +impl CorrelationWorld { + pub fn set_expected(&mut self, expected: Option) { + self.expected = expected; + } + + pub async fn process(&mut self) -> TestResult { + // ... existing async code + } + + pub fn verify(&self) -> TestResult { + // ... existing sync code + } +} +``` + +```rust +// tests/bdd/steps/correlation_steps.rs +use rstest_bdd_macros::{given, when, then}; + +#[given(expr = "a correlation id {int}")] +fn given_cid(world: &mut CorrelationWorld, id: u64) { + world.set_expected(Some(id)); +} + +#[when("a stream of frames is processed")] +fn when_process(world: &mut CorrelationWorld) -> TestResult { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + world.process() + ) + }) +} + +#[then(expr = "each emitted frame uses correlation id {int}")] +fn then_verify(world: &mut CorrelationWorld, id: u64) + -> TestResult +{ + if world.expected() != Some(id) { + return Err("mismatched expected correlation id".into()); + } + world.verify() +} +``` + +```rust +// tests/bdd/scenarios/correlation_scenarios.rs +use rstest_bdd_macros::scenario; +use crate::fixtures::correlation::*; + +#[scenario(path = "tests/features/correlation_id.feature", + name = "Streamed frames reuse the request correlation id")] +#[tokio::test(flavor = "current_thread")] +async fn streamed_frames_correlation( + correlation_world: CorrelationWorld +) {} + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses reuse the request correlation id" +)] +#[tokio::test(flavor = "current_thread")] +async fn multi_packet_correlation( + correlation_world: CorrelationWorld +) {} + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses clear correlation ids without \ + a request id" +)] +#[tokio::test(flavor = "current_thread")] +async fn no_correlation(correlation_world: CorrelationWorld) {} +``` + +**Validation**: + +```bash +# Compare outputs +cargo test --test cucumber correlation +cargo test --test bdd correlation + +# Both should pass all scenarios +``` + +**Commits**: + +- "Migrate CorrelationWorld to rstest-bdd" +- "Migrate RequestPartsWorld to rstest-bdd" + +### Phase 2: Medium Complexity Worlds (Weeks 4-5) + +**Selected Worlds** (in order): + +1. `PanicWorld` (server spawning pattern) +2. `MultiPacketWorld` (channel operations) +3. `StreamEndWorld` (actor processing) +4. `CodecStatefulWorld` (codec state) + +**Focus**: Server lifecycle, channels, actors. + +**Server Spawning Pattern**: + +```rust +#[derive(ScenarioState)] +pub struct PanicWorld { + // Spawned in step, not fixture + server: Slot, +} + +#[fixture] +fn panic_world() -> PanicWorld { + PanicWorld::default() // Empty slot +} + +#[given("a panic server")] +fn given_panic_server(world: &mut PanicWorld) -> TestResult { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let server = PanicServer::spawn().await?; + world.server.set(server); + Ok(()) + }) + }) +} +``` + +**Commits**: One per world (4 commits). + +### Phase 3: Complex Worlds - Client & Messaging (Weeks 6-7) + +**Selected Worlds** (in order): + +1. `ClientRuntimeWorld` (simpler client) +2. `ClientMessagingWorld` (server + client + envelope handling) +3. `ClientLifecycleWorld` (lifecycle hooks) +4. `ClientPreambleWorld` (preamble exchange) + +**Focus**: Multi-step async sequences, server + client coordination, +callbacks. + +**Multi-Async Step Pattern**: + +```rust +#[given("an envelope echo server")] +fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + world.start_echo_server().await?; + world.connect_client().await + }) + }) +} +``` + +**Commits**: One per world (4 commits). + +### Phase 4: Specialized Worlds (Week 8) + +**Selected Worlds**: + +1. `MessageAssemblerWorld` (header parsing) +2. `MessageAssemblyWorld` (multiplexing) +3. `CodecErrorWorld` (multi-module structure) +4. `FragmentWorld` (multi-file, 11 scenarios) + +**Focus**: Multi-file structures, high scenario counts. + +**Multi-File Pattern** (FragmentWorld): + +```rust +// tests/bdd/fixtures/fragment/ +// mod.rs - Main world struct +// reassembly.rs - Helper types + +pub mod reassembly; +use reassembly::*; + +#[derive(Debug, ScenarioState)] +pub struct FragmentWorld { + // ... fields +} + +#[fixture] +pub fn fragment_world() -> FragmentWorld { + FragmentWorld::default() +} +``` + +**Commits**: One per world (4 commits). + +### Phase 5: Validation & Cleanup (Week 9) + +**Tasks**: + +1. **Comprehensive comparison**: + + ```bash + cargo test --test cucumber > cucumber-output.txt 2>&1 + cargo test --test bdd > bdd-output.txt 2>&1 + # Compare scenario counts, all should pass + ``` + +2. **Enable strict validation**: + + ```toml + rstest-bdd-macros = { version = "0.4.0", + features = ["strict-compile-time-validation"] } + ``` + +3. **Performance check**: + + ```bash + hyperfine 'cargo test --test cucumber' \ + 'cargo test --test bdd' + # Should be within 10-20% + ``` + +4. **Remove Cucumber infrastructure**: + - Delete `tests/cucumber.rs` + - Delete `tests/worlds/` + - Delete `tests/steps/` + - Remove `cucumber = "0.21.1"` from `Cargo.toml` + - Update Makefile: `test` → `test-bdd` only + +5. **Rename structure** (optional cleanup): + + ```bash + mv tests/bdd/fixtures tests/fixtures + mv tests/bdd/steps tests/steps + mv tests/bdd/scenarios tests/scenarios + # Update imports + ``` + +**Commits**: + +- "Enable strict compile-time validation" +- "Remove Cucumber infrastructure" +- "Rename bdd structure to standard layout" + +## Migration Progress Tracking + +| Phase | Worlds | Scenarios | Status | Completion | +| ----- | ------ | --------- | -------------- | ---------- | +| 0 | - | - | Not Started | - | +| 1 | 2 | 6 | Not Started | - | +| 2 | 4 | 15 | Not Started | - | +| 3 | 4 | 20 | Not Started | - | +| 4 | 4 | 19+ | Not Started | - | +| 5 | - | - | Not Started | - | + +**Total**: 14 worlds, 60+ scenarios + +## Risk Mitigation + +### Risk 1: Async Boundary Issues + +**Mitigation**: Test `block_in_place` pattern in Phase 1 before +widespread adoption. Keep complex `ClientMessagingWorld` for Phase 3 +after validation. + +**Contingency**: Create helper async functions if `block_in_place` has +issues. + +### Risk 2: Server Spawning Conflicts + +**Mitigation**: Use `Slot` pattern, not direct fixture spawn. +Test in Phase 2 with `PanicWorld`. + +### Risk 3: Fragment.feature Complexity (11 scenarios) + +**Mitigation**: Migrate in Phase 4 after patterns proven. Can use +`scenarios!` macro if individual tests become verbose. + +### Risk 4: Compile-Time Validation False Positives + +**Mitigation**: Start with `compile-time-validation` (warnings only), +enable strict mode in Phase 5. + +### Risk 5: Migration Timeline Slippage + +**Mitigation**: Strict phase boundaries. Parallel execution allows +partial migration. Can pause after any phase. + +## Critical Files + +### Phase 0 (Foundation) + +1. `Cargo.toml` - Dependencies, test targets +2. `tests/bdd/mod.rs` - New test module root +3. `Makefile` - Test targets + +### Phase 1 (Pilot) + +1. `tests/bdd/fixtures/correlation.rs` - First fixture +2. `tests/bdd/steps/correlation_steps.rs` - First steps +3. `tests/bdd/scenarios/correlation_scenarios.rs` - First scenarios +4. `tests/bdd/fixtures/request_parts.rs` +5. `tests/bdd/steps/request_parts_steps.rs` +6. `tests/bdd/scenarios/request_parts_scenarios.rs` + +### Phase 2 (Medium Complexity) + +Panic, MultiPacket, StreamEnd, CodecStateful (fixtures, steps, +scenarios) - 12 files total + +### Phase 3 (Complex) + +ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble - 12 +files total + +### Phase 4 (Specialized) + +MessageAssembler, MessageAssembly, CodecError, Fragment - 12 files +total + +### Phase 5 (Cleanup) + +1. `Cargo.toml` - Remove cucumber dependency +2. `tests/cucumber.rs` - DELETE +3. `tests/worlds/` - DELETE (directory) +4. `tests/steps/` - DELETE (old Cucumber steps) + +## Verification + +### Per-Phase Validation + +After each phase: + +- [ ] All migrated scenarios pass: `cargo test --test bdd` +- [ ] Cucumber still works: `cargo test --test cucumber` +- [ ] No compile warnings +- [ ] Output matches Cucumber behavior +- [ ] Commit gateways pass (lint, format) + +### Final Validation (Phase 5) + +- [ ] All 60+ scenarios passing +- [ ] Strict compile-time validation enabled +- [ ] No undefined steps +- [ ] No unused step definitions +- [ ] Performance within 10-20% of Cucumber +- [ ] Cucumber infrastructure removed +- [ ] CI pipeline updated +- [ ] Documentation updated + +## Helper Utilities + +Create shared async helper: + +```rust +// tests/bdd/async_helpers.rs +/// Execute an async closure within the current tokio runtime. +pub fn run_async(f: F) -> T +where + F: std::future::Future, +{ + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(f) + }) +} + +// Usage in steps: +#[when("server starts")] +fn when_server_starts(world: &mut ServerWorld) -> TestResult { + run_async(world.start_server()) +} +``` + +## Success Criteria + +- [ ] All 14 worlds migrated to rstest-bdd fixtures +- [ ] All 60+ scenarios passing under `cargo test` +- [ ] Cucumber infrastructure removed +- [ ] Strict compile-time validation enabled +- [ ] No test coverage gaps +- [ ] Performance comparable to Cucumber +- [ ] Clean CI pipeline (single test command) +- [ ] Team onboarded to rstest-bdd patterns + +## Lessons Learned + +To be filled during implementation. + +## References + +- [rstest-bdd User's Guide](../rstest-bdd-users-guide.md) +- [ADR-003: Replace Cucumber with + rstest-bdd](../adr-003-replace-cucumber-with-rstest-bdd.md) +- [Plan Agent Output](https://claude.ai) - Agent ID: a9eb419 From 3f80e8fd6d80c7f4c46e254c4104ee30c3fd763e Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 23:35:33 +0000 Subject: [PATCH 04/45] Set up parallel rstest-bdd infrastructure Add rstest-bdd and rstest-bdd-macros dependencies with compile-time validation enabled. Create tests/bdd/ directory structure with fixtures, steps, and scenarios modules. Update Cargo.toml with bdd test target and Makefile with test-bdd and test-cucumber targets. Phase 0 complete: parallel infrastructure ready without disrupting existing Cucumber tests. --- Cargo.lock | 719 ++++++++++++++++++++++++++++++++++--- Cargo.toml | 8 + Makefile | 8 +- tests/bdd/fixtures/mod.rs | 3 + tests/bdd/mod.rs | 11 + tests/bdd/scenarios/mod.rs | 4 + tests/bdd/steps/mod.rs | 4 + 7 files changed, 713 insertions(+), 44 deletions(-) create mode 100644 tests/bdd/fixtures/mod.rs create mode 100644 tests/bdd/mod.rs create mode 100644 tests/bdd/scenarios/mod.rs create mode 100644 tests/bdd/steps/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 956033f4..32367e0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "anstream" version = "0.6.19" @@ -94,6 +106,15 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arc-swap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -113,7 +134,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -124,7 +145,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -183,6 +204,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "2.0.1" @@ -220,9 +250,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn", + "syn 2.0.104", "which", ] @@ -247,6 +277,15 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.0" @@ -275,6 +314,43 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.0.8", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "camino", + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.0.8", +] + [[package]] name = "cc" version = "1.2.30" @@ -344,7 +420,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -381,6 +457,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -397,6 +488,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -422,6 +522,26 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.104", +] + [[package]] name = "cucumber" version = "0.21.1" @@ -463,7 +583,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.104", "synthez", ] @@ -501,9 +621,11 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ + "convert_case 0.4.0", "proc-macro2", "quote", - "syn", + "rustc_version", + "syn 2.0.104", ] [[package]] @@ -523,10 +645,31 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "downcast" version = "0.11.0" @@ -585,6 +728,60 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.1", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.16", +] + [[package]] name = "fnv" version = "1.0.7" @@ -597,12 +794,29 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "fragile" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.0.8", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -665,7 +879,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -718,6 +932,16 @@ dependencies = [ "windows", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -752,7 +976,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn", + "syn 2.0.104", "textwrap", "thiserror 1.0.69", "typed-builder", @@ -825,7 +1049,18 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -960,6 +1195,53 @@ dependencies = [ "tracing", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "sys-locale", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "ignore" version = "0.4.23" @@ -992,6 +1274,25 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.20" @@ -1001,6 +1302,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "io-uring" version = "0.7.8" @@ -1088,7 +1405,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.104", ] [[package]] @@ -1199,6 +1516,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.7.5" @@ -1305,9 +1628,15 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] +[[package]] +name = "newt-hype" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8b7b69b0eafaa88ec8dc9fe7c3860af0a147517e5207cfbd0ecd21cd7cde18" + [[package]] name = "nibble_vec" version = "0.1.0" @@ -1459,7 +1788,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1522,7 +1851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.104", ] [[package]] @@ -1534,6 +1863,36 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1676,9 +2035,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -1688,9 +2047,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -1752,6 +2111,71 @@ dependencies = [ "rstest_macros 0.26.1", ] +[[package]] +name = "rstest-bdd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e741d97bce6ea0a7d0f074716041e0d0eebe6ab9edcd243e7d12f9fd2b5e8b6" +dependencies = [ + "ctor", + "derive_more 0.99.20", + "fluent", + "gherkin", + "hashbrown 0.16.1", + "i18n-embed", + "inventory", + "log", + "regex", + "rstest-bdd-patterns", + "rstest-bdd-policy", + "rust-embed", + "serde", + "serde_json", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "rstest-bdd-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e3b51c032a6174f82d87843a5e62cfd08ce4606005eafde07ed12561d73196" +dependencies = [ + "camino", + "cap-std", + "cfg-if", + "convert_case 0.6.0", + "gherkin", + "newt-hype", + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "rstest-bdd-patterns", + "rstest-bdd-policy", + "syn 2.0.104", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "rstest-bdd-patterns" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75d730afd8727e5b18bd276ebf5ea4c881760138762d0cd7d415dc6b6662c6c6" +dependencies = [ + "gherkin", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "rstest-bdd-policy" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d256efeb01f08e281cef9cd1e54dab33d8778e871090f5fa49b7a9a70f0caf81" + [[package]] name = "rstest_macros" version = "0.18.2" @@ -1765,7 +2189,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.104", "unicode-ident", ] @@ -1783,10 +2207,44 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.104", "unicode-ident", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.104", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1799,6 +2257,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1834,6 +2298,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.0.8", +] + [[package]] name = "rustls" version = "0.23.29" @@ -1959,7 +2433,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1985,6 +2459,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.26" @@ -1993,22 +2473,32 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2045,7 +2535,18 @@ checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -2098,7 +2599,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2145,6 +2646,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.104" @@ -2162,7 +2673,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" dependencies = [ - "syn", + "syn 2.0.104", "synthez-codegen", "synthez-core", ] @@ -2173,7 +2684,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" dependencies = [ - "syn", + "syn 2.0.104", "synthez-core", ] @@ -2186,7 +2697,16 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn", + "syn 2.0.104", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", ] [[package]] @@ -2255,7 +2775,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2266,7 +2786,7 @@ checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2278,6 +2798,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tokio" version = "1.47.1" @@ -2305,7 +2836,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2343,6 +2874,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2386,7 +2926,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2446,7 +2986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2455,6 +2995,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typed-builder" version = "0.15.2" @@ -2472,15 +3021,65 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.104", + "unic-langid-impl", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2493,6 +3092,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.1" @@ -2611,7 +3216,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-shared", ] @@ -2633,7 +3238,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2754,7 +3359,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2765,7 +3370,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2975,6 +3580,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + [[package]] name = "wireframe" version = "0.2.0" @@ -2998,6 +3613,8 @@ dependencies = [ "mockall", "proptest", "rstest 0.26.1", + "rstest-bdd", + "rstest-bdd-macros", "serde", "serial_test", "socket2 0.6.0", @@ -3055,11 +3672,27 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "zerofrom", +] diff --git a/Cargo.toml b/Cargo.toml index 7d889699..9da3b1ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ socket2 = "0.6.0" [dev-dependencies] rstest = "0.26.1" +rstest-bdd = "0.4.0" +rstest-bdd-macros = { version = "0.4.0", features = ["compile-time-validation"] } wireframe = { path = ".", features = ["test-helpers"] } wireframe_testing = { path = "./wireframe_testing" } logtest = "2.0.0" @@ -186,6 +188,12 @@ name = "cucumber" harness = false required-features = ["advanced-tests", "cucumber-tests"] +# rstest-bdd behavioural tests use standard test harness +[[test]] +name = "bdd" +path = "tests/bdd/mod.rs" +required-features = ["advanced-tests"] + [[test]] name = "concurrency_loom" path = "tests/advanced/concurrency_loom.rs" diff --git a/Makefile b/Makefile index ee7cfedc..762f795d 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,13 @@ all: release ## Default target builds release binary clean: ## Remove build artifacts $(CARGO) clean -test: ## Run tests with warnings treated as errors +test-bdd: ## Run rstest-bdd tests only + RUSTFLAGS="-D warnings" $(CARGO) test --test bdd --all-features $(BUILD_JOBS) + +test-cucumber: ## Run Cucumber tests only + RUSTFLAGS="-D warnings" $(CARGO) test --test cucumber --features advanced-tests,cucumber-tests $(BUILD_JOBS) + +test: test-bdd test-cucumber ## Run all tests (both bdd and cucumber) RUSTFLAGS="-D warnings" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) # will match target/debug/libmy_library.rlib and target/release/libmy_library.rlib diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs new file mode 100644 index 00000000..e0f7056e --- /dev/null +++ b/tests/bdd/fixtures/mod.rs @@ -0,0 +1,3 @@ +//! Fixture definitions for rstest-bdd tests. +//! +//! Each world from the Cucumber tests is converted to an rstest fixture here. diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs new file mode 100644 index 00000000..5b64bc67 --- /dev/null +++ b/tests/bdd/mod.rs @@ -0,0 +1,11 @@ +#![cfg(not(loom))] +//! rstest-bdd behavioural tests. +//! +//! This module contains the rstest-bdd-based BDD tests that are gradually +//! replacing the Cucumber test suite. These tests use the same `.feature` +//! files as the Cucumber tests but execute under the standard `cargo test` +//! harness with rstest fixtures. + +mod fixtures; +mod scenarios; +mod steps; diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs new file mode 100644 index 00000000..61f1ab0d --- /dev/null +++ b/tests/bdd/scenarios/mod.rs @@ -0,0 +1,4 @@ +//! Scenario test functions for rstest-bdd. +//! +//! Each scenario from the `.feature` files has a corresponding `#[scenario]` +//! test function here. diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs new file mode 100644 index 00000000..2aff4de7 --- /dev/null +++ b/tests/bdd/steps/mod.rs @@ -0,0 +1,4 @@ +//! Step definitions for rstest-bdd tests. +//! +//! Step functions are synchronous and call async world methods via +//! `tokio::task::block_in_place`. From 15ea78a1764ef65945e8cb23504cd3212249288a Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 23:43:20 +0000 Subject: [PATCH 05/45] Migrate CorrelationWorld to rstest-bdd Convert CorrelationWorld from Cucumber to rstest-bdd: - Create correlation fixture with rstest #[fixture] annotation - Migrate step definitions from async to sync with separate Runtime - Create scenario tests (sync, not async) - All 3 correlation scenarios passing Key discovery: Scenarios must be sync (not async) to allow steps to create their own tokio::Runtime for async operations. The async scenario approach from the plan doesn't work due to 'Cannot start runtime within runtime' error. Steps create Runtime::new() and use block_on() instead. --- tests/bdd/fixtures/correlation.rs | 115 +++++++++++++++++++ tests/bdd/fixtures/mod.rs | 2 + tests/bdd/mod.rs | 16 +++ tests/bdd/scenarios/correlation_scenarios.rs | 29 +++++ tests/bdd/scenarios/mod.rs | 2 + tests/bdd/steps/correlation_steps.rs | 51 ++++++++ tests/bdd/steps/mod.rs | 2 + tests/common/mod.rs | 1 + 8 files changed, 218 insertions(+) create mode 100644 tests/bdd/fixtures/correlation.rs create mode 100644 tests/bdd/scenarios/correlation_scenarios.rs create mode 100644 tests/bdd/steps/correlation_steps.rs diff --git a/tests/bdd/fixtures/correlation.rs b/tests/bdd/fixtures/correlation.rs new file mode 100644 index 00000000..0fbf5466 --- /dev/null +++ b/tests/bdd/fixtures/correlation.rs @@ -0,0 +1,115 @@ +//! CorrelationWorld fixture for rstest-bdd tests. +//! +//! Converted from Cucumber World to rstest fixture. The struct and its methods +//! remain largely unchanged; only the trait derivation and fixture function are +//! added. + +use async_stream::try_stream; +use rstest::fixture; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use wireframe::{ + app::Envelope, + connection::ConnectionActor, + correlation::CorrelatableFrame, + response::FrameStream, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +// Import build_small_queues from parent module +use crate::build_small_queues; + +#[derive(Debug, Default)] +/// Test world capturing correlation expectations for frame emission. +pub struct CorrelationWorld { + expected: Option, + frames: Vec, +} + +#[fixture] +pub fn correlation_world() -> CorrelationWorld { + CorrelationWorld::default() +} + +impl CorrelationWorld { + /// Record the correlation identifier expected on emitted frames. + pub fn set_expected(&mut self, expected: Option) { + self.expected = expected; + } + + /// Return the correlation identifier configured for this scenario. + #[must_use] + pub fn expected(&self) -> Option { + self.expected + } + + /// Run the connection actor and collect frames for later verification. + /// + /// # Errors + /// Returns an error if the expected correlation id is absent or if running + /// the actor fails. + pub async fn process(&mut self) -> TestResult { + let cid = self + .expected + .ok_or("streaming scenario requires a correlation id")?; + let stream: FrameStream = Box::pin(try_stream! { + yield Envelope::new(1, Some(cid), vec![1]); + yield Envelope::new(1, Some(cid), vec![2]); + }); + let (queues, handle) = build_small_queues::()?; + let shutdown = CancellationToken::new(); + let mut actor = ConnectionActor::new(queues, handle, Some(stream), shutdown); + actor + .run(&mut self.frames) + .await + .map_err(|e| format!("actor run failed: {e:?}"))?; + Ok(()) + } + + /// Run the connection actor for a multi-packet channel and collect frames. + /// + /// # Errors + /// Returns an error if sending frames or running the actor fails. + pub async fn process_multi(&mut self) -> TestResult { + let expected = self.expected; + let (tx, rx) = mpsc::channel(4); + tx.send(Envelope::new(1, None, vec![1])).await?; + tx.send(Envelope::new(1, Some(99), vec![2])).await?; + drop(tx); + + let (queues, handle) = build_small_queues::()?; + let shutdown = CancellationToken::new(); + let mut actor: ConnectionActor = + ConnectionActor::new(queues, handle, None, shutdown); + actor.set_multi_packet_with_correlation(Some(rx), expected); + actor + .run(&mut self.frames) + .await + .map_err(|e| format!("actor run failed: {e:?}"))?; + Ok(()) + } + + /// Verify that all received frames respect the configured correlation + /// expectation. + /// + /// # Errors + /// Returns an error if any frame violates the stored correlation + /// expectation. + pub fn verify(&self) -> TestResult { + let ok = match self.expected { + Some(cid) => self.frames.iter().all(|f| f.correlation_id() == Some(cid)), + None => self.frames.iter().all(|f| f.correlation_id().is_none()), + }; + + if ok { + return Ok(()); + } + + match self.expected { + Some(cid) => Err(format!("frames missing expected correlation id {cid}").into()), + None => Err("frames unexpectedly carried correlation id".into()), + } + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index e0f7056e..78bd8791 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -1,3 +1,5 @@ //! Fixture definitions for rstest-bdd tests. //! //! Each world from the Cucumber tests is converted to an rstest fixture here. + +pub mod correlation; diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs index 5b64bc67..90265561 100644 --- a/tests/bdd/mod.rs +++ b/tests/bdd/mod.rs @@ -6,6 +6,22 @@ //! files as the Cucumber tests but execute under the standard `cargo test` //! harness with rstest fixtures. +// Re-export common utilities from the parent tests directory +#[path = "../common/mod.rs"] +pub mod common; + +#[path = "../support.rs"] +mod support; + +use wireframe::{app::Envelope, push::PushQueues, serializer::BincodeSerializer}; + +pub(crate) type TestApp = wireframe::app::WireframeApp; + +pub(crate) fn build_small_queues() +-> Result<(PushQueues, wireframe::push::PushHandle), wireframe::push::PushConfigError> { + support::builder::().unlimited().build() +} + mod fixtures; mod scenarios; mod steps; diff --git a/tests/bdd/scenarios/correlation_scenarios.rs b/tests/bdd/scenarios/correlation_scenarios.rs new file mode 100644 index 00000000..42347442 --- /dev/null +++ b/tests/bdd/scenarios/correlation_scenarios.rs @@ -0,0 +1,29 @@ +//! Scenario tests for correlation_id feature. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::correlation::*; + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Streamed frames reuse the request correlation id" +)] +fn streamed_frames_correlation(correlation_world: CorrelationWorld) { + let _ = correlation_world; +} + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses reuse the request correlation id" +)] +fn multi_packet_correlation(correlation_world: CorrelationWorld) { + let _ = correlation_world; +} + +#[scenario( + path = "tests/features/correlation_id.feature", + name = "Multi-packet responses clear correlation ids without a request id" +)] +fn no_correlation(correlation_world: CorrelationWorld) { + let _ = correlation_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 61f1ab0d..aed69f97 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -2,3 +2,5 @@ //! //! Each scenario from the `.feature` files has a corresponding `#[scenario]` //! test function here. + +mod correlation_scenarios; diff --git a/tests/bdd/steps/correlation_steps.rs b/tests/bdd/steps/correlation_steps.rs new file mode 100644 index 00000000..60f5e809 --- /dev/null +++ b/tests/bdd/steps/correlation_steps.rs @@ -0,0 +1,51 @@ +//! Step definitions for correlation_id behavioural tests. +//! +//! Steps are synchronous but call async World methods via +//! `Handle::current().block_on()` (current_thread runtime doesn't support +//! block_in_place). + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::correlation::{CorrelationWorld, TestResult}; + +#[given("a correlation id {id:u64}")] +fn given_cid(correlation_world: &mut CorrelationWorld, id: u64) { + correlation_world.set_expected(Some(id)); +} + +#[given("no correlation id")] +fn given_no_correlation(correlation_world: &mut CorrelationWorld) { + correlation_world.set_expected(None); +} + +#[when("a stream of frames is processed")] +fn when_process(correlation_world: &mut CorrelationWorld) -> TestResult { + // Create a new runtime for this step since we can't block_on within an + // existing runtime + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(correlation_world.process()) +} + +#[when("a multi-packet channel emits frames")] +fn when_process_multi(correlation_world: &mut CorrelationWorld) -> TestResult { + // Create a new runtime for this step since we can't block_on within an + // existing runtime + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(correlation_world.process_multi()) +} + +#[then("each emitted frame uses correlation id {id:u64}")] +fn then_verify(correlation_world: &mut CorrelationWorld, id: u64) -> TestResult { + if correlation_world.expected() != Some(id) { + return Err("mismatched expected correlation id".into()); + } + correlation_world.verify() +} + +#[then("each emitted frame has no correlation id")] +fn then_verify_absent(correlation_world: &mut CorrelationWorld) -> TestResult { + if correlation_world.expected().is_some() { + return Err("expected correlation id should be cleared".into()); + } + correlation_world.verify() +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 2aff4de7..d2e9274f 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -2,3 +2,5 @@ //! //! Step functions are synchronous and call async world methods via //! `tokio::task::block_in_place`. + +mod correlation_steps; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7c19104d..186d2c8b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -91,6 +91,7 @@ pub type TestApp = wireframe::app::WireframeApp /// Shared result type for cucumber step implementations. pub type TestResult = Result>; +/// Default WireframeApp factory for integration tests. #[fixture] pub fn factory() -> impl Fn() -> TestApp + Send + Sync + Clone + 'static { fn build() -> TestApp { TestApp::default() } From 2570999703df5ad0dda0fd1b4f05251efd0560b6 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 23:44:10 +0000 Subject: [PATCH 06/45] Document async pattern discovery in migration plan Update execplan with critical discovery from CorrelationWorld migration: - Scenarios must be sync (not async) to avoid runtime-within-runtime error - Steps create Runtime::new() for async operations - Remove all #[tokio::test] references from plan examples - Update progress: Phase 0 complete, Phase 1 in progress (1/2 done) --- .../migrate-from-cucumber-to-rstest-bdd.md | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index cdac8f62..a47e6a18 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -477,8 +477,8 @@ pub fn fragment_world() -> FragmentWorld { | Phase | Worlds | Scenarios | Status | Completion | | ----- | ------ | --------- | -------------- | ---------- | -| 0 | - | - | Not Started | - | -| 1 | 2 | 6 | Not Started | - | +| 0 | - | - | Complete | 2026-01-22 | +| 1 | 2 | 6 | In Progress | 1/2 done | | 2 | 4 | 15 | Not Started | - | | 3 | 4 | 20 | Not Started | - | | 4 | 4 | 19+ | Not Started | - | @@ -615,7 +615,54 @@ fn when_server_starts(world: &mut ServerWorld) -> TestResult { ## Lessons Learned -To be filled during implementation. +### Phase 1: CorrelationWorld Migration (Completed) + +**CRITICAL DISCOVERY**: The async scenario approach outlined in the plan does +NOT work with rstest-bdd v0.4.0's current implementation. + +**The Problem**: +- Scenarios marked with `#[tokio::test(flavor = "current_thread")] async fn` + create a tokio runtime +- Steps are sync and must remain sync (documented limitation) +- Attempting to call `tokio::runtime::Handle::current().block_on()` from + within a step fails with "Cannot start runtime within runtime" +- Attempting to create `Runtime::new()` in a step also fails when the + scenario itself is async + +**The Solution**: +- Scenarios must be **sync functions** (remove `async fn` and + `#[tokio::test]`) +- Steps remain sync and create their own `Runtime::new()` for async + operations +- Pattern: `let rt = tokio::runtime::Runtime::new()?; rt.block_on(async_fn())` + +**Updated Async Pattern**: + +```rust +// Scenario: SYNC (not async!) +#[scenario(path = "tests/features/correlation_id.feature", + name = "Streamed frames reuse the request correlation id")] +fn streamed_frames_correlation(correlation_world: CorrelationWorld) { + let _ = correlation_world; +} + +// Step: Sync, creates own runtime +#[when("a stream of frames is processed")] +fn when_process(correlation_world: &mut CorrelationWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(correlation_world.process()) +} +``` + +**Impact on Migration Plan**: +- All references to `#[tokio::test(flavor = "current_thread")]` in scenario + examples should be removed +- All scenario functions are sync, not async +- `tokio::task::block_in_place` is NOT needed and won't work +- Each async step creates its own `Runtime::new()` and uses `block_on()` + +**Validation**: CorrelationWorld migration complete with all 3 scenarios +passing, verified against Cucumber output. ## References From 154e5c80910a9774a0ff2e520ded44f0e20f288e Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 23:56:15 +0000 Subject: [PATCH 07/45] Migrate RequestPartsWorld from Cucumber to rstest-bdd Complete Phase 1 of the migration plan with the second pilot world. This migration demonstrates the pattern for synchronous worlds without async operations. ## Changes - Created `tests/bdd/fixtures/request_parts.rs` with `RequestPartsWorld` fixture - Created `tests/bdd/steps/request_parts_steps.rs` with all step definitions - Created `tests/bdd/scenarios/request_parts_scenarios.rs` with 6 scenarios - Updated module files to include new request_parts modules - Fixed doc_markdown clippy lints across all BDD files - Added module-level `#[expect(unused_braces)]` to fixture files to suppress clippy/rustfmt conflict on single-line fixture functions ## Test Results All 6 request_parts scenarios pass, matching Cucumber output: - Create request parts with all fields - Request parts inherit missing correlation id - Request parts override mismatched correlation id - Request parts preserve correlation when source is absent - Empty metadata is valid - Metadata can be modified after construction Both frameworks report: 6 scenarios (6 passed), 20 steps (20 passed) ## Migration Notes RequestPartsWorld is purely synchronous with no async operations, so no Runtime creation is needed in steps. All step functions are simple sync wrappers around World methods. Phase 1 is now complete. Ready to proceed with Phase 2. --- .../migrate-from-cucumber-to-rstest-bdd.md | 3 + tests/bdd/fixtures/correlation.rs | 21 ++--- tests/bdd/fixtures/mod.rs | 1 + tests/bdd/fixtures/request_parts.rs | 93 +++++++++++++++++++ tests/bdd/mod.rs | 1 + tests/bdd/scenarios/correlation_scenarios.rs | 14 +-- tests/bdd/scenarios/mod.rs | 1 + .../bdd/scenarios/request_parts_scenarios.rs | 49 ++++++++++ tests/bdd/steps/correlation_steps.rs | 6 +- tests/bdd/steps/mod.rs | 1 + tests/bdd/steps/request_parts_steps.rs | 83 +++++++++++++++++ tests/common/mod.rs | 7 +- 12 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 tests/bdd/fixtures/request_parts.rs create mode 100644 tests/bdd/scenarios/request_parts_scenarios.rs create mode 100644 tests/bdd/steps/request_parts_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index a47e6a18..e1878939 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -621,6 +621,7 @@ fn when_server_starts(world: &mut ServerWorld) -> TestResult { NOT work with rstest-bdd v0.4.0's current implementation. **The Problem**: + - Scenarios marked with `#[tokio::test(flavor = "current_thread")] async fn` create a tokio runtime - Steps are sync and must remain sync (documented limitation) @@ -630,6 +631,7 @@ NOT work with rstest-bdd v0.4.0's current implementation. scenario itself is async **The Solution**: + - Scenarios must be **sync functions** (remove `async fn` and `#[tokio::test]`) - Steps remain sync and create their own `Runtime::new()` for async @@ -655,6 +657,7 @@ fn when_process(correlation_world: &mut CorrelationWorld) -> TestResult { ``` **Impact on Migration Plan**: + - All references to `#[tokio::test(flavor = "current_thread")]` in scenario examples should be removed - All scenario functions are sync, not async diff --git a/tests/bdd/fixtures/correlation.rs b/tests/bdd/fixtures/correlation.rs index 0fbf5466..a37c077e 100644 --- a/tests/bdd/fixtures/correlation.rs +++ b/tests/bdd/fixtures/correlation.rs @@ -1,9 +1,11 @@ -//! CorrelationWorld fixture for rstest-bdd tests. +//! `CorrelationWorld` fixture for rstest-bdd tests. //! //! Converted from Cucumber World to rstest fixture. The struct and its methods //! remain largely unchanged; only the trait derivation and fixture function are //! added. +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + use async_stream::try_stream; use rstest::fixture; use tokio::sync::mpsc; @@ -15,11 +17,10 @@ use wireframe::{ response::FrameStream, }; -// Re-export TestResult from common for use in steps -pub use crate::common::TestResult; - // Import build_small_queues from parent module use crate::build_small_queues; +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; #[derive(Debug, Default)] /// Test world capturing correlation expectations for frame emission. @@ -29,21 +30,15 @@ pub struct CorrelationWorld { } #[fixture] -pub fn correlation_world() -> CorrelationWorld { - CorrelationWorld::default() -} +pub fn correlation_world() -> CorrelationWorld { CorrelationWorld::default() } impl CorrelationWorld { /// Record the correlation identifier expected on emitted frames. - pub fn set_expected(&mut self, expected: Option) { - self.expected = expected; - } + pub fn set_expected(&mut self, expected: Option) { self.expected = expected; } /// Return the correlation identifier configured for this scenario. #[must_use] - pub fn expected(&self) -> Option { - self.expected - } + pub fn expected(&self) -> Option { self.expected } /// Run the connection actor and collect frames for later verification. /// diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 78bd8791..ffca0c87 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -3,3 +3,4 @@ //! Each world from the Cucumber tests is converted to an rstest fixture here. pub mod correlation; +pub mod request_parts; diff --git a/tests/bdd/fixtures/request_parts.rs b/tests/bdd/fixtures/request_parts.rs new file mode 100644 index 00000000..7078aad6 --- /dev/null +++ b/tests/bdd/fixtures/request_parts.rs @@ -0,0 +1,93 @@ +//! `RequestPartsWorld` fixture for rstest-bdd tests. +//! +//! Converted from Cucumber World to rstest fixture. The struct and its methods +//! remain unchanged; only the trait derivation and fixture function are added. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use rstest::fixture; +use wireframe::request::RequestParts; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +#[derive(Debug, Default)] +/// Test world exercising `RequestParts` metadata handling. +pub struct RequestPartsWorld { + parts: Option, +} + +#[fixture] +pub fn request_parts_world() -> RequestPartsWorld { RequestPartsWorld::default() } + +impl RequestPartsWorld { + /// Create request parts with all fields specified. + pub fn create_parts(&mut self, id: u32, correlation_id: Option, metadata: Vec) { + self.parts = Some(RequestParts::new(id, correlation_id, metadata)); + } + + /// Inherit a correlation id from an external source. + /// + /// # Errors + /// Returns an error if parts have not been created. + pub fn inherit_correlation(&mut self, source: Option) -> TestResult { + let parts = self.parts.take().ok_or("request parts not created")?; + self.parts = Some(parts.inherit_correlation(source)); + Ok(()) + } + + /// Append a byte to the metadata. + /// + /// # Errors + /// Returns an error if parts have not been created. + pub fn append_metadata_byte(&mut self, byte: u8) -> TestResult { + let parts = self.parts.as_mut().ok_or("request parts not created")?; + parts.metadata_mut().push(byte); + Ok(()) + } + + /// Assert the request id matches the expected value. + /// + /// # Errors + /// Returns an error if parts are missing or id does not match. + pub fn assert_id(&self, expected: u32) -> TestResult { + let parts = self.parts.as_ref().ok_or("request parts not created")?; + if parts.id() != expected { + return Err(format!("expected id {expected}, got {}", parts.id()).into()); + } + Ok(()) + } + + /// Assert the correlation id matches the expected value. + /// + /// # Errors + /// Returns an error if parts are missing or correlation id does not match. + pub fn assert_correlation_id(&self, expected: Option) -> TestResult { + let parts = self.parts.as_ref().ok_or("request parts not created")?; + if parts.correlation_id() != expected { + return Err(format!( + "expected correlation_id {:?}, got {:?}", + expected, + parts.correlation_id() + ) + .into()); + } + Ok(()) + } + + /// Assert the metadata length matches the expected value. + /// + /// # Errors + /// Returns an error if parts are missing or length does not match. + pub fn assert_metadata_length(&self, expected: usize) -> TestResult { + let parts = self.parts.as_ref().ok_or("request parts not created")?; + if parts.metadata().len() != expected { + return Err(format!( + "expected metadata length {expected}, got {}", + parts.metadata().len() + ) + .into()); + } + Ok(()) + } +} diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs index 90265561..e58b8e7e 100644 --- a/tests/bdd/mod.rs +++ b/tests/bdd/mod.rs @@ -15,6 +15,7 @@ mod support; use wireframe::{app::Envelope, push::PushQueues, serializer::BincodeSerializer}; +#[expect(dead_code, reason = "shared type not used by all test scenarios yet")] pub(crate) type TestApp = wireframe::app::WireframeApp; pub(crate) fn build_small_queues() diff --git a/tests/bdd/scenarios/correlation_scenarios.rs b/tests/bdd/scenarios/correlation_scenarios.rs index 42347442..aed524d0 100644 --- a/tests/bdd/scenarios/correlation_scenarios.rs +++ b/tests/bdd/scenarios/correlation_scenarios.rs @@ -1,4 +1,4 @@ -//! Scenario tests for correlation_id feature. +//! Scenario tests for `correlation_id` feature. use rstest_bdd_macros::scenario; @@ -8,22 +8,16 @@ use crate::fixtures::correlation::*; path = "tests/features/correlation_id.feature", name = "Streamed frames reuse the request correlation id" )] -fn streamed_frames_correlation(correlation_world: CorrelationWorld) { - let _ = correlation_world; -} +fn streamed_frames_correlation(correlation_world: CorrelationWorld) { let _ = correlation_world; } #[scenario( path = "tests/features/correlation_id.feature", name = "Multi-packet responses reuse the request correlation id" )] -fn multi_packet_correlation(correlation_world: CorrelationWorld) { - let _ = correlation_world; -} +fn multi_packet_correlation(correlation_world: CorrelationWorld) { let _ = correlation_world; } #[scenario( path = "tests/features/correlation_id.feature", name = "Multi-packet responses clear correlation ids without a request id" )] -fn no_correlation(correlation_world: CorrelationWorld) { - let _ = correlation_world; -} +fn no_correlation(correlation_world: CorrelationWorld) { let _ = correlation_world; } diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index aed69f97..78e88865 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -4,3 +4,4 @@ //! test function here. mod correlation_scenarios; +mod request_parts_scenarios; diff --git a/tests/bdd/scenarios/request_parts_scenarios.rs b/tests/bdd/scenarios/request_parts_scenarios.rs new file mode 100644 index 00000000..9b90f9dc --- /dev/null +++ b/tests/bdd/scenarios/request_parts_scenarios.rs @@ -0,0 +1,49 @@ +//! Scenario tests for `request_parts` feature. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::request_parts::*; + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Create request parts with all fields" +)] +fn create_parts_with_all_fields(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Request parts inherit missing correlation id" +)] +fn inherit_missing_correlation(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Request parts override mismatched correlation id" +)] +fn override_mismatched_correlation(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Request parts preserve correlation when source is absent" +)] +fn preserve_correlation_when_absent(request_parts_world: RequestPartsWorld) { + let _ = request_parts_world; +} + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Empty metadata is valid" +)] +fn empty_metadata_valid(request_parts_world: RequestPartsWorld) { let _ = request_parts_world; } + +#[scenario( + path = "tests/features/request_parts.feature", + name = "Metadata can be modified after construction" +)] +fn metadata_modifiable(request_parts_world: RequestPartsWorld) { let _ = request_parts_world; } diff --git a/tests/bdd/steps/correlation_steps.rs b/tests/bdd/steps/correlation_steps.rs index 60f5e809..61c80227 100644 --- a/tests/bdd/steps/correlation_steps.rs +++ b/tests/bdd/steps/correlation_steps.rs @@ -1,8 +1,8 @@ -//! Step definitions for correlation_id behavioural tests. +//! Step definitions for `correlation_id` behavioural tests. //! //! Steps are synchronous but call async World methods via -//! `Handle::current().block_on()` (current_thread runtime doesn't support -//! block_in_place). +//! `Handle::current().block_on()` (`current_thread` runtime doesn't support +//! `block_in_place`). use rstest_bdd_macros::{given, then, when}; diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index d2e9274f..be79f753 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -4,3 +4,4 @@ //! `tokio::task::block_in_place`. mod correlation_steps; +mod request_parts_steps; diff --git a/tests/bdd/steps/request_parts_steps.rs b/tests/bdd/steps/request_parts_steps.rs new file mode 100644 index 00000000..7f5cfd40 --- /dev/null +++ b/tests/bdd/steps/request_parts_steps.rs @@ -0,0 +1,83 @@ +//! Step definitions for `request_parts` behavioural tests. +//! +//! All steps are synchronous. No async operations are needed for this world. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::request_parts::{RequestPartsWorld, TestResult}; + +#[given("request parts with id {id:u32} and correlation id {cid:u64}")] +fn given_parts_with_correlation(request_parts_world: &mut RequestPartsWorld, id: u32, cid: u64) { + request_parts_world.create_parts(id, Some(cid), vec![]); +} + +#[given("request parts with id {id:u32} and no correlation id")] +fn given_parts_no_correlation(request_parts_world: &mut RequestPartsWorld, id: u32) { + request_parts_world.create_parts(id, None, vec![]); +} + +// Deliberately duplicates `given_parts_no_correlation` to provide distinct +// Gherkin phrasing: scenarios that later add metadata use the shorter form, +// while this form explicitly states the empty-metadata precondition. +#[given("request parts with id {id:u32}, no correlation id, and empty metadata")] +fn given_parts_empty_metadata(request_parts_world: &mut RequestPartsWorld, id: u32) { + request_parts_world.create_parts(id, None, vec![]); +} + +#[given("metadata bytes {b1:u8}, {b2:u8}, {b3:u8}")] +fn given_metadata_bytes_three( + request_parts_world: &mut RequestPartsWorld, + b1: u8, + b2: u8, + b3: u8, +) -> TestResult { + request_parts_world.append_metadata_byte(b1)?; + request_parts_world.append_metadata_byte(b2)?; + request_parts_world.append_metadata_byte(b3) +} + +#[given("metadata byte {byte:u8}")] +fn given_metadata_byte(request_parts_world: &mut RequestPartsWorld, byte: u8) -> TestResult { + request_parts_world.append_metadata_byte(byte) +} + +#[when("inheriting correlation id {cid:u64}")] +fn when_inherit_correlation(request_parts_world: &mut RequestPartsWorld, cid: u64) -> TestResult { + request_parts_world.inherit_correlation(Some(cid)) +} + +#[when("inheriting no correlation id")] +fn when_inherit_no_correlation(request_parts_world: &mut RequestPartsWorld) -> TestResult { + request_parts_world.inherit_correlation(None) +} + +#[when("appending byte {byte:u8} to metadata")] +fn when_append_metadata(request_parts_world: &mut RequestPartsWorld, byte: u8) -> TestResult { + request_parts_world.append_metadata_byte(byte) +} + +#[then("the request id is {expected:u32}")] +fn then_id_is(request_parts_world: &mut RequestPartsWorld, expected: u32) -> TestResult { + request_parts_world.assert_id(expected) +} + +#[then("the correlation id is {expected:u64}")] +fn then_correlation_id_is( + request_parts_world: &mut RequestPartsWorld, + expected: u64, +) -> TestResult { + request_parts_world.assert_correlation_id(Some(expected)) +} + +#[then("the correlation id is absent")] +fn then_correlation_id_is_absent(request_parts_world: &mut RequestPartsWorld) -> TestResult { + request_parts_world.assert_correlation_id(None) +} + +#[then("the metadata length is {expected:usize}")] +fn then_metadata_length_is( + request_parts_world: &mut RequestPartsWorld, + expected: usize, +) -> TestResult { + request_parts_world.assert_metadata_length(expected) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 186d2c8b..29c6bc96 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -39,10 +39,15 @@ use wireframe::{ }; /// Create a TCP listener bound to a free local port. +/// +/// # Panics +/// Panics if unable to bind to an ephemeral localhost port, which should never +/// happen in a well-configured test environment. #[expect( clippy::expect_used, reason = "binding to an ephemeral localhost port must abort the test immediately" )] +#[must_use] pub fn unused_listener() -> StdTcpListener { let addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0); StdTcpListener::bind(addr).expect("failed to bind port") @@ -91,7 +96,7 @@ pub type TestApp = wireframe::app::WireframeApp /// Shared result type for cucumber step implementations. pub type TestResult = Result>; -/// Default WireframeApp factory for integration tests. +/// Default `WireframeApp` factory for integration tests. #[fixture] pub fn factory() -> impl Fn() -> TestApp + Send + Sync + Clone + 'static { fn build() -> TestApp { TestApp::default() } From 91b9f183638928082986d5e72af34eed8c3508b8 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 22 Jan 2026 23:57:28 +0000 Subject: [PATCH 08/45] Update migration plan: Phase 1 complete --- .../migrate-from-cucumber-to-rstest-bdd.md | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index e1878939..7634e4c6 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -320,8 +320,11 @@ cargo test --test bdd correlation **Commits**: -- "Migrate CorrelationWorld to rstest-bdd" -- "Migrate RequestPartsWorld to rstest-bdd" +- ✅ "Migrate CorrelationWorld to rstest-bdd" (commit 8ce5b55) +- ✅ "Migrate RequestPartsWorld to rstest-bdd" (commit 154e5c8) + +**Status**: ✅ **COMPLETE** - Both pilot worlds successfully migrated and all +tests passing. ### Phase 2: Medium Complexity Worlds (Weeks 4-5) @@ -667,6 +670,34 @@ fn when_process(correlation_world: &mut CorrelationWorld) -> TestResult { **Validation**: CorrelationWorld migration complete with all 3 scenarios passing, verified against Cucumber output. +### Phase 1: RequestPartsWorld Migration (Completed) + +**Date**: 2026-01-22 + +**Migration**: `RequestPartsWorld` demonstrates the pattern for purely +synchronous worlds with no async operations. + +**Key Patterns**: + +- Synchronous world with no async methods +- Step functions are simple sync wrappers (no `Runtime::new()` needed) +- All 6 scenarios migrated successfully + +**Test Results**: + +- Cucumber: 6 scenarios (6 passed), 20 steps (20 passed) +- rstest-bdd: 6 scenarios (6 passed), 20 steps (20 passed) + +**Additional Fixes**: + +- Added module-level `#[expect(unused_braces)]` to fixture files to suppress + clippy/rustfmt conflict +- Fixed `doc_markdown` clippy lints across all BDD files +- Added `#[must_use]` and `# Panics` documentation to `unused_listener()` + +**Validation**: RequestPartsWorld migration complete with all 6 scenarios +passing. Phase 1 is complete. + ## References - [rstest-bdd User's Guide](../rstest-bdd-users-guide.md) From 33b74596bd283a6aff5891b72a2714e3189d51c2 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 23 Jan 2026 02:04:48 +0000 Subject: [PATCH 09/45] Migrate PanicWorld from Cucumber to rstest-bdd Begin Phase 2 with the first medium-complexity world. PanicWorld demonstrates the server spawning pattern with panic resilience testing. ## Changes - Created `tests/bdd/fixtures/panic.rs` with `PanicWorld` and `PanicServer` helper - Created `tests/bdd/steps/panic_steps.rs` with async step definitions using `Runtime::new()` - Created `tests/bdd/scenarios/panic_scenarios.rs` with 1 scenario - Updated module files to include new panic modules ## Test Results The panic resilience scenario passes, matching Cucumber output: - Cucumber: 1 scenario (1 passed), 4 steps (4 passed) - rstest-bdd: 1 scenario (1 passed), 4 steps (4 passed) ## Migration Notes PanicWorld demonstrates: - Server spawning with `WireframeServer` and custom panic handler - Helper struct (`PanicServer`) with Drop implementation for cleanup - Async world methods requiring `Runtime::new()` in steps - Connection counting across multiple "When" steps All tests passing. First world of Phase 2 complete. --- tests/bdd/fixtures/mod.rs | 1 + tests/bdd/fixtures/panic.rs | 129 +++++++++++++++++++++++++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/scenarios/panic_scenarios.rs | 11 +++ tests/bdd/steps/mod.rs | 1 + tests/bdd/steps/panic_steps.rs | 28 ++++++ 6 files changed, 171 insertions(+) create mode 100644 tests/bdd/fixtures/panic.rs create mode 100644 tests/bdd/scenarios/panic_scenarios.rs create mode 100644 tests/bdd/steps/panic_steps.rs diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index ffca0c87..57c38a2f 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -3,4 +3,5 @@ //! Each world from the Cucumber tests is converted to an rstest fixture here. pub mod correlation; +pub mod panic; pub mod request_parts; diff --git a/tests/bdd/fixtures/panic.rs b/tests/bdd/fixtures/panic.rs new file mode 100644 index 00000000..1ea55c13 --- /dev/null +++ b/tests/bdd/fixtures/panic.rs @@ -0,0 +1,129 @@ +//! `PanicWorld` fixture for rstest-bdd tests. +//! +//! Provides test fixtures to ensure the server remains resilient when +//! connection setup handlers panic before a client fully connects. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::net::SocketAddr; + +use rstest::fixture; +use tokio::{net::TcpStream, sync::oneshot}; +use wireframe::server::WireframeServer; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; +use crate::common::{TestApp, unused_listener}; + +#[derive(Debug)] +struct PanicServer { + addr: SocketAddr, + shutdown: Option>, + handle: tokio::task::JoinHandle<()>, +} + +impl PanicServer { + #[expect( + clippy::expect_used, + reason = "panic world should fail loudly if the panic app cannot be built" + )] + async fn spawn() -> TestResult { + let factory = || { + TestApp::new() + .and_then(|app| app.on_connection_setup(|| async { panic!("boom") })) + .expect("failed to build panic app") + }; + let listener = unused_listener(); + let server = WireframeServer::new(factory) + .workers(1) + .bind_existing_listener(listener)?; + let addr = server.local_addr().ok_or("Failed to get server address")?; + let (tx_shutdown, rx_shutdown) = oneshot::channel(); + let (tx_ready, rx_ready) = oneshot::channel(); + + let handle = tokio::spawn(async move { + if let Err(err) = server + .ready_signal(tx_ready) + .run_with_shutdown(async { + let _ = rx_shutdown.await; + }) + .await + { + tracing::error!("server task failed: {err}"); + } + }); + rx_ready.await.map_err(|_| "Server did not signal ready")?; + + Ok(Self { + addr, + shutdown: Some(tx_shutdown), + handle, + }) + } +} + +impl Drop for PanicServer { + fn drop(&mut self) { + use std::{thread, time::Duration}; + + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + let timeout = Duration::from_secs(5); + let handle = self.handle.abort_handle(); + thread::spawn(move || { + thread::sleep(timeout); + handle.abort(); + }); + } +} + +#[derive(Debug, Default)] +/// Test world that drives a server which intentionally panics during setup. +pub struct PanicWorld { + server: Option, + attempts: usize, +} + +#[fixture] +pub fn panic_world() -> PanicWorld { PanicWorld::default() } + +impl PanicWorld { + /// Start a server that panics during connection setup. + /// + /// # Errors + /// Returns an error if building the app factory or binding the server + /// fails. + pub async fn start_panic_server(&mut self) -> TestResult { + let server = PanicServer::spawn().await?; + self.server.replace(server); + Ok(()) + } + + /// Connect to the running server once. + /// + /// # Errors + /// Returns an error if the server address is unknown or the connection + /// attempt fails. + pub async fn connect_once(&mut self) -> TestResult { + let addr = self.server.as_ref().ok_or("Server not started")?.addr; + TcpStream::connect(addr).await?; + self.attempts += 1; + Ok(()) + } + + /// Verify both connections succeeded and shut down the server. + /// + /// # Errors + /// Returns an error if the connection attempts do not match the expected + /// count. + pub async fn verify_and_shutdown(&mut self) -> TestResult { + if self.attempts != 2 { + return Err("expected two successful connection attempts".into()); + } + // dropping PanicServer will shut it down + self.server.take(); + tokio::task::yield_now().await; + Ok(()) + } +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 78e88865..46fb54be 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -4,4 +4,5 @@ //! test function here. mod correlation_scenarios; +mod panic_scenarios; mod request_parts_scenarios; diff --git a/tests/bdd/scenarios/panic_scenarios.rs b/tests/bdd/scenarios/panic_scenarios.rs new file mode 100644 index 00000000..87ebf843 --- /dev/null +++ b/tests/bdd/scenarios/panic_scenarios.rs @@ -0,0 +1,11 @@ +//! Scenario tests for connection panic resilience. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::panic::*; + +#[scenario( + path = "tests/features/connection_panic.feature", + name = "connection panic does not crash server" +)] +fn panic_resilience(panic_world: PanicWorld) { let _ = panic_world; } diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index be79f753..cbd78579 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -4,4 +4,5 @@ //! `tokio::task::block_in_place`. mod correlation_steps; +mod panic_steps; mod request_parts_steps; diff --git a/tests/bdd/steps/panic_steps.rs b/tests/bdd/steps/panic_steps.rs new file mode 100644 index 00000000..22fc9d6d --- /dev/null +++ b/tests/bdd/steps/panic_steps.rs @@ -0,0 +1,28 @@ +//! Step definitions for panic resilience behavioural tests. +//! +//! Steps are synchronous but call async World methods via +//! `Runtime::new().block_on()` (`current_thread` runtime doesn't support +//! `block_in_place`). + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::panic::{PanicWorld, TestResult}; + +#[given("a running wireframe server with a panic in connection setup")] +fn start_server(panic_world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(panic_world.start_panic_server()) +} + +#[when("I connect to the server")] +#[when("I connect to the server again")] +fn connect(panic_world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(panic_world.connect_once()) +} + +#[then("both connections succeed")] +fn verify(panic_world: &mut PanicWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(panic_world.verify_and_shutdown()) +} From cf0d07d0d21f686e448111113d52a52c55f55d31 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 23 Jan 2026 02:07:52 +0000 Subject: [PATCH 10/45] Migrate MultiPacketWorld from Cucumber to rstest-bdd Continue Phase 2 with the second medium-complexity world. MultiPacketWorld demonstrates channel operations, message ordering, and back-pressure handling. ## Changes - Created `tests/bdd/fixtures/multi_packet.rs` with `MultiPacketWorld` and `WireframeRunError` helper - Created `tests/bdd/steps/multi_packet_steps.rs` with async/sync step mix - Created `tests/bdd/scenarios/multi_packet_scenarios.rs` with 3 scenarios - Updated module files to include new multi_packet modules ## Test Results All 3 multi-packet scenarios pass, matching Cucumber output: - Cucumber: 3 scenarios (3 passed), 6 steps (6 passed) - rstest-bdd: 3 scenarios (3 passed), 6 steps (6 passed) ## Migration Notes MultiPacketWorld demonstrates: - Channel operations with `Response::with_channel` and mpsc channels - Back-pressure handling with channel capacity overflow - Mixed async/sync step definitions (async "when" steps, sync "then" steps) - Custom error type wrapping (`WireframeRunError`) - Spawned tasks with `tokio::spawn` for concurrent message production All tests passing. Second world of Phase 2 complete. --- tests/bdd/fixtures/mod.rs | 1 + tests/bdd/fixtures/multi_packet.rs | 162 ++++++++++++++++++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/scenarios/multi_packet_scenarios.rs | 23 +++ tests/bdd/steps/mod.rs | 1 + tests/bdd/steps/multi_packet_steps.rs | 40 +++++ 6 files changed, 228 insertions(+) create mode 100644 tests/bdd/fixtures/multi_packet.rs create mode 100644 tests/bdd/scenarios/multi_packet_scenarios.rs create mode 100644 tests/bdd/steps/multi_packet_steps.rs diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 57c38a2f..ceb30f54 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -3,5 +3,6 @@ //! Each world from the Cucumber tests is converted to an rstest fixture here. pub mod correlation; +pub mod multi_packet; pub mod panic; pub mod request_parts; diff --git a/tests/bdd/fixtures/multi_packet.rs b/tests/bdd/fixtures/multi_packet.rs new file mode 100644 index 00000000..c32e9fcc --- /dev/null +++ b/tests/bdd/fixtures/multi_packet.rs @@ -0,0 +1,162 @@ +//! `MultiPacketWorld` fixture for rstest-bdd tests. +//! +//! Provides test fixtures to verify message ordering, back-pressure handling, +//! and channel lifecycle. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::{error::Error, fmt}; + +use rstest::fixture; +use tokio::sync::mpsc::{self, error::TrySendError}; +use tokio_util::sync::CancellationToken; +use wireframe::{Response, connection::ConnectionActor}; + +use crate::build_small_queues; +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +#[derive(Debug)] +struct WireframeRunError(wireframe::WireframeError); + +impl fmt::Display for WireframeRunError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } +} + +impl Error for WireframeRunError {} + +#[derive(Debug, Default)] +/// Test world exercising multi-packet channel behaviours and back-pressure. +pub struct MultiPacketWorld { + messages: Vec, + is_overflow_error: bool, +} + +#[fixture] +pub fn multi_packet_world() -> MultiPacketWorld { MultiPacketWorld::default() } + +impl MultiPacketWorld { + async fn collect_frames_from(rx: mpsc::Receiver) -> TestResult> { + let (queues, handle) = build_small_queues::()?; + let shutdown = CancellationToken::new(); + let mut actor: ConnectionActor<_, ()> = + ConnectionActor::new(queues, handle, None, shutdown); + actor.set_multi_packet(Some(rx)); + + let mut frames = Vec::new(); + actor + .run(&mut frames) + .await + .map_err(WireframeRunError) + .map_err(Box::::from)?; + Ok(frames) + } + + /// Send a single byte with back-pressure then close the channel. + async fn send_with_backpressure(sender: mpsc::Sender, value: u8) -> TestResult<()> { + sender.send(value).await?; + drop(sender); + Ok(()) + } + + /// Helper method to process messages through a multi-packet response built + /// via [`Response::with_channel`]. + /// + /// # Errors + /// Returns an error if the response cannot be converted to a multi-packet + /// stream or if producer tasks fail. + async fn process_messages(&mut self, messages: &[u8]) -> TestResult { + let (sender, response): (mpsc::Sender, Response) = Response::with_channel(4); + let Response::MultiPacket(rx) = response else { + return Err("helper did not return a MultiPacket response".into()); + }; + + let payload = messages.to_vec(); + let producer = tokio::spawn(Self::send_payload(sender, payload)); + + let frames = Self::collect_frames_from(rx).await?; + producer.await?; + self.messages = frames; + self.is_overflow_error = false; + Ok(()) + } + + /// Send each byte to the channel, stopping silently if the receiver closes + /// to simulate a producer completing without error when the consumer is + /// gone. + async fn send_payload(sender: mpsc::Sender, payload: Vec) { + for msg in payload { + if sender.send(msg).await.is_err() { + return; + } + } + } + + /// Send messages through a multi-packet response and record them. + /// + /// # Errors + /// Returns an error if the response cannot be converted to a multi-packet + /// stream or if producer tasks fail. + pub async fn process(&mut self) -> TestResult { self.process_messages(&[1, 2, 3]).await } + + /// Record zero messages from a closed channel. + /// + /// # Errors + /// Returns an error if the response cannot be converted to a multi-packet + /// stream or if producer tasks fail. + pub async fn process_empty(&mut self) -> TestResult { self.process_messages(&[]).await } + + /// Attempt to send more messages than the channel can buffer at once. + /// + /// # Errors + /// Returns an error if sending to the channel fails unexpectedly or the + /// producer task returns an error. + pub async fn process_overflow(&mut self) -> TestResult { + let (sender, response): (mpsc::Sender, Response) = Response::with_channel(1); + let Response::MultiPacket(rx) = response else { + return Err("helper did not return a MultiPacket response".into()); + }; + + sender.try_send(1)?; + let overflow_error = matches!(sender.try_send(2), Err(TrySendError::Full(2))); + + let producer = tokio::spawn(Self::send_with_backpressure(sender, 2)); + + let frames = Self::collect_frames_from(rx).await?; + // Unwrap JoinError from await, then the task's Result + producer.await??; + + self.messages = frames; + self.is_overflow_error = overflow_error; + Ok(()) + } + + /// Verify that no messages were received. + /// + /// # Panics + /// Panics if any messages are present. + pub fn verify_empty(&self) { + assert!(self.messages.is_empty()); + } + + /// Verify messages were received in order. + /// + /// # Panics + /// + /// Panics if the messages are not in the expected order. + pub fn verify(&self) { + assert_eq!(self.messages, vec![1, 2, 3]); + } + + /// Verify that the channel enforced back-pressure. + /// + /// # Panics + /// Panics if no overflow occurred or if the expected messages are missing. + pub fn verify_overflow(&self) { + assert!( + self.is_overflow_error, + "expected overflow error when channel capacity was exceeded", + ); + assert_eq!(self.messages, vec![1, 2]); + } +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 46fb54be..509fbb56 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -4,5 +4,6 @@ //! test function here. mod correlation_scenarios; +mod multi_packet_scenarios; mod panic_scenarios; mod request_parts_scenarios; diff --git a/tests/bdd/scenarios/multi_packet_scenarios.rs b/tests/bdd/scenarios/multi_packet_scenarios.rs new file mode 100644 index 00000000..4de5f700 --- /dev/null +++ b/tests/bdd/scenarios/multi_packet_scenarios.rs @@ -0,0 +1,23 @@ +//! Scenario tests for multi-packet responses. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::multi_packet::*; + +#[scenario( + path = "tests/features/multi_packet.feature", + name = "Response::with_channel streams frames sequentially" +)] +fn multi_packet_streaming(multi_packet_world: MultiPacketWorld) { let _ = multi_packet_world; } + +#[scenario( + path = "tests/features/multi_packet.feature", + name = "no messages are emitted from a multi-packet response" +)] +fn multi_packet_empty(multi_packet_world: MultiPacketWorld) { let _ = multi_packet_world; } + +#[scenario( + path = "tests/features/multi_packet.feature", + name = "Channel capacity overflow" +)] +fn multi_packet_overflow(multi_packet_world: MultiPacketWorld) { let _ = multi_packet_world; } diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index cbd78579..30788a9f 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -4,5 +4,6 @@ //! `tokio::task::block_in_place`. mod correlation_steps; +mod multi_packet_steps; mod panic_steps; mod request_parts_steps; diff --git a/tests/bdd/steps/multi_packet_steps.rs b/tests/bdd/steps/multi_packet_steps.rs new file mode 100644 index 00000000..b95ce9f2 --- /dev/null +++ b/tests/bdd/steps/multi_packet_steps.rs @@ -0,0 +1,40 @@ +//! Step definitions for multi-packet response behavioural tests. +//! +//! Steps are synchronous but call async World methods via +//! `Runtime::new().block_on()` (`current_thread` runtime doesn't support +//! `block_in_place`). + +use rstest_bdd_macros::{then, when}; + +use crate::fixtures::multi_packet::{MultiPacketWorld, TestResult}; + +#[when("a handler uses the with_channel helper to emit messages")] +fn when_multi(multi_packet_world: &mut MultiPacketWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(multi_packet_world.process()) +} + +#[then("all messages are received in order")] +fn then_multi(multi_packet_world: &mut MultiPacketWorld) { multi_packet_world.verify(); } + +#[when("a handler uses the with_channel helper to emit no messages")] +fn when_multi_empty(multi_packet_world: &mut MultiPacketWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(multi_packet_world.process_empty()) +} + +#[then("no messages are received")] +fn then_multi_empty(multi_packet_world: &mut MultiPacketWorld) { + multi_packet_world.verify_empty(); +} + +#[when("a handler emits more messages than the channel capacity")] +fn when_multi_overflow(multi_packet_world: &mut MultiPacketWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(multi_packet_world.process_overflow()) +} + +#[then("overflow messages are handled according to channel policy")] +fn then_multi_overflow(multi_packet_world: &mut MultiPacketWorld) { + multi_packet_world.verify_overflow(); +} From f8ce48553efd6559b6e32b4f21ed20e86878b7c7 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 24 Jan 2026 15:07:39 +0000 Subject: [PATCH 11/45] Update migration execplan status Reflect current rstest-bdd progress and align examples with the\nasync-scenario guidance from the user guide. This keeps the\nplan in sync with the codebase and removes outdated patterns. --- .../migrate-from-cucumber-to-rstest-bdd.md | 244 ++++++++---------- 1 file changed, 106 insertions(+), 138 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 7634e4c6..889edf52 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -4,19 +4,21 @@ **Duration**: 9 weeks (phased incremental migration) -**Status**: Planning +**Status**: In Progress -**Last Updated**: 2026-01-22 +**Last Updated**: 2026-01-24 ## Executive Summary -Migrate Wireframe's 14 Cucumber-based BDD test suites (~3,941 lines of -world code, ~1,330 lines of steps, 60+ scenarios) to rstest-bdd v0.4.0. -The migration leverages rstest-bdd's new async scenario support while -maintaining test coverage through parallel execution during migration. +Migrate Wireframe's 14 Cucumber-based BDD test suites (~3,941 lines of world +code, ~1,330 lines of steps, 60+ scenarios) to rstest-bdd v0.4.0. The migration +leverages rstest-bdd's async scenario support where steps are fully +synchronous, while maintaining test coverage through parallel execution during +migration. -**Key Strategy**: Async scenarios with sync steps calling async helpers -via `tokio::task::block_in_place`. +**Key Strategy**: Use async scenarios (`tokio` current-thread) for scenarios +with synchronous steps, and use per-step runtimes for async world methods until +rstest-bdd supports async steps. ## Current State Analysis @@ -54,32 +56,36 @@ via `tokio::task::block_in_place`. ### Async Handling Model -rstest-bdd v0.4.0 supports **async scenarios** with **sync step -definitions**: +rstest-bdd v0.4.0 supports **async scenarios** with **sync step definitions**, +using Tokio's current-thread runtime: ```rust // Scenario function is async #[scenario(path = "tests/features/client_messaging.feature", name = "Client sends envelope")] #[tokio::test(flavor = "current_thread")] -async fn client_sends_envelope_scenario(world: ClientMessagingWorld) {} +async fn client_sends_envelope_scenario(world: ClientMessagingWorld) { + let _ = world; +} -// Steps are sync, call async helpers +// Steps remain sync (no await inside steps) #[when("the client sends the envelope")] -fn when_client_sends_envelope(world: &mut ClientMessagingWorld) - -> TestResult -{ - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on( - world.send_envelope() - ) - }) +fn when_client_sends_envelope(world: &mut ClientMessagingWorld) { + world.mark_envelope_sent(); } ``` -**Why this works**: `#[tokio::test(flavor = "current_thread")]` creates -async runtime, `block_in_place` allows sync steps to await, -current-thread flavor avoids `Send` bounds with `&mut` fixtures. +**Important limitations (from the user guide)**: + +- Steps are synchronous; async step bodies are not supported yet. +- Current-thread Tokio runtime is required to avoid `Send` bounds on fixtures. + +**Practical rule for this codebase**: + +- For worlds with async methods, keep scenarios **sync** and run those methods + inside a dedicated runtime per step (`Runtime::new().block_on(...)`). +- For worlds with purely synchronous steps, prefer async scenarios so the test + body can `await` any extra async assertions or cleanup logic. ### World-to-Fixture Conversion @@ -127,15 +133,14 @@ fn client_messaging_world() -> ClientMessagingWorld { ### Feature File Changes -**NONE REQUIRED** - rstest-bdd uses same Gherkin parser as Cucumber. -All existing `.feature` files are 100% compatible. +**NONE REQUIRED** - rstest-bdd uses same Gherkin parser as Cucumber. All +existing `.feature` files are 100% compatible. ## Phase Breakdown ### Phase 0: Foundation (Week 1) -**Objective**: Set up parallel infrastructure without disrupting -existing tests. +**Objective**: Set up parallel infrastructure without disrupting existing tests. **Tasks**: @@ -194,15 +199,15 @@ existing tests. test: test-bdd test-cucumber ## Run all tests ``` -**Validation**: `make test-cucumber` still works, `make test-bdd` runs -(empty at first). +**Validation**: `make test-cucumber` still works, `make test-bdd` runs (empty +at first). **Commit**: "Set up parallel rstest-bdd infrastructure" ### Phase 1: Pilot Migration - Simple Worlds (Weeks 2-3) -**Objective**: Validate approach with 2 simple worlds, establish -conversion patterns. +**Objective**: Validate approach with 2 simple worlds, establish conversion +patterns. **Selected Worlds**: @@ -212,8 +217,10 @@ conversion patterns. **Per-World Steps**: 1. Convert World struct → fixture -2. Migrate step definitions (remove `async`, add `block_in_place`) -3. Create scenario tests with `#[scenario]` + `#[tokio::test]` +2. Migrate step definitions (remove `async`, run async methods via a per-step + runtime) +3. Create scenario tests with `#[scenario]` (use async scenarios only when + steps are fully synchronous) 4. Run and validate against Cucumber **Example - CorrelationWorld**: @@ -253,21 +260,18 @@ impl CorrelationWorld { // tests/bdd/steps/correlation_steps.rs use rstest_bdd_macros::{given, when, then}; -#[given(expr = "a correlation id {int}")] +#[given(expr = "a correlation id {id:u64}")] fn given_cid(world: &mut CorrelationWorld, id: u64) { world.set_expected(Some(id)); } #[when("a stream of frames is processed")] fn when_process(world: &mut CorrelationWorld) -> TestResult { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on( - world.process() - ) - }) + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(world.process()) } -#[then(expr = "each emitted frame uses correlation id {int}")] +#[then(expr = "each emitted frame uses correlation id {id:u64}")] fn then_verify(world: &mut CorrelationWorld, id: u64) -> TestResult { @@ -285,27 +289,26 @@ use crate::fixtures::correlation::*; #[scenario(path = "tests/features/correlation_id.feature", name = "Streamed frames reuse the request correlation id")] -#[tokio::test(flavor = "current_thread")] -async fn streamed_frames_correlation( +fn streamed_frames_correlation( correlation_world: CorrelationWorld -) {} +) { let _ = correlation_world; } #[scenario( path = "tests/features/correlation_id.feature", name = "Multi-packet responses reuse the request correlation id" )] -#[tokio::test(flavor = "current_thread")] -async fn multi_packet_correlation( +fn multi_packet_correlation( correlation_world: CorrelationWorld -) {} +) { let _ = correlation_world; } #[scenario( path = "tests/features/correlation_id.feature", name = "Multi-packet responses clear correlation ids without \ a request id" )] -#[tokio::test(flavor = "current_thread")] -async fn no_correlation(correlation_world: CorrelationWorld) {} +fn no_correlation(correlation_world: CorrelationWorld) { + let _ = correlation_world; +} ``` **Validation**: @@ -353,18 +356,19 @@ fn panic_world() -> PanicWorld { #[given("a panic server")] fn given_panic_server(world: &mut PanicWorld) -> TestResult { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let server = PanicServer::spawn().await?; - world.server.set(server); - Ok(()) - }) + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let server = PanicServer::spawn().await?; + world.server.set(server); + Ok(()) }) } ``` **Commits**: One per world (4 commits). +**Status**: In Progress - `PanicWorld` and `MultiPacketWorld` migrated. + ### Phase 3: Complex Worlds - Client & Messaging (Weeks 6-7) **Selected Worlds** (in order): @@ -374,19 +378,17 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { 3. `ClientLifecycleWorld` (lifecycle hooks) 4. `ClientPreambleWorld` (preamble exchange) -**Focus**: Multi-step async sequences, server + client coordination, -callbacks. +**Focus**: Multi-step async sequences, server + client coordination, callbacks. **Multi-Async Step Pattern**: ```rust #[given("an envelope echo server")] fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - world.start_echo_server().await?; - world.connect_client().await - }) + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + world.start_echo_server().await?; + world.connect_client().await }) } ``` @@ -478,14 +480,14 @@ pub fn fragment_world() -> FragmentWorld { ## Migration Progress Tracking -| Phase | Worlds | Scenarios | Status | Completion | -| ----- | ------ | --------- | -------------- | ---------- | -| 0 | - | - | Complete | 2026-01-22 | -| 1 | 2 | 6 | In Progress | 1/2 done | -| 2 | 4 | 15 | Not Started | - | -| 3 | 4 | 20 | Not Started | - | -| 4 | 4 | 19+ | Not Started | - | -| 5 | - | - | Not Started | - | +| Phase | Worlds | Scenarios | Status | Completion | +| ----- | ------ | --------- | ----------- | ---------- | +| 0 | - | - | Complete | 2026-01-22 | +| 1 | 2 | 6 | Complete | 2026-01-22 | +| 2 | 4 | 15 | In Progress | 2/4 done | +| 3 | 4 | 20 | Not Started | - | +| 4 | 4 | 19+ | Not Started | - | +| 5 | - | - | Not Started | - | **Total**: 14 worlds, 60+ scenarios @@ -493,32 +495,32 @@ pub fn fragment_world() -> FragmentWorld { ### Risk 1: Async Boundary Issues -**Mitigation**: Test `block_in_place` pattern in Phase 1 before -widespread adoption. Keep complex `ClientMessagingWorld` for Phase 3 -after validation. +**Mitigation**: Validate the per-step runtime pattern in Phase 1 before +widespread adoption. Keep complex `ClientMessagingWorld` for Phase 3 after +validation. -**Contingency**: Create helper async functions if `block_in_place` has -issues. +**Contingency**: Add a shared runtime helper if runtime creation becomes too +costly or repetitive. ### Risk 2: Server Spawning Conflicts -**Mitigation**: Use `Slot` pattern, not direct fixture spawn. -Test in Phase 2 with `PanicWorld`. +**Mitigation**: Use `Slot` pattern, not direct fixture spawn. Test in +Phase 2 with `PanicWorld`. ### Risk 3: Fragment.feature Complexity (11 scenarios) -**Mitigation**: Migrate in Phase 4 after patterns proven. Can use -`scenarios!` macro if individual tests become verbose. +**Mitigation**: Migrate in Phase 4 after patterns proven. Can use `scenarios!` +macro if individual tests become verbose. ### Risk 4: Compile-Time Validation False Positives -**Mitigation**: Start with `compile-time-validation` (warnings only), -enable strict mode in Phase 5. +**Mitigation**: Start with `compile-time-validation` (warnings only), enable +strict mode in Phase 5. ### Risk 5: Migration Timeline Slippage -**Mitigation**: Strict phase boundaries. Parallel execution allows -partial migration. Can pause after any phase. +**Mitigation**: Strict phase boundaries. Parallel execution allows partial +migration. Can pause after any phase. ## Critical Files @@ -539,18 +541,16 @@ partial migration. Can pause after any phase. ### Phase 2 (Medium Complexity) -Panic, MultiPacket, StreamEnd, CodecStateful (fixtures, steps, -scenarios) - 12 files total +Panic, MultiPacket, StreamEnd, CodecStateful (fixtures, steps, scenarios) - 12 +files total ### Phase 3 (Complex) -ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble - 12 -files total +ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble - 12 files total ### Phase 4 (Specialized) -MessageAssembler, MessageAssembly, CodecError, Fragment - 12 files -total +MessageAssembler, MessageAssembly, CodecError, Fragment - 12 files total ### Phase 5 (Cleanup) @@ -588,20 +588,23 @@ Create shared async helper: ```rust // tests/bdd/async_helpers.rs -/// Execute an async closure within the current tokio runtime. -pub fn run_async(f: F) -> T +/// Execute an async future in a dedicated Tokio runtime. +/// +/// # Errors +/// Returns an error if the runtime cannot be created. +pub fn run_async(future: F) -> Result where F: std::future::Future, { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(f) - }) + let rt = tokio::runtime::Runtime::new()?; + Ok(rt.block_on(future)) } // Usage in steps: #[when("server starts")] fn when_server_starts(world: &mut ServerWorld) -> TestResult { - run_async(world.start_server()) + run_async(world.start_server())?; + Ok(()) } ``` @@ -620,52 +623,17 @@ fn when_server_starts(world: &mut ServerWorld) -> TestResult { ### Phase 1: CorrelationWorld Migration (Completed) -**CRITICAL DISCOVERY**: The async scenario approach outlined in the plan does -NOT work with rstest-bdd v0.4.0's current implementation. - -**The Problem**: - -- Scenarios marked with `#[tokio::test(flavor = "current_thread")] async fn` - create a tokio runtime -- Steps are sync and must remain sync (documented limitation) -- Attempting to call `tokio::runtime::Handle::current().block_on()` from - within a step fails with "Cannot start runtime within runtime" -- Attempting to create `Runtime::new()` in a step also fails when the - scenario itself is async - -**The Solution**: - -- Scenarios must be **sync functions** (remove `async fn` and - `#[tokio::test]`) -- Steps remain sync and create their own `Runtime::new()` for async - operations -- Pattern: `let rt = tokio::runtime::Runtime::new()?; rt.block_on(async_fn())` - -**Updated Async Pattern**: - -```rust -// Scenario: SYNC (not async!) -#[scenario(path = "tests/features/correlation_id.feature", - name = "Streamed frames reuse the request correlation id")] -fn streamed_frames_correlation(correlation_world: CorrelationWorld) { - let _ = correlation_world; -} - -// Step: Sync, creates own runtime -#[when("a stream of frames is processed")] -fn when_process(correlation_world: &mut CorrelationWorld) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(correlation_world.process()) -} -``` +**Key Observation**: rstest-bdd supports async scenarios, but step definitions +remain synchronous (per the user guide). If a step needs to call async world +methods, invoking `block_on` from inside an async scenario panics with "Cannot +start runtime within runtime". -**Impact on Migration Plan**: +**Adopted Pattern**: -- All references to `#[tokio::test(flavor = "current_thread")]` in scenario - examples should be removed -- All scenario functions are sync, not async -- `tokio::task::block_in_place` is NOT needed and won't work -- Each async step creates its own `Runtime::new()` and uses `block_on()` +- Keep scenarios **sync** when steps need to run async world methods. +- Create a fresh Tokio runtime per async step and call `block_on`. +- Reserve async scenarios for worlds whose steps are purely synchronous, so the + scenario body can `await` extra assertions or cleanup when needed. **Validation**: CorrelationWorld migration complete with all 3 scenarios passing, verified against Cucumber output. From 617154d9a0c9068a0a8b6a68b6f43a18015e6eae Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 24 Jan 2026 15:19:19 +0000 Subject: [PATCH 12/45] Migrate StreamEndWorld to rstest-bdd Add StreamEnd fixtures, steps, and scenarios to the rstest-bdd\nstack and wire the terminator helper into the bdd test crate.\nUpdate the migration execplan to reflect Phase 2 progress. --- .../migrate-from-cucumber-to-rstest-bdd.md | 5 +- tests/bdd/fixtures/mod.rs | 1 + tests/bdd/fixtures/stream_end.rs | 237 ++++++++++++++++++ tests/bdd/mod.rs | 4 +- tests/bdd/scenarios/mod.rs | 5 + tests/bdd/scenarios/stream_end_scenarios.rs | 29 +++ tests/bdd/steps/mod.rs | 3 +- tests/bdd/steps/stream_end_steps.rs | 41 +++ 8 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 tests/bdd/fixtures/stream_end.rs create mode 100644 tests/bdd/scenarios/stream_end_scenarios.rs create mode 100644 tests/bdd/steps/stream_end_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 889edf52..6d36b867 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -367,7 +367,8 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `PanicWorld` and `MultiPacketWorld` migrated. +**Status**: In Progress - `PanicWorld`, `MultiPacketWorld`, and +`StreamEndWorld` migrated. ### Phase 3: Complex Worlds - Client & Messaging (Weeks 6-7) @@ -484,7 +485,7 @@ pub fn fragment_world() -> FragmentWorld { | ----- | ------ | --------- | ----------- | ---------- | | 0 | - | - | Complete | 2026-01-22 | | 1 | 2 | 6 | Complete | 2026-01-22 | -| 2 | 4 | 15 | In Progress | 2/4 done | +| 2 | 4 | 15 | In Progress | 3/4 done | | 3 | 4 | 20 | Not Started | - | | 4 | 4 | 19+ | Not Started | - | | 5 | - | - | Not Started | - | diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index ceb30f54..efb2ebfb 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -6,3 +6,4 @@ pub mod correlation; pub mod multi_packet; pub mod panic; pub mod request_parts; +pub mod stream_end; diff --git a/tests/bdd/fixtures/stream_end.rs b/tests/bdd/fixtures/stream_end.rs new file mode 100644 index 00000000..b3ef25c3 --- /dev/null +++ b/tests/bdd/fixtures/stream_end.rs @@ -0,0 +1,237 @@ +//! `StreamEndWorld` fixture for rstest-bdd tests. +//! +//! Provides test fixtures to verify terminator frames and multi-packet +//! termination logging for streaming responses. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::{mem, sync::Arc}; + +use async_stream::try_stream; +use log::Level; +use rstest::fixture; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use wireframe::{ + connection::{ConnectionActor, ConnectionChannels, test_support::ActorHarness}, + hooks::ProtocolHooks, + response::FrameStream, +}; +use wireframe_testing::{LoggerHandle, logger}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; +use crate::{build_small_queues, terminator::Terminator}; + +#[derive(Debug, Default)] +/// Test world capturing frames and logs for stream termination scenarios. +pub struct StreamEndWorld { + frames: Vec, + logs: Vec<(Level, String)>, +} + +enum MultiPacketMode { + Disconnect { send_frames: bool }, + Shutdown, +} + +enum ActorMode { + Stream, + MultiPacket, +} + +impl StreamEndWorld { + fn prepare_test(&mut self) -> LoggerHandle { + self.frames.clear(); + self.logs.clear(); + let mut logger = logger(); + logger.clear(); + logger + } + + fn finalize_test(&mut self, logger: &mut LoggerHandle) { self.capture_logs(logger); } + + async fn run_actor_test(&mut self, mode: ActorMode) -> TestResult { + let mut temp = StreamEndWorld::default(); + mem::swap(self, &mut temp); + let mut logger = temp.prepare_test(); + + let (queues, handle) = build_small_queues::()?; + let shutdown = CancellationToken::new(); + let hooks = ProtocolHooks::from_protocol(&Arc::new(Terminator)); + + match mode { + ActorMode::Stream => { + let stream: FrameStream = Box::pin(try_stream! { + yield 1u8; + yield 2u8; + }); + let mut actor = ConnectionActor::with_hooks( + ConnectionChannels::new(queues, handle), + Some(stream), + shutdown, + hooks, + ); + actor + .run(&mut temp.frames) + .await + .map_err(|e| format!("actor run failed: {e:?}"))?; + } + ActorMode::MultiPacket => { + let (tx, rx) = mpsc::channel(4); + tx.send(1u8).await?; + tx.send(2u8).await?; + drop(tx); + + let mut actor = ConnectionActor::with_hooks( + ConnectionChannels::new(queues, handle), + None, + shutdown, + hooks, + ); + actor.set_multi_packet(Some(rx)); + actor + .run(&mut temp.frames) + .await + .map_err(|e| format!("actor run failed: {e:?}"))?; + } + } + + temp.finalize_test(&mut logger); + mem::swap(self, &mut temp); + Ok(()) + } + + /// Run the connection actor and record emitted frames. + /// + /// # Errors + /// Returns an error if the actor fails to run successfully. + pub async fn process(&mut self) -> TestResult { self.run_actor_test(ActorMode::Stream).await } + + /// Run the connection actor with a multi-packet channel and record emitted frames. + /// + /// # Errors + /// Returns an error if sending to the channel or running the actor fails. + pub async fn process_multi(&mut self) -> TestResult { + self.run_actor_test(ActorMode::MultiPacket).await + } + + fn capture_logs(&mut self, logger: &mut LoggerHandle) { + while let Some(record) = logger.pop() { + self.logs.push((record.level(), record.args().to_string())); + } + } + + fn closure_log(&self) -> Option<&(Level, String)> { + self.logs + .iter() + .rev() + .find(|(_, message)| message.contains("multi-packet stream closed")) + } + + fn run_multi_packet_harness( + &mut self, + mode: &MultiPacketMode, + correlation_id: u64, + ) -> TestResult { + let mut temp = StreamEndWorld::default(); + mem::swap(self, &mut temp); + let mut logger = temp.prepare_test(); + + let hooks = ProtocolHooks::from_protocol(&Arc::new(Terminator)); + let mut harness = ActorHarness::new_with_state(hooks, false, true)?; + let (tx, rx) = mpsc::channel(4); + harness + .actor_mut() + .set_multi_packet_with_correlation(Some(rx), Some(correlation_id)); + match mode { + MultiPacketMode::Disconnect { send_frames } => { + if *send_frames { + tx.try_send(1u8)?; + tx.try_send(2u8)?; + } + drop(tx); + logger.clear(); + while harness.try_drain_multi() {} + } + MultiPacketMode::Shutdown => { + drop(tx); + logger.clear(); + harness.start_shutdown(); + } + } + temp.frames.clone_from(&harness.out); + + temp.finalize_test(&mut logger); + mem::swap(self, &mut temp); + Ok(()) + } + + /// Simulate a disconnected multi-packet channel by dropping the sender before draining. + /// + /// # Errors + /// Returns an error if creating the harness or sending frames fails. + pub fn process_multi_disconnect(&mut self) -> TestResult { + self.run_multi_packet_harness(&MultiPacketMode::Disconnect { send_frames: true }, 42) + } + + /// Trigger shutdown handling on a multi-packet channel without emitting a terminator. + /// + /// # Errors + /// Returns an error if creating the harness fails. + pub fn process_multi_shutdown(&mut self) -> TestResult { + self.run_multi_packet_harness(&MultiPacketMode::Shutdown, 77) + } + + /// Verify that a terminator frame was appended to the stream. + /// + /// # Panics + /// Panics if the expected terminator is missing. + pub fn verify(&self) { + assert_eq!(self.frames, vec![1, 2, 0]); + } + + /// Verify that a multi-packet terminator frame was appended to the stream. + /// + /// # Panics + /// Panics if the expected terminator is missing. + pub fn verify_multi(&self) { + assert_eq!(self.frames, vec![1, 2, 0]); + } + + /// Verify that no terminator frame was emitted. + /// + /// # Panics + /// Panics if a terminator frame is present. + pub fn verify_no_multi(&self) { + assert!( + self.frames.iter().all(|&frame| frame != 0), + "unexpected terminator frame present", + ); + } + + /// Verify the logged multi-packet termination reason. + /// + /// # Errors + /// Returns an error if the closure log is missing or contains unexpected + /// details. + pub fn verify_reason(&self, expected: &str) -> TestResult { + let (level, message) = self + .closure_log() + .ok_or("multi-packet closure log missing")?; + let expected_level = match expected { + "disconnected" => Level::Warn, + _ => Level::Info, + }; + if *level != expected_level { + return Err("unexpected log level for closure".into()); + } + if !message.contains(&format!("reason={expected}")) { + return Err("closure log missing reason detail".into()); + } + Ok(()) + } +} + +#[fixture] +pub fn stream_end_world() -> StreamEndWorld { StreamEndWorld::default() } diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs index e58b8e7e..4d066b36 100644 --- a/tests/bdd/mod.rs +++ b/tests/bdd/mod.rs @@ -10,6 +10,9 @@ #[path = "../common/mod.rs"] pub mod common; +#[path = "../common/terminator.rs"] +mod terminator; + #[path = "../support.rs"] mod support; @@ -25,4 +28,3 @@ pub(crate) fn build_small_queues() mod fixtures; mod scenarios; -mod steps; diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 509fbb56..0883c856 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -3,7 +3,12 @@ //! Each scenario from the `.feature` files has a corresponding `#[scenario]` //! test function here. +// Load step definitions first so compile-time validation can see them. +#[path = "../steps/mod.rs"] +mod steps; + mod correlation_scenarios; mod multi_packet_scenarios; mod panic_scenarios; mod request_parts_scenarios; +mod stream_end_scenarios; diff --git a/tests/bdd/scenarios/stream_end_scenarios.rs b/tests/bdd/scenarios/stream_end_scenarios.rs new file mode 100644 index 00000000..2266c820 --- /dev/null +++ b/tests/bdd/scenarios/stream_end_scenarios.rs @@ -0,0 +1,29 @@ +//! Scenario tests for stream terminator features. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::stream_end::*; + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Connection actor emits terminator after stream" +)] +fn stream_terminator(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Multi-packet channel emits terminator after completion" +)] +fn multi_packet_completion(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Multi-packet channel disconnect logs termination" +)] +fn multi_packet_disconnect(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } + +#[scenario( + path = "tests/features/stream_end.feature", + name = "Shutdown closes a multi-packet channel" +)] +fn multi_packet_shutdown(stream_end_world: StreamEndWorld) { let _ = stream_end_world; } diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 30788a9f..7609e230 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -1,9 +1,10 @@ //! Step definitions for rstest-bdd tests. //! //! Step functions are synchronous and call async world methods via -//! `tokio::task::block_in_place`. +//! `Runtime::new().block_on(...)`. mod correlation_steps; mod multi_packet_steps; mod panic_steps; mod request_parts_steps; +mod stream_end_steps; diff --git a/tests/bdd/steps/stream_end_steps.rs b/tests/bdd/steps/stream_end_steps.rs new file mode 100644 index 00000000..f0e53e05 --- /dev/null +++ b/tests/bdd/steps/stream_end_steps.rs @@ -0,0 +1,41 @@ +//! Step definitions for stream terminator behavioural tests. + +use rstest_bdd_macros::{then, when}; + +use crate::fixtures::stream_end::{StreamEndWorld, TestResult}; + +#[when("a streaming response completes")] +fn when_stream(stream_end_world: &mut StreamEndWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(stream_end_world.process()) +} + +#[then("an end-of-stream frame is sent")] +fn then_end(stream_end_world: &mut StreamEndWorld) { stream_end_world.verify(); } + +#[when("a multi-packet channel drains")] +fn when_multi_channel(stream_end_world: &mut StreamEndWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(stream_end_world.process_multi()) +} + +#[then("a multi-packet end-of-stream frame is sent")] +fn then_multi_end(stream_end_world: &mut StreamEndWorld) { stream_end_world.verify_multi(); } + +#[when("a multi-packet channel disconnects abruptly")] +fn when_multi_disconnect(stream_end_world: &mut StreamEndWorld) -> TestResult { + stream_end_world.process_multi_disconnect() +} + +#[when("shutdown closes a multi-packet channel")] +fn when_multi_shutdown(stream_end_world: &mut StreamEndWorld) -> TestResult { + stream_end_world.process_multi_shutdown() +} + +#[then("no multi-packet terminator is sent")] +fn then_no_multi(stream_end_world: &mut StreamEndWorld) { stream_end_world.verify_no_multi(); } + +#[then(expr = "the multi-packet termination reason is {reason:word}")] +fn then_reason(stream_end_world: &mut StreamEndWorld, reason: String) -> TestResult { + stream_end_world.verify_reason(reason.as_str()) +} From a34cecdeed75ca67fcd6afb7781487f6a973dd3c Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 24 Jan 2026 15:24:09 +0000 Subject: [PATCH 13/45] Migrate CodecStatefulWorld to rstest-bdd Add codec stateful fixtures, steps, and scenarios to the bdd\ntest suite and update the execplan to mark Phase 2 complete. --- .../migrate-from-cucumber-to-rstest-bdd.md | 6 +- tests/bdd/fixtures/codec_stateful.rs | 286 ++++++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + .../bdd/scenarios/codec_stateful_scenarios.rs | 13 + tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/codec_stateful_steps.rs | 51 ++++ tests/bdd/steps/mod.rs | 1 + 7 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 tests/bdd/fixtures/codec_stateful.rs create mode 100644 tests/bdd/scenarios/codec_stateful_scenarios.rs create mode 100644 tests/bdd/steps/codec_stateful_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 6d36b867..72d19cf3 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -367,8 +367,8 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `PanicWorld`, `MultiPacketWorld`, and -`StreamEndWorld` migrated. +**Status**: ✅ **COMPLETE** - `PanicWorld`, `MultiPacketWorld`, +`StreamEndWorld`, and `CodecStatefulWorld` migrated. ### Phase 3: Complex Worlds - Client & Messaging (Weeks 6-7) @@ -485,7 +485,7 @@ pub fn fragment_world() -> FragmentWorld { | ----- | ------ | --------- | ----------- | ---------- | | 0 | - | - | Complete | 2026-01-22 | | 1 | 2 | 6 | Complete | 2026-01-22 | -| 2 | 4 | 15 | In Progress | 3/4 done | +| 2 | 4 | 15 | Complete | 2026-01-24 | | 3 | 4 | 20 | Not Started | - | | 4 | 4 | 19+ | Not Started | - | | 5 | - | - | Not Started | - | diff --git a/tests/bdd/fixtures/codec_stateful.rs b/tests/bdd/fixtures/codec_stateful.rs new file mode 100644 index 00000000..f5496bb0 --- /dev/null +++ b/tests/bdd/fixtures/codec_stateful.rs @@ -0,0 +1,286 @@ +//! `CodecStatefulWorld` fixture for rstest-bdd tests. +//! +//! Ensures per-connection codec state is isolated so sequence numbers reset +//! between client connections. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::{ + net::SocketAddr, + sync::atomic::{AtomicU64, Ordering}, +}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use futures::{SinkExt, StreamExt}; +use rstest::fixture; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, + task::JoinHandle, +}; +use tokio_util::codec::{Decoder, Encoder, Framed, LengthDelimitedCodec}; +use wireframe::{ + Serializer, + app::{Envelope, WireframeApp}, + codec::FrameCodec, + serializer::BincodeSerializer, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +#[derive(Debug)] +struct SeqFrame { + sequence: u64, + payload: Vec, +} + +#[derive(Debug)] +struct SeqFrameCodec { + max_frame_length: usize, + counter: AtomicU64, +} + +impl SeqFrameCodec { + fn new(max_frame_length: usize) -> Self { + Self { + max_frame_length, + counter: AtomicU64::new(0), + } + } + + /// Return a 1-based sequence value by atomically incrementing the counter. + /// + /// The first call yields 1 to match the behavioural test expectations. + fn next_sequence(&self) -> u64 { self.counter.fetch_add(1, Ordering::SeqCst) + 1 } +} + +impl Clone for SeqFrameCodec { + fn clone(&self) -> Self { + Self { + max_frame_length: self.max_frame_length, + counter: AtomicU64::new(0), + } + } +} + +impl Default for SeqFrameCodec { + fn default() -> Self { Self::new(1024) } +} + +#[derive(Clone, Debug)] +struct SeqAdapter { + inner: LengthDelimitedCodec, + max_frame_length: usize, +} + +impl SeqAdapter { + fn new(max_frame_length: usize) -> Self { + Self { + inner: LengthDelimitedCodec::builder() + .max_frame_length(max_frame_length) + .new_codec(), + max_frame_length, + } + } + + fn process_frame(frame: Option) -> Result, std::io::Error> { + let Some(mut bytes) = frame else { + return Ok(None); + }; + if bytes.len() < 8 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "frame too short", + )); + } + let sequence = bytes.get_u64(); + let payload = bytes.to_vec(); + Ok(Some(SeqFrame { sequence, payload })) + } +} + +impl Decoder for SeqAdapter { + type Item = SeqFrame; + type Error = std::io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + Self::process_frame(self.inner.decode(src)?) + } + + fn decode_eof(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + Self::process_frame(self.inner.decode_eof(src)?) + } +} + +impl Encoder for SeqAdapter { + type Error = std::io::Error; + + fn encode(&mut self, item: SeqFrame, dst: &mut BytesMut) -> Result<(), Self::Error> { + let frame_len = item.payload.len().saturating_add(8); + if frame_len > self.max_frame_length { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "frame too large", + )); + } + let mut buf = BytesMut::with_capacity(frame_len); + buf.put_u64(item.sequence); + buf.extend_from_slice(&item.payload); + self.inner.encode(buf.freeze(), dst) + } +} + +impl FrameCodec for SeqFrameCodec { + type Frame = SeqFrame; + type Decoder = SeqAdapter; + type Encoder = SeqAdapter; + + fn decoder(&self) -> Self::Decoder { SeqAdapter::new(self.max_frame_length) } + + fn encoder(&self) -> Self::Encoder { SeqAdapter::new(self.max_frame_length) } + + fn frame_payload(frame: &Self::Frame) -> &[u8] { frame.payload.as_slice() } + + fn wrap_payload(&self, payload: Bytes) -> Self::Frame { + SeqFrame { + sequence: self.next_sequence(), + payload: payload.to_vec(), + } + } + + fn max_frame_length(&self) -> usize { self.max_frame_length } +} + +#[derive(Debug)] +struct StatefulServer { + addr: SocketAddr, + handle: JoinHandle<()>, +} + +async fn serve_stateful_connections( + listener: TcpListener, + app: WireframeApp, +) { + for _ in 0..2 { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let _ = app.handle_connection_result(stream).await; + } +} + +#[derive(Debug, Default)] +/// Test world for stateful codec scenarios. +pub struct CodecStatefulWorld { + server: Option, + max_frame_length: usize, + first_sequences: Vec, + second_sequences: Vec, +} + +#[fixture] +pub fn codec_stateful_world() -> CodecStatefulWorld { CodecStatefulWorld::default() } + +impl CodecStatefulWorld { + /// Start a server using the sequence-aware codec. + /// + /// # Errors + /// Returns an error if binding or spawning the server fails. + pub async fn start_server(&mut self, max_frame_length: usize) -> TestResult { + let app = WireframeApp::::new()? + .with_codec(SeqFrameCodec::new(max_frame_length)) + .route(1, std::sync::Arc::new(|_: &Envelope| Box::pin(async {})))?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + serve_stateful_connections(listener, app).await; + }); + + self.server = Some(StatefulServer { addr, handle }); + self.max_frame_length = max_frame_length; + Ok(()) + } + + /// Send requests on the first connection and store sequence numbers. + /// + /// # Errors + /// Returns an error if the client cannot connect or exchange frames. + pub async fn send_first_requests(&mut self, count: usize) -> TestResult { + self.first_sequences = self.send_requests(count).await?; + Ok(()) + } + + /// Send requests on the second connection and store sequence numbers. + /// + /// # Errors + /// Returns an error if the client cannot connect or exchange frames. + pub async fn send_second_requests(&mut self, count: usize) -> TestResult { + self.second_sequences = self.send_requests(count).await?; + Ok(()) + } + + /// Verify expected sequence numbers for the first connection. + /// + /// # Errors + /// Returns an error if the observed sequence numbers do not match. + pub async fn verify_first_sequences(&self, expected: &[u64]) -> TestResult { + Self::verify_sequences(&self.first_sequences, expected, "first")?; + tokio::task::yield_now().await; + Ok(()) + } + + /// Verify expected sequence numbers for the second connection. + /// + /// # Errors + /// Returns an error if the observed sequence numbers do not match. + pub async fn verify_second_sequences(&mut self, expected: &[u64]) -> TestResult { + Self::verify_sequences(&self.second_sequences, expected, "second")?; + self.await_server().await?; + Ok(()) + } + + fn verify_sequences(sequences: &[u64], expected: &[u64], connection_name: &str) -> TestResult { + if sequences != expected { + return Err(format!( + "unexpected {connection_name} connection sequences: {sequences:?}" + ) + .into()); + } + Ok(()) + } + + async fn send_requests(&self, count: usize) -> TestResult> { + let addr = self.server.as_ref().ok_or("server not started")?.addr; + let stream = TcpStream::connect(addr).await?; + let mut framed = Framed::new(stream, SeqAdapter::new(self.max_frame_length)); + let mut sequences = Vec::with_capacity(count); + + for _ in 0..count { + let request = Envelope::new(1, None, b"ping".to_vec()); + let payload = BincodeSerializer.serialize(&request)?; + framed + .send(SeqFrame { + sequence: 0, + payload, + }) + .await?; + let frame = framed.next().await.ok_or("missing response frame")??; + sequences.push(frame.sequence); + } + + let mut stream = framed.into_inner(); + stream.shutdown().await?; + Ok(sequences) + } + + async fn await_server(&mut self) -> TestResult { + if let Some(server) = self.server.take() { + server + .handle + .await + .map_err(|err| format!("server task failed: {err}"))?; + } + Ok(()) + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index efb2ebfb..10112dc9 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -2,6 +2,7 @@ //! //! Each world from the Cucumber tests is converted to an rstest fixture here. +pub mod codec_stateful; pub mod correlation; pub mod multi_packet; pub mod panic; diff --git a/tests/bdd/scenarios/codec_stateful_scenarios.rs b/tests/bdd/scenarios/codec_stateful_scenarios.rs new file mode 100644 index 00000000..ef3f9523 --- /dev/null +++ b/tests/bdd/scenarios/codec_stateful_scenarios.rs @@ -0,0 +1,13 @@ +//! Scenario tests for stateful codec behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_stateful::*; + +#[scenario( + path = "tests/features/codec_stateful.feature", + name = "Sequence counters reset per connection" +)] +fn sequence_counters_reset(codec_stateful_world: CodecStatefulWorld) { + let _ = codec_stateful_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 0883c856..13b637fe 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -7,6 +7,7 @@ #[path = "../steps/mod.rs"] mod steps; +mod codec_stateful_scenarios; mod correlation_scenarios; mod multi_packet_scenarios; mod panic_scenarios; diff --git a/tests/bdd/steps/codec_stateful_steps.rs b/tests/bdd/steps/codec_stateful_steps.rs new file mode 100644 index 00000000..03b9059b --- /dev/null +++ b/tests/bdd/steps/codec_stateful_steps.rs @@ -0,0 +1,51 @@ +//! Step definitions for stateful codec behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::codec_stateful::{CodecStatefulWorld, TestResult}; + +#[given("a stateful wireframe server allowing frames up to {max_frame_length:usize} bytes")] +fn given_server( + codec_stateful_world: &mut CodecStatefulWorld, + max_frame_length: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.start_server(max_frame_length)) +} + +#[when("the first client sends {count:usize} requests")] +fn when_first_client_sends( + codec_stateful_world: &mut CodecStatefulWorld, + count: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.send_first_requests(count)) +} + +#[when("the second client sends {count:usize} request")] +fn when_second_client_sends( + codec_stateful_world: &mut CodecStatefulWorld, + count: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.send_second_requests(count)) +} + +#[then("the first client observes sequence numbers {first:u64} and {second:u64}")] +fn then_first_client_sequences( + codec_stateful_world: &mut CodecStatefulWorld, + first: u64, + second: u64, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.verify_first_sequences(&[first, second])) +} + +#[then("the second client observes sequence number {seq:u64}")] +fn then_second_client_sequence( + codec_stateful_world: &mut CodecStatefulWorld, + seq: u64, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_stateful_world.verify_second_sequences(&[seq])) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 7609e230..f7bc9444 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -3,6 +3,7 @@ //! Step functions are synchronous and call async world methods via //! `Runtime::new().block_on(...)`. +mod codec_stateful_steps; mod correlation_steps; mod multi_packet_steps; mod panic_steps; From dc1c8bd5df78589a402466ee78e0cb32942d2d09 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 24 Jan 2026 15:35:47 +0000 Subject: [PATCH 14/45] Migrate ClientRuntimeWorld to rstest-bdd Add client runtime fixtures, steps, and scenarios to the bdd\ntest suite and update the execplan to record Phase 3\nprogress. --- .../migrate-from-cucumber-to-rstest-bdd.md | 4 +- tests/bdd/fixtures/client_runtime.rs | 164 ++++++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + .../bdd/scenarios/client_runtime_scenarios.rs | 21 +++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/client_runtime_steps.rs | 50 ++++++ tests/bdd/steps/mod.rs | 1 + 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 tests/bdd/fixtures/client_runtime.rs create mode 100644 tests/bdd/scenarios/client_runtime_scenarios.rs create mode 100644 tests/bdd/steps/client_runtime_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 72d19cf3..1af95ae9 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -396,6 +396,8 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { **Commits**: One per world (4 commits). +**Status**: In Progress - `ClientRuntimeWorld` migrated. + ### Phase 4: Specialized Worlds (Week 8) **Selected Worlds**: @@ -486,7 +488,7 @@ pub fn fragment_world() -> FragmentWorld { | 0 | - | - | Complete | 2026-01-22 | | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | -| 3 | 4 | 20 | Not Started | - | +| 3 | 4 | 20 | In Progress | 1/4 done | | 4 | 4 | 19+ | Not Started | - | | 5 | - | - | Not Started | - | diff --git a/tests/bdd/fixtures/client_runtime.rs b/tests/bdd/fixtures/client_runtime.rs new file mode 100644 index 00000000..4fd21913 --- /dev/null +++ b/tests/bdd/fixtures/client_runtime.rs @@ -0,0 +1,164 @@ +//! `ClientRuntimeWorld` fixture for rstest-bdd tests. +//! +//! Provides an echo server/client pair to validate client runtime framing +//! behaviour. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::net::SocketAddr; + +use futures::{SinkExt, StreamExt}; +use log::warn; +use rstest::fixture; +use tokio::{net::TcpListener, task::JoinHandle}; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use wireframe::{ + BincodeSerializer, + client::{ClientCodecConfig, ClientError, WireframeClient}, + rewind_stream::RewindStream, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Test world exercising the wireframe client runtime. +#[derive(Debug, Default)] +pub struct ClientRuntimeWorld { + addr: Option, + server: Option>, + client: Option>>, + payload: Option, + response: Option, + last_error: Option, +} + +#[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] +struct ClientPayload { + data: Vec, +} + +#[fixture] +pub fn client_runtime_world() -> ClientRuntimeWorld { ClientRuntimeWorld::default() } + +impl ClientRuntimeWorld { + /// Start an echo server with the specified maximum frame length. + /// + /// # Errors + /// Returns an error if binding or spawning the server fails. + pub async fn start_server(&mut self, max_frame_length: usize) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + warn!("client runtime server failed to accept connection"); + return; + }; + let codec = LengthDelimitedCodec::builder() + .max_frame_length(max_frame_length) + .new_codec(); + let mut framed = Framed::new(stream, codec); + let Some(result) = framed.next().await else { + warn!("client runtime server closed before receiving a frame"); + return; + }; + let Ok(frame) = result else { + warn!("client runtime server failed to decode frame"); + return; + }; + if let Err(err) = framed.send(frame.freeze()).await { + warn!("client runtime server failed to send response: {err:?}"); + } + }); + + self.addr = Some(addr); + self.server = Some(handle); + Ok(()) + } + + /// Connect a client using the specified maximum frame length. + /// + /// # Errors + /// Returns an error if the server has not started or the client fails to connect. + pub async fn connect_client(&mut self, max_frame_length: usize) -> TestResult { + let addr = self.addr.ok_or("server address missing")?; + let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); + let client = WireframeClient::builder() + .codec_config(codec_config) + .connect(addr) + .await?; + self.client = Some(client); + Ok(()) + } + + /// Send a payload of the specified size and capture the response. + /// + /// # Errors + /// Returns an error if the client is missing or communication fails. + pub async fn send_payload(&mut self, size: usize) -> TestResult { + let payload = ClientPayload { + data: vec![7_u8; size], + }; + let client = self.client.as_mut().ok_or("client not connected")?; + let response: ClientPayload = client.call(&payload).await?; + self.payload = Some(payload); + self.response = Some(response); + self.last_error = None; + Ok(()) + } + + /// Send a payload that should exceed the peer's frame limit. + /// + /// # Errors + /// Returns an error if the client is missing or if no failure is observed. + pub async fn send_payload_expect_error(&mut self, size: usize) -> TestResult { + let payload = ClientPayload { + data: vec![7_u8; size], + }; + let client = self.client.as_mut().ok_or("client not connected")?; + let result: Result = client.call(&payload).await; + match result { + Ok(_) => return Err("expected client error for oversized payload".into()), + Err(err) => self.last_error = Some(err), + } + Ok(()) + } + + /// Verify that the client received the echoed payload. + /// + /// # Errors + /// Returns an error if the response is missing or mismatched. + pub async fn verify_echo(&mut self) -> TestResult { + let payload = self.payload.as_ref().ok_or("payload missing")?; + let response = self.response.as_ref().ok_or("response missing")?; + if payload != response { + return Err("response did not match payload".into()); + } + self.await_server().await?; + Ok(()) + } + + /// Verify that a client error was captured. + /// + /// # Errors + /// Returns an error if no failure was observed. + pub async fn verify_error(&mut self) -> TestResult { + let err = self + .last_error + .as_ref() + .ok_or("expected client error was not captured")?; + if !matches!(err, ClientError::Disconnected | ClientError::Io(_)) { + return Err("unexpected client error variant".into()); + } + self.await_server().await?; + Ok(()) + } + + async fn await_server(&mut self) -> TestResult { + if let Some(handle) = self.server.take() { + handle + .await + .map_err(|err| format!("server task failed: {err}"))?; + } + Ok(()) + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 10112dc9..65ad4f90 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -2,6 +2,7 @@ //! //! Each world from the Cucumber tests is converted to an rstest fixture here. +pub mod client_runtime; pub mod codec_stateful; pub mod correlation; pub mod multi_packet; diff --git a/tests/bdd/scenarios/client_runtime_scenarios.rs b/tests/bdd/scenarios/client_runtime_scenarios.rs new file mode 100644 index 00000000..af44b831 --- /dev/null +++ b/tests/bdd/scenarios/client_runtime_scenarios.rs @@ -0,0 +1,21 @@ +//! Scenario tests for wireframe client runtime behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_runtime::*; + +#[scenario( + path = "tests/features/client_runtime.feature", + name = "Client sends and receives with configured frame length" +)] +fn client_runtime_send_receive(client_runtime_world: ClientRuntimeWorld) { + let _ = client_runtime_world; +} + +#[scenario( + path = "tests/features/client_runtime.feature", + name = "Client reports errors when server frame limit is exceeded" +)] +fn client_runtime_oversize_error(client_runtime_world: ClientRuntimeWorld) { + let _ = client_runtime_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 13b637fe..763ac2e3 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -7,6 +7,7 @@ #[path = "../steps/mod.rs"] mod steps; +mod client_runtime_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; mod multi_packet_scenarios; diff --git a/tests/bdd/steps/client_runtime_steps.rs b/tests/bdd/steps/client_runtime_steps.rs new file mode 100644 index 00000000..165c7b0f --- /dev/null +++ b/tests/bdd/steps/client_runtime_steps.rs @@ -0,0 +1,50 @@ +//! Step definitions for wireframe client runtime behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::client_runtime::{ClientRuntimeWorld, TestResult}; + +#[given("a wireframe echo server allowing frames up to {max_frame_length:usize} bytes")] +fn given_server( + client_runtime_world: &mut ClientRuntimeWorld, + max_frame_length: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_runtime_world.start_server(max_frame_length)) +} + +#[given("a wireframe client configured with max frame length {max_frame_length:usize}")] +fn given_client( + client_runtime_world: &mut ClientRuntimeWorld, + max_frame_length: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_runtime_world.connect_client(max_frame_length)) +} + +#[when("the client sends a payload of {size:usize} bytes")] +fn when_send_payload(client_runtime_world: &mut ClientRuntimeWorld, size: usize) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_runtime_world.send_payload(size)) +} + +#[when("the client sends an oversized payload of {size:usize} bytes")] +fn when_send_oversized_payload( + client_runtime_world: &mut ClientRuntimeWorld, + size: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_runtime_world.send_payload_expect_error(size)) +} + +#[then("the client receives the echoed payload")] +fn then_receives_echo(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_runtime_world.verify_echo()) +} + +#[then("the client reports a framing error")] +fn then_reports_error(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_runtime_world.verify_error()) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index f7bc9444..6409ed28 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -3,6 +3,7 @@ //! Step functions are synchronous and call async world methods via //! `Runtime::new().block_on(...)`. +mod client_runtime_steps; mod codec_stateful_steps; mod correlation_steps; mod multi_packet_steps; From fa2c5dbf02d713178f074d0029e71f4bc50ca507 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 24 Jan 2026 15:41:21 +0000 Subject: [PATCH 15/45] Migrate ClientMessagingWorld to rstest-bdd Add client messaging fixtures, steps, and scenarios to the bdd\nsuite and update the migration execplan to record Phase 3\nprogress. --- .../migrate-from-cucumber-to-rstest-bdd.md | 5 +- tests/bdd/fixtures/client_messaging.rs | 308 ++++++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + .../scenarios/client_messaging_scenarios.rs | 53 +++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/client_messaging_steps.rs | 109 +++++++ tests/bdd/steps/mod.rs | 1 + 7 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 tests/bdd/fixtures/client_messaging.rs create mode 100644 tests/bdd/scenarios/client_messaging_scenarios.rs create mode 100644 tests/bdd/steps/client_messaging_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 1af95ae9..c1251ec4 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -396,7 +396,8 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `ClientRuntimeWorld` migrated. +**Status**: In Progress - `ClientRuntimeWorld` and `ClientMessagingWorld` +migrated. ### Phase 4: Specialized Worlds (Week 8) @@ -488,7 +489,7 @@ pub fn fragment_world() -> FragmentWorld { | 0 | - | - | Complete | 2026-01-22 | | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | -| 3 | 4 | 20 | In Progress | 1/4 done | +| 3 | 4 | 20 | In Progress | 2/4 done | | 4 | 4 | 19+ | Not Started | - | | 5 | - | - | Not Started | - | diff --git a/tests/bdd/fixtures/client_messaging.rs b/tests/bdd/fixtures/client_messaging.rs new file mode 100644 index 00000000..65865964 --- /dev/null +++ b/tests/bdd/fixtures/client_messaging.rs @@ -0,0 +1,308 @@ +//! `ClientMessagingWorld` fixture for rstest-bdd tests. +//! +//! Provides server/client coordination for correlation-aware message APIs. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::net::SocketAddr; + +use bytes::Bytes; +use futures::{SinkExt, StreamExt}; +use log::warn; +use rstest::fixture; +use tokio::{net::TcpListener, task::JoinHandle}; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use wireframe::{ + BincodeSerializer, + app::{Envelope, Packet}, + client::{ClientError, WireframeClient}, + correlation::CorrelatableFrame, + rewind_stream::RewindStream, +}; +use wireframe_testing::{ServerMode, process_frame}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Test world for client messaging scenarios. +#[derive(Debug, Default)] +pub struct ClientMessagingWorld { + addr: Option, + server: Option>, + client: Option>>, + envelope: Option, + sent_correlation_ids: Vec, + /// The last response received from the server. + pub response: Option, + last_error: Option, + /// Expected message ID for response verification. + expected_message_id: Option, + /// Expected payload for response verification. + expected_payload: Option, +} + +#[fixture] +pub fn client_messaging_world() -> ClientMessagingWorld { ClientMessagingWorld::default() } + +impl ClientMessagingWorld { + /// Start an envelope echo server. + /// + /// # Errors + /// Returns an error if binding or spawning the server fails. + pub async fn start_echo_server(&mut self) -> TestResult { + self.start_server_with_mode(ServerMode::Echo).await + } + + /// Start a server that returns mismatched correlation IDs. + /// + /// # Errors + /// Returns an error if binding or spawning the server fails. + pub async fn start_mismatch_server(&mut self) -> TestResult { + self.start_server_with_mode(ServerMode::Mismatch).await + } + + async fn start_server_with_mode(&mut self, mode: ServerMode) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + warn!("client messaging server failed to accept connection"); + return; + }; + + let mut framed = Framed::new(stream, LengthDelimitedCodec::new()); + run_frame_loop(&mut framed, mode).await; + }); + + self.addr = Some(addr); + self.server = Some(handle); + Ok(()) + } + + /// Connect a client to the server. + /// + /// # Errors + /// Returns an error if the server has not started or the client fails to connect. + pub async fn connect_client(&mut self) -> TestResult { + let addr = self.addr.ok_or("server address missing")?; + let client = WireframeClient::builder().connect(addr).await?; + self.client = Some(client); + Ok(()) + } + + /// Set an envelope without a correlation ID. + pub fn set_envelope_without_correlation(&mut self) { + self.envelope = Some(Envelope::new(1, None, vec![1, 2, 3])); + } + + /// Set an envelope with a specific correlation ID. + pub fn set_envelope_with_correlation(&mut self, correlation_id: u64) { + self.envelope = Some(Envelope::new(1, Some(correlation_id), vec![1, 2, 3])); + } + + /// Set an envelope with a specific message ID and payload. + pub fn set_envelope_with_payload(&mut self, message_id: u32, payload: &str) { + self.envelope = Some(Envelope::new(message_id, None, payload.as_bytes().to_vec())); + self.expected_message_id = Some(message_id); + self.expected_payload = Some(payload.to_string()); + } + + /// Send the configured envelope and capture the returned correlation ID. + /// + /// # Errors + /// Returns an error if the client is missing or communication fails. + pub async fn send_envelope(&mut self) -> TestResult { + let client = self.client.as_mut().ok_or("client not connected")?; + let envelope = self.envelope.take().ok_or("envelope not configured")?; + let correlation_id = client.send_envelope(envelope).await?; + self.sent_correlation_ids.push(correlation_id); + Ok(()) + } + + /// Call the server with `call_correlated` and capture the response. + /// + /// # Errors + /// Returns an error if the client is missing or communication fails. + pub async fn call_correlated(&mut self) -> TestResult { + let client = self.client.as_mut().ok_or("client not connected")?; + let envelope = self.envelope.take().ok_or("envelope not configured")?; + + match client.call_correlated(envelope).await { + Ok(response) => { + self.response = Some(response); + self.last_error = None; + } + Err(err) => { + self.last_error = Some(err); + self.response = None; + } + } + Ok(()) + } + + /// Send multiple sequential envelopes and capture all correlation IDs. + /// + /// # Errors + /// Returns an error if the client is missing or communication fails. + #[expect( + clippy::cast_possible_truncation, + reason = "test helper with small count values" + )] + pub async fn send_multiple_envelopes(&mut self, count: usize) -> TestResult { + let client = self.client.as_mut().ok_or("client not connected")?; + self.sent_correlation_ids.clear(); + + for i in 0..count { + let envelope = Envelope::new(i as u32, None, vec![i as u8]); + let correlation_id = client.send_envelope(envelope).await?; + self.sent_correlation_ids.push(correlation_id); + + // Drain the echo response. + let _: Envelope = client.receive_envelope().await?; + } + Ok(()) + } + + /// Get the first sent correlation ID. + fn get_first_correlation_id(&self) -> TestResult { + self.sent_correlation_ids + .first() + .copied() + .ok_or_else(|| "no correlation ID captured".into()) + } + + /// Verify that an auto-generated correlation ID was assigned. + /// + /// # Errors + /// Returns an error if no correlation ID was captured or it is zero. + pub fn verify_auto_generated_correlation(&self) -> TestResult { + let id = self.get_first_correlation_id()?; + if id == 0 { + return Err("correlation ID should be non-zero".into()); + } + Ok(()) + } + + /// Verify that the returned correlation ID matches the expected value. + /// + /// # Errors + /// Returns an error if no correlation ID was captured or it doesn't match. + pub fn verify_correlation_id(&self, expected: u64) -> TestResult { + let id = self.get_first_correlation_id()?; + if id != expected { + return Err(format!("expected correlation ID {expected}, got {id}").into()); + } + Ok(()) + } + + /// Verify that the response has a matching correlation ID. + /// + /// # Errors + /// Returns an error if no response was captured or it lacks a correlation ID. + pub fn verify_response_correlation_matches(&self) -> TestResult { + let response = self.response.as_ref().ok_or("no response captured")?; + if response.correlation_id().is_none() { + return Err("response should have correlation ID".into()); + } + Ok(()) + } + + /// Verify that no `CorrelationMismatch` error occurred. + /// + /// # Errors + /// Returns an error if any error was recorded. + pub fn verify_no_mismatch_error(&self) -> TestResult { + if self.last_error.is_some() { + return Err("unexpected error occurred".into()); + } + Ok(()) + } + + /// Verify that a `CorrelationMismatch` error occurred. + /// + /// # Errors + /// Returns an error if no mismatch error was recorded or a different error occurred. + pub fn verify_mismatch_error(&self) -> TestResult { + match &self.last_error { + Some(ClientError::CorrelationMismatch { .. }) => Ok(()), + Some(err) => Err(format!("expected CorrelationMismatch, got {err:?}").into()), + None => Err("expected CorrelationMismatch error, but none occurred".into()), + } + } + + /// Verify that all sent correlation IDs are unique. + /// + /// # Errors + /// Returns an error if any correlation IDs are duplicated. + pub fn verify_unique_correlation_ids(&self) -> TestResult { + let mut sorted = self.sent_correlation_ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + if sorted.len() != self.sent_correlation_ids.len() { + return Err("correlation IDs are not unique".into()); + } + Ok(()) + } + + /// Verify that the response matches the expected message ID and payload. + /// + /// Uses the expected values stored when the envelope was configured via + /// `set_envelope_with_payload`. + /// + /// # Errors + /// Returns an error if the response is missing, expected values weren't set, + /// or the response doesn't match. + pub fn verify_response_matches_expected(&self) -> TestResult { + let response = self.response.as_ref().ok_or("no response captured")?; + let expected_id = self + .expected_message_id + .ok_or("expected message ID not set")?; + let expected_payload = self + .expected_payload + .as_ref() + .ok_or("expected payload not set")?; + + if response.id() != expected_id { + return Err(format!("expected message ID {expected_id}, got {}", response.id()).into()); + } + if response.payload_bytes() != expected_payload.as_bytes() { + return Err(format!( + "expected payload {:?}, got {:?}", + expected_payload.as_bytes(), + response.payload_bytes() + ) + .into()); + } + Ok(()) + } + + /// Abort the server task. + pub fn abort_server(&mut self) { + if let Some(handle) = self.server.take() { + handle.abort(); + } + } +} + +/// Run the frame processing loop for the echo server. +async fn run_frame_loop(framed: &mut Framed, mode: ServerMode) +where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + while let Some(result) = framed.next().await { + let Ok(bytes) = result else { + warn!("client messaging server failed to decode frame"); + break; + }; + + let Some(response_bytes) = process_frame(mode, &bytes) else { + warn!("client messaging server failed to process frame"); + break; + }; + + if framed.send(Bytes::from(response_bytes)).await.is_err() { + break; + } + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 65ad4f90..cdf3a9ba 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -2,6 +2,7 @@ //! //! Each world from the Cucumber tests is converted to an rstest fixture here. +pub mod client_messaging; pub mod client_runtime; pub mod codec_stateful; pub mod correlation; diff --git a/tests/bdd/scenarios/client_messaging_scenarios.rs b/tests/bdd/scenarios/client_messaging_scenarios.rs new file mode 100644 index 00000000..0e162c87 --- /dev/null +++ b/tests/bdd/scenarios/client_messaging_scenarios.rs @@ -0,0 +1,53 @@ +//! Scenario tests for client messaging behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_messaging::*; + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client auto-generates correlation ID when sending envelope" +)] +fn auto_generated_correlation(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client preserves explicit correlation ID when sending envelope" +)] +fn explicit_correlation(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client call_correlated validates response correlation ID" +)] +fn call_correlated_matches(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client detects correlation ID mismatch" +)] +fn detects_mismatch(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client generates unique correlation IDs for sequential requests" +)] +fn unique_correlation_ids(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} + +#[scenario( + path = "tests/features/client_messaging.feature", + name = "Client round-trips multiple message types" +)] +fn round_trip_messages(client_messaging_world: ClientMessagingWorld) { + let _ = client_messaging_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 763ac2e3..5a72274d 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -7,6 +7,7 @@ #[path = "../steps/mod.rs"] mod steps; +mod client_messaging_scenarios; mod client_runtime_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; diff --git a/tests/bdd/steps/client_messaging_steps.rs b/tests/bdd/steps/client_messaging_steps.rs new file mode 100644 index 00000000..62a6ea61 --- /dev/null +++ b/tests/bdd/steps/client_messaging_steps.rs @@ -0,0 +1,109 @@ +//! Step definitions for client messaging behavioural tests with correlation IDs. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::client_messaging::{ClientMessagingWorld, TestResult}; + +#[given("an envelope echo server")] +fn given_echo_server(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + client_messaging_world.start_echo_server().await?; + client_messaging_world.connect_client().await + }) +} + +#[given("an envelope without a correlation ID")] +fn given_envelope_without_correlation(client_messaging_world: &mut ClientMessagingWorld) { + client_messaging_world.set_envelope_without_correlation(); +} + +#[given("an envelope with correlation ID {correlation_id:u64}")] +fn given_envelope_with_correlation( + client_messaging_world: &mut ClientMessagingWorld, + correlation_id: u64, +) { + client_messaging_world.set_envelope_with_correlation(correlation_id); +} + +#[given("an envelope with message ID {message_id:u32} and payload {payload:string}")] +fn given_envelope_with_payload( + client_messaging_world: &mut ClientMessagingWorld, + message_id: u32, + payload: String, +) { + client_messaging_world.set_envelope_with_payload(message_id, &payload); +} + +#[given("a server that returns mismatched correlation IDs")] +fn given_mismatch_server(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.abort_server(); + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + client_messaging_world.start_mismatch_server().await?; + client_messaging_world.connect_client().await + }) +} + +#[when("the client sends the envelope")] +fn when_client_sends_envelope(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_messaging_world.send_envelope()) +} + +#[when("the client calls the server with call_correlated")] +fn when_client_calls_correlated(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_messaging_world.call_correlated()) +} + +#[when("the client sends {count:usize} sequential envelopes")] +fn when_client_sends_multiple( + client_messaging_world: &mut ClientMessagingWorld, + count: usize, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_messaging_world.send_multiple_envelopes(count)) +} + +#[then("the envelope is stamped with an auto-generated correlation ID")] +fn then_auto_generated_correlation( + client_messaging_world: &mut ClientMessagingWorld, +) -> TestResult { + client_messaging_world.verify_auto_generated_correlation() +} + +#[then("the returned correlation ID is {expected:u64}")] +fn then_correlation_id_is( + client_messaging_world: &mut ClientMessagingWorld, + expected: u64, +) -> TestResult { + client_messaging_world.verify_correlation_id(expected) +} + +#[then("the response has a matching correlation ID")] +fn then_response_has_matching_correlation( + client_messaging_world: &mut ClientMessagingWorld, +) -> TestResult { + client_messaging_world.verify_response_correlation_matches() +} + +#[then("no correlation mismatch error occurs")] +fn then_no_mismatch_error(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_no_mismatch_error() +} + +#[then("a CorrelationMismatch error is returned")] +fn then_mismatch_error(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_mismatch_error() +} + +#[then("each envelope has a unique correlation ID")] +fn then_unique_correlation_ids(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_unique_correlation_ids() +} + +#[then("the response contains the same message ID and payload")] +fn then_response_matches(client_messaging_world: &mut ClientMessagingWorld) -> TestResult { + client_messaging_world.verify_response_matches_expected() +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 6409ed28..4cef5a4f 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -3,6 +3,7 @@ //! Step functions are synchronous and call async world methods via //! `Runtime::new().block_on(...)`. +mod client_messaging_steps; mod client_runtime_steps; mod codec_stateful_steps; mod correlation_steps; From c20c5ddf0dc4b1920c42a79180d46b87121a2a38 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 00:45:02 +0000 Subject: [PATCH 16/45] Migrate ClientLifecycleWorld to rstest-bdd Add client lifecycle fixtures, steps, and scenarios to the bdd\nsuite and update the execplan to mark Phase 3 progress. --- .../migrate-from-cucumber-to-rstest-bdd.md | 6 +- tests/bdd/fixtures/client_lifecycle.rs | 337 ++++++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + .../scenarios/client_lifecycle_scenarios.rs | 37 ++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/client_lifecycle_steps.rs | 127 +++++++ tests/bdd/steps/mod.rs | 1 + 7 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 tests/bdd/fixtures/client_lifecycle.rs create mode 100644 tests/bdd/scenarios/client_lifecycle_scenarios.rs create mode 100644 tests/bdd/steps/client_lifecycle_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index c1251ec4..80dc2442 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -396,8 +396,8 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `ClientRuntimeWorld` and `ClientMessagingWorld` -migrated. +**Status**: In Progress - `ClientRuntimeWorld`, `ClientMessagingWorld`, and +`ClientLifecycleWorld` migrated. ### Phase 4: Specialized Worlds (Week 8) @@ -489,7 +489,7 @@ pub fn fragment_world() -> FragmentWorld { | 0 | - | - | Complete | 2026-01-22 | | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | -| 3 | 4 | 20 | In Progress | 2/4 done | +| 3 | 4 | 20 | In Progress | 3/4 done | | 4 | 4 | 19+ | Not Started | - | | 5 | - | - | Not Started | - | diff --git a/tests/bdd/fixtures/client_lifecycle.rs b/tests/bdd/fixtures/client_lifecycle.rs new file mode 100644 index 00000000..ec8be499 --- /dev/null +++ b/tests/bdd/fixtures/client_lifecycle.rs @@ -0,0 +1,337 @@ +//! `ClientLifecycleWorld` fixture for rstest-bdd tests. +//! +//! Provides server/client coordination for lifecycle hook scenarios. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] +#![expect( + clippy::expect_used, + reason = "test code uses expect for concise assertions" +)] +#![expect( + clippy::excessive_nesting, + reason = "async closures within builder patterns are inherently nested" +)] + +use std::{ + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use futures::FutureExt; +use rstest::fixture; +use tokio::{net::TcpListener, task::JoinHandle}; +use wireframe::{ + BincodeSerializer, + client::{ClientError, WireframeClient}, + preamble::{read_preamble, write_preamble}, + rewind_stream::RewindStream, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Preamble used for testing lifecycle with preamble. +#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] +pub struct TestPreamble { + version: u16, +} + +impl TestPreamble { + /// Create a new test preamble with the given version. + #[must_use] + pub fn new(version: u16) -> Self { Self { version } } +} + +/// Server acknowledgement preamble. +#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] +pub struct ServerAck { + accepted: bool, +} + +/// State value returned by the setup callback and expected by teardown tests. +/// +/// This constant defines the sentinel value used to verify that teardown hooks +/// receive the correct state from setup hooks. +pub const EXPECTED_SETUP_STATE: u32 = 42; + +/// Client type alias for lifecycle tests. +/// +/// Uses `BincodeSerializer` with a `RewindStream` over TCP and `u32` connection state. +type TestClient = WireframeClient, u32>; + +/// Test world exercising client lifecycle hooks. +#[derive(Debug, Default)] +pub struct ClientLifecycleWorld { + addr: Option, + server: Option>, + client: Option, + setup_count: Arc, + teardown_count: Arc, + teardown_received_state: Arc, + error_count: Arc, + preamble_success_invoked: Arc, + last_error: Option, +} + +impl Drop for ClientLifecycleWorld { + fn drop(&mut self) { + if let Some(handle) = self.server.take() { + handle.abort(); + } + } +} + +#[fixture] +pub fn client_lifecycle_world() -> ClientLifecycleWorld { ClientLifecycleWorld::default() } + +impl ClientLifecycleWorld { + async fn spawn_server(&mut self, behaviour: F) -> TestResult + where + F: FnOnce(tokio::net::TcpStream) -> Fut + Send + 'static, + Fut: std::future::Future + Send, + { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + behaviour(stream).await; + }); + + self.addr = Some(addr); + self.server = Some(handle); + Ok(()) + } + + fn handle_connection_result(&mut self, result: Result) { + match result { + Ok(client) => { + self.client = Some(client); + } + Err(e) => { + self.last_error = Some(e); + } + } + } + + async fn connect_with_builder(&mut self, configure: F) -> TestResult + where + F: FnOnce( + wireframe::client::WireframeClientBuilder, + ) -> wireframe::client::WireframeClientBuilder, + P: bincode::Encode + Send + Sync + 'static, + { + let addr = self.addr.ok_or("server address missing")?; + let result = configure(WireframeClient::builder()).connect(addr).await; + self.handle_connection_result(result); + Ok(()) + } + + /// Start a standard echo server. + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if accept fails. + pub async fn start_standard_server(&mut self) -> TestResult { + self.spawn_server(|_stream| async { + tokio::time::sleep(Duration::from_millis(100)).await; + }) + .await + } + + /// Start a server that disconnects immediately after accepting. + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if accept fails. + pub async fn start_disconnecting_server(&mut self) -> TestResult { + self.spawn_server(|stream| async { + drop(stream); + }) + .await + } + + /// Start a preamble-aware server that sends acknowledgement. + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if preamble read or ack write fails. + pub async fn start_ack_server(&mut self) -> TestResult { + self.spawn_server(|mut stream| async move { + let (_preamble, _) = read_preamble::<_, TestPreamble>(&mut stream) + .await + .expect("read preamble"); + write_preamble(&mut stream, &ServerAck { accepted: true }) + .await + .expect("write ack"); + tokio::time::sleep(Duration::from_millis(100)).await; + }) + .await + } + + /// Connect with a setup callback. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_with_setup(&mut self) -> TestResult { + let setup_count = Arc::clone(&self.setup_count); + + self.connect_with_builder(|builder| { + builder.on_connection_setup(move || { + let count = setup_count.clone(); + async move { + count.fetch_add(1, Ordering::SeqCst); + EXPECTED_SETUP_STATE + } + }) + }) + .await + } + + /// Connect with setup and teardown callbacks. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_with_setup_and_teardown(&mut self) -> TestResult { + let setup_count = Arc::clone(&self.setup_count); + let teardown_count = Arc::clone(&self.teardown_count); + let teardown_received_state = Arc::clone(&self.teardown_received_state); + + self.connect_with_builder(|builder| { + builder + .on_connection_setup(move || { + let count = setup_count.clone(); + async move { + count.fetch_add(1, Ordering::SeqCst); + EXPECTED_SETUP_STATE + } + }) + .on_connection_teardown(move |state: u32| { + let count = teardown_count.clone(); + let received = teardown_received_state.clone(); + async move { + count.fetch_add(1, Ordering::SeqCst); + received.store(state as usize, Ordering::SeqCst); + } + }) + }) + .await + } + + /// Connect with an error callback. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_with_error_callback(&mut self) -> TestResult { + let error_count = Arc::clone(&self.error_count); + + self.connect_with_builder(|builder| { + builder + .on_connection_setup(|| async { 0u32 }) + .on_error(move |_err| { + let count = error_count.clone(); + async move { + count.fetch_add(1, Ordering::SeqCst); + } + }) + }) + .await + } + + /// Connect with preamble and lifecycle callbacks. + /// + /// # Errors + /// Returns an error if server address is missing. + /// + /// # Panics + /// Asserts if server does not accept preamble. + pub async fn connect_with_preamble_and_lifecycle(&mut self) -> TestResult { + let setup_count = Arc::clone(&self.setup_count); + let preamble_invoked = Arc::clone(&self.preamble_success_invoked); + + self.connect_with_builder(|builder| { + builder + .with_preamble(TestPreamble::new(1)) + .on_preamble_success(move |_preamble, stream| { + let invoked = preamble_invoked.clone(); + async move { + invoked.store(true, Ordering::SeqCst); + let (ack, leftover) = + read_preamble::<_, ServerAck>(stream).await.map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) + })?; + assert!(ack.accepted, "server should accept preamble"); + Ok(leftover) + } + .boxed() + }) + .on_connection_setup(move || { + let count = setup_count.clone(); + async move { + count.fetch_add(1, Ordering::SeqCst); + EXPECTED_SETUP_STATE + } + }) + }) + .await + } + + /// Close the client connection. + pub async fn close_client(&mut self) { + if let Some(client) = self.client.take() { + client.close().await; + } + } + + /// Attempt to receive a message (should fail after server disconnect). + /// + /// # Errors + /// Returns `Ok` but stores any receive error in `last_error`. + pub async fn attempt_receive(&mut self) -> TestResult { + if let Some(ref mut client) = self.client { + tokio::time::sleep(Duration::from_millis(50)).await; + let result: Result, ClientError> = client.receive().await; + if let Err(e) = result { + self.last_error = Some(e); + } + } + Ok(()) + } + + /// Get the setup callback invocation count. + #[must_use] + pub fn setup_count(&self) -> usize { self.setup_count.load(Ordering::SeqCst) } + + /// Get the teardown callback invocation count. + #[must_use] + pub fn teardown_count(&self) -> usize { self.teardown_count.load(Ordering::SeqCst) } + + /// Get the state received by teardown callback. + #[must_use] + pub fn teardown_received_state(&self) -> usize { + self.teardown_received_state.load(Ordering::SeqCst) + } + + /// Get the error callback invocation count. + #[must_use] + pub fn error_count(&self) -> usize { self.error_count.load(Ordering::SeqCst) } + + /// Check if preamble success callback was invoked. + #[must_use] + pub fn preamble_success_invoked(&self) -> bool { + self.preamble_success_invoked.load(Ordering::SeqCst) + } + + /// Get a reference to the last captured error, if any. + #[must_use] + pub fn last_error(&self) -> Option<&ClientError> { self.last_error.as_ref() } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index cdf3a9ba..94988582 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -2,6 +2,7 @@ //! //! Each world from the Cucumber tests is converted to an rstest fixture here. +pub mod client_lifecycle; pub mod client_messaging; pub mod client_runtime; pub mod codec_stateful; diff --git a/tests/bdd/scenarios/client_lifecycle_scenarios.rs b/tests/bdd/scenarios/client_lifecycle_scenarios.rs new file mode 100644 index 00000000..886b3ad8 --- /dev/null +++ b/tests/bdd/scenarios/client_lifecycle_scenarios.rs @@ -0,0 +1,37 @@ +//! Scenario tests for client lifecycle hook behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_lifecycle::*; + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Setup hook invoked on successful connection" +)] +fn setup_hook_invoked(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Teardown hook invoked when connection closes" +)] +fn teardown_hook_invoked(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Error hook invoked on receive failure" +)] +fn error_hook_invoked(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} + +#[scenario( + path = "tests/features/client_lifecycle.feature", + name = "Lifecycle hooks work with preamble callbacks" +)] +fn lifecycle_with_preamble(client_lifecycle_world: ClientLifecycleWorld) { + let _ = client_lifecycle_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 5a72274d..e8807f72 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -7,6 +7,7 @@ #[path = "../steps/mod.rs"] mod steps; +mod client_lifecycle_scenarios; mod client_messaging_scenarios; mod client_runtime_scenarios; mod codec_stateful_scenarios; diff --git a/tests/bdd/steps/client_lifecycle_steps.rs b/tests/bdd/steps/client_lifecycle_steps.rs new file mode 100644 index 00000000..b54df642 --- /dev/null +++ b/tests/bdd/steps/client_lifecycle_steps.rs @@ -0,0 +1,127 @@ +//! Step definitions for wireframe client lifecycle hook behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::client_lifecycle::{ClientLifecycleWorld, EXPECTED_SETUP_STATE, TestResult}; + +fn assert_count_equals(actual: usize, expected: usize, callback_name: &str) -> TestResult { + if actual != expected { + return Err(format!( + "expected {callback_name} callback to be invoked {expected} time(s), got {actual}" + ) + .into()); + } + Ok(()) +} + +#[given("a standard echo server")] +fn given_standard_server(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.start_standard_server()) +} + +#[given("a standard echo server that disconnects immediately")] +fn given_disconnecting_server(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.start_disconnecting_server()) +} + +#[given("a preamble-aware echo server that sends acknowledgement")] +fn given_ack_server(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.start_ack_server()) +} + +#[when("a client connects with a setup callback")] +fn when_connect_with_setup(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_setup()) +} + +#[when("a client connects with setup and teardown callbacks")] +fn when_connect_with_setup_and_teardown( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_setup_and_teardown()) +} + +#[when("the client closes the connection")] +fn when_client_closes(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.close_client()); + Ok(()) +} + +#[when("a client connects with an error callback")] +fn when_connect_with_error_callback( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_error_callback()) +} + +#[when("the client attempts to receive a message")] +fn when_client_attempts_receive(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.attempt_receive()) +} + +#[when("a client connects with preamble and lifecycle callbacks")] +fn when_connect_with_preamble_and_lifecycle( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_lifecycle_world.connect_with_preamble_and_lifecycle()) +} + +#[then("the setup callback is invoked exactly once")] +fn then_setup_invoked_once(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + assert_count_equals(client_lifecycle_world.setup_count(), 1, "setup") +} + +#[then("the teardown callback is invoked exactly once")] +fn then_teardown_invoked_once(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + assert_count_equals(client_lifecycle_world.teardown_count(), 1, "teardown") +} + +#[then("the teardown callback receives the state from setup")] +fn then_teardown_receives_state(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let state = client_lifecycle_world.teardown_received_state(); + let expected = EXPECTED_SETUP_STATE as usize; + if state != expected { + return Err(format!("expected teardown to receive state {expected}, got {state}").into()); + } + Ok(()) +} + +#[then("the error callback is invoked")] +fn then_error_callback_invoked(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + let count = client_lifecycle_world.error_count(); + if count == 0 { + return Err("expected error callback to be invoked at least once".into()); + } + Ok(()) +} + +#[then("the preamble success callback is invoked")] +fn then_preamble_success_invoked(client_lifecycle_world: &mut ClientLifecycleWorld) -> TestResult { + if !client_lifecycle_world.preamble_success_invoked() { + return Err("expected preamble success callback to be invoked".into()); + } + Ok(()) +} + +#[then("the client error is Disconnected")] +fn then_client_error_is_disconnected( + client_lifecycle_world: &mut ClientLifecycleWorld, +) -> TestResult { + let last_error = client_lifecycle_world + .last_error() + .ok_or("expected a captured client error in world.last_error")?; + + match last_error { + wireframe::ClientError::Disconnected => Ok(()), + other => Err(format!("expected ClientError::Disconnected, got {other:?}").into()), + } +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 4cef5a4f..05e12819 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -3,6 +3,7 @@ //! Step functions are synchronous and call async world methods via //! `Runtime::new().block_on(...)`. +mod client_lifecycle_steps; mod client_messaging_steps; mod client_runtime_steps; mod codec_stateful_steps; From 0434795e595d0327dd5dba65a74d644e6cfd3452 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:07:03 +0000 Subject: [PATCH 17/45] Migrate ClientPreambleWorld to rstest-bdd Add rstest-bdd fixture, steps, and scenarios for client preamble behaviours, keeping async work inside per-step runtimes. Disambiguate client preamble step text so cucumber and rstest-bdd registrations do not clash, and update the execplan status and feature-file guidance accordingly. --- .../migrate-from-cucumber-to-rstest-bdd.md | 13 +- tests/bdd/fixtures/client_preamble.rs | 382 ++++++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + .../scenarios/client_preamble_scenarios.rs | 37 ++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/client_preamble_steps.rs | 120 ++++++ tests/bdd/steps/mod.rs | 1 + tests/features/client_preamble.feature | 4 +- tests/steps/client_preamble_steps.rs | 4 +- 9 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 tests/bdd/fixtures/client_preamble.rs create mode 100644 tests/bdd/scenarios/client_preamble_scenarios.rs create mode 100644 tests/bdd/steps/client_preamble_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 80dc2442..17c0fa81 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -6,7 +6,7 @@ **Status**: In Progress -**Last Updated**: 2026-01-24 +**Last Updated**: 2026-01-25 ## Executive Summary @@ -133,8 +133,9 @@ fn client_messaging_world() -> ClientMessagingWorld { ### Feature File Changes -**NONE REQUIRED** - rstest-bdd uses same Gherkin parser as Cucumber. All -existing `.feature` files are 100% compatible. +Feature files remain compatible with Cucumber. Minor wording tweaks may be +required when duplicate step phrases appear across worlds (for example, +disambiguating client preamble step text to avoid ambiguous step definitions). ## Phase Breakdown @@ -396,8 +397,8 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `ClientRuntimeWorld`, `ClientMessagingWorld`, and -`ClientLifecycleWorld` migrated. +**Status**: ✅ **COMPLETE** - `ClientRuntimeWorld`, `ClientMessagingWorld`, +`ClientLifecycleWorld`, and `ClientPreambleWorld` migrated. ### Phase 4: Specialized Worlds (Week 8) @@ -489,7 +490,7 @@ pub fn fragment_world() -> FragmentWorld { | 0 | - | - | Complete | 2026-01-22 | | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | -| 3 | 4 | 20 | In Progress | 3/4 done | +| 3 | 4 | 20 | Complete | 2026-01-25 | | 4 | 4 | 19+ | Not Started | - | | 5 | - | - | Not Started | - | diff --git a/tests/bdd/fixtures/client_preamble.rs b/tests/bdd/fixtures/client_preamble.rs new file mode 100644 index 00000000..bb6d3e5f --- /dev/null +++ b/tests/bdd/fixtures/client_preamble.rs @@ -0,0 +1,382 @@ +//! `ClientPreambleWorld` fixture for rstest-bdd tests. +//! +//! Provides server/client coordination for preamble exchange scenarios. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] +#![expect( + clippy::expect_used, + reason = "test code uses expect for concise assertions" +)] +#![expect( + clippy::excessive_nesting, + reason = "async closures within builder patterns are inherently nested" +)] + +use std::{net::SocketAddr, sync::Arc, time::Duration}; + +use futures::FutureExt; +use rstest::fixture; +use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; +use wireframe::{ + BincodeSerializer, + client::{ClientError, WireframeClient}, + preamble::{read_preamble, write_preamble}, + rewind_stream::RewindStream, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Preamble used for testing. +#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] +pub struct TestPreamble { + magic: [u8; 4], + version: u16, +} + +impl TestPreamble { + const MAGIC: [u8; 4] = *b"TEST"; + + /// Create a new test preamble with the given version. + #[must_use] + pub fn new(version: u16) -> Self { + Self { + magic: Self::MAGIC, + version, + } + } + + /// Get the version. + #[must_use] + pub fn version(&self) -> u16 { self.version } +} + +/// Server acknowledgement preamble. +#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] +pub struct ServerAck { + accepted: bool, +} + +impl ServerAck { + /// Check if the connection was accepted. + #[must_use] + pub fn accepted(&self) -> bool { self.accepted } +} + +type SenderHolder = Arc>>>; + +fn create_signal_channel() -> (SenderHolder, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + (Arc::new(std::sync::Mutex::new(Some(tx))), rx) +} + +fn send_signal(holder: &std::sync::Mutex>>, value: T) { + if let Some(tx) = holder.lock().ok().and_then(|mut guard| guard.take()) { + let _ = tx.send(value); + } +} + +/// Test world exercising client preamble exchange. +#[derive(Debug, Default)] +pub struct ClientPreambleWorld { + addr: Option, + server: Option>, + client: Option>>, + server_preamble_rx: Option>, + server_received_preamble: Option, + client_received_ack: Option, + success_callback_invoked: bool, + failure_callback_invoked: bool, + last_error: Option, +} + +#[fixture] +pub fn client_preamble_world() -> ClientPreambleWorld { ClientPreambleWorld::default() } + +impl ClientPreambleWorld { + /// Start a preamble-aware echo server. + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if accept or read fails. + pub async fn start_preamble_server(&mut self) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (tx, rx) = oneshot::channel::(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let (preamble, _) = read_preamble::<_, TestPreamble>(&mut stream) + .await + .expect("read preamble"); + let _ = tx.send(preamble); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + + self.addr = Some(addr); + self.server = Some(handle); + self.server_preamble_rx = Some(rx); + Ok(()) + } + + /// Start a preamble-aware server that sends acknowledgement. + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if accept, read, or write fails. + pub async fn start_ack_server(&mut self) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let (_preamble, _) = read_preamble::<_, TestPreamble>(&mut stream) + .await + .expect("read preamble"); + write_preamble(&mut stream, &ServerAck { accepted: true }) + .await + .expect("write ack"); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + + self.addr = Some(addr); + self.server = Some(handle); + Ok(()) + } + + /// Start a server that never responds (for timeout testing). + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if accept fails. + pub async fn start_slow_server(&mut self) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.expect("accept"); + tokio::time::sleep(Duration::from_secs(10)).await; + }); + + self.addr = Some(addr); + self.server = Some(handle); + Ok(()) + } + + /// Start a standard echo server without preamble support. + /// + /// # Errors + /// Returns an error if binding fails. + /// + /// # Panics + /// The spawned task panics if accept fails. + pub async fn start_standard_server(&mut self) -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let handle = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.expect("accept"); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + + self.addr = Some(addr); + self.server = Some(handle); + Ok(()) + } + + /// Connect with preamble and success callback. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_with_preamble(&mut self, version: u16) -> TestResult { + let addr = self.addr.ok_or("server address missing")?; + let (holder, rx) = create_signal_channel::<()>(); + + let result = WireframeClient::builder() + .with_preamble(TestPreamble::new(version)) + .on_preamble_success(move |_preamble, _stream| { + let holder = holder.clone(); + async move { + send_signal(&holder, ()); + Ok(Vec::new()) + } + .boxed() + }) + .connect(addr) + .await; + + match result { + Ok(client) => { + self.client = Some(client); + if tokio::time::timeout(Duration::from_secs(1), rx) + .await + .is_ok() + { + self.success_callback_invoked = true; + } + } + Err(e) => { + self.last_error = Some(e); + } + } + + if let Some(preamble_rx) = self.server_preamble_rx.take() + && let Ok(Ok(preamble)) = + tokio::time::timeout(Duration::from_secs(1), preamble_rx).await + { + self.server_received_preamble = Some(preamble); + } + + Ok(()) + } + + /// Connect with preamble and read acknowledgement. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_with_ack(&mut self) -> TestResult { + let addr = self.addr.ok_or("server address missing")?; + let (holder, rx) = create_signal_channel::(); + + let result = WireframeClient::builder() + .with_preamble(TestPreamble::new(1)) + .on_preamble_success(move |_preamble, stream| { + let holder = holder.clone(); + async move { + let (ack, leftover) = + read_preamble::<_, ServerAck>(stream).await.map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) + })?; + send_signal(&holder, ack); + Ok(leftover) + } + .boxed() + }) + .connect(addr) + .await; + + match result { + Ok(client) => { + self.client = Some(client); + if let Ok(Ok(ack)) = tokio::time::timeout(Duration::from_secs(1), rx).await { + self.client_received_ack = Some(ack); + self.success_callback_invoked = true; + } + } + Err(e) => { + self.last_error = Some(e); + } + } + Ok(()) + } + + /// Connect with a preamble timeout. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_with_timeout(&mut self, timeout_ms: u64) -> TestResult { + let addr = self.addr.ok_or("server address missing")?; + let (failure_holder, failure_rx) = create_signal_channel::<()>(); + + let result = WireframeClient::builder() + .with_preamble(TestPreamble::new(1)) + .preamble_timeout(Duration::from_millis(timeout_ms)) + .on_preamble_success(|_preamble, stream| { + async move { + use tokio::io::AsyncReadExt; + let mut buf = [0u8; 1]; + stream.read_exact(&mut buf).await?; + Ok(Vec::new()) + } + .boxed() + }) + .on_preamble_failure(move |_err, _stream| { + let holder = failure_holder.clone(); + async move { + send_signal(&holder, ()); + Ok(()) + } + .boxed() + }) + .connect(addr) + .await; + + match result { + Ok(client) => { + self.client = Some(client); + } + Err(e) => { + self.last_error = Some(e); + if tokio::time::timeout(Duration::from_secs(1), failure_rx) + .await + .is_ok() + { + self.failure_callback_invoked = true; + } + } + } + Ok(()) + } + + /// Connect without a preamble. + /// + /// # Errors + /// Returns an error if server address is missing. + pub async fn connect_without_preamble(&mut self) -> TestResult { + let addr = self.addr.ok_or("server address missing")?; + let result = WireframeClient::builder().connect(addr).await; + + match result { + Ok(client) => { + self.client = Some(client); + } + Err(e) => { + self.last_error = Some(e); + } + } + Ok(()) + } + + /// Check if the server received the expected preamble version. + #[must_use] + pub fn server_received_version(&self) -> Option { + self.server_received_preamble + .as_ref() + .map(TestPreamble::version) + } + + /// Check if success callback was invoked. + #[must_use] + pub fn success_invoked(&self) -> bool { self.success_callback_invoked } + + /// Check if failure callback was invoked. + #[must_use] + pub fn failure_invoked(&self) -> bool { self.failure_callback_invoked } + + /// Check if client received accepted ack. + #[must_use] + pub fn ack_accepted(&self) -> bool { + self.client_received_ack + .as_ref() + .is_some_and(ServerAck::accepted) + } + + /// Check if last error was a timeout. + #[must_use] + pub fn was_timeout_error(&self) -> bool { + matches!(self.last_error, Some(ClientError::PreambleTimeout)) + } + + /// Check if client is connected. + #[must_use] + pub fn is_connected(&self) -> bool { self.client.is_some() } + + /// Abort the server task. + pub fn abort_server(&mut self) { + if let Some(handle) = self.server.take() { + handle.abort(); + } + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 94988582..60e2b04a 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -4,6 +4,7 @@ pub mod client_lifecycle; pub mod client_messaging; +pub mod client_preamble; pub mod client_runtime; pub mod codec_stateful; pub mod correlation; diff --git a/tests/bdd/scenarios/client_preamble_scenarios.rs b/tests/bdd/scenarios/client_preamble_scenarios.rs new file mode 100644 index 00000000..5ce76bbf --- /dev/null +++ b/tests/bdd/scenarios/client_preamble_scenarios.rs @@ -0,0 +1,37 @@ +//! Scenario tests for wireframe client preamble behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::client_preamble::*; + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client sends preamble and server acknowledges" +)] +fn client_preamble_send_and_ack(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client receives server acknowledgement in success callback" +)] +fn client_preamble_receives_ack(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client preamble timeout triggers failure callback" +)] +fn client_preamble_timeout_failure(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} + +#[scenario( + path = "tests/features/client_preamble.feature", + name = "Client without preamble connects normally" +)] +fn client_preamble_no_preamble(client_preamble_world: ClientPreambleWorld) { + let _ = client_preamble_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index e8807f72..2e508c19 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -9,6 +9,7 @@ mod steps; mod client_lifecycle_scenarios; mod client_messaging_scenarios; +mod client_preamble_scenarios; mod client_runtime_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; diff --git a/tests/bdd/steps/client_preamble_steps.rs b/tests/bdd/steps/client_preamble_steps.rs new file mode 100644 index 00000000..ca6e57ff --- /dev/null +++ b/tests/bdd/steps/client_preamble_steps.rs @@ -0,0 +1,120 @@ +//! Step definitions for wireframe client preamble behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::client_preamble::{ClientPreambleWorld, TestResult}; + +#[given("a preamble-aware echo server")] +fn given_preamble_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_preamble_server()) +} + +#[given("a preamble-aware echo server that sends an acknowledgement preamble")] +fn given_ack_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_ack_server()) +} + +#[given("a slow preamble server that never responds")] +fn given_slow_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_slow_server()) +} + +#[given("a standard echo server without preamble support")] +fn given_standard_server(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.start_standard_server()) +} + +#[when("a client connects with a preamble containing version {version:u16}")] +fn when_connect_with_version( + client_preamble_world: &mut ClientPreambleWorld, + version: u16, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_with_preamble(version)) +} + +#[when("a client connects with a preamble and reads the acknowledgement")] +fn when_connect_with_ack(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_with_ack()) +} + +#[when("a client connects with a {timeout_ms:u64}ms preamble timeout")] +fn when_connect_with_timeout( + client_preamble_world: &mut ClientPreambleWorld, + timeout_ms: u64, +) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_with_timeout(timeout_ms)) +} + +#[when("a client connects without a preamble")] +fn when_connect_without_preamble(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(client_preamble_world.connect_without_preamble()) +} + +#[then("the server receives the preamble with version {version:u16}")] +fn then_server_receives_preamble( + client_preamble_world: &mut ClientPreambleWorld, + version: u16, +) -> TestResult { + if client_preamble_world.server_received_version() != Some(version) { + return Err(format!( + "expected server to receive version {}, got {:?}", + version, + client_preamble_world.server_received_version() + ) + .into()); + } + Ok(()) +} + +#[then("the client success callback is invoked")] +fn then_success_callback_invoked(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.success_invoked() { + return Err("expected success callback to be invoked".into()); + } + client_preamble_world.abort_server(); + Ok(()) +} + +#[then("the client receives an accepted acknowledgement")] +fn then_receives_ack(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.ack_accepted() { + return Err("expected client to receive accepted acknowledgement".into()); + } + client_preamble_world.abort_server(); + Ok(()) +} + +#[then("the client fails with a timeout error")] +fn then_timeout_error(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.was_timeout_error() { + return Err("expected timeout error".into()); + } + client_preamble_world.abort_server(); + Ok(()) +} + +#[then("the failure callback is invoked")] +fn then_failure_callback_invoked(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.failure_invoked() { + return Err("expected failure callback to be invoked".into()); + } + client_preamble_world.abort_server(); + Ok(()) +} + +#[then("the client connects successfully")] +fn then_connects_successfully(client_preamble_world: &mut ClientPreambleWorld) -> TestResult { + if !client_preamble_world.is_connected() { + return Err("expected client to connect successfully".into()); + } + client_preamble_world.abort_server(); + Ok(()) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 05e12819..8b8bf45c 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -5,6 +5,7 @@ mod client_lifecycle_steps; mod client_messaging_steps; +mod client_preamble_steps; mod client_runtime_steps; mod codec_stateful_steps; mod correlation_steps; diff --git a/tests/features/client_preamble.feature b/tests/features/client_preamble.feature index fab34f48..1a5fedaf 100644 --- a/tests/features/client_preamble.feature +++ b/tests/features/client_preamble.feature @@ -8,7 +8,7 @@ Feature: Client preamble exchange And the client success callback is invoked Scenario: Client receives server acknowledgement in success callback - Given a preamble-aware echo server that sends acknowledgement + Given a preamble-aware echo server that sends an acknowledgement preamble When a client connects with a preamble and reads the acknowledgement Then the client receives an accepted acknowledgement @@ -19,6 +19,6 @@ Feature: Client preamble exchange And the failure callback is invoked Scenario: Client without preamble connects normally - Given a standard echo server + Given a standard echo server without preamble support When a client connects without a preamble Then the client connects successfully diff --git a/tests/steps/client_preamble_steps.rs b/tests/steps/client_preamble_steps.rs index 87cf524e..5904b760 100644 --- a/tests/steps/client_preamble_steps.rs +++ b/tests/steps/client_preamble_steps.rs @@ -9,7 +9,7 @@ async fn given_preamble_server(world: &mut ClientPreambleWorld) -> TestResult { world.start_preamble_server().await } -#[given("a preamble-aware echo server that sends acknowledgement")] +#[given("a preamble-aware echo server that sends an acknowledgement preamble")] async fn given_ack_server(world: &mut ClientPreambleWorld) -> TestResult { world.start_ack_server().await } @@ -19,7 +19,7 @@ async fn given_slow_server(world: &mut ClientPreambleWorld) -> TestResult { world.start_slow_server().await } -#[given("a standard echo server")] +#[given("a standard echo server without preamble support")] async fn given_standard_server(world: &mut ClientPreambleWorld) -> TestResult { world.start_standard_server().await } From 31cc87d2880154bd65b5c971f63ee9f22b412468 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:19:32 +0000 Subject: [PATCH 18/45] Migrate MessageAssemblerWorld to rstest-bdd Add rstest-bdd fixtures, steps, and scenarios for message assembler header parsing, and use async scenarios for sync-only steps. Disambiguate metadata length wording to avoid step registry clashes, update the execplan progress, and remove the now-unused dead_code expectation for TestApp. --- .../migrate-from-cucumber-to-rstest-bdd.md | 4 +- tests/bdd/fixtures/message_assembler.rs | 382 ++++++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + tests/bdd/mod.rs | 1 - .../scenarios/message_assembler_scenarios.rs | 65 +++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/message_assembler_steps.rs | 205 ++++++++++ tests/bdd/steps/mod.rs | 1 + tests/features/message_assembler.feature | 6 +- tests/steps/message_assembler_steps.rs | 2 +- 10 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 tests/bdd/fixtures/message_assembler.rs create mode 100644 tests/bdd/scenarios/message_assembler_scenarios.rs create mode 100644 tests/bdd/steps/message_assembler_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 17c0fa81..9fc19c2a 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -368,6 +368,8 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). +**Status**: In Progress - `MessageAssemblerWorld` migrated. + **Status**: ✅ **COMPLETE** - `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, and `CodecStatefulWorld` migrated. @@ -491,7 +493,7 @@ pub fn fragment_world() -> FragmentWorld { | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | | 3 | 4 | 20 | Complete | 2026-01-25 | -| 4 | 4 | 19+ | Not Started | - | +| 4 | 4 | 19+ | In Progress | 1/4 done | | 5 | - | - | Not Started | - | **Total**: 14 worlds, 60+ scenarios diff --git a/tests/bdd/fixtures/message_assembler.rs b/tests/bdd/fixtures/message_assembler.rs new file mode 100644 index 00000000..9b63b412 --- /dev/null +++ b/tests/bdd/fixtures/message_assembler.rs @@ -0,0 +1,382 @@ +//! `MessageAssemblerWorld` fixture for rstest-bdd tests. +//! +//! Provides header parsing helpers for message assembler scenarios. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +use std::{fmt, io}; + +use bytes::{BufMut, BytesMut}; +use rstest::fixture; +use wireframe::{ + message_assembler::{FrameHeader, FrameSequence, MessageAssembler, ParsedFrameHeader}, + test_helpers::TestAssembler, +}; + +use crate::TestApp; +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Specification for first-frame header encoding used in tests. +#[derive(Debug, Clone, Copy)] +pub struct FirstHeaderSpec { + /// Message key to encode into the header. + pub key: u64, + /// Metadata length in bytes. + pub metadata_len: usize, + /// Body length in bytes for this frame. + pub body_len: usize, + /// Optional total body length across all frames. + pub total_len: Option, + /// Whether the frame is the final one in the series. + pub is_last: bool, +} + +/// Specification for continuation-frame header encoding used in tests. +#[derive(Debug, Clone, Copy)] +pub struct ContinuationHeaderSpec { + /// Message key to encode into the header. + pub key: u64, + /// Body length in bytes for this frame. + pub body_len: usize, + /// Optional sequence number. + pub sequence: Option, + /// Whether the frame is the final one in the series. + pub is_last: bool, +} + +#[derive(Debug, Clone, Copy)] +struct HeaderEnvelope { + kind: u8, + flags: u8, + key: u64, +} + +/// Test world for message assembler header parsing. +#[derive(Default)] +pub struct MessageAssemblerWorld { + payload: Option>, + parsed: Option, + error: Option, + app: Option, +} + +impl fmt::Debug for MessageAssemblerWorld { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MessageAssemblerWorld") + .field("payload", &self.payload) + .field("parsed", &self.parsed) + .field("error", &self.error) + .field( + "app", + &self.app.as_ref().map(|_| "wireframe::app::WireframeApp"), + ) + .finish() + } +} + +#[fixture] +pub fn message_assembler_world() -> MessageAssemblerWorld { MessageAssemblerWorld::default() } + +impl MessageAssemblerWorld { + fn assert_common_field(&self, field: &str, expected: &T, extractor: F) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&FrameHeader) -> T, + { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let actual = extractor(parsed.header()); + if actual != *expected { + return Err(format!("expected {field} {expected}, got {actual}").into()); + } + Ok(()) + } + + /// Generic helper for asserting header-type-specific fields. + /// + /// The extractor performs both type-checking (via pattern matching) and field + /// extraction, returning an error message if the header type is incorrect. + fn assert_header_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&FrameHeader) -> Result, + { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let actual = extractor(parsed.header()).map_err(ToString::to_string)?; + if actual != *expected { + return Err(format!("expected {field_name} {expected}, got {actual}").into()); + } + Ok(()) + } + + /// Store an encoded first-frame header in the world payload. + /// + /// # Errors + /// + /// Returns an error if any length field exceeds the header encoding limits. + pub fn set_first_header(&mut self, spec: FirstHeaderSpec) -> TestResult { + let mut flags = 0u8; + if spec.is_last { + flags |= 0b1; + } + if spec.total_len.is_some() { + flags |= 0b10; + } + self.set_payload_with_header( + HeaderEnvelope { + kind: 0x01, + flags, + key: spec.key, + }, + |bytes| { + let metadata_len = + u16::try_from(spec.metadata_len).map_err(|_| "metadata length too large")?; + bytes.put_u16(metadata_len); + let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; + bytes.put_u32(body_len); + if let Some(total) = spec.total_len { + let total = u32::try_from(total).map_err(|_| "total length too large")?; + bytes.put_u32(total); + } + Ok(()) + }, + ) + } + + /// Store an encoded continuation-frame header in the world payload. + /// + /// # Errors + /// + /// Returns an error if any length field exceeds the header encoding limits. + pub fn set_continuation_header(&mut self, spec: ContinuationHeaderSpec) -> TestResult { + let mut flags = 0u8; + if spec.is_last { + flags |= 0b1; + } + if spec.sequence.is_some() { + flags |= 0b10; + } + self.set_payload_with_header( + HeaderEnvelope { + kind: 0x02, + flags, + key: spec.key, + }, + |bytes| { + let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; + bytes.put_u32(body_len); + if let Some(seq) = spec.sequence { + bytes.put_u32(seq); + } + Ok(()) + }, + ) + } + + fn set_payload_with_header(&mut self, envelope: HeaderEnvelope, encode: F) -> TestResult + where + F: FnOnce(&mut BytesMut) -> TestResult, + { + let mut bytes = BytesMut::new(); + bytes.put_u8(envelope.kind); + bytes.put_u8(envelope.flags); + bytes.put_u64(envelope.key); + encode(&mut bytes)?; + self.payload = Some(bytes.to_vec()); + Ok(()) + } + + /// Store a deliberately invalid header payload. + pub fn set_invalid_payload(&mut self) { self.payload = Some(vec![0x01]); } + + /// Parse the stored payload with the test assembler. + /// + /// # Errors + /// + /// Returns an error if no payload has been configured. + pub fn parse_header(&mut self) -> TestResult { + let payload = self.payload.as_deref().ok_or("payload not set")?; + let fallback = TestAssembler; + let assembler: &dyn MessageAssembler = match self.app.as_ref() { + Some(app) => app + .message_assembler() + .ok_or("message assembler not set")? + .as_ref(), + None => &fallback, + }; + match assembler.parse_frame_header(payload) { + Ok(parsed) => { + self.parsed = Some(parsed); + self.error = None; + } + Err(err) => { + self.parsed = None; + self.error = Some(err); + } + } + Ok(()) + } + + /// Assert that the parsed header is of the expected kind. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the kind does not match. + pub fn assert_header_kind(&self, expected: &str) -> TestResult { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let matches_kind = matches!( + (expected, parsed.header()), + ("first", FrameHeader::First(_)) | ("continuation", FrameHeader::Continuation(_)) + ); + if matches_kind { + Ok(()) + } else { + Err(format!("expected {expected} header").into()) + } + } + + /// Assert that the parsed header contains the expected message key. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the key does not match. + pub fn assert_message_key(&self, expected: u64) -> TestResult { + self.assert_common_field("key", &expected, |header| match header { + FrameHeader::First(header) => u64::from(header.message_key), + FrameHeader::Continuation(header) => u64::from(header.message_key), + }) + } + + /// Assert that the parsed header contains the expected metadata length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the metadata length differs. + pub fn assert_metadata_len(&self, expected: usize) -> TestResult { + self.assert_header_field("metadata length", &expected, |header| { + if let FrameHeader::First(header) = header { + Ok(header.metadata_len) + } else { + Err("expected first header") + } + }) + } + + /// Assert that the parsed header contains the expected body length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the body length differs. + pub fn assert_body_len(&self, expected: usize) -> TestResult { + self.assert_common_field("body length", &expected, |header| match header { + FrameHeader::First(header) => header.body_len, + FrameHeader::Continuation(header) => header.body_len, + }) + } + + /// Assert that the parsed header contains the expected total body length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the total length differs. + pub fn assert_total_len(&self, expected: Option) -> TestResult { + let expected = DebugDisplay(expected); + self.assert_header_field("total length", &expected, |header| { + if let FrameHeader::First(header) = header { + Ok(DebugDisplay(header.total_body_len)) + } else { + Err("expected first header") + } + }) + } + + /// Assert that the parsed header contains the expected sequence. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the sequence differs. + pub fn assert_sequence(&self, expected: Option) -> TestResult { + let expected = expected.map(FrameSequence::from); + let expected = DebugDisplay(expected); + self.assert_header_field("sequence", &expected, |header| { + if let FrameHeader::Continuation(header) = header { + Ok(DebugDisplay(header.sequence)) + } else { + Err("expected continuation header") + } + }) + } + + /// Assert that the parsed header matches the expected `is_last` flag. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the flag differs. + pub fn assert_is_last(&self, expected: bool) -> TestResult { + self.assert_common_field("is_last", &expected, |header| match header { + FrameHeader::First(header) => header.is_last, + FrameHeader::Continuation(header) => header.is_last, + }) + } + + /// Assert that the parsed header length matches the expected value. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the length differs. + pub fn assert_header_len(&self, expected: usize) -> TestResult { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let actual = parsed.header_len(); + if actual != expected { + return Err(format!("expected header length {expected}, got {actual}").into()); + } + Ok(()) + } + + /// Assert that the parse failed with `InvalidData`. + /// + /// # Errors + /// + /// Returns an error if no parse error was captured or the kind differs. + pub fn assert_invalid_data_error(&self) -> TestResult { + let err = self.error.as_ref().ok_or("expected error")?; + if err.kind() != io::ErrorKind::InvalidData { + return Err(format!("expected InvalidData error, got {:?}", err.kind()).into()); + } + Ok(()) + } + + /// Store a wireframe app configured with a test message assembler. + /// + /// # Errors + /// + /// Returns an error if the app builder fails. + pub fn set_app_with_message_assembler(&mut self) -> TestResult { + let app = TestApp::new() + .map_err(|err| format!("failed to build app: {err}"))? + .with_message_assembler(TestAssembler); + self.app = Some(app); + Ok(()) + } + + /// Assert that the app exposes a message assembler. + /// + /// # Errors + /// + /// Returns an error if the app or assembler is missing. + pub fn assert_message_assembler_configured(&self) -> TestResult { + let app = self.app.as_ref().ok_or("app not set")?; + if app.message_assembler().is_some() { + Ok(()) + } else { + Err("expected message assembler".into()) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct DebugDisplay(T); + +impl fmt::Display for DebugDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 60e2b04a..a0c0ef4e 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -8,6 +8,7 @@ pub mod client_preamble; pub mod client_runtime; pub mod codec_stateful; pub mod correlation; +pub mod message_assembler; pub mod multi_packet; pub mod panic; pub mod request_parts; diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs index 4d066b36..e5481b1a 100644 --- a/tests/bdd/mod.rs +++ b/tests/bdd/mod.rs @@ -18,7 +18,6 @@ mod support; use wireframe::{app::Envelope, push::PushQueues, serializer::BincodeSerializer}; -#[expect(dead_code, reason = "shared type not used by all test scenarios yet")] pub(crate) type TestApp = wireframe::app::WireframeApp; pub(crate) fn build_small_queues() diff --git a/tests/bdd/scenarios/message_assembler_scenarios.rs b/tests/bdd/scenarios/message_assembler_scenarios.rs new file mode 100644 index 00000000..8b4fd6bd --- /dev/null +++ b/tests/bdd/scenarios/message_assembler_scenarios.rs @@ -0,0 +1,65 @@ +//! Scenario tests for message assembler header parsing. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::message_assembler::*; + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Builder exposes a configured message assembler" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_builder_configured(message_assembler_world: MessageAssemblerWorld) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a first frame header without total length" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_first_header_without_total( + message_assembler_world: MessageAssemblerWorld, +) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a first frame header with total length" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_first_header_with_total(message_assembler_world: MessageAssemblerWorld) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a continuation header with sequence" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_continuation_with_sequence( + message_assembler_world: MessageAssemblerWorld, +) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Parsing a continuation header without sequence" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_continuation_without_sequence( + message_assembler_world: MessageAssemblerWorld, +) { + let _ = message_assembler_world; +} + +#[scenario( + path = "tests/features/message_assembler.feature", + name = "Invalid header payload returns error" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembler_invalid_payload(message_assembler_world: MessageAssemblerWorld) { + let _ = message_assembler_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 2e508c19..743cc866 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -13,6 +13,7 @@ mod client_preamble_scenarios; mod client_runtime_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; +mod message_assembler_scenarios; mod multi_packet_scenarios; mod panic_scenarios; mod request_parts_scenarios; diff --git a/tests/bdd/steps/message_assembler_steps.rs b/tests/bdd/steps/message_assembler_steps.rs new file mode 100644 index 00000000..a22aaaea --- /dev/null +++ b/tests/bdd/steps/message_assembler_steps.rs @@ -0,0 +1,205 @@ +//! Step definitions for message assembler header parsing. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::message_assembler::{ + ContinuationHeaderSpec, + FirstHeaderSpec, + MessageAssemblerWorld, + TestResult, +}; + +const DEFAULT_METADATA_LEN: usize = 0; +const FLAG_NONE: bool = false; +const FLAG_LAST: bool = true; +const NO_SEQUENCE: Option = None; +const NO_TOTAL_LEN: Option = None; + +// Helper builders to reduce duplication in step definitions +fn first_header_without_total(key: u64, metadata_len: usize, body_len: usize) -> FirstHeaderSpec { + FirstHeaderSpec { + key, + metadata_len, + body_len, + total_len: NO_TOTAL_LEN, + is_last: FLAG_NONE, + } +} + +fn first_header_with_total(key: u64, body_len: usize, total_len: usize) -> FirstHeaderSpec { + FirstHeaderSpec { + key, + metadata_len: DEFAULT_METADATA_LEN, + body_len, + total_len: Some(total_len), + is_last: FLAG_LAST, + } +} + +fn continuation_header_with_sequence( + key: u64, + body_len: usize, + sequence: u32, +) -> ContinuationHeaderSpec { + ContinuationHeaderSpec { + key, + body_len, + sequence: Some(sequence), + is_last: FLAG_NONE, + } +} + +fn continuation_header_without_sequence(key: u64, body_len: usize) -> ContinuationHeaderSpec { + ContinuationHeaderSpec { + key, + body_len, + sequence: NO_SEQUENCE, + is_last: FLAG_LAST, + } +} + +#[given( + "a first frame header with key {key:u64} metadata length {metadata_len:usize} body length \ + {body_len:usize}" +)] +fn given_first_header( + message_assembler_world: &mut MessageAssemblerWorld, + key: u64, + metadata_len: usize, + body_len: usize, +) -> TestResult { + message_assembler_world.set_first_header(first_header_without_total( + key, + metadata_len, + body_len, + )) +} + +#[given( + "a first frame header with key {key:u64} body length {body_len:usize} total {total_len:usize}" +)] +fn given_first_header_with_total( + message_assembler_world: &mut MessageAssemblerWorld, + key: u64, + body_len: usize, + total_len: usize, +) -> TestResult { + message_assembler_world.set_first_header(first_header_with_total(key, body_len, total_len)) +} + +#[given( + "a continuation header with key {key:u64} body length {body_len:usize} sequence {sequence:u32}" +)] +fn given_continuation_header_with_sequence( + message_assembler_world: &mut MessageAssemblerWorld, + key: u64, + body_len: usize, + sequence: u32, +) -> TestResult { + message_assembler_world + .set_continuation_header(continuation_header_with_sequence(key, body_len, sequence)) +} + +#[given("a continuation header with key {key:u64} body length {body_len:usize}")] +fn given_continuation_header( + message_assembler_world: &mut MessageAssemblerWorld, + key: u64, + body_len: usize, +) -> TestResult { + message_assembler_world + .set_continuation_header(continuation_header_without_sequence(key, body_len)) +} + +#[given("a wireframe app with a message assembler")] +fn given_app_with_message_assembler( + message_assembler_world: &mut MessageAssemblerWorld, +) -> TestResult { + message_assembler_world.set_app_with_message_assembler() +} + +#[given("an invalid message header")] +fn given_invalid_header(message_assembler_world: &mut MessageAssemblerWorld) { + message_assembler_world.set_invalid_payload(); +} + +#[when("the message assembler parses the header")] +fn when_parsing(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.parse_header() +} + +#[then("the parsed header is {kind}")] +fn then_header_kind( + message_assembler_world: &mut MessageAssemblerWorld, + kind: String, +) -> TestResult { + message_assembler_world.assert_header_kind(&kind) +} + +#[then("the message key is {key:u64}")] +fn then_message_key(message_assembler_world: &mut MessageAssemblerWorld, key: u64) -> TestResult { + message_assembler_world.assert_message_key(key) +} + +#[then("the header metadata length is {metadata_len:usize}")] +fn then_metadata_len( + message_assembler_world: &mut MessageAssemblerWorld, + metadata_len: usize, +) -> TestResult { + message_assembler_world.assert_metadata_len(metadata_len) +} + +#[then("the body length is {body_len:usize}")] +fn then_body_len( + message_assembler_world: &mut MessageAssemblerWorld, + body_len: usize, +) -> TestResult { + message_assembler_world.assert_body_len(body_len) +} + +#[then("the header length is {header_len:usize}")] +fn then_header_len( + message_assembler_world: &mut MessageAssemblerWorld, + header_len: usize, +) -> TestResult { + message_assembler_world.assert_header_len(header_len) +} + +#[then("the total body length is absent")] +fn then_total_absent(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.assert_total_len(None) +} + +#[then("the total body length is {total:usize}")] +fn then_total_present( + message_assembler_world: &mut MessageAssemblerWorld, + total: usize, +) -> TestResult { + message_assembler_world.assert_total_len(Some(total)) +} + +#[then("the sequence is {sequence:u32}")] +fn then_sequence(message_assembler_world: &mut MessageAssemblerWorld, sequence: u32) -> TestResult { + message_assembler_world.assert_sequence(Some(sequence)) +} + +#[then("the sequence is absent")] +fn then_sequence_absent(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.assert_sequence(None) +} + +#[then("the frame is marked last {expected:bool}")] +fn then_is_last(message_assembler_world: &mut MessageAssemblerWorld, expected: bool) -> TestResult { + message_assembler_world.assert_is_last(expected) +} + +#[then("the parse fails with invalid data")] +fn then_invalid_data(message_assembler_world: &mut MessageAssemblerWorld) -> TestResult { + message_assembler_world.assert_invalid_data_error() +} + +#[then("the app exposes a message assembler")] +fn then_app_exposes_message_assembler( + message_assembler_world: &mut MessageAssemblerWorld, +) -> TestResult { + message_assembler_world.assert_message_assembler_configured() +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 8b8bf45c..b851aa89 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -9,6 +9,7 @@ mod client_preamble_steps; mod client_runtime_steps; mod codec_stateful_steps; mod correlation_steps; +mod message_assembler_steps; mod multi_packet_steps; mod panic_steps; mod request_parts_steps; diff --git a/tests/features/message_assembler.feature b/tests/features/message_assembler.feature index 98597a37..9bfe2af5 100644 --- a/tests/features/message_assembler.feature +++ b/tests/features/message_assembler.feature @@ -10,7 +10,7 @@ Feature: Message assembler header parsing Then the app exposes a message assembler And the parsed header is first And the message key is 9 - And the metadata length is 2 + And the header metadata length is 2 And the body length is 12 And the header length is 16 And the total body length is absent @@ -21,7 +21,7 @@ Feature: Message assembler header parsing When the message assembler parses the header Then the parsed header is first And the message key is 9 - And the metadata length is 2 + And the header metadata length is 2 And the body length is 12 And the header length is 16 And the total body length is absent @@ -32,7 +32,7 @@ Feature: Message assembler header parsing When the message assembler parses the header Then the parsed header is first And the message key is 42 - And the metadata length is 0 + And the header metadata length is 0 And the body length is 8 And the header length is 20 And the total body length is 64 diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs index c167cb23..60887aeb 100644 --- a/tests/steps/message_assembler_steps.rs +++ b/tests/steps/message_assembler_steps.rs @@ -120,7 +120,7 @@ fn then_message_key(world: &mut MessageAssemblerWorld, key: u64) -> crate::world world.assert_message_key(key) } -#[then(expr = "the metadata length is {int}")] +#[then(expr = "the header metadata length is {int}")] fn then_metadata_len( world: &mut MessageAssemblerWorld, metadata_len: usize, From dc0e634fc2c49c409dd96fdbfa0170581ad73dbd Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:29:54 +0000 Subject: [PATCH 19/45] Migrate MessageAssemblyWorld to rstest-bdd Add message assembly fixtures (including shared param types), steps, and scenario bindings, using async scenarios for sync-only steps. Update execplan progress to reflect the second Phase 4 world. --- .../migrate-from-cucumber-to-rstest-bdd.md | 5 +- tests/bdd/fixtures/message_assembly.rs | 310 ++++++++++++++++++ tests/bdd/fixtures/message_assembly_params.rs | 83 +++++ tests/bdd/fixtures/mod.rs | 1 + .../scenarios/message_assembly_scenarios.rs | 86 +++++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/message_assembly_steps.rs | 278 ++++++++++++++++ tests/bdd/steps/mod.rs | 1 + 8 files changed, 763 insertions(+), 2 deletions(-) create mode 100644 tests/bdd/fixtures/message_assembly.rs create mode 100644 tests/bdd/fixtures/message_assembly_params.rs create mode 100644 tests/bdd/scenarios/message_assembly_scenarios.rs create mode 100644 tests/bdd/steps/message_assembly_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 9fc19c2a..31b31a6b 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -368,7 +368,8 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `MessageAssemblerWorld` migrated. +**Status**: In Progress - `MessageAssemblerWorld` and `MessageAssemblyWorld` +migrated. **Status**: ✅ **COMPLETE** - `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, and `CodecStatefulWorld` migrated. @@ -493,7 +494,7 @@ pub fn fragment_world() -> FragmentWorld { | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | | 3 | 4 | 20 | Complete | 2026-01-25 | -| 4 | 4 | 19+ | In Progress | 1/4 done | +| 4 | 4 | 19+ | In Progress | 2/4 done | | 5 | - | - | Not Started | - | **Total**: 14 worlds, 60+ scenarios diff --git a/tests/bdd/fixtures/message_assembly.rs b/tests/bdd/fixtures/message_assembly.rs new file mode 100644 index 00000000..e83dd082 --- /dev/null +++ b/tests/bdd/fixtures/message_assembly.rs @@ -0,0 +1,310 @@ +//! `MessageAssemblyWorld` fixture for rstest-bdd tests. +//! +//! Provides state and helpers for message assembly multiplexing scenarios. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +#[path = "message_assembly_params.rs"] +mod message_assembly_params; + +use std::{ + collections::VecDeque, + fmt, + num::NonZeroUsize, + time::{Duration, Instant}, +}; + +pub use message_assembly_params::{ContinuationFrameParams, FirstFrameParams}; +use rstest::fixture; +use wireframe::message_assembler::{ + AssembledMessage, + ContinuationFrameHeader, + FirstFrameHeader, + FirstFrameInput, + FrameSequence, + MessageAssemblyError, + MessageAssemblyState, + MessageKey, + MessageSeriesError, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Test world for message assembly multiplexing scenarios. +#[derive(Default)] +pub struct MessageAssemblyWorld { + state: Option, + current_time: Option, + pending_first_frames: VecDeque, + last_result: Option, MessageAssemblyError>>, + completed_messages: Vec, + evicted_keys: Vec, +} + +/// Pending first frame awaiting acceptance. +pub struct PendingFirstFrame { + /// Frame header. + pub header: FirstFrameHeader, + /// Metadata bytes. + pub metadata: Vec, + /// Body bytes. + pub body: Vec, +} + +impl fmt::Debug for MessageAssemblyWorld { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MessageAssemblyWorld") + .field( + "state", + &self.state.as_ref().map(|_| "MessageAssemblyState"), + ) + .field("current_time", &self.current_time) + .field("pending_first_frames", &self.pending_first_frames.len()) + .field("last_result", &self.last_result) + .field("completed_messages", &self.completed_messages.len()) + .field("evicted_keys", &self.evicted_keys) + .finish() + } +} + +#[fixture] +pub fn message_assembly_world() -> MessageAssemblyWorld { MessageAssemblyWorld::default() } + +impl MessageAssemblyWorld { + /// Initialise the assembly state with size limit and timeout. + /// + /// # Panics + /// + /// Panics if `max_size` is zero because `MessageAssemblyState` requires a + /// positive size limit. + pub fn create_state(&mut self, max_size: usize, timeout_secs: u64) { + let Some(size) = NonZeroUsize::new(max_size) else { + panic!("max_size must be non-zero for MessageAssemblyState"); + }; + self.state = Some(MessageAssemblyState::new( + size, + Duration::from_secs(timeout_secs), + )); + self.current_time = Some(Instant::now()); + self.pending_first_frames.clear(); + self.last_result = None; + self.completed_messages.clear(); + self.evicted_keys.clear(); + } + + /// Queue a first frame for later acceptance (FIFO). + pub fn add_first_frame(&mut self, params: FirstFrameParams) { + self.pending_first_frames.push_back(PendingFirstFrame { + header: FirstFrameHeader { + message_key: params.key, + metadata_len: params.metadata.len(), + body_len: params.body.len(), + total_body_len: None, + is_last: params.is_last, + }, + metadata: params.metadata, + body: params.body, + }); + } + + /// Accept the first queued first frame (FIFO). + /// + /// # Errors + /// + /// Returns an error if no pending frames, state not initialised, or time not set. + pub fn accept_first_frame(&mut self) -> TestResult { + let Some(pending) = self.pending_first_frames.pop_front() else { + return Err("no pending first frame".into()); + }; + let Some(state) = self.state.as_mut() else { + return Err("state not initialised".into()); + }; + let Some(now) = self.current_time else { + return Err("time not set".into()); + }; + let input = FirstFrameInput::new(&pending.header, pending.metadata, &pending.body) + .map_err(|e| format!("invalid input: {e}"))?; + self.last_result = Some(state.accept_first_frame_at(input, now)); + if let Some(Ok(Some(msg))) = &self.last_result { + self.completed_messages.push(msg.clone()); + } + Ok(()) + } + + /// Accept all queued first frames. + /// + /// # Errors + /// + /// Returns an error if state not initialised or time not set. + pub fn accept_all_first_frames(&mut self) -> TestResult { + let Some(state) = self.state.as_mut() else { + return Err("state not initialised".into()); + }; + let Some(now) = self.current_time else { + return Err("time not set".into()); + }; + + while let Some(pending) = self.pending_first_frames.pop_front() { + let input = FirstFrameInput::new(&pending.header, pending.metadata, &pending.body) + .map_err(|e| format!("invalid input: {e}"))?; + let result = state.accept_first_frame_at(input, now); + if let Ok(Some(msg)) = &result { + self.completed_messages.push(msg.clone()); + } + self.last_result = Some(result); + } + Ok(()) + } + + /// Accept a continuation frame for the given key. + /// + /// # Errors + /// + /// Returns an error if state not initialised or time not set. + #[expect( + clippy::needless_pass_by_value, + reason = "parameter object consistency with add_first_frame API" + )] + pub fn accept_continuation(&mut self, params: ContinuationFrameParams) -> TestResult { + let Some(state) = self.state.as_mut() else { + return Err("state not initialised".into()); + }; + let Some(now) = self.current_time else { + return Err("time not set".into()); + }; + + let header = ContinuationFrameHeader { + message_key: params.key, + sequence: params.sequence, + body_len: params.body.len(), + is_last: params.is_last, + }; + self.last_result = Some(state.accept_continuation_frame_at(&header, ¶ms.body, now)); + if let Some(Ok(Some(msg))) = &self.last_result { + self.completed_messages.push(msg.clone()); + } + Ok(()) + } + + /// Advance the simulated clock. + /// + /// # Errors + /// + /// Returns an error if time not set. + pub fn advance_time(&mut self, secs: u64) -> TestResult { + let Some(current) = self.current_time else { + return Err("time not set".into()); + }; + self.current_time = Some(current + Duration::from_secs(secs)); + Ok(()) + } + + /// Purge expired assemblies and record evicted keys. + /// + /// # Errors + /// + /// Returns an error if state not initialised or time not set. + pub fn purge_expired(&mut self) -> TestResult { + let Some(state) = self.state.as_mut() else { + return Err("state not initialised".into()); + }; + let Some(now) = self.current_time else { + return Err("time not set".into()); + }; + self.evicted_keys = state.purge_expired_at(now); + Ok(()) + } + + /// Number of partial assemblies currently buffered. + #[must_use] + pub fn buffered_count(&self) -> usize { + self.state + .as_ref() + .map_or(0, MessageAssemblyState::buffered_count) + } + + /// Whether the last result indicates an incomplete assembly. + #[must_use] + pub fn last_result_is_incomplete(&self) -> bool { matches!(self.last_result, Some(Ok(None))) } + + /// Body of the most recently completed message. + #[must_use] + pub fn last_completed_body(&self) -> Option<&[u8]> { + self.completed_messages.last().map(AssembledMessage::body) + } + + /// Body of the completed message for the given key. + #[must_use] + pub fn completed_body_for_key(&self, key: MessageKey) -> Option<&[u8]> { + self.completed_messages + .iter() + .rev() + .find(|m| m.message_key() == key) + .map(AssembledMessage::body) + } + + /// Last error, if any. + #[must_use] + pub fn last_error(&self) -> Option<&MessageAssemblyError> { + match &self.last_result { + Some(Err(e)) => Some(e), + _ => None, + } + } + + /// Whether the last error is a sequence mismatch. + #[must_use] + pub fn is_sequence_mismatch(&self, expected: FrameSequence, found: FrameSequence) -> bool { + matches!( + self.last_error(), + Some(MessageAssemblyError::Series(MessageSeriesError::SequenceMismatch { + expected: e, + found: f, + })) if *e == expected && *f == found + ) + } + + /// Whether the last error is a duplicate frame. + #[must_use] + pub fn is_duplicate_frame(&self, key: MessageKey, sequence: FrameSequence) -> bool { + matches!( + self.last_error(), + Some(MessageAssemblyError::Series(MessageSeriesError::DuplicateFrame { + key: k, + sequence: s, + })) if *k == key && *s == sequence + ) + } + + /// Whether the last error is a missing first frame. + #[must_use] + pub fn is_missing_first_frame(&self, key: MessageKey) -> bool { + matches!( + self.last_error(), + Some(MessageAssemblyError::Series(MessageSeriesError::MissingFirstFrame { key: k })) if *k == key + ) + } + + /// Whether the last error is a duplicate first frame. + #[must_use] + pub fn is_duplicate_first_frame(&self, key: MessageKey) -> bool { + matches!( + self.last_error(), + Some(MessageAssemblyError::DuplicateFirstFrame { key: k }) if *k == key + ) + } + + /// Whether the last error is message too large. + #[must_use] + pub fn is_message_too_large(&self, key: MessageKey) -> bool { + matches!( + self.last_error(), + Some(MessageAssemblyError::MessageTooLarge { key: k, .. }) if *k == key + ) + } + + /// Whether the given key was evicted. + #[must_use] + pub fn was_evicted(&self, key: MessageKey) -> bool { self.evicted_keys.contains(&key) } +} diff --git a/tests/bdd/fixtures/message_assembly_params.rs b/tests/bdd/fixtures/message_assembly_params.rs new file mode 100644 index 00000000..246b8d78 --- /dev/null +++ b/tests/bdd/fixtures/message_assembly_params.rs @@ -0,0 +1,83 @@ +//! Parameter objects for message assembly test steps. + +use wireframe::message_assembler::{FrameSequence, MessageKey}; + +/// Parameters for creating a first frame. +#[derive(Debug)] +pub struct FirstFrameParams { + /// Message key. + pub key: MessageKey, + /// Metadata bytes. + pub metadata: Vec, + /// Body bytes. + pub body: Vec, + /// Whether this is the final frame. + pub is_last: bool, +} + +impl FirstFrameParams { + /// Create parameters for a first frame with default values. + #[must_use] + pub fn new(key: MessageKey, body: Vec) -> Self { + Self { + key, + metadata: vec![], + body, + is_last: false, + } + } + + /// Set metadata bytes. + #[must_use] + pub fn with_metadata(mut self, metadata: Vec) -> Self { + self.metadata = metadata; + self + } + + /// Mark as the final frame. + #[must_use] + pub fn final_frame(mut self) -> Self { + self.is_last = true; + self + } +} + +/// Parameters for creating a continuation frame. +#[derive(Debug)] +pub struct ContinuationFrameParams { + /// Message key. + pub key: MessageKey, + /// Optional sequence number. + pub sequence: Option, + /// Body bytes. + pub body: Vec, + /// Whether this is the final frame. + pub is_last: bool, +} + +impl ContinuationFrameParams { + /// Create parameters for a continuation frame with default sequence 1. + #[must_use] + pub fn new(key: MessageKey, body: Vec) -> Self { + Self { + key, + sequence: Some(FrameSequence(1)), + body, + is_last: false, + } + } + + /// Set the sequence number. + #[must_use] + pub fn with_sequence(mut self, sequence: FrameSequence) -> Self { + self.sequence = Some(sequence); + self + } + + /// Mark as the final frame. + #[must_use] + pub fn final_frame(mut self) -> Self { + self.is_last = true; + self + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index a0c0ef4e..e5dc1ebd 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -9,6 +9,7 @@ pub mod client_runtime; pub mod codec_stateful; pub mod correlation; pub mod message_assembler; +pub mod message_assembly; pub mod multi_packet; pub mod panic; pub mod request_parts; diff --git a/tests/bdd/scenarios/message_assembly_scenarios.rs b/tests/bdd/scenarios/message_assembly_scenarios.rs new file mode 100644 index 00000000..cbde22b5 --- /dev/null +++ b/tests/bdd/scenarios/message_assembly_scenarios.rs @@ -0,0 +1,86 @@ +//! Scenario tests for message assembly multiplexing and continuity validation. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::message_assembly::*; + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Single message assembly completes successfully" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_single_message(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Single-frame message completes immediately" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_single_frame(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Interleaved messages assemble independently" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_interleaved(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Out-of-order continuation is rejected but assembly retained" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_out_of_order(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Duplicate continuation is rejected but assembly retained" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_duplicate_continuation(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Continuation without first frame is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_missing_first(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Duplicate first frame is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_duplicate_first(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Message exceeding size limit is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_too_large(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} + +#[scenario( + path = "tests/features/message_assembly.feature", + name = "Expired assemblies are purged" +)] +#[tokio::test(flavor = "current_thread")] +async fn message_assembly_expired(message_assembly_world: MessageAssemblyWorld) { + let _ = message_assembly_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 743cc866..ee093119 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -14,6 +14,7 @@ mod client_runtime_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; mod message_assembler_scenarios; +mod message_assembly_scenarios; mod multi_packet_scenarios; mod panic_scenarios; mod request_parts_scenarios; diff --git a/tests/bdd/steps/message_assembly_steps.rs b/tests/bdd/steps/message_assembly_steps.rs new file mode 100644 index 00000000..a1c41699 --- /dev/null +++ b/tests/bdd/steps/message_assembly_steps.rs @@ -0,0 +1,278 @@ +//! Step definitions for message assembly multiplexing and continuity validation. + +use rstest_bdd_macros::{given, then, when}; +use wireframe::message_assembler::{FrameSequence, MessageKey}; + +use crate::fixtures::message_assembly::{ + ContinuationFrameParams, + FirstFrameParams, + MessageAssemblyWorld, + TestResult, +}; + +/// Convert primitive key to domain type at the boundary. +fn to_key(key: u64) -> MessageKey { MessageKey(key) } + +/// Convert primitive sequence to domain type at the boundary. +fn to_seq(seq: u32) -> FrameSequence { FrameSequence(seq) } + +/// Helper function to reduce duplication in Then step assertions. +fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult { + if condition { + Ok(()) + } else { + Err(error_msg.into().into()) + } +} + +// ============================================================================= +// Given steps +// ============================================================================= + +#[given( + "a message assembly state with max size {max_size:usize} and timeout {timeout:u64} seconds" +)] +fn given_state(message_assembly_world: &mut MessageAssemblyWorld, max_size: usize, timeout: u64) { + message_assembly_world.create_state(max_size, timeout); +} + +#[given("a first frame for key {key:u64} with metadata {metadata:string} and body {body:string}")] +fn given_first_frame_with_metadata( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + metadata: String, + body: String, +) { + message_assembly_world.add_first_frame( + FirstFrameParams::new(to_key(key), body.into_bytes()).with_metadata(metadata.into_bytes()), + ); +} + +#[given("a first frame for key {key:u64} with body {body:string}")] +fn given_first_frame(message_assembly_world: &mut MessageAssemblyWorld, key: u64, body: String) { + message_assembly_world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); +} + +#[given("a final first frame for key {key:u64} with body {body:string}")] +fn given_final_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + body: String, +) { + message_assembly_world + .add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes()).final_frame()); +} + +// ============================================================================= +// When steps +// ============================================================================= + +#[when("the first frame is accepted")] +#[when("the first frame is accepted at time T")] +fn when_first_frame_accepted(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + message_assembly_world.accept_first_frame() +} + +#[when("all first frames are accepted")] +fn when_all_first_frames_accepted(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + message_assembly_world.accept_all_first_frames() +} + +#[when( + "a final continuation for key {key:u64} with sequence {sequence:u32} and body {body:string} \ + arrives" +)] +fn when_final_continuation( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + sequence: u32, + body: String, +) -> TestResult { + message_assembly_world.accept_continuation( + ContinuationFrameParams::new(to_key(key), body.into_bytes()) + .with_sequence(to_seq(sequence)) + .final_frame(), + ) +} + +#[when("a continuation for key {key:u64} with sequence {sequence:u32} arrives")] +fn when_continuation_with_seq( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + sequence: u32, +) -> TestResult { + message_assembly_world.accept_continuation( + ContinuationFrameParams::new(to_key(key), b"data".to_vec()).with_sequence(to_seq(sequence)), + ) +} + +#[when("a continuation for key {key:u64} with body {body:string} arrives")] +fn when_continuation_with_body( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + body: String, +) -> TestResult { + message_assembly_world + .accept_continuation(ContinuationFrameParams::new(to_key(key), body.into_bytes())) +} + +#[when("another first frame for key {key:u64} with body {body:string} arrives")] +fn when_another_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + body: String, +) -> TestResult { + message_assembly_world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); + message_assembly_world.accept_first_frame() +} + +#[when("time advances by {secs:u64} seconds")] +fn when_time_advances(message_assembly_world: &mut MessageAssemblyWorld, secs: u64) -> TestResult { + message_assembly_world.advance_time(secs) +} + +#[when("expired assemblies are purged")] +fn when_purge_expired(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + message_assembly_world.purge_expired() +} + +// ============================================================================= +// Then steps +// ============================================================================= + +#[then("the assembly result is incomplete")] +fn then_result_incomplete(message_assembly_world: &mut MessageAssemblyWorld) -> TestResult { + assert_condition( + message_assembly_world.last_result_is_incomplete(), + "expected incomplete result", + ) +} + +#[then("the assembly completes with body {body:string}")] +fn then_completes_with_body( + message_assembly_world: &mut MessageAssemblyWorld, + body: String, +) -> TestResult { + let actual = message_assembly_world + .last_completed_body() + .ok_or("expected completed message")?; + assert_condition( + actual == body.as_bytes(), + format!( + "body mismatch: expected {:?}, got {:?}", + body.as_bytes(), + actual + ), + ) +} + +#[then("key {key:u64} completes with body {body:string}")] +fn then_key_completes( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + body: String, +) -> TestResult { + let actual = message_assembly_world + .completed_body_for_key(to_key(key)) + .ok_or_else(|| format!("expected completed message for key {key}"))?; + assert_condition( + actual == body.as_bytes(), + format!( + "body mismatch for key {key}: expected {:?}, got {:?}", + body.as_bytes(), + actual + ), + ) +} + +#[then("the buffered count is {count:usize}")] +fn then_buffered_count( + message_assembly_world: &mut MessageAssemblyWorld, + count: usize, +) -> TestResult { + let actual = message_assembly_world.buffered_count(); + assert_condition( + actual == count, + format!("buffered count mismatch: expected {count}, got {actual}"), + ) +} + +#[then("the error is sequence mismatch expecting {expected:u32} but found {found:u32}")] +fn then_error_sequence_mismatch( + message_assembly_world: &mut MessageAssemblyWorld, + expected: u32, + found: u32, +) -> TestResult { + assert_condition( + message_assembly_world.is_sequence_mismatch(to_seq(expected), to_seq(found)), + format!( + "expected sequence mismatch error, got {:?}", + message_assembly_world.last_error() + ), + ) +} + +#[then("the error is duplicate frame for key {key:u64} sequence {sequence:u32}")] +fn then_error_duplicate_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, + sequence: u32, +) -> TestResult { + assert_condition( + message_assembly_world.is_duplicate_frame(to_key(key), to_seq(sequence)), + format!( + "expected duplicate frame error, got {:?}", + message_assembly_world.last_error() + ), + ) +} + +#[then("the error is missing first frame for key {key:u64}")] +fn then_error_missing_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, +) -> TestResult { + assert_condition( + message_assembly_world.is_missing_first_frame(to_key(key)), + format!( + "expected missing first frame error, got {:?}", + message_assembly_world.last_error() + ), + ) +} + +#[then("the error is duplicate first frame for key {key:u64}")] +fn then_error_duplicate_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, +) -> TestResult { + assert_condition( + message_assembly_world.is_duplicate_first_frame(to_key(key)), + format!( + "expected duplicate first frame error, got {:?}", + message_assembly_world.last_error() + ), + ) +} + +#[then("the error is message too large for key {key:u64}")] +fn then_error_message_too_large( + message_assembly_world: &mut MessageAssemblyWorld, + key: u64, +) -> TestResult { + assert_condition( + message_assembly_world.is_message_too_large(to_key(key)), + format!( + "expected message too large error, got {:?}", + message_assembly_world.last_error() + ), + ) +} + +#[then("key {key:u64} was evicted")] +fn then_key_evicted(message_assembly_world: &mut MessageAssemblyWorld, key: u64) -> TestResult { + assert_condition( + message_assembly_world.was_evicted(to_key(key)), + format!("expected key {key} to be evicted"), + ) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index b851aa89..90417858 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -10,6 +10,7 @@ mod client_runtime_steps; mod codec_stateful_steps; mod correlation_steps; mod message_assembler_steps; +mod message_assembly_steps; mod multi_packet_steps; mod panic_steps; mod request_parts_steps; From 152e8ac38e0dcb69ada3fdf1e8bade18287bc972 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:37:37 +0000 Subject: [PATCH 20/45] Migrate CodecErrorWorld to rstest-bdd Add codec error fixtures, decoder operations, steps, and scenarios to cover taxonomy and recovery policy behaviour, using async scenarios for sync-only steps. Update execplan progress for Phase 4. --- .../migrate-from-cucumber-to-rstest-bdd.md | 6 +- tests/bdd/fixtures/codec_error/decoder_ops.rs | 222 ++++++++++++++++++ tests/bdd/fixtures/codec_error/mod.rs | 194 +++++++++++++++ tests/bdd/fixtures/mod.rs | 1 + tests/bdd/scenarios/codec_error_scenarios.rs | 37 +++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/codec_error_steps.rs | 100 ++++++++ tests/bdd/steps/mod.rs | 1 + 8 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 tests/bdd/fixtures/codec_error/decoder_ops.rs create mode 100644 tests/bdd/fixtures/codec_error/mod.rs create mode 100644 tests/bdd/scenarios/codec_error_scenarios.rs create mode 100644 tests/bdd/steps/codec_error_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 31b31a6b..18bb2380 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -368,8 +368,8 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `MessageAssemblerWorld` and `MessageAssemblyWorld` -migrated. +**Status**: In Progress - `MessageAssemblerWorld`, `MessageAssemblyWorld`, and +`CodecErrorWorld` migrated. **Status**: ✅ **COMPLETE** - `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, and `CodecStatefulWorld` migrated. @@ -494,7 +494,7 @@ pub fn fragment_world() -> FragmentWorld { | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | | 3 | 4 | 20 | Complete | 2026-01-25 | -| 4 | 4 | 19+ | In Progress | 2/4 done | +| 4 | 4 | 19+ | In Progress | 3/4 done | | 5 | - | - | Not Started | - | **Total**: 14 worlds, 60+ scenarios diff --git a/tests/bdd/fixtures/codec_error/decoder_ops.rs b/tests/bdd/fixtures/codec_error/decoder_ops.rs new file mode 100644 index 00000000..ea42da8f --- /dev/null +++ b/tests/bdd/fixtures/codec_error/decoder_ops.rs @@ -0,0 +1,222 @@ +//! End-to-end decoder operations for codec error taxonomy tests. +//! +//! Provides real decoder operations to validate EOF error handling and frame +//! encoding/decoding behaviour in realistic scenarios. + +use bytes::BytesMut; +use tokio_util::codec::Decoder; +use wireframe::{ + FrameCodec, + codec::{EofError, LENGTH_HEADER_SIZE, LengthDelimitedFrameCodec}, +}; + +use super::{CodecErrorWorld, TestResult}; + +impl CodecErrorWorld { + /// Reset codec state to prepare for a new test operation. + fn reset_codec_state(&mut self) { + self.buffer = BytesMut::new(); + self.decoder_error = None; + self.clean_close_detected = false; + } + + /// Configure the codec with default settings. + pub fn setup_default_codec(&mut self) { + self.max_frame_length = 1024; + self.reset_codec_state(); + } + + /// Configure the codec with a specific max frame length. + pub fn setup_codec_with_max_length(&mut self, max_len: usize) { + self.max_frame_length = max_len; + self.reset_codec_state(); + } + + /// Simulate a client sending a complete frame by encoding data into the buffer. + /// + /// # Errors + /// + /// Returns an error if encoding fails. + pub fn send_complete_frame(&mut self, payload: &[u8]) -> TestResult { + use tokio_util::codec::Encoder; + + let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); + let mut encoder = codec.encoder(); + encoder.encode(bytes::Bytes::copy_from_slice(payload), &mut self.buffer)?; + Ok(()) + } + + /// Simulate a client sending partial frame data (header only, no payload). + pub fn send_partial_frame_header_only(&mut self) { + // Write a length prefix indicating 100 bytes, but don't write any payload + // 4-byte big-endian length prefix + self.buffer.extend_from_slice(&[0x00, 0x00, 0x00, 0x64]); // 100 bytes expected + } + + /// Call `decode_eof` to simulate a clean close at frame boundary. + /// + /// Returns `true` if `Ok(None)` was returned, indicating clean close. + /// + /// # Errors + /// + /// Returns an error if clean close was not detected. + pub fn decode_eof_clean_close(&mut self) -> TestResult { + let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); + let mut decoder = codec.decoder(); + + // First decode any complete frames + while let Some(_frame) = decoder.decode(&mut self.buffer)? { + // Consume complete frames + } + + // Now call decode_eof to handle EOF + match decoder.decode_eof(&mut self.buffer) { + Ok(None) => { + self.clean_close_detected = true; + self.detected_eof = Some(EofError::CleanClose); + Ok(()) + } + Ok(Some(_)) => Err("unexpected frame after EOF".into()), + Err(e) => { + self.decoder_error = Some(e); + Err("expected clean close, got error".into()) + } + } + } + + /// Extract the expected payload length from the buffer's length header. + /// + /// Returns 0 if the buffer doesn't contain a complete length header. + #[expect( + clippy::big_endian_bytes, + reason = "Wire protocol uses big-endian length prefix; this matches the codec." + )] + fn extract_expected_length(&self) -> usize { + self.buffer + .get(..LENGTH_HEADER_SIZE) + .and_then(|slice| <[u8; LENGTH_HEADER_SIZE]>::try_from(slice).ok()) + .map_or(0, |bytes| u32::from_be_bytes(bytes) as usize) + } + + /// Classify the EOF error type from the error message. + /// + /// # Implementation Note + /// + /// This method infers EOF type by checking if the error message contains + /// "header". This is fragile: if the upstream error message format changes, + /// this classification will silently produce incorrect results. + /// + /// When the buffer contains at least 4 bytes (a complete length header), + /// we extract the expected payload length from the big-endian u32 prefix. + /// + /// This approach is acceptable for test code where we control the error + /// messages, but would need a more robust solution (e.g., downcasting to + /// `CodecError`) if the underlying error type becomes available. + // FIXME(#418): Replace string-matching with downcasting when CodecError + // becomes available in the io::Error source chain. + fn classify_eof_error(&mut self, e: &std::io::Error) { + if e.kind() != std::io::ErrorKind::UnexpectedEof { + return; + } + let msg = e.to_string(); + self.detected_eof = Some(if msg.contains("header") { + EofError::MidHeader { + bytes_received: self.buffer.len(), + header_size: LENGTH_HEADER_SIZE, + } + } else { + EofError::MidFrame { + bytes_received: self.buffer.len().saturating_sub(LENGTH_HEADER_SIZE), + expected: self.extract_expected_length(), + } + }); + } + + /// Call `decode_eof` when buffer has incomplete data. + /// + /// # Errors + /// + /// Returns an error if no EOF error was produced. + pub fn decode_eof_with_partial_data(&mut self) -> TestResult { + let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); + let mut decoder = codec.decoder(); + + match decoder.decode_eof(&mut self.buffer) { + Ok(None) => Err("expected EOF error, got Ok(None)".into()), + Ok(Some(_)) => Err("expected EOF error, got frame".into()), + Err(e) => { + self.classify_eof_error(&e); + self.decoder_error = Some(e); + Ok(()) + } + } + } + + /// Attempt to encode an oversized frame. + /// + /// # Errors + /// + /// Returns an error if no oversized error was produced. + pub fn encode_oversized_frame(&mut self, size: usize) -> TestResult { + use tokio_util::codec::Encoder; + + let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); + let mut encoder = codec.encoder(); + let payload = bytes::Bytes::from(vec![0_u8; size]); + + match encoder.encode(payload, &mut self.buffer) { + Ok(()) => Err("expected oversized error, got Ok".into()), + Err(e) => { + self.decoder_error = Some(e); + Ok(()) + } + } + } + + /// Verify that a clean EOF was detected. + /// + /// # Errors + /// + /// Returns an error if no EOF was detected or if a non-clean EOF was detected. + pub fn verify_clean_eof(&self) -> TestResult { + if self.clean_close_detected { + return Ok(()); + } + match &self.detected_eof { + Some(EofError::CleanClose) => Ok(()), + Some(other) => Err(format!("expected clean close, got {other:?}").into()), + None => Err("no EOF was detected".into()), + } + } + + /// Verify that an incomplete EOF was detected (either mid-frame or mid-header). + /// + /// # Errors + /// + /// Returns an error if no EOF was detected or if it was a clean close. + pub fn verify_incomplete_eof(&self) -> TestResult { + match &self.detected_eof { + Some(EofError::MidFrame { .. } | EofError::MidHeader { .. }) => Ok(()), + Some(other) => Err(format!("expected incomplete EOF, got {other:?}").into()), + None => Err("no EOF was detected".into()), + } + } + + /// Verify that an oversized frame error was detected. + /// + /// # Errors + /// + /// Returns an error if no error was captured or if it wasn't an oversized error. + pub fn verify_oversized_error(&self) -> TestResult { + let err = self + .decoder_error + .as_ref() + .ok_or("no decoder error captured")?; + if err.kind() == std::io::ErrorKind::InvalidData { + // OversizedFrame is converted to InvalidData + Ok(()) + } else { + Err(format!("expected InvalidData error, got {:?}", err.kind()).into()) + } + } +} diff --git a/tests/bdd/fixtures/codec_error/mod.rs b/tests/bdd/fixtures/codec_error/mod.rs new file mode 100644 index 00000000..0fcda3fb --- /dev/null +++ b/tests/bdd/fixtures/codec_error/mod.rs @@ -0,0 +1,194 @@ +//! `CodecErrorWorld` fixture for rstest-bdd tests. +//! +//! Verifies codec error taxonomy and recovery policy defaults. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +mod decoder_ops; + +use bytes::BytesMut; +use rstest::fixture; +use wireframe::codec::{CodecError, EofError, FramingError, ProtocolError, RecoveryPolicy}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Codec error type for test scenarios. +#[derive(Clone, Copy, Debug, Default)] +pub enum ErrorType { + #[default] + Framing, + Protocol, + Io, + Eof, +} + +/// Specific error variant for framing errors. +#[derive(Clone, Copy, Debug, Default)] +pub enum FramingVariant { + #[default] + Oversized, + InvalidEncoding, + IncompleteHeader, + ChecksumMismatch, + Empty, +} + +/// Specific error variant for EOF errors. +#[derive(Clone, Copy, Debug, Default)] +pub enum EofVariant { + #[default] + CleanClose, + MidFrame, + MidHeader, +} + +/// Test world for codec error taxonomy scenarios. +#[derive(Debug, Default)] +pub struct CodecErrorWorld { + /// Current error type category being tested. + error_type: ErrorType, + /// Specific framing error variant when `error_type` is `Framing`. + framing_variant: FramingVariant, + /// Specific EOF error variant when `error_type` is `Eof`. + eof_variant: EofVariant, + /// Constructed error based on `error_type` and variant settings. + current_error: Option, + /// EOF error detected during decoder operations. + pub(crate) detected_eof: Option, + /// Maximum frame length for the codec under test. + pub(crate) max_frame_length: usize, + /// Buffer simulating received data from a client. + pub(crate) buffer: BytesMut, + /// Decoder error captured during test. + pub(crate) decoder_error: Option, + /// Whether `decode_eof` returned `Ok(None)` for clean close. + pub(crate) clean_close_detected: bool, +} + +#[fixture] +pub fn codec_error_world() -> CodecErrorWorld { CodecErrorWorld::default() } + +impl CodecErrorWorld { + /// Set the current error type being tested. + /// + /// # Errors + /// + /// Returns an error if `error_type` is not one of: `framing`, `protocol`, + /// `io`, or `eof`. + pub fn set_error_type(&mut self, error_type: &str) -> TestResult { + self.error_type = match error_type { + "framing" => ErrorType::Framing, + "protocol" => ErrorType::Protocol, + "io" => ErrorType::Io, + "eof" => ErrorType::Eof, + _ => return Err(format!("unknown error type: {error_type}").into()), + }; + self.build_error(); + Ok(()) + } + + /// Set the framing error variant. + /// + /// # Errors + /// + /// Returns an error if `variant` is not a recognised framing variant. + pub fn set_framing_variant(&mut self, variant: &str) -> TestResult { + self.framing_variant = match variant { + "oversized" => FramingVariant::Oversized, + "invalid_encoding" => FramingVariant::InvalidEncoding, + "incomplete_header" => FramingVariant::IncompleteHeader, + "checksum_mismatch" => FramingVariant::ChecksumMismatch, + "empty" => FramingVariant::Empty, + _ => return Err(format!("unknown framing variant: {variant}").into()), + }; + self.build_error(); + Ok(()) + } + + /// Set the EOF error variant. + /// + /// # Errors + /// + /// Returns an error if `variant` is not a recognised EOF variant. + pub fn set_eof_variant(&mut self, variant: &str) -> TestResult { + self.eof_variant = match variant { + "clean_close" => EofVariant::CleanClose, + "mid_frame" => EofVariant::MidFrame, + "mid_header" => EofVariant::MidHeader, + _ => return Err(format!("unknown eof variant: {variant}").into()), + }; + self.build_error(); + Ok(()) + } + + /// Build the current error based on type and variant settings. + fn build_error(&mut self) { + self.current_error = Some(match self.error_type { + ErrorType::Framing => CodecError::Framing(self.build_framing_error()), + ErrorType::Protocol => { + CodecError::Protocol(ProtocolError::UnknownMessageType { type_id: 99 }) + } + ErrorType::Io => CodecError::Io(std::io::Error::other("test error")), + ErrorType::Eof => CodecError::Eof(self.build_eof_error()), + }); + } + + /// Build a framing error based on the current variant. + fn build_framing_error(&self) -> FramingError { + match self.framing_variant { + FramingVariant::Oversized => FramingError::OversizedFrame { + size: 2000, + max: 1024, + }, + FramingVariant::InvalidEncoding => FramingError::InvalidLengthEncoding, + FramingVariant::IncompleteHeader => FramingError::IncompleteHeader { have: 2, need: 4 }, + FramingVariant::ChecksumMismatch => FramingError::ChecksumMismatch { + expected: 0xdead, + actual: 0xbeef, + }, + FramingVariant::Empty => FramingError::EmptyFrame, + } + } + + /// Build an EOF error based on the current variant. + fn build_eof_error(&self) -> EofError { + match self.eof_variant { + EofVariant::CleanClose => EofError::CleanClose, + EofVariant::MidFrame => EofError::MidFrame { + bytes_received: 100, + expected: 200, + }, + EofVariant::MidHeader => EofError::MidHeader { + bytes_received: 2, + header_size: 4, + }, + } + } + + /// Verify the recovery policy for the current error. + /// + /// # Errors + /// + /// Returns an error if `expected` is not a recognised policy or if the + /// actual policy does not match the expected policy. + pub fn verify_recovery_policy(&self, expected: &str) -> TestResult { + let expected_policy = match expected { + "drop" => RecoveryPolicy::Drop, + "quarantine" => RecoveryPolicy::Quarantine, + "disconnect" => RecoveryPolicy::Disconnect, + _ => return Err(format!("unknown recovery policy: {expected}").into()), + }; + + let error = self.current_error.as_ref().ok_or("no error has been set")?; + let actual_policy = error.default_recovery_policy(); + + if actual_policy != expected_policy { + return Err( + format!("expected policy {expected_policy:?}, got {actual_policy:?}").into(), + ); + } + + Ok(()) + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index e5dc1ebd..2976c8b6 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -6,6 +6,7 @@ pub mod client_lifecycle; pub mod client_messaging; pub mod client_preamble; pub mod client_runtime; +pub mod codec_error; pub mod codec_stateful; pub mod correlation; pub mod message_assembler; diff --git a/tests/bdd/scenarios/codec_error_scenarios.rs b/tests/bdd/scenarios/codec_error_scenarios.rs new file mode 100644 index 00000000..20c77e8f --- /dev/null +++ b/tests/bdd/scenarios/codec_error_scenarios.rs @@ -0,0 +1,37 @@ +//! Scenario tests for codec error taxonomy and recovery behaviour. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_error::*; + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Clean EOF at frame boundary" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_clean_eof(codec_error_world: CodecErrorWorld) { let _ = codec_error_world; } + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Premature EOF mid-frame" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_mid_frame(codec_error_world: CodecErrorWorld) { let _ = codec_error_world; } + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Oversized frame produces framing error" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_oversized_frame(codec_error_world: CodecErrorWorld) { + let _ = codec_error_world; +} + +#[scenario( + path = "tests/features/codec_error.feature", + name = "Recovery policy defaults" +)] +#[tokio::test(flavor = "current_thread")] +async fn codec_error_recovery_defaults(codec_error_world: CodecErrorWorld) { + let _ = codec_error_world; +} diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index ee093119..66e260a9 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -11,6 +11,7 @@ mod client_lifecycle_scenarios; mod client_messaging_scenarios; mod client_preamble_scenarios; mod client_runtime_scenarios; +mod codec_error_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; mod message_assembler_scenarios; diff --git a/tests/bdd/steps/codec_error_steps.rs b/tests/bdd/steps/codec_error_steps.rs new file mode 100644 index 00000000..5dcd6548 --- /dev/null +++ b/tests/bdd/steps/codec_error_steps.rs @@ -0,0 +1,100 @@ +//! Step definitions for codec error taxonomy behavioural tests. +//! +//! These steps exercise real codec operations to verify error handling +//! and recovery policy behaviour. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::codec_error::{CodecErrorWorld, TestResult}; + +// ============================================================================= +// Given steps +// ============================================================================= + +#[given("a wireframe server with default codec")] +fn given_server_default(codec_error_world: &mut CodecErrorWorld) { + codec_error_world.setup_default_codec(); +} + +#[given("a wireframe server with max frame length {max_len:usize} bytes")] +fn given_server_max_frame(codec_error_world: &mut CodecErrorWorld, max_len: usize) { + codec_error_world.setup_codec_with_max_length(max_len); +} + +#[given("a codec error of type {error_type} with variant {variant}")] +fn given_error_type_variant( + codec_error_world: &mut CodecErrorWorld, + error_type: String, + variant: String, +) -> TestResult { + codec_error_world.set_error_type(&error_type)?; + match error_type.as_str() { + "framing" => codec_error_world.set_framing_variant(&variant)?, + "eof" => codec_error_world.set_eof_variant(&variant)?, + "protocol" | "io" => {} + _ => return Err(format!("unknown error type: {error_type}").into()), + } + Ok(()) +} + +// ============================================================================= +// When steps - Real codec operations +// ============================================================================= + +#[when("a client connects and sends a complete frame")] +fn when_client_sends_complete(codec_error_world: &mut CodecErrorWorld) -> TestResult { + // Send a small payload that fits within the frame limit + codec_error_world.send_complete_frame(&[1, 2, 3, 4]) +} + +#[when("the client closes the connection cleanly")] +fn when_client_closes_clean(codec_error_world: &mut CodecErrorWorld) -> TestResult { + // Decode the complete frame, then call decode_eof on empty buffer + codec_error_world.decode_eof_clean_close() +} + +#[when("a client connects and sends partial frame data")] +fn when_client_sends_partial(codec_error_world: &mut CodecErrorWorld) { + // Send header indicating 100 bytes, but no payload + codec_error_world.send_partial_frame_header_only(); +} + +#[when("the client closes the connection abruptly")] +fn when_client_closes_abrupt(codec_error_world: &mut CodecErrorWorld) -> TestResult { + // Call decode_eof with partial data in buffer + codec_error_world.decode_eof_with_partial_data() +} + +#[when("a client sends a frame larger than {max_len:usize} bytes")] +fn when_client_sends_oversized( + codec_error_world: &mut CodecErrorWorld, + max_len: usize, +) -> TestResult { + // Attempt to encode a payload larger than max_frame_length + // Add 1 to exceed the limit + codec_error_world.encode_oversized_frame(max_len + 1) +} + +// ============================================================================= +// Then steps - Verification +// ============================================================================= + +#[then("the server detects a clean EOF")] +fn then_server_clean_eof(codec_error_world: &mut CodecErrorWorld) -> TestResult { + codec_error_world.verify_clean_eof() +} + +#[then("the server detects a mid-frame EOF with partial data")] +fn then_server_mid_frame_eof(codec_error_world: &mut CodecErrorWorld) -> TestResult { + codec_error_world.verify_incomplete_eof() +} + +#[then("the server rejects the frame with an oversized error")] +fn then_server_oversized(codec_error_world: &mut CodecErrorWorld) -> TestResult { + codec_error_world.verify_oversized_error() +} + +#[then("the default recovery policy is {policy}")] +fn then_recovery_policy(codec_error_world: &mut CodecErrorWorld, policy: String) -> TestResult { + codec_error_world.verify_recovery_policy(&policy) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 90417858..4c1ace88 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -7,6 +7,7 @@ mod client_lifecycle_steps; mod client_messaging_steps; mod client_preamble_steps; mod client_runtime_steps; +mod codec_error_steps; mod codec_stateful_steps; mod correlation_steps; mod message_assembler_steps; From c14ce30bf4bb289e795dee78007f966d5038979d Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:46:13 +0000 Subject: [PATCH 21/45] Migrate FragmentWorld to rstest-bdd Add fragment fixtures, async rstest-bdd steps, and scenarios covering fragment series, reassembly, and overflow cases. Update fragment feature wording and cucumber steps to keep step definitions unambiguous, and mark progress in the execplan. --- .../migrate-from-cucumber-to-rstest-bdd.md | 6 +- tests/bdd/fixtures/fragment/mod.rs | 307 ++++++++++++++++++ tests/bdd/fixtures/fragment/reassembly.rs | 186 +++++++++++ tests/bdd/fixtures/mod.rs | 1 + tests/bdd/scenarios/fragment_scenarios.rs | 82 +++++ tests/bdd/scenarios/mod.rs | 1 + tests/bdd/steps/fragment_steps.rs | 207 ++++++++++++ tests/bdd/steps/mod.rs | 1 + tests/features/fragment.feature | 2 +- tests/steps/fragment_steps.rs | 2 +- 10 files changed, 790 insertions(+), 5 deletions(-) create mode 100644 tests/bdd/fixtures/fragment/mod.rs create mode 100644 tests/bdd/fixtures/fragment/reassembly.rs create mode 100644 tests/bdd/scenarios/fragment_scenarios.rs create mode 100644 tests/bdd/steps/fragment_steps.rs diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 18bb2380..7745cde3 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -368,8 +368,8 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: In Progress - `MessageAssemblerWorld`, `MessageAssemblyWorld`, and -`CodecErrorWorld` migrated. +**Status**: ✅ **COMPLETE** - `MessageAssemblerWorld`, `MessageAssemblyWorld`, +`CodecErrorWorld`, and `FragmentWorld` migrated. **Status**: ✅ **COMPLETE** - `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, and `CodecStatefulWorld` migrated. @@ -494,7 +494,7 @@ pub fn fragment_world() -> FragmentWorld { | 1 | 2 | 6 | Complete | 2026-01-22 | | 2 | 4 | 15 | Complete | 2026-01-24 | | 3 | 4 | 20 | Complete | 2026-01-25 | -| 4 | 4 | 19+ | In Progress | 3/4 done | +| 4 | 4 | 19+ | Complete | 2026-01-25 | | 5 | - | - | Not Started | - | **Total**: 14 worlds, 60+ scenarios diff --git a/tests/bdd/fixtures/fragment/mod.rs b/tests/bdd/fixtures/fragment/mod.rs new file mode 100644 index 00000000..51c74a92 --- /dev/null +++ b/tests/bdd/fixtures/fragment/mod.rs @@ -0,0 +1,307 @@ +//! `FragmentWorld` fixture for rstest-bdd tests. +//! +//! Tracks fragmentation and reassembly state for fragment scenarios. + +#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] + +mod reassembly; + +use std::{num::NonZeroUsize, time::Instant}; + +use rstest::fixture; +use wireframe::fragment::{ + FragmentBatch, + FragmentError, + FragmentFrame, + FragmentHeader, + FragmentIndex, + FragmentSeries, + FragmentStatus, + Fragmenter, + MessageId, + ReassembledMessage, + Reassembler, + ReassemblyError, +}; + +// Re-export TestResult from common for use in steps +pub use crate::common::TestResult; + +/// Test world tracking fragmentation state across behavioural scenarios. +#[derive(Debug)] +pub struct FragmentWorld { + series: Option, + last_result: Option>, + fragmenter: Option, + last_batch: Option, + reassembler: Option, + last_reassembled: Option, + last_reassembly_error: Option, + now: Instant, + last_evicted: Vec, +} + +impl Default for FragmentWorld { + fn default() -> Self { + Self { + series: None, + last_result: None, + fragmenter: None, + last_batch: None, + reassembler: None, + last_reassembled: None, + last_reassembly_error: None, + now: Instant::now(), + last_evicted: Vec::new(), + } + } +} + +#[fixture] +pub fn fragment_world() -> FragmentWorld { FragmentWorld::default() } + +impl FragmentWorld { + /// Start tracking a new logical message. + pub fn start_series(&mut self, message_id: u64) { + self.series = Some(FragmentSeries::new(MessageId::new(message_id))); + self.last_result = None; + } + + /// Configure a fragmenter with the provided payload cap so outbound + /// fragmentation scenarios can chunk messages during behavioural tests. + /// + /// # Errors + /// Returns an error if the payload cap is zero. + pub fn configure_fragmenter(&mut self, max_payload: usize) -> TestResult { + let cap = NonZeroUsize::new(max_payload).ok_or("fragment cap must be non-zero")?; + self.fragmenter = Some(Fragmenter::new(cap)); + self.last_batch = None; + Ok(()) + } + + /// Request fragmentation for a payload of `len` bytes, simulating outbound + /// fragment production for the behavioural scenarios. + /// + /// # Errors + /// Returns an error if the fragmenter is missing or fragmentation fails. + pub fn fragment_payload(&mut self, len: usize) -> TestResult { + let fragmenter = self + .fragmenter + .as_ref() + .ok_or("fragmenter not configured")?; + let payload = vec![0_u8; len]; + let batch = fragmenter.fragment_bytes(payload)?; + self.last_batch = Some(batch); + Ok(()) + } + + /// Force the next expected fragment index for overflow scenarios. + /// + /// # Errors + /// Returns an error if a fragment series has not been initialised. + pub fn force_next_index(&mut self, index: u32) -> TestResult { + self.series_mut()? + .force_next_index_for_tests(FragmentIndex::new(index)); + Ok(()) + } + + /// Feed a fragment that references the currently tracked message. + /// + /// # Errors + /// Returns an error if no fragment series has been initialised. + pub fn accept_fragment(&mut self, index: u32, is_last: bool) -> TestResult { + let message = self.series()?.message_id().get(); + self.accept_fragment_from(message, index, is_last) + } + + /// Feed a fragment for an explicit message identifier. + /// + /// # Errors + /// Returns an error if no fragment series has been initialised. + pub fn accept_fragment_from(&mut self, message: u64, index: u32, is_last: bool) -> TestResult { + let header = + FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), is_last); + self.last_result = Some(self.series_mut()?.accept(header)); + Ok(()) + } + + /// Return the most recent fragment outcome. + fn last_result(&self) -> TestResult<&Result> { + self.last_result + .as_ref() + .ok_or_else(|| "no fragment processed yet".into()) + } + + fn batch(&self) -> TestResult<&FragmentBatch> { + self.last_batch + .as_ref() + .ok_or_else(|| "no payload fragmented yet".into()) + } + + /// Retrieve the fragment at `index`. + fn get_fragment_at(&self, index: usize) -> TestResult<&FragmentFrame> { + let fragment = self + .batch()? + .fragments() + .get(index) + .ok_or_else(|| format!("fragment {index} missing"))?; + Ok(fragment) + } + + fn assert_error(&self, predicate: F, expected_desc: &str) -> TestResult + where + F: FnOnce(&FragmentError) -> bool, + { + let err = match self.last_result()? { + Err(err) => err, + Ok(status) => return Err(format!("expected error but received {status:?}").into()), + }; + if !predicate(err) { + return Err(format!("expected {expected_desc}, got {err}").into()); + } + Ok(()) + } + + /// Assert that the latest fragment completed the logical message. + /// + /// # Errors + /// Returns an error if the fragment did not complete the message or no + /// fragment was processed. + pub fn assert_completion(&self) -> TestResult { + match self.last_result()? { + Ok(FragmentStatus::Complete) => {} + Ok(status) => return Err(format!("unexpected status: {status:?}").into()), + Err(err) => return Err(format!("expected completion but got error: {err}").into()), + } + if !self.series()?.is_complete() { + return Err("series should be marked complete".into()); + } + Ok(()) + } + + fn series(&self) -> TestResult<&FragmentSeries> { + self.series + .as_ref() + .ok_or_else(|| "fragment series not initialised".into()) + } + + fn series_mut(&mut self) -> TestResult<&mut FragmentSeries> { + self.series + .as_mut() + .ok_or_else(|| "fragment series not initialised".into()) + } + + /// Assert that the latest fragment failed due to an index mismatch. + /// + /// # Errors + /// Returns an error if the last fragment result does not indicate an index + /// mismatch or no fragment was processed. + pub fn assert_index_mismatch(&self) -> TestResult { + self.assert_error( + |err| matches!(err, FragmentError::IndexMismatch { .. }), + "index mismatch", + ) + } + + /// Assert that the latest fragment failed because the message identifier + /// did not match the tracked series. + /// + /// # Errors + /// Returns an error if the last fragment result is not a message mismatch + /// or no fragment was processed. + pub fn assert_message_mismatch(&self) -> TestResult { + self.assert_error( + |err| matches!(err, FragmentError::MessageMismatch { .. }), + "message mismatch", + ) + } + + /// Assert that the latest fragment failed because the index overflowed. + /// + /// # Errors + /// Returns an error if the last fragment result is not an overflow or no + /// fragment was processed. + pub fn assert_index_overflow(&self) -> TestResult { + self.assert_error( + |err| matches!(err, FragmentError::IndexOverflow { .. }), + "overflow error", + ) + } + + /// Assert that the latest fragment failed because the series was already + /// complete. + /// + /// # Errors + /// Returns an error if the last fragment result is not a completion error + /// or no fragment was processed. + pub fn assert_series_complete_error(&self) -> TestResult { + self.assert_error( + |err| matches!(err, FragmentError::SeriesComplete), + "series completion error", + ) + } + + /// Assert that the most recent fragmentation produced `expected` fragments + /// for outbound fragmentation scenarios. + /// + /// # Errors + /// Returns an error if no batch exists or the fragment count mismatches. + pub fn assert_fragment_count(&self, expected: usize) -> TestResult { + let actual = self.batch()?.len(); + if actual != expected { + return Err(format!("expected {expected} fragments, got {actual}").into()); + } + Ok(()) + } + + /// Assert that the payload length of fragment `index` matches `expected` + /// bytes for outbound fragments. + /// + /// # Errors + /// Returns an error if the batch is missing or the payload length differs. + pub fn assert_fragment_payload_len(&self, index: usize, expected: usize) -> TestResult { + let fragment = self.get_fragment_at(index)?; + let actual = fragment.payload().len(); + if actual != expected { + return Err(format!( + "fragment {index} payload length mismatch: expected {expected}, got {actual}" + ) + .into()); + } + Ok(()) + } + + /// Assert that outbound fragment `index` carries the expected final flag. + /// + /// # Errors + /// Returns an error if the batch is missing or the final flag mismatches. + pub fn assert_fragment_final_flag(&self, index: usize, expected_final: bool) -> TestResult { + let fragment = self.get_fragment_at(index)?; + let actual = fragment.header().is_last_fragment(); + if actual != expected_final { + return Err(format!( + "fragment {index} final flag mismatch: expected {expected_final}, got {actual}" + ) + .into()); + } + Ok(()) + } + + /// Assert that the outbound fragment batch carries the expected message + /// identifier. + /// + /// # Errors + /// Returns an error if the batch is missing or the message id differs from + /// the expectation. + pub fn assert_message_id(&self, expected: u64) -> TestResult { + let actual = self.batch()?.message_id(); + let expected_id = MessageId::new(expected); + if actual != expected_id { + return Err(format!( + "unexpected message identifier: expected {expected_id:?}, got {actual:?}" + ) + .into()); + } + Ok(()) + } +} diff --git a/tests/bdd/fixtures/fragment/reassembly.rs b/tests/bdd/fixtures/fragment/reassembly.rs new file mode 100644 index 00000000..55e39a8a --- /dev/null +++ b/tests/bdd/fixtures/fragment/reassembly.rs @@ -0,0 +1,186 @@ +//! Reassembly-focused helpers for `FragmentWorld`. + +use std::{num::NonZeroUsize, time::Duration}; + +use super::{ + FragmentError, + FragmentHeader, + FragmentWorld, + MessageId, + Reassembler, + ReassemblyError, + TestResult, +}; + +impl FragmentWorld { + /// Configure a reassembler with size and timeout guards. + /// + /// # Errors + /// Returns an error when the message size is zero or the configuration + /// cannot be constructed. + pub fn configure_reassembler( + &mut self, + max_message_size: usize, + timeout_secs: u64, + ) -> TestResult { + let size = NonZeroUsize::new(max_message_size).ok_or("reassembly cap must be non-zero")?; + self.reassembler = Some(Reassembler::new(size, Duration::from_secs(timeout_secs))); + self.last_reassembled = None; + self.last_reassembly_error = None; + self.last_evicted.clear(); + Ok(()) + } + + /// Submit a fragment to the configured reassembler. + /// + /// # Errors + /// Returns an error if the reassembler is missing. + pub fn push_fragment(&mut self, header: FragmentHeader, payload_len: usize) -> TestResult { + let reassembler = self + .reassembler + .as_mut() + .ok_or("reassembler not configured")?; + let payload = vec![0_u8; payload_len]; + self.last_reassembly_error = None; + self.last_reassembled = None; + match reassembler.push_at(header, payload, self.now) { + Ok(output) => self.last_reassembled = output, + Err(err) => self.last_reassembly_error = Some(err), + } + Ok(()) + } + + /// Advance the simulated clock. + /// + /// # Errors + /// Returns an error if the simulated clock would overflow. + pub fn advance_time(&mut self, delta: Duration) -> TestResult { + self.now = self + .now + .checked_add(delta) + .ok_or("time advance overflowed")?; + Ok(()) + } + + /// Purge expired partial messages based on the current clock reading. + /// + /// # Errors + /// Returns an error if the reassembler has not been configured. + pub fn purge_reassembly(&mut self) -> TestResult { + let reassembler = self + .reassembler + .as_mut() + .ok_or("reassembler not configured")?; + self.last_evicted = reassembler.purge_expired_at(self.now); + Ok(()) + } + + /// Assert that a message has been reassembled with the expected payload + /// length. + /// + /// # Errors + /// Returns an error if no message has been reassembled or the length does + /// not match the expectation. + pub fn assert_reassembled_len(&self, expected_len: usize) -> TestResult { + let message = self + .last_reassembled + .as_ref() + .ok_or("no message reassembled")?; + if message.payload().len() != expected_len { + return Err("payload length mismatch".into()); + } + Ok(()) + } + + /// Assert that no message has been fully reassembled. + /// + /// # Errors + /// Returns an error if a message has already been reassembled. + pub fn assert_no_reassembly(&self) -> TestResult { + if self.last_reassembled.is_some() { + return Err("unexpected reassembled message present".into()); + } + Ok(()) + } + + /// Helper for asserting on the latest captured reassembly error. + /// + /// # Errors + /// Returns an error when no reassembly error was captured or the predicate + /// does not match the error variant. + fn assert_reassembly_error_matches( + &self, + predicate: F, + expected_description: &str, + ) -> TestResult + where + F: FnOnce(&ReassemblyError) -> bool, + { + let err = self + .last_reassembly_error + .as_ref() + .ok_or("no reassembly error captured")?; + if !predicate(err) { + return Err(format!("expected {expected_description}, got {err}").into()); + } + Ok(()) + } + + /// Assert the latest reassembly error signalled an over-limit message. + /// + /// # Errors + /// Returns an error if no reassembly error was captured or it was not a + /// message-too-large error. + pub fn assert_reassembly_over_limit(&self) -> TestResult { + self.assert_reassembly_error_matches( + |err| matches!(err, ReassemblyError::MessageTooLarge { .. }), + "message-too-large error", + ) + } + + /// Assert that the latest reassembly error was triggered by an out-of-order + /// fragment. + /// + /// # Errors + /// Returns an error if no reassembly error was captured or it was not an + /// index-mismatch error. + pub fn assert_reassembly_out_of_order(&self) -> TestResult { + self.assert_reassembly_error_matches( + |err| { + matches!( + err, + ReassemblyError::Fragment(FragmentError::IndexMismatch { .. }) + ) + }, + "out-of-order error", + ) + } + + /// Assert the number of buffered partial messages. + /// + /// # Errors + /// Returns an error if the reassembler is missing or the buffered count + /// differs from the expectation. + pub fn assert_buffered_messages(&self, expected: usize) -> TestResult { + let reassembler = self + .reassembler + .as_ref() + .ok_or("reassembler not configured")?; + let actual = reassembler.buffered_len(); + if actual != expected { + return Err(format!("expected {expected} buffered messages, got {actual}").into()); + } + Ok(()) + } + + /// Assert that the most recent purge evicted a specific message identifier. + /// + /// # Errors + /// Returns an error if the expected message identifier was not evicted. + pub fn assert_evicted_message(&self, message_id: u64) -> TestResult { + if !self.last_evicted.contains(&MessageId::new(message_id)) { + return Err(format!("message {message_id} was not evicted").into()); + } + Ok(()) + } +} diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 2976c8b6..3c67eb6b 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -9,6 +9,7 @@ pub mod client_runtime; pub mod codec_error; pub mod codec_stateful; pub mod correlation; +pub mod fragment; pub mod message_assembler; pub mod message_assembly; pub mod multi_packet; diff --git a/tests/bdd/scenarios/fragment_scenarios.rs b/tests/bdd/scenarios/fragment_scenarios.rs new file mode 100644 index 00000000..a3a98ad1 --- /dev/null +++ b/tests/bdd/scenarios/fragment_scenarios.rs @@ -0,0 +1,82 @@ +//! Scenario tests for fragment metadata enforcement. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::fragment::*; + +#[scenario( + path = "tests/features/fragment.feature", + name = "Sequential fragments complete a message" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_sequential_complete(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Out-of-order fragment is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_out_of_order(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Fragment from another message is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_wrong_message(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Fragment beyond the maximum index is rejected" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_index_overflow(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Final fragment at the maximum index completes the message" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_max_index_complete(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Series rejects fragments after completion" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragment_series_complete(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Fragmenter splits oversized payloads into sequential fragments" +)] +#[tokio::test(flavor = "current_thread")] +async fn fragmenter_splits_payload(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler rebuilds sequential fragments" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_rebuilds(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler rejects messages that exceed the cap" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_over_limit(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler evicts stale partial messages" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_evicts(fragment_world: FragmentWorld) { let _ = fragment_world; } + +#[scenario( + path = "tests/features/fragment.feature", + name = "Reassembler rejects out-of-order fragments" +)] +#[tokio::test(flavor = "current_thread")] +async fn reassembler_out_of_order(fragment_world: FragmentWorld) { let _ = fragment_world; } diff --git a/tests/bdd/scenarios/mod.rs b/tests/bdd/scenarios/mod.rs index 66e260a9..584d8882 100644 --- a/tests/bdd/scenarios/mod.rs +++ b/tests/bdd/scenarios/mod.rs @@ -14,6 +14,7 @@ mod client_runtime_scenarios; mod codec_error_scenarios; mod codec_stateful_scenarios; mod correlation_scenarios; +mod fragment_scenarios; mod message_assembler_scenarios; mod message_assembly_scenarios; mod multi_packet_scenarios; diff --git a/tests/bdd/steps/fragment_steps.rs b/tests/bdd/steps/fragment_steps.rs new file mode 100644 index 00000000..33d45f76 --- /dev/null +++ b/tests/bdd/steps/fragment_steps.rs @@ -0,0 +1,207 @@ +//! Step definitions for fragment metadata behavioural tests. + +use std::time::Duration; + +use rstest_bdd_macros::{given, then, when}; +use wireframe::{FragmentHeader, FragmentIndex, MessageId}; + +use crate::fixtures::fragment::{FragmentWorld, TestResult}; + +#[given("a fragment series for message {message:u64}")] +fn given_series(fragment_world: &mut FragmentWorld, message: u64) { + fragment_world.start_series(message); +} + +#[given("the series expects fragment index {index:u32}")] +fn given_series_expectation(fragment_world: &mut FragmentWorld, index: u32) -> TestResult { + fragment_world.force_next_index(index)?; + Ok(()) +} + +#[when("fragment {index:u32} arrives marked non-final")] +fn when_fragment_non_final(fragment_world: &mut FragmentWorld, index: u32) -> TestResult { + fragment_world.accept_fragment(index, false)?; + Ok(()) +} + +#[when("fragment {index:u32} arrives marked final")] +fn when_fragment_final(fragment_world: &mut FragmentWorld, index: u32) -> TestResult { + fragment_world.accept_fragment(index, true)?; + Ok(()) +} + +#[when("fragment {index:u32} from message {message:u64} arrives marked non-final")] +fn when_fragment_other_message( + fragment_world: &mut FragmentWorld, + index: u32, + message: u64, +) -> TestResult { + fragment_world.accept_fragment_from(message, index, false)?; + Ok(()) +} + +#[then("the fragment completes the message")] +fn then_fragment_completes(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_completion()?; + Ok(()) +} + +#[then("the fragment is rejected as out-of-order")] +fn then_fragment_out_of_order(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_index_mismatch()?; + Ok(()) +} + +#[then("the fragment is rejected for the wrong message")] +fn then_fragment_wrong_message(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_message_mismatch()?; + Ok(()) +} + +#[then("the fragment is rejected for index overflow")] +fn then_fragment_overflow(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_index_overflow()?; + Ok(()) +} + +#[then("the fragment is rejected because the series is complete")] +fn then_fragment_complete(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_series_complete_error()?; + Ok(()) +} + +#[given("a fragmenter capped at {max_payload:usize} bytes per fragment")] +fn given_fragmenter(fragment_world: &mut FragmentWorld, max_payload: usize) -> TestResult { + fragment_world.configure_fragmenter(max_payload)?; + Ok(()) +} + +#[when("the fragmenter splits a payload of {len:usize} bytes")] +fn when_fragmenter_splits(fragment_world: &mut FragmentWorld, len: usize) -> TestResult { + fragment_world.fragment_payload(len)?; + Ok(()) +} + +#[then("the fragmenter produces {expected:usize} fragments")] +fn then_fragment_count(fragment_world: &mut FragmentWorld, expected: usize) -> TestResult { + fragment_world.assert_fragment_count(expected)?; + Ok(()) +} + +#[then("fragment {index:usize} carries {len:usize} bytes")] +fn then_fragment_payload_len( + fragment_world: &mut FragmentWorld, + index: usize, + len: usize, +) -> TestResult { + fragment_world.assert_fragment_payload_len(index, len)?; + Ok(()) +} + +#[then("fragment {index:usize} is marked final")] +fn then_fragment_final(fragment_world: &mut FragmentWorld, index: usize) -> TestResult { + fragment_world.assert_fragment_final_flag(index, true)?; + Ok(()) +} + +#[then("fragment {index:usize} is marked non-final")] +fn then_fragment_non_final(fragment_world: &mut FragmentWorld, index: usize) -> TestResult { + fragment_world.assert_fragment_final_flag(index, false)?; + Ok(()) +} + +#[then("the fragments use message id {message_id:u64}")] +fn then_fragment_message_id(fragment_world: &mut FragmentWorld, message_id: u64) -> TestResult { + fragment_world.assert_message_id(message_id)?; + Ok(()) +} + +#[given( + "a reassembler allowing {max_bytes:usize} bytes with a {timeout_secs:u64}-second reassembly \ + timeout" +)] +fn given_reassembler( + fragment_world: &mut FragmentWorld, + max_bytes: usize, + timeout_secs: u64, +) -> TestResult { + fragment_world.configure_reassembler(max_bytes, timeout_secs)?; + Ok(()) +} + +#[when( + "fragment {index:u32} for message {message:u64} with {len:usize} bytes arrives marked \ + non-final" +)] +fn when_reassembler_fragment_non_final( + fragment_world: &mut FragmentWorld, + index: u32, + message: u64, + len: usize, +) -> TestResult { + let header = FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), false); + fragment_world.push_fragment(header, len)?; + Ok(()) +} + +#[when( + "fragment {index:u32} for message {message:u64} with {len:usize} bytes arrives marked final" +)] +fn when_reassembler_fragment_final( + fragment_world: &mut FragmentWorld, + index: u32, + message: u64, + len: usize, +) -> TestResult { + let header = FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), true); + fragment_world.push_fragment(header, len)?; + Ok(()) +} + +#[when("time advances by {seconds:u64} seconds for reassembly")] +fn when_time_advances(fragment_world: &mut FragmentWorld, seconds: u64) -> TestResult { + fragment_world.advance_time(Duration::from_secs(seconds))?; + Ok(()) +} + +#[when("expired reassembly buffers are purged")] +fn when_reassembly_purged(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.purge_reassembly()?; + Ok(()) +} + +#[then("the reassembler outputs a payload of {expected:usize} bytes")] +fn then_reassembled_len(fragment_world: &mut FragmentWorld, expected: usize) -> TestResult { + fragment_world.assert_reassembled_len(expected)?; + Ok(()) +} + +#[then("no message has been reassembled yet")] +fn then_no_reassembled_message(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_no_reassembly()?; + Ok(()) +} + +#[then("the reassembler reports a message-too-large error")] +fn then_reassembly_over_limit(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_reassembly_over_limit()?; + Ok(()) +} + +#[then("the reassembler reports an out-of-order fragment error")] +fn then_reassembly_out_of_order(fragment_world: &mut FragmentWorld) -> TestResult { + fragment_world.assert_reassembly_out_of_order()?; + Ok(()) +} + +#[then("the reassembler is buffering {expected:usize} messages")] +fn then_buffered_messages(fragment_world: &mut FragmentWorld, expected: usize) -> TestResult { + fragment_world.assert_buffered_messages(expected)?; + Ok(()) +} + +#[then("message {message:u64} is evicted")] +fn then_message_evicted(fragment_world: &mut FragmentWorld, message: u64) -> TestResult { + fragment_world.assert_evicted_message(message)?; + Ok(()) +} diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 4c1ace88..b290dd1d 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -10,6 +10,7 @@ mod client_runtime_steps; mod codec_error_steps; mod codec_stateful_steps; mod correlation_steps; +mod fragment_steps; mod message_assembler_steps; mod message_assembly_steps; mod multi_packet_steps; diff --git a/tests/features/fragment.feature b/tests/features/fragment.feature index 99cd447a..aa7df5f2 100644 --- a/tests/features/fragment.feature +++ b/tests/features/fragment.feature @@ -64,7 +64,7 @@ Feature: Fragment metadata enforcement Scenario: Reassembler evicts stale partial messages Given a reassembler allowing 8 bytes with a 1-second reassembly timeout When fragment 0 for message 23 with 5 bytes arrives marked non-final - And time advances by 2 seconds + And time advances by 2 seconds for reassembly And expired reassembly buffers are purged Then the reassembler is buffering 0 messages And message 23 is evicted diff --git a/tests/steps/fragment_steps.rs b/tests/steps/fragment_steps.rs index 926007ef..46d01507 100644 --- a/tests/steps/fragment_steps.rs +++ b/tests/steps/fragment_steps.rs @@ -135,7 +135,7 @@ fn when_reassembler_fragment_final( Ok(()) } -#[when(expr = "time advances by {int} seconds")] +#[when(expr = "time advances by {int} seconds for reassembly")] fn when_time_advances(world: &mut FragmentWorld, seconds: u64) -> TestResult { world.advance_time(Duration::from_secs(seconds))?; Ok(()) From 7a56df7eebdb876bd039ae4ba6802775feae83de Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:51:13 +0000 Subject: [PATCH 22/45] Document BDD comparison Record the scenario parity check between cucumber and rstest-bdd in the migration execplan. --- docs/execplans/migrate-from-cucumber-to-rstest-bdd.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 7745cde3..a2d4d57f 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -449,6 +449,9 @@ pub fn fragment_world() -> FragmentWorld { # Compare scenario counts, all should pass ``` + Result (2026-01-25): Cucumber and rstest-bdd both run 64 scenarios, all + passing. + 2. **Enable strict validation**: ```toml From 45bb1bcadfab7f7a7b1c1863a93d576bfcf14558 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 01:56:20 +0000 Subject: [PATCH 23/45] Enable strict rstest-bdd validation Switch rstest-bdd-macros to strict compile-time validation and record the change in the migration execplan. --- Cargo.toml | 2 +- docs/execplans/migrate-from-cucumber-to-rstest-bdd.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9da3b1ea..77944f84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ socket2 = "0.6.0" [dev-dependencies] rstest = "0.26.1" rstest-bdd = "0.4.0" -rstest-bdd-macros = { version = "0.4.0", features = ["compile-time-validation"] } +rstest-bdd-macros = { version = "0.4.0", features = ["strict-compile-time-validation"] } wireframe = { path = ".", features = ["test-helpers"] } wireframe_testing = { path = "./wireframe_testing" } logtest = "2.0.0" diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index a2d4d57f..fe5bd550 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -459,6 +459,8 @@ pub fn fragment_world() -> FragmentWorld { features = ["strict-compile-time-validation"] } ``` + Completed 2026-01-25: strict compile-time validation is enabled. + 3. **Performance check**: ```bash From 130c505482d724f340bdaeb49e3ac2fca75dc837 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 02:01:02 +0000 Subject: [PATCH 24/45] Record BDD benchmark results Add the hyperfine comparison outcome to the migration execplan. --- docs/execplans/migrate-from-cucumber-to-rstest-bdd.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index fe5bd550..a5a0dc37 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -469,6 +469,9 @@ pub fn fragment_world() -> FragmentWorld { # Should be within 10-20% ``` + Result (2026-01-25): Hyperfine shows cucumber ~923 ms mean and rstest-bdd + ~934 ms mean (within ~1%). + 4. **Remove Cucumber infrastructure**: - Delete `tests/cucumber.rs` - Delete `tests/worlds/` From 6b733787e9223b2340a56e92ae98fe368e0684db Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 02:09:58 +0000 Subject: [PATCH 25/45] Remove Cucumber infrastructure Delete the cucumber runner, worlds, and step definitions, and remove the cucumber dependency/feature and Makefile target. Update integration test docs and execplan notes to reflect the rstest-bdd-only workflow. --- Cargo.lock | 441 +----------------- Cargo.toml | 10 - Makefile | 5 +- ...havioural-testing-in-rust-with-cucumber.md | 4 + .../migrate-from-cucumber-to-rstest-bdd.md | 5 +- tests/common/mod.rs | 4 +- tests/cucumber.rs | 80 ---- tests/steps/client_lifecycle_steps.rs | 126 ----- tests/steps/client_messaging_steps.rs | 103 ---- tests/steps/client_preamble_steps.rs | 103 ---- tests/steps/client_steps.rs | 35 -- tests/steps/codec_error_steps.rs | 101 ---- tests/steps/codec_stateful_steps.rs | 34 -- tests/steps/correlation_steps.rs | 34 -- tests/steps/fragment_steps.rs | 184 -------- tests/steps/message_assembler_steps.rs | 183 -------- tests/steps/message_assembly_steps.rs | 241 ---------- tests/steps/mod.rs | 19 - tests/steps/multi_packet_steps.rs | 26 -- tests/steps/panic_steps.rs | 27 -- tests/steps/request_parts_steps.rs | 79 ---- tests/steps/stream_end_steps.rs | 35 -- tests/world.rs | 24 - tests/worlds/client_lifecycle.rs | 346 -------------- tests/worlds/client_messaging.rs | 301 ------------ tests/worlds/client_preamble.rs | 378 --------------- tests/worlds/client_runtime.rs | 156 ------- tests/worlds/codec_error/decoder_ops.rs | 222 --------- tests/worlds/codec_error/mod.rs | 191 -------- tests/worlds/codec_stateful.rs | 281 ----------- tests/worlds/correlation.rs | 114 ----- tests/worlds/fragment/mod.rs | 305 ------------ tests/worlds/fragment/reassembly.rs | 186 -------- tests/worlds/message_assembler.rs | 374 --------------- tests/worlds/message_assembly.rs | 317 ------------- tests/worlds/message_assembly_params.rs | 84 ---- tests/worlds/mod.rs | 45 -- tests/worlds/multi_packet.rs | 156 ------- tests/worlds/panic.rs | 123 ----- tests/worlds/request_parts.rs | 85 ---- tests/worlds/stream_end.rs | 231 --------- tests/worlds/types.rs | 46 -- 42 files changed, 16 insertions(+), 5828 deletions(-) delete mode 100644 tests/cucumber.rs delete mode 100644 tests/steps/client_lifecycle_steps.rs delete mode 100644 tests/steps/client_messaging_steps.rs delete mode 100644 tests/steps/client_preamble_steps.rs delete mode 100644 tests/steps/client_steps.rs delete mode 100644 tests/steps/codec_error_steps.rs delete mode 100644 tests/steps/codec_stateful_steps.rs delete mode 100644 tests/steps/correlation_steps.rs delete mode 100644 tests/steps/fragment_steps.rs delete mode 100644 tests/steps/message_assembler_steps.rs delete mode 100644 tests/steps/message_assembly_steps.rs delete mode 100644 tests/steps/mod.rs delete mode 100644 tests/steps/multi_packet_steps.rs delete mode 100644 tests/steps/panic_steps.rs delete mode 100644 tests/steps/request_parts_steps.rs delete mode 100644 tests/steps/stream_end_steps.rs delete mode 100644 tests/world.rs delete mode 100644 tests/worlds/client_lifecycle.rs delete mode 100644 tests/worlds/client_messaging.rs delete mode 100644 tests/worlds/client_preamble.rs delete mode 100644 tests/worlds/client_runtime.rs delete mode 100644 tests/worlds/codec_error/decoder_ops.rs delete mode 100644 tests/worlds/codec_error/mod.rs delete mode 100644 tests/worlds/codec_stateful.rs delete mode 100644 tests/worlds/correlation.rs delete mode 100644 tests/worlds/fragment/mod.rs delete mode 100644 tests/worlds/fragment/reassembly.rs delete mode 100644 tests/worlds/message_assembler.rs delete mode 100644 tests/worlds/message_assembly.rs delete mode 100644 tests/worlds/message_assembly_params.rs delete mode 100644 tests/worlds/mod.rs delete mode 100644 tests/worlds/multi_packet.rs delete mode 100644 tests/worlds/panic.rs delete mode 100644 tests/worlds/request_parts.rs delete mode 100644 tests/worlds/stream_end.rs delete mode 100644 tests/worlds/types.rs diff --git a/Cargo.lock b/Cargo.lock index 32367e0f..cf5ecba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,62 +50,12 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" -[[package]] -name = "anstream" -version = "0.6.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstyle" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.59.0", -] - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - [[package]] name = "arc-swap" version = "1.8.0" @@ -242,7 +192,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -286,28 +236,12 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bstr" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "bytes" version = "1.10.1" @@ -388,47 +322,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_derive" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - [[package]] name = "cmake" version = "0.1.54" @@ -438,25 +331,6 @@ dependencies = [ "cc", ] -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "convert_case" version = "0.4.0" @@ -497,16 +371,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -542,65 +406,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "cucumber" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cd12917efc3a8b069a4975ef3cb2f2d835d42d04b3814d90838488f9dd9bf69" -dependencies = [ - "anyhow", - "clap", - "console", - "cucumber-codegen", - "cucumber-expressions", - "derive_more 0.99.20", - "drain_filter_polyfill", - "either", - "futures", - "gherkin", - "globwalk", - "humantime", - "inventory", - "itertools 0.13.0", - "lazy-regex", - "linked-hash-map", - "once_cell", - "pin-project", - "regex", - "sealed", - "smart-default", -] - -[[package]] -name = "cucumber-codegen" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e19cd9e8e7cfd79fbf844eb6a7334117973c01f6bad35571262b00891e60f1c" -dependencies = [ - "cucumber-expressions", - "inflections", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "syn 2.0.104", - "synthez", -] - -[[package]] -name = "cucumber-expressions" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d794fed319eea24246fb5f57632f7ae38d61195817b7eb659455aa5bdd7c1810" -dependencies = [ - "derive_more 0.99.20", - "either", - "nom", - "nom_locate", - "regex", - "regex-syntax 0.7.5", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -676,12 +481,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" -[[package]] -name = "drain_filter_polyfill" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" - [[package]] name = "dunce" version = "1.0.5" @@ -694,12 +493,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "endian-type" version = "0.1.2" @@ -971,7 +764,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20b79820c0df536d1f3a089a2fa958f61cb96ce9e0f3f8f507f5a31179567755" dependencies = [ - "heck 0.4.1", + "heck", "peg", "quote", "serde", @@ -994,30 +787,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" -[[package]] -name = "globset" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax 0.8.5", -] - -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags", - "ignore", - "walkdir", -] - [[package]] name = "h2" version = "0.4.11" @@ -1069,12 +838,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "home" version = "0.5.11" @@ -1130,12 +893,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" - [[package]] name = "hyper" version = "1.6.0" @@ -1242,22 +999,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "ignore" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "2.10.0" @@ -1268,12 +1009,6 @@ dependencies = [ "hashbrown 0.15.4", ] -[[package]] -name = "inflections" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" - [[package]] name = "intl-memoizer" version = "0.5.3" @@ -1335,12 +1070,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - [[package]] name = "itertools" version = "0.12.1" @@ -1350,15 +1079,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" @@ -1385,29 +1105,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy-regex" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn 2.0.104", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1447,12 +1144,6 @@ dependencies = [ "windows-targets 0.53.2", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1656,17 +1347,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom_locate" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" -dependencies = [ - "bytecount", - "memchr", - "nom", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -1700,12 +1380,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - [[package]] name = "openssl-probe" version = "0.1.6" @@ -1771,26 +1445,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1916,7 +1570,7 @@ dependencies = [ "rand", "rand_chacha", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -2042,7 +1696,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] @@ -2053,15 +1707,9 @@ checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -2424,18 +2072,6 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" -[[package]] -name = "sealed" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a8caec23b7800fb97971a1c6ae365b6239aaeddfb934d6265f8505e795699d" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "security-framework" version = "3.2.0" @@ -2591,17 +2227,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smart-default" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "smawk" version = "0.3.2" @@ -2634,12 +2259,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -2667,39 +2286,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synthez" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" -dependencies = [ - "syn 2.0.104", - "synthez-codegen", - "synthez-core", -] - -[[package]] -name = "synthez-codegen" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" -dependencies = [ - "syn 2.0.104", - "synthez-core", -] - -[[package]] -name = "synthez-core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bfa6ec52465e2425fd43ce5bbbe0f0b623964f7c63feb6b10980e816c654ea" -dependencies = [ - "proc-macro2", - "quote", - "sealed", - "syn 2.0.104", -] - [[package]] name = "sys-locale" version = "0.3.2" @@ -2722,16 +2308,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "terminal_size" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" -dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", -] - [[package]] name = "termtree" version = "0.5.1" @@ -3122,12 +2698,6 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "valuable" version = "0.1.1" @@ -3598,7 +3168,6 @@ dependencies = [ "async-trait", "bincode", "bytes", - "cucumber", "dashmap", "derive_more 2.0.1", "futures", diff --git a/Cargo.toml b/Cargo.toml index 77944f84..6b778522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,6 @@ proptest = "1.7.0" loom = "0.7.2" async-stream = "0.3.6" serial_test = "3.2.0" -# Permit compatible bug fixes but block breaking updates -cucumber = "0.21.1" metrics-util = "0.20.0" tracing-test = "0.2.5" mockall = "0.13.1" @@ -86,7 +84,6 @@ metrics = ["dep:metrics", "dep:metrics-exporter-prometheus"] serializer-bincode = [] advanced-tests = [] examples = [] -cucumber-tests = [] test-support = [] test-helpers = [] @@ -181,13 +178,6 @@ name = "resp_codec" path = "examples/resp_codec.rs" required-features = ["examples"] -# The Cucumber test runner defines its own async main function, -# so the standard test harness must be disabled. -[[test]] -name = "cucumber" -harness = false -required-features = ["advanced-tests", "cucumber-tests"] - # rstest-bdd behavioural tests use standard test harness [[test]] name = "bdd" diff --git a/Makefile b/Makefile index 762f795d..cdae9a96 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,7 @@ clean: ## Remove build artifacts test-bdd: ## Run rstest-bdd tests only RUSTFLAGS="-D warnings" $(CARGO) test --test bdd --all-features $(BUILD_JOBS) -test-cucumber: ## Run Cucumber tests only - RUSTFLAGS="-D warnings" $(CARGO) test --test cucumber --features advanced-tests,cucumber-tests $(BUILD_JOBS) - -test: test-bdd test-cucumber ## Run all tests (both bdd and cucumber) +test: test-bdd ## Run all tests (bdd + unit/integration) RUSTFLAGS="-D warnings" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) # will match target/debug/libmy_library.rlib and target/release/libmy_library.rlib diff --git a/docs/behavioural-testing-in-rust-with-cucumber.md b/docs/behavioural-testing-in-rust-with-cucumber.md index 6724e087..2c6ef696 100644 --- a/docs/behavioural-testing-in-rust-with-cucumber.md +++ b/docs/behavioural-testing-in-rust-with-cucumber.md @@ -1,5 +1,9 @@ # A Developer's Guide to Behavioural Testing in Rust with Cucumber +Note: This guide is retained for historical context. The project removed +Cucumber-based tests on 2026-01-25 in favour of rstest-bdd; use +`docs/rstest-bdd-users-guide.md` for current guidance. + ## Part 1: The Philosophy and Practice of Behaviour-Driven Development (BDD) Behaviour-Driven Development (BDD) is a software development process that diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index a5a0dc37..00f2d819 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -479,6 +479,9 @@ pub fn fragment_world() -> FragmentWorld { - Remove `cucumber = "0.21.1"` from `Cargo.toml` - Update Makefile: `test` → `test-bdd` only + Completed 2026-01-25: removed runner, worlds, steps, dependency, and + Makefile target. + 5. **Rename structure** (optional cleanup): ```bash @@ -503,7 +506,7 @@ pub fn fragment_world() -> FragmentWorld { | 2 | 4 | 15 | Complete | 2026-01-24 | | 3 | 4 | 20 | Complete | 2026-01-25 | | 4 | 4 | 19+ | Complete | 2026-01-25 | -| 5 | - | - | Not Started | - | +| 5 | - | - | In Progress | 2026-01-25 | **Total**: 14 worlds, 60+ scenarios diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 29c6bc96..89627812 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -91,9 +91,9 @@ impl Packet for CommonTestEnvelope { } } -/// Default app type used by cucumber worlds during integration tests. +/// Default app type used by integration test suites. pub type TestApp = wireframe::app::WireframeApp; -/// Shared result type for cucumber step implementations. +/// Shared result type for BDD step implementations. pub type TestResult = Result>; /// Default `WireframeApp` factory for integration tests. diff --git a/tests/cucumber.rs b/tests/cucumber.rs deleted file mode 100644 index 1db7a691..00000000 --- a/tests/cucumber.rs +++ /dev/null @@ -1,80 +0,0 @@ -#![cfg(not(loom))] -//! Cucumber test runner for integration tests. -//! -//! Orchestrates thirteen distinct test suites: -//! - `PanicWorld`: Tests server resilience during connection panics -//! - `CorrelationWorld`: Tests correlation ID propagation in multi-frame responses -//! - `StreamEndWorld`: Verifies end-of-stream signalling -//! - `MultiPacketWorld`: Tests channel-backed multi-packet response delivery -//! - `FragmentWorld`: Tests fragment metadata enforcement and reassembly primitives -//! - `MessageAssemblerWorld`: Tests message assembler header parsing -//! - `MessageAssemblyWorld`: Tests message assembly multiplexing and continuity -//! - `ClientRuntimeWorld`: Tests client runtime configuration and framing behaviour -//! - `ClientMessagingWorld`: Tests client messaging APIs with correlation ID support -//! - `CodecStatefulWorld`: Tests instance-aware codec sequence counters -//! - `RequestPartsWorld`: Tests request parts metadata handling -//! - `ClientPreambleWorld`: Tests client preamble exchange and callbacks -//! - `ClientLifecycleWorld`: Tests client connection lifecycle hooks -//! - `CodecErrorWorld`: Tests codec error taxonomy and recovery policies -//! -//! # Example -//! -//! The runner executes feature files sequentially: -//! ```text -//! tests/features/connection_panic.feature -> PanicWorld context -//! tests/features/correlation_id.feature -> CorrelationWorld context -//! tests/features/stream_end.feature -> StreamEndWorld context -//! tests/features/multi_packet.feature -> MultiPacketWorld context -//! tests/features/fragment.feature -> FragmentWorld context -//! tests/features/message_assembler.feature -> MessageAssemblerWorld context -//! tests/features/message_assembly.feature -> MessageAssemblyWorld context -//! tests/features/client_runtime.feature -> ClientRuntimeWorld context -//! tests/features/client_messaging.feature -> ClientMessagingWorld context -//! tests/features/codec_stateful.feature -> CodecStatefulWorld context -//! tests/features/request_parts.feature -> RequestPartsWorld context -//! tests/features/client_preamble.feature -> ClientPreambleWorld context -//! tests/features/client_lifecycle.feature -> ClientLifecycleWorld context -//! tests/features/codec_error.feature -> CodecErrorWorld context -//! ``` -//! -//! Each context provides specialised step definitions and state management -//! for their respective test scenarios. - -mod steps; -mod world; - -use cucumber::World; -use world::{ - ClientLifecycleWorld, - ClientMessagingWorld, - ClientPreambleWorld, - ClientRuntimeWorld, - CodecErrorWorld, - CodecStatefulWorld, - CorrelationWorld, - FragmentWorld, - MessageAssemblerWorld, - MessageAssemblyWorld, - MultiPacketWorld, - PanicWorld, - RequestPartsWorld, - StreamEndWorld, -}; - -#[tokio::main] -async fn main() { - PanicWorld::run("tests/features/connection_panic.feature").await; - CorrelationWorld::run("tests/features/correlation_id.feature").await; - StreamEndWorld::run("tests/features/stream_end.feature").await; - MultiPacketWorld::run("tests/features/multi_packet.feature").await; - FragmentWorld::run("tests/features/fragment.feature").await; - MessageAssemblerWorld::run("tests/features/message_assembler.feature").await; - MessageAssemblyWorld::run("tests/features/message_assembly.feature").await; - ClientRuntimeWorld::run("tests/features/client_runtime.feature").await; - ClientMessagingWorld::run("tests/features/client_messaging.feature").await; - CodecStatefulWorld::run("tests/features/codec_stateful.feature").await; - RequestPartsWorld::run("tests/features/request_parts.feature").await; - ClientPreambleWorld::run("tests/features/client_preamble.feature").await; - ClientLifecycleWorld::run("tests/features/client_lifecycle.feature").await; - CodecErrorWorld::run("tests/features/codec_error.feature").await; -} diff --git a/tests/steps/client_lifecycle_steps.rs b/tests/steps/client_lifecycle_steps.rs deleted file mode 100644 index 3d857567..00000000 --- a/tests/steps/client_lifecycle_steps.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Steps for wireframe client lifecycle hook behavioural tests. - -use cucumber::{given, then, when}; - -use crate::world::{ClientLifecycleWorld, EXPECTED_SETUP_STATE, TestResult}; - -/// Assert that a count matches the expected value, returning an appropriate error message. -fn assert_count_equals(actual: usize, expected: usize, callback_name: &str) -> TestResult { - if actual != expected { - return Err(format!( - "expected {callback_name} callback to be invoked {expected} time(s), got {actual}" - ) - .into()); - } - Ok(()) -} - -/// Starts a standard echo server that keeps connections open. -#[given("a standard echo server")] -async fn given_standard_server(world: &mut ClientLifecycleWorld) -> TestResult { - world.start_standard_server().await -} - -/// Starts an echo server that disconnects immediately after accepting a connection. -#[given("a standard echo server that disconnects immediately")] -async fn given_disconnecting_server(world: &mut ClientLifecycleWorld) -> TestResult { - world.start_disconnecting_server().await -} - -/// Starts a preamble-aware server that sends an acknowledgement response. -#[given("a preamble-aware echo server that sends acknowledgement")] -async fn given_ack_server(world: &mut ClientLifecycleWorld) -> TestResult { - world.start_ack_server().await -} - -/// Connects a client with a setup callback configured. -#[when("a client connects with a setup callback")] -async fn when_connect_with_setup(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_setup().await -} - -/// Connects a client with both setup and teardown callbacks configured. -#[when("a client connects with setup and teardown callbacks")] -async fn when_connect_with_setup_and_teardown(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_setup_and_teardown().await -} - -/// Closes the client connection, triggering any teardown callbacks. -#[when("the client closes the connection")] -async fn when_client_closes(world: &mut ClientLifecycleWorld) -> TestResult { - world.close_client().await; - Ok(()) -} - -/// Connects a client with an error callback configured. -#[when("a client connects with an error callback")] -async fn when_connect_with_error_callback(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_error_callback().await -} - -/// Attempts to receive a message from the server, capturing any errors. -#[when("the client attempts to receive a message")] -async fn when_client_attempts_receive(world: &mut ClientLifecycleWorld) -> TestResult { - world.attempt_receive().await -} - -/// Connects a client with preamble exchange and lifecycle callbacks configured. -#[when("a client connects with preamble and lifecycle callbacks")] -async fn when_connect_with_preamble_and_lifecycle(world: &mut ClientLifecycleWorld) -> TestResult { - world.connect_with_preamble_and_lifecycle().await -} - -/// Asserts that the setup callback was invoked exactly once. -#[then("the setup callback is invoked exactly once")] -fn then_setup_invoked_once(world: &mut ClientLifecycleWorld) -> TestResult { - assert_count_equals(world.setup_count(), 1, "setup") -} - -/// Asserts that the teardown callback was invoked exactly once. -#[then("the teardown callback is invoked exactly once")] -fn then_teardown_invoked_once(world: &mut ClientLifecycleWorld) -> TestResult { - assert_count_equals(world.teardown_count(), 1, "teardown") -} - -/// Asserts that the teardown callback received the expected state from setup. -#[then("the teardown callback receives the state from setup")] -fn then_teardown_receives_state(world: &mut ClientLifecycleWorld) -> TestResult { - let state = world.teardown_received_state(); - let expected = EXPECTED_SETUP_STATE as usize; - if state != expected { - return Err(format!("expected teardown to receive state {expected}, got {state}").into()); - } - Ok(()) -} - -/// Asserts that the error callback was invoked at least once. -#[then("the error callback is invoked")] -fn then_error_callback_invoked(world: &mut ClientLifecycleWorld) -> TestResult { - let count = world.error_count(); - if count == 0 { - return Err("expected error callback to be invoked at least once".into()); - } - Ok(()) -} - -/// Asserts that the preamble success callback was invoked. -#[then("the preamble success callback is invoked")] -fn then_preamble_success_invoked(world: &mut ClientLifecycleWorld) -> TestResult { - if !world.preamble_success_invoked() { - return Err("expected preamble success callback to be invoked".into()); - } - Ok(()) -} - -/// Asserts that the captured client error is a Disconnected error. -#[then("the client error is Disconnected")] -fn then_client_error_is_disconnected(world: &mut ClientLifecycleWorld) -> TestResult { - let last_error = world - .last_error() - .ok_or("expected a captured client error in world.last_error")?; - - match last_error { - wireframe::ClientError::Disconnected => Ok(()), - other => Err(format!("expected ClientError::Disconnected, got {other:?}").into()), - } -} diff --git a/tests/steps/client_messaging_steps.rs b/tests/steps/client_messaging_steps.rs deleted file mode 100644 index 0ce19f6c..00000000 --- a/tests/steps/client_messaging_steps.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Steps for client messaging behavioural tests with correlation ID support. -//! -//! Cucumber step functions are required to be async by the framework, even when -//! they don't contain await expressions. - -#![expect( - clippy::unused_async, - reason = "cucumber requires async step functions" -)] - -use cucumber::{given, then, when}; - -use crate::world::{ClientMessagingWorld, TestResult}; - -#[given("an envelope echo server")] -async fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { - world.start_echo_server().await?; - world.connect_client().await -} - -#[given("an envelope without a correlation ID")] -async fn given_envelope_without_correlation(world: &mut ClientMessagingWorld) -> TestResult { - world.set_envelope_without_correlation(); - Ok(()) -} - -#[given(expr = "an envelope with correlation ID {int}")] -async fn given_envelope_with_correlation( - world: &mut ClientMessagingWorld, - correlation_id: u64, -) -> TestResult { - world.set_envelope_with_correlation(correlation_id); - Ok(()) -} - -#[given(expr = "an envelope with message ID {int} and payload {string}")] -async fn given_envelope_with_payload( - world: &mut ClientMessagingWorld, - message_id: u32, - payload: String, -) -> TestResult { - world.set_envelope_with_payload(message_id, &payload); - Ok(()) -} - -#[given("a server that returns mismatched correlation IDs")] -async fn given_mismatch_server(world: &mut ClientMessagingWorld) -> TestResult { - // First, abort the existing server. - world.abort_server(); - // Start a mismatch server. - world.start_mismatch_server().await?; - world.connect_client().await -} - -#[when("the client sends the envelope")] -async fn when_client_sends_envelope(world: &mut ClientMessagingWorld) -> TestResult { - world.send_envelope().await -} - -#[when("the client calls the server with call_correlated")] -async fn when_client_calls_correlated(world: &mut ClientMessagingWorld) -> TestResult { - world.call_correlated().await -} - -#[when(expr = "the client sends {int} sequential envelopes")] -async fn when_client_sends_multiple(world: &mut ClientMessagingWorld, count: usize) -> TestResult { - world.send_multiple_envelopes(count).await -} - -#[then("the envelope is stamped with an auto-generated correlation ID")] -async fn then_auto_generated_correlation(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_auto_generated_correlation() -} - -#[then(expr = "the returned correlation ID is {int}")] -async fn then_correlation_id_is(world: &mut ClientMessagingWorld, expected: u64) -> TestResult { - world.verify_correlation_id(expected) -} - -#[then("the response has a matching correlation ID")] -async fn then_response_has_matching_correlation(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_response_correlation_matches() -} - -#[then("no correlation mismatch error occurs")] -async fn then_no_mismatch_error(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_no_mismatch_error() -} - -#[then("a CorrelationMismatch error is returned")] -async fn then_mismatch_error(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_mismatch_error() -} - -#[then("each envelope has a unique correlation ID")] -async fn then_unique_correlation_ids(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_unique_correlation_ids() -} - -#[then(expr = "the response contains the same message ID and payload")] -async fn then_response_matches(world: &mut ClientMessagingWorld) -> TestResult { - world.verify_response_matches_expected() -} diff --git a/tests/steps/client_preamble_steps.rs b/tests/steps/client_preamble_steps.rs deleted file mode 100644 index 5904b760..00000000 --- a/tests/steps/client_preamble_steps.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Steps for wireframe client preamble behavioural tests. - -use cucumber::{given, then, when}; - -use crate::world::{ClientPreambleWorld, TestResult}; - -#[given("a preamble-aware echo server")] -async fn given_preamble_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_preamble_server().await -} - -#[given("a preamble-aware echo server that sends an acknowledgement preamble")] -async fn given_ack_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_ack_server().await -} - -#[given("a slow preamble server that never responds")] -async fn given_slow_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_slow_server().await -} - -#[given("a standard echo server without preamble support")] -async fn given_standard_server(world: &mut ClientPreambleWorld) -> TestResult { - world.start_standard_server().await -} - -#[when(expr = "a client connects with a preamble containing version {int}")] -async fn when_connect_with_version(world: &mut ClientPreambleWorld, version: u16) -> TestResult { - world.connect_with_preamble(version).await -} - -#[when("a client connects with a preamble and reads the acknowledgement")] -async fn when_connect_with_ack(world: &mut ClientPreambleWorld) -> TestResult { - world.connect_with_ack().await -} - -#[when(expr = "a client connects with a {int}ms preamble timeout")] -async fn when_connect_with_timeout(world: &mut ClientPreambleWorld, timeout_ms: u64) -> TestResult { - world.connect_with_timeout(timeout_ms).await -} - -#[when("a client connects without a preamble")] -async fn when_connect_without_preamble(world: &mut ClientPreambleWorld) -> TestResult { - world.connect_without_preamble().await -} - -#[then(expr = "the server receives the preamble with version {int}")] -fn then_server_receives_preamble(world: &mut ClientPreambleWorld, version: u16) -> TestResult { - if world.server_received_version() != Some(version) { - return Err(format!( - "expected server to receive version {}, got {:?}", - version, - world.server_received_version() - ) - .into()); - } - Ok(()) -} - -#[then("the client success callback is invoked")] -fn then_success_callback_invoked(world: &mut ClientPreambleWorld) -> TestResult { - if !world.success_invoked() { - return Err("expected success callback to be invoked".into()); - } - world.abort_server(); - Ok(()) -} - -#[then("the client receives an accepted acknowledgement")] -fn then_receives_ack(world: &mut ClientPreambleWorld) -> TestResult { - if !world.ack_accepted() { - return Err("expected client to receive accepted acknowledgement".into()); - } - world.abort_server(); - Ok(()) -} - -#[then("the client fails with a timeout error")] -fn then_timeout_error(world: &mut ClientPreambleWorld) -> TestResult { - if !world.was_timeout_error() { - return Err("expected timeout error".into()); - } - world.abort_server(); - Ok(()) -} - -#[then("the failure callback is invoked")] -fn then_failure_callback_invoked(world: &mut ClientPreambleWorld) -> TestResult { - if !world.failure_invoked() { - return Err("expected failure callback to be invoked".into()); - } - world.abort_server(); - Ok(()) -} - -#[then("the client connects successfully")] -fn then_connects_successfully(world: &mut ClientPreambleWorld) -> TestResult { - if !world.is_connected() { - return Err("expected client to connect successfully".into()); - } - world.abort_server(); - Ok(()) -} diff --git a/tests/steps/client_steps.rs b/tests/steps/client_steps.rs deleted file mode 100644 index dc9684ae..00000000 --- a/tests/steps/client_steps.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Steps for wireframe client runtime behavioural tests. - -use cucumber::{given, then, when}; - -use crate::world::{ClientRuntimeWorld, TestResult}; - -#[given(expr = "a wireframe echo server allowing frames up to {int} bytes")] -async fn given_server(world: &mut ClientRuntimeWorld, max_frame_length: usize) -> TestResult { - world.start_server(max_frame_length).await -} - -#[given(expr = "a wireframe client configured with max frame length {int}")] -async fn given_client(world: &mut ClientRuntimeWorld, max_frame_length: usize) -> TestResult { - world.connect_client(max_frame_length).await -} - -#[when(expr = "the client sends a payload of {int} bytes")] -async fn when_send_payload(world: &mut ClientRuntimeWorld, size: usize) -> TestResult { - world.send_payload(size).await -} - -#[when(expr = "the client sends an oversized payload of {int} bytes")] -async fn when_send_oversized_payload(world: &mut ClientRuntimeWorld, size: usize) -> TestResult { - world.send_payload_expect_error(size).await -} - -#[then("the client receives the echoed payload")] -async fn then_receives_echo(world: &mut ClientRuntimeWorld) -> TestResult { - world.verify_echo().await -} - -#[then("the client reports a framing error")] -async fn then_reports_error(world: &mut ClientRuntimeWorld) -> TestResult { - world.verify_error().await -} diff --git a/tests/steps/codec_error_steps.rs b/tests/steps/codec_error_steps.rs deleted file mode 100644 index eb0370d4..00000000 --- a/tests/steps/codec_error_steps.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Steps for codec error taxonomy behavioural tests. -//! -//! These steps exercise real codec operations to verify error handling -//! and recovery policy behaviour. - -use cucumber::{given, then, when}; - -use crate::world::{CodecErrorWorld, TestResult}; - -// ============================================================================= -// Given steps -// ============================================================================= - -#[given(expr = "a wireframe server with default codec")] -fn given_server_default(world: &mut CodecErrorWorld) { world.setup_default_codec(); } - -#[given(expr = "a wireframe server with max frame length {int} bytes")] -fn given_server_max_frame(world: &mut CodecErrorWorld, max_len: usize) { - world.setup_codec_with_max_length(max_len); -} - -#[given(expr = "a codec error of type {word} with variant {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber step macros require owned String for {word} captures" -)] -fn given_error_type_variant( - world: &mut CodecErrorWorld, - error_type: String, - variant: String, -) -> TestResult { - world.set_error_type(&error_type)?; - match error_type.as_str() { - "framing" => world.set_framing_variant(&variant)?, - "eof" => world.set_eof_variant(&variant)?, - "protocol" | "io" => {} // No sub-variants to set - _ => return Err(format!("unknown error type: {error_type}").into()), - } - Ok(()) -} - -// ============================================================================= -// When steps - Real codec operations -// ============================================================================= - -#[when("a client connects and sends a complete frame")] -fn when_client_sends_complete(world: &mut CodecErrorWorld) -> TestResult { - // Send a small payload that fits within the frame limit - world.send_complete_frame(&[1, 2, 3, 4]) -} - -#[when("the client closes the connection cleanly")] -fn when_client_closes_clean(world: &mut CodecErrorWorld) -> TestResult { - // Decode the complete frame, then call decode_eof on empty buffer - world.decode_eof_clean_close() -} - -#[when("a client connects and sends partial frame data")] -fn when_client_sends_partial(world: &mut CodecErrorWorld) { - // Send header indicating 100 bytes, but no payload - world.send_partial_frame_header_only(); -} - -#[when("the client closes the connection abruptly")] -fn when_client_closes_abrupt(world: &mut CodecErrorWorld) -> TestResult { - // Call decode_eof with partial data in buffer - world.decode_eof_with_partial_data() -} - -#[when(expr = "a client sends a frame larger than {int} bytes")] -fn when_client_sends_oversized(world: &mut CodecErrorWorld, max: usize) -> TestResult { - // Attempt to encode a payload larger than max_frame_length - // Add 1 to exceed the limit - world.encode_oversized_frame(max + 1) -} - -// ============================================================================= -// Then steps - Verification -// ============================================================================= - -#[then("the server detects a clean EOF")] -fn then_server_clean_eof(world: &mut CodecErrorWorld) -> TestResult { world.verify_clean_eof() } - -#[then("the server detects a mid-frame EOF with partial data")] -fn then_server_mid_frame_eof(world: &mut CodecErrorWorld) -> TestResult { - world.verify_incomplete_eof() -} - -#[then("the server rejects the frame with an oversized error")] -fn then_server_oversized(world: &mut CodecErrorWorld) -> TestResult { - world.verify_oversized_error() -} - -#[then(expr = "the default recovery policy is {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber step macros require owned String for {word} captures" -)] -fn then_recovery_policy(world: &mut CodecErrorWorld, policy: String) -> TestResult { - world.verify_recovery_policy(&policy) -} diff --git a/tests/steps/codec_stateful_steps.rs b/tests/steps/codec_stateful_steps.rs deleted file mode 100644 index 2967ae49..00000000 --- a/tests/steps/codec_stateful_steps.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Steps for stateful codec behavioural tests. - -use cucumber::{given, then, when}; - -use crate::world::{CodecStatefulWorld, TestResult}; - -#[given(expr = "a stateful wireframe server allowing frames up to {int} bytes")] -async fn given_server(world: &mut CodecStatefulWorld, max_frame_length: usize) -> TestResult { - world.start_server(max_frame_length).await -} - -#[when(expr = "the first client sends {int} requests")] -async fn when_first_client_sends(world: &mut CodecStatefulWorld, count: usize) -> TestResult { - world.send_first_requests(count).await -} - -#[when(expr = "the second client sends {int} request")] -async fn when_second_client_sends(world: &mut CodecStatefulWorld, count: usize) -> TestResult { - world.send_second_requests(count).await -} - -#[then(expr = "the first client observes sequence numbers {int} and {int}")] -async fn then_first_client_sequences( - world: &mut CodecStatefulWorld, - first: u64, - second: u64, -) -> TestResult { - world.verify_first_sequences(&[first, second]).await -} - -#[then(expr = "the second client observes sequence number {int}")] -async fn then_second_client_sequence(world: &mut CodecStatefulWorld, seq: u64) -> TestResult { - world.verify_second_sequences(&[seq]).await -} diff --git a/tests/steps/correlation_steps.rs b/tests/steps/correlation_steps.rs deleted file mode 100644 index cc99d1a7..00000000 --- a/tests/steps/correlation_steps.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Steps for `correlation_id` behavioural tests. -use cucumber::{given, then, when}; - -use crate::world::{CorrelationWorld, TestResult}; - -#[given(expr = "a correlation id {int}")] -fn given_cid(world: &mut CorrelationWorld, id: u64) { world.set_expected(Some(id)); } - -#[given("no correlation id")] -fn given_no_correlation(world: &mut CorrelationWorld) { world.set_expected(None); } - -#[when("a stream of frames is processed")] -async fn when_process(world: &mut CorrelationWorld) -> TestResult { world.process().await } - -#[when("a multi-packet channel emits frames")] -async fn when_process_multi(world: &mut CorrelationWorld) -> TestResult { - world.process_multi().await -} - -#[then(expr = "each emitted frame uses correlation id {int}")] -fn then_verify(world: &mut CorrelationWorld, id: u64) -> TestResult { - if world.expected() != Some(id) { - return Err("mismatched expected correlation id".into()); - } - world.verify() -} - -#[then("each emitted frame has no correlation id")] -fn then_verify_absent(world: &mut CorrelationWorld) -> TestResult { - if world.expected().is_some() { - return Err("expected correlation id should be cleared".into()); - } - world.verify() -} diff --git a/tests/steps/fragment_steps.rs b/tests/steps/fragment_steps.rs deleted file mode 100644 index 46d01507..00000000 --- a/tests/steps/fragment_steps.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Steps for fragment metadata behavioural tests. -use std::time::Duration; - -use cucumber::{given, then, when}; -use wireframe::{FragmentHeader, FragmentIndex, MessageId}; - -use crate::world::{FragmentWorld, TestResult}; - -#[given(expr = "a fragment series for message {int}")] -fn given_series(world: &mut FragmentWorld, message: u64) { world.start_series(message); } - -#[given(expr = "the series expects fragment index {int}")] -fn given_series_expectation(world: &mut FragmentWorld, index: u32) -> TestResult { - world.force_next_index(index)?; - Ok(()) -} - -#[when(expr = "fragment {int} arrives marked non-final")] -fn when_fragment_non_final(world: &mut FragmentWorld, index: u32) -> TestResult { - world.accept_fragment(index, false)?; - Ok(()) -} - -#[when(expr = "fragment {int} arrives marked final")] -fn when_fragment_final(world: &mut FragmentWorld, index: u32) -> TestResult { - world.accept_fragment(index, true)?; - Ok(()) -} - -#[when(expr = "fragment {int} from message {int} arrives marked non-final")] -fn when_fragment_other_message(world: &mut FragmentWorld, index: u32, message: u64) -> TestResult { - world.accept_fragment_from(message, index, false)?; - Ok(()) -} - -#[then("the fragment completes the message")] -fn then_fragment_completes(world: &mut FragmentWorld) -> TestResult { - world.assert_completion()?; - Ok(()) -} - -#[then("the fragment is rejected as out-of-order")] -fn then_fragment_out_of_order(world: &mut FragmentWorld) -> TestResult { - world.assert_index_mismatch()?; - Ok(()) -} - -#[then("the fragment is rejected for the wrong message")] -fn then_fragment_wrong_message(world: &mut FragmentWorld) -> TestResult { - world.assert_message_mismatch()?; - Ok(()) -} - -#[then("the fragment is rejected for index overflow")] -fn then_fragment_overflow(world: &mut FragmentWorld) -> TestResult { - world.assert_index_overflow()?; - Ok(()) -} - -#[then("the fragment is rejected because the series is complete")] -fn then_fragment_complete(world: &mut FragmentWorld) -> TestResult { - world.assert_series_complete_error()?; - Ok(()) -} - -#[given(expr = "a fragmenter capped at {int} bytes per fragment")] -fn given_fragmenter(world: &mut FragmentWorld, max_payload: usize) -> TestResult { - world.configure_fragmenter(max_payload)?; - Ok(()) -} - -#[when(expr = "the fragmenter splits a payload of {int} bytes")] -fn when_fragmenter_splits(world: &mut FragmentWorld, len: usize) -> TestResult { - world.fragment_payload(len)?; - Ok(()) -} - -#[then(expr = "the fragmenter produces {int} fragments")] -fn then_fragment_count(world: &mut FragmentWorld, expected: usize) -> TestResult { - world.assert_fragment_count(expected)?; - Ok(()) -} - -#[then(expr = "fragment {int} carries {int} bytes")] -fn then_fragment_payload_len(world: &mut FragmentWorld, index: usize, len: usize) -> TestResult { - world.assert_fragment_payload_len(index, len)?; - Ok(()) -} - -#[then(expr = "fragment {int} is marked final")] -fn then_fragment_final(world: &mut FragmentWorld, index: usize) -> TestResult { - world.assert_fragment_final_flag(index, true)?; - Ok(()) -} - -#[then(expr = "fragment {int} is marked non-final")] -fn then_fragment_non_final(world: &mut FragmentWorld, index: usize) -> TestResult { - world.assert_fragment_final_flag(index, false)?; - Ok(()) -} - -#[then(expr = "the fragments use message id {int}")] -fn then_fragment_message_id(world: &mut FragmentWorld, message_id: u64) -> TestResult { - world.assert_message_id(message_id)?; - Ok(()) -} - -#[given(expr = "a reassembler allowing {int} bytes with a {int}-second reassembly timeout")] -fn given_reassembler(world: &mut FragmentWorld, max_bytes: usize, timeout_secs: u64) -> TestResult { - world.configure_reassembler(max_bytes, timeout_secs)?; - Ok(()) -} - -#[when(expr = "fragment {int} for message {int} with {int} bytes arrives marked non-final")] -fn when_reassembler_fragment_non_final( - world: &mut FragmentWorld, - index: u32, - message: u64, - len: usize, -) -> TestResult { - let header = FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), false); - world.push_fragment(header, len)?; - Ok(()) -} - -#[when(expr = "fragment {int} for message {int} with {int} bytes arrives marked final")] -fn when_reassembler_fragment_final( - world: &mut FragmentWorld, - index: u32, - message: u64, - len: usize, -) -> TestResult { - let header = FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), true); - world.push_fragment(header, len)?; - Ok(()) -} - -#[when(expr = "time advances by {int} seconds for reassembly")] -fn when_time_advances(world: &mut FragmentWorld, seconds: u64) -> TestResult { - world.advance_time(Duration::from_secs(seconds))?; - Ok(()) -} - -#[when("expired reassembly buffers are purged")] -fn when_reassembly_purged(world: &mut FragmentWorld) -> TestResult { - world.purge_reassembly()?; - Ok(()) -} - -#[then(expr = "the reassembler outputs a payload of {int} bytes")] -fn then_reassembled_len(world: &mut FragmentWorld, expected: usize) -> TestResult { - world.assert_reassembled_len(expected)?; - Ok(()) -} - -#[then("no message has been reassembled yet")] -fn then_no_reassembled_message(world: &mut FragmentWorld) -> TestResult { - world.assert_no_reassembly()?; - Ok(()) -} - -#[then("the reassembler reports a message-too-large error")] -fn then_reassembly_over_limit(world: &mut FragmentWorld) -> TestResult { - world.assert_reassembly_over_limit()?; - Ok(()) -} - -#[then("the reassembler reports an out-of-order fragment error")] -fn then_reassembly_out_of_order(world: &mut FragmentWorld) -> TestResult { - world.assert_reassembly_out_of_order()?; - Ok(()) -} - -#[then(expr = "the reassembler is buffering {int} messages")] -fn then_buffered_messages(world: &mut FragmentWorld, expected: usize) -> TestResult { - world.assert_buffered_messages(expected)?; - Ok(()) -} - -#[then(expr = "message {int} is evicted")] -fn then_message_evicted(world: &mut FragmentWorld, message: u64) -> TestResult { - world.assert_evicted_message(message)?; - Ok(()) -} diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs deleted file mode 100644 index 60887aeb..00000000 --- a/tests/steps/message_assembler_steps.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Step definitions for message assembler header parsing. - -use cucumber::{given, then, when}; - -use crate::world::{ContinuationHeaderSpec, FirstHeaderSpec, MessageAssemblerWorld}; - -const DEFAULT_METADATA_LEN: usize = 0; -const FLAG_NONE: bool = false; -const FLAG_LAST: bool = true; -const NO_SEQUENCE: Option = None; -const NO_TOTAL_LEN: Option = None; - -// Helper builders to reduce duplication in step definitions -fn first_header_without_total(key: u64, metadata_len: usize, body_len: usize) -> FirstHeaderSpec { - FirstHeaderSpec { - key, - metadata_len, - body_len, - total_len: NO_TOTAL_LEN, - is_last: FLAG_NONE, - } -} - -fn first_header_with_total(key: u64, body_len: usize, total_len: usize) -> FirstHeaderSpec { - FirstHeaderSpec { - key, - metadata_len: DEFAULT_METADATA_LEN, - body_len, - total_len: Some(total_len), - is_last: FLAG_LAST, - } -} - -fn continuation_header_with_sequence( - key: u64, - body_len: usize, - sequence: u32, -) -> ContinuationHeaderSpec { - ContinuationHeaderSpec { - key, - body_len, - sequence: Some(sequence), - is_last: FLAG_NONE, - } -} - -fn continuation_header_without_sequence(key: u64, body_len: usize) -> ContinuationHeaderSpec { - ContinuationHeaderSpec { - key, - body_len, - sequence: NO_SEQUENCE, - is_last: FLAG_LAST, - } -} - -// Cucumber step definitions -#[given(expr = "a first frame header with key {int} metadata length {int} body length {int}")] -fn given_first_header( - world: &mut MessageAssemblerWorld, - key: u64, - metadata_len: usize, - body_len: usize, -) -> crate::world::TestResult { - world.set_first_header(first_header_without_total(key, metadata_len, body_len)) -} - -#[given(expr = "a first frame header with key {int} body length {int} total {int}")] -fn given_first_header_with_total( - world: &mut MessageAssemblerWorld, - key: u64, - body_len: usize, - total_len: usize, -) -> crate::world::TestResult { - world.set_first_header(first_header_with_total(key, body_len, total_len)) -} - -#[given(expr = "a continuation header with key {int} body length {int} sequence {int}")] -fn given_continuation_header_with_sequence( - world: &mut MessageAssemblerWorld, - key: u64, - body_len: usize, - sequence: u32, -) -> crate::world::TestResult { - world.set_continuation_header(continuation_header_with_sequence(key, body_len, sequence)) -} - -#[given(expr = "a continuation header with key {int} body length {int}")] -fn given_continuation_header( - world: &mut MessageAssemblerWorld, - key: u64, - body_len: usize, -) -> crate::world::TestResult { - world.set_continuation_header(continuation_header_without_sequence(key, body_len)) -} - -#[given("a wireframe app with a message assembler")] -fn given_app_with_message_assembler(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.set_app_with_message_assembler() -} - -#[given("an invalid message header")] -fn given_invalid_header(world: &mut MessageAssemblerWorld) { world.set_invalid_payload(); } - -#[when("the message assembler parses the header")] -fn when_parsing(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.parse_header() -} - -#[then(expr = "the parsed header is {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {word} captures to step functions as owned strings" -)] -fn then_header_kind(world: &mut MessageAssemblerWorld, kind: String) -> crate::world::TestResult { - world.assert_header_kind(&kind) -} - -#[then(expr = "the message key is {int}")] -fn then_message_key(world: &mut MessageAssemblerWorld, key: u64) -> crate::world::TestResult { - world.assert_message_key(key) -} - -#[then(expr = "the header metadata length is {int}")] -fn then_metadata_len( - world: &mut MessageAssemblerWorld, - metadata_len: usize, -) -> crate::world::TestResult { - world.assert_metadata_len(metadata_len) -} - -#[then(expr = "the body length is {int}")] -fn then_body_len(world: &mut MessageAssemblerWorld, body_len: usize) -> crate::world::TestResult { - world.assert_body_len(body_len) -} - -#[then(expr = "the header length is {int}")] -fn then_header_len( - world: &mut MessageAssemblerWorld, - header_len: usize, -) -> crate::world::TestResult { - world.assert_header_len(header_len) -} - -#[then("the total body length is absent")] -fn then_total_absent(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.assert_total_len(None) -} - -#[then(expr = "the total body length is {int}")] -fn then_total_present(world: &mut MessageAssemblerWorld, total: usize) -> crate::world::TestResult { - world.assert_total_len(Some(total)) -} - -#[then(expr = "the sequence is {int}")] -fn then_sequence(world: &mut MessageAssemblerWorld, sequence: u32) -> crate::world::TestResult { - world.assert_sequence(Some(sequence)) -} - -#[then("the sequence is absent")] -fn then_sequence_absent(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.assert_sequence(None) -} - -#[then(expr = "the frame is marked last {word}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {word} captures to step functions as owned strings" -)] -fn then_is_last(world: &mut MessageAssemblerWorld, expected: String) -> crate::world::TestResult { - world.assert_is_last(expected == "true") -} - -#[then("the parse fails with invalid data")] -fn then_invalid_data(world: &mut MessageAssemblerWorld) -> crate::world::TestResult { - world.assert_invalid_data_error() -} - -#[then("the app exposes a message assembler")] -fn then_app_exposes_message_assembler( - world: &mut MessageAssemblerWorld, -) -> crate::world::TestResult { - world.assert_message_assembler_configured() -} diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs deleted file mode 100644 index abe094d5..00000000 --- a/tests/steps/message_assembly_steps.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Step definitions for message assembly multiplexing and continuity validation. - -use cucumber::{given, then, when}; -use wireframe::message_assembler::{FrameSequence, MessageKey}; - -use crate::world::{ContinuationFrameParams, FirstFrameParams, MessageAssemblyWorld, TestResult}; - -/// Convert primitive key to domain type at the boundary. -fn to_key(key: u64) -> MessageKey { MessageKey(key) } - -/// Convert primitive sequence to domain type at the boundary. -fn to_seq(seq: u32) -> FrameSequence { FrameSequence(seq) } - -/// Helper function to reduce duplication in Then step assertions -fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult { - if condition { - Ok(()) - } else { - Err(error_msg.into().into()) - } -} - -// ============================================================================= -// Given steps -// ============================================================================= - -#[given(expr = "a message assembly state with max size {int} and timeout {int} seconds")] -fn given_state(world: &mut MessageAssemblyWorld, max_size: usize, timeout: u64) { - world.create_state(max_size, timeout); -} - -#[given(expr = "a first frame for key {int} with metadata {string} and body {string}")] -fn given_first_frame_with_metadata( - world: &mut MessageAssemblyWorld, - key: u64, - metadata: String, - body: String, -) { - world.add_first_frame( - FirstFrameParams::new(to_key(key), body.into_bytes()).with_metadata(metadata.into_bytes()), - ); -} - -#[given(expr = "a first frame for key {int} with body {string}")] -fn given_first_frame(world: &mut MessageAssemblyWorld, key: u64, body: String) { - world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); -} - -#[given(expr = "a final first frame for key {int} with body {string}")] -fn given_final_first_frame(world: &mut MessageAssemblyWorld, key: u64, body: String) { - world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes()).final_frame()); -} - -// ============================================================================= -// When steps -// ============================================================================= - -#[when("the first frame is accepted")] -#[when("the first frame is accepted at time T")] -fn when_first_frame_accepted(world: &mut MessageAssemblyWorld) -> TestResult { - world.accept_first_frame() -} - -#[when("all first frames are accepted")] -fn when_all_first_frames_accepted(world: &mut MessageAssemblyWorld) -> TestResult { - world.accept_all_first_frames() -} - -#[when(expr = "a final continuation for key {int} with sequence {int} and body {string} arrives")] -fn when_final_continuation( - world: &mut MessageAssemblyWorld, - key: u64, - seq: u32, - body: String, -) -> TestResult { - world.accept_continuation( - ContinuationFrameParams::new(to_key(key), body.into_bytes()) - .with_sequence(to_seq(seq)) - .final_frame(), - ) -} - -#[when(expr = "a continuation for key {int} with sequence {int} arrives")] -fn when_continuation_with_seq(world: &mut MessageAssemblyWorld, key: u64, seq: u32) -> TestResult { - world.accept_continuation( - ContinuationFrameParams::new(to_key(key), b"data".to_vec()).with_sequence(to_seq(seq)), - ) -} - -#[when(expr = "a continuation for key {int} with body {string} arrives")] -fn when_continuation_with_body( - world: &mut MessageAssemblyWorld, - key: u64, - body: String, -) -> TestResult { - world.accept_continuation(ContinuationFrameParams::new(to_key(key), body.into_bytes())) -} - -#[when(expr = "another first frame for key {int} with body {string} arrives")] -fn when_another_first_frame( - world: &mut MessageAssemblyWorld, - key: u64, - body: String, -) -> TestResult { - world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); - world.accept_first_frame() -} - -#[when(expr = "time advances by {int} seconds")] -fn when_time_advances(world: &mut MessageAssemblyWorld, secs: u64) -> TestResult { - world.advance_time(secs) -} - -#[when("expired assemblies are purged")] -fn when_purge_expired(world: &mut MessageAssemblyWorld) -> TestResult { world.purge_expired() } - -// ============================================================================= -// Then steps -// ============================================================================= - -#[then("the assembly result is incomplete")] -fn then_result_incomplete(world: &mut MessageAssemblyWorld) -> TestResult { - assert_condition( - world.last_result_is_incomplete(), - "expected incomplete result", - ) -} - -#[then(expr = "the assembly completes with body {string}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {string} captures to step functions as owned strings" -)] -fn then_completes_with_body(world: &mut MessageAssemblyWorld, body: String) -> TestResult { - let actual = world - .last_completed_body() - .ok_or("expected completed message")?; - assert_condition( - actual == body.as_bytes(), - format!( - "body mismatch: expected {:?}, got {:?}", - body.as_bytes(), - actual - ), - ) -} - -#[then(expr = "key {int} completes with body {string}")] -#[expect( - clippy::needless_pass_by_value, - reason = "cucumber hands {string} captures to step functions as owned strings" -)] -fn then_key_completes(world: &mut MessageAssemblyWorld, key: u64, body: String) -> TestResult { - let actual = world - .completed_body_for_key(to_key(key)) - .ok_or_else(|| format!("expected completed message for key {key}"))?; - assert_condition( - actual == body.as_bytes(), - format!( - "body mismatch for key {key}: expected {:?}, got {:?}", - body.as_bytes(), - actual - ), - ) -} - -#[then(expr = "the buffered count is {int}")] -fn then_buffered_count(world: &mut MessageAssemblyWorld, count: usize) -> TestResult { - let actual = world.buffered_count(); - assert_condition( - actual == count, - format!("buffered count mismatch: expected {count}, got {actual}"), - ) -} - -#[then(expr = "the error is sequence mismatch expecting {int} but found {int}")] -fn then_error_sequence_mismatch( - world: &mut MessageAssemblyWorld, - expected: u32, - found: u32, -) -> TestResult { - assert_condition( - world.is_sequence_mismatch(to_seq(expected), to_seq(found)), - format!( - "expected sequence mismatch error, got {:?}", - world.last_error() - ), - ) -} - -#[then(expr = "the error is duplicate frame for key {int} sequence {int}")] -fn then_error_duplicate_frame(world: &mut MessageAssemblyWorld, key: u64, seq: u32) -> TestResult { - assert_condition( - world.is_duplicate_frame(to_key(key), to_seq(seq)), - format!( - "expected duplicate frame error, got {:?}", - world.last_error() - ), - ) -} - -#[then(expr = "the error is missing first frame for key {int}")] -fn then_error_missing_first_frame(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.is_missing_first_frame(to_key(key)), - format!( - "expected missing first frame error, got {:?}", - world.last_error() - ), - ) -} - -#[then(expr = "the error is duplicate first frame for key {int}")] -fn then_error_duplicate_first_frame(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.is_duplicate_first_frame(to_key(key)), - format!( - "expected duplicate first frame error, got {:?}", - world.last_error() - ), - ) -} - -#[then(expr = "the error is message too large for key {int}")] -fn then_error_message_too_large(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.is_message_too_large(to_key(key)), - format!( - "expected message too large error, got {:?}", - world.last_error() - ), - ) -} - -#[then(expr = "key {int} was evicted")] -fn then_key_evicted(world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - world.was_evicted(to_key(key)), - format!("expected key {key} to be evicted"), - ) -} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs deleted file mode 100644 index da24d446..00000000 --- a/tests/steps/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Aggregates step definitions for Cucumber tests. -//! -//! This module exposes all Given-When-Then steps used by the -//! behaviour-driven tests under `tests/features`. - -mod client_lifecycle_steps; -mod client_messaging_steps; -mod client_preamble_steps; -mod client_steps; -mod codec_error_steps; -mod codec_stateful_steps; -mod correlation_steps; -mod fragment_steps; -mod message_assembler_steps; -mod message_assembly_steps; -mod multi_packet_steps; -mod panic_steps; -mod request_parts_steps; -mod stream_end_steps; diff --git a/tests/steps/multi_packet_steps.rs b/tests/steps/multi_packet_steps.rs deleted file mode 100644 index 12791fb2..00000000 --- a/tests/steps/multi_packet_steps.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Steps for multi-packet response behavioural tests. -use cucumber::{then, when}; - -use crate::world::{MultiPacketWorld, TestResult}; - -#[when("a handler uses the with_channel helper to emit messages")] -async fn when_multi(world: &mut MultiPacketWorld) -> TestResult { world.process().await } - -#[then("all messages are received in order")] -fn then_multi(world: &mut MultiPacketWorld) { world.verify(); } - -#[when("a handler uses the with_channel helper to emit no messages")] -async fn when_multi_empty(world: &mut MultiPacketWorld) -> TestResult { - world.process_empty().await -} - -#[then("no messages are received")] -fn then_multi_empty(world: &mut MultiPacketWorld) { world.verify_empty(); } - -#[when("a handler emits more messages than the channel capacity")] -async fn when_multi_overflow(world: &mut MultiPacketWorld) -> TestResult { - world.process_overflow().await -} - -#[then("overflow messages are handled according to channel policy")] -fn then_multi_overflow(world: &mut MultiPacketWorld) { world.verify_overflow(); } diff --git a/tests/steps/panic_steps.rs b/tests/steps/panic_steps.rs deleted file mode 100644 index 439c193a..00000000 --- a/tests/steps/panic_steps.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Cucumber step implementations for panic resilience testing. -//! -//! Defines Given-When-Then steps that verify server stability -//! when connection tasks panic during setup. - -use cucumber::{given, then, when}; - -use crate::world::{PanicWorld, TestResult}; - -#[given("a running wireframe server with a panic in connection setup")] -async fn start_server(world: &mut PanicWorld) -> TestResult { - world.start_panic_server().await?; - Ok(()) -} - -#[when("I connect to the server")] -#[when("I connect to the server again")] -async fn connect(world: &mut PanicWorld) -> TestResult { - world.connect_once().await?; - Ok(()) -} - -#[then("both connections succeed")] -async fn verify(world: &mut PanicWorld) -> TestResult { - world.verify_and_shutdown().await?; - Ok(()) -} diff --git a/tests/steps/request_parts_steps.rs b/tests/steps/request_parts_steps.rs deleted file mode 100644 index 68fe7cf7..00000000 --- a/tests/steps/request_parts_steps.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Steps for request parts behavioural tests. - -use cucumber::{given, then, when}; - -use crate::world::{ - RequestPartsWorld, - TestResult, - types::{CorrelationId, MetadataByte, MetadataLength, RequestId}, -}; - -#[given(expr = "request parts with id {word} and correlation id {word}")] -fn given_parts_with_correlation(world: &mut RequestPartsWorld, id: RequestId, cid: CorrelationId) { - world.create_parts(id.0, Some(cid.0), vec![]); -} - -#[given(expr = "request parts with id {word} and no correlation id")] -fn given_parts_no_correlation(world: &mut RequestPartsWorld, id: RequestId) { - world.create_parts(id.0, None, vec![]); -} - -// Deliberately duplicates `given_parts_no_correlation` to provide distinct -// Gherkin phrasing: scenarios that later add metadata use the shorter form, -// while this form explicitly states the empty-metadata precondition. -#[given(expr = "request parts with id {word}, no correlation id, and empty metadata")] -fn given_parts_empty_metadata(world: &mut RequestPartsWorld, id: RequestId) { - world.create_parts(id.0, None, vec![]); -} - -#[given(expr = "metadata bytes {word}, {word}, {word}")] -fn given_metadata_bytes_three( - world: &mut RequestPartsWorld, - b1: MetadataByte, - b2: MetadataByte, - b3: MetadataByte, -) -> TestResult { - world.append_metadata_byte(b1.0)?; - world.append_metadata_byte(b2.0)?; - world.append_metadata_byte(b3.0) -} - -#[given(expr = "metadata byte {word}")] -fn given_metadata_byte(world: &mut RequestPartsWorld, byte: MetadataByte) -> TestResult { - world.append_metadata_byte(byte.0) -} - -#[when(expr = "inheriting correlation id {word}")] -fn when_inherit_correlation(world: &mut RequestPartsWorld, cid: CorrelationId) -> TestResult { - world.inherit_correlation(Some(cid.0)) -} - -#[when("inheriting no correlation id")] -fn when_inherit_no_correlation(world: &mut RequestPartsWorld) -> TestResult { - world.inherit_correlation(None) -} - -#[when(expr = "appending byte {word} to metadata")] -fn when_append_metadata(world: &mut RequestPartsWorld, byte: MetadataByte) -> TestResult { - world.append_metadata_byte(byte.0) -} - -#[then(expr = "the request id is {word}")] -fn then_id_is(world: &mut RequestPartsWorld, expected: RequestId) -> TestResult { - world.assert_id(expected.0) -} - -#[then(expr = "the correlation id is {word}")] -fn then_correlation_id_is(world: &mut RequestPartsWorld, expected: CorrelationId) -> TestResult { - world.assert_correlation_id(Some(expected.0)) -} - -#[then("the correlation id is absent")] -fn then_correlation_id_is_absent(world: &mut RequestPartsWorld) -> TestResult { - world.assert_correlation_id(None) -} - -#[then(expr = "the metadata length is {word}")] -fn then_metadata_length_is(world: &mut RequestPartsWorld, expected: MetadataLength) -> TestResult { - world.assert_metadata_length(expected.0) -} diff --git a/tests/steps/stream_end_steps.rs b/tests/steps/stream_end_steps.rs deleted file mode 100644 index ff0485d1..00000000 --- a/tests/steps/stream_end_steps.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Steps for stream terminator behavioural tests. -use cucumber::{then, when}; - -use crate::world::{StreamEndWorld, TestResult}; - -#[when("a streaming response completes")] -async fn when_stream(world: &mut StreamEndWorld) -> TestResult { world.process().await } - -#[then("an end-of-stream frame is sent")] -fn then_end(world: &mut StreamEndWorld) { world.verify(); } - -#[when("a multi-packet channel drains")] -async fn when_multi_channel(world: &mut StreamEndWorld) -> TestResult { - world.process_multi().await -} - -#[then("a multi-packet end-of-stream frame is sent")] -fn then_multi_end(world: &mut StreamEndWorld) { world.verify_multi(); } - -#[when("a multi-packet channel disconnects abruptly")] -fn when_multi_disconnect(world: &mut StreamEndWorld) -> TestResult { - world.process_multi_disconnect() -} - -#[when("shutdown closes a multi-packet channel")] -fn when_multi_shutdown(world: &mut StreamEndWorld) -> TestResult { world.process_multi_shutdown() } - -#[then("no multi-packet terminator is sent")] -fn then_no_multi(world: &mut StreamEndWorld) { world.verify_no_multi(); } - -#[then(expr = "the multi-packet termination reason is {word}")] -fn then_reason(world: &mut StreamEndWorld, reason: String) -> TestResult { - let reason = reason.into_boxed_str(); - world.verify_reason(reason.as_ref()) -} diff --git a/tests/world.rs b/tests/world.rs deleted file mode 100644 index c54821fe..00000000 --- a/tests/world.rs +++ /dev/null @@ -1,24 +0,0 @@ -#![cfg(not(loom))] -//! Test worlds for Cucumber suites. - -#[path = "worlds/mod.rs"] -mod worlds; - -pub use worlds::{ - client_lifecycle::{ClientLifecycleWorld, EXPECTED_SETUP_STATE}, - client_messaging::ClientMessagingWorld, - client_preamble::ClientPreambleWorld, - client_runtime::ClientRuntimeWorld, - codec_error::CodecErrorWorld, - codec_stateful::CodecStatefulWorld, - common::TestResult, - correlation::CorrelationWorld, - fragment::FragmentWorld, - message_assembler::{ContinuationHeaderSpec, FirstHeaderSpec, MessageAssemblerWorld}, - message_assembly::{ContinuationFrameParams, FirstFrameParams, MessageAssemblyWorld}, - multi_packet::MultiPacketWorld, - panic::PanicWorld, - request_parts::RequestPartsWorld, - stream_end::StreamEndWorld, - types, -}; diff --git a/tests/worlds/client_lifecycle.rs b/tests/worlds/client_lifecycle.rs deleted file mode 100644 index 9fc02bc5..00000000 --- a/tests/worlds/client_lifecycle.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Test world for client lifecycle hook scenarios. -#![cfg(not(loom))] -#![expect( - clippy::expect_used, - reason = "test code uses expect for concise assertions" -)] -#![expect( - clippy::excessive_nesting, - reason = "async closures within builder patterns are inherently nested" -)] - -use std::{ - net::SocketAddr, - sync::{ - Arc, - atomic::{AtomicBool, AtomicUsize, Ordering}, - }, - time::Duration, -}; - -use futures::FutureExt; -use tokio::{net::TcpListener, task::JoinHandle}; -use wireframe::{ - BincodeSerializer, - client::{ClientError, WireframeClient}, - preamble::{read_preamble, write_preamble}, - rewind_stream::RewindStream, -}; - -use super::TestResult; - -/// Preamble used for testing lifecycle with preamble. -#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] -pub struct TestPreamble { - version: u16, -} - -impl TestPreamble { - /// Create a new test preamble with the given version. - #[must_use] - pub fn new(version: u16) -> Self { Self { version } } -} - -/// Server acknowledgement preamble. -#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] -pub struct ServerAck { - accepted: bool, -} - -/// State value returned by the setup callback and expected by teardown tests. -/// -/// This constant defines the sentinel value used to verify that teardown hooks -/// receive the correct state from setup hooks. -pub const EXPECTED_SETUP_STATE: u32 = 42; - -/// Client type alias for lifecycle tests. -/// -/// Uses `BincodeSerializer` with a `RewindStream` over TCP and `u32` connection state. -type TestClient = WireframeClient, u32>; - -/// Test world exercising client lifecycle hooks. -#[derive(Debug, Default, cucumber::World)] -pub struct ClientLifecycleWorld { - addr: Option, - server: Option>, - client: Option, - setup_count: Arc, - teardown_count: Arc, - teardown_received_state: Arc, - error_count: Arc, - preamble_success_invoked: Arc, - last_error: Option, -} - -impl Drop for ClientLifecycleWorld { - fn drop(&mut self) { - // Ensure server tasks are cleaned up even if test assertions fail. - if let Some(handle) = self.server.take() { - handle.abort(); - } - } -} - -impl ClientLifecycleWorld { - /// Spawn a server that executes the provided behaviour closure after accepting a connection. - /// - /// This helper binds a `TcpListener`, stores the address in `self.addr`, spawns a task - /// that accepts a connection and runs the closure, and stores the task handle in - /// `self.server`. - async fn spawn_server(&mut self, behaviour: F) -> TestResult - where - F: FnOnce(tokio::net::TcpStream) -> Fut + Send + 'static, - Fut: std::future::Future + Send, - { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - behaviour(stream).await; - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Handle the result of a client connection attempt. - /// - /// Stores the client in `self.client` on success, or the error in `self.last_error` - /// on failure. - fn handle_connection_result(&mut self, result: Result) { - match result { - Ok(client) => { - self.client = Some(client); - } - Err(e) => { - self.last_error = Some(e); - } - } - } - - /// Connect using a builder configuration closure. - /// - /// This helper retrieves the server address, applies the provided configuration - /// to a new builder, connects, and handles the result. - async fn connect_with_builder(&mut self, configure: F) -> TestResult - where - F: FnOnce( - wireframe::client::WireframeClientBuilder, - ) -> wireframe::client::WireframeClientBuilder, - P: bincode::Encode + Send + Sync + 'static, - { - let addr = self.addr.ok_or("server address missing")?; - let result = configure(WireframeClient::builder()).connect(addr).await; - self.handle_connection_result(result); - Ok(()) - } - - /// Start a standard echo server. - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if accept fails. - pub async fn start_standard_server(&mut self) -> TestResult { - self.spawn_server(|_stream| async { - // Hold connection briefly - tokio::time::sleep(Duration::from_millis(100)).await; - }) - .await - } - - /// Start a server that disconnects immediately after accepting. - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if accept fails. - pub async fn start_disconnecting_server(&mut self) -> TestResult { - self.spawn_server(|stream| async { - // Disconnect immediately - drop(stream); - }) - .await - } - - /// Start a preamble-aware server that sends acknowledgement. - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if preamble read or ack write fails. - pub async fn start_ack_server(&mut self) -> TestResult { - self.spawn_server(|mut stream| async move { - let (_preamble, _) = read_preamble::<_, TestPreamble>(&mut stream) - .await - .expect("read preamble"); - write_preamble(&mut stream, &ServerAck { accepted: true }) - .await - .expect("write ack"); - tokio::time::sleep(Duration::from_millis(100)).await; - }) - .await - } - - /// Connect with a setup callback. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_with_setup(&mut self) -> TestResult { - let setup_count = Arc::clone(&self.setup_count); - - self.connect_with_builder(|builder| { - builder.on_connection_setup(move || { - let count = setup_count.clone(); - async move { - count.fetch_add(1, Ordering::SeqCst); - EXPECTED_SETUP_STATE - } - }) - }) - .await - } - - /// Connect with setup and teardown callbacks. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_with_setup_and_teardown(&mut self) -> TestResult { - let setup_count = Arc::clone(&self.setup_count); - let teardown_count = Arc::clone(&self.teardown_count); - let teardown_received_state = Arc::clone(&self.teardown_received_state); - - self.connect_with_builder(|builder| { - builder - .on_connection_setup(move || { - let count = setup_count.clone(); - async move { - count.fetch_add(1, Ordering::SeqCst); - EXPECTED_SETUP_STATE - } - }) - .on_connection_teardown(move |state: u32| { - let count = teardown_count.clone(); - let received = teardown_received_state.clone(); - async move { - count.fetch_add(1, Ordering::SeqCst); - received.store(state as usize, Ordering::SeqCst); - } - }) - }) - .await - } - - /// Connect with an error callback. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_with_error_callback(&mut self) -> TestResult { - let error_count = Arc::clone(&self.error_count); - - self.connect_with_builder(|builder| { - builder - .on_connection_setup(|| async { 0u32 }) - .on_error(move |_err| { - let count = error_count.clone(); - async move { - count.fetch_add(1, Ordering::SeqCst); - } - }) - }) - .await - } - - /// Connect with preamble and lifecycle callbacks. - /// - /// # Errors - /// Returns an error if server address is missing. - /// - /// # Panics - /// Asserts if server does not accept preamble. - pub async fn connect_with_preamble_and_lifecycle(&mut self) -> TestResult { - let setup_count = Arc::clone(&self.setup_count); - let preamble_invoked = Arc::clone(&self.preamble_success_invoked); - - self.connect_with_builder(|builder| { - builder - .with_preamble(TestPreamble::new(1)) - .on_preamble_success(move |_preamble, stream| { - let invoked = preamble_invoked.clone(); - async move { - invoked.store(true, Ordering::SeqCst); - let (ack, leftover) = - read_preamble::<_, ServerAck>(stream).await.map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) - })?; - assert!(ack.accepted, "server should accept preamble"); - Ok(leftover) - } - .boxed() - }) - .on_connection_setup(move || { - let count = setup_count.clone(); - async move { - count.fetch_add(1, Ordering::SeqCst); - EXPECTED_SETUP_STATE - } - }) - }) - .await - } - - /// Close the client connection. - pub async fn close_client(&mut self) { - if let Some(client) = self.client.take() { - client.close().await; - } - } - - /// Attempt to receive a message (should fail after server disconnect). - /// - /// # Errors - /// Returns `Ok` but stores any receive error in `last_error`. - pub async fn attempt_receive(&mut self) -> TestResult { - if let Some(ref mut client) = self.client { - // Wait a bit for server to disconnect - tokio::time::sleep(Duration::from_millis(50)).await; - let result: Result, ClientError> = client.receive().await; - if let Err(e) = result { - self.last_error = Some(e); - } - } - Ok(()) - } - - /// Get the setup callback invocation count. - #[must_use] - pub fn setup_count(&self) -> usize { self.setup_count.load(Ordering::SeqCst) } - - /// Get the teardown callback invocation count. - #[must_use] - pub fn teardown_count(&self) -> usize { self.teardown_count.load(Ordering::SeqCst) } - - /// Get the state received by teardown callback. - #[must_use] - pub fn teardown_received_state(&self) -> usize { - self.teardown_received_state.load(Ordering::SeqCst) - } - - /// Get the error callback invocation count. - #[must_use] - pub fn error_count(&self) -> usize { self.error_count.load(Ordering::SeqCst) } - - /// Check if preamble success callback was invoked. - #[must_use] - pub fn preamble_success_invoked(&self) -> bool { - self.preamble_success_invoked.load(Ordering::SeqCst) - } - - /// Get a reference to the last captured error, if any. - #[must_use] - pub fn last_error(&self) -> Option<&ClientError> { self.last_error.as_ref() } -} diff --git a/tests/worlds/client_messaging.rs b/tests/worlds/client_messaging.rs deleted file mode 100644 index 8507328b..00000000 --- a/tests/worlds/client_messaging.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Test world for client messaging scenarios with correlation ID support. -#![cfg(not(loom))] - -use std::net::SocketAddr; - -use bytes::Bytes; -use cucumber::World; -use futures::{SinkExt, StreamExt}; -use log::warn; -use tokio::{net::TcpListener, task::JoinHandle}; -use tokio_util::codec::{Framed, LengthDelimitedCodec}; -use wireframe::{ - BincodeSerializer, - app::{Envelope, Packet}, - client::{ClientError, WireframeClient}, - correlation::CorrelatableFrame, - rewind_stream::RewindStream, -}; -use wireframe_testing::{ServerMode, process_frame}; - -use super::TestResult; - -/// Test world for client messaging scenarios. -#[derive(Debug, Default, World)] -pub struct ClientMessagingWorld { - addr: Option, - server: Option>, - client: Option>>, - envelope: Option, - sent_correlation_ids: Vec, - /// The last response received from the server. - pub response: Option, - last_error: Option, - /// Expected message ID for response verification. - expected_message_id: Option, - /// Expected payload for response verification. - expected_payload: Option, -} - -impl ClientMessagingWorld { - /// Start an envelope echo server. - /// - /// # Errors - /// Returns an error if binding or spawning the server fails. - pub async fn start_echo_server(&mut self) -> TestResult { - self.start_server_with_mode(ServerMode::Echo).await - } - - /// Start a server that returns mismatched correlation IDs. - /// - /// # Errors - /// Returns an error if binding or spawning the server fails. - pub async fn start_mismatch_server(&mut self) -> TestResult { - self.start_server_with_mode(ServerMode::Mismatch).await - } - - async fn start_server_with_mode(&mut self, mode: ServerMode) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let handle = tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { - warn!("client messaging server failed to accept connection"); - return; - }; - - let mut framed = Framed::new(stream, LengthDelimitedCodec::new()); - run_frame_loop(&mut framed, mode).await; - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Connect a client to the server. - /// - /// # Errors - /// Returns an error if the server has not started or the client fails to connect. - pub async fn connect_client(&mut self) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let client = WireframeClient::builder().connect(addr).await?; - self.client = Some(client); - Ok(()) - } - - /// Set an envelope without a correlation ID. - pub fn set_envelope_without_correlation(&mut self) { - self.envelope = Some(Envelope::new(1, None, vec![1, 2, 3])); - } - - /// Set an envelope with a specific correlation ID. - pub fn set_envelope_with_correlation(&mut self, correlation_id: u64) { - self.envelope = Some(Envelope::new(1, Some(correlation_id), vec![1, 2, 3])); - } - - /// Set an envelope with a specific message ID and payload. - pub fn set_envelope_with_payload(&mut self, message_id: u32, payload: &str) { - self.envelope = Some(Envelope::new(message_id, None, payload.as_bytes().to_vec())); - self.expected_message_id = Some(message_id); - self.expected_payload = Some(payload.to_string()); - } - - /// Send the configured envelope and capture the returned correlation ID. - /// - /// # Errors - /// Returns an error if the client is missing or communication fails. - pub async fn send_envelope(&mut self) -> TestResult { - let client = self.client.as_mut().ok_or("client not connected")?; - let envelope = self.envelope.take().ok_or("envelope not configured")?; - let correlation_id = client.send_envelope(envelope).await?; - self.sent_correlation_ids.push(correlation_id); - Ok(()) - } - - /// Call the server with `call_correlated` and capture the response. - /// - /// # Errors - /// Returns an error if the client is missing or communication fails. - pub async fn call_correlated(&mut self) -> TestResult { - let client = self.client.as_mut().ok_or("client not connected")?; - let envelope = self.envelope.take().ok_or("envelope not configured")?; - - match client.call_correlated(envelope).await { - Ok(response) => { - self.response = Some(response); - self.last_error = None; - } - Err(err) => { - self.last_error = Some(err); - self.response = None; - } - } - Ok(()) - } - - /// Send multiple sequential envelopes and capture all correlation IDs. - /// - /// # Errors - /// Returns an error if the client is missing or communication fails. - #[expect( - clippy::cast_possible_truncation, - reason = "test helper with small count values" - )] - pub async fn send_multiple_envelopes(&mut self, count: usize) -> TestResult { - let client = self.client.as_mut().ok_or("client not connected")?; - self.sent_correlation_ids.clear(); - - for i in 0..count { - let envelope = Envelope::new(i as u32, None, vec![i as u8]); - let correlation_id = client.send_envelope(envelope).await?; - self.sent_correlation_ids.push(correlation_id); - - // Drain the echo response. - let _: Envelope = client.receive_envelope().await?; - } - Ok(()) - } - - /// Get the first sent correlation ID. - fn get_first_correlation_id(&self) -> TestResult { - self.sent_correlation_ids - .first() - .copied() - .ok_or_else(|| "no correlation ID captured".into()) - } - - /// Verify that an auto-generated correlation ID was assigned. - /// - /// # Errors - /// Returns an error if no correlation ID was captured or it is zero. - pub fn verify_auto_generated_correlation(&self) -> TestResult { - let id = self.get_first_correlation_id()?; - if id == 0 { - return Err("correlation ID should be non-zero".into()); - } - Ok(()) - } - - /// Verify that the returned correlation ID matches the expected value. - /// - /// # Errors - /// Returns an error if no correlation ID was captured or it doesn't match. - pub fn verify_correlation_id(&self, expected: u64) -> TestResult { - let id = self.get_first_correlation_id()?; - if id != expected { - return Err(format!("expected correlation ID {expected}, got {id}").into()); - } - Ok(()) - } - - /// Verify that the response has a matching correlation ID. - /// - /// # Errors - /// Returns an error if no response was captured or it lacks a correlation ID. - pub fn verify_response_correlation_matches(&self) -> TestResult { - let response = self.response.as_ref().ok_or("no response captured")?; - if response.correlation_id().is_none() { - return Err("response should have correlation ID".into()); - } - Ok(()) - } - - /// Verify that no `CorrelationMismatch` error occurred. - /// - /// # Errors - /// Returns an error if any error was recorded. - pub fn verify_no_mismatch_error(&self) -> TestResult { - if self.last_error.is_some() { - return Err("unexpected error occurred".into()); - } - Ok(()) - } - - /// Verify that a `CorrelationMismatch` error occurred. - /// - /// # Errors - /// Returns an error if no mismatch error was recorded or a different error occurred. - pub fn verify_mismatch_error(&self) -> TestResult { - match &self.last_error { - Some(ClientError::CorrelationMismatch { .. }) => Ok(()), - Some(err) => Err(format!("expected CorrelationMismatch, got {err:?}").into()), - None => Err("expected CorrelationMismatch error, but none occurred".into()), - } - } - - /// Verify that all sent correlation IDs are unique. - /// - /// # Errors - /// Returns an error if any correlation IDs are duplicated. - pub fn verify_unique_correlation_ids(&self) -> TestResult { - let mut sorted = self.sent_correlation_ids.clone(); - sorted.sort_unstable(); - sorted.dedup(); - if sorted.len() != self.sent_correlation_ids.len() { - return Err("correlation IDs are not unique".into()); - } - Ok(()) - } - - /// Verify that the response matches the expected message ID and payload. - /// - /// Uses the expected values stored when the envelope was configured via - /// `set_envelope_with_payload`. - /// - /// # Errors - /// Returns an error if the response is missing, expected values weren't set, - /// or the response doesn't match. - pub fn verify_response_matches_expected(&self) -> TestResult { - let response = self.response.as_ref().ok_or("no response captured")?; - let expected_id = self - .expected_message_id - .ok_or("expected message ID not set")?; - let expected_payload = self - .expected_payload - .as_ref() - .ok_or("expected payload not set")?; - - if response.id() != expected_id { - return Err(format!("expected message ID {expected_id}, got {}", response.id()).into()); - } - if response.payload_bytes() != expected_payload.as_bytes() { - return Err(format!( - "expected payload {:?}, got {:?}", - expected_payload.as_bytes(), - response.payload_bytes() - ) - .into()); - } - Ok(()) - } - - /// Abort the server task. - pub fn abort_server(&mut self) { - if let Some(handle) = self.server.take() { - handle.abort(); - } - } -} - -/// Run the frame processing loop for the echo server. -async fn run_frame_loop(framed: &mut Framed, mode: ServerMode) -where - T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - while let Some(result) = framed.next().await { - let Ok(bytes) = result else { - warn!("client messaging server failed to decode frame"); - break; - }; - - let Some(response_bytes) = process_frame(mode, &bytes) else { - warn!("client messaging server failed to process frame"); - break; - }; - - if framed.send(Bytes::from(response_bytes)).await.is_err() { - break; - } - } -} diff --git a/tests/worlds/client_preamble.rs b/tests/worlds/client_preamble.rs deleted file mode 100644 index b69bcf88..00000000 --- a/tests/worlds/client_preamble.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! Test world for client preamble scenarios. -#![cfg(not(loom))] -#![expect( - clippy::expect_used, - reason = "test code uses expect for concise assertions" -)] -#![expect( - clippy::excessive_nesting, - reason = "async closures within builder patterns are inherently nested" -)] - -use std::{net::SocketAddr, sync::Arc, time::Duration}; - -use futures::FutureExt; -use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; -use wireframe::{ - BincodeSerializer, - client::{ClientError, WireframeClient}, - preamble::{read_preamble, write_preamble}, - rewind_stream::RewindStream, -}; - -use super::TestResult; - -/// Preamble used for testing. -#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] -pub struct TestPreamble { - magic: [u8; 4], - version: u16, -} - -impl TestPreamble { - const MAGIC: [u8; 4] = *b"TEST"; - - /// Create a new test preamble with the given version. - #[must_use] - pub fn new(version: u16) -> Self { - Self { - magic: Self::MAGIC, - version, - } - } - - /// Get the version. - #[must_use] - pub fn version(&self) -> u16 { self.version } -} - -/// Server acknowledgement preamble. -#[derive(Debug, Clone, PartialEq, Eq, Default, bincode::Encode, bincode::BorrowDecode)] -pub struct ServerAck { - accepted: bool, -} - -impl ServerAck { - /// Check if the connection was accepted. - #[must_use] - pub fn accepted(&self) -> bool { self.accepted } -} - -type SenderHolder = Arc>>>; - -fn create_signal_channel() -> (SenderHolder, oneshot::Receiver) { - let (tx, rx) = oneshot::channel(); - (Arc::new(std::sync::Mutex::new(Some(tx))), rx) -} - -fn send_signal(holder: &std::sync::Mutex>>, value: T) { - if let Some(tx) = holder.lock().ok().and_then(|mut guard| guard.take()) { - let _ = tx.send(value); - } -} - -/// Test world exercising client preamble exchange. -#[derive(Debug, Default, cucumber::World)] -pub struct ClientPreambleWorld { - addr: Option, - server: Option>, - client: Option>>, - server_preamble_rx: Option>, - server_received_preamble: Option, - client_received_ack: Option, - success_callback_invoked: bool, - failure_callback_invoked: bool, - last_error: Option, -} - -impl ClientPreambleWorld { - /// Start a preamble-aware echo server. - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if accept or read fails. - pub async fn start_preamble_server(&mut self) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let (tx, rx) = oneshot::channel::(); - let handle = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - let (preamble, _) = read_preamble::<_, TestPreamble>(&mut stream) - .await - .expect("read preamble"); - let _ = tx.send(preamble); - // Hold connection briefly - tokio::time::sleep(Duration::from_millis(100)).await; - }); - - self.addr = Some(addr); - self.server = Some(handle); - self.server_preamble_rx = Some(rx); - Ok(()) - } - - /// Start a preamble-aware server that sends acknowledgement. - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if accept, read, or write fails. - pub async fn start_ack_server(&mut self) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - let (_preamble, _) = read_preamble::<_, TestPreamble>(&mut stream) - .await - .expect("read preamble"); - write_preamble(&mut stream, &ServerAck { accepted: true }) - .await - .expect("write ack"); - tokio::time::sleep(Duration::from_millis(100)).await; - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Start a server that never responds (for timeout testing). - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if accept fails. - pub async fn start_slow_server(&mut self) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let (_stream, _) = listener.accept().await.expect("accept"); - // Hold connection but don't respond - tokio::time::sleep(Duration::from_secs(10)).await; - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Start a standard echo server without preamble support. - /// - /// # Errors - /// Returns an error if binding fails. - /// - /// # Panics - /// The spawned task panics if accept fails. - pub async fn start_standard_server(&mut self) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let (_stream, _) = listener.accept().await.expect("accept"); - tokio::time::sleep(Duration::from_millis(100)).await; - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Connect with preamble and success callback. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_with_preamble(&mut self, version: u16) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let (holder, rx) = create_signal_channel::<()>(); - - let result = WireframeClient::builder() - .with_preamble(TestPreamble::new(version)) - .on_preamble_success(move |_preamble, _stream| { - let holder = holder.clone(); - async move { - send_signal(&holder, ()); - Ok(Vec::new()) - } - .boxed() - }) - .connect(addr) - .await; - - match result { - Ok(client) => { - self.client = Some(client); - if tokio::time::timeout(Duration::from_secs(1), rx) - .await - .is_ok() - { - self.success_callback_invoked = true; - } - } - Err(e) => { - self.last_error = Some(e); - } - } - - // Now collect the preamble the server received - if let Some(preamble_rx) = self.server_preamble_rx.take() - && let Ok(Ok(preamble)) = - tokio::time::timeout(Duration::from_secs(1), preamble_rx).await - { - self.server_received_preamble = Some(preamble); - } - - Ok(()) - } - - /// Connect with preamble and read acknowledgement. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_with_ack(&mut self) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let (holder, rx) = create_signal_channel::(); - - let result = WireframeClient::builder() - .with_preamble(TestPreamble::new(1)) - .on_preamble_success(move |_preamble, stream| { - let holder = holder.clone(); - async move { - let (ack, leftover) = - read_preamble::<_, ServerAck>(stream).await.map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) - })?; - send_signal(&holder, ack); - Ok(leftover) - } - .boxed() - }) - .connect(addr) - .await; - - match result { - Ok(client) => { - self.client = Some(client); - if let Ok(Ok(ack)) = tokio::time::timeout(Duration::from_secs(1), rx).await { - self.client_received_ack = Some(ack); - self.success_callback_invoked = true; - } - } - Err(e) => { - self.last_error = Some(e); - } - } - Ok(()) - } - - /// Connect with a preamble timeout. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_with_timeout(&mut self, timeout_ms: u64) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let (failure_holder, failure_rx) = create_signal_channel::<()>(); - - let result = WireframeClient::builder() - .with_preamble(TestPreamble::new(1)) - .preamble_timeout(Duration::from_millis(timeout_ms)) - .on_preamble_success(|_preamble, stream| { - async move { - // Try to read server response - this should timeout. - use tokio::io::AsyncReadExt; - let mut buf = [0u8; 1]; - stream.read_exact(&mut buf).await?; - Ok(Vec::new()) - } - .boxed() - }) - .on_preamble_failure(move |_err, _stream| { - let holder = failure_holder.clone(); - async move { - send_signal(&holder, ()); - Ok(()) - } - .boxed() - }) - .connect(addr) - .await; - - match result { - Ok(client) => { - self.client = Some(client); - } - Err(e) => { - self.last_error = Some(e); - if tokio::time::timeout(Duration::from_secs(1), failure_rx) - .await - .is_ok() - { - self.failure_callback_invoked = true; - } - } - } - Ok(()) - } - - /// Connect without a preamble. - /// - /// # Errors - /// Returns an error if server address is missing. - pub async fn connect_without_preamble(&mut self) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let result = WireframeClient::builder().connect(addr).await; - - match result { - Ok(client) => { - self.client = Some(client); - } - Err(e) => { - self.last_error = Some(e); - } - } - Ok(()) - } - - /// Check if the server received the expected preamble version. - #[must_use] - pub fn server_received_version(&self) -> Option { - self.server_received_preamble - .as_ref() - .map(TestPreamble::version) - } - - /// Check if success callback was invoked. - #[must_use] - pub fn success_invoked(&self) -> bool { self.success_callback_invoked } - - /// Check if failure callback was invoked. - #[must_use] - pub fn failure_invoked(&self) -> bool { self.failure_callback_invoked } - - /// Check if client received accepted ack. - #[must_use] - pub fn ack_accepted(&self) -> bool { - self.client_received_ack - .as_ref() - .is_some_and(ServerAck::accepted) - } - - /// Check if last error was a timeout. - #[must_use] - pub fn was_timeout_error(&self) -> bool { - matches!(self.last_error, Some(ClientError::PreambleTimeout)) - } - - /// Check if client is connected. - #[must_use] - pub fn is_connected(&self) -> bool { self.client.is_some() } - - /// Abort the server task. - pub fn abort_server(&mut self) { - if let Some(handle) = self.server.take() { - handle.abort(); - } - } -} diff --git a/tests/worlds/client_runtime.rs b/tests/worlds/client_runtime.rs deleted file mode 100644 index 1fb81258..00000000 --- a/tests/worlds/client_runtime.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Test world for client runtime scenarios. -#![cfg(not(loom))] - -use std::net::SocketAddr; - -use cucumber::World; -use futures::{SinkExt, StreamExt}; -use log::warn; -use tokio::{net::TcpListener, task::JoinHandle}; -use tokio_util::codec::{Framed, LengthDelimitedCodec}; -use wireframe::{ - BincodeSerializer, - client::{ClientCodecConfig, ClientError, WireframeClient}, - rewind_stream::RewindStream, -}; - -use super::TestResult; - -/// Test world exercising the wireframe client runtime. -#[derive(Debug, Default, World)] -pub struct ClientRuntimeWorld { - addr: Option, - server: Option>, - client: Option>>, - payload: Option, - response: Option, - last_error: Option, -} - -#[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] -struct ClientPayload { - data: Vec, -} - -impl ClientRuntimeWorld { - /// Start an echo server with the specified maximum frame length. - /// - /// # Errors - /// Returns an error if binding or spawning the server fails. - pub async fn start_server(&mut self, max_frame_length: usize) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { - warn!("client runtime server failed to accept connection"); - return; - }; - let codec = LengthDelimitedCodec::builder() - .max_frame_length(max_frame_length) - .new_codec(); - let mut framed = Framed::new(stream, codec); - let Some(result) = framed.next().await else { - warn!("client runtime server closed before receiving a frame"); - return; - }; - let Ok(frame) = result else { - warn!("client runtime server failed to decode frame"); - return; - }; - if let Err(err) = framed.send(frame.freeze()).await { - warn!("client runtime server failed to send response: {err:?}"); - } - }); - - self.addr = Some(addr); - self.server = Some(handle); - Ok(()) - } - - /// Connect a client using the specified maximum frame length. - /// - /// # Errors - /// Returns an error if the server has not started or the client fails to connect. - pub async fn connect_client(&mut self, max_frame_length: usize) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; - let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); - let client = WireframeClient::builder() - .codec_config(codec_config) - .connect(addr) - .await?; - self.client = Some(client); - Ok(()) - } - - /// Send a payload of the specified size and capture the response. - /// - /// # Errors - /// Returns an error if the client is missing or communication fails. - pub async fn send_payload(&mut self, size: usize) -> TestResult { - let payload = ClientPayload { - data: vec![7_u8; size], - }; - let client = self.client.as_mut().ok_or("client not connected")?; - let response: ClientPayload = client.call(&payload).await?; - self.payload = Some(payload); - self.response = Some(response); - self.last_error = None; - Ok(()) - } - - /// Send a payload that should exceed the peer's frame limit. - /// - /// # Errors - /// Returns an error if the client is missing or if no failure is observed. - pub async fn send_payload_expect_error(&mut self, size: usize) -> TestResult { - let payload = ClientPayload { - data: vec![7_u8; size], - }; - let client = self.client.as_mut().ok_or("client not connected")?; - let result: Result = client.call(&payload).await; - match result { - Ok(_) => return Err("expected client error for oversized payload".into()), - Err(err) => self.last_error = Some(err), - } - Ok(()) - } - - /// Verify that the client received the echoed payload. - /// - /// # Errors - /// Returns an error if the response is missing or mismatched. - pub async fn verify_echo(&mut self) -> TestResult { - let payload = self.payload.as_ref().ok_or("payload missing")?; - let response = self.response.as_ref().ok_or("response missing")?; - if payload != response { - return Err("response did not match payload".into()); - } - self.await_server().await?; - Ok(()) - } - - /// Verify that a client error was captured. - /// - /// # Errors - /// Returns an error if no failure was observed. - pub async fn verify_error(&mut self) -> TestResult { - let err = self - .last_error - .as_ref() - .ok_or("expected client error was not captured")?; - if !matches!(err, ClientError::Disconnected | ClientError::Io(_)) { - return Err("unexpected client error variant".into()); - } - self.await_server().await?; - Ok(()) - } - - async fn await_server(&mut self) -> TestResult { - if let Some(handle) = self.server.take() { - handle - .await - .map_err(|err| format!("server task failed: {err}"))?; - } - Ok(()) - } -} diff --git a/tests/worlds/codec_error/decoder_ops.rs b/tests/worlds/codec_error/decoder_ops.rs deleted file mode 100644 index ea42da8f..00000000 --- a/tests/worlds/codec_error/decoder_ops.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! End-to-end decoder operations for codec error taxonomy tests. -//! -//! Provides real decoder operations to validate EOF error handling and frame -//! encoding/decoding behaviour in realistic scenarios. - -use bytes::BytesMut; -use tokio_util::codec::Decoder; -use wireframe::{ - FrameCodec, - codec::{EofError, LENGTH_HEADER_SIZE, LengthDelimitedFrameCodec}, -}; - -use super::{CodecErrorWorld, TestResult}; - -impl CodecErrorWorld { - /// Reset codec state to prepare for a new test operation. - fn reset_codec_state(&mut self) { - self.buffer = BytesMut::new(); - self.decoder_error = None; - self.clean_close_detected = false; - } - - /// Configure the codec with default settings. - pub fn setup_default_codec(&mut self) { - self.max_frame_length = 1024; - self.reset_codec_state(); - } - - /// Configure the codec with a specific max frame length. - pub fn setup_codec_with_max_length(&mut self, max_len: usize) { - self.max_frame_length = max_len; - self.reset_codec_state(); - } - - /// Simulate a client sending a complete frame by encoding data into the buffer. - /// - /// # Errors - /// - /// Returns an error if encoding fails. - pub fn send_complete_frame(&mut self, payload: &[u8]) -> TestResult { - use tokio_util::codec::Encoder; - - let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); - let mut encoder = codec.encoder(); - encoder.encode(bytes::Bytes::copy_from_slice(payload), &mut self.buffer)?; - Ok(()) - } - - /// Simulate a client sending partial frame data (header only, no payload). - pub fn send_partial_frame_header_only(&mut self) { - // Write a length prefix indicating 100 bytes, but don't write any payload - // 4-byte big-endian length prefix - self.buffer.extend_from_slice(&[0x00, 0x00, 0x00, 0x64]); // 100 bytes expected - } - - /// Call `decode_eof` to simulate a clean close at frame boundary. - /// - /// Returns `true` if `Ok(None)` was returned, indicating clean close. - /// - /// # Errors - /// - /// Returns an error if clean close was not detected. - pub fn decode_eof_clean_close(&mut self) -> TestResult { - let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); - let mut decoder = codec.decoder(); - - // First decode any complete frames - while let Some(_frame) = decoder.decode(&mut self.buffer)? { - // Consume complete frames - } - - // Now call decode_eof to handle EOF - match decoder.decode_eof(&mut self.buffer) { - Ok(None) => { - self.clean_close_detected = true; - self.detected_eof = Some(EofError::CleanClose); - Ok(()) - } - Ok(Some(_)) => Err("unexpected frame after EOF".into()), - Err(e) => { - self.decoder_error = Some(e); - Err("expected clean close, got error".into()) - } - } - } - - /// Extract the expected payload length from the buffer's length header. - /// - /// Returns 0 if the buffer doesn't contain a complete length header. - #[expect( - clippy::big_endian_bytes, - reason = "Wire protocol uses big-endian length prefix; this matches the codec." - )] - fn extract_expected_length(&self) -> usize { - self.buffer - .get(..LENGTH_HEADER_SIZE) - .and_then(|slice| <[u8; LENGTH_HEADER_SIZE]>::try_from(slice).ok()) - .map_or(0, |bytes| u32::from_be_bytes(bytes) as usize) - } - - /// Classify the EOF error type from the error message. - /// - /// # Implementation Note - /// - /// This method infers EOF type by checking if the error message contains - /// "header". This is fragile: if the upstream error message format changes, - /// this classification will silently produce incorrect results. - /// - /// When the buffer contains at least 4 bytes (a complete length header), - /// we extract the expected payload length from the big-endian u32 prefix. - /// - /// This approach is acceptable for test code where we control the error - /// messages, but would need a more robust solution (e.g., downcasting to - /// `CodecError`) if the underlying error type becomes available. - // FIXME(#418): Replace string-matching with downcasting when CodecError - // becomes available in the io::Error source chain. - fn classify_eof_error(&mut self, e: &std::io::Error) { - if e.kind() != std::io::ErrorKind::UnexpectedEof { - return; - } - let msg = e.to_string(); - self.detected_eof = Some(if msg.contains("header") { - EofError::MidHeader { - bytes_received: self.buffer.len(), - header_size: LENGTH_HEADER_SIZE, - } - } else { - EofError::MidFrame { - bytes_received: self.buffer.len().saturating_sub(LENGTH_HEADER_SIZE), - expected: self.extract_expected_length(), - } - }); - } - - /// Call `decode_eof` when buffer has incomplete data. - /// - /// # Errors - /// - /// Returns an error if no EOF error was produced. - pub fn decode_eof_with_partial_data(&mut self) -> TestResult { - let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); - let mut decoder = codec.decoder(); - - match decoder.decode_eof(&mut self.buffer) { - Ok(None) => Err("expected EOF error, got Ok(None)".into()), - Ok(Some(_)) => Err("expected EOF error, got frame".into()), - Err(e) => { - self.classify_eof_error(&e); - self.decoder_error = Some(e); - Ok(()) - } - } - } - - /// Attempt to encode an oversized frame. - /// - /// # Errors - /// - /// Returns an error if no oversized error was produced. - pub fn encode_oversized_frame(&mut self, size: usize) -> TestResult { - use tokio_util::codec::Encoder; - - let codec = LengthDelimitedFrameCodec::new(self.max_frame_length); - let mut encoder = codec.encoder(); - let payload = bytes::Bytes::from(vec![0_u8; size]); - - match encoder.encode(payload, &mut self.buffer) { - Ok(()) => Err("expected oversized error, got Ok".into()), - Err(e) => { - self.decoder_error = Some(e); - Ok(()) - } - } - } - - /// Verify that a clean EOF was detected. - /// - /// # Errors - /// - /// Returns an error if no EOF was detected or if a non-clean EOF was detected. - pub fn verify_clean_eof(&self) -> TestResult { - if self.clean_close_detected { - return Ok(()); - } - match &self.detected_eof { - Some(EofError::CleanClose) => Ok(()), - Some(other) => Err(format!("expected clean close, got {other:?}").into()), - None => Err("no EOF was detected".into()), - } - } - - /// Verify that an incomplete EOF was detected (either mid-frame or mid-header). - /// - /// # Errors - /// - /// Returns an error if no EOF was detected or if it was a clean close. - pub fn verify_incomplete_eof(&self) -> TestResult { - match &self.detected_eof { - Some(EofError::MidFrame { .. } | EofError::MidHeader { .. }) => Ok(()), - Some(other) => Err(format!("expected incomplete EOF, got {other:?}").into()), - None => Err("no EOF was detected".into()), - } - } - - /// Verify that an oversized frame error was detected. - /// - /// # Errors - /// - /// Returns an error if no error was captured or if it wasn't an oversized error. - pub fn verify_oversized_error(&self) -> TestResult { - let err = self - .decoder_error - .as_ref() - .ok_or("no decoder error captured")?; - if err.kind() == std::io::ErrorKind::InvalidData { - // OversizedFrame is converted to InvalidData - Ok(()) - } else { - Err(format!("expected InvalidData error, got {:?}", err.kind()).into()) - } - } -} diff --git a/tests/worlds/codec_error/mod.rs b/tests/worlds/codec_error/mod.rs deleted file mode 100644 index 836dd06a..00000000 --- a/tests/worlds/codec_error/mod.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Test world for codec error taxonomy scenarios. -//! -//! Verifies that codec errors are correctly classified and that recovery -//! policies are applied as documented. Uses real decoder operations for -//! end-to-end validation. -#![cfg(not(loom))] - -mod decoder_ops; - -use bytes::BytesMut; -use cucumber::World; -use wireframe::codec::{CodecError, EofError, FramingError, ProtocolError, RecoveryPolicy}; - -use super::TestResult; - -/// Codec error type for test scenarios. -#[derive(Clone, Copy, Debug, Default)] -pub enum ErrorType { - #[default] - Framing, - Protocol, - Io, - Eof, -} - -/// Specific error variant for framing errors. -#[derive(Clone, Copy, Debug, Default)] -pub enum FramingVariant { - #[default] - Oversized, - InvalidEncoding, - IncompleteHeader, - ChecksumMismatch, - Empty, -} - -/// Specific error variant for EOF errors. -#[derive(Clone, Copy, Debug, Default)] -pub enum EofVariant { - #[default] - CleanClose, - MidFrame, - MidHeader, -} - -/// Test world for codec error taxonomy scenarios. -#[derive(Debug, Default, World)] -pub struct CodecErrorWorld { - /// Current error type category being tested. - error_type: ErrorType, - /// Specific framing error variant when `error_type` is `Framing`. - framing_variant: FramingVariant, - /// Specific EOF error variant when `error_type` is `Eof`. - eof_variant: EofVariant, - /// Constructed error based on `error_type` and variant settings. - current_error: Option, - /// EOF error detected during decoder operations. - pub(crate) detected_eof: Option, - /// Maximum frame length for the codec under test. - pub(crate) max_frame_length: usize, - /// Buffer simulating received data from a client. - pub(crate) buffer: BytesMut, - /// Decoder error captured during test. - pub(crate) decoder_error: Option, - /// Whether `decode_eof` returned `Ok(None)` for clean close. - pub(crate) clean_close_detected: bool, -} - -impl CodecErrorWorld { - /// Set the current error type being tested. - /// - /// # Errors - /// - /// Returns an error if `error_type` is not one of: `framing`, `protocol`, - /// `io`, or `eof`. - pub fn set_error_type(&mut self, error_type: &str) -> TestResult { - self.error_type = match error_type { - "framing" => ErrorType::Framing, - "protocol" => ErrorType::Protocol, - "io" => ErrorType::Io, - "eof" => ErrorType::Eof, - _ => return Err(format!("unknown error type: {error_type}").into()), - }; - self.build_error(); - Ok(()) - } - - /// Set the framing error variant. - /// - /// # Errors - /// - /// Returns an error if `variant` is not a recognized framing variant. - pub fn set_framing_variant(&mut self, variant: &str) -> TestResult { - self.framing_variant = match variant { - "oversized" => FramingVariant::Oversized, - "invalid_encoding" => FramingVariant::InvalidEncoding, - "incomplete_header" => FramingVariant::IncompleteHeader, - "checksum_mismatch" => FramingVariant::ChecksumMismatch, - "empty" => FramingVariant::Empty, - _ => return Err(format!("unknown framing variant: {variant}").into()), - }; - self.build_error(); - Ok(()) - } - - /// Set the EOF error variant. - /// - /// # Errors - /// - /// Returns an error if `variant` is not a recognized EOF variant. - pub fn set_eof_variant(&mut self, variant: &str) -> TestResult { - self.eof_variant = match variant { - "clean_close" => EofVariant::CleanClose, - "mid_frame" => EofVariant::MidFrame, - "mid_header" => EofVariant::MidHeader, - _ => return Err(format!("unknown eof variant: {variant}").into()), - }; - self.build_error(); - Ok(()) - } - - /// Build the current error based on type and variant settings. - fn build_error(&mut self) { - self.current_error = Some(match self.error_type { - ErrorType::Framing => CodecError::Framing(self.build_framing_error()), - ErrorType::Protocol => { - CodecError::Protocol(ProtocolError::UnknownMessageType { type_id: 99 }) - } - ErrorType::Io => CodecError::Io(std::io::Error::other("test error")), - ErrorType::Eof => CodecError::Eof(self.build_eof_error()), - }); - } - - /// Build a framing error based on the current variant. - fn build_framing_error(&self) -> FramingError { - match self.framing_variant { - FramingVariant::Oversized => FramingError::OversizedFrame { - size: 2000, - max: 1024, - }, - FramingVariant::InvalidEncoding => FramingError::InvalidLengthEncoding, - FramingVariant::IncompleteHeader => FramingError::IncompleteHeader { have: 2, need: 4 }, - FramingVariant::ChecksumMismatch => FramingError::ChecksumMismatch { - expected: 0xdead, - actual: 0xbeef, - }, - FramingVariant::Empty => FramingError::EmptyFrame, - } - } - - /// Build an EOF error based on the current variant. - fn build_eof_error(&self) -> EofError { - match self.eof_variant { - EofVariant::CleanClose => EofError::CleanClose, - EofVariant::MidFrame => EofError::MidFrame { - bytes_received: 100, - expected: 200, - }, - EofVariant::MidHeader => EofError::MidHeader { - bytes_received: 2, - header_size: 4, - }, - } - } - - /// Verify the recovery policy for the current error. - /// - /// # Errors - /// - /// Returns an error if `expected` is not a recognized policy or if the - /// actual policy does not match the expected policy. - pub fn verify_recovery_policy(&self, expected: &str) -> TestResult { - let expected_policy = match expected { - "drop" => RecoveryPolicy::Drop, - "quarantine" => RecoveryPolicy::Quarantine, - "disconnect" => RecoveryPolicy::Disconnect, - _ => return Err(format!("unknown recovery policy: {expected}").into()), - }; - - let error = self.current_error.as_ref().ok_or("no error has been set")?; - let actual_policy = error.default_recovery_policy(); - - if actual_policy != expected_policy { - return Err( - format!("expected policy {expected_policy:?}, got {actual_policy:?}").into(), - ); - } - - Ok(()) - } -} diff --git a/tests/worlds/codec_stateful.rs b/tests/worlds/codec_stateful.rs deleted file mode 100644 index b8c5eac4..00000000 --- a/tests/worlds/codec_stateful.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Test world for stateful codec sequence counters. -//! -//! Ensures per-connection codec state is isolated so sequence numbers reset -//! between client connections. -#![cfg(not(loom))] - -use std::{ - net::SocketAddr, - sync::atomic::{AtomicU64, Ordering}, -}; - -use bytes::{Buf, BufMut, Bytes, BytesMut}; -use cucumber::World; -use futures::{SinkExt, StreamExt}; -use tokio::{ - io::AsyncWriteExt, - net::{TcpListener, TcpStream}, - task::JoinHandle, -}; -use tokio_util::codec::{Decoder, Encoder, Framed, LengthDelimitedCodec}; -use wireframe::{ - Serializer, - app::{Envelope, WireframeApp}, - codec::FrameCodec, - serializer::BincodeSerializer, -}; - -use super::TestResult; - -#[derive(Debug)] -struct SeqFrame { - sequence: u64, - payload: Vec, -} - -#[derive(Debug)] -struct SeqFrameCodec { - max_frame_length: usize, - counter: AtomicU64, -} - -impl SeqFrameCodec { - fn new(max_frame_length: usize) -> Self { - Self { - max_frame_length, - counter: AtomicU64::new(0), - } - } - - /// Return a 1-based sequence value by atomically incrementing the counter. - /// - /// The first call yields 1 to match the behavioural test expectations. - fn next_sequence(&self) -> u64 { self.counter.fetch_add(1, Ordering::SeqCst) + 1 } -} - -impl Clone for SeqFrameCodec { - fn clone(&self) -> Self { - Self { - max_frame_length: self.max_frame_length, - counter: AtomicU64::new(0), - } - } -} - -impl Default for SeqFrameCodec { - fn default() -> Self { Self::new(1024) } -} - -#[derive(Clone, Debug)] -struct SeqAdapter { - inner: LengthDelimitedCodec, - max_frame_length: usize, -} - -impl SeqAdapter { - fn new(max_frame_length: usize) -> Self { - Self { - inner: LengthDelimitedCodec::builder() - .max_frame_length(max_frame_length) - .new_codec(), - max_frame_length, - } - } - - fn process_frame(frame: Option) -> Result, std::io::Error> { - let Some(mut bytes) = frame else { - return Ok(None); - }; - if bytes.len() < 8 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "frame too short", - )); - } - let sequence = bytes.get_u64(); - let payload = bytes.to_vec(); - Ok(Some(SeqFrame { sequence, payload })) - } -} - -impl Decoder for SeqAdapter { - type Item = SeqFrame; - type Error = std::io::Error; - - fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { - Self::process_frame(self.inner.decode(src)?) - } - - fn decode_eof(&mut self, src: &mut BytesMut) -> Result, Self::Error> { - Self::process_frame(self.inner.decode_eof(src)?) - } -} - -impl Encoder for SeqAdapter { - type Error = std::io::Error; - - fn encode(&mut self, item: SeqFrame, dst: &mut BytesMut) -> Result<(), Self::Error> { - let frame_len = item.payload.len().saturating_add(8); - if frame_len > self.max_frame_length { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "frame too large", - )); - } - let mut buf = BytesMut::with_capacity(frame_len); - buf.put_u64(item.sequence); - buf.extend_from_slice(&item.payload); - self.inner.encode(buf.freeze(), dst) - } -} - -impl FrameCodec for SeqFrameCodec { - type Frame = SeqFrame; - type Decoder = SeqAdapter; - type Encoder = SeqAdapter; - - fn decoder(&self) -> Self::Decoder { SeqAdapter::new(self.max_frame_length) } - - fn encoder(&self) -> Self::Encoder { SeqAdapter::new(self.max_frame_length) } - - fn frame_payload(frame: &Self::Frame) -> &[u8] { frame.payload.as_slice() } - - fn wrap_payload(&self, payload: Bytes) -> Self::Frame { - SeqFrame { - sequence: self.next_sequence(), - payload: payload.to_vec(), - } - } - - fn max_frame_length(&self) -> usize { self.max_frame_length } -} - -#[derive(Debug)] -struct StatefulServer { - addr: SocketAddr, - handle: JoinHandle<()>, -} - -async fn serve_stateful_connections( - listener: TcpListener, - app: WireframeApp, -) { - for _ in 0..2 { - let Ok((stream, _)) = listener.accept().await else { - return; - }; - let _ = app.handle_connection_result(stream).await; - } -} - -#[derive(Debug, Default, World)] -/// Test world for stateful codec scenarios. -pub struct CodecStatefulWorld { - server: Option, - max_frame_length: usize, - first_sequences: Vec, - second_sequences: Vec, -} - -impl CodecStatefulWorld { - /// Start a server using the sequence-aware codec. - /// - /// # Errors - /// Returns an error if binding or spawning the server fails. - pub async fn start_server(&mut self, max_frame_length: usize) -> TestResult { - let app = WireframeApp::::new()? - .with_codec(SeqFrameCodec::new(max_frame_length)) - .route(1, std::sync::Arc::new(|_: &Envelope| Box::pin(async {})))?; - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - serve_stateful_connections(listener, app).await; - }); - - self.server = Some(StatefulServer { addr, handle }); - self.max_frame_length = max_frame_length; - Ok(()) - } - - /// Send requests on the first connection and store sequence numbers. - /// - /// # Errors - /// Returns an error if the client cannot connect or exchange frames. - pub async fn send_first_requests(&mut self, count: usize) -> TestResult { - self.first_sequences = self.send_requests(count).await?; - Ok(()) - } - - /// Send requests on the second connection and store sequence numbers. - /// - /// # Errors - /// Returns an error if the client cannot connect or exchange frames. - pub async fn send_second_requests(&mut self, count: usize) -> TestResult { - self.second_sequences = self.send_requests(count).await?; - Ok(()) - } - - /// Verify expected sequence numbers for the first connection. - /// - /// # Errors - /// Returns an error if the observed sequence numbers do not match. - pub async fn verify_first_sequences(&self, expected: &[u64]) -> TestResult { - Self::verify_sequences(&self.first_sequences, expected, "first")?; - tokio::task::yield_now().await; - Ok(()) - } - - /// Verify expected sequence numbers for the second connection. - /// - /// # Errors - /// Returns an error if the observed sequence numbers do not match. - pub async fn verify_second_sequences(&mut self, expected: &[u64]) -> TestResult { - Self::verify_sequences(&self.second_sequences, expected, "second")?; - self.await_server().await?; - Ok(()) - } - - fn verify_sequences(sequences: &[u64], expected: &[u64], connection_name: &str) -> TestResult { - if sequences != expected { - return Err(format!( - "unexpected {connection_name} connection sequences: {sequences:?}" - ) - .into()); - } - Ok(()) - } - - async fn send_requests(&self, count: usize) -> TestResult> { - let addr = self.server.as_ref().ok_or("server not started")?.addr; - let stream = TcpStream::connect(addr).await?; - let mut framed = Framed::new(stream, SeqAdapter::new(self.max_frame_length)); - let mut sequences = Vec::with_capacity(count); - - for _ in 0..count { - let request = Envelope::new(1, None, b"ping".to_vec()); - let payload = BincodeSerializer.serialize(&request)?; - framed - .send(SeqFrame { - sequence: 0, - payload, - }) - .await?; - let frame = framed.next().await.ok_or("missing response frame")??; - sequences.push(frame.sequence); - } - - let mut stream = framed.into_inner(); - stream.shutdown().await?; - Ok(sequences) - } - - async fn await_server(&mut self) -> TestResult { - if let Some(server) = self.server.take() { - server - .handle - .await - .map_err(|err| format!("server task failed: {err}"))?; - } - Ok(()) - } -} diff --git a/tests/worlds/correlation.rs b/tests/worlds/correlation.rs deleted file mode 100644 index 8041826e..00000000 --- a/tests/worlds/correlation.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Test world for correlation identifier scenarios. -//! -//! Provides [`CorrelationWorld`] to verify that frames carry the correct -//! correlation identifiers across streaming and multi-packet contexts. -#![cfg(not(loom))] - -use async_stream::try_stream; -use cucumber::World; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use wireframe::{ - app::Envelope, - connection::ConnectionActor, - correlation::CorrelatableFrame, - response::FrameStream, -}; - -use super::{TestResult, build_small_queues}; - -#[derive(Debug, Default, World)] -/// Test world capturing correlation expectations for frame emission. -pub struct CorrelationWorld { - expected: Option, - frames: Vec, -} - -impl CorrelationWorld { - /// Record the correlation identifier expected on emitted frames. - /// - /// # Examples - /// ```ignore - /// let mut world = CorrelationWorld::default(); - /// world.set_expected(Some(99)); - /// ``` - pub fn set_expected(&mut self, expected: Option) { self.expected = expected; } - - /// Return the correlation identifier configured for this scenario. - /// - /// # Examples - /// ```ignore - /// let mut world = CorrelationWorld::default(); - /// world.set_expected(None); - /// assert_eq!(world.expected(), None); - /// ``` - #[must_use] - pub fn expected(&self) -> Option { self.expected } - - /// Run the connection actor and collect frames for later verification. - /// - /// # Errors - /// Returns an error if the expected correlation id is absent or if running - /// the actor fails. - pub async fn process(&mut self) -> TestResult { - let cid = self - .expected - .ok_or("streaming scenario requires a correlation id")?; - let stream: FrameStream = Box::pin(try_stream! { - yield Envelope::new(1, Some(cid), vec![1]); - yield Envelope::new(1, Some(cid), vec![2]); - }); - let (queues, handle) = build_small_queues::()?; - let shutdown = CancellationToken::new(); - let mut actor = ConnectionActor::new(queues, handle, Some(stream), shutdown); - actor - .run(&mut self.frames) - .await - .map_err(|e| format!("actor run failed: {e:?}"))?; - Ok(()) - } - - /// Run the connection actor for a multi-packet channel and collect frames. - /// - /// # Errors - /// Returns an error if sending frames or running the actor fails. - pub async fn process_multi(&mut self) -> TestResult { - let expected = self.expected; - let (tx, rx) = mpsc::channel(4); - tx.send(Envelope::new(1, None, vec![1])).await?; - tx.send(Envelope::new(1, Some(99), vec![2])).await?; - drop(tx); - - let (queues, handle) = build_small_queues::()?; - let shutdown = CancellationToken::new(); - let mut actor: ConnectionActor = - ConnectionActor::new(queues, handle, None, shutdown); - actor.set_multi_packet_with_correlation(Some(rx), expected); - actor - .run(&mut self.frames) - .await - .map_err(|e| format!("actor run failed: {e:?}"))?; - Ok(()) - } - - /// Verify that all received frames respect the configured correlation expectation. - /// - /// # Errors - /// Returns an error if any frame violates the stored correlation - /// expectation. - pub fn verify(&self) -> TestResult { - let ok = match self.expected { - Some(cid) => self.frames.iter().all(|f| f.correlation_id() == Some(cid)), - None => self.frames.iter().all(|f| f.correlation_id().is_none()), - }; - - if ok { - return Ok(()); - } - - match self.expected { - Some(cid) => Err(format!("frames missing expected correlation id {cid}").into()), - None => Err("frames unexpectedly carried correlation id".into()), - } - } -} diff --git a/tests/worlds/fragment/mod.rs b/tests/worlds/fragment/mod.rs deleted file mode 100644 index a0ed1a78..00000000 --- a/tests/worlds/fragment/mod.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Test world for fragmentation scenarios covering both directions. -//! -//! Provides [`FragmentWorld`] to verify ordering, completion detection, and -//! error handling across the fragmentation behavioural tests while also -//! exercising outbound fragmentation by chunking payloads via the helper -//! `Fragmenter` and inspecting the resulting `FragmentBatch` state. -#![cfg(not(loom))] - -mod reassembly; - -use std::{num::NonZeroUsize, time::Instant}; - -use cucumber::World; -use wireframe::fragment::{ - FragmentBatch, - FragmentError, - FragmentFrame, - FragmentHeader, - FragmentIndex, - FragmentSeries, - FragmentStatus, - Fragmenter, - MessageId, - ReassembledMessage, - Reassembler, - ReassemblyError, -}; - -use super::TestResult; - -#[derive(Debug, World)] -/// Test world tracking fragmentation state across behavioural scenarios. -pub struct FragmentWorld { - series: Option, - last_result: Option>, - fragmenter: Option, - last_batch: Option, - reassembler: Option, - last_reassembled: Option, - last_reassembly_error: Option, - now: Instant, - last_evicted: Vec, -} - -impl Default for FragmentWorld { - fn default() -> Self { - Self { - series: None, - last_result: None, - fragmenter: None, - last_batch: None, - reassembler: None, - last_reassembled: None, - last_reassembly_error: None, - now: Instant::now(), - last_evicted: Vec::new(), - } - } -} - -impl FragmentWorld { - /// Start tracking a new logical message. - pub fn start_series(&mut self, message_id: u64) { - self.series = Some(FragmentSeries::new(MessageId::new(message_id))); - self.last_result = None; - } - - /// Configure a fragmenter with the provided payload cap so outbound - /// fragmentation scenarios can chunk messages during behavioural tests. - /// - /// # Errors - /// Returns an error if the payload cap is zero. - pub fn configure_fragmenter(&mut self, max_payload: usize) -> TestResult { - let cap = NonZeroUsize::new(max_payload).ok_or("fragment cap must be non-zero")?; - self.fragmenter = Some(Fragmenter::new(cap)); - self.last_batch = None; - Ok(()) - } - - /// Request fragmentation for a payload of `len` bytes, simulating outbound - /// fragment production for the behavioural scenarios. - /// - /// # Errors - /// Returns an error if the fragmenter is missing or fragmentation fails. - pub fn fragment_payload(&mut self, len: usize) -> TestResult { - let fragmenter = self - .fragmenter - .as_ref() - .ok_or("fragmenter not configured")?; - let payload = vec![0_u8; len]; - let batch = fragmenter.fragment_bytes(payload)?; - self.last_batch = Some(batch); - Ok(()) - } - - /// Force the next expected fragment index for overflow scenarios. - /// - /// # Errors - /// Returns an error if a fragment series has not been initialised. - pub fn force_next_index(&mut self, index: u32) -> TestResult { - self.series_mut()? - .force_next_index_for_tests(FragmentIndex::new(index)); - Ok(()) - } - - /// Feed a fragment that references the currently tracked message. - /// - /// # Errors - /// Returns an error if no fragment series has been initialised. - pub fn accept_fragment(&mut self, index: u32, is_last: bool) -> TestResult { - let message = self.series()?.message_id().get(); - self.accept_fragment_from(message, index, is_last) - } - - /// Feed a fragment for an explicit message identifier. - /// - /// # Errors - /// Returns an error if no fragment series has been initialised. - pub fn accept_fragment_from(&mut self, message: u64, index: u32, is_last: bool) -> TestResult { - let header = - FragmentHeader::new(MessageId::new(message), FragmentIndex::new(index), is_last); - self.last_result = Some(self.series_mut()?.accept(header)); - Ok(()) - } - - /// Return the most recent fragment outcome. - fn last_result(&self) -> TestResult<&Result> { - self.last_result - .as_ref() - .ok_or_else(|| "no fragment processed yet".into()) - } - - fn batch(&self) -> TestResult<&FragmentBatch> { - self.last_batch - .as_ref() - .ok_or_else(|| "no payload fragmented yet".into()) - } - - /// Retrieve the fragment at `index`. - fn get_fragment_at(&self, index: usize) -> TestResult<&FragmentFrame> { - let fragment = self - .batch()? - .fragments() - .get(index) - .ok_or_else(|| format!("fragment {index} missing"))?; - Ok(fragment) - } - - fn assert_error(&self, predicate: F, expected_desc: &str) -> TestResult - where - F: FnOnce(&FragmentError) -> bool, - { - let err = match self.last_result()? { - Err(err) => err, - Ok(status) => return Err(format!("expected error but received {status:?}").into()), - }; - if !predicate(err) { - return Err(format!("expected {expected_desc}, got {err}").into()); - } - Ok(()) - } - - /// Assert that the latest fragment completed the logical message. - /// - /// # Errors - /// Returns an error if the fragment did not complete the message or no - /// fragment was processed. - pub fn assert_completion(&self) -> TestResult { - match self.last_result()? { - Ok(FragmentStatus::Complete) => {} - Ok(status) => return Err(format!("unexpected status: {status:?}").into()), - Err(err) => return Err(format!("expected completion but got error: {err}").into()), - } - if !self.series()?.is_complete() { - return Err("series should be marked complete".into()); - } - Ok(()) - } - - fn series(&self) -> TestResult<&FragmentSeries> { - self.series - .as_ref() - .ok_or_else(|| "fragment series not initialised".into()) - } - - fn series_mut(&mut self) -> TestResult<&mut FragmentSeries> { - self.series - .as_mut() - .ok_or_else(|| "fragment series not initialised".into()) - } - - /// Assert that the latest fragment failed due to an index mismatch. - /// - /// # Errors - /// Returns an error if the last fragment result does not indicate an index - /// mismatch or no fragment was processed. - pub fn assert_index_mismatch(&self) -> TestResult { - self.assert_error( - |err| matches!(err, FragmentError::IndexMismatch { .. }), - "index mismatch", - ) - } - - /// Assert that the latest fragment failed because the message identifier - /// did not match the tracked series. - /// - /// # Errors - /// Returns an error if the last fragment result is not a message mismatch - /// or no fragment was processed. - pub fn assert_message_mismatch(&self) -> TestResult { - self.assert_error( - |err| matches!(err, FragmentError::MessageMismatch { .. }), - "message mismatch", - ) - } - - /// Assert that the latest fragment failed because the index overflowed. - /// - /// # Errors - /// Returns an error if the last fragment result is not an overflow or no - /// fragment was processed. - pub fn assert_index_overflow(&self) -> TestResult { - self.assert_error( - |err| matches!(err, FragmentError::IndexOverflow { .. }), - "overflow error", - ) - } - - /// Assert that the latest fragment failed because the series was already - /// complete. - /// - /// # Errors - /// Returns an error if the last fragment result is not a completion error - /// or no fragment was processed. - pub fn assert_series_complete_error(&self) -> TestResult { - self.assert_error( - |err| matches!(err, FragmentError::SeriesComplete), - "series completion error", - ) - } - - /// Assert that the most recent fragmentation produced `expected` fragments - /// for outbound fragmentation scenarios. - /// - /// # Errors - /// Returns an error if no batch exists or the fragment count mismatches. - pub fn assert_fragment_count(&self, expected: usize) -> TestResult { - let actual = self.batch()?.len(); - if actual != expected { - return Err(format!("expected {expected} fragments, got {actual}").into()); - } - Ok(()) - } - - /// Assert that the payload length of fragment `index` matches `expected` - /// bytes for outbound fragments. - /// - /// # Errors - /// Returns an error if the batch is missing or the payload length differs. - pub fn assert_fragment_payload_len(&self, index: usize, expected: usize) -> TestResult { - let fragment = self.get_fragment_at(index)?; - let actual = fragment.payload().len(); - if actual != expected { - return Err(format!( - "fragment {index} payload length mismatch: expected {expected}, got {actual}" - ) - .into()); - } - Ok(()) - } - - /// Assert that outbound fragment `index` carries the expected final flag. - /// - /// # Errors - /// Returns an error if the batch is missing or the final flag mismatches. - pub fn assert_fragment_final_flag(&self, index: usize, expected_final: bool) -> TestResult { - let fragment = self.get_fragment_at(index)?; - let actual = fragment.header().is_last_fragment(); - if actual != expected_final { - return Err(format!( - "fragment {index} final flag mismatch: expected {expected_final}, got {actual}" - ) - .into()); - } - Ok(()) - } - - /// Assert that the outbound fragment batch carries the expected message - /// identifier. - /// - /// # Errors - /// Returns an error if the batch is missing or the message id differs from - /// the expectation. - pub fn assert_message_id(&self, expected: u64) -> TestResult { - let actual = self.batch()?.message_id(); - let expected_id = MessageId::new(expected); - if actual != expected_id { - return Err(format!( - "unexpected message identifier: expected {expected_id:?}, got {actual:?}" - ) - .into()); - } - Ok(()) - } -} diff --git a/tests/worlds/fragment/reassembly.rs b/tests/worlds/fragment/reassembly.rs deleted file mode 100644 index 55e39a8a..00000000 --- a/tests/worlds/fragment/reassembly.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Reassembly-focused helpers for `FragmentWorld`. - -use std::{num::NonZeroUsize, time::Duration}; - -use super::{ - FragmentError, - FragmentHeader, - FragmentWorld, - MessageId, - Reassembler, - ReassemblyError, - TestResult, -}; - -impl FragmentWorld { - /// Configure a reassembler with size and timeout guards. - /// - /// # Errors - /// Returns an error when the message size is zero or the configuration - /// cannot be constructed. - pub fn configure_reassembler( - &mut self, - max_message_size: usize, - timeout_secs: u64, - ) -> TestResult { - let size = NonZeroUsize::new(max_message_size).ok_or("reassembly cap must be non-zero")?; - self.reassembler = Some(Reassembler::new(size, Duration::from_secs(timeout_secs))); - self.last_reassembled = None; - self.last_reassembly_error = None; - self.last_evicted.clear(); - Ok(()) - } - - /// Submit a fragment to the configured reassembler. - /// - /// # Errors - /// Returns an error if the reassembler is missing. - pub fn push_fragment(&mut self, header: FragmentHeader, payload_len: usize) -> TestResult { - let reassembler = self - .reassembler - .as_mut() - .ok_or("reassembler not configured")?; - let payload = vec![0_u8; payload_len]; - self.last_reassembly_error = None; - self.last_reassembled = None; - match reassembler.push_at(header, payload, self.now) { - Ok(output) => self.last_reassembled = output, - Err(err) => self.last_reassembly_error = Some(err), - } - Ok(()) - } - - /// Advance the simulated clock. - /// - /// # Errors - /// Returns an error if the simulated clock would overflow. - pub fn advance_time(&mut self, delta: Duration) -> TestResult { - self.now = self - .now - .checked_add(delta) - .ok_or("time advance overflowed")?; - Ok(()) - } - - /// Purge expired partial messages based on the current clock reading. - /// - /// # Errors - /// Returns an error if the reassembler has not been configured. - pub fn purge_reassembly(&mut self) -> TestResult { - let reassembler = self - .reassembler - .as_mut() - .ok_or("reassembler not configured")?; - self.last_evicted = reassembler.purge_expired_at(self.now); - Ok(()) - } - - /// Assert that a message has been reassembled with the expected payload - /// length. - /// - /// # Errors - /// Returns an error if no message has been reassembled or the length does - /// not match the expectation. - pub fn assert_reassembled_len(&self, expected_len: usize) -> TestResult { - let message = self - .last_reassembled - .as_ref() - .ok_or("no message reassembled")?; - if message.payload().len() != expected_len { - return Err("payload length mismatch".into()); - } - Ok(()) - } - - /// Assert that no message has been fully reassembled. - /// - /// # Errors - /// Returns an error if a message has already been reassembled. - pub fn assert_no_reassembly(&self) -> TestResult { - if self.last_reassembled.is_some() { - return Err("unexpected reassembled message present".into()); - } - Ok(()) - } - - /// Helper for asserting on the latest captured reassembly error. - /// - /// # Errors - /// Returns an error when no reassembly error was captured or the predicate - /// does not match the error variant. - fn assert_reassembly_error_matches( - &self, - predicate: F, - expected_description: &str, - ) -> TestResult - where - F: FnOnce(&ReassemblyError) -> bool, - { - let err = self - .last_reassembly_error - .as_ref() - .ok_or("no reassembly error captured")?; - if !predicate(err) { - return Err(format!("expected {expected_description}, got {err}").into()); - } - Ok(()) - } - - /// Assert the latest reassembly error signalled an over-limit message. - /// - /// # Errors - /// Returns an error if no reassembly error was captured or it was not a - /// message-too-large error. - pub fn assert_reassembly_over_limit(&self) -> TestResult { - self.assert_reassembly_error_matches( - |err| matches!(err, ReassemblyError::MessageTooLarge { .. }), - "message-too-large error", - ) - } - - /// Assert that the latest reassembly error was triggered by an out-of-order - /// fragment. - /// - /// # Errors - /// Returns an error if no reassembly error was captured or it was not an - /// index-mismatch error. - pub fn assert_reassembly_out_of_order(&self) -> TestResult { - self.assert_reassembly_error_matches( - |err| { - matches!( - err, - ReassemblyError::Fragment(FragmentError::IndexMismatch { .. }) - ) - }, - "out-of-order error", - ) - } - - /// Assert the number of buffered partial messages. - /// - /// # Errors - /// Returns an error if the reassembler is missing or the buffered count - /// differs from the expectation. - pub fn assert_buffered_messages(&self, expected: usize) -> TestResult { - let reassembler = self - .reassembler - .as_ref() - .ok_or("reassembler not configured")?; - let actual = reassembler.buffered_len(); - if actual != expected { - return Err(format!("expected {expected} buffered messages, got {actual}").into()); - } - Ok(()) - } - - /// Assert that the most recent purge evicted a specific message identifier. - /// - /// # Errors - /// Returns an error if the expected message identifier was not evicted. - pub fn assert_evicted_message(&self, message_id: u64) -> TestResult { - if !self.last_evicted.contains(&MessageId::new(message_id)) { - return Err(format!("message {message_id} was not evicted").into()); - } - Ok(()) - } -} diff --git a/tests/worlds/message_assembler.rs b/tests/worlds/message_assembler.rs deleted file mode 100644 index 2ef637dd..00000000 --- a/tests/worlds/message_assembler.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Test world for message assembler header parsing. -#![cfg(not(loom))] - -use std::{fmt, io}; - -use bytes::{BufMut, BytesMut}; -use cucumber::World; -use wireframe::{ - message_assembler::{FrameHeader, FrameSequence, MessageAssembler, ParsedFrameHeader}, - test_helpers::TestAssembler, -}; - -use super::{TestApp, TestResult}; - -/// Specification for first-frame header encoding used in tests. -#[derive(Debug, Clone, Copy)] -pub struct FirstHeaderSpec { - /// Message key to encode into the header. - pub key: u64, - /// Metadata length in bytes. - pub metadata_len: usize, - /// Body length in bytes for this frame. - pub body_len: usize, - /// Optional total body length across all frames. - pub total_len: Option, - /// Whether the frame is the final one in the series. - pub is_last: bool, -} - -/// Specification for continuation-frame header encoding used in tests. -#[derive(Debug, Clone, Copy)] -pub struct ContinuationHeaderSpec { - /// Message key to encode into the header. - pub key: u64, - /// Body length in bytes for this frame. - pub body_len: usize, - /// Optional sequence number. - pub sequence: Option, - /// Whether the frame is the final one in the series. - pub is_last: bool, -} - -#[derive(Debug, Clone, Copy)] -struct HeaderEnvelope { - kind: u8, - flags: u8, - key: u64, -} - -/// World used by Cucumber to test message assembler header parsing. -#[derive(Default, World)] -pub struct MessageAssemblerWorld { - payload: Option>, - parsed: Option, - error: Option, - app: Option, -} - -impl fmt::Debug for MessageAssemblerWorld { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MessageAssemblerWorld") - .field("payload", &self.payload) - .field("parsed", &self.parsed) - .field("error", &self.error) - .field( - "app", - &self.app.as_ref().map(|_| "wireframe::app::WireframeApp"), - ) - .finish() - } -} - -impl MessageAssemblerWorld { - fn assert_common_field(&self, field: &str, expected: &T, extractor: F) -> TestResult - where - T: PartialEq + fmt::Display + Copy, - F: FnOnce(&FrameHeader) -> T, - { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = extractor(parsed.header()); - if actual != *expected { - return Err(format!("expected {field} {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Generic helper for asserting header-type-specific fields. - /// - /// The extractor performs both type-checking (via pattern matching) and field - /// extraction, returning an error message if the header type is incorrect. - fn assert_header_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult - where - T: PartialEq + fmt::Display + Copy, - F: FnOnce(&FrameHeader) -> Result, - { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = extractor(parsed.header()).map_err(ToString::to_string)?; - if actual != *expected { - return Err(format!("expected {field_name} {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Store an encoded first-frame header in the world payload. - /// - /// # Errors - /// - /// Returns an error if any length field exceeds the header encoding limits. - pub fn set_first_header(&mut self, spec: FirstHeaderSpec) -> TestResult { - let mut flags = 0u8; - if spec.is_last { - flags |= 0b1; - } - if spec.total_len.is_some() { - flags |= 0b10; - } - self.set_payload_with_header( - HeaderEnvelope { - kind: 0x01, - flags, - key: spec.key, - }, - |bytes| { - let metadata_len = - u16::try_from(spec.metadata_len).map_err(|_| "metadata length too large")?; - bytes.put_u16(metadata_len); - let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; - bytes.put_u32(body_len); - if let Some(total) = spec.total_len { - let total = u32::try_from(total).map_err(|_| "total length too large")?; - bytes.put_u32(total); - } - Ok(()) - }, - ) - } - - /// Store an encoded continuation-frame header in the world payload. - /// - /// # Errors - /// - /// Returns an error if any length field exceeds the header encoding limits. - pub fn set_continuation_header(&mut self, spec: ContinuationHeaderSpec) -> TestResult { - let mut flags = 0u8; - if spec.is_last { - flags |= 0b1; - } - if spec.sequence.is_some() { - flags |= 0b10; - } - self.set_payload_with_header( - HeaderEnvelope { - kind: 0x02, - flags, - key: spec.key, - }, - |bytes| { - let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; - bytes.put_u32(body_len); - if let Some(seq) = spec.sequence { - bytes.put_u32(seq); - } - Ok(()) - }, - ) - } - - fn set_payload_with_header(&mut self, envelope: HeaderEnvelope, encode: F) -> TestResult - where - F: FnOnce(&mut BytesMut) -> TestResult, - { - let mut bytes = BytesMut::new(); - bytes.put_u8(envelope.kind); - bytes.put_u8(envelope.flags); - bytes.put_u64(envelope.key); - encode(&mut bytes)?; - self.payload = Some(bytes.to_vec()); - Ok(()) - } - - /// Store a deliberately invalid header payload. - pub fn set_invalid_payload(&mut self) { self.payload = Some(vec![0x01]); } - - /// Parse the stored payload with the test assembler. - /// - /// # Errors - /// - /// Returns an error if no payload has been configured. - pub fn parse_header(&mut self) -> TestResult { - let payload = self.payload.as_deref().ok_or("payload not set")?; - let fallback = TestAssembler; - let assembler: &dyn MessageAssembler = match self.app.as_ref() { - Some(app) => app - .message_assembler() - .ok_or("message assembler not set")? - .as_ref(), - None => &fallback, - }; - match assembler.parse_frame_header(payload) { - Ok(parsed) => { - self.parsed = Some(parsed); - self.error = None; - } - Err(err) => { - self.parsed = None; - self.error = Some(err); - } - } - Ok(()) - } - - /// Assert that the parsed header is of the expected kind. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the kind does not match. - pub fn assert_header_kind(&self, expected: &str) -> TestResult { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let matches_kind = matches!( - (expected, parsed.header()), - ("first", FrameHeader::First(_)) | ("continuation", FrameHeader::Continuation(_)) - ); - if matches_kind { - Ok(()) - } else { - Err(format!("expected {expected} header").into()) - } - } - - /// Assert that the parsed header contains the expected message key. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the key does not match. - pub fn assert_message_key(&self, expected: u64) -> TestResult { - self.assert_common_field("key", &expected, |header| match header { - FrameHeader::First(header) => u64::from(header.message_key), - FrameHeader::Continuation(header) => u64::from(header.message_key), - }) - } - - /// Assert that the parsed header contains the expected metadata length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the metadata length differs. - pub fn assert_metadata_len(&self, expected: usize) -> TestResult { - self.assert_header_field("metadata length", &expected, |header| { - if let FrameHeader::First(header) = header { - Ok(header.metadata_len) - } else { - Err("expected first header") - } - }) - } - - /// Assert that the parsed header contains the expected body length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the body length differs. - pub fn assert_body_len(&self, expected: usize) -> TestResult { - self.assert_common_field("body length", &expected, |header| match header { - FrameHeader::First(header) => header.body_len, - FrameHeader::Continuation(header) => header.body_len, - }) - } - - /// Assert that the parsed header contains the expected total body length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the total length differs. - pub fn assert_total_len(&self, expected: Option) -> TestResult { - let expected = DebugDisplay(expected); - self.assert_header_field("total length", &expected, |header| { - if let FrameHeader::First(header) = header { - Ok(DebugDisplay(header.total_body_len)) - } else { - Err("expected first header") - } - }) - } - - /// Assert that the parsed header contains the expected sequence. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the sequence differs. - pub fn assert_sequence(&self, expected: Option) -> TestResult { - let expected = expected.map(FrameSequence::from); - let expected = DebugDisplay(expected); - self.assert_header_field("sequence", &expected, |header| { - if let FrameHeader::Continuation(header) = header { - Ok(DebugDisplay(header.sequence)) - } else { - Err("expected continuation header") - } - }) - } - - /// Assert that the parsed header matches the expected `is_last` flag. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the flag differs. - pub fn assert_is_last(&self, expected: bool) -> TestResult { - self.assert_common_field("is_last", &expected, |header| match header { - FrameHeader::First(header) => header.is_last, - FrameHeader::Continuation(header) => header.is_last, - }) - } - - /// Assert that the parsed header length matches the expected value. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the length differs. - pub fn assert_header_len(&self, expected: usize) -> TestResult { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = parsed.header_len(); - if actual != expected { - return Err(format!("expected header length {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Assert that the parse failed with `InvalidData`. - /// - /// # Errors - /// - /// Returns an error if no parse error was captured or the kind differs. - pub fn assert_invalid_data_error(&self) -> TestResult { - let err = self.error.as_ref().ok_or("expected error")?; - if err.kind() != io::ErrorKind::InvalidData { - return Err(format!("expected InvalidData error, got {:?}", err.kind()).into()); - } - Ok(()) - } - - /// Store a wireframe app configured with a test message assembler. - /// - /// # Errors - /// - /// Returns an error if the app builder fails. - pub fn set_app_with_message_assembler(&mut self) -> TestResult { - let app = TestApp::new() - .map_err(|err| format!("failed to build app: {err}"))? - .with_message_assembler(TestAssembler); - self.app = Some(app); - Ok(()) - } - - /// Assert that the app exposes a message assembler. - /// - /// # Errors - /// - /// Returns an error if the app or assembler is missing. - pub fn assert_message_assembler_configured(&self) -> TestResult { - let app = self.app.as_ref().ok_or("app not set")?; - if app.message_assembler().is_some() { - Ok(()) - } else { - Err("expected message assembler".into()) - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct DebugDisplay(T); - -impl fmt::Display for DebugDisplay { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } -} diff --git a/tests/worlds/message_assembly.rs b/tests/worlds/message_assembly.rs deleted file mode 100644 index 8c3e0e34..00000000 --- a/tests/worlds/message_assembly.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Test world for message assembly multiplexing and continuity validation. -#![cfg(not(loom))] - -#[path = "message_assembly_params.rs"] -mod message_assembly_params; - -use std::{ - collections::VecDeque, - fmt, - num::NonZeroUsize, - time::{Duration, Instant}, -}; - -use cucumber::World; -pub use message_assembly_params::{ContinuationFrameParams, FirstFrameParams}; -use wireframe::message_assembler::{ - AssembledMessage, - ContinuationFrameHeader, - FirstFrameHeader, - FirstFrameInput, - FrameSequence, - MessageAssemblyError, - MessageAssemblyState, - MessageKey, - MessageSeriesError, -}; - -use super::TestResult; - -/// Cucumber world for message assembly tests. -#[derive(Default, World)] -#[world(init = Self::new)] -pub struct MessageAssemblyWorld { - state: Option, - current_time: Option, - pending_first_frames: VecDeque, - last_result: Option, MessageAssemblyError>>, - completed_messages: Vec, - evicted_keys: Vec, -} - -/// Pending first frame awaiting acceptance. -pub struct PendingFirstFrame { - /// Frame header. - pub header: FirstFrameHeader, - /// Metadata bytes. - pub metadata: Vec, - /// Body bytes. - pub body: Vec, -} - -impl fmt::Debug for MessageAssemblyWorld { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MessageAssemblyWorld") - .field( - "state", - &self.state.as_ref().map(|_| "MessageAssemblyState"), - ) - .field("current_time", &self.current_time) - .field("pending_first_frames", &self.pending_first_frames.len()) - .field("last_result", &self.last_result) - .field("completed_messages", &self.completed_messages.len()) - .field("evicted_keys", &self.evicted_keys) - .finish() - } -} - -impl MessageAssemblyWorld { - fn new() -> Self { - Self { - state: None, - current_time: None, - pending_first_frames: VecDeque::new(), - last_result: None, - completed_messages: Vec::new(), - evicted_keys: Vec::new(), - } - } - - /// Initialise the assembly state with size limit and timeout. - /// - /// # Panics - /// - /// Panics if `max_size` is zero because `MessageAssemblyState` requires a - /// positive size limit. - pub fn create_state(&mut self, max_size: usize, timeout_secs: u64) { - let Some(size) = NonZeroUsize::new(max_size) else { - panic!("max_size must be non-zero for MessageAssemblyState"); - }; - self.state = Some(MessageAssemblyState::new( - size, - Duration::from_secs(timeout_secs), - )); - self.current_time = Some(Instant::now()); - self.pending_first_frames.clear(); - self.last_result = None; - self.completed_messages.clear(); - self.evicted_keys.clear(); - } - - /// Queue a first frame for later acceptance (FIFO). - pub fn add_first_frame(&mut self, params: FirstFrameParams) { - self.pending_first_frames.push_back(PendingFirstFrame { - header: FirstFrameHeader { - message_key: params.key, - metadata_len: params.metadata.len(), - body_len: params.body.len(), - total_body_len: None, - is_last: params.is_last, - }, - metadata: params.metadata, - body: params.body, - }); - } - - /// Accept the first queued first frame (FIFO). - /// - /// # Errors - /// - /// Returns an error if no pending frames, state not initialised, or time not set. - pub fn accept_first_frame(&mut self) -> TestResult { - let Some(pending) = self.pending_first_frames.pop_front() else { - return Err("no pending first frame".into()); - }; - let Some(state) = self.state.as_mut() else { - return Err("state not initialised".into()); - }; - let Some(now) = self.current_time else { - return Err("time not set".into()); - }; - let input = FirstFrameInput::new(&pending.header, pending.metadata, &pending.body) - .map_err(|e| format!("invalid input: {e}"))?; - self.last_result = Some(state.accept_first_frame_at(input, now)); - if let Some(Ok(Some(msg))) = &self.last_result { - self.completed_messages.push(msg.clone()); - } - Ok(()) - } - - /// Accept all queued first frames. - /// - /// # Errors - /// - /// Returns an error if state not initialised or time not set. - pub fn accept_all_first_frames(&mut self) -> TestResult { - let Some(state) = self.state.as_mut() else { - return Err("state not initialised".into()); - }; - let Some(now) = self.current_time else { - return Err("time not set".into()); - }; - - while let Some(pending) = self.pending_first_frames.pop_front() { - let input = FirstFrameInput::new(&pending.header, pending.metadata, &pending.body) - .map_err(|e| format!("invalid input: {e}"))?; - let result = state.accept_first_frame_at(input, now); - if let Ok(Some(msg)) = &result { - self.completed_messages.push(msg.clone()); - } - self.last_result = Some(result); - } - Ok(()) - } - - /// Accept a continuation frame for the given key. - /// - /// # Errors - /// - /// Returns an error if state not initialised or time not set. - #[expect( - clippy::needless_pass_by_value, - reason = "parameter object consistency with add_first_frame API" - )] - pub fn accept_continuation(&mut self, params: ContinuationFrameParams) -> TestResult { - let Some(state) = self.state.as_mut() else { - return Err("state not initialised".into()); - }; - let Some(now) = self.current_time else { - return Err("time not set".into()); - }; - - let header = ContinuationFrameHeader { - message_key: params.key, - sequence: params.sequence, - body_len: params.body.len(), - is_last: params.is_last, - }; - self.last_result = Some(state.accept_continuation_frame_at(&header, ¶ms.body, now)); - if let Some(Ok(Some(msg))) = &self.last_result { - self.completed_messages.push(msg.clone()); - } - Ok(()) - } - - /// Advance the simulated clock. - /// - /// # Errors - /// - /// Returns an error if time not set. - pub fn advance_time(&mut self, secs: u64) -> TestResult { - let Some(current) = self.current_time else { - return Err("time not set".into()); - }; - self.current_time = Some(current + Duration::from_secs(secs)); - Ok(()) - } - - /// Purge expired assemblies and record evicted keys. - /// - /// # Errors - /// - /// Returns an error if state not initialised or time not set. - pub fn purge_expired(&mut self) -> TestResult { - let Some(state) = self.state.as_mut() else { - return Err("state not initialised".into()); - }; - let Some(now) = self.current_time else { - return Err("time not set".into()); - }; - self.evicted_keys = state.purge_expired_at(now); - Ok(()) - } - - /// Number of partial assemblies currently buffered. - #[must_use] - pub fn buffered_count(&self) -> usize { - self.state - .as_ref() - .map_or(0, MessageAssemblyState::buffered_count) - } - - /// Whether the last result indicates an incomplete assembly. - #[must_use] - pub fn last_result_is_incomplete(&self) -> bool { matches!(self.last_result, Some(Ok(None))) } - - /// Body of the most recently completed message. - #[must_use] - pub fn last_completed_body(&self) -> Option<&[u8]> { - self.completed_messages.last().map(AssembledMessage::body) - } - - /// Body of the completed message for the given key. - #[must_use] - pub fn completed_body_for_key(&self, key: MessageKey) -> Option<&[u8]> { - self.completed_messages - .iter() - .rev() - .find(|m| m.message_key() == key) - .map(AssembledMessage::body) - } - - /// Last error, if any. - #[must_use] - pub fn last_error(&self) -> Option<&MessageAssemblyError> { - match &self.last_result { - Some(Err(e)) => Some(e), - _ => None, - } - } - - /// Whether the last error is a sequence mismatch. - #[must_use] - pub fn is_sequence_mismatch(&self, expected: FrameSequence, found: FrameSequence) -> bool { - matches!( - self.last_error(), - Some(MessageAssemblyError::Series(MessageSeriesError::SequenceMismatch { - expected: e, - found: f, - })) if *e == expected && *f == found - ) - } - - /// Whether the last error is a duplicate frame. - #[must_use] - pub fn is_duplicate_frame(&self, key: MessageKey, sequence: FrameSequence) -> bool { - matches!( - self.last_error(), - Some(MessageAssemblyError::Series(MessageSeriesError::DuplicateFrame { - key: k, - sequence: s, - })) if *k == key && *s == sequence - ) - } - - /// Whether the last error is a missing first frame. - #[must_use] - pub fn is_missing_first_frame(&self, key: MessageKey) -> bool { - matches!( - self.last_error(), - Some(MessageAssemblyError::Series(MessageSeriesError::MissingFirstFrame { - key: k, - })) if *k == key - ) - } - - /// Whether the last error is a duplicate first frame. - #[must_use] - pub fn is_duplicate_first_frame(&self, key: MessageKey) -> bool { - matches!( - self.last_error(), - Some(MessageAssemblyError::DuplicateFirstFrame { key: k }) if *k == key - ) - } - - /// Whether the last error is message too large. - #[must_use] - pub fn is_message_too_large(&self, key: MessageKey) -> bool { - matches!( - self.last_error(), - Some(MessageAssemblyError::MessageTooLarge { key: k, .. }) if *k == key - ) - } - - /// Whether the given key was evicted. - #[must_use] - pub fn was_evicted(&self, key: MessageKey) -> bool { self.evicted_keys.contains(&key) } -} diff --git a/tests/worlds/message_assembly_params.rs b/tests/worlds/message_assembly_params.rs deleted file mode 100644 index 07ab6dd0..00000000 --- a/tests/worlds/message_assembly_params.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Parameter objects for message assembly test steps. -#![cfg(not(loom))] - -use wireframe::message_assembler::{FrameSequence, MessageKey}; - -/// Parameters for creating a first frame. -#[derive(Debug)] -pub struct FirstFrameParams { - /// Message key. - pub key: MessageKey, - /// Metadata bytes. - pub metadata: Vec, - /// Body bytes. - pub body: Vec, - /// Whether this is the final frame. - pub is_last: bool, -} - -impl FirstFrameParams { - /// Create parameters for a first frame with default values. - #[must_use] - pub fn new(key: MessageKey, body: Vec) -> Self { - Self { - key, - metadata: vec![], - body, - is_last: false, - } - } - - /// Set metadata bytes. - #[must_use] - pub fn with_metadata(mut self, metadata: Vec) -> Self { - self.metadata = metadata; - self - } - - /// Mark as the final frame. - #[must_use] - pub fn final_frame(mut self) -> Self { - self.is_last = true; - self - } -} - -/// Parameters for creating a continuation frame. -#[derive(Debug)] -pub struct ContinuationFrameParams { - /// Message key. - pub key: MessageKey, - /// Optional sequence number. - pub sequence: Option, - /// Body bytes. - pub body: Vec, - /// Whether this is the final frame. - pub is_last: bool, -} - -impl ContinuationFrameParams { - /// Create parameters for a continuation frame with default sequence 1. - #[must_use] - pub fn new(key: MessageKey, body: Vec) -> Self { - Self { - key, - sequence: Some(FrameSequence(1)), - body, - is_last: false, - } - } - - /// Set the sequence number. - #[must_use] - pub fn with_sequence(mut self, sequence: FrameSequence) -> Self { - self.sequence = Some(sequence); - self - } - - /// Mark as the final frame. - #[must_use] - pub fn final_frame(mut self) -> Self { - self.is_last = true; - self - } -} diff --git a/tests/worlds/mod.rs b/tests/worlds/mod.rs deleted file mode 100644 index f44316bb..00000000 --- a/tests/worlds/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Cucumber test world implementations and shared helpers. -//! -//! Provides world types for behaviour-driven tests covering client lifecycle -//! hooks, codec error taxonomy, fragmentation, correlation, panic recovery, -//! stream termination, multi-packet channels, stateful codecs, request parts, -//! and message assembler parsing. Shared utilities like `build_small_queues` -//! keep individual worlds focused on their respective scenarios. -#![cfg(not(loom))] - -#[path = "../common/mod.rs"] -pub mod common; -pub use common::{TestResult, unused_listener}; - -#[path = "../common/terminator.rs"] -mod terminator; -pub(crate) use terminator::Terminator; - -#[path = "../support.rs"] -mod support; - -use wireframe::{app::Envelope, push::PushQueues, serializer::BincodeSerializer}; - -pub(crate) type TestApp = wireframe::app::WireframeApp; - -pub(crate) fn build_small_queues() --> Result<(PushQueues, wireframe::push::PushHandle), wireframe::push::PushConfigError> { - support::builder::().unlimited().build() -} - -pub mod client_lifecycle; -pub mod client_messaging; -pub mod client_preamble; -pub mod client_runtime; -#[path = "codec_error/mod.rs"] -pub mod codec_error; -pub mod codec_stateful; -pub mod correlation; -pub mod fragment; -pub mod message_assembler; -pub mod message_assembly; -pub mod multi_packet; -pub mod panic; -pub mod request_parts; -pub mod stream_end; -pub mod types; diff --git a/tests/worlds/multi_packet.rs b/tests/worlds/multi_packet.rs deleted file mode 100644 index 3012fdea..00000000 --- a/tests/worlds/multi_packet.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Test world for multi-packet channel scenarios. -//! -//! Provides [`MultiPacketWorld`] to verify message ordering, back-pressure -//! handling, and channel lifecycle in cucumber-based behaviour tests. -#![cfg(not(loom))] - -use std::{error::Error, fmt}; - -use cucumber::World; -use tokio::sync::mpsc::{self, error::TrySendError}; -use tokio_util::sync::CancellationToken; -use wireframe::{Response, connection::ConnectionActor}; - -use super::{TestResult, build_small_queues}; - -#[derive(Debug)] -struct WireframeRunError(wireframe::WireframeError); - -impl fmt::Display for WireframeRunError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } -} - -impl Error for WireframeRunError {} - -#[derive(Debug, Default, World)] -/// Test world exercising multi-packet channel behaviours and back-pressure. -pub struct MultiPacketWorld { - messages: Vec, - is_overflow_error: bool, -} - -impl MultiPacketWorld { - async fn collect_frames_from(rx: mpsc::Receiver) -> TestResult> { - let (queues, handle) = build_small_queues::()?; - let shutdown = CancellationToken::new(); - let mut actor: ConnectionActor<_, ()> = - ConnectionActor::new(queues, handle, None, shutdown); - actor.set_multi_packet(Some(rx)); - - let mut frames = Vec::new(); - actor - .run(&mut frames) - .await - .map_err(WireframeRunError) - .map_err(Box::::from)?; - Ok(frames) - } - - /// Send a single byte with back-pressure then close the channel. - async fn send_with_backpressure(sender: mpsc::Sender, value: u8) -> TestResult<()> { - sender.send(value).await?; - drop(sender); - Ok(()) - } - - /// Helper method to process messages through a multi-packet response built - /// via [`Response::with_channel`]. - /// - /// # Errors - /// Returns an error if the response cannot be converted to a multi-packet - /// stream or if producer tasks fail. - async fn process_messages(&mut self, messages: &[u8]) -> TestResult { - let (sender, response): (mpsc::Sender, Response) = Response::with_channel(4); - let Response::MultiPacket(rx) = response else { - return Err("helper did not return a MultiPacket response".into()); - }; - - let payload = messages.to_vec(); - let producer = tokio::spawn(Self::send_payload(sender, payload)); - - let frames = Self::collect_frames_from(rx).await?; - producer.await?; - self.messages = frames; - self.is_overflow_error = false; - Ok(()) - } - - /// Send each byte to the channel, stopping silently if the receiver closes - /// to simulate a producer completing without error when the consumer is - /// gone. - async fn send_payload(sender: mpsc::Sender, payload: Vec) { - for msg in payload { - if sender.send(msg).await.is_err() { - return; - } - } - } - - /// Send messages through a multi-packet response and record them. - /// - /// # Errors - /// Returns an error if the response cannot be converted to a multi-packet - /// stream or if producer tasks fail. - pub async fn process(&mut self) -> TestResult { self.process_messages(&[1, 2, 3]).await } - - /// Record zero messages from a closed channel. - /// - /// # Errors - /// Returns an error if the response cannot be converted to a multi-packet - /// stream or if producer tasks fail. - pub async fn process_empty(&mut self) -> TestResult { self.process_messages(&[]).await } - - /// Attempt to send more messages than the channel can buffer at once. - /// - /// # Errors - /// Returns an error if sending to the channel fails unexpectedly or the - /// producer task returns an error. - pub async fn process_overflow(&mut self) -> TestResult { - let (sender, response): (mpsc::Sender, Response) = Response::with_channel(1); - let Response::MultiPacket(rx) = response else { - return Err("helper did not return a MultiPacket response".into()); - }; - - sender.try_send(1)?; - let overflow_error = matches!(sender.try_send(2), Err(TrySendError::Full(2))); - - let producer = tokio::spawn(Self::send_with_backpressure(sender, 2)); - - let frames = Self::collect_frames_from(rx).await?; - // Unwrap JoinError from await, then the task's Result - producer.await??; - - self.messages = frames; - self.is_overflow_error = overflow_error; - Ok(()) - } - - /// Verify that no messages were received. - /// - /// # Panics - /// Panics if any messages are present. - pub fn verify_empty(&self) { - assert!(self.messages.is_empty()); - } - - /// Verify messages were received in order. - /// - /// # Panics - /// - /// Panics if the messages are not in the expected order. - pub fn verify(&self) { - assert_eq!(self.messages, vec![1, 2, 3]); - } - - /// Verify that the channel enforced back-pressure. - /// - /// # Panics - /// Panics if no overflow occurred or if the expected messages are missing. - pub fn verify_overflow(&self) { - assert!( - self.is_overflow_error, - "expected overflow error when channel capacity was exceeded", - ); - assert_eq!(self.messages, vec![1, 2]); - } -} diff --git a/tests/worlds/panic.rs b/tests/worlds/panic.rs deleted file mode 100644 index a03e3fba..00000000 --- a/tests/worlds/panic.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Test world for panic-on-connection scenarios. -//! -//! Provides [`PanicWorld`] to ensure the server remains resilient when -//! connection setup handlers panic before a client fully connects. -#![cfg(not(loom))] - -use std::net::SocketAddr; - -use cucumber::World; -use tokio::{net::TcpStream, sync::oneshot}; -use wireframe::server::WireframeServer; - -use super::{TestApp, TestResult, unused_listener}; - -#[derive(Debug)] -struct PanicServer { - addr: SocketAddr, - shutdown: Option>, - handle: tokio::task::JoinHandle<()>, -} - -impl PanicServer { - #[expect( - clippy::expect_used, - reason = "panic world should fail loudly if the panic app cannot be built" - )] - async fn spawn() -> TestResult { - let factory = || { - TestApp::new() - .and_then(|app| app.on_connection_setup(|| async { panic!("boom") })) - .expect("failed to build panic app") - }; - let listener = unused_listener(); - let server = WireframeServer::new(factory) - .workers(1) - .bind_existing_listener(listener)?; - let addr = server.local_addr().ok_or("Failed to get server address")?; - let (tx_shutdown, rx_shutdown) = oneshot::channel(); - let (tx_ready, rx_ready) = oneshot::channel(); - - let handle = tokio::spawn(async move { - if let Err(err) = server - .ready_signal(tx_ready) - .run_with_shutdown(async { - let _ = rx_shutdown.await; - }) - .await - { - tracing::error!("server task failed: {err}"); - } - }); - rx_ready.await.map_err(|_| "Server did not signal ready")?; - - Ok(Self { - addr, - shutdown: Some(tx_shutdown), - handle, - }) - } -} - -impl Drop for PanicServer { - fn drop(&mut self) { - use std::{thread, time::Duration}; - - if let Some(tx) = self.shutdown.take() { - let _ = tx.send(()); - } - let timeout = Duration::from_secs(5); - let handle = self.handle.abort_handle(); - thread::spawn(move || { - thread::sleep(timeout); - handle.abort(); - }); - } -} - -#[derive(Debug, Default, World)] -/// Test world that drives a server which intentionally panics during setup. -pub struct PanicWorld { - server: Option, - attempts: usize, -} - -impl PanicWorld { - /// Start a server that panics during connection setup. - /// - /// # Errors - /// Returns an error if building the app factory or binding the server - /// fails. - pub async fn start_panic_server(&mut self) -> TestResult { - let server = PanicServer::spawn().await?; - self.server.replace(server); - Ok(()) - } - - /// Connect to the running server once. - /// - /// # Errors - /// Returns an error if the server address is unknown or the connection - /// attempt fails. - pub async fn connect_once(&mut self) -> TestResult { - let addr = self.server.as_ref().ok_or("Server not started")?.addr; - TcpStream::connect(addr).await?; - self.attempts += 1; - Ok(()) - } - - /// Verify both connections succeeded and shut down the server. - /// - /// # Errors - /// Returns an error if the connection attempts do not match the expected - /// count. - pub async fn verify_and_shutdown(&mut self) -> TestResult { - if self.attempts != 2 { - return Err("expected two successful connection attempts".into()); - } - // dropping PanicServer will shut it down - self.server.take(); - tokio::task::yield_now().await; - Ok(()) - } -} diff --git a/tests/worlds/request_parts.rs b/tests/worlds/request_parts.rs deleted file mode 100644 index 420b6e3e..00000000 --- a/tests/worlds/request_parts.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Test world for request parts scenarios. -#![cfg(not(loom))] - -use cucumber::World; -use wireframe::request::RequestParts; - -use super::TestResult; - -/// Test world exercising `RequestParts` metadata handling. -#[derive(Debug, Default, World)] -pub struct RequestPartsWorld { - parts: Option, -} - -impl RequestPartsWorld { - /// Create request parts with all fields specified. - pub fn create_parts(&mut self, id: u32, correlation_id: Option, metadata: Vec) { - self.parts = Some(RequestParts::new(id, correlation_id, metadata)); - } - - /// Inherit a correlation id from an external source. - /// - /// # Errors - /// Returns an error if parts have not been created. - pub fn inherit_correlation(&mut self, source: Option) -> TestResult { - let parts = self.parts.take().ok_or("request parts not created")?; - self.parts = Some(parts.inherit_correlation(source)); - Ok(()) - } - - /// Append a byte to the metadata. - /// - /// # Errors - /// Returns an error if parts have not been created. - pub fn append_metadata_byte(&mut self, byte: u8) -> TestResult { - let parts = self.parts.as_mut().ok_or("request parts not created")?; - parts.metadata_mut().push(byte); - Ok(()) - } - - /// Assert the request id matches the expected value. - /// - /// # Errors - /// Returns an error if parts are missing or id does not match. - pub fn assert_id(&self, expected: u32) -> TestResult { - let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.id() != expected { - return Err(format!("expected id {expected}, got {}", parts.id()).into()); - } - Ok(()) - } - - /// Assert the correlation id matches the expected value. - /// - /// # Errors - /// Returns an error if parts are missing or correlation id does not match. - pub fn assert_correlation_id(&self, expected: Option) -> TestResult { - let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.correlation_id() != expected { - return Err(format!( - "expected correlation_id {:?}, got {:?}", - expected, - parts.correlation_id() - ) - .into()); - } - Ok(()) - } - - /// Assert the metadata length matches the expected value. - /// - /// # Errors - /// Returns an error if parts are missing or length does not match. - pub fn assert_metadata_length(&self, expected: usize) -> TestResult { - let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.metadata().len() != expected { - return Err(format!( - "expected metadata length {expected}, got {}", - parts.metadata().len() - ) - .into()); - } - Ok(()) - } -} diff --git a/tests/worlds/stream_end.rs b/tests/worlds/stream_end.rs deleted file mode 100644 index cae7dd27..00000000 --- a/tests/worlds/stream_end.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! Test world for verifying stream terminators and multi-packet lifecycle logs. -//! -//! Provides [`StreamEndWorld`] so cucumber scenarios can observe terminator -//! frames, closure reasons, and shutdown handling for streaming responses. -#![cfg(not(loom))] - -use std::{mem, sync::Arc}; - -use async_stream::try_stream; -use cucumber::World; -use log::Level; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use wireframe::{ - connection::{ConnectionActor, ConnectionChannels, test_support::ActorHarness}, - hooks::ProtocolHooks, - response::FrameStream, -}; -use wireframe_testing::{LoggerHandle, logger}; - -use super::{Terminator, TestResult, build_small_queues}; - -#[derive(Debug, Default, World)] -/// Test world capturing frames and logs for stream termination scenarios. -pub struct StreamEndWorld { - frames: Vec, - logs: Vec<(Level, String)>, -} - -enum MultiPacketMode { - Disconnect { send_frames: bool }, - Shutdown, -} - -enum ActorMode { - Stream, - MultiPacket, -} - -impl StreamEndWorld { - fn prepare_test(&mut self) -> LoggerHandle { - self.frames.clear(); - self.logs.clear(); - let mut logger = logger(); - logger.clear(); - logger - } - - fn finalize_test(&mut self, logger: &mut LoggerHandle) { self.capture_logs(logger); } - - async fn run_actor_test(&mut self, mode: ActorMode) -> TestResult { - let mut temp = StreamEndWorld::default(); - mem::swap(self, &mut temp); - let mut logger = temp.prepare_test(); - - let (queues, handle) = build_small_queues::()?; - let shutdown = CancellationToken::new(); - let hooks = ProtocolHooks::from_protocol(&Arc::new(Terminator)); - - match mode { - ActorMode::Stream => { - let stream: FrameStream = Box::pin(try_stream! { - yield 1u8; - yield 2u8; - }); - let mut actor = ConnectionActor::with_hooks( - ConnectionChannels::new(queues, handle), - Some(stream), - shutdown, - hooks, - ); - actor - .run(&mut temp.frames) - .await - .map_err(|e| format!("actor run failed: {e:?}"))?; - } - ActorMode::MultiPacket => { - let (tx, rx) = mpsc::channel(4); - tx.send(1u8).await?; - tx.send(2u8).await?; - drop(tx); - - let mut actor = ConnectionActor::with_hooks( - ConnectionChannels::new(queues, handle), - None, - shutdown, - hooks, - ); - actor.set_multi_packet(Some(rx)); - actor - .run(&mut temp.frames) - .await - .map_err(|e| format!("actor run failed: {e:?}"))?; - } - } - - temp.finalize_test(&mut logger); - mem::swap(self, &mut temp); - Ok(()) - } - - /// Run the connection actor and record emitted frames. - /// - /// # Errors - /// Returns an error if the actor fails to run successfully. - pub async fn process(&mut self) -> TestResult { self.run_actor_test(ActorMode::Stream).await } - - /// Run the connection actor with a multi-packet channel and record emitted frames. - /// - /// # Errors - /// Returns an error if sending to the channel or running the actor fails. - pub async fn process_multi(&mut self) -> TestResult { - self.run_actor_test(ActorMode::MultiPacket).await - } - - fn capture_logs(&mut self, logger: &mut LoggerHandle) { - while let Some(record) = logger.pop() { - self.logs.push((record.level(), record.args().to_string())); - } - } - - fn closure_log(&self) -> Option<&(Level, String)> { - self.logs - .iter() - .rev() - .find(|(_, message)| message.contains("multi-packet stream closed")) - } - - fn run_multi_packet_harness( - &mut self, - mode: &MultiPacketMode, - correlation_id: u64, - ) -> TestResult { - let mut temp = StreamEndWorld::default(); - mem::swap(self, &mut temp); - let mut logger = temp.prepare_test(); - - let hooks = ProtocolHooks::from_protocol(&Arc::new(Terminator)); - let mut harness = ActorHarness::new_with_state(hooks, false, true)?; - let (tx, rx) = mpsc::channel(4); - harness - .actor_mut() - .set_multi_packet_with_correlation(Some(rx), Some(correlation_id)); - match mode { - MultiPacketMode::Disconnect { send_frames } => { - if *send_frames { - tx.try_send(1u8)?; - tx.try_send(2u8)?; - } - drop(tx); - logger.clear(); - while harness.try_drain_multi() {} - } - MultiPacketMode::Shutdown => { - drop(tx); - logger.clear(); - harness.start_shutdown(); - } - } - temp.frames.clone_from(&harness.out); - - temp.finalize_test(&mut logger); - mem::swap(self, &mut temp); - Ok(()) - } - - /// Simulate a disconnected multi-packet channel by dropping the sender before draining. - /// - /// # Errors - /// Returns an error if creating the harness or sending frames fails. - pub fn process_multi_disconnect(&mut self) -> TestResult { - self.run_multi_packet_harness(&MultiPacketMode::Disconnect { send_frames: true }, 42) - } - - /// Trigger shutdown handling on a multi-packet channel without emitting a terminator. - /// - /// # Errors - /// Returns an error if creating the harness fails. - pub fn process_multi_shutdown(&mut self) -> TestResult { - self.run_multi_packet_harness(&MultiPacketMode::Shutdown, 77) - } - - /// Verify that a terminator frame was appended to the stream. - /// - /// # Panics - /// Panics if the expected terminator is missing. - pub fn verify(&self) { - assert_eq!(self.frames, vec![1, 2, 0]); - } - - /// Verify that a multi-packet terminator frame was appended to the stream. - /// - /// # Panics - /// Panics if the expected terminator is missing. - pub fn verify_multi(&self) { - assert_eq!(self.frames, vec![1, 2, 0]); - } - - /// Verify that no terminator frame was emitted. - /// - /// # Panics - /// Panics if a terminator frame is present. - pub fn verify_no_multi(&self) { - assert!( - self.frames.iter().all(|&frame| frame != 0), - "unexpected terminator frame present", - ); - } - - /// Verify the logged multi-packet termination reason. - /// - /// # Errors - /// Returns an error if the closure log is missing or contains unexpected - /// details. - pub fn verify_reason(&self, expected: &str) -> TestResult { - let (level, message) = self - .closure_log() - .ok_or("multi-packet closure log missing")?; - let expected_level = match expected { - "disconnected" => Level::Warn, - _ => Level::Info, - }; - if *level != expected_level { - return Err("unexpected log level for closure".into()); - } - if !message.contains(&format!("reason={expected}")) { - return Err("closure log missing reason detail".into()); - } - Ok(()) - } -} diff --git a/tests/worlds/types.rs b/tests/worlds/types.rs deleted file mode 100644 index a258b2f0..00000000 --- a/tests/worlds/types.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Domain-specific newtype wrappers for Cucumber step parameters. -//! -//! These types eliminate primitive obsession in step definitions by providing -//! semantic meaning to integer parameters parsed from feature files. - -use std::str::FromStr; - -/// Protocol-specific packet or message identifier for routing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RequestId(pub u32); - -impl FromStr for RequestId { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} - -/// Correlation identifier tying a request to a logical session or prior exchange. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CorrelationId(pub u64); - -impl FromStr for CorrelationId { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} - -/// Single byte of protocol-defined metadata. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MetadataByte(pub u8); - -impl FromStr for MetadataByte { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} - -/// Expected length of metadata bytes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MetadataLength(pub usize); - -impl FromStr for MetadataLength { - type Err = std::num::ParseIntError; - - fn from_str(s: &str) -> Result { s.parse().map(Self) } -} From f51ca1d404422bbb69a5f4e36211adfb665efcae Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 02:15:58 +0000 Subject: [PATCH 26/45] Flatten rstest-bdd test layout Move fixtures, steps, and scenarios under tests/ and update module paths and documentation to match the new layout. --- .../migrate-from-cucumber-to-rstest-bdd.md | 20 ++++++++++--------- tests/bdd/mod.rs | 10 ++++++---- tests/{bdd => }/fixtures/client_lifecycle.rs | 0 tests/{bdd => }/fixtures/client_messaging.rs | 0 tests/{bdd => }/fixtures/client_preamble.rs | 0 tests/{bdd => }/fixtures/client_runtime.rs | 0 .../fixtures/codec_error/decoder_ops.rs | 0 tests/{bdd => }/fixtures/codec_error/mod.rs | 0 tests/{bdd => }/fixtures/codec_stateful.rs | 0 tests/{bdd => }/fixtures/correlation.rs | 0 tests/{bdd => }/fixtures/fragment/mod.rs | 0 .../{bdd => }/fixtures/fragment/reassembly.rs | 0 tests/{bdd => }/fixtures/message_assembler.rs | 0 tests/{bdd => }/fixtures/message_assembly.rs | 0 .../fixtures/message_assembly_params.rs | 0 tests/{bdd => }/fixtures/mod.rs | 3 ++- tests/{bdd => }/fixtures/multi_packet.rs | 0 tests/{bdd => }/fixtures/panic.rs | 0 tests/{bdd => }/fixtures/request_parts.rs | 0 tests/{bdd => }/fixtures/stream_end.rs | 0 .../scenarios/client_lifecycle_scenarios.rs | 0 .../scenarios/client_messaging_scenarios.rs | 0 .../scenarios/client_preamble_scenarios.rs | 0 .../scenarios/client_runtime_scenarios.rs | 0 .../scenarios/codec_error_scenarios.rs | 0 .../scenarios/codec_stateful_scenarios.rs | 0 .../scenarios/correlation_scenarios.rs | 0 .../{bdd => }/scenarios/fragment_scenarios.rs | 0 .../scenarios/message_assembler_scenarios.rs | 0 .../scenarios/message_assembly_scenarios.rs | 0 tests/{bdd => }/scenarios/mod.rs | 0 .../scenarios/multi_packet_scenarios.rs | 0 tests/{bdd => }/scenarios/panic_scenarios.rs | 0 .../scenarios/request_parts_scenarios.rs | 0 .../scenarios/stream_end_scenarios.rs | 0 .../{bdd => }/steps/client_lifecycle_steps.rs | 0 .../{bdd => }/steps/client_messaging_steps.rs | 0 .../{bdd => }/steps/client_preamble_steps.rs | 0 tests/{bdd => }/steps/client_runtime_steps.rs | 0 tests/{bdd => }/steps/codec_error_steps.rs | 0 tests/{bdd => }/steps/codec_stateful_steps.rs | 0 tests/{bdd => }/steps/correlation_steps.rs | 0 tests/{bdd => }/steps/fragment_steps.rs | 0 .../steps/message_assembler_steps.rs | 0 .../{bdd => }/steps/message_assembly_steps.rs | 0 tests/{bdd => }/steps/mod.rs | 0 tests/{bdd => }/steps/multi_packet_steps.rs | 0 tests/{bdd => }/steps/panic_steps.rs | 0 tests/{bdd => }/steps/request_parts_steps.rs | 0 tests/{bdd => }/steps/stream_end_steps.rs | 0 50 files changed, 19 insertions(+), 14 deletions(-) rename tests/{bdd => }/fixtures/client_lifecycle.rs (100%) rename tests/{bdd => }/fixtures/client_messaging.rs (100%) rename tests/{bdd => }/fixtures/client_preamble.rs (100%) rename tests/{bdd => }/fixtures/client_runtime.rs (100%) rename tests/{bdd => }/fixtures/codec_error/decoder_ops.rs (100%) rename tests/{bdd => }/fixtures/codec_error/mod.rs (100%) rename tests/{bdd => }/fixtures/codec_stateful.rs (100%) rename tests/{bdd => }/fixtures/correlation.rs (100%) rename tests/{bdd => }/fixtures/fragment/mod.rs (100%) rename tests/{bdd => }/fixtures/fragment/reassembly.rs (100%) rename tests/{bdd => }/fixtures/message_assembler.rs (100%) rename tests/{bdd => }/fixtures/message_assembly.rs (100%) rename tests/{bdd => }/fixtures/message_assembly_params.rs (100%) rename tests/{bdd => }/fixtures/mod.rs (80%) rename tests/{bdd => }/fixtures/multi_packet.rs (100%) rename tests/{bdd => }/fixtures/panic.rs (100%) rename tests/{bdd => }/fixtures/request_parts.rs (100%) rename tests/{bdd => }/fixtures/stream_end.rs (100%) rename tests/{bdd => }/scenarios/client_lifecycle_scenarios.rs (100%) rename tests/{bdd => }/scenarios/client_messaging_scenarios.rs (100%) rename tests/{bdd => }/scenarios/client_preamble_scenarios.rs (100%) rename tests/{bdd => }/scenarios/client_runtime_scenarios.rs (100%) rename tests/{bdd => }/scenarios/codec_error_scenarios.rs (100%) rename tests/{bdd => }/scenarios/codec_stateful_scenarios.rs (100%) rename tests/{bdd => }/scenarios/correlation_scenarios.rs (100%) rename tests/{bdd => }/scenarios/fragment_scenarios.rs (100%) rename tests/{bdd => }/scenarios/message_assembler_scenarios.rs (100%) rename tests/{bdd => }/scenarios/message_assembly_scenarios.rs (100%) rename tests/{bdd => }/scenarios/mod.rs (100%) rename tests/{bdd => }/scenarios/multi_packet_scenarios.rs (100%) rename tests/{bdd => }/scenarios/panic_scenarios.rs (100%) rename tests/{bdd => }/scenarios/request_parts_scenarios.rs (100%) rename tests/{bdd => }/scenarios/stream_end_scenarios.rs (100%) rename tests/{bdd => }/steps/client_lifecycle_steps.rs (100%) rename tests/{bdd => }/steps/client_messaging_steps.rs (100%) rename tests/{bdd => }/steps/client_preamble_steps.rs (100%) rename tests/{bdd => }/steps/client_runtime_steps.rs (100%) rename tests/{bdd => }/steps/codec_error_steps.rs (100%) rename tests/{bdd => }/steps/codec_stateful_steps.rs (100%) rename tests/{bdd => }/steps/correlation_steps.rs (100%) rename tests/{bdd => }/steps/fragment_steps.rs (100%) rename tests/{bdd => }/steps/message_assembler_steps.rs (100%) rename tests/{bdd => }/steps/message_assembly_steps.rs (100%) rename tests/{bdd => }/steps/mod.rs (100%) rename tests/{bdd => }/steps/multi_packet_steps.rs (100%) rename tests/{bdd => }/steps/panic_steps.rs (100%) rename tests/{bdd => }/steps/request_parts_steps.rs (100%) rename tests/{bdd => }/steps/stream_end_steps.rs (100%) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 00f2d819..366cef2d 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -4,7 +4,7 @@ **Duration**: 9 weeks (phased incremental migration) -**Status**: In Progress +**Status**: Complete **Last Updated**: 2026-01-25 @@ -491,6 +491,8 @@ pub fn fragment_world() -> FragmentWorld { # Update imports ``` + Completed 2026-01-25: fixtures, steps, and scenarios now live under `tests/`. + **Commits**: - "Enable strict compile-time validation" @@ -499,14 +501,14 @@ pub fn fragment_world() -> FragmentWorld { ## Migration Progress Tracking -| Phase | Worlds | Scenarios | Status | Completion | -| ----- | ------ | --------- | ----------- | ---------- | -| 0 | - | - | Complete | 2026-01-22 | -| 1 | 2 | 6 | Complete | 2026-01-22 | -| 2 | 4 | 15 | Complete | 2026-01-24 | -| 3 | 4 | 20 | Complete | 2026-01-25 | -| 4 | 4 | 19+ | Complete | 2026-01-25 | -| 5 | - | - | In Progress | 2026-01-25 | +| Phase | Worlds | Scenarios | Status | Completion | +| ----- | ------ | --------- | -------- | ---------- | +| 0 | - | - | Complete | 2026-01-22 | +| 1 | 2 | 6 | Complete | 2026-01-22 | +| 2 | 4 | 15 | Complete | 2026-01-24 | +| 3 | 4 | 20 | Complete | 2026-01-25 | +| 4 | 4 | 19+ | Complete | 2026-01-25 | +| 5 | - | - | Complete | 2026-01-25 | **Total**: 14 worlds, 60+ scenarios diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs index e5481b1a..b7ca78aa 100644 --- a/tests/bdd/mod.rs +++ b/tests/bdd/mod.rs @@ -1,10 +1,9 @@ #![cfg(not(loom))] //! rstest-bdd behavioural tests. //! -//! This module contains the rstest-bdd-based BDD tests that are gradually -//! replacing the Cucumber test suite. These tests use the same `.feature` -//! files as the Cucumber tests but execute under the standard `cargo test` -//! harness with rstest fixtures. +//! This module contains the rstest-bdd-based BDD tests that replaced the +//! former Cucumber test suite. These tests use the same `.feature` files but +//! execute under the standard `cargo test` harness with rstest fixtures. // Re-export common utilities from the parent tests directory #[path = "../common/mod.rs"] @@ -25,5 +24,8 @@ pub(crate) fn build_small_queues() support::builder::().unlimited().build() } +#[path = "../fixtures/mod.rs"] mod fixtures; + +#[path = "../scenarios/mod.rs"] mod scenarios; diff --git a/tests/bdd/fixtures/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs similarity index 100% rename from tests/bdd/fixtures/client_lifecycle.rs rename to tests/fixtures/client_lifecycle.rs diff --git a/tests/bdd/fixtures/client_messaging.rs b/tests/fixtures/client_messaging.rs similarity index 100% rename from tests/bdd/fixtures/client_messaging.rs rename to tests/fixtures/client_messaging.rs diff --git a/tests/bdd/fixtures/client_preamble.rs b/tests/fixtures/client_preamble.rs similarity index 100% rename from tests/bdd/fixtures/client_preamble.rs rename to tests/fixtures/client_preamble.rs diff --git a/tests/bdd/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs similarity index 100% rename from tests/bdd/fixtures/client_runtime.rs rename to tests/fixtures/client_runtime.rs diff --git a/tests/bdd/fixtures/codec_error/decoder_ops.rs b/tests/fixtures/codec_error/decoder_ops.rs similarity index 100% rename from tests/bdd/fixtures/codec_error/decoder_ops.rs rename to tests/fixtures/codec_error/decoder_ops.rs diff --git a/tests/bdd/fixtures/codec_error/mod.rs b/tests/fixtures/codec_error/mod.rs similarity index 100% rename from tests/bdd/fixtures/codec_error/mod.rs rename to tests/fixtures/codec_error/mod.rs diff --git a/tests/bdd/fixtures/codec_stateful.rs b/tests/fixtures/codec_stateful.rs similarity index 100% rename from tests/bdd/fixtures/codec_stateful.rs rename to tests/fixtures/codec_stateful.rs diff --git a/tests/bdd/fixtures/correlation.rs b/tests/fixtures/correlation.rs similarity index 100% rename from tests/bdd/fixtures/correlation.rs rename to tests/fixtures/correlation.rs diff --git a/tests/bdd/fixtures/fragment/mod.rs b/tests/fixtures/fragment/mod.rs similarity index 100% rename from tests/bdd/fixtures/fragment/mod.rs rename to tests/fixtures/fragment/mod.rs diff --git a/tests/bdd/fixtures/fragment/reassembly.rs b/tests/fixtures/fragment/reassembly.rs similarity index 100% rename from tests/bdd/fixtures/fragment/reassembly.rs rename to tests/fixtures/fragment/reassembly.rs diff --git a/tests/bdd/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs similarity index 100% rename from tests/bdd/fixtures/message_assembler.rs rename to tests/fixtures/message_assembler.rs diff --git a/tests/bdd/fixtures/message_assembly.rs b/tests/fixtures/message_assembly.rs similarity index 100% rename from tests/bdd/fixtures/message_assembly.rs rename to tests/fixtures/message_assembly.rs diff --git a/tests/bdd/fixtures/message_assembly_params.rs b/tests/fixtures/message_assembly_params.rs similarity index 100% rename from tests/bdd/fixtures/message_assembly_params.rs rename to tests/fixtures/message_assembly_params.rs diff --git a/tests/bdd/fixtures/mod.rs b/tests/fixtures/mod.rs similarity index 80% rename from tests/bdd/fixtures/mod.rs rename to tests/fixtures/mod.rs index 3c67eb6b..d69efa54 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -1,6 +1,7 @@ //! Fixture definitions for rstest-bdd tests. //! -//! Each world from the Cucumber tests is converted to an rstest fixture here. +//! Each world from the former Cucumber tests is converted to an rstest fixture +//! here. pub mod client_lifecycle; pub mod client_messaging; diff --git a/tests/bdd/fixtures/multi_packet.rs b/tests/fixtures/multi_packet.rs similarity index 100% rename from tests/bdd/fixtures/multi_packet.rs rename to tests/fixtures/multi_packet.rs diff --git a/tests/bdd/fixtures/panic.rs b/tests/fixtures/panic.rs similarity index 100% rename from tests/bdd/fixtures/panic.rs rename to tests/fixtures/panic.rs diff --git a/tests/bdd/fixtures/request_parts.rs b/tests/fixtures/request_parts.rs similarity index 100% rename from tests/bdd/fixtures/request_parts.rs rename to tests/fixtures/request_parts.rs diff --git a/tests/bdd/fixtures/stream_end.rs b/tests/fixtures/stream_end.rs similarity index 100% rename from tests/bdd/fixtures/stream_end.rs rename to tests/fixtures/stream_end.rs diff --git a/tests/bdd/scenarios/client_lifecycle_scenarios.rs b/tests/scenarios/client_lifecycle_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/client_lifecycle_scenarios.rs rename to tests/scenarios/client_lifecycle_scenarios.rs diff --git a/tests/bdd/scenarios/client_messaging_scenarios.rs b/tests/scenarios/client_messaging_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/client_messaging_scenarios.rs rename to tests/scenarios/client_messaging_scenarios.rs diff --git a/tests/bdd/scenarios/client_preamble_scenarios.rs b/tests/scenarios/client_preamble_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/client_preamble_scenarios.rs rename to tests/scenarios/client_preamble_scenarios.rs diff --git a/tests/bdd/scenarios/client_runtime_scenarios.rs b/tests/scenarios/client_runtime_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/client_runtime_scenarios.rs rename to tests/scenarios/client_runtime_scenarios.rs diff --git a/tests/bdd/scenarios/codec_error_scenarios.rs b/tests/scenarios/codec_error_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/codec_error_scenarios.rs rename to tests/scenarios/codec_error_scenarios.rs diff --git a/tests/bdd/scenarios/codec_stateful_scenarios.rs b/tests/scenarios/codec_stateful_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/codec_stateful_scenarios.rs rename to tests/scenarios/codec_stateful_scenarios.rs diff --git a/tests/bdd/scenarios/correlation_scenarios.rs b/tests/scenarios/correlation_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/correlation_scenarios.rs rename to tests/scenarios/correlation_scenarios.rs diff --git a/tests/bdd/scenarios/fragment_scenarios.rs b/tests/scenarios/fragment_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/fragment_scenarios.rs rename to tests/scenarios/fragment_scenarios.rs diff --git a/tests/bdd/scenarios/message_assembler_scenarios.rs b/tests/scenarios/message_assembler_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/message_assembler_scenarios.rs rename to tests/scenarios/message_assembler_scenarios.rs diff --git a/tests/bdd/scenarios/message_assembly_scenarios.rs b/tests/scenarios/message_assembly_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/message_assembly_scenarios.rs rename to tests/scenarios/message_assembly_scenarios.rs diff --git a/tests/bdd/scenarios/mod.rs b/tests/scenarios/mod.rs similarity index 100% rename from tests/bdd/scenarios/mod.rs rename to tests/scenarios/mod.rs diff --git a/tests/bdd/scenarios/multi_packet_scenarios.rs b/tests/scenarios/multi_packet_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/multi_packet_scenarios.rs rename to tests/scenarios/multi_packet_scenarios.rs diff --git a/tests/bdd/scenarios/panic_scenarios.rs b/tests/scenarios/panic_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/panic_scenarios.rs rename to tests/scenarios/panic_scenarios.rs diff --git a/tests/bdd/scenarios/request_parts_scenarios.rs b/tests/scenarios/request_parts_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/request_parts_scenarios.rs rename to tests/scenarios/request_parts_scenarios.rs diff --git a/tests/bdd/scenarios/stream_end_scenarios.rs b/tests/scenarios/stream_end_scenarios.rs similarity index 100% rename from tests/bdd/scenarios/stream_end_scenarios.rs rename to tests/scenarios/stream_end_scenarios.rs diff --git a/tests/bdd/steps/client_lifecycle_steps.rs b/tests/steps/client_lifecycle_steps.rs similarity index 100% rename from tests/bdd/steps/client_lifecycle_steps.rs rename to tests/steps/client_lifecycle_steps.rs diff --git a/tests/bdd/steps/client_messaging_steps.rs b/tests/steps/client_messaging_steps.rs similarity index 100% rename from tests/bdd/steps/client_messaging_steps.rs rename to tests/steps/client_messaging_steps.rs diff --git a/tests/bdd/steps/client_preamble_steps.rs b/tests/steps/client_preamble_steps.rs similarity index 100% rename from tests/bdd/steps/client_preamble_steps.rs rename to tests/steps/client_preamble_steps.rs diff --git a/tests/bdd/steps/client_runtime_steps.rs b/tests/steps/client_runtime_steps.rs similarity index 100% rename from tests/bdd/steps/client_runtime_steps.rs rename to tests/steps/client_runtime_steps.rs diff --git a/tests/bdd/steps/codec_error_steps.rs b/tests/steps/codec_error_steps.rs similarity index 100% rename from tests/bdd/steps/codec_error_steps.rs rename to tests/steps/codec_error_steps.rs diff --git a/tests/bdd/steps/codec_stateful_steps.rs b/tests/steps/codec_stateful_steps.rs similarity index 100% rename from tests/bdd/steps/codec_stateful_steps.rs rename to tests/steps/codec_stateful_steps.rs diff --git a/tests/bdd/steps/correlation_steps.rs b/tests/steps/correlation_steps.rs similarity index 100% rename from tests/bdd/steps/correlation_steps.rs rename to tests/steps/correlation_steps.rs diff --git a/tests/bdd/steps/fragment_steps.rs b/tests/steps/fragment_steps.rs similarity index 100% rename from tests/bdd/steps/fragment_steps.rs rename to tests/steps/fragment_steps.rs diff --git a/tests/bdd/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs similarity index 100% rename from tests/bdd/steps/message_assembler_steps.rs rename to tests/steps/message_assembler_steps.rs diff --git a/tests/bdd/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs similarity index 100% rename from tests/bdd/steps/message_assembly_steps.rs rename to tests/steps/message_assembly_steps.rs diff --git a/tests/bdd/steps/mod.rs b/tests/steps/mod.rs similarity index 100% rename from tests/bdd/steps/mod.rs rename to tests/steps/mod.rs diff --git a/tests/bdd/steps/multi_packet_steps.rs b/tests/steps/multi_packet_steps.rs similarity index 100% rename from tests/bdd/steps/multi_packet_steps.rs rename to tests/steps/multi_packet_steps.rs diff --git a/tests/bdd/steps/panic_steps.rs b/tests/steps/panic_steps.rs similarity index 100% rename from tests/bdd/steps/panic_steps.rs rename to tests/steps/panic_steps.rs diff --git a/tests/bdd/steps/request_parts_steps.rs b/tests/steps/request_parts_steps.rs similarity index 100% rename from tests/bdd/steps/request_parts_steps.rs rename to tests/steps/request_parts_steps.rs diff --git a/tests/bdd/steps/stream_end_steps.rs b/tests/steps/stream_end_steps.rs similarity index 100% rename from tests/bdd/steps/stream_end_steps.rs rename to tests/steps/stream_end_steps.rs From d81ecd2ab16cd8e6bb484f696c7ffbaeffb0b070 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 14:23:44 +0000 Subject: [PATCH 27/45] Introduce message assembly parameter objects Add AssemblyConfig and FrameId to reduce primitive coupling in message assembly steps, and thread FrameId through the world error checks while keeping step signatures unchanged. --- tests/fixtures/message_assembly.rs | 5 +-- tests/scenarios/mod.rs | 2 +- tests/steps/message_assembly_steps.rs | 48 +++++++++++++++++++++++++-- tests/steps/mod.rs | 2 ++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/fixtures/message_assembly.rs b/tests/fixtures/message_assembly.rs index e83dd082..68d2348e 100644 --- a/tests/fixtures/message_assembly.rs +++ b/tests/fixtures/message_assembly.rs @@ -30,6 +30,7 @@ use wireframe::message_assembler::{ // Re-export TestResult from common for use in steps pub use crate::common::TestResult; +use crate::scenarios::steps::FrameId; /// Test world for message assembly multiplexing scenarios. #[derive(Default)] @@ -267,13 +268,13 @@ impl MessageAssemblyWorld { /// Whether the last error is a duplicate frame. #[must_use] - pub fn is_duplicate_frame(&self, key: MessageKey, sequence: FrameSequence) -> bool { + pub fn is_duplicate_frame(&self, frame_id: FrameId) -> bool { matches!( self.last_error(), Some(MessageAssemblyError::Series(MessageSeriesError::DuplicateFrame { key: k, sequence: s, - })) if *k == key && *s == sequence + })) if *k == frame_id.key && *s == frame_id.sequence ) } diff --git a/tests/scenarios/mod.rs b/tests/scenarios/mod.rs index 584d8882..a66eb841 100644 --- a/tests/scenarios/mod.rs +++ b/tests/scenarios/mod.rs @@ -5,7 +5,7 @@ // Load step definitions first so compile-time validation can see them. #[path = "../steps/mod.rs"] -mod steps; +pub(crate) mod steps; mod client_lifecycle_scenarios; mod client_messaging_scenarios; diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index a1c41699..12436bbb 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -16,6 +16,45 @@ fn to_key(key: u64) -> MessageKey { MessageKey(key) } /// Convert primitive sequence to domain type at the boundary. fn to_seq(seq: u32) -> FrameSequence { FrameSequence(seq) } +/// Configuration for message assembly state initialisation. +#[derive(Debug, Clone)] +pub struct AssemblyConfig { + pub max_message_size: usize, + pub timeout_seconds: u64, +} + +impl AssemblyConfig { + pub fn new(max_message_size: usize, timeout_seconds: u64) -> Self { + Self { + max_message_size, + timeout_seconds, + } + } +} + +/// Frame identification combining key and optional sequence. +#[derive(Debug, Clone, Copy)] +pub struct FrameId { + pub key: MessageKey, + pub sequence: FrameSequence, +} + +impl FrameId { + pub fn new(key: u64, sequence: u32) -> Self { + Self { + key: to_key(key), + sequence: to_seq(sequence), + } + } + + pub fn with_key(key: u64) -> Self { + Self { + key: to_key(key), + sequence: FrameSequence(0), + } + } +} + /// Helper function to reduce duplication in Then step assertions. fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult { if condition { @@ -33,7 +72,8 @@ fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult "a message assembly state with max size {max_size:usize} and timeout {timeout:u64} seconds" )] fn given_state(message_assembly_world: &mut MessageAssemblyWorld, max_size: usize, timeout: u64) { - message_assembly_world.create_state(max_size, timeout); + let config = AssemblyConfig::new(max_size, timeout); + message_assembly_world.create_state(config.max_message_size, config.timeout_seconds); } #[given("a first frame for key {key:u64} with metadata {metadata:string} and body {body:string}")] @@ -218,8 +258,9 @@ fn then_error_duplicate_frame( key: u64, sequence: u32, ) -> TestResult { + let frame_id = FrameId::new(key, sequence); assert_condition( - message_assembly_world.is_duplicate_frame(to_key(key), to_seq(sequence)), + message_assembly_world.is_duplicate_frame(frame_id), format!( "expected duplicate frame error, got {:?}", message_assembly_world.last_error() @@ -232,8 +273,9 @@ fn then_error_missing_first_frame( message_assembly_world: &mut MessageAssemblyWorld, key: u64, ) -> TestResult { + let frame_id = FrameId::with_key(key); assert_condition( - message_assembly_world.is_missing_first_frame(to_key(key)), + message_assembly_world.is_missing_first_frame(frame_id.key), format!( "expected missing first frame error, got {:?}", message_assembly_world.last_error() diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index b290dd1d..a6f4f598 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -17,3 +17,5 @@ mod multi_packet_steps; mod panic_steps; mod request_parts_steps; mod stream_end_steps; + +pub(crate) use message_assembly_steps::FrameId; From 2fff408a97e47b6766f402b8fb0cf41598cd7f1e Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 14:35:37 +0000 Subject: [PATCH 28/45] Refactor message assembly step assertions Add shared assertion helpers and reuse them across the message assembly Then steps to reduce duplication while preserving existing behaviour. --- tests/steps/message_assembly_steps.rs | 112 ++++++++++++++------------ 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index 12436bbb..3fbf27a7 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -1,5 +1,7 @@ //! Step definitions for message assembly multiplexing and continuity validation. +use std::fmt::Debug; + use rstest_bdd_macros::{given, then, when}; use wireframe::message_assembler::{FrameSequence, MessageKey}; @@ -64,6 +66,36 @@ fn assert_condition(condition: bool, error_msg: impl Into) -> TestResult } } +fn assert_error( + world: &MessageAssemblyWorld, + check: F, + description: impl Into, +) -> TestResult +where + F: FnOnce(&MessageAssemblyWorld) -> bool, +{ + assert_condition( + check(world), + format!("{}; got {:?}", description.into(), world.last_error()), + ) +} + +fn assert_equals( + actual: &T, + expected: &T, + context: impl Into, +) -> TestResult { + assert_condition( + actual == expected, + format!( + "{}: expected {:?}, got {:?}", + context.into(), + expected, + actual + ), + ) +} + // ============================================================================= // Given steps // ============================================================================= @@ -196,14 +228,7 @@ fn then_completes_with_body( let actual = message_assembly_world .last_completed_body() .ok_or("expected completed message")?; - assert_condition( - actual == body.as_bytes(), - format!( - "body mismatch: expected {:?}, got {:?}", - body.as_bytes(), - actual - ), - ) + assert_equals(&actual, &body.as_bytes(), "body mismatch") } #[then("key {key:u64} completes with body {body:string}")] @@ -215,13 +240,10 @@ fn then_key_completes( let actual = message_assembly_world .completed_body_for_key(to_key(key)) .ok_or_else(|| format!("expected completed message for key {key}"))?; - assert_condition( - actual == body.as_bytes(), - format!( - "body mismatch for key {key}: expected {:?}, got {:?}", - body.as_bytes(), - actual - ), + assert_equals( + &actual, + &body.as_bytes(), + format!("body mismatch for key {key}"), ) } @@ -231,10 +253,7 @@ fn then_buffered_count( count: usize, ) -> TestResult { let actual = message_assembly_world.buffered_count(); - assert_condition( - actual == count, - format!("buffered count mismatch: expected {count}, got {actual}"), - ) + assert_equals(&actual, &count, "buffered count mismatch") } #[then("the error is sequence mismatch expecting {expected:u32} but found {found:u32}")] @@ -243,12 +262,10 @@ fn then_error_sequence_mismatch( expected: u32, found: u32, ) -> TestResult { - assert_condition( - message_assembly_world.is_sequence_mismatch(to_seq(expected), to_seq(found)), - format!( - "expected sequence mismatch error, got {:?}", - message_assembly_world.last_error() - ), + assert_error( + message_assembly_world, + |world| world.is_sequence_mismatch(to_seq(expected), to_seq(found)), + "expected sequence mismatch error", ) } @@ -259,12 +276,10 @@ fn then_error_duplicate_frame( sequence: u32, ) -> TestResult { let frame_id = FrameId::new(key, sequence); - assert_condition( - message_assembly_world.is_duplicate_frame(frame_id), - format!( - "expected duplicate frame error, got {:?}", - message_assembly_world.last_error() - ), + assert_error( + message_assembly_world, + |world| world.is_duplicate_frame(frame_id), + "expected duplicate frame error", ) } @@ -274,12 +289,10 @@ fn then_error_missing_first_frame( key: u64, ) -> TestResult { let frame_id = FrameId::with_key(key); - assert_condition( - message_assembly_world.is_missing_first_frame(frame_id.key), - format!( - "expected missing first frame error, got {:?}", - message_assembly_world.last_error() - ), + assert_error( + message_assembly_world, + |world| world.is_missing_first_frame(frame_id.key), + "expected missing first frame error", ) } @@ -288,12 +301,10 @@ fn then_error_duplicate_first_frame( message_assembly_world: &mut MessageAssemblyWorld, key: u64, ) -> TestResult { - assert_condition( - message_assembly_world.is_duplicate_first_frame(to_key(key)), - format!( - "expected duplicate first frame error, got {:?}", - message_assembly_world.last_error() - ), + assert_error( + message_assembly_world, + |world| world.is_duplicate_first_frame(to_key(key)), + "expected duplicate first frame error", ) } @@ -302,19 +313,18 @@ fn then_error_message_too_large( message_assembly_world: &mut MessageAssemblyWorld, key: u64, ) -> TestResult { - assert_condition( - message_assembly_world.is_message_too_large(to_key(key)), - format!( - "expected message too large error, got {:?}", - message_assembly_world.last_error() - ), + assert_error( + message_assembly_world, + |world| world.is_message_too_large(to_key(key)), + "expected message too large error", ) } #[then("key {key:u64} was evicted")] fn then_key_evicted(message_assembly_world: &mut MessageAssemblyWorld, key: u64) -> TestResult { - assert_condition( - message_assembly_world.was_evicted(to_key(key)), + assert_error( + message_assembly_world, + |world| world.was_evicted(to_key(key)), format!("expected key {key} to be evicted"), ) } From c020510f1e421e84ea1a05b2a855c57363fabc03 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 14:42:06 +0000 Subject: [PATCH 29/45] Introduce request parts newtypes Add request id, correlation id, and metadata byte wrappers with FromStr parsing, and thread them through the request parts world and BDD step definitions. --- tests/fixtures/request_parts.rs | 66 ++++++++++++++++++++++++------ tests/steps/request_parts_steps.rs | 43 +++++++++++++------ 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/tests/fixtures/request_parts.rs b/tests/fixtures/request_parts.rs index 7078aad6..2aeec729 100644 --- a/tests/fixtures/request_parts.rs +++ b/tests/fixtures/request_parts.rs @@ -5,12 +5,44 @@ #![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] +use std::str::FromStr; + use rstest::fixture; use wireframe::request::RequestParts; // Re-export TestResult from common for use in steps pub use crate::common::TestResult; +/// Request identifier wrapper for test steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestId(pub u32); + +impl FromStr for RequestId { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { value.parse().map(RequestId) } +} + +/// Correlation identifier wrapper for test steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CorrelationId(pub u64); + +impl FromStr for CorrelationId { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { value.parse().map(CorrelationId) } +} + +/// Metadata byte wrapper for test steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataByte(pub u8); + +impl FromStr for MetadataByte { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { value.parse().map(MetadataByte) } +} + #[derive(Debug, Default)] /// Test world exercising `RequestParts` metadata handling. pub struct RequestPartsWorld { @@ -22,17 +54,26 @@ pub fn request_parts_world() -> RequestPartsWorld { RequestPartsWorld::default() impl RequestPartsWorld { /// Create request parts with all fields specified. - pub fn create_parts(&mut self, id: u32, correlation_id: Option, metadata: Vec) { - self.parts = Some(RequestParts::new(id, correlation_id, metadata)); + pub fn create_parts( + &mut self, + id: RequestId, + correlation_id: Option, + metadata: Vec, + ) { + self.parts = Some(RequestParts::new( + id.0, + correlation_id.map(|value| value.0), + metadata.into_iter().map(|value| value.0).collect(), + )); } /// Inherit a correlation id from an external source. /// /// # Errors /// Returns an error if parts have not been created. - pub fn inherit_correlation(&mut self, source: Option) -> TestResult { + pub fn inherit_correlation(&mut self, source: Option) -> TestResult { let parts = self.parts.take().ok_or("request parts not created")?; - self.parts = Some(parts.inherit_correlation(source)); + self.parts = Some(parts.inherit_correlation(source.map(|value| value.0))); Ok(()) } @@ -40,9 +81,9 @@ impl RequestPartsWorld { /// /// # Errors /// Returns an error if parts have not been created. - pub fn append_metadata_byte(&mut self, byte: u8) -> TestResult { + pub fn append_metadata_byte(&mut self, byte: MetadataByte) -> TestResult { let parts = self.parts.as_mut().ok_or("request parts not created")?; - parts.metadata_mut().push(byte); + parts.metadata_mut().push(byte.0); Ok(()) } @@ -50,10 +91,10 @@ impl RequestPartsWorld { /// /// # Errors /// Returns an error if parts are missing or id does not match. - pub fn assert_id(&self, expected: u32) -> TestResult { + pub fn assert_id(&self, expected: RequestId) -> TestResult { let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.id() != expected { - return Err(format!("expected id {expected}, got {}", parts.id()).into()); + if parts.id() != expected.0 { + return Err(format!("expected id {}, got {}", expected.0, parts.id()).into()); } Ok(()) } @@ -62,12 +103,13 @@ impl RequestPartsWorld { /// /// # Errors /// Returns an error if parts are missing or correlation id does not match. - pub fn assert_correlation_id(&self, expected: Option) -> TestResult { + pub fn assert_correlation_id(&self, expected: Option) -> TestResult { let parts = self.parts.as_ref().ok_or("request parts not created")?; - if parts.correlation_id() != expected { + let expected_raw = expected.map(|value| value.0); + if parts.correlation_id() != expected_raw { return Err(format!( "expected correlation_id {:?}, got {:?}", - expected, + expected_raw, parts.correlation_id() ) .into()); diff --git a/tests/steps/request_parts_steps.rs b/tests/steps/request_parts_steps.rs index 7f5cfd40..d77ee534 100644 --- a/tests/steps/request_parts_steps.rs +++ b/tests/steps/request_parts_steps.rs @@ -4,15 +4,25 @@ use rstest_bdd_macros::{given, then, when}; -use crate::fixtures::request_parts::{RequestPartsWorld, TestResult}; +use crate::fixtures::request_parts::{ + CorrelationId, + MetadataByte, + RequestId, + RequestPartsWorld, + TestResult, +}; #[given("request parts with id {id:u32} and correlation id {cid:u64}")] -fn given_parts_with_correlation(request_parts_world: &mut RequestPartsWorld, id: u32, cid: u64) { +fn given_parts_with_correlation( + request_parts_world: &mut RequestPartsWorld, + id: RequestId, + cid: CorrelationId, +) { request_parts_world.create_parts(id, Some(cid), vec![]); } #[given("request parts with id {id:u32} and no correlation id")] -fn given_parts_no_correlation(request_parts_world: &mut RequestPartsWorld, id: u32) { +fn given_parts_no_correlation(request_parts_world: &mut RequestPartsWorld, id: RequestId) { request_parts_world.create_parts(id, None, vec![]); } @@ -20,16 +30,16 @@ fn given_parts_no_correlation(request_parts_world: &mut RequestPartsWorld, id: u // Gherkin phrasing: scenarios that later add metadata use the shorter form, // while this form explicitly states the empty-metadata precondition. #[given("request parts with id {id:u32}, no correlation id, and empty metadata")] -fn given_parts_empty_metadata(request_parts_world: &mut RequestPartsWorld, id: u32) { +fn given_parts_empty_metadata(request_parts_world: &mut RequestPartsWorld, id: RequestId) { request_parts_world.create_parts(id, None, vec![]); } #[given("metadata bytes {b1:u8}, {b2:u8}, {b3:u8}")] fn given_metadata_bytes_three( request_parts_world: &mut RequestPartsWorld, - b1: u8, - b2: u8, - b3: u8, + b1: MetadataByte, + b2: MetadataByte, + b3: MetadataByte, ) -> TestResult { request_parts_world.append_metadata_byte(b1)?; request_parts_world.append_metadata_byte(b2)?; @@ -37,12 +47,18 @@ fn given_metadata_bytes_three( } #[given("metadata byte {byte:u8}")] -fn given_metadata_byte(request_parts_world: &mut RequestPartsWorld, byte: u8) -> TestResult { +fn given_metadata_byte( + request_parts_world: &mut RequestPartsWorld, + byte: MetadataByte, +) -> TestResult { request_parts_world.append_metadata_byte(byte) } #[when("inheriting correlation id {cid:u64}")] -fn when_inherit_correlation(request_parts_world: &mut RequestPartsWorld, cid: u64) -> TestResult { +fn when_inherit_correlation( + request_parts_world: &mut RequestPartsWorld, + cid: CorrelationId, +) -> TestResult { request_parts_world.inherit_correlation(Some(cid)) } @@ -52,19 +68,22 @@ fn when_inherit_no_correlation(request_parts_world: &mut RequestPartsWorld) -> T } #[when("appending byte {byte:u8} to metadata")] -fn when_append_metadata(request_parts_world: &mut RequestPartsWorld, byte: u8) -> TestResult { +fn when_append_metadata( + request_parts_world: &mut RequestPartsWorld, + byte: MetadataByte, +) -> TestResult { request_parts_world.append_metadata_byte(byte) } #[then("the request id is {expected:u32}")] -fn then_id_is(request_parts_world: &mut RequestPartsWorld, expected: u32) -> TestResult { +fn then_id_is(request_parts_world: &mut RequestPartsWorld, expected: RequestId) -> TestResult { request_parts_world.assert_id(expected) } #[then("the correlation id is {expected:u64}")] fn then_correlation_id_is( request_parts_world: &mut RequestPartsWorld, - expected: u64, + expected: CorrelationId, ) -> TestResult { request_parts_world.assert_correlation_id(Some(expected)) } From 53f0af854acac7185184ee1ae348b9b53da55d6d Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 21:59:30 +0000 Subject: [PATCH 30/45] Use BDD parameter newtypes for assembly Introduce message assembly step parameter wrappers with FromStr parsing and switch step definitions to the new types while preserving Gherkin patterns and behaviour. --- tests/steps/message_assembly_steps.rs | 200 ++++++++++++++++++-------- 1 file changed, 138 insertions(+), 62 deletions(-) diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index 3fbf27a7..895efc0d 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -1,6 +1,6 @@ //! Step definitions for message assembly multiplexing and continuity validation. -use std::fmt::Debug; +use std::{fmt::Debug, str::FromStr}; use rstest_bdd_macros::{given, then, when}; use wireframe::message_assembler::{FrameSequence, MessageKey}; @@ -12,6 +12,54 @@ use crate::fixtures::message_assembly::{ TestResult, }; +/// Wrapper for message key parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MessageKeyParam(pub u64); + +impl FromStr for MessageKeyParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(MessageKeyParam) } +} + +impl MessageKeyParam { + pub fn to_key(self) -> MessageKey { MessageKey(self.0) } +} + +/// Wrapper for sequence number parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SequenceParam(pub u32); + +impl FromStr for SequenceParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(SequenceParam) } +} + +impl SequenceParam { + pub fn to_seq(self) -> FrameSequence { FrameSequence(self.0) } +} + +/// Wrapper for count/size parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CountParam(pub usize); + +impl FromStr for CountParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(CountParam) } +} + +/// Wrapper for timeout duration parameters in BDD steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeoutParam(pub u64); + +impl FromStr for TimeoutParam { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { s.parse::().map(TimeoutParam) } +} + /// Convert primitive key to domain type at the boundary. fn to_key(key: u64) -> MessageKey { MessageKey(key) } @@ -101,38 +149,50 @@ fn assert_equals( // ============================================================================= #[given( - "a message assembly state with max size {max_size:usize} and timeout {timeout:u64} seconds" + "a message assembly state with max size {max_size:CountParam} and timeout \ + {timeout:TimeoutParam} seconds" )] -fn given_state(message_assembly_world: &mut MessageAssemblyWorld, max_size: usize, timeout: u64) { - let config = AssemblyConfig::new(max_size, timeout); +fn given_state( + message_assembly_world: &mut MessageAssemblyWorld, + max_size: CountParam, + timeout: TimeoutParam, +) { + let config = AssemblyConfig::new(max_size.0, timeout.0); message_assembly_world.create_state(config.max_message_size, config.timeout_seconds); } -#[given("a first frame for key {key:u64} with metadata {metadata:string} and body {body:string}")] +#[given( + "a first frame for key {key:MessageKeyParam} with metadata {metadata:string} and body \ + {body:string}" +)] fn given_first_frame_with_metadata( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, metadata: String, body: String, ) { message_assembly_world.add_first_frame( - FirstFrameParams::new(to_key(key), body.into_bytes()).with_metadata(metadata.into_bytes()), + FirstFrameParams::new(key.to_key(), body.into_bytes()).with_metadata(metadata.into_bytes()), ); } -#[given("a first frame for key {key:u64} with body {body:string}")] -fn given_first_frame(message_assembly_world: &mut MessageAssemblyWorld, key: u64, body: String) { - message_assembly_world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); +#[given("a first frame for key {key:MessageKeyParam} with body {body:string}")] +fn given_first_frame( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, + body: String, +) { + message_assembly_world.add_first_frame(FirstFrameParams::new(key.to_key(), body.into_bytes())); } -#[given("a final first frame for key {key:u64} with body {body:string}")] +#[given("a final first frame for key {key:MessageKeyParam} with body {body:string}")] fn given_final_first_frame( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, body: String, ) { message_assembly_world - .add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes()).final_frame()); + .add_first_frame(FirstFrameParams::new(key.to_key(), body.into_bytes()).final_frame()); } // ============================================================================= @@ -151,56 +211,64 @@ fn when_all_first_frames_accepted(message_assembly_world: &mut MessageAssemblyWo } #[when( - "a final continuation for key {key:u64} with sequence {sequence:u32} and body {body:string} \ - arrives" + "a final continuation for key {key:MessageKeyParam} with sequence {sequence:SequenceParam} \ + and body {body:string} arrives" )] fn when_final_continuation( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, - sequence: u32, + key: MessageKeyParam, + sequence: SequenceParam, body: String, ) -> TestResult { message_assembly_world.accept_continuation( - ContinuationFrameParams::new(to_key(key), body.into_bytes()) - .with_sequence(to_seq(sequence)) + ContinuationFrameParams::new(key.to_key(), body.into_bytes()) + .with_sequence(sequence.to_seq()) .final_frame(), ) } -#[when("a continuation for key {key:u64} with sequence {sequence:u32} arrives")] +#[when( + "a continuation for key {key:MessageKeyParam} with sequence {sequence:SequenceParam} arrives" +)] fn when_continuation_with_seq( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, - sequence: u32, + key: MessageKeyParam, + sequence: SequenceParam, ) -> TestResult { message_assembly_world.accept_continuation( - ContinuationFrameParams::new(to_key(key), b"data".to_vec()).with_sequence(to_seq(sequence)), + ContinuationFrameParams::new(key.to_key(), b"data".to_vec()) + .with_sequence(sequence.to_seq()), ) } -#[when("a continuation for key {key:u64} with body {body:string} arrives")] +#[when("a continuation for key {key:MessageKeyParam} with body {body:string} arrives")] fn when_continuation_with_body( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, body: String, ) -> TestResult { - message_assembly_world - .accept_continuation(ContinuationFrameParams::new(to_key(key), body.into_bytes())) + message_assembly_world.accept_continuation(ContinuationFrameParams::new( + key.to_key(), + body.into_bytes(), + )) } -#[when("another first frame for key {key:u64} with body {body:string} arrives")] +#[when("another first frame for key {key:MessageKeyParam} with body {body:string} arrives")] fn when_another_first_frame( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, body: String, ) -> TestResult { - message_assembly_world.add_first_frame(FirstFrameParams::new(to_key(key), body.into_bytes())); + message_assembly_world.add_first_frame(FirstFrameParams::new(key.to_key(), body.into_bytes())); message_assembly_world.accept_first_frame() } -#[when("time advances by {secs:u64} seconds")] -fn when_time_advances(message_assembly_world: &mut MessageAssemblyWorld, secs: u64) -> TestResult { - message_assembly_world.advance_time(secs) +#[when("time advances by {secs:TimeoutParam} seconds")] +fn when_time_advances( + message_assembly_world: &mut MessageAssemblyWorld, + secs: TimeoutParam, +) -> TestResult { + message_assembly_world.advance_time(secs.0) } #[when("expired assemblies are purged")] @@ -231,51 +299,56 @@ fn then_completes_with_body( assert_equals(&actual, &body.as_bytes(), "body mismatch") } -#[then("key {key:u64} completes with body {body:string}")] +#[then("key {key:MessageKeyParam} completes with body {body:string}")] fn then_key_completes( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, body: String, ) -> TestResult { let actual = message_assembly_world - .completed_body_for_key(to_key(key)) - .ok_or_else(|| format!("expected completed message for key {key}"))?; + .completed_body_for_key(key.to_key()) + .ok_or_else(|| format!("expected completed message for key {}", key.0))?; assert_equals( &actual, &body.as_bytes(), - format!("body mismatch for key {key}"), + format!("body mismatch for key {}", key.0), ) } -#[then("the buffered count is {count:usize}")] +#[then("the buffered count is {count:CountParam}")] fn then_buffered_count( message_assembly_world: &mut MessageAssemblyWorld, - count: usize, + count: CountParam, ) -> TestResult { let actual = message_assembly_world.buffered_count(); - assert_equals(&actual, &count, "buffered count mismatch") + assert_equals(&actual, &count.0, "buffered count mismatch") } -#[then("the error is sequence mismatch expecting {expected:u32} but found {found:u32}")] +#[then( + "the error is sequence mismatch expecting {expected:SequenceParam} but found \ + {found:SequenceParam}" +)] fn then_error_sequence_mismatch( message_assembly_world: &mut MessageAssemblyWorld, - expected: u32, - found: u32, + expected: SequenceParam, + found: SequenceParam, ) -> TestResult { assert_error( message_assembly_world, - |world| world.is_sequence_mismatch(to_seq(expected), to_seq(found)), + |world| world.is_sequence_mismatch(expected.to_seq(), found.to_seq()), "expected sequence mismatch error", ) } -#[then("the error is duplicate frame for key {key:u64} sequence {sequence:u32}")] +#[then( + "the error is duplicate frame for key {key:MessageKeyParam} sequence {sequence:SequenceParam}" +)] fn then_error_duplicate_frame( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, - sequence: u32, + key: MessageKeyParam, + sequence: SequenceParam, ) -> TestResult { - let frame_id = FrameId::new(key, sequence); + let frame_id = FrameId::new(key.0, sequence.0); assert_error( message_assembly_world, |world| world.is_duplicate_frame(frame_id), @@ -283,12 +356,12 @@ fn then_error_duplicate_frame( ) } -#[then("the error is missing first frame for key {key:u64}")] +#[then("the error is missing first frame for key {key:MessageKeyParam}")] fn then_error_missing_first_frame( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, ) -> TestResult { - let frame_id = FrameId::with_key(key); + let frame_id = FrameId::with_key(key.0); assert_error( message_assembly_world, |world| world.is_missing_first_frame(frame_id.key), @@ -296,35 +369,38 @@ fn then_error_missing_first_frame( ) } -#[then("the error is duplicate first frame for key {key:u64}")] +#[then("the error is duplicate first frame for key {key:MessageKeyParam}")] fn then_error_duplicate_first_frame( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, ) -> TestResult { assert_error( message_assembly_world, - |world| world.is_duplicate_first_frame(to_key(key)), + |world| world.is_duplicate_first_frame(key.to_key()), "expected duplicate first frame error", ) } -#[then("the error is message too large for key {key:u64}")] +#[then("the error is message too large for key {key:MessageKeyParam}")] fn then_error_message_too_large( message_assembly_world: &mut MessageAssemblyWorld, - key: u64, + key: MessageKeyParam, ) -> TestResult { assert_error( message_assembly_world, - |world| world.is_message_too_large(to_key(key)), + |world| world.is_message_too_large(key.to_key()), "expected message too large error", ) } -#[then("key {key:u64} was evicted")] -fn then_key_evicted(message_assembly_world: &mut MessageAssemblyWorld, key: u64) -> TestResult { +#[then("key {key:MessageKeyParam} was evicted")] +fn then_key_evicted( + message_assembly_world: &mut MessageAssemblyWorld, + key: MessageKeyParam, +) -> TestResult { assert_error( message_assembly_world, - |world| world.was_evicted(to_key(key)), - format!("expected key {key} to be evicted"), + |world| world.was_evicted(key.to_key()), + format!("expected key {} to be evicted", key.0), ) } From 4cdc2fcdba847cb96d8122451c0f249a0cb5cbcf Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 22:04:54 +0000 Subject: [PATCH 31/45] Unify message assembler field assertions Replace duplicated header field helpers with a single\nassertion routine for consistent error messaging.\nThis keeps header checks concise while preserving behaviour. --- tests/fixtures/message_assembler.rs | 63 +++++++++++++---------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs index 9b63b412..2204dfc4 100644 --- a/tests/fixtures/message_assembler.rs +++ b/tests/fixtures/message_assembler.rs @@ -79,30 +79,19 @@ impl fmt::Debug for MessageAssemblerWorld { pub fn message_assembler_world() -> MessageAssemblerWorld { MessageAssemblerWorld::default() } impl MessageAssemblerWorld { - fn assert_common_field(&self, field: &str, expected: &T, extractor: F) -> TestResult - where - T: PartialEq + fmt::Display + Copy, - F: FnOnce(&FrameHeader) -> T, - { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = extractor(parsed.header()); - if actual != *expected { - return Err(format!("expected {field} {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Generic helper for asserting header-type-specific fields. + /// Generic assertion helper for any header field. /// - /// The extractor performs both type-checking (via pattern matching) and field - /// extraction, returning an error message if the header type is incorrect. - fn assert_header_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult + /// The extractor returns `Result` to allow for both type-checking + /// and field extraction. For fields present in both header types, the extractor + /// should always succeed. For type-specific fields, the extractor can return an + /// error if the header type is incorrect. + fn assert_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult where T: PartialEq + fmt::Display + Copy, - F: FnOnce(&FrameHeader) -> Result, + F: FnOnce(&FrameHeader) -> Result, { let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = extractor(parsed.header()).map_err(ToString::to_string)?; + let actual = extractor(parsed.header())?; if actual != *expected { return Err(format!("expected {field_name} {expected}, got {actual}").into()); } @@ -241,9 +230,11 @@ impl MessageAssemblerWorld { /// /// Returns an error if no header was parsed or the key does not match. pub fn assert_message_key(&self, expected: u64) -> TestResult { - self.assert_common_field("key", &expected, |header| match header { - FrameHeader::First(header) => u64::from(header.message_key), - FrameHeader::Continuation(header) => u64::from(header.message_key), + self.assert_field("key", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => u64::from(header.message_key), + FrameHeader::Continuation(header) => u64::from(header.message_key), + }) }) } @@ -253,11 +244,11 @@ impl MessageAssemblerWorld { /// /// Returns an error if no header was parsed or the metadata length differs. pub fn assert_metadata_len(&self, expected: usize) -> TestResult { - self.assert_header_field("metadata length", &expected, |header| { + self.assert_field("metadata length", &expected, |header| { if let FrameHeader::First(header) = header { Ok(header.metadata_len) } else { - Err("expected first header") + Err("expected first header".to_string()) } }) } @@ -268,9 +259,11 @@ impl MessageAssemblerWorld { /// /// Returns an error if no header was parsed or the body length differs. pub fn assert_body_len(&self, expected: usize) -> TestResult { - self.assert_common_field("body length", &expected, |header| match header { - FrameHeader::First(header) => header.body_len, - FrameHeader::Continuation(header) => header.body_len, + self.assert_field("body length", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => header.body_len, + FrameHeader::Continuation(header) => header.body_len, + }) }) } @@ -281,11 +274,11 @@ impl MessageAssemblerWorld { /// Returns an error if no header was parsed or the total length differs. pub fn assert_total_len(&self, expected: Option) -> TestResult { let expected = DebugDisplay(expected); - self.assert_header_field("total length", &expected, |header| { + self.assert_field("total length", &expected, |header| { if let FrameHeader::First(header) = header { Ok(DebugDisplay(header.total_body_len)) } else { - Err("expected first header") + Err("expected first header".to_string()) } }) } @@ -298,11 +291,11 @@ impl MessageAssemblerWorld { pub fn assert_sequence(&self, expected: Option) -> TestResult { let expected = expected.map(FrameSequence::from); let expected = DebugDisplay(expected); - self.assert_header_field("sequence", &expected, |header| { + self.assert_field("sequence", &expected, |header| { if let FrameHeader::Continuation(header) = header { Ok(DebugDisplay(header.sequence)) } else { - Err("expected continuation header") + Err("expected continuation header".to_string()) } }) } @@ -313,9 +306,11 @@ impl MessageAssemblerWorld { /// /// Returns an error if no header was parsed or the flag differs. pub fn assert_is_last(&self, expected: bool) -> TestResult { - self.assert_common_field("is_last", &expected, |header| match header { - FrameHeader::First(header) => header.is_last, - FrameHeader::Continuation(header) => header.is_last, + self.assert_field("is_last", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => header.is_last, + FrameHeader::Continuation(header) => header.is_last, + }) }) } From d1780dbae8684956c811cd5b62fdfafd63325dd1 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 25 Jan 2026 22:08:24 +0000 Subject: [PATCH 32/45] Add header spec builders for tests Introduce fluent constructors for first and continuation\nheader specs to reduce setup duplication. Update\nmessage assembler step definitions to use the new\nbuilder pattern. --- tests/fixtures/message_assembler.rs | 55 +++++++++++++++++++++ tests/steps/message_assembler_steps.rs | 66 ++++---------------------- 2 files changed, 64 insertions(+), 57 deletions(-) diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs index 2204dfc4..c3b21e2b 100644 --- a/tests/fixtures/message_assembler.rs +++ b/tests/fixtures/message_assembler.rs @@ -32,6 +32,37 @@ pub struct FirstHeaderSpec { pub is_last: bool, } +impl FirstHeaderSpec { + /// Create a first header spec with default metadata and flags. + pub fn new(key: u64, body_len: usize) -> Self { + Self { + key, + metadata_len: 0, + body_len, + total_len: None, + is_last: false, + } + } + + /// Set the metadata length to encode into the header. + pub fn with_metadata_len(mut self, metadata_len: usize) -> Self { + self.metadata_len = metadata_len; + self + } + + /// Set the total message length to encode into the header. + pub fn with_total_len(mut self, total_len: usize) -> Self { + self.total_len = Some(total_len); + self + } + + /// Set whether the header should be marked as the final frame. + pub fn with_last_flag(mut self, is_last: bool) -> Self { + self.is_last = is_last; + self + } +} + /// Specification for continuation-frame header encoding used in tests. #[derive(Debug, Clone, Copy)] pub struct ContinuationHeaderSpec { @@ -45,6 +76,30 @@ pub struct ContinuationHeaderSpec { pub is_last: bool, } +impl ContinuationHeaderSpec { + /// Create a continuation header spec with default sequence and flags. + pub fn new(key: u64, body_len: usize) -> Self { + Self { + key, + body_len, + sequence: None, + is_last: false, + } + } + + /// Set the continuation sequence to encode into the header. + pub fn with_sequence(mut self, sequence: u32) -> Self { + self.sequence = Some(sequence); + self + } + + /// Set whether the header should be marked as the final frame. + pub fn with_last_flag(mut self, is_last: bool) -> Self { + self.is_last = is_last; + self + } +} + #[derive(Debug, Clone, Copy)] struct HeaderEnvelope { kind: u8, diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs index a22aaaea..56a70cb6 100644 --- a/tests/steps/message_assembler_steps.rs +++ b/tests/steps/message_assembler_steps.rs @@ -9,55 +9,6 @@ use crate::fixtures::message_assembler::{ TestResult, }; -const DEFAULT_METADATA_LEN: usize = 0; -const FLAG_NONE: bool = false; -const FLAG_LAST: bool = true; -const NO_SEQUENCE: Option = None; -const NO_TOTAL_LEN: Option = None; - -// Helper builders to reduce duplication in step definitions -fn first_header_without_total(key: u64, metadata_len: usize, body_len: usize) -> FirstHeaderSpec { - FirstHeaderSpec { - key, - metadata_len, - body_len, - total_len: NO_TOTAL_LEN, - is_last: FLAG_NONE, - } -} - -fn first_header_with_total(key: u64, body_len: usize, total_len: usize) -> FirstHeaderSpec { - FirstHeaderSpec { - key, - metadata_len: DEFAULT_METADATA_LEN, - body_len, - total_len: Some(total_len), - is_last: FLAG_LAST, - } -} - -fn continuation_header_with_sequence( - key: u64, - body_len: usize, - sequence: u32, -) -> ContinuationHeaderSpec { - ContinuationHeaderSpec { - key, - body_len, - sequence: Some(sequence), - is_last: FLAG_NONE, - } -} - -fn continuation_header_without_sequence(key: u64, body_len: usize) -> ContinuationHeaderSpec { - ContinuationHeaderSpec { - key, - body_len, - sequence: NO_SEQUENCE, - is_last: FLAG_LAST, - } -} - #[given( "a first frame header with key {key:u64} metadata length {metadata_len:usize} body length \ {body_len:usize}" @@ -68,11 +19,8 @@ fn given_first_header( metadata_len: usize, body_len: usize, ) -> TestResult { - message_assembler_world.set_first_header(first_header_without_total( - key, - metadata_len, - body_len, - )) + message_assembler_world + .set_first_header(FirstHeaderSpec::new(key, body_len).with_metadata_len(metadata_len)) } #[given( @@ -84,7 +32,11 @@ fn given_first_header_with_total( body_len: usize, total_len: usize, ) -> TestResult { - message_assembler_world.set_first_header(first_header_with_total(key, body_len, total_len)) + message_assembler_world.set_first_header( + FirstHeaderSpec::new(key, body_len) + .with_total_len(total_len) + .with_last_flag(true), + ) } #[given( @@ -97,7 +49,7 @@ fn given_continuation_header_with_sequence( sequence: u32, ) -> TestResult { message_assembler_world - .set_continuation_header(continuation_header_with_sequence(key, body_len, sequence)) + .set_continuation_header(ContinuationHeaderSpec::new(key, body_len).with_sequence(sequence)) } #[given("a continuation header with key {key:u64} body length {body_len:usize}")] @@ -107,7 +59,7 @@ fn given_continuation_header( body_len: usize, ) -> TestResult { message_assembler_world - .set_continuation_header(continuation_header_without_sequence(key, body_len)) + .set_continuation_header(ContinuationHeaderSpec::new(key, body_len).with_last_flag(true)) } #[given("a wireframe app with a message assembler")] From 5d7cbb5b52477a1f76bba0152188ba49790085f1 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 26 Jan 2026 08:10:34 +0000 Subject: [PATCH 33/45] Add header-specific assertion helpers Introduce specialised first/continuation header helpers to\ncentralise type checks and simplify assertion code.\nUpdate metadata/total/sequence checks to use them. --- tests/fixtures/message_assembler.rs | 59 +++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs index c3b21e2b..7e65d857 100644 --- a/tests/fixtures/message_assembler.rs +++ b/tests/fixtures/message_assembler.rs @@ -153,6 +153,41 @@ impl MessageAssemblerWorld { Ok(()) } + /// Assert a field specific to First headers. + fn assert_first_field(&self, field_name: &str, expected: &T, extractor: F) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&wireframe::message_assembler::FirstFrameHeader) -> T, + { + self.assert_field(field_name, expected, |header| { + if let FrameHeader::First(header) = header { + Ok(extractor(header)) + } else { + Err("expected first header".to_string()) + } + }) + } + + /// Assert a field specific to Continuation headers. + fn assert_continuation_field( + &self, + field_name: &str, + expected: &T, + extractor: F, + ) -> TestResult + where + T: PartialEq + fmt::Display + Copy, + F: FnOnce(&wireframe::message_assembler::ContinuationFrameHeader) -> T, + { + self.assert_field(field_name, expected, |header| { + if let FrameHeader::Continuation(header) = header { + Ok(extractor(header)) + } else { + Err("expected continuation header".to_string()) + } + }) + } + /// Store an encoded first-frame header in the world payload. /// /// # Errors @@ -299,13 +334,7 @@ impl MessageAssemblerWorld { /// /// Returns an error if no header was parsed or the metadata length differs. pub fn assert_metadata_len(&self, expected: usize) -> TestResult { - self.assert_field("metadata length", &expected, |header| { - if let FrameHeader::First(header) = header { - Ok(header.metadata_len) - } else { - Err("expected first header".to_string()) - } - }) + self.assert_first_field("metadata length", &expected, |header| header.metadata_len) } /// Assert that the parsed header contains the expected body length. @@ -329,12 +358,8 @@ impl MessageAssemblerWorld { /// Returns an error if no header was parsed or the total length differs. pub fn assert_total_len(&self, expected: Option) -> TestResult { let expected = DebugDisplay(expected); - self.assert_field("total length", &expected, |header| { - if let FrameHeader::First(header) = header { - Ok(DebugDisplay(header.total_body_len)) - } else { - Err("expected first header".to_string()) - } + self.assert_first_field("total length", &expected, |header| { + DebugDisplay(header.total_body_len) }) } @@ -346,12 +371,8 @@ impl MessageAssemblerWorld { pub fn assert_sequence(&self, expected: Option) -> TestResult { let expected = expected.map(FrameSequence::from); let expected = DebugDisplay(expected); - self.assert_field("sequence", &expected, |header| { - if let FrameHeader::Continuation(header) = header { - Ok(DebugDisplay(header.sequence)) - } else { - Err("expected continuation header".to_string()) - } + self.assert_continuation_field("sequence", &expected, |header| { + DebugDisplay(header.sequence) }) } From 4b9d8b6fab3cb2fcc77e06045b078cd2c9e7c663 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 18:04:06 +0000 Subject: [PATCH 34/45] Address BDD review feedback Apply review fixes across BDD fixtures and steps. - Deduplicate async runtime usage in step helpers - Remove blanket lint expectations and tidy fixtures - Update Makefile and execplan guidance to match new flow --- Makefile | 2 +- .../migrate-from-cucumber-to-rstest-bdd.md | 94 +++++------ tests/bdd/mod.rs | 3 +- tests/fixtures/client_lifecycle.rs | 7 +- tests/fixtures/client_messaging.rs | 8 +- tests/fixtures/client_preamble.rs | 7 +- tests/fixtures/client_runtime.rs | 8 +- tests/fixtures/codec_error/mod.rs | 8 +- tests/fixtures/codec_stateful.rs | 8 +- tests/fixtures/correlation.rs | 8 +- tests/fixtures/fragment/mod.rs | 8 +- tests/fixtures/message_assembler.rs | 147 +---------------- .../message_assembler_asserts.rs | 152 ++++++++++++++++++ tests/fixtures/message_assembly.rs | 36 +++-- tests/fixtures/multi_packet.rs | 8 +- tests/fixtures/panic.rs | 8 +- tests/fixtures/request_parts.rs | 10 +- tests/fixtures/stream_end.rs | 8 +- tests/scenarios/codec_stateful_scenarios.rs | 4 +- tests/scenarios/message_assembly_scenarios.rs | 45 +++--- tests/steps/client_runtime_steps.rs | 37 +++-- tests/steps/correlation_steps.rs | 5 +- tests/steps/message_assembly_steps.rs | 31 +--- 23 files changed, 344 insertions(+), 308 deletions(-) create mode 100644 tests/fixtures/message_assembler/message_assembler_asserts.rs diff --git a/Makefile b/Makefile index cdae9a96..c252820b 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ clean: ## Remove build artifacts test-bdd: ## Run rstest-bdd tests only RUSTFLAGS="-D warnings" $(CARGO) test --test bdd --all-features $(BUILD_JOBS) -test: test-bdd ## Run all tests (bdd + unit/integration) +test: ## Run all tests (bdd + unit/integration) RUSTFLAGS="-D warnings" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) # will match target/debug/libmy_library.rlib and target/release/libmy_library.rlib diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 366cef2d..bb1624fd 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -6,7 +6,11 @@ **Status**: Complete -**Last Updated**: 2026-01-25 +**Last Updated**: 2026-01-27 + +> [!IMPORTANT] +> Migration is complete. The Cucumber runner, worlds, and steps were removed +> on 2026-01-25. Any remaining Cucumber references are historical context only. ## Executive Summary @@ -158,18 +162,12 @@ disambiguating client preamble step text to avoid ambiguous step definitions). ```text tests/ - bdd/ # NEW: rstest-bdd tests - mod.rs - fixtures/ - mod.rs - steps/ - mod.rs - scenarios/ - mod.rs - cucumber.rs # KEEP: existing runner - features/ # KEEP: shared .feature files - worlds/ # KEEP: existing Cucumber worlds - steps/ # KEEP: existing Cucumber steps + bdd/ + mod.rs # rstest-bdd entrypoint + fixtures/ # rstest fixtures and test helpers + steps/ # rstest-bdd step definitions + scenarios/ # rstest-bdd scenario functions + features/ # shared `.feature` files ``` 3. Update `Cargo.toml` test configuration: @@ -181,9 +179,9 @@ disambiguating client preamble step text to avoid ambiguous step definitions). required-features = ["advanced-tests"] [[test]] - name = "cucumber" - path = "tests/cucumber.rs" - required-features = ["advanced-tests", "cucumber-tests"] + name = "concurrency_loom" + path = "tests/advanced/concurrency_loom.rs" + required-features = ["advanced-tests"] ``` 4. Update Makefile: @@ -193,15 +191,12 @@ disambiguating client preamble step text to avoid ambiguous step definitions). RUSTFLAGS="-D warnings" $(CARGO) test --test bdd \ --all-features $(BUILD_JOBS) - test-cucumber: ## Run Cucumber tests only - RUSTFLAGS="-D warnings" $(CARGO) test --test cucumber \ - --features cucumber-tests $(BUILD_JOBS) - - test: test-bdd test-cucumber ## Run all tests + test: ## Run all tests (bdd + unit/integration) + RUSTFLAGS="-D warnings" $(CARGO) test --all-targets \ + --all-features $(BUILD_JOBS) ``` -**Validation**: `make test-cucumber` still works, `make test-bdd` runs (empty -at first). +**Validation**: `make test-bdd` and `make test` both succeed. **Commit**: "Set up parallel rstest-bdd infrastructure" @@ -315,11 +310,8 @@ fn no_correlation(correlation_world: CorrelationWorld) { **Validation**: ```bash -# Compare outputs -cargo test --test cucumber correlation cargo test --test bdd correlation - -# Both should pass all scenarios +cargo test --test bdd request_parts ``` **Commits**: @@ -444,13 +436,11 @@ pub fn fragment_world() -> FragmentWorld { 1. **Comprehensive comparison**: ```bash - cargo test --test cucumber > cucumber-output.txt 2>&1 cargo test --test bdd > bdd-output.txt 2>&1 - # Compare scenario counts, all should pass + cargo test --all-targets --all-features > full-output.txt 2>&1 ``` - Result (2026-01-25): Cucumber and rstest-bdd both run 64 scenarios, all - passing. + Result (2026-01-27): rstest-bdd runs 65 scenarios and the full suite passes. 2. **Enable strict validation**: @@ -464,23 +454,19 @@ pub fn fragment_world() -> FragmentWorld { 3. **Performance check**: ```bash - hyperfine 'cargo test --test cucumber' \ - 'cargo test --test bdd' - # Should be within 10-20% + hyperfine 'cargo test --test bdd' ``` - Result (2026-01-25): Hyperfine shows cucumber ~923 ms mean and rstest-bdd - ~934 ms mean (within ~1%). + Result (2026-01-27): rstest-bdd completes within the historical baseline. 4. **Remove Cucumber infrastructure**: - - Delete `tests/cucumber.rs` - - Delete `tests/worlds/` - - Delete `tests/steps/` - - Remove `cucumber = "0.21.1"` from `Cargo.toml` - - Update Makefile: `test` → `test-bdd` only + - ✅ Delete `tests/cucumber.rs` + - ✅ Delete `tests/worlds/` + - ✅ Delete legacy Cucumber `tests/steps/` + - ✅ Remove `cucumber = "0.21.1"` from `Cargo.toml` + - ✅ Update Makefile targets for rstest-bdd - Completed 2026-01-25: removed runner, worlds, steps, dependency, and - Makefile target. + Completed 2026-01-25. 5. **Rename structure** (optional cleanup): @@ -493,6 +479,13 @@ pub fn fragment_world() -> FragmentWorld { Completed 2026-01-25: fixtures, steps, and scenarios now live under `tests/`. +### Historical baseline (pre-removal) + +- Prior to removing the Cucumber runner on 2026-01-25, both suites were run + side by side and passed the same scenario set. +- The last recorded comparison showed Cucumber at ~923 ms mean and rstest-bdd + at ~934 ms mean. + **Commits**: - "Enable strict compile-time validation" @@ -573,12 +566,12 @@ ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble - 12 files total MessageAssembler, MessageAssembly, CodecError, Fragment - 12 files total -### Phase 5 (Cleanup) +### Phase 5 (Cleanup) — completed 2026-01-25 -1. `Cargo.toml` - Remove cucumber dependency -2. `tests/cucumber.rs` - DELETE -3. `tests/worlds/` - DELETE (directory) -4. `tests/steps/` - DELETE (old Cucumber steps) +- ✅ `Cargo.toml`: remove Cucumber dependency +- ✅ `tests/cucumber.rs`: delete runner +- ✅ `tests/worlds/`: delete directory +- ✅ `tests/steps/`: delete legacy Cucumber steps ## Verification @@ -587,9 +580,8 @@ MessageAssembler, MessageAssembly, CodecError, Fragment - 12 files total After each phase: - [ ] All migrated scenarios pass: `cargo test --test bdd` -- [ ] Cucumber still works: `cargo test --test cucumber` - [ ] No compile warnings -- [ ] Output matches Cucumber behavior +- [ ] Output matches expected behaviour - [ ] Commit gateways pass (lint, format) ### Final Validation (Phase 5) @@ -598,7 +590,7 @@ After each phase: - [ ] Strict compile-time validation enabled - [ ] No undefined steps - [ ] No unused step definitions -- [ ] Performance within 10-20% of Cucumber +- [ ] Performance within the historical baseline - [ ] Cucumber infrastructure removed - [ ] CI pipeline updated - [ ] Documentation updated diff --git a/tests/bdd/mod.rs b/tests/bdd/mod.rs index b7ca78aa..096cc6e5 100644 --- a/tests/bdd/mod.rs +++ b/tests/bdd/mod.rs @@ -1,10 +1,11 @@ -#![cfg(not(loom))] //! rstest-bdd behavioural tests. //! //! This module contains the rstest-bdd-based BDD tests that replaced the //! former Cucumber test suite. These tests use the same `.feature` files but //! execute under the standard `cargo test` harness with rstest fixtures. +#![cfg(not(loom))] + // Re-export common utilities from the parent tests directory #[path = "../common/mod.rs"] pub mod common; diff --git a/tests/fixtures/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs index ec8be499..8cc58963 100644 --- a/tests/fixtures/client_lifecycle.rs +++ b/tests/fixtures/client_lifecycle.rs @@ -2,7 +2,6 @@ //! //! Provides server/client coordination for lifecycle hook scenarios. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] #![expect( clippy::expect_used, reason = "test code uses expect for concise assertions" @@ -85,8 +84,12 @@ impl Drop for ClientLifecycleWorld { } } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn client_lifecycle_world() -> ClientLifecycleWorld { ClientLifecycleWorld::default() } +pub fn client_lifecycle_world() -> ClientLifecycleWorld { + ClientLifecycleWorld::default() +} impl ClientLifecycleWorld { async fn spawn_server(&mut self, behaviour: F) -> TestResult diff --git a/tests/fixtures/client_messaging.rs b/tests/fixtures/client_messaging.rs index 65865964..65c82903 100644 --- a/tests/fixtures/client_messaging.rs +++ b/tests/fixtures/client_messaging.rs @@ -2,8 +2,6 @@ //! //! Provides server/client coordination for correlation-aware message APIs. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::net::SocketAddr; use bytes::Bytes; @@ -41,8 +39,12 @@ pub struct ClientMessagingWorld { expected_payload: Option, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn client_messaging_world() -> ClientMessagingWorld { ClientMessagingWorld::default() } +pub fn client_messaging_world() -> ClientMessagingWorld { + ClientMessagingWorld::default() +} impl ClientMessagingWorld { /// Start an envelope echo server. diff --git a/tests/fixtures/client_preamble.rs b/tests/fixtures/client_preamble.rs index bb6d3e5f..ce69253a 100644 --- a/tests/fixtures/client_preamble.rs +++ b/tests/fixtures/client_preamble.rs @@ -2,7 +2,6 @@ //! //! Provides server/client coordination for preamble exchange scenarios. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] #![expect( clippy::expect_used, reason = "test code uses expect for concise assertions" @@ -90,8 +89,12 @@ pub struct ClientPreambleWorld { last_error: Option, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn client_preamble_world() -> ClientPreambleWorld { ClientPreambleWorld::default() } +pub fn client_preamble_world() -> ClientPreambleWorld { + ClientPreambleWorld::default() +} impl ClientPreambleWorld { /// Start a preamble-aware echo server. diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 4fd21913..279b62f0 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -3,8 +3,6 @@ //! Provides an echo server/client pair to validate client runtime framing //! behaviour. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::net::SocketAddr; use futures::{SinkExt, StreamExt}; @@ -37,8 +35,12 @@ struct ClientPayload { data: Vec, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn client_runtime_world() -> ClientRuntimeWorld { ClientRuntimeWorld::default() } +pub fn client_runtime_world() -> ClientRuntimeWorld { + ClientRuntimeWorld::default() +} impl ClientRuntimeWorld { /// Start an echo server with the specified maximum frame length. diff --git a/tests/fixtures/codec_error/mod.rs b/tests/fixtures/codec_error/mod.rs index 0fcda3fb..e6898a86 100644 --- a/tests/fixtures/codec_error/mod.rs +++ b/tests/fixtures/codec_error/mod.rs @@ -2,8 +2,6 @@ //! //! Verifies codec error taxonomy and recovery policy defaults. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - mod decoder_ops; use bytes::BytesMut; @@ -66,8 +64,12 @@ pub struct CodecErrorWorld { pub(crate) clean_close_detected: bool, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn codec_error_world() -> CodecErrorWorld { CodecErrorWorld::default() } +pub fn codec_error_world() -> CodecErrorWorld { + CodecErrorWorld::default() +} impl CodecErrorWorld { /// Set the current error type being tested. diff --git a/tests/fixtures/codec_stateful.rs b/tests/fixtures/codec_stateful.rs index f5496bb0..3bb5b0bf 100644 --- a/tests/fixtures/codec_stateful.rs +++ b/tests/fixtures/codec_stateful.rs @@ -3,8 +3,6 @@ //! Ensures per-connection codec state is isolated so sequence numbers reset //! between client connections. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::{ net::SocketAddr, sync::atomic::{AtomicU64, Ordering}, @@ -179,8 +177,12 @@ pub struct CodecStatefulWorld { second_sequences: Vec, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn codec_stateful_world() -> CodecStatefulWorld { CodecStatefulWorld::default() } +pub fn codec_stateful_world() -> CodecStatefulWorld { + CodecStatefulWorld::default() +} impl CodecStatefulWorld { /// Start a server using the sequence-aware codec. diff --git a/tests/fixtures/correlation.rs b/tests/fixtures/correlation.rs index a37c077e..12ec58fb 100644 --- a/tests/fixtures/correlation.rs +++ b/tests/fixtures/correlation.rs @@ -4,8 +4,6 @@ //! remain largely unchanged; only the trait derivation and fixture function are //! added. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use async_stream::try_stream; use rstest::fixture; use tokio::sync::mpsc; @@ -29,8 +27,12 @@ pub struct CorrelationWorld { frames: Vec, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn correlation_world() -> CorrelationWorld { CorrelationWorld::default() } +pub fn correlation_world() -> CorrelationWorld { + CorrelationWorld::default() +} impl CorrelationWorld { /// Record the correlation identifier expected on emitted frames. diff --git a/tests/fixtures/fragment/mod.rs b/tests/fixtures/fragment/mod.rs index 51c74a92..2862a886 100644 --- a/tests/fixtures/fragment/mod.rs +++ b/tests/fixtures/fragment/mod.rs @@ -2,8 +2,6 @@ //! //! Tracks fragmentation and reassembly state for fragment scenarios. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - mod reassembly; use std::{num::NonZeroUsize, time::Instant}; @@ -57,8 +55,12 @@ impl Default for FragmentWorld { } } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn fragment_world() -> FragmentWorld { FragmentWorld::default() } +pub fn fragment_world() -> FragmentWorld { + FragmentWorld::default() +} impl FragmentWorld { /// Start tracking a new logical message. diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs index 7e65d857..58007ab2 100644 --- a/tests/fixtures/message_assembler.rs +++ b/tests/fixtures/message_assembler.rs @@ -2,14 +2,12 @@ //! //! Provides header parsing helpers for message assembler scenarios. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::{fmt, io}; use bytes::{BufMut, BytesMut}; use rstest::fixture; use wireframe::{ - message_assembler::{FrameHeader, FrameSequence, MessageAssembler, ParsedFrameHeader}, + message_assembler::{FrameHeader, MessageAssembler, ParsedFrameHeader}, test_helpers::TestAssembler, }; @@ -130,8 +128,12 @@ impl fmt::Debug for MessageAssemblerWorld { } } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn message_assembler_world() -> MessageAssemblerWorld { MessageAssemblerWorld::default() } +pub fn message_assembler_world() -> MessageAssemblerWorld { + MessageAssemblerWorld::default() +} impl MessageAssemblerWorld { /// Generic assertion helper for any header field. @@ -313,141 +315,6 @@ impl MessageAssemblerWorld { Err(format!("expected {expected} header").into()) } } - - /// Assert that the parsed header contains the expected message key. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the key does not match. - pub fn assert_message_key(&self, expected: u64) -> TestResult { - self.assert_field("key", &expected, |header| { - Ok(match header { - FrameHeader::First(header) => u64::from(header.message_key), - FrameHeader::Continuation(header) => u64::from(header.message_key), - }) - }) - } - - /// Assert that the parsed header contains the expected metadata length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the metadata length differs. - pub fn assert_metadata_len(&self, expected: usize) -> TestResult { - self.assert_first_field("metadata length", &expected, |header| header.metadata_len) - } - - /// Assert that the parsed header contains the expected body length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the body length differs. - pub fn assert_body_len(&self, expected: usize) -> TestResult { - self.assert_field("body length", &expected, |header| { - Ok(match header { - FrameHeader::First(header) => header.body_len, - FrameHeader::Continuation(header) => header.body_len, - }) - }) - } - - /// Assert that the parsed header contains the expected total body length. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the total length differs. - pub fn assert_total_len(&self, expected: Option) -> TestResult { - let expected = DebugDisplay(expected); - self.assert_first_field("total length", &expected, |header| { - DebugDisplay(header.total_body_len) - }) - } - - /// Assert that the parsed header contains the expected sequence. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the sequence differs. - pub fn assert_sequence(&self, expected: Option) -> TestResult { - let expected = expected.map(FrameSequence::from); - let expected = DebugDisplay(expected); - self.assert_continuation_field("sequence", &expected, |header| { - DebugDisplay(header.sequence) - }) - } - - /// Assert that the parsed header matches the expected `is_last` flag. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the flag differs. - pub fn assert_is_last(&self, expected: bool) -> TestResult { - self.assert_field("is_last", &expected, |header| { - Ok(match header { - FrameHeader::First(header) => header.is_last, - FrameHeader::Continuation(header) => header.is_last, - }) - }) - } - - /// Assert that the parsed header length matches the expected value. - /// - /// # Errors - /// - /// Returns an error if no header was parsed or the length differs. - pub fn assert_header_len(&self, expected: usize) -> TestResult { - let parsed = self.parsed.as_ref().ok_or("no parsed header")?; - let actual = parsed.header_len(); - if actual != expected { - return Err(format!("expected header length {expected}, got {actual}").into()); - } - Ok(()) - } - - /// Assert that the parse failed with `InvalidData`. - /// - /// # Errors - /// - /// Returns an error if no parse error was captured or the kind differs. - pub fn assert_invalid_data_error(&self) -> TestResult { - let err = self.error.as_ref().ok_or("expected error")?; - if err.kind() != io::ErrorKind::InvalidData { - return Err(format!("expected InvalidData error, got {:?}", err.kind()).into()); - } - Ok(()) - } - - /// Store a wireframe app configured with a test message assembler. - /// - /// # Errors - /// - /// Returns an error if the app builder fails. - pub fn set_app_with_message_assembler(&mut self) -> TestResult { - let app = TestApp::new() - .map_err(|err| format!("failed to build app: {err}"))? - .with_message_assembler(TestAssembler); - self.app = Some(app); - Ok(()) - } - - /// Assert that the app exposes a message assembler. - /// - /// # Errors - /// - /// Returns an error if the app or assembler is missing. - pub fn assert_message_assembler_configured(&self) -> TestResult { - let app = self.app.as_ref().ok_or("app not set")?; - if app.message_assembler().is_some() { - Ok(()) - } else { - Err("expected message assembler".into()) - } - } } -#[derive(Clone, Copy, Debug, PartialEq)] -struct DebugDisplay(T); - -impl fmt::Display for DebugDisplay { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } -} +mod message_assembler_asserts; diff --git a/tests/fixtures/message_assembler/message_assembler_asserts.rs b/tests/fixtures/message_assembler/message_assembler_asserts.rs new file mode 100644 index 00000000..84260030 --- /dev/null +++ b/tests/fixtures/message_assembler/message_assembler_asserts.rs @@ -0,0 +1,152 @@ +//! Assertion helpers for `MessageAssemblerWorld`. +//! +//! This module keeps the fixture file under the 400 line guideline while +//! preserving a cohesive set of assertion helpers. + +use std::{fmt, io}; + +use wireframe::{ + message_assembler::{FrameHeader, FrameSequence}, + test_helpers::TestAssembler, +}; + +use super::{MessageAssemblerWorld, TestApp, TestResult}; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct DebugDisplay(T); + +impl fmt::Display for DebugDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } +} + +impl MessageAssemblerWorld { + /// Assert that the parsed header contains the expected message key. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the key does not match. + pub fn assert_message_key(&self, expected: u64) -> TestResult { + self.assert_field("key", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => u64::from(header.message_key), + FrameHeader::Continuation(header) => u64::from(header.message_key), + }) + }) + } + + /// Assert that the parsed header contains the expected metadata length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the metadata length differs. + pub fn assert_metadata_len(&self, expected: usize) -> TestResult { + self.assert_first_field("metadata length", &expected, |header| header.metadata_len) + } + + /// Assert that the parsed header contains the expected body length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the body length differs. + pub fn assert_body_len(&self, expected: usize) -> TestResult { + self.assert_field("body length", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => header.body_len, + FrameHeader::Continuation(header) => header.body_len, + }) + }) + } + + /// Assert that the parsed header contains the expected total body length. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the total length differs. + pub fn assert_total_len(&self, expected: Option) -> TestResult { + let expected = DebugDisplay(expected); + self.assert_first_field("total length", &expected, |header| { + DebugDisplay(header.total_body_len) + }) + } + + /// Assert that the parsed header contains the expected sequence. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the sequence differs. + pub fn assert_sequence(&self, expected: Option) -> TestResult { + let expected = expected.map(FrameSequence::from); + let expected = DebugDisplay(expected); + self.assert_continuation_field("sequence", &expected, |header| { + DebugDisplay(header.sequence) + }) + } + + /// Assert that the parsed header matches the expected `is_last` flag. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the flag differs. + pub fn assert_is_last(&self, expected: bool) -> TestResult { + self.assert_field("is_last", &expected, |header| { + Ok(match header { + FrameHeader::First(header) => header.is_last, + FrameHeader::Continuation(header) => header.is_last, + }) + }) + } + + /// Assert that the parsed header length matches the expected value. + /// + /// # Errors + /// + /// Returns an error if no header was parsed or the length differs. + pub fn assert_header_len(&self, expected: usize) -> TestResult { + let parsed = self.parsed.as_ref().ok_or("no parsed header")?; + let actual = parsed.header_len(); + if actual != expected { + return Err(format!("expected header length {expected}, got {actual}").into()); + } + Ok(()) + } + + /// Assert that the parse failed with `InvalidData`. + /// + /// # Errors + /// + /// Returns an error if no parse error was captured or the kind differs. + pub fn assert_invalid_data_error(&self) -> TestResult { + let err = self.error.as_ref().ok_or("expected error")?; + if err.kind() != io::ErrorKind::InvalidData { + return Err(format!("expected InvalidData error, got {:?}", err.kind()).into()); + } + Ok(()) + } + + /// Store a wireframe app configured with a test message assembler. + /// + /// # Errors + /// + /// Returns an error if the app builder fails. + pub fn set_app_with_message_assembler(&mut self) -> TestResult { + let app = TestApp::new() + .map_err(|err| format!("failed to build app: {err}"))? + .with_message_assembler(TestAssembler); + self.app = Some(app); + Ok(()) + } + + /// Assert that the app exposes a message assembler. + /// + /// # Errors + /// + /// Returns an error if the app or assembler is missing. + pub fn assert_message_assembler_configured(&self) -> TestResult { + let app = self.app.as_ref().ok_or("app not set")?; + if app.message_assembler().is_some() { + Ok(()) + } else { + Err("expected message assembler".into()) + } + } +} diff --git a/tests/fixtures/message_assembly.rs b/tests/fixtures/message_assembly.rs index 68d2348e..3992f093 100644 --- a/tests/fixtures/message_assembly.rs +++ b/tests/fixtures/message_assembly.rs @@ -2,8 +2,6 @@ //! //! Provides state and helpers for message assembly multiplexing scenarios. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - #[path = "message_assembly_params.rs"] mod message_assembly_params; @@ -32,6 +30,22 @@ use wireframe::message_assembler::{ pub use crate::common::TestResult; use crate::scenarios::steps::FrameId; +/// Configuration for message assembly state initialisation. +#[derive(Debug, Clone, Copy)] +pub struct AssemblyConfig { + pub max_message_size: usize, + pub timeout_seconds: u64, +} + +impl AssemblyConfig { + pub fn new(max_message_size: usize, timeout_seconds: u64) -> Self { + Self { + max_message_size, + timeout_seconds, + } + } +} + /// Test world for message assembly multiplexing scenarios. #[derive(Default)] pub struct MessageAssemblyWorld { @@ -69,23 +83,27 @@ impl fmt::Debug for MessageAssemblyWorld { } } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn message_assembly_world() -> MessageAssemblyWorld { MessageAssemblyWorld::default() } +pub fn message_assembly_world() -> MessageAssemblyWorld { + MessageAssemblyWorld::default() +} impl MessageAssemblyWorld { /// Initialise the assembly state with size limit and timeout. /// /// # Panics /// - /// Panics if `max_size` is zero because `MessageAssemblyState` requires a - /// positive size limit. - pub fn create_state(&mut self, max_size: usize, timeout_secs: u64) { - let Some(size) = NonZeroUsize::new(max_size) else { - panic!("max_size must be non-zero for MessageAssemblyState"); + /// Panics if `max_message_size` is zero because `MessageAssemblyState` + /// requires a positive size limit. + pub fn create_state(&mut self, config: AssemblyConfig) { + let Some(size) = NonZeroUsize::new(config.max_message_size) else { + panic!("max_message_size must be non-zero for MessageAssemblyState"); }; self.state = Some(MessageAssemblyState::new( size, - Duration::from_secs(timeout_secs), + Duration::from_secs(config.timeout_seconds), )); self.current_time = Some(Instant::now()); self.pending_first_frames.clear(); diff --git a/tests/fixtures/multi_packet.rs b/tests/fixtures/multi_packet.rs index c32e9fcc..804f3f34 100644 --- a/tests/fixtures/multi_packet.rs +++ b/tests/fixtures/multi_packet.rs @@ -3,8 +3,6 @@ //! Provides test fixtures to verify message ordering, back-pressure handling, //! and channel lifecycle. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::{error::Error, fmt}; use rstest::fixture; @@ -32,8 +30,12 @@ pub struct MultiPacketWorld { is_overflow_error: bool, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn multi_packet_world() -> MultiPacketWorld { MultiPacketWorld::default() } +pub fn multi_packet_world() -> MultiPacketWorld { + MultiPacketWorld::default() +} impl MultiPacketWorld { async fn collect_frames_from(rx: mpsc::Receiver) -> TestResult> { diff --git a/tests/fixtures/panic.rs b/tests/fixtures/panic.rs index 1ea55c13..84087a9f 100644 --- a/tests/fixtures/panic.rs +++ b/tests/fixtures/panic.rs @@ -3,8 +3,6 @@ //! Provides test fixtures to ensure the server remains resilient when //! connection setup handlers panic before a client fully connects. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::net::SocketAddr; use rstest::fixture; @@ -85,8 +83,12 @@ pub struct PanicWorld { attempts: usize, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn panic_world() -> PanicWorld { PanicWorld::default() } +pub fn panic_world() -> PanicWorld { + PanicWorld::default() +} impl PanicWorld { /// Start a server that panics during connection setup. diff --git a/tests/fixtures/request_parts.rs b/tests/fixtures/request_parts.rs index 2aeec729..c9d8c667 100644 --- a/tests/fixtures/request_parts.rs +++ b/tests/fixtures/request_parts.rs @@ -3,8 +3,6 @@ //! Converted from Cucumber World to rstest fixture. The struct and its methods //! remain unchanged; only the trait derivation and fixture function are added. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::str::FromStr; use rstest::fixture; @@ -43,14 +41,18 @@ impl FromStr for MetadataByte { fn from_str(value: &str) -> Result { value.parse().map(MetadataByte) } } -#[derive(Debug, Default)] /// Test world exercising `RequestParts` metadata handling. +#[derive(Debug, Default)] pub struct RequestPartsWorld { parts: Option, } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn request_parts_world() -> RequestPartsWorld { RequestPartsWorld::default() } +pub fn request_parts_world() -> RequestPartsWorld { + RequestPartsWorld::default() +} impl RequestPartsWorld { /// Create request parts with all fields specified. diff --git a/tests/fixtures/stream_end.rs b/tests/fixtures/stream_end.rs index b3ef25c3..dc2a2dc2 100644 --- a/tests/fixtures/stream_end.rs +++ b/tests/fixtures/stream_end.rs @@ -3,8 +3,6 @@ //! Provides test fixtures to verify terminator frames and multi-packet //! termination logging for streaming responses. -#![expect(unused_braces, reason = "rustfmt forces single-line fixture functions")] - use std::{mem, sync::Arc}; use async_stream::try_stream; @@ -233,5 +231,9 @@ impl StreamEndWorld { } } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] -pub fn stream_end_world() -> StreamEndWorld { StreamEndWorld::default() } +pub fn stream_end_world() -> StreamEndWorld { + StreamEndWorld::default() +} diff --git a/tests/scenarios/codec_stateful_scenarios.rs b/tests/scenarios/codec_stateful_scenarios.rs index ef3f9523..4a396c71 100644 --- a/tests/scenarios/codec_stateful_scenarios.rs +++ b/tests/scenarios/codec_stateful_scenarios.rs @@ -8,6 +8,4 @@ use crate::fixtures::codec_stateful::*; path = "tests/features/codec_stateful.feature", name = "Sequence counters reset per connection" )] -fn sequence_counters_reset(codec_stateful_world: CodecStatefulWorld) { - let _ = codec_stateful_world; -} +fn sequence_counters_reset(codec_stateful_world: CodecStatefulWorld) { drop(codec_stateful_world); } diff --git a/tests/scenarios/message_assembly_scenarios.rs b/tests/scenarios/message_assembly_scenarios.rs index cbde22b5..c475f917 100644 --- a/tests/scenarios/message_assembly_scenarios.rs +++ b/tests/scenarios/message_assembly_scenarios.rs @@ -8,79 +8,70 @@ use crate::fixtures::message_assembly::*; path = "tests/features/message_assembly.feature", name = "Single message assembly completes successfully" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_single_message(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_single_message(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Single-frame message completes immediately" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_single_frame(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_single_frame(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Interleaved messages assemble independently" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_interleaved(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_interleaved(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Out-of-order continuation is rejected but assembly retained" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_out_of_order(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_out_of_order(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Duplicate continuation is rejected but assembly retained" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_duplicate_continuation(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_duplicate_continuation(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Continuation without first frame is rejected" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_missing_first(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_missing_first(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Duplicate first frame is rejected" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_duplicate_first(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_duplicate_first(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Message exceeding size limit is rejected" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_too_large(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_too_large(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } #[scenario( path = "tests/features/message_assembly.feature", name = "Expired assemblies are purged" )] -#[tokio::test(flavor = "current_thread")] -async fn message_assembly_expired(message_assembly_world: MessageAssemblyWorld) { - let _ = message_assembly_world; +fn message_assembly_expired(message_assembly_world: MessageAssemblyWorld) { + drop(message_assembly_world); } diff --git a/tests/steps/client_runtime_steps.rs b/tests/steps/client_runtime_steps.rs index 165c7b0f..4d505128 100644 --- a/tests/steps/client_runtime_steps.rs +++ b/tests/steps/client_runtime_steps.rs @@ -1,16 +1,34 @@ //! Step definitions for wireframe client runtime behavioural tests. +use std::{future::Future, sync::OnceLock}; + use rstest_bdd_macros::{given, then, when}; +use tokio::runtime::Runtime; use crate::fixtures::client_runtime::{ClientRuntimeWorld, TestResult}; +static RUNTIME: OnceLock> = OnceLock::new(); + +fn runtime() -> TestResult<&'static Runtime> { + match RUNTIME.get_or_init(|| Runtime::new().map_err(|err| err.to_string())) { + Ok(runtime) => Ok(runtime), + Err(err) => Err(err.clone().into()), + } +} + +fn block_on(fut: Fut) -> TestResult +where + Fut: Future, +{ + runtime()?.block_on(fut) +} + #[given("a wireframe echo server allowing frames up to {max_frame_length:usize} bytes")] fn given_server( client_runtime_world: &mut ClientRuntimeWorld, max_frame_length: usize, ) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(client_runtime_world.start_server(max_frame_length)) + block_on(client_runtime_world.start_server(max_frame_length)) } #[given("a wireframe client configured with max frame length {max_frame_length:usize}")] @@ -18,14 +36,12 @@ fn given_client( client_runtime_world: &mut ClientRuntimeWorld, max_frame_length: usize, ) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(client_runtime_world.connect_client(max_frame_length)) + block_on(client_runtime_world.connect_client(max_frame_length)) } #[when("the client sends a payload of {size:usize} bytes")] fn when_send_payload(client_runtime_world: &mut ClientRuntimeWorld, size: usize) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(client_runtime_world.send_payload(size)) + block_on(client_runtime_world.send_payload(size)) } #[when("the client sends an oversized payload of {size:usize} bytes")] @@ -33,18 +49,15 @@ fn when_send_oversized_payload( client_runtime_world: &mut ClientRuntimeWorld, size: usize, ) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(client_runtime_world.send_payload_expect_error(size)) + block_on(client_runtime_world.send_payload_expect_error(size)) } #[then("the client receives the echoed payload")] fn then_receives_echo(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(client_runtime_world.verify_echo()) + block_on(client_runtime_world.verify_echo()) } #[then("the client reports a framing error")] fn then_reports_error(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(client_runtime_world.verify_error()) + block_on(client_runtime_world.verify_error()) } diff --git a/tests/steps/correlation_steps.rs b/tests/steps/correlation_steps.rs index 61c80227..3879111a 100644 --- a/tests/steps/correlation_steps.rs +++ b/tests/steps/correlation_steps.rs @@ -1,8 +1,7 @@ //! Step definitions for `correlation_id` behavioural tests. //! -//! Steps are synchronous but call async World methods via -//! `Handle::current().block_on()` (`current_thread` runtime doesn't support -//! `block_in_place`). +//! Steps are synchronous but call async world methods via +//! `Runtime::new().block_on(...)`. use rstest_bdd_macros::{given, then, when}; diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index 895efc0d..ef80b5a9 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -6,6 +6,7 @@ use rstest_bdd_macros::{given, then, when}; use wireframe::message_assembler::{FrameSequence, MessageKey}; use crate::fixtures::message_assembly::{ + AssemblyConfig, ContinuationFrameParams, FirstFrameParams, MessageAssemblyWorld, @@ -60,28 +61,6 @@ impl FromStr for TimeoutParam { fn from_str(s: &str) -> Result { s.parse::().map(TimeoutParam) } } -/// Convert primitive key to domain type at the boundary. -fn to_key(key: u64) -> MessageKey { MessageKey(key) } - -/// Convert primitive sequence to domain type at the boundary. -fn to_seq(seq: u32) -> FrameSequence { FrameSequence(seq) } - -/// Configuration for message assembly state initialisation. -#[derive(Debug, Clone)] -pub struct AssemblyConfig { - pub max_message_size: usize, - pub timeout_seconds: u64, -} - -impl AssemblyConfig { - pub fn new(max_message_size: usize, timeout_seconds: u64) -> Self { - Self { - max_message_size, - timeout_seconds, - } - } -} - /// Frame identification combining key and optional sequence. #[derive(Debug, Clone, Copy)] pub struct FrameId { @@ -92,14 +71,14 @@ pub struct FrameId { impl FrameId { pub fn new(key: u64, sequence: u32) -> Self { Self { - key: to_key(key), - sequence: to_seq(sequence), + key: MessageKey(key), + sequence: FrameSequence(sequence), } } pub fn with_key(key: u64) -> Self { Self { - key: to_key(key), + key: MessageKey(key), sequence: FrameSequence(0), } } @@ -158,7 +137,7 @@ fn given_state( timeout: TimeoutParam, ) { let config = AssemblyConfig::new(max_size.0, timeout.0); - message_assembly_world.create_state(config.max_message_size, config.timeout_seconds); + message_assembly_world.create_state(config); } #[given( From 13a2d6b08327559e51ad608ebbc52e09b121f8e3 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 18:35:15 +0000 Subject: [PATCH 35/45] Reuse runtime in client runtime world Store a Tokio runtime on ClientRuntimeWorld and run client operations synchronously through it. This keeps the server task alive across steps and removes per-step runtime creation in the BDD step definitions. --- tests/fixtures/client_runtime.rs | 130 +++++++++++++++++++--------- tests/steps/client_runtime_steps.rs | 31 ++----- 2 files changed, 93 insertions(+), 68 deletions(-) diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 279b62f0..9144758a 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -3,7 +3,10 @@ //! Provides an echo server/client pair to validate client runtime framing //! behaviour. -use std::net::SocketAddr; +use std::{ + cell::{Cell, RefCell}, + net::SocketAddr, +}; use futures::{SinkExt, StreamExt}; use log::warn; @@ -20,14 +23,34 @@ use wireframe::{ pub use crate::common::TestResult; /// Test world exercising the wireframe client runtime. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ClientRuntimeWorld { - addr: Option, - server: Option>, - client: Option>>, - payload: Option, - response: Option, - last_error: Option, + runtime: tokio::runtime::Runtime, + addr: Cell>, + server: RefCell>>, + client: + RefCell>>>, + payload: RefCell>, + response: RefCell>, + last_error: RefCell>, +} + +impl Default for ClientRuntimeWorld { + fn default() -> Self { + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(err) => panic!("failed to create runtime: {err}"), + }; + Self { + runtime, + addr: Cell::new(None), + server: RefCell::new(None), + client: RefCell::new(None), + payload: RefCell::new(None), + response: RefCell::new(None), + last_error: RefCell::new(None), + } + } } #[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] @@ -47,10 +70,12 @@ impl ClientRuntimeWorld { /// /// # Errors /// Returns an error if binding or spawning the server fails. - pub async fn start_server(&mut self, max_frame_length: usize) -> TestResult { - let listener = TcpListener::bind("127.0.0.1:0").await?; + pub fn start_server(&self, max_frame_length: usize) -> TestResult { + let listener = self + .runtime + .block_on(async { TcpListener::bind("127.0.0.1:0").await })?; let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { + let handle = self.runtime.spawn(async move { let Ok((stream, _)) = listener.accept().await else { warn!("client runtime server failed to accept connection"); return; @@ -72,8 +97,8 @@ impl ClientRuntimeWorld { } }); - self.addr = Some(addr); - self.server = Some(handle); + self.addr.set(Some(addr)); + *self.server.borrow_mut() = Some(handle); Ok(()) } @@ -81,14 +106,16 @@ impl ClientRuntimeWorld { /// /// # Errors /// Returns an error if the server has not started or the client fails to connect. - pub async fn connect_client(&mut self, max_frame_length: usize) -> TestResult { - let addr = self.addr.ok_or("server address missing")?; + pub fn connect_client(&self, max_frame_length: usize) -> TestResult { + let addr = self.addr.get().ok_or("server address missing")?; let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); - let client = WireframeClient::builder() - .codec_config(codec_config) - .connect(addr) - .await?; - self.client = Some(client); + let client = self.runtime.block_on(async { + WireframeClient::builder() + .codec_config(codec_config) + .connect(addr) + .await + })?; + *self.client.borrow_mut() = Some(client); Ok(()) } @@ -96,15 +123,22 @@ impl ClientRuntimeWorld { /// /// # Errors /// Returns an error if the client is missing or communication fails. - pub async fn send_payload(&mut self, size: usize) -> TestResult { + pub fn send_payload(&self, size: usize) -> TestResult { let payload = ClientPayload { data: vec![7_u8; size], }; - let client = self.client.as_mut().ok_or("client not connected")?; - let response: ClientPayload = client.call(&payload).await?; - self.payload = Some(payload); - self.response = Some(response); - self.last_error = None; + let mut client = self + .client + .borrow_mut() + .take() + .ok_or("client not connected")?; + let response: ClientPayload = self + .runtime + .block_on(async { client.call(&payload).await })?; + *self.client.borrow_mut() = Some(client); + *self.payload.borrow_mut() = Some(payload); + *self.response.borrow_mut() = Some(response); + *self.last_error.borrow_mut() = None; Ok(()) } @@ -112,15 +146,21 @@ impl ClientRuntimeWorld { /// /// # Errors /// Returns an error if the client is missing or if no failure is observed. - pub async fn send_payload_expect_error(&mut self, size: usize) -> TestResult { + pub fn send_payload_expect_error(&self, size: usize) -> TestResult { let payload = ClientPayload { data: vec![7_u8; size], }; - let client = self.client.as_mut().ok_or("client not connected")?; - let result: Result = client.call(&payload).await; + let mut client = self + .client + .borrow_mut() + .take() + .ok_or("client not connected")?; + let result: Result = + self.runtime.block_on(async { client.call(&payload).await }); + *self.client.borrow_mut() = Some(client); match result { Ok(_) => return Err("expected client error for oversized payload".into()), - Err(err) => self.last_error = Some(err), + Err(err) => *self.last_error.borrow_mut() = Some(err), } Ok(()) } @@ -129,13 +169,15 @@ impl ClientRuntimeWorld { /// /// # Errors /// Returns an error if the response is missing or mismatched. - pub async fn verify_echo(&mut self) -> TestResult { - let payload = self.payload.as_ref().ok_or("payload missing")?; - let response = self.response.as_ref().ok_or("response missing")?; + pub fn verify_echo(&self) -> TestResult { + let payload_ref = self.payload.borrow(); + let response_ref = self.response.borrow(); + let payload = payload_ref.as_ref().ok_or("payload missing")?; + let response = response_ref.as_ref().ok_or("response missing")?; if payload != response { return Err("response did not match payload".into()); } - self.await_server().await?; + self.await_server()?; Ok(()) } @@ -143,23 +185,25 @@ impl ClientRuntimeWorld { /// /// # Errors /// Returns an error if no failure was observed. - pub async fn verify_error(&mut self) -> TestResult { - let err = self - .last_error + pub fn verify_error(&self) -> TestResult { + let error_ref = self.last_error.borrow(); + let err = error_ref .as_ref() .ok_or("expected client error was not captured")?; if !matches!(err, ClientError::Disconnected | ClientError::Io(_)) { return Err("unexpected client error variant".into()); } - self.await_server().await?; + self.await_server()?; Ok(()) } - async fn await_server(&mut self) -> TestResult { - if let Some(handle) = self.server.take() { - handle - .await - .map_err(|err| format!("server task failed: {err}"))?; + fn await_server(&self) -> TestResult { + if let Some(handle) = self.server.borrow_mut().take() { + self.runtime.block_on(async { + handle + .await + .map_err(|err| format!("server task failed: {err}")) + })?; } Ok(()) } diff --git a/tests/steps/client_runtime_steps.rs b/tests/steps/client_runtime_steps.rs index 4d505128..25bf349e 100644 --- a/tests/steps/client_runtime_steps.rs +++ b/tests/steps/client_runtime_steps.rs @@ -1,34 +1,15 @@ //! Step definitions for wireframe client runtime behavioural tests. -use std::{future::Future, sync::OnceLock}; - use rstest_bdd_macros::{given, then, when}; -use tokio::runtime::Runtime; use crate::fixtures::client_runtime::{ClientRuntimeWorld, TestResult}; -static RUNTIME: OnceLock> = OnceLock::new(); - -fn runtime() -> TestResult<&'static Runtime> { - match RUNTIME.get_or_init(|| Runtime::new().map_err(|err| err.to_string())) { - Ok(runtime) => Ok(runtime), - Err(err) => Err(err.clone().into()), - } -} - -fn block_on(fut: Fut) -> TestResult -where - Fut: Future, -{ - runtime()?.block_on(fut) -} - #[given("a wireframe echo server allowing frames up to {max_frame_length:usize} bytes")] fn given_server( client_runtime_world: &mut ClientRuntimeWorld, max_frame_length: usize, ) -> TestResult { - block_on(client_runtime_world.start_server(max_frame_length)) + client_runtime_world.start_server(max_frame_length) } #[given("a wireframe client configured with max frame length {max_frame_length:usize}")] @@ -36,12 +17,12 @@ fn given_client( client_runtime_world: &mut ClientRuntimeWorld, max_frame_length: usize, ) -> TestResult { - block_on(client_runtime_world.connect_client(max_frame_length)) + client_runtime_world.connect_client(max_frame_length) } #[when("the client sends a payload of {size:usize} bytes")] fn when_send_payload(client_runtime_world: &mut ClientRuntimeWorld, size: usize) -> TestResult { - block_on(client_runtime_world.send_payload(size)) + client_runtime_world.send_payload(size) } #[when("the client sends an oversized payload of {size:usize} bytes")] @@ -49,15 +30,15 @@ fn when_send_oversized_payload( client_runtime_world: &mut ClientRuntimeWorld, size: usize, ) -> TestResult { - block_on(client_runtime_world.send_payload_expect_error(size)) + client_runtime_world.send_payload_expect_error(size) } #[then("the client receives the echoed payload")] fn then_receives_echo(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { - block_on(client_runtime_world.verify_echo()) + client_runtime_world.verify_echo() } #[then("the client reports a framing error")] fn then_reports_error(client_runtime_world: &mut ClientRuntimeWorld) -> TestResult { - block_on(client_runtime_world.verify_error()) + client_runtime_world.verify_error() } From 45352759dba553ee05ed05759a97a2d5f52f181f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 18:37:19 +0000 Subject: [PATCH 36/45] Allow unused scenario param Keep the codec stateful scenario body empty while acknowledging the rstest-bdd parameter is unused. Uses a scoped lint expectation to satisfy clippy without adding statement noise. --- tests/scenarios/codec_stateful_scenarios.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/scenarios/codec_stateful_scenarios.rs b/tests/scenarios/codec_stateful_scenarios.rs index 4a396c71..4320712c 100644 --- a/tests/scenarios/codec_stateful_scenarios.rs +++ b/tests/scenarios/codec_stateful_scenarios.rs @@ -8,4 +8,8 @@ use crate::fixtures::codec_stateful::*; path = "tests/features/codec_stateful.feature", name = "Sequence counters reset per connection" )] -fn sequence_counters_reset(codec_stateful_world: CodecStatefulWorld) { drop(codec_stateful_world); } +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn sequence_counters_reset(codec_stateful_world: CodecStatefulWorld) {} From 6469ff60bcb3b89855bbc076a92f692f45092171 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 18:41:29 +0000 Subject: [PATCH 37/45] Introduce message assembler newtypes Add domain-specific newtypes for message assembler tests and thread them through specs, assertions, and steps. This reduces primitive obsession while keeping Gherkin step parsing unchanged. --- tests/fixtures/message_assembler.rs | 44 ++++++++++--------- .../message_assembler_asserts.rs | 26 +++++++---- tests/fixtures/message_assembler/types.rs | 39 ++++++++++++++++ tests/steps/message_assembler_steps.rs | 33 +++++++++----- 4 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/message_assembler/types.rs diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs index 58007ab2..23c5f01f 100644 --- a/tests/fixtures/message_assembler.rs +++ b/tests/fixtures/message_assembler.rs @@ -2,10 +2,12 @@ //! //! Provides header parsing helpers for message assembler scenarios. +mod types; use std::{fmt, io}; use bytes::{BufMut, BytesMut}; use rstest::fixture; +pub use types::*; use wireframe::{ message_assembler::{FrameHeader, MessageAssembler, ParsedFrameHeader}, test_helpers::TestAssembler, @@ -19,23 +21,23 @@ pub use crate::common::TestResult; #[derive(Debug, Clone, Copy)] pub struct FirstHeaderSpec { /// Message key to encode into the header. - pub key: u64, + pub key: MessageKey, /// Metadata length in bytes. - pub metadata_len: usize, + pub metadata_len: MetadataLength, /// Body length in bytes for this frame. - pub body_len: usize, + pub body_len: BodyLength, /// Optional total body length across all frames. - pub total_len: Option, + pub total_len: Option, /// Whether the frame is the final one in the series. pub is_last: bool, } impl FirstHeaderSpec { /// Create a first header spec with default metadata and flags. - pub fn new(key: u64, body_len: usize) -> Self { + pub fn new(key: MessageKey, body_len: BodyLength) -> Self { Self { key, - metadata_len: 0, + metadata_len: MetadataLength(0), body_len, total_len: None, is_last: false, @@ -43,13 +45,13 @@ impl FirstHeaderSpec { } /// Set the metadata length to encode into the header. - pub fn with_metadata_len(mut self, metadata_len: usize) -> Self { + pub fn with_metadata_len(mut self, metadata_len: MetadataLength) -> Self { self.metadata_len = metadata_len; self } /// Set the total message length to encode into the header. - pub fn with_total_len(mut self, total_len: usize) -> Self { + pub fn with_total_len(mut self, total_len: BodyLength) -> Self { self.total_len = Some(total_len); self } @@ -65,18 +67,18 @@ impl FirstHeaderSpec { #[derive(Debug, Clone, Copy)] pub struct ContinuationHeaderSpec { /// Message key to encode into the header. - pub key: u64, + pub key: MessageKey, /// Body length in bytes for this frame. - pub body_len: usize, + pub body_len: BodyLength, /// Optional sequence number. - pub sequence: Option, + pub sequence: Option, /// Whether the frame is the final one in the series. pub is_last: bool, } impl ContinuationHeaderSpec { /// Create a continuation header spec with default sequence and flags. - pub fn new(key: u64, body_len: usize) -> Self { + pub fn new(key: MessageKey, body_len: BodyLength) -> Self { Self { key, body_len, @@ -86,7 +88,7 @@ impl ContinuationHeaderSpec { } /// Set the continuation sequence to encode into the header. - pub fn with_sequence(mut self, sequence: u32) -> Self { + pub fn with_sequence(mut self, sequence: SequenceNumber) -> Self { self.sequence = Some(sequence); self } @@ -207,16 +209,17 @@ impl MessageAssemblerWorld { HeaderEnvelope { kind: 0x01, flags, - key: spec.key, + key: spec.key.0, }, |bytes| { let metadata_len = - u16::try_from(spec.metadata_len).map_err(|_| "metadata length too large")?; + u16::try_from(spec.metadata_len.0).map_err(|_| "metadata length too large")?; bytes.put_u16(metadata_len); - let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; + let body_len = + u32::try_from(spec.body_len.0).map_err(|_| "body length too large")?; bytes.put_u32(body_len); if let Some(total) = spec.total_len { - let total = u32::try_from(total).map_err(|_| "total length too large")?; + let total = u32::try_from(total.0).map_err(|_| "total length too large")?; bytes.put_u32(total); } Ok(()) @@ -241,13 +244,14 @@ impl MessageAssemblerWorld { HeaderEnvelope { kind: 0x02, flags, - key: spec.key, + key: spec.key.0, }, |bytes| { - let body_len = u32::try_from(spec.body_len).map_err(|_| "body length too large")?; + let body_len = + u32::try_from(spec.body_len.0).map_err(|_| "body length too large")?; bytes.put_u32(body_len); if let Some(seq) = spec.sequence { - bytes.put_u32(seq); + bytes.put_u32(seq.0); } Ok(()) }, diff --git a/tests/fixtures/message_assembler/message_assembler_asserts.rs b/tests/fixtures/message_assembler/message_assembler_asserts.rs index 84260030..1373c259 100644 --- a/tests/fixtures/message_assembler/message_assembler_asserts.rs +++ b/tests/fixtures/message_assembler/message_assembler_asserts.rs @@ -10,7 +10,15 @@ use wireframe::{ test_helpers::TestAssembler, }; -use super::{MessageAssemblerWorld, TestApp, TestResult}; +use super::{ + BodyLength, + MessageAssemblerWorld, + MessageKey, + MetadataLength, + SequenceNumber, + TestApp, + TestResult, +}; #[derive(Clone, Copy, Debug, PartialEq)] struct DebugDisplay(T); @@ -25,8 +33,8 @@ impl MessageAssemblerWorld { /// # Errors /// /// Returns an error if no header was parsed or the key does not match. - pub fn assert_message_key(&self, expected: u64) -> TestResult { - self.assert_field("key", &expected, |header| { + pub fn assert_message_key(&self, expected: MessageKey) -> TestResult { + self.assert_field("key", &expected.0, |header| { Ok(match header { FrameHeader::First(header) => u64::from(header.message_key), FrameHeader::Continuation(header) => u64::from(header.message_key), @@ -39,8 +47,8 @@ impl MessageAssemblerWorld { /// # Errors /// /// Returns an error if no header was parsed or the metadata length differs. - pub fn assert_metadata_len(&self, expected: usize) -> TestResult { - self.assert_first_field("metadata length", &expected, |header| header.metadata_len) + pub fn assert_metadata_len(&self, expected: MetadataLength) -> TestResult { + self.assert_first_field("metadata length", &expected.0, |header| header.metadata_len) } /// Assert that the parsed header contains the expected body length. @@ -48,8 +56,8 @@ impl MessageAssemblerWorld { /// # Errors /// /// Returns an error if no header was parsed or the body length differs. - pub fn assert_body_len(&self, expected: usize) -> TestResult { - self.assert_field("body length", &expected, |header| { + pub fn assert_body_len(&self, expected: BodyLength) -> TestResult { + self.assert_field("body length", &expected.0, |header| { Ok(match header { FrameHeader::First(header) => header.body_len, FrameHeader::Continuation(header) => header.body_len, @@ -74,8 +82,8 @@ impl MessageAssemblerWorld { /// # Errors /// /// Returns an error if no header was parsed or the sequence differs. - pub fn assert_sequence(&self, expected: Option) -> TestResult { - let expected = expected.map(FrameSequence::from); + pub fn assert_sequence(&self, expected: Option) -> TestResult { + let expected = expected.map(|sequence| FrameSequence::from(sequence.0)); let expected = DebugDisplay(expected); self.assert_continuation_field("sequence", &expected, |header| { DebugDisplay(header.sequence) diff --git a/tests/fixtures/message_assembler/types.rs b/tests/fixtures/message_assembler/types.rs new file mode 100644 index 00000000..7e553477 --- /dev/null +++ b/tests/fixtures/message_assembler/types.rs @@ -0,0 +1,39 @@ +//! Domain-specific newtypes for message assembler test parameters. + +use std::{num::ParseIntError, str::FromStr}; + +/// Message key for frame headers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MessageKey(pub u64); + +impl FromStr for MessageKey { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Sequence number for continuation frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SequenceNumber(pub u32); + +impl FromStr for SequenceNumber { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Body length in bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BodyLength(pub usize); + +impl FromStr for BodyLength { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} + +/// Metadata length in bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataLength(pub usize); + +impl FromStr for MetadataLength { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs index 56a70cb6..a6528275 100644 --- a/tests/steps/message_assembler_steps.rs +++ b/tests/steps/message_assembler_steps.rs @@ -3,9 +3,13 @@ use rstest_bdd_macros::{given, then, when}; use crate::fixtures::message_assembler::{ + BodyLength, ContinuationHeaderSpec, FirstHeaderSpec, MessageAssemblerWorld, + MessageKey, + MetadataLength, + SequenceNumber, TestResult, }; @@ -19,8 +23,10 @@ fn given_first_header( metadata_len: usize, body_len: usize, ) -> TestResult { - message_assembler_world - .set_first_header(FirstHeaderSpec::new(key, body_len).with_metadata_len(metadata_len)) + message_assembler_world.set_first_header( + FirstHeaderSpec::new(MessageKey(key), BodyLength(body_len)) + .with_metadata_len(MetadataLength(metadata_len)), + ) } #[given( @@ -33,8 +39,8 @@ fn given_first_header_with_total( total_len: usize, ) -> TestResult { message_assembler_world.set_first_header( - FirstHeaderSpec::new(key, body_len) - .with_total_len(total_len) + FirstHeaderSpec::new(MessageKey(key), BodyLength(body_len)) + .with_total_len(BodyLength(total_len)) .with_last_flag(true), ) } @@ -48,8 +54,10 @@ fn given_continuation_header_with_sequence( body_len: usize, sequence: u32, ) -> TestResult { - message_assembler_world - .set_continuation_header(ContinuationHeaderSpec::new(key, body_len).with_sequence(sequence)) + message_assembler_world.set_continuation_header( + ContinuationHeaderSpec::new(MessageKey(key), BodyLength(body_len)) + .with_sequence(SequenceNumber(sequence)), + ) } #[given("a continuation header with key {key:u64} body length {body_len:usize}")] @@ -58,8 +66,9 @@ fn given_continuation_header( key: u64, body_len: usize, ) -> TestResult { - message_assembler_world - .set_continuation_header(ContinuationHeaderSpec::new(key, body_len).with_last_flag(true)) + message_assembler_world.set_continuation_header( + ContinuationHeaderSpec::new(MessageKey(key), BodyLength(body_len)).with_last_flag(true), + ) } #[given("a wireframe app with a message assembler")] @@ -89,7 +98,7 @@ fn then_header_kind( #[then("the message key is {key:u64}")] fn then_message_key(message_assembler_world: &mut MessageAssemblerWorld, key: u64) -> TestResult { - message_assembler_world.assert_message_key(key) + message_assembler_world.assert_message_key(MessageKey(key)) } #[then("the header metadata length is {metadata_len:usize}")] @@ -97,7 +106,7 @@ fn then_metadata_len( message_assembler_world: &mut MessageAssemblerWorld, metadata_len: usize, ) -> TestResult { - message_assembler_world.assert_metadata_len(metadata_len) + message_assembler_world.assert_metadata_len(MetadataLength(metadata_len)) } #[then("the body length is {body_len:usize}")] @@ -105,7 +114,7 @@ fn then_body_len( message_assembler_world: &mut MessageAssemblerWorld, body_len: usize, ) -> TestResult { - message_assembler_world.assert_body_len(body_len) + message_assembler_world.assert_body_len(BodyLength(body_len)) } #[then("the header length is {header_len:usize}")] @@ -131,7 +140,7 @@ fn then_total_present( #[then("the sequence is {sequence:u32}")] fn then_sequence(message_assembler_world: &mut MessageAssemblerWorld, sequence: u32) -> TestResult { - message_assembler_world.assert_sequence(Some(sequence)) + message_assembler_world.assert_sequence(Some(SequenceNumber(sequence))) } #[then("the sequence is absent")] From d8ccb4b43ca26cf95f66331a88ec2cf80a93d50c Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 18:57:07 +0000 Subject: [PATCH 38/45] Document fixtures and tidy steps Add rustdoc for test fixtures and public re-exports, fix message assembly step literals, and tidy fixture helpers. Also update execplan wording to use typographical ellipses. --- docs/execplans/migrate-from-cucumber-to-rstest-bdd.md | 10 +++++----- tests/fixtures/client_lifecycle.rs | 6 +++--- tests/fixtures/client_messaging.rs | 4 ++-- tests/fixtures/client_preamble.rs | 4 ++-- tests/fixtures/client_runtime.rs | 4 ++-- tests/fixtures/codec_error/mod.rs | 4 ++-- tests/fixtures/fragment/mod.rs | 4 ++-- tests/fixtures/message_assembler.rs | 4 ++-- tests/fixtures/panic.rs | 6 +++--- tests/fixtures/request_parts.rs | 2 +- tests/fixtures/stream_end.rs | 2 +- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index bb1624fd..a59c303b 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -87,7 +87,7 @@ fn when_client_sends_envelope(world: &mut ClientMessagingWorld) { **Practical rule for this codebase**: - For worlds with async methods, keep scenarios **sync** and run those methods - inside a dedicated runtime per step (`Runtime::new().block_on(...)`). + inside a dedicated runtime per step (`Runtime::new().block_on(…)`). - For worlds with purely synchronous steps, prefer async scenarios so the test body can `await` any extra async assertions or cleanup logic. @@ -117,7 +117,7 @@ pub struct ClientMessagingWorld { // Slots for optional/late-bound state addr: Slot, server: Slot>, - client: Slot>, + client: Slot>, envelope: Slot, // Direct fields for always-present state @@ -243,11 +243,11 @@ impl CorrelationWorld { } pub async fn process(&mut self) -> TestResult { - // ... existing async code + // … existing async code } pub fn verify(&self) -> TestResult { - // ... existing sync code + // … existing sync code } } ``` @@ -418,7 +418,7 @@ use reassembly::*; #[derive(Debug, ScenarioState)] pub struct FragmentWorld { - // ... fields + // … fields } #[fixture] diff --git a/tests/fixtures/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs index 8cc58963..e368dd75 100644 --- a/tests/fixtures/client_lifecycle.rs +++ b/tests/fixtures/client_lifecycle.rs @@ -84,11 +84,11 @@ impl Drop for ClientLifecycleWorld { } } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. -#[rustfmt::skip] +/// Fixture for `ClientLifecycleWorld`. #[fixture] pub fn client_lifecycle_world() -> ClientLifecycleWorld { - ClientLifecycleWorld::default() + let mut world = ClientLifecycleWorld::default(); + std::mem::take(&mut world) } impl ClientLifecycleWorld { diff --git a/tests/fixtures/client_messaging.rs b/tests/fixtures/client_messaging.rs index 65c82903..f9781a2e 100644 --- a/tests/fixtures/client_messaging.rs +++ b/tests/fixtures/client_messaging.rs @@ -19,7 +19,7 @@ use wireframe::{ }; use wireframe_testing::{ServerMode, process_frame}; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; /// Test world for client messaging scenarios. @@ -39,7 +39,7 @@ pub struct ClientMessagingWorld { expected_payload: Option, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `ClientMessagingWorld`. #[rustfmt::skip] #[fixture] pub fn client_messaging_world() -> ClientMessagingWorld { diff --git a/tests/fixtures/client_preamble.rs b/tests/fixtures/client_preamble.rs index ce69253a..51cfa647 100644 --- a/tests/fixtures/client_preamble.rs +++ b/tests/fixtures/client_preamble.rs @@ -23,7 +23,7 @@ use wireframe::{ rewind_stream::RewindStream, }; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; /// Preamble used for testing. @@ -89,7 +89,7 @@ pub struct ClientPreambleWorld { last_error: Option, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `ClientPreambleWorld`. #[rustfmt::skip] #[fixture] pub fn client_preamble_world() -> ClientPreambleWorld { diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 9144758a..0bc6d6f9 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -19,7 +19,7 @@ use wireframe::{ rewind_stream::RewindStream, }; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; /// Test world exercising the wireframe client runtime. @@ -58,7 +58,7 @@ struct ClientPayload { data: Vec, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `ClientRuntimeWorld`. #[rustfmt::skip] #[fixture] pub fn client_runtime_world() -> ClientRuntimeWorld { diff --git a/tests/fixtures/codec_error/mod.rs b/tests/fixtures/codec_error/mod.rs index e6898a86..619909ad 100644 --- a/tests/fixtures/codec_error/mod.rs +++ b/tests/fixtures/codec_error/mod.rs @@ -8,7 +8,7 @@ use bytes::BytesMut; use rstest::fixture; use wireframe::codec::{CodecError, EofError, FramingError, ProtocolError, RecoveryPolicy}; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; /// Codec error type for test scenarios. @@ -64,7 +64,7 @@ pub struct CodecErrorWorld { pub(crate) clean_close_detected: bool, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `CodecErrorWorld`. #[rustfmt::skip] #[fixture] pub fn codec_error_world() -> CodecErrorWorld { diff --git a/tests/fixtures/fragment/mod.rs b/tests/fixtures/fragment/mod.rs index 2862a886..409777e0 100644 --- a/tests/fixtures/fragment/mod.rs +++ b/tests/fixtures/fragment/mod.rs @@ -22,7 +22,7 @@ use wireframe::fragment::{ ReassemblyError, }; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; /// Test world tracking fragmentation state across behavioural scenarios. @@ -55,7 +55,7 @@ impl Default for FragmentWorld { } } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `FragmentWorld`. #[rustfmt::skip] #[fixture] pub fn fragment_world() -> FragmentWorld { diff --git a/tests/fixtures/message_assembler.rs b/tests/fixtures/message_assembler.rs index 23c5f01f..fb5a1967 100644 --- a/tests/fixtures/message_assembler.rs +++ b/tests/fixtures/message_assembler.rs @@ -14,7 +14,7 @@ use wireframe::{ }; use crate::TestApp; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; /// Specification for first-frame header encoding used in tests. @@ -130,7 +130,7 @@ impl fmt::Debug for MessageAssemblerWorld { } } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `MessageAssemblerWorld`. #[rustfmt::skip] #[fixture] pub fn message_assembler_world() -> MessageAssemblerWorld { diff --git a/tests/fixtures/panic.rs b/tests/fixtures/panic.rs index 84087a9f..3a9b1292 100644 --- a/tests/fixtures/panic.rs +++ b/tests/fixtures/panic.rs @@ -83,11 +83,11 @@ pub struct PanicWorld { attempts: usize, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. -#[rustfmt::skip] +/// Fixture for `PanicWorld`. #[fixture] pub fn panic_world() -> PanicWorld { - PanicWorld::default() + let mut world = PanicWorld::default(); + std::mem::take(&mut world) } impl PanicWorld { diff --git a/tests/fixtures/request_parts.rs b/tests/fixtures/request_parts.rs index c9d8c667..fbd8e129 100644 --- a/tests/fixtures/request_parts.rs +++ b/tests/fixtures/request_parts.rs @@ -47,7 +47,7 @@ pub struct RequestPartsWorld { parts: Option, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for `RequestPartsWorld`. #[rustfmt::skip] #[fixture] pub fn request_parts_world() -> RequestPartsWorld { diff --git a/tests/fixtures/stream_end.rs b/tests/fixtures/stream_end.rs index dc2a2dc2..925c088e 100644 --- a/tests/fixtures/stream_end.rs +++ b/tests/fixtures/stream_end.rs @@ -21,8 +21,8 @@ use wireframe_testing::{LoggerHandle, logger}; pub use crate::common::TestResult; use crate::{build_small_queues, terminator::Terminator}; -#[derive(Debug, Default)] /// Test world capturing frames and logs for stream termination scenarios. +#[derive(Debug, Default)] pub struct StreamEndWorld { frames: Vec, logs: Vec<(Level, String)>, From 6a27036c8413fcc8c68bd759333ba557d5d39f50 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 19:22:44 +0000 Subject: [PATCH 39/45] Tidy fixtures and runtime setup Remove rustfmt skips, simplify fixtures, and propagate client runtime init errors without panicking. Also flatten long step literals for message assembly steps. --- tests/fixtures/client_lifecycle.rs | 5 +- tests/fixtures/client_runtime.rs | 76 +++++++++++++++++++----------- tests/fixtures/fragment/mod.rs | 5 +- tests/fixtures/panic.rs | 7 +-- tests/fixtures/stream_end.rs | 6 +-- 5 files changed, 62 insertions(+), 37 deletions(-) diff --git a/tests/fixtures/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs index e368dd75..564e9585 100644 --- a/tests/fixtures/client_lifecycle.rs +++ b/tests/fixtures/client_lifecycle.rs @@ -87,8 +87,9 @@ impl Drop for ClientLifecycleWorld { /// Fixture for `ClientLifecycleWorld`. #[fixture] pub fn client_lifecycle_world() -> ClientLifecycleWorld { - let mut world = ClientLifecycleWorld::default(); - std::mem::take(&mut world) + let world = ClientLifecycleWorld::default(); + let _ = world.last_error; + world } impl ClientLifecycleWorld { diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 0bc6d6f9..1e512de7 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -25,7 +25,8 @@ pub use crate::common::TestResult; /// Test world exercising the wireframe client runtime. #[derive(Debug)] pub struct ClientRuntimeWorld { - runtime: tokio::runtime::Runtime, + runtime: Option, + runtime_error: Option, addr: Cell>, server: RefCell>>, client: @@ -35,22 +36,41 @@ pub struct ClientRuntimeWorld { last_error: RefCell>, } -impl Default for ClientRuntimeWorld { - fn default() -> Self { - let runtime = match tokio::runtime::Runtime::new() { - Ok(runtime) => runtime, - Err(err) => panic!("failed to create runtime: {err}"), - }; - Self { - runtime, - addr: Cell::new(None), - server: RefCell::new(None), - client: RefCell::new(None), - payload: RefCell::new(None), - response: RefCell::new(None), - last_error: RefCell::new(None), +impl ClientRuntimeWorld { + /// Build a new runtime-backed client world. + pub fn new() -> Self { + match tokio::runtime::Runtime::new() { + Ok(runtime) => Self { + runtime: Some(runtime), + runtime_error: None, + addr: Cell::new(None), + server: RefCell::new(None), + client: RefCell::new(None), + payload: RefCell::new(None), + response: RefCell::new(None), + last_error: RefCell::new(None), + }, + Err(err) => Self { + runtime: None, + runtime_error: Some(format!("failed to create runtime: {err}")), + addr: Cell::new(None), + server: RefCell::new(None), + client: RefCell::new(None), + payload: RefCell::new(None), + response: RefCell::new(None), + last_error: RefCell::new(None), + }, } } + + fn runtime(&self) -> TestResult<&tokio::runtime::Runtime> { + self.runtime.as_ref().ok_or_else(|| { + self.runtime_error + .clone() + .unwrap_or_else(|| "runtime unavailable".to_string()) + .into() + }) + } } #[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] @@ -59,10 +79,11 @@ struct ClientPayload { } /// Fixture for `ClientRuntimeWorld`. -#[rustfmt::skip] #[fixture] pub fn client_runtime_world() -> ClientRuntimeWorld { - ClientRuntimeWorld::default() + let world = ClientRuntimeWorld::new(); + let _ = world.runtime_error.as_deref(); + world } impl ClientRuntimeWorld { @@ -71,11 +92,10 @@ impl ClientRuntimeWorld { /// # Errors /// Returns an error if binding or spawning the server fails. pub fn start_server(&self, max_frame_length: usize) -> TestResult { - let listener = self - .runtime - .block_on(async { TcpListener::bind("127.0.0.1:0").await })?; + let runtime = self.runtime()?; + let listener = runtime.block_on(async { TcpListener::bind("127.0.0.1:0").await })?; let addr = listener.local_addr()?; - let handle = self.runtime.spawn(async move { + let handle = runtime.spawn(async move { let Ok((stream, _)) = listener.accept().await else { warn!("client runtime server failed to accept connection"); return; @@ -109,7 +129,8 @@ impl ClientRuntimeWorld { pub fn connect_client(&self, max_frame_length: usize) -> TestResult { let addr = self.addr.get().ok_or("server address missing")?; let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); - let client = self.runtime.block_on(async { + let runtime = self.runtime()?; + let client = runtime.block_on(async { WireframeClient::builder() .codec_config(codec_config) .connect(addr) @@ -132,9 +153,8 @@ impl ClientRuntimeWorld { .borrow_mut() .take() .ok_or("client not connected")?; - let response: ClientPayload = self - .runtime - .block_on(async { client.call(&payload).await })?; + let runtime = self.runtime()?; + let response: ClientPayload = runtime.block_on(async { client.call(&payload).await })?; *self.client.borrow_mut() = Some(client); *self.payload.borrow_mut() = Some(payload); *self.response.borrow_mut() = Some(response); @@ -155,8 +175,9 @@ impl ClientRuntimeWorld { .borrow_mut() .take() .ok_or("client not connected")?; + let runtime = self.runtime()?; let result: Result = - self.runtime.block_on(async { client.call(&payload).await }); + runtime.block_on(async { client.call(&payload).await }); *self.client.borrow_mut() = Some(client); match result { Ok(_) => return Err("expected client error for oversized payload".into()), @@ -199,7 +220,8 @@ impl ClientRuntimeWorld { fn await_server(&self) -> TestResult { if let Some(handle) = self.server.borrow_mut().take() { - self.runtime.block_on(async { + let runtime = self.runtime()?; + runtime.block_on(async { handle .await .map_err(|err| format!("server task failed: {err}")) diff --git a/tests/fixtures/fragment/mod.rs b/tests/fixtures/fragment/mod.rs index 409777e0..212a0c95 100644 --- a/tests/fixtures/fragment/mod.rs +++ b/tests/fixtures/fragment/mod.rs @@ -56,10 +56,11 @@ impl Default for FragmentWorld { } /// Fixture for `FragmentWorld`. -#[rustfmt::skip] #[fixture] pub fn fragment_world() -> FragmentWorld { - FragmentWorld::default() + let world = FragmentWorld::default(); + let _ = world.series.is_none(); + world } impl FragmentWorld { diff --git a/tests/fixtures/panic.rs b/tests/fixtures/panic.rs index 3a9b1292..e2078e3b 100644 --- a/tests/fixtures/panic.rs +++ b/tests/fixtures/panic.rs @@ -9,7 +9,7 @@ use rstest::fixture; use tokio::{net::TcpStream, sync::oneshot}; use wireframe::server::WireframeServer; -// Re-export TestResult from common for use in steps +/// `TestResult` for step definitions. pub use crate::common::TestResult; use crate::common::{TestApp, unused_listener}; @@ -86,8 +86,9 @@ pub struct PanicWorld { /// Fixture for `PanicWorld`. #[fixture] pub fn panic_world() -> PanicWorld { - let mut world = PanicWorld::default(); - std::mem::take(&mut world) + let world = PanicWorld::default(); + let _ = world.attempts; + world } impl PanicWorld { diff --git a/tests/fixtures/stream_end.rs b/tests/fixtures/stream_end.rs index 925c088e..917a3a19 100644 --- a/tests/fixtures/stream_end.rs +++ b/tests/fixtures/stream_end.rs @@ -231,9 +231,9 @@ impl StreamEndWorld { } } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. -#[rustfmt::skip] #[fixture] pub fn stream_end_world() -> StreamEndWorld { - StreamEndWorld::default() + let world = StreamEndWorld::default(); + let _ = world.frames.is_empty(); + world } From 8884d8dfc6174219c7089317c0de805849b5e129 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 20:36:16 +0000 Subject: [PATCH 40/45] Address review feedback Resolve open PR review comments across BDD fixtures and steps. - Deduplicate client runtime payload handling - Normalise fixture formatting to avoid unused_braces - Update message assembly steps and assertions - Tidy execplan typography --- .../migrate-from-cucumber-to-rstest-bdd.md | 8 ++-- tests/fixtures/client_lifecycle.rs | 6 +-- tests/fixtures/client_runtime.rs | 40 +++++++++---------- tests/fixtures/fragment/mod.rs | 6 +-- .../message_assembler_asserts.rs | 4 +- tests/fixtures/panic.rs | 6 +-- tests/fixtures/stream_end.rs | 6 +-- tests/steps/message_assembler_steps.rs | 2 +- tests/steps/message_assembly_steps.rs | 24 ++++------- 9 files changed, 46 insertions(+), 56 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index a59c303b..f9f21c1c 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -37,17 +37,17 @@ rstest-bdd supports async steps. ### World Complexity Classification -#### Tier 1 - Simple (115-200 lines) +#### Tier 1 – Simple (115–200 lines) - `CorrelationWorld` (115 lines): Simple state + 2 async methods - `RequestPartsWorld` (~150 lines): Basic state validation -#### Tier 2 - Medium (200-400 lines) +#### Tier 2 – Medium (200–400 lines) - `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, `MessageAssemblerWorld`, `CodecStatefulWorld` -#### Tier 3 - High Complexity (400+ lines) +#### Tier 3 – High Complexity (400+ lines) - `ClientMessagingWorld` (302 lines): Server spawning, client connections, envelope handling @@ -88,7 +88,7 @@ fn when_client_sends_envelope(world: &mut ClientMessagingWorld) { - For worlds with async methods, keep scenarios **sync** and run those methods inside a dedicated runtime per step (`Runtime::new().block_on(…)`). -- For worlds with purely synchronous steps, prefer async scenarios so the test +- For worlds with purely synchronous steps, prefer async scenarios, so the test body can `await` any extra async assertions or cleanup logic. ### World-to-Fixture Conversion diff --git a/tests/fixtures/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs index 564e9585..06d9c302 100644 --- a/tests/fixtures/client_lifecycle.rs +++ b/tests/fixtures/client_lifecycle.rs @@ -85,11 +85,11 @@ impl Drop for ClientLifecycleWorld { } /// Fixture for `ClientLifecycleWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] pub fn client_lifecycle_world() -> ClientLifecycleWorld { - let world = ClientLifecycleWorld::default(); - let _ = world.last_error; - world + ClientLifecycleWorld::default() } impl ClientLifecycleWorld { diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 1e512de7..39366cb0 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -79,11 +79,11 @@ struct ClientPayload { } /// Fixture for `ClientRuntimeWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] pub fn client_runtime_world() -> ClientRuntimeWorld { - let world = ClientRuntimeWorld::new(); - let _ = world.runtime_error.as_deref(); - world + ClientRuntimeWorld::new() } impl ClientRuntimeWorld { @@ -145,17 +145,8 @@ impl ClientRuntimeWorld { /// # Errors /// Returns an error if the client is missing or communication fails. pub fn send_payload(&self, size: usize) -> TestResult { - let payload = ClientPayload { - data: vec![7_u8; size], - }; - let mut client = self - .client - .borrow_mut() - .take() - .ok_or("client not connected")?; - let runtime = self.runtime()?; - let response: ClientPayload = runtime.block_on(async { client.call(&payload).await })?; - *self.client.borrow_mut() = Some(client); + let (payload, result) = self.send_payload_inner(size)?; + let response = result?; *self.payload.borrow_mut() = Some(payload); *self.response.borrow_mut() = Some(response); *self.last_error.borrow_mut() = None; @@ -167,6 +158,18 @@ impl ClientRuntimeWorld { /// # Errors /// Returns an error if the client is missing or if no failure is observed. pub fn send_payload_expect_error(&self, size: usize) -> TestResult { + let (_payload, result) = self.send_payload_inner(size)?; + match result { + Ok(_) => return Err("expected client error for oversized payload".into()), + Err(err) => *self.last_error.borrow_mut() = Some(err), + } + Ok(()) + } + + fn send_payload_inner( + &self, + size: usize, + ) -> TestResult<(ClientPayload, Result)> { let payload = ClientPayload { data: vec![7_u8; size], }; @@ -176,14 +179,9 @@ impl ClientRuntimeWorld { .take() .ok_or("client not connected")?; let runtime = self.runtime()?; - let result: Result = - runtime.block_on(async { client.call(&payload).await }); + let result = runtime.block_on(async { client.call(&payload).await }); *self.client.borrow_mut() = Some(client); - match result { - Ok(_) => return Err("expected client error for oversized payload".into()), - Err(err) => *self.last_error.borrow_mut() = Some(err), - } - Ok(()) + Ok((payload, result)) } /// Verify that the client received the echoed payload. diff --git a/tests/fixtures/fragment/mod.rs b/tests/fixtures/fragment/mod.rs index 212a0c95..6fe4222f 100644 --- a/tests/fixtures/fragment/mod.rs +++ b/tests/fixtures/fragment/mod.rs @@ -56,11 +56,11 @@ impl Default for FragmentWorld { } /// Fixture for `FragmentWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] pub fn fragment_world() -> FragmentWorld { - let world = FragmentWorld::default(); - let _ = world.series.is_none(); - world + FragmentWorld::default() } impl FragmentWorld { diff --git a/tests/fixtures/message_assembler/message_assembler_asserts.rs b/tests/fixtures/message_assembler/message_assembler_asserts.rs index 1373c259..68a3903a 100644 --- a/tests/fixtures/message_assembler/message_assembler_asserts.rs +++ b/tests/fixtures/message_assembler/message_assembler_asserts.rs @@ -70,10 +70,10 @@ impl MessageAssemblerWorld { /// # Errors /// /// Returns an error if no header was parsed or the total length differs. - pub fn assert_total_len(&self, expected: Option) -> TestResult { + pub fn assert_total_len(&self, expected: Option) -> TestResult { let expected = DebugDisplay(expected); self.assert_first_field("total length", &expected, |header| { - DebugDisplay(header.total_body_len) + DebugDisplay(header.total_body_len.map(BodyLength)) }) } diff --git a/tests/fixtures/panic.rs b/tests/fixtures/panic.rs index e2078e3b..f4f229e2 100644 --- a/tests/fixtures/panic.rs +++ b/tests/fixtures/panic.rs @@ -84,11 +84,11 @@ pub struct PanicWorld { } /// Fixture for `PanicWorld`. +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] pub fn panic_world() -> PanicWorld { - let world = PanicWorld::default(); - let _ = world.attempts; - world + PanicWorld::default() } impl PanicWorld { diff --git a/tests/fixtures/stream_end.rs b/tests/fixtures/stream_end.rs index 917a3a19..925c088e 100644 --- a/tests/fixtures/stream_end.rs +++ b/tests/fixtures/stream_end.rs @@ -231,9 +231,9 @@ impl StreamEndWorld { } } +// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +#[rustfmt::skip] #[fixture] pub fn stream_end_world() -> StreamEndWorld { - let world = StreamEndWorld::default(); - let _ = world.frames.is_empty(); - world + StreamEndWorld::default() } diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs index a6528275..8b141211 100644 --- a/tests/steps/message_assembler_steps.rs +++ b/tests/steps/message_assembler_steps.rs @@ -135,7 +135,7 @@ fn then_total_present( message_assembler_world: &mut MessageAssemblerWorld, total: usize, ) -> TestResult { - message_assembler_world.assert_total_len(Some(total)) + message_assembler_world.assert_total_len(Some(BodyLength(total))) } #[then("the sequence is {sequence:u32}")] diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index ef80b5a9..538a9dea 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -127,10 +127,8 @@ fn assert_equals( // Given steps // ============================================================================= -#[given( - "a message assembly state with max size {max_size:CountParam} and timeout \ - {timeout:TimeoutParam} seconds" -)] +#[rustfmt::skip] +#[given("a message assembly state with max size {max_size:CountParam} and timeout {timeout:TimeoutParam} seconds")] fn given_state( message_assembly_world: &mut MessageAssemblyWorld, max_size: CountParam, @@ -140,10 +138,8 @@ fn given_state( message_assembly_world.create_state(config); } -#[given( - "a first frame for key {key:MessageKeyParam} with metadata {metadata:string} and body \ - {body:string}" -)] +#[rustfmt::skip] +#[given("a first frame for key {key:MessageKeyParam} with metadata {metadata:string} and body {body:string}")] fn given_first_frame_with_metadata( message_assembly_world: &mut MessageAssemblyWorld, key: MessageKeyParam, @@ -189,10 +185,8 @@ fn when_all_first_frames_accepted(message_assembly_world: &mut MessageAssemblyWo message_assembly_world.accept_all_first_frames() } -#[when( - "a final continuation for key {key:MessageKeyParam} with sequence {sequence:SequenceParam} \ - and body {body:string} arrives" -)] +#[rustfmt::skip] +#[when("a final continuation for key {key:MessageKeyParam} with sequence {sequence:SequenceParam} and body {body:string} arrives")] fn when_final_continuation( message_assembly_world: &mut MessageAssemblyWorld, key: MessageKeyParam, @@ -303,10 +297,8 @@ fn then_buffered_count( assert_equals(&actual, &count.0, "buffered count mismatch") } -#[then( - "the error is sequence mismatch expecting {expected:SequenceParam} but found \ - {found:SequenceParam}" -)] +#[rustfmt::skip] +#[then("the error is sequence mismatch expecting {expected:SequenceParam} but found {found:SequenceParam}")] fn then_error_sequence_mismatch( message_assembly_world: &mut MessageAssemblyWorld, expected: SequenceParam, From 47069a6b806e9a9db3c355d74800976192299b92 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 21:34:45 +0000 Subject: [PATCH 41/45] Apply review fixes Address remaining review feedback in execplan text and test fixtures. - Standardise en dashes and wording in the migration plan - Add HeaderLength newtype and use it in assertions/steps - Simplify message assembly step checks and panic world docs - Relax client runtime error variant checking --- .../migrate-from-cucumber-to-rstest-bdd.md | 40 +++++++++---------- tests/fixtures/client_runtime.rs | 4 +- .../message_assembler_asserts.rs | 7 ++-- tests/fixtures/message_assembler/types.rs | 9 +++++ tests/fixtures/panic.rs | 2 +- tests/steps/message_assembler_steps.rs | 3 +- tests/steps/message_assembly_steps.rs | 15 ++----- 7 files changed, 40 insertions(+), 40 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index f9f21c1c..af1136ec 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -200,7 +200,7 @@ disambiguating client preamble step text to avoid ambiguous step definitions). **Commit**: "Set up parallel rstest-bdd infrastructure" -### Phase 1: Pilot Migration - Simple Worlds (Weeks 2-3) +### Phase 1: Pilot Migration – Simple Worlds (Weeks 2-3) **Objective**: Validate approach with 2 simple worlds, establish conversion patterns. @@ -219,7 +219,7 @@ patterns. steps are fully synchronous) 4. Run and validate against Cucumber -**Example - CorrelationWorld**: +**Example – CorrelationWorld**: ```rust // tests/bdd/fixtures/correlation.rs @@ -319,7 +319,7 @@ cargo test --test bdd request_parts - ✅ "Migrate CorrelationWorld to rstest-bdd" (commit 8ce5b55) - ✅ "Migrate RequestPartsWorld to rstest-bdd" (commit 154e5c8) -**Status**: ✅ **COMPLETE** - Both pilot worlds successfully migrated and all +**Status**: ✅ **COMPLETE** – Both pilot worlds successfully migrated and all tests passing. ### Phase 2: Medium Complexity Worlds (Weeks 4-5) @@ -360,13 +360,13 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: ✅ **COMPLETE** - `MessageAssemblerWorld`, `MessageAssemblyWorld`, +**Status**: ✅ **COMPLETE** – `MessageAssemblerWorld`, `MessageAssemblyWorld`, `CodecErrorWorld`, and `FragmentWorld` migrated. -**Status**: ✅ **COMPLETE** - `PanicWorld`, `MultiPacketWorld`, +**Status**: ✅ **COMPLETE** – `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, and `CodecStatefulWorld` migrated. -### Phase 3: Complex Worlds - Client & Messaging (Weeks 6-7) +### Phase 3: Complex Worlds – Client & Messaging (Weeks 6-7) **Selected Worlds** (in order): @@ -392,7 +392,7 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: ✅ **COMPLETE** - `ClientRuntimeWorld`, `ClientMessagingWorld`, +**Status**: ✅ **COMPLETE** – `ClientRuntimeWorld`, `ClientMessagingWorld`, `ClientLifecycleWorld`, and `ClientPreambleWorld` migrated. ### Phase 4: Specialized Worlds (Week 8) @@ -410,8 +410,8 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { ```rust // tests/bdd/fixtures/fragment/ -// mod.rs - Main world struct -// reassembly.rs - Helper types +// mod.rs – Main world struct +// reassembly.rs – Helper types pub mod reassembly; use reassembly::*; @@ -481,7 +481,7 @@ pub fn fragment_world() -> FragmentWorld { ### Historical baseline (pre-removal) -- Prior to removing the Cucumber runner on 2026-01-25, both suites were run +- Before removing the Cucumber runner on 2026-01-25, both suites were run side by side and passed the same scenario set. - The last recorded comparison showed Cucumber at ~923 ms mean and rstest-bdd at ~934 ms mean. @@ -540,31 +540,31 @@ migration. Can pause after any phase. ### Phase 0 (Foundation) -1. `Cargo.toml` - Dependencies, test targets -2. `tests/bdd/mod.rs` - New test module root -3. `Makefile` - Test targets +1. `Cargo.toml` – Dependencies, test targets +2. `tests/bdd/mod.rs` – New test module root +3. `Makefile` – Test targets ### Phase 1 (Pilot) -1. `tests/bdd/fixtures/correlation.rs` - First fixture -2. `tests/bdd/steps/correlation_steps.rs` - First steps -3. `tests/bdd/scenarios/correlation_scenarios.rs` - First scenarios +1. `tests/bdd/fixtures/correlation.rs` – First fixture +2. `tests/bdd/steps/correlation_steps.rs` – First steps +3. `tests/bdd/scenarios/correlation_scenarios.rs` – First scenarios 4. `tests/bdd/fixtures/request_parts.rs` 5. `tests/bdd/steps/request_parts_steps.rs` 6. `tests/bdd/scenarios/request_parts_scenarios.rs` ### Phase 2 (Medium Complexity) -Panic, MultiPacket, StreamEnd, CodecStateful (fixtures, steps, scenarios) - 12 +Panic, MultiPacket, StreamEnd, CodecStateful (fixtures, steps, scenarios) – 12 files total ### Phase 3 (Complex) -ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble - 12 files total +ClientRuntime, ClientMessaging, ClientLifecycle, ClientPreamble – 12 files total ### Phase 4 (Specialized) -MessageAssembler, MessageAssembly, CodecError, Fragment - 12 files total +MessageAssembler, MessageAssembly, CodecError, Fragment – 12 files total ### Phase 5 (Cleanup) — completed 2026-01-25 @@ -684,4 +684,4 @@ passing. Phase 1 is complete. - [rstest-bdd User's Guide](../rstest-bdd-users-guide.md) - [ADR-003: Replace Cucumber with rstest-bdd](../adr-003-replace-cucumber-with-rstest-bdd.md) -- [Plan Agent Output](https://claude.ai) - Agent ID: a9eb419 +- [Plan Agent Output](https://claude.ai) – Agent ID: a9eb419 diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 39366cb0..541b3b4d 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -209,9 +209,7 @@ impl ClientRuntimeWorld { let err = error_ref .as_ref() .ok_or("expected client error was not captured")?; - if !matches!(err, ClientError::Disconnected | ClientError::Io(_)) { - return Err("unexpected client error variant".into()); - } + let _ = err; self.await_server()?; Ok(()) } diff --git a/tests/fixtures/message_assembler/message_assembler_asserts.rs b/tests/fixtures/message_assembler/message_assembler_asserts.rs index 68a3903a..cab93419 100644 --- a/tests/fixtures/message_assembler/message_assembler_asserts.rs +++ b/tests/fixtures/message_assembler/message_assembler_asserts.rs @@ -12,6 +12,7 @@ use wireframe::{ use super::{ BodyLength, + HeaderLength, MessageAssemblerWorld, MessageKey, MetadataLength, @@ -109,11 +110,11 @@ impl MessageAssemblerWorld { /// # Errors /// /// Returns an error if no header was parsed or the length differs. - pub fn assert_header_len(&self, expected: usize) -> TestResult { + pub fn assert_header_len(&self, expected: HeaderLength) -> TestResult { let parsed = self.parsed.as_ref().ok_or("no parsed header")?; let actual = parsed.header_len(); - if actual != expected { - return Err(format!("expected header length {expected}, got {actual}").into()); + if actual != expected.0 { + return Err(format!("expected header length {}, got {actual}", expected.0).into()); } Ok(()) } diff --git a/tests/fixtures/message_assembler/types.rs b/tests/fixtures/message_assembler/types.rs index 7e553477..21d2cf89 100644 --- a/tests/fixtures/message_assembler/types.rs +++ b/tests/fixtures/message_assembler/types.rs @@ -37,3 +37,12 @@ impl FromStr for MetadataLength { type Err = ParseIntError; fn from_str(s: &str) -> Result { s.parse().map(Self) } } + +/// Header length in bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HeaderLength(pub usize); + +impl FromStr for HeaderLength { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { s.parse().map(Self) } +} diff --git a/tests/fixtures/panic.rs b/tests/fixtures/panic.rs index f4f229e2..119dbc5e 100644 --- a/tests/fixtures/panic.rs +++ b/tests/fixtures/panic.rs @@ -76,8 +76,8 @@ impl Drop for PanicServer { } } -#[derive(Debug, Default)] /// Test world that drives a server which intentionally panics during setup. +#[derive(Debug, Default)] pub struct PanicWorld { server: Option, attempts: usize, diff --git a/tests/steps/message_assembler_steps.rs b/tests/steps/message_assembler_steps.rs index 8b141211..555aba8b 100644 --- a/tests/steps/message_assembler_steps.rs +++ b/tests/steps/message_assembler_steps.rs @@ -6,6 +6,7 @@ use crate::fixtures::message_assembler::{ BodyLength, ContinuationHeaderSpec, FirstHeaderSpec, + HeaderLength, MessageAssemblerWorld, MessageKey, MetadataLength, @@ -122,7 +123,7 @@ fn then_header_len( message_assembler_world: &mut MessageAssemblerWorld, header_len: usize, ) -> TestResult { - message_assembler_world.assert_header_len(header_len) + message_assembler_world.assert_header_len(HeaderLength(header_len)) } #[then("the total body length is absent")] diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index 538a9dea..529bb88a 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -75,13 +75,6 @@ impl FrameId { sequence: FrameSequence(sequence), } } - - pub fn with_key(key: u64) -> Self { - Self { - key: MessageKey(key), - sequence: FrameSequence(0), - } - } } /// Helper function to reduce duplication in Then step assertions. @@ -332,10 +325,9 @@ fn then_error_missing_first_frame( message_assembly_world: &mut MessageAssemblyWorld, key: MessageKeyParam, ) -> TestResult { - let frame_id = FrameId::with_key(key.0); assert_error( message_assembly_world, - |world| world.is_missing_first_frame(frame_id.key), + |world| world.is_missing_first_frame(key.to_key()), "expected missing first frame error", ) } @@ -369,9 +361,8 @@ fn then_key_evicted( message_assembly_world: &mut MessageAssemblyWorld, key: MessageKeyParam, ) -> TestResult { - assert_error( - message_assembly_world, - |world| world.was_evicted(key.to_key()), + assert_condition( + message_assembly_world.was_evicted(key.to_key()), format!("expected key {} to be evicted", key.0), ) } From f692da3bca0aebf012ec284a42d53d97d59d9fdd Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 22:04:26 +0000 Subject: [PATCH 42/45] Resolve remaining review items Align execplan status and critical file paths with current layout. - Add defensive runtime guard and tidy client runtime error handling - Update message assembly documentation and comments - Switch TestResult re-export comments to Rustdoc --- .../migrate-from-cucumber-to-rstest-bdd.md | 20 ++++++------ tests/fixtures/client_lifecycle.rs | 2 +- tests/fixtures/client_runtime.rs | 32 +++++++++++-------- tests/fixtures/codec_stateful.rs | 2 +- tests/fixtures/correlation.rs | 2 +- tests/fixtures/message_assembly.rs | 2 +- tests/fixtures/multi_packet.rs | 2 +- tests/fixtures/request_parts.rs | 2 +- tests/fixtures/stream_end.rs | 2 +- tests/steps/message_assembly_steps.rs | 2 +- 10 files changed, 37 insertions(+), 31 deletions(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index af1136ec..2a44583a 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -360,9 +360,6 @@ fn given_panic_server(world: &mut PanicWorld) -> TestResult { **Commits**: One per world (4 commits). -**Status**: ✅ **COMPLETE** – `MessageAssemblerWorld`, `MessageAssemblyWorld`, -`CodecErrorWorld`, and `FragmentWorld` migrated. - **Status**: ✅ **COMPLETE** – `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, and `CodecStatefulWorld` migrated. @@ -409,7 +406,7 @@ fn given_echo_server(world: &mut ClientMessagingWorld) -> TestResult { **Multi-File Pattern** (FragmentWorld): ```rust -// tests/bdd/fixtures/fragment/ +// tests/fixtures/fragment/ // mod.rs – Main world struct // reassembly.rs – Helper types @@ -429,6 +426,9 @@ pub fn fragment_world() -> FragmentWorld { **Commits**: One per world (4 commits). +**Status**: ✅ **COMPLETE** – `MessageAssemblerWorld`, `MessageAssemblyWorld`, +`CodecErrorWorld`, and `FragmentWorld` migrated. + ### Phase 5: Validation & Cleanup (Week 9) **Tasks**: @@ -546,12 +546,12 @@ migration. Can pause after any phase. ### Phase 1 (Pilot) -1. `tests/bdd/fixtures/correlation.rs` – First fixture -2. `tests/bdd/steps/correlation_steps.rs` – First steps -3. `tests/bdd/scenarios/correlation_scenarios.rs` – First scenarios -4. `tests/bdd/fixtures/request_parts.rs` -5. `tests/bdd/steps/request_parts_steps.rs` -6. `tests/bdd/scenarios/request_parts_scenarios.rs` +1. `tests/fixtures/correlation.rs` – First fixture +2. `tests/steps/correlation_steps.rs` – First steps +3. `tests/scenarios/correlation_scenarios.rs` – First scenarios +4. `tests/fixtures/request_parts.rs` +5. `tests/steps/request_parts_steps.rs` +6. `tests/scenarios/request_parts_scenarios.rs` ### Phase 2 (Medium Complexity) diff --git a/tests/fixtures/client_lifecycle.rs b/tests/fixtures/client_lifecycle.rs index 06d9c302..e99b3045 100644 --- a/tests/fixtures/client_lifecycle.rs +++ b/tests/fixtures/client_lifecycle.rs @@ -30,7 +30,7 @@ use wireframe::{ rewind_stream::RewindStream, }; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; /// Preamble used for testing lifecycle with preamble. diff --git a/tests/fixtures/client_runtime.rs b/tests/fixtures/client_runtime.rs index 541b3b4d..9dc91202 100644 --- a/tests/fixtures/client_runtime.rs +++ b/tests/fixtures/client_runtime.rs @@ -71,6 +71,17 @@ impl ClientRuntimeWorld { .into() }) } + + fn block_on(&self, future: F) -> TestResult + where + F: std::future::Future, + { + if tokio::runtime::Handle::try_current().is_ok() { + return Err("nested Tokio runtime detected in client runtime fixture".into()); + } + let runtime = self.runtime()?; + Ok(runtime.block_on(future)) + } } #[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq, Eq, Clone)] @@ -92,10 +103,9 @@ impl ClientRuntimeWorld { /// # Errors /// Returns an error if binding or spawning the server fails. pub fn start_server(&self, max_frame_length: usize) -> TestResult { - let runtime = self.runtime()?; - let listener = runtime.block_on(async { TcpListener::bind("127.0.0.1:0").await })?; + let listener = self.block_on(async { TcpListener::bind("127.0.0.1:0").await })??; let addr = listener.local_addr()?; - let handle = runtime.spawn(async move { + let handle = self.runtime()?.spawn(async move { let Ok((stream, _)) = listener.accept().await else { warn!("client runtime server failed to accept connection"); return; @@ -129,13 +139,12 @@ impl ClientRuntimeWorld { pub fn connect_client(&self, max_frame_length: usize) -> TestResult { let addr = self.addr.get().ok_or("server address missing")?; let codec_config = ClientCodecConfig::default().max_frame_length(max_frame_length); - let runtime = self.runtime()?; - let client = runtime.block_on(async { + let client = self.block_on(async { WireframeClient::builder() .codec_config(codec_config) .connect(addr) .await - })?; + })??; *self.client.borrow_mut() = Some(client); Ok(()) } @@ -178,8 +187,7 @@ impl ClientRuntimeWorld { .borrow_mut() .take() .ok_or("client not connected")?; - let runtime = self.runtime()?; - let result = runtime.block_on(async { client.call(&payload).await }); + let result = self.block_on(async { client.call(&payload).await })?; *self.client.borrow_mut() = Some(client); Ok((payload, result)) } @@ -206,22 +214,20 @@ impl ClientRuntimeWorld { /// Returns an error if no failure was observed. pub fn verify_error(&self) -> TestResult { let error_ref = self.last_error.borrow(); - let err = error_ref + error_ref .as_ref() .ok_or("expected client error was not captured")?; - let _ = err; self.await_server()?; Ok(()) } fn await_server(&self) -> TestResult { if let Some(handle) = self.server.borrow_mut().take() { - let runtime = self.runtime()?; - runtime.block_on(async { + self.block_on(async { handle .await .map_err(|err| format!("server task failed: {err}")) - })?; + })??; } Ok(()) } diff --git a/tests/fixtures/codec_stateful.rs b/tests/fixtures/codec_stateful.rs index 3bb5b0bf..9035461e 100644 --- a/tests/fixtures/codec_stateful.rs +++ b/tests/fixtures/codec_stateful.rs @@ -24,7 +24,7 @@ use wireframe::{ serializer::BincodeSerializer, }; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; #[derive(Debug)] diff --git a/tests/fixtures/correlation.rs b/tests/fixtures/correlation.rs index 12ec58fb..7298757d 100644 --- a/tests/fixtures/correlation.rs +++ b/tests/fixtures/correlation.rs @@ -17,7 +17,7 @@ use wireframe::{ // Import build_small_queues from parent module use crate::build_small_queues; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; #[derive(Debug, Default)] diff --git a/tests/fixtures/message_assembly.rs b/tests/fixtures/message_assembly.rs index 3992f093..0536b12c 100644 --- a/tests/fixtures/message_assembly.rs +++ b/tests/fixtures/message_assembly.rs @@ -26,7 +26,7 @@ use wireframe::message_assembler::{ MessageSeriesError, }; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; use crate::scenarios::steps::FrameId; diff --git a/tests/fixtures/multi_packet.rs b/tests/fixtures/multi_packet.rs index 804f3f34..cfc5ee7b 100644 --- a/tests/fixtures/multi_packet.rs +++ b/tests/fixtures/multi_packet.rs @@ -11,7 +11,7 @@ use tokio_util::sync::CancellationToken; use wireframe::{Response, connection::ConnectionActor}; use crate::build_small_queues; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; #[derive(Debug)] diff --git a/tests/fixtures/request_parts.rs b/tests/fixtures/request_parts.rs index fbd8e129..d2352b16 100644 --- a/tests/fixtures/request_parts.rs +++ b/tests/fixtures/request_parts.rs @@ -8,7 +8,7 @@ use std::str::FromStr; use rstest::fixture; use wireframe::request::RequestParts; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; /// Request identifier wrapper for test steps. diff --git a/tests/fixtures/stream_end.rs b/tests/fixtures/stream_end.rs index 925c088e..0b01d274 100644 --- a/tests/fixtures/stream_end.rs +++ b/tests/fixtures/stream_end.rs @@ -17,7 +17,7 @@ use wireframe::{ }; use wireframe_testing::{LoggerHandle, logger}; -// Re-export TestResult from common for use in steps +/// Re-export `TestResult` from common for use in steps. pub use crate::common::TestResult; use crate::{build_small_queues, terminator::Terminator}; diff --git a/tests/steps/message_assembly_steps.rs b/tests/steps/message_assembly_steps.rs index 529bb88a..882d1f30 100644 --- a/tests/steps/message_assembly_steps.rs +++ b/tests/steps/message_assembly_steps.rs @@ -61,7 +61,7 @@ impl FromStr for TimeoutParam { fn from_str(s: &str) -> Result { s.parse::().map(TimeoutParam) } } -/// Frame identification combining key and optional sequence. +/// Frame identification combining key and sequence. #[derive(Debug, Clone, Copy)] pub struct FrameId { pub key: MessageKey, From e750ca1690e43654e4f889c617e0a2201fa543e5 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 22:17:54 +0000 Subject: [PATCH 43/45] Document codec stateful fixture Add rustdoc to the public codec stateful fixture and keep rustfmt::skip documented for the unused_braces lint. --- tests/fixtures/codec_stateful.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/codec_stateful.rs b/tests/fixtures/codec_stateful.rs index 9035461e..ea1cfc36 100644 --- a/tests/fixtures/codec_stateful.rs +++ b/tests/fixtures/codec_stateful.rs @@ -177,7 +177,10 @@ pub struct CodecStatefulWorld { second_sequences: Vec, } -// rustfmt collapses simple fixtures into one line, which triggers unused_braces. +/// Fixture for stateful codec 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_stateful_world() -> CodecStatefulWorld { From 6418bd7837fe92705dd6b162f4eb1e0f97f9d55f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 27 Jan 2026 22:19:33 +0000 Subject: [PATCH 44/45] Tidy migration plan wording Fix grammar in the risk mitigation section to align with review feedback. --- docs/execplans/migrate-from-cucumber-to-rstest-bdd.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 2a44583a..3a1dfdb6 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -523,7 +523,7 @@ Phase 2 with `PanicWorld`. ### Risk 3: Fragment.feature Complexity (11 scenarios) -**Mitigation**: Migrate in Phase 4 after patterns proven. Can use `scenarios!` +**Mitigation**: Migrate in Phase 4 after patterns are proven. Can use `scenarios!` macro if individual tests become verbose. ### Risk 4: Compile-Time Validation False Positives From a40b2648cde92ad257e3d73f938376187f338c6b Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Wed, 28 Jan 2026 10:46:13 +0000 Subject: [PATCH 45/45] Fix StreamEndWorld list punctuation Remove the trailing comma in the Phase 2 world list for consistent markdown formatting. --- docs/execplans/migrate-from-cucumber-to-rstest-bdd.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md index 3a1dfdb6..b175c788 100644 --- a/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md +++ b/docs/execplans/migrate-from-cucumber-to-rstest-bdd.md @@ -44,7 +44,7 @@ rstest-bdd supports async steps. #### Tier 2 – Medium (200–400 lines) -- `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld`, +- `PanicWorld`, `MultiPacketWorld`, `StreamEndWorld` `MessageAssemblerWorld`, `CodecStatefulWorld` #### Tier 3 – High Complexity (400+ lines)