From 26947935f440d9c9a19d804cddaf0d05627958d6 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 23:32:13 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`codex/i?= =?UTF-8?q?mplement-optional-dead-letter-queue`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @leynos. * https://github.com/leynos/wireframe/pull/198#issuecomment-3047332173 The following files were modified: * `src/app.rs` * `src/push.rs` * `tests/push.rs` * `tests/push_policies.rs` * `wireframe_testing/src/logging.rs` --- src/app.rs | 73 ++++++++++++++------ src/push.rs | 63 ++++++++++------- tests/push.rs | 99 ++++++++++++++++++++++++++- tests/push_policies.rs | 112 ++++++++++++++++++++++++++++++- wireframe_testing/src/logging.rs | 58 ++++++++++++++-- 5 files changed, 352 insertions(+), 53 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4dfabffe..29367f14 100644 --- a/src/app.rs +++ b/src/app.rs @@ -227,10 +227,15 @@ where C: Send + 'static, E: Packet, { + /// Creates a new `WireframeApp` with no routes, services, middleware, or application data. /// - /// Initialises empty routes, services, middleware, and application data. - /// Sets the default frame processor and serializer, with no connection - /// lifecycle hooks. + /// The default frame processor and serializer are set, and no connection lifecycle hooks or dead letter queue are configured. + /// + /// # Examples + /// + /// ```no_run + /// let app = WireframeApp::<_, (), Envelope>::default(); + /// ``` fn default() -> Self { Self { routes: HashMap::new(), @@ -330,22 +335,29 @@ where Ok(self) } - /// Register a callback invoked when a new connection is established. + /// Registers an asynchronous callback to be invoked when a new connection is established. /// - /// The callback can perform authentication or other setup tasks and - /// returns connection-specific state stored for the connection's - /// lifetime. + /// The callback can perform authentication or setup tasks and returns connection-specific state, + /// which is stored for the lifetime of the connection. This method changes the connection state + /// type parameter from `C` to `C2`, so subsequent builder methods will operate on the new state type. /// /// # Type Parameters /// - /// This method changes the connection state type parameter from `C` to `C2`. - /// This means that any subsequent builder methods will operate on the new connection state type - /// `C2`. Be aware of this type transition when chaining builder methods. + /// - `C2`: The new connection state type returned by the setup callback. /// - /// # Errors + /// # Returns /// - /// This function always succeeds currently but uses [`Result`] for - /// consistency with other builder methods. + /// Returns a new `WireframeApp` builder with the updated connection state type. + /// + /// # Examples + /// + /// ```no_run + /// use wireframe::WireframeApp; + /// + /// let app = WireframeApp::default() + /// .on_connection_setup(|| async { /* perform setup */ 42 }) + /// .unwrap(); + /// ``` pub fn on_connection_setup(self, f: F) -> Result> where F: Fn() -> Fut + Send + Sync + 'static, @@ -384,11 +396,21 @@ where Ok(self) } - /// Install a [`WireframeProtocol`] implementation. + /// Installs a custom `WireframeProtocol` for connection and frame lifecycle hooks. + /// + /// The provided protocol is wrapped in an `Arc` and stored for use by the connection actor, + /// enabling custom behaviour for connection setup, frame modification, and command completion. /// - /// The protocol defines hooks for connection setup, frame modification, and - /// command completion. It is wrapped in an [`Arc`] and stored for later use - /// by the connection actor. + /// # Returns + /// + /// The updated builder with the protocol installed. + /// + /// # Examples + /// + /// ```no_run + /// let app = WireframeApp::default() + /// .with_protocol(MyProtocol::new()); + /// ``` #[must_use] pub fn with_protocol

(mut self, protocol: P) -> Self where @@ -398,7 +420,12 @@ where self } - /// Configure a Dead Letter Queue for dropped push frames. + /// Sets a Dead Letter Queue (DLQ) channel to receive dropped push frames. + /// + /// This allows the application to capture or forward push frames that could not be delivered, + /// by sending their raw bytes to the provided asynchronous channel. + /// + /// # Examples /// /// ```rust,no_run /// use tokio::sync::mpsc; @@ -448,7 +475,15 @@ where self } - /// Replace the serializer used for messages. + /// Sets a custom serializer for message encoding and decoding, returning a new builder instance with the specified serializer. + /// + /// # Examples + /// + /// ```no_run + /// use mycrate::{WireframeApp, BincodeSerializer}; + /// + /// let app = WireframeApp::default().serializer(BincodeSerializer::default()); + /// ``` #[must_use] pub fn serializer(self, serializer: Ser) -> WireframeApp where diff --git a/src/push.rs b/src/push.rs index 4bdbbbc3..1ccd3e74 100644 --- a/src/push.rs +++ b/src/push.rs @@ -73,6 +73,7 @@ pub enum PushConfigError { } impl std::fmt::Display for PushConfigError { + /// Formats a `PushConfigError` for display, providing details about the invalid rate value. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidRate(r) => { @@ -139,9 +140,9 @@ impl PushHandle { self.push_with_priority(frame, PushPriority::High).await } - /// Push a low-priority frame subject to rate limiting. + /// Pushes a low-priority frame to the queue, applying rate limiting if configured. /// - /// Awaits if the rate limiter has no available tokens or the queue is full. + /// This method waits if the rate limiter has no available tokens or if the low-priority queue is full. The frame is sent with low priority and will be delivered after any high-priority frames. /// /// # Errors /// @@ -154,7 +155,7 @@ impl PushHandle { /// /// #[tokio::test] /// async fn example() { - /// let (mut queues, handle) = PushQueues::bounded_with_rate(1, 1, Some(1)); + /// let (mut queues, handle) = PushQueues::bounded_with_rate(1, 1, Some(1)).unwrap(); /// handle.push_low_priority(10u8).await.unwrap(); /// let (priority, frame) = queues.recv().await.unwrap(); /// assert_eq!(priority, PushPriority::Low); @@ -165,7 +166,17 @@ impl PushHandle { self.push_with_priority(frame, PushPriority::Low).await } - /// Send a frame to the configured dead letter queue if available. + /// Attempts to send a frame to the configured dead letter queue (DLQ), if present. + /// + /// If the DLQ is full or closed, logs an error indicating the frame was lost. + /// + /// # Examples + /// + /// ```no_run + /// // Assume `handle` is a PushHandle with a configured DLQ. + /// handle.route_to_dlq(frame); + /// // If the DLQ is full or closed, an error is logged and the frame is dropped. + /// ``` fn route_to_dlq(&self, frame: F) { if let Some(dlq) = &self.0.dlq_tx { match dlq.try_send(frame) { @@ -180,15 +191,13 @@ impl PushHandle { } } - /// Attempt to push a frame with the given priority and policy. + /// Attempts to push a frame to the queue with the specified priority and policy. + /// + /// If the queue is full, the behaviour depends on the provided policy: an error is returned, the frame is dropped, or a warning is logged and the frame is dropped. Dropped frames are routed to the dead letter queue if one is configured. /// /// # Errors /// - /// Returns [`PushError::QueueFull`] if the queue is full and the policy is - /// [`PushPolicy::ReturnErrorIfFull`]. Returns [`PushError::Closed`] if the - /// receiving end has been dropped. When [`PushPolicy::DropIfFull`] or - /// [`PushPolicy::WarnAndDropIfFull`] is used, a configured dead letter queue - /// receives the dropped frame. + /// Returns [`PushError::QueueFull`] if the queue is full and the policy is [`PushPolicy::ReturnErrorIfFull`]. Returns [`PushError::Closed`] if the receiving end has been dropped. When [`PushPolicy::DropIfFull`] or [`PushPolicy::WarnAndDropIfFull`] is used, a configured dead letter queue receives the dropped frame. /// /// # Examples /// @@ -249,8 +258,9 @@ pub struct PushQueues { } impl PushQueues { - /// Create a new set of queues with the specified bounds for each priority - /// and return them along with a [`PushHandle`] for producers. + /// Creates a new set of bounded push queues for high and low priority frames, returning the queues and a [`PushHandle`] for producers. + /// + /// The queues are rate-limited to the default push rate. Use this when you want simple bounded queues with prioritisation and default rate limiting. /// /// # Examples /// @@ -270,13 +280,14 @@ impl PushQueues { /// # Panics /// /// Panics if an internal invariant is violated. This should never occur. - #[must_use] pub fn bounded(high_capacity: usize, low_capacity: usize) -> (Self, PushHandle) { Self::bounded_with_rate_dlq(high_capacity, low_capacity, Some(DEFAULT_PUSH_RATE), None) .expect("DEFAULT_PUSH_RATE is always valid") } - /// Create queues with no rate limiting. + /// Creates high- and low-priority push queues with bounded capacity and no rate limiting. + /// + /// Returns a tuple containing the push queues and a handle for pushing frames. Both queues are bounded by the specified capacities, and no rate limiting is applied to frame pushes. /// /// # Examples /// @@ -298,16 +309,17 @@ impl PushQueues { Self::bounded_with_rate_dlq(high_capacity, low_capacity, None, None).unwrap() } - /// Create queues with a custom rate limit in pushes per second. + /// Creates prioritised push queues with an optional global rate limit. /// - /// The limiter enforces fairness by allowing at most `rate` pushes - /// per second across all producers for the returned [`PushHandle`]. - /// Pass `None` to disable rate limiting entirely. + /// The returned queues support high and low priority channels. If `rate` is + /// specified, it limits the total number of pushes per second across all + /// producers using the associated [`PushHandle`]. Passing `None` disables + /// rate limiting entirely. /// /// # Errors /// - /// Returns [`PushConfigError::InvalidRate`] if `rate` is zero or greater - /// than [`MAX_PUSH_RATE`]. + /// Returns [`PushConfigError::InvalidRate`] if `rate` is zero or exceeds + /// [`MAX_PUSH_RATE`]. /// /// # Examples /// @@ -330,16 +342,15 @@ impl PushQueues { Self::bounded_with_rate_dlq(high_capacity, low_capacity, rate, None) } - /// Create queues with a custom rate limit and optional dead letter queue. + /// Creates prioritised push queues with optional rate limiting and dead letter queue support. /// - /// Frames that would be dropped by [`try_push`](PushHandle::try_push) when - /// using [`PushPolicy::DropIfFull`] or [`PushPolicy::WarnAndDropIfFull`] - /// are routed to `dlq` if provided. + /// Frames that would be dropped by [`try_push`](PushHandle::try_push) under + /// [`PushPolicy::DropIfFull`] or [`PushPolicy::WarnAndDropIfFull`] are routed to the provided + /// dead letter queue (`dlq`) if supplied. /// /// # Errors /// - /// Returns [`PushConfigError::InvalidRate`] if `rate` is zero or greater - /// than [`MAX_PUSH_RATE`]. + /// Returns [`PushConfigError::InvalidRate`] if `rate` is zero or exceeds [`MAX_PUSH_RATE`]. /// /// # Examples /// diff --git a/tests/push.rs b/tests/push.rs index 05fe000e..21406cb7 100644 --- a/tests/push.rs +++ b/tests/push.rs @@ -50,6 +50,19 @@ async fn push_queues_error_on_closed() { assert!(matches!(res, Err(PushError::Closed))); } +/// Tests that the rate limiter blocks pushes exceeding the configured rate limit for both +/// high and low priorities. +/// +/// After pushing one frame, a second push attempt is made and expected to time out, +/// demonstrating that the rate limiter enforces blocking. Advancing time allows a +/// subsequent push, and the test verifies that only the allowed frames are received. +/// +/// # Examples +/// +/// ```no_run +/// // This test is parameterised and runs for both high and low priorities. +/// // It verifies that the rate limiter blocks excess pushes and allows them after waiting. +/// ``` #[rstest] #[case::high(PushPriority::High)] #[case::low(PushPriority::Low)] @@ -85,6 +98,29 @@ async fn rate_limiter_blocks_when_exceeded(#[case] priority: PushPriority) { assert_eq!((first, second), (1, 3)); } +/// Tests that the rate limiter allows pushes after the wait interval has elapsed. +/// +/// This test verifies that after pushing a frame and waiting for the rate limiter's interval, +/// a subsequent push is permitted. It ensures that both frames are received in the correct order. +/// +/// # Examples +/// +/// ```no_run +/// # use wireframe::push::PushQueues; +/// # use tokio::time::{self, Duration}; +/// # #[tokio::main] +/// # async fn main() { +/// time::pause(); +/// let (mut queues, handle) = PushQueues::bounded_with_rate(2, 2, Some(1)).unwrap(); +/// handle.push_high_priority(1u8).await.unwrap(); +/// time::advance(Duration::from_secs(1)).await; +/// handle.push_high_priority(2u8).await.unwrap(); +/// +/// let (_, a) = queues.recv().await.unwrap(); +/// let (_, b) = queues.recv().await.unwrap(); +/// assert_eq!((a, b), (1, 2)); +/// # } +/// ``` #[tokio::test] async fn rate_limiter_allows_after_wait() { time::pause(); @@ -98,6 +134,38 @@ async fn rate_limiter_allows_after_wait() { assert_eq!((a, b), (1, 2)); } +/// Tests that the rate limiter is enforced across both high and low priority queues. +/// +/// Verifies that after pushing a high priority frame, a low priority push attempt blocks +/// until the rate limiter interval elapses, after which the push succeeds. Ensures that +/// the rate limiter is shared between priorities and that frames are received in the +/// correct order. +/// +/// # Examples +/// +/// ```no_run +/// # use wireframe::push::{PushQueues, PushPriority}; +/// # use tokio::time::{self, Duration}; +/// # #[tokio::main] +/// # async fn main() { +/// time::pause(); +/// let (mut queues, handle) = PushQueues::bounded_with_rate(2, 2, Some(1)).unwrap(); +/// handle.push_high_priority(1u8).await.unwrap(); +/// +/// let attempt = time::timeout(Duration::from_millis(10), handle.push_low_priority(2u8)).await; +/// assert!(attempt.is_err()); +/// +/// time::advance(Duration::from_secs(1)).await; +/// handle.push_low_priority(2u8).await.unwrap(); +/// +/// let (prio1, frame1) = queues.recv().await.unwrap(); +/// let (prio2, frame2) = queues.recv().await.unwrap(); +/// assert_eq!(prio1, PushPriority::High); +/// assert_eq!(frame1, 1); +/// assert_eq!(prio2, PushPriority::Low); +/// assert_eq!(frame2, 2); +/// # } +/// ``` #[tokio::test] async fn rate_limiter_shared_across_priorities() { time::pause(); @@ -131,7 +199,36 @@ async fn unlimited_queues_do_not_block() { assert_eq!((a, b), (1, 2)); } -#[tokio::test] +/// Tests that the rate limiter allows a burst of pushes within its configured capacity and blocks any excess until the interval elapses. +/// +/// This test pushes frames up to the burst capacity, verifies that an additional push is rate limited (blocked), then advances time to allow further pushes. It confirms that all frames are received in the expected order. +/// +/// # Examples +/// +/// ```no_run +/// # use wireframe::push::{PushQueues, PushPriority}; +/// # use tokio::time::{self, Duration}; +/// # #[tokio::main] +/// # async fn main() { +/// time::pause(); +/// let (mut queues, handle) = PushQueues::bounded_with_rate(4, 4, Some(3)).unwrap(); +/// +/// for i in 0u8..3 { +/// handle.push_high_priority(i).await.unwrap(); +/// } +/// +/// let res = time::timeout(Duration::from_millis(10), handle.push_high_priority(99)).await; +/// assert!(res.is_err()); +/// +/// time::advance(Duration::from_secs(1)).await; +/// handle.push_high_priority(100).await.unwrap(); +/// +/// for expected in [0u8, 1u8, 2u8, 100u8] { +/// let (_, frame) = queues.recv().await.unwrap(); +/// assert_eq!(frame, expected); +/// } +/// # } +/// ``` async fn rate_limiter_allows_burst_within_capacity_and_blocks_excess() { time::pause(); let (mut queues, handle) = PushQueues::bounded_with_rate(4, 4, Some(3)).unwrap(); diff --git a/tests/push_policies.rs b/tests/push_policies.rs index a5e334ed..1ade3fe8 100644 --- a/tests/push_policies.rs +++ b/tests/push_policies.rs @@ -11,9 +11,19 @@ use tokio::{ use wireframe::push::{PushPolicy, PushPriority, PushQueues}; use wireframe_testing::{LoggerHandle, logger}; +/// Creates a single-threaded Tokio runtime with all features enabled for use in tests. +/// +/// # Examples +/// +/// ```no_run +/// let runtime = rt(); +/// runtime.block_on(async { +/// // Run async test code here +/// }); +/// ``` #[allow( - unused_braces, - reason = "rustc false positive for single line rstest fixtures" +unused_braces, +reason = "rustc false positive for single line rstest fixtures" )] #[fixture] fn rt() -> Runtime { @@ -23,6 +33,24 @@ fn rt() -> Runtime { .expect("failed to build test runtime") } +/// Tests the behaviour of push queue policies when attempting to push to a full queue. +/// +/// Verifies that, depending on the specified `PushPolicy`, a warning log is emitted or not +/// when a push is dropped due to a full queue. Ensures only the first item is received from +/// the queue and that dropped items do not appear. Checks logger output for expected warning +/// messages based on the policy. +/// +/// # Parameters +/// +/// - `policy`: The push policy to apply when the queue is full. +/// - `expect_warning`: Whether a warning log is expected for the given policy. +/// - `expected_msg`: The message expected to appear in the warning log, if any. +/// +/// # Examples +/// +/// ```no_run +/// // Runs as part of the test suite; not intended for direct invocation. +/// ``` #[rstest] #[case::drop_if_full(PushPolicy::DropIfFull, false, "push queue full")] #[case::warn_and_drop(PushPolicy::WarnAndDropIfFull, true, "push queue full")] @@ -64,6 +92,17 @@ fn push_policy_behaviour( }); } +/// Tests that a dropped frame due to a full push queue is sent to the dead-letter queue (DLQ). +/// +/// This test pushes two items into a bounded queue with DLQ enabled. The first item is accepted, +/// and the second, which overflows the queue, is routed to the DLQ. The test asserts that the +/// first item is received from the main queue and the dropped item is received from the DLQ. +/// +/// # Examples +/// +/// ```no_run +/// dropped_frame_goes_to_dlq(rt()); +/// ``` #[rstest] fn dropped_frame_goes_to_dlq(rt: Runtime) { rt.block_on(async { @@ -82,12 +121,50 @@ fn dropped_frame_goes_to_dlq(rt: Runtime) { }); } +/// Fills the dead-letter queue (DLQ) channel to simulate a full DLQ state by sending a dummy value. +/// +/// This function is used in tests to ensure that subsequent attempts to send to the DLQ will fail due to capacity. +/// +/// # Examples +/// +/// ```no_run +/// let (tx, mut rx) = tokio::sync::mpsc::channel(1); +/// setup_dlq_full(&tx, &mut Some(rx)); +/// // The DLQ channel is now full. +/// ``` fn setup_dlq_full(tx: &mpsc::Sender, _rx: &mut Option>) { tx.try_send(99).unwrap(); } +/// Simulates a closed dead-letter queue (DLQ) by dropping its receiver. +/// +/// This function is used in tests to mimic the scenario where the DLQ channel +/// is closed and cannot receive any more messages. +/// +/// # Examples +/// +/// ```no_run +/// let (tx, mut rx) = tokio::sync::mpsc::channel(1); +/// setup_dlq_closed(&tx, &mut Some(rx)); +/// // The DLQ receiver is now dropped (closed). +/// ``` fn setup_dlq_closed(_: &mpsc::Sender, rx: &mut Option>) { drop(rx.take()); } +/// Asserts that the DLQ receiver contains the expected pre-filled value and is then empty. +/// +/// This function checks that the next message received from the provided DLQ receiver is +/// the value `99`, and that no further messages are available. +/// +/// # Examples +/// +/// ```no_run +/// use tokio::sync::mpsc; +/// # use your_crate::assert_dlq_full; +/// let (tx, mut rx) = mpsc::channel(1); +/// tx.blocking_send(99).unwrap(); +/// let mut rx_opt = Some(rx); +/// tokio_test::block_on(assert_dlq_full(&mut rx_opt)); +/// ``` fn assert_dlq_full(rx: &mut Option>) -> BoxFuture<'_, ()> { Box::pin(async move { let receiver = rx.as_mut().expect("receiver missing"); @@ -96,8 +173,39 @@ fn assert_dlq_full(rx: &mut Option>) -> BoxFuture<'_, ()> { }) } +/// Returns a future that completes immediately, performing no assertions on a closed DLQ receiver. +/// +/// This function is used in tests to represent scenarios where the dead-letter queue (DLQ) +/// receiver has been closed and no further validation is required. +/// +/// # Examples +/// +/// ```no_run +/// use tokio::sync::mpsc; +/// use futures::executor::block_on; +/// +/// let mut rx: Option> = None; +/// block_on(assert_dlq_closed(&mut rx)); +/// ``` fn assert_dlq_closed(_: &mut Option>) -> BoxFuture<'_, ()> { Box::pin(async {}) } +/// Tests error handling when dropped items cannot be delivered to the dead-letter queue (DLQ). +/// +/// This parameterised test covers scenarios where the DLQ is either full or closed. It verifies +/// that dropped items are handled according to the specified push policy, and that appropriate +/// error logs are emitted when the DLQ cannot accept new items. +/// +/// # Parameters +/// - `setup`: Function to configure the DLQ state (full or closed) before the test. +/// - `policy`: The push policy to use when the main queue is full. +/// - `expected`: Substring expected to appear in the error log message. +/// - `assertion`: Function to assert the final state of the DLQ receiver. +/// +/// # Examples +/// +/// ```no_run +/// // Runs automatically as part of the test suite; not intended for direct invocation. +/// ``` #[rstest] #[case::dlq_full(setup_dlq_full, PushPolicy::WarnAndDropIfFull, "DLQ", assert_dlq_full)] #[case::dlq_closed(setup_dlq_closed, PushPolicy::DropIfFull, "closed", assert_dlq_closed)] diff --git a/wireframe_testing/src/logging.rs b/wireframe_testing/src/logging.rs index 2e1193d0..3a54cb43 100644 --- a/wireframe_testing/src/logging.rs +++ b/wireframe_testing/src/logging.rs @@ -17,7 +17,18 @@ pub struct LoggerHandle { } impl LoggerHandle { - /// Acquire the global [`Logger`] instance. + /// Acquires exclusive access to the global logger instance for testing. + /// + /// Returns a handle that provides serialised, thread-safe access to a singleton + /// [`Logger`], ensuring that concurrent tests do not interfere with each other's + /// log capture. Panics if the logger mutex is poisoned. + /// + /// # Examples + /// + /// ```no_run + /// let handle = LoggerHandle::new(); + /// handle.clear(); // Clear previous logs before running a test. + /// ``` pub fn new() -> Self { static LOGGER: OnceLock> = OnceLock::new(); @@ -31,16 +42,53 @@ impl LoggerHandle { impl std::ops::Deref for LoggerHandle { type Target = Logger; - fn deref(&self) -> &Self::Target { &self.guard } + /// Returns a reference to the underlying logger, enabling read-only access through the handle. +/// +/// # Examples +/// +/// ```no_run +/// let handle = LoggerHandle::new(); +/// let logs = handle.logs(); +/// ``` +fn deref(&self) -> &Self::Target { &self.guard } } impl std::ops::DerefMut for LoggerHandle { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.guard } + /// Provides mutable access to the underlying `Logger` instance. +/// +/// # Examples +/// +/// ```no_run +/// use wireframe_testing::logging::LoggerHandle; +/// +/// let mut handle = LoggerHandle::new(); +/// handle.clear(); // Mutably access the logger to clear captured logs +/// ``` +fn deref_mut(&mut self) -> &mut Self::Target { &mut self.guard } } +/// Provides a test fixture that returns exclusive access to the global logger. +/// +/// This fixture ensures that each test receives a unique handle to the global +/// logger, allowing safe capture and inspection of log output without +/// interference from other tests. +/// +/// # Examples +/// +/// ```no_run +/// use wireframe_testing::logging::logger; +/// +/// #[rstest::rstest] +/// fn test_logging(logger: LoggerHandle) { +/// logger.clear(); +/// // ... perform actions that produce logs ... +/// let logs = logger.pop(); +/// assert!(logs.contains("expected log message")); +/// } +/// ``` #[allow( - unused_braces, - reason = "rustc false positive for single line rstest fixtures" +unused_braces, +reason = "rustc false positive for single line rstest fixtures" )] #[fixture] pub fn logger() -> LoggerHandle { LoggerHandle::new() }