Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,22 @@ describes one or more scenarios using the standard Given/When/Then syntax.
Scenario functions are annotated with `#[scenario(path = "…", name = "…")]` and
receive fixture parameters by name.

### Non-zero test values

[`tests/support/non_zero.rs`](../tests/support/non_zero.rs) defines the shared
`nz!` macro for test fixtures that require a `NonZeroUsize`. Use `nz!(value)`
for the default diagnostic or `nz!(value, "message")` to identify the fixture.
The macro constructs the value in a constant expression, so a zero argument is
rejected at compile time.

The message-assembler budget tests in
[`src/message_assembler/budget_tests.rs`](../src/message_assembler/budget_tests.rs)
and the fragment adapter tests in
[`src/fragment/tests/adapter_tests.rs`](../src/fragment/tests/adapter_tests.rs)
include this shared helper. Keep zero-value compile-fail coverage in
`tests/ui/`: the UI source includes the helper, and its case is registered in
`tests/compile_error.rs` with the expected `.stderr` diagnostic committed.

### trybuild compile-time tests

Compile-time API contracts live in
Expand Down
7 changes: 5 additions & 2 deletions src/app/inbound_handler/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ fn decode_envelope_tracks_failures_and_logs_correlation_id() {
&frame,
&mut failure_tracker,
);
assert!(result.is_ok(), "expected recoverable decode failure");
assert!(result.expect("decode result").is_none());
let decoded = result.expect("expected recoverable decode failure");
assert!(
decoded.is_none(),
"recoverable decode should not yield a message"
);
}

let mut failure_tracker =
Expand Down
56 changes: 15 additions & 41 deletions src/client/tests/error_handling.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Error handling tests for the wireframe client.

use std::{
future::Future,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
Expand All @@ -9,7 +10,7 @@ use std::{
};

use bytes::Bytes;
use futures::SinkExt;
use futures::{SinkExt, future::lazy};
use tokio::net::TcpStream;
use tokio_util::codec::{Framed, LengthDelimitedCodec};

Expand All @@ -27,6 +28,13 @@ use crate::{
serializer::Serializer,
};

/// Increment the error-hook count when its future is first polled.
fn record_error_count(count: Arc<AtomicUsize>) -> impl Future<Output = ()> + Send {
lazy(move |_| {
count.fetch_add(1, Ordering::SeqCst);
})
}

/// Connects a client and returns both the client and server stream for custom server behaviour.
///
/// This helper is used by tests that need to manipulate the server stream after connection,
Expand All @@ -48,18 +56,9 @@ where
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn error_callback_invoked_on_receive_error() {
let error_count = test_error_hook_on_disconnect(|builder, count| {
builder.on_error(move |_err| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
}
})
builder.on_error(move |_err| record_error_count(count.clone()))
})
.await
.expect("run error hook scenario");
Expand Down Expand Up @@ -87,21 +86,12 @@ async fn no_error_hook_does_not_panic() {
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn error_callback_invoked_on_deserialize_error() {
let error_count = Arc::new(AtomicUsize::new(0));
let count = error_count.clone();

let (mut client, server_stream) = connect_with_server(|builder| {
builder.on_error(move |_err| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
}
})
builder.on_error(move |_err| record_error_count(count.clone()))
})
.await
.expect("connect client and server");
Expand Down Expand Up @@ -136,10 +126,6 @@ async fn error_callback_invoked_on_deserialize_error() {
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn error_callback_invoked_on_send_io_error() {
#[derive(bincode::Encode, bincode::BorrowDecode)]
struct TestMessage(Vec<u8>);
Expand All @@ -148,12 +134,7 @@ async fn error_callback_invoked_on_send_io_error() {
let count = error_count.clone();

let (mut client, server_stream) = connect_with_server(|builder| {
builder.on_error(move |_err| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
}
})
builder.on_error(move |_err| record_error_count(count.clone()))
})
.await
.expect("connect client and server");
Expand Down Expand Up @@ -187,10 +168,6 @@ async fn error_callback_invoked_on_send_io_error() {
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn error_callback_invoked_on_serialize_error() {
#[derive(bincode::Encode, bincode::BorrowDecode)]
struct TestMessage(u32);
Expand All @@ -199,12 +176,9 @@ async fn error_callback_invoked_on_serialize_error() {
let count = error_count.clone();

let (mut client, _server) = connect_with_server(|builder| {
builder.serializer(FailingSerializer).on_error(move |_err| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
}
})
builder
.serializer(FailingSerializer)
.on_error(move |_err| record_error_count(count.clone()))
})
.await
.expect("connect client and server");
Expand Down
73 changes: 33 additions & 40 deletions src/client/tests/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,38 @@
//! Lifecycle hook tests for the wireframe client.

use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
use std::{
future::Future,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};

use futures::future::lazy;

use super::helpers::{counting_hook, test_error_hook_on_disconnect, test_with_client};

/// Increment a lifecycle-hook count when its future is first polled.
fn record_count(count: Arc<AtomicUsize>) -> impl Future<Output = ()> + Send {
lazy(move |_| {
count.fetch_add(1, Ordering::SeqCst);
})
}

/// Count setup when the callback future is first polled, then return the
/// state that the teardown callback receives.
async fn record_setup(count: Arc<AtomicUsize>) -> &'static str {
record_count(count).await;
"state"
}

/// Store the setup state when the teardown callback future is first polled.
fn record_teardown_state(value: Arc<AtomicUsize>, state: usize) -> impl Future<Output = ()> + Send {
lazy(move |_| {
value.store(state, Ordering::SeqCst);
})
}

#[tokio::test]
async fn setup_callback_invoked_on_connect() {
let (setup_count, increment) = counting_hook();
Expand All @@ -23,23 +49,14 @@ async fn setup_callback_invoked_on_connect() {
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn teardown_callback_receives_setup_state() {
let teardown_value = Arc::new(AtomicUsize::new(0));
let value = teardown_value.clone();

let client = test_with_client(|builder| {
builder
.on_connection_setup(|| async { 42usize })
.on_connection_teardown(move |state| {
let value = value.clone();
async move {
value.store(state, Ordering::SeqCst);
}
})
.on_connection_teardown(move |state| record_teardown_state(value.clone(), state))
})
.await
.expect("connect client");
Expand Down Expand Up @@ -73,10 +90,6 @@ async fn teardown_without_setup_does_not_run() {
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn setup_and_teardown_callbacks_run() {
let setup_count = Arc::new(AtomicUsize::new(0));
let teardown_count = Arc::new(AtomicUsize::new(0));
Expand All @@ -85,19 +98,8 @@ async fn setup_and_teardown_callbacks_run() {

let client = test_with_client(|builder| {
builder
.on_connection_setup(move || {
let setup = setup.clone();
async move {
setup.fetch_add(1, Ordering::SeqCst);
"state"
}
})
.on_connection_teardown(move |_: &str| {
let teardown = teardown.clone();
async move {
teardown.fetch_add(1, Ordering::SeqCst);
}
})
.on_connection_setup(move || record_setup(setup.clone()))
.on_connection_teardown(move |_: &str| record_count(teardown.clone()))
})
.await
.expect("connect client");
Expand All @@ -118,21 +120,12 @@ async fn setup_and_teardown_callbacks_run() {
}

#[tokio::test]
#[expect(
clippy::excessive_nesting,
reason = "async closures within builder patterns are inherently nested"
)]
async fn on_connection_setup_preserves_error_hook() {
// Configure on_error first, then on_connection_setup.
// The error hook should be preserved.
let error_count = test_error_hook_on_disconnect(|builder, count| {
builder
.on_error(move |_err| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
}
})
.on_error(move |_err| record_count(count.clone()))
.on_connection_setup(|| async { 42u32 })
})
.await
Expand Down
10 changes: 1 addition & 9 deletions src/client/tests/send_streaming_infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,7 @@ pub(super) async fn create_send_client_with_error_hook(
}

/// Generate a body of `n` bytes for testing: cycles through 0–255.
#[expect(
clippy::integer_division_remainder_used,
reason = "modulo generates a deterministic test byte pattern"
)]
#[expect(
clippy::cast_possible_truncation,
reason = "value is modulo 256, guaranteed to fit in u8"
)]
pub(super) fn test_body(n: usize) -> Vec<u8> { (0..n).map(|i| (i % 256) as u8).collect() }
pub(super) fn test_body(n: usize) -> Vec<u8> { (u8::MIN..=u8::MAX).cycle().take(n).collect() }

type ByteResult = Result<bytes::Bytes, std::io::Error>;
type BlockingStream = tokio_stream::wrappers::ReceiverStream<ByteResult>;
Expand Down
17 changes: 12 additions & 5 deletions src/client/tests/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ where
.next()
.await
.ok_or("stream ended before yielding a data frame")??;
assert_eq!(frame.payload, expected_payload);
if frame.payload != expected_payload {
return Err(format!(
"stream payload mismatch: got {:?}, expected {:?}",
frame.payload, expected_payload
)
.into());
}

let end = stream.next().await;
assert!(end.is_none(), "stream should terminate");
if end.is_some() {
return Err("stream should terminate after one data frame".into());
}
Ok(())
}

Expand Down Expand Up @@ -98,9 +106,8 @@ async fn response_stream_terminates_on_terminator(
.expect("call_streaming");

// First item is the data frame.
let first = stream.next().await;
assert!(first.is_some(), "should yield one data frame");
assert!(first.expect("some").is_ok(), "data frame should be Ok");
let first = stream.next().await.expect("should yield one data frame");
assert!(first.is_ok(), "data frame should be Ok");

// Mid-stream, before the terminator is polled, the stream must report
// that it has not terminated (guards the `is_terminated` false polarity).
Expand Down
25 changes: 16 additions & 9 deletions src/fragment/tests/adapter_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,20 @@ impl Fragmentable for TestPacket {
/// Result alias for fallible fragment-adapter test helpers.
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;

fn adapter_config() -> TestResult<FragmentationConfig> {
Ok(FragmentationConfig {
fragment_payload_cap: NonZeroUsize::new(4)
.ok_or("fragment payload cap must be non-zero")?,
max_message_size: NonZeroUsize::new(64).ok_or("max message size must be non-zero")?,
include!("../../../tests/support/non_zero.rs");

/// Fixture payload cap; an accidental zero fails during compilation.
const FRAGMENT_PAYLOAD_CAP: NonZeroUsize = nz!(4, "fragment payload cap must be non-zero");

/// Fixture maximum message size; an accidental zero fails during compilation.
const MAX_MESSAGE_SIZE: NonZeroUsize = nz!(64, "max message size must be non-zero");

fn adapter_config() -> FragmentationConfig {
FragmentationConfig {
fragment_payload_cap: FRAGMENT_PAYLOAD_CAP,
max_message_size: MAX_MESSAGE_SIZE,
reassembly_timeout: Duration::from_secs(30),
})
}
}

fn build_test_packet() -> TestPacket {
Expand Down Expand Up @@ -91,7 +98,7 @@ fn reassemble_fragment_sequence(

#[test]
fn default_fragment_adapter_fragments_and_reassembles_test_packets() {
let mut adapter = DefaultFragmentAdapter::new(adapter_config().expect("build adapter config"));
let mut adapter = DefaultFragmentAdapter::new(adapter_config());
let packet = build_test_packet();

let fragments = adapter
Expand All @@ -111,7 +118,7 @@ fn default_fragment_adapter_fragments_and_reassembles_test_packets() {

#[test]
fn default_fragment_adapter_passes_through_non_fragment_payloads() {
let mut adapter = DefaultFragmentAdapter::new(adapter_config().expect("build adapter config"));
let mut adapter = DefaultFragmentAdapter::new(adapter_config());
let packet = TestPacket {
id: 12,
correlation_id: Some(9),
Expand All @@ -127,7 +134,7 @@ fn default_fragment_adapter_passes_through_non_fragment_payloads() {

#[test]
fn default_fragment_adapter_exposes_purge_api() {
let mut config = adapter_config().expect("build adapter config");
let mut config = adapter_config();
config.reassembly_timeout = Duration::ZERO;
let mut adapter = DefaultFragmentAdapter::new(config);
let header = FragmentHeader::new(MessageId::new(81), FragmentIndex::zero(), false);
Expand Down
Loading
Loading