Skip to main content

TestHarness

Struct TestHarness 

Source
pub struct TestHarness { /* private fields */ }
Expand description

Drives an App against a Headless backend: queues synthetic input, steps frames, and reads back the rendered view.

§The two-frame rule

A press and a release queued together resolve a frame later than the same gesture arriving from real input, because hit-testing (e.g. retroglyph-ui’ Interaction) snapshots the previous frame’s pointer state before this frame’s queued events are applied, and because step drains at most one queued event per call, click’s Down and Up land in two separate frames rather than one, costing a third, event-free frame before that snapshot catches up: resolved_press from Down’s frame latches active on Up’s frame, and only the frame after that sees resolved_release. click queues both events for you; run and settle already run that trailing event-free frame, so call one of those after queuing input rather than a fixed number of manual step calls.

§Presenting

step presents automatically: skipped on Flow::Idle, skipped as a no-op if update already presented (mirroring run_on’s own behavior). Nothing queued is visible in view until a step call has run.

§Examples

use retroglyph_core::testing::TestHarness;
use retroglyph_core::app::{App, Flow, Frame};
use retroglyph_core::backend::Backend;
use retroglyph_core::color::Style;
use retroglyph_core::terminal::Terminal;

struct Counter(u32);

impl<B: Backend> App<B> for Counter {
    fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
        // Draining (not just `has_input`, which leaves the event buffered) matters here:
        // `run` follows the queue's last event with a trailing event-free frame (see "the
        // two-frame rule" above), and an undrained event would still be visible to
        // `has_input` on that next frame too, double-counting it.
        if term.drain_events().count() > 0 {
            self.0 += 1;
        }
        term.surface()
            .put((0, 0), char::from_digit(self.0, 10).unwrap_or('?'), Style::default());
        if frame.frame > 10 {
            Flow::Exit
        } else {
            Flow::Continue
        }
    }
}

let mut harness = TestHarness::new(10, 1);
let mut app = Counter(0);

harness.key(retroglyph_core::event::KeyCode::Char(' '));
harness.run(&mut app);

assert_eq!(app.0, 1);
assert!(harness.view().starts_with('1'));

Implementations§

Source§

impl TestHarness

Source

pub fn new(width: u16, height: u16) -> TestHarness

Creates a harness with a width x height Headless backend.

Source

pub const fn with_step_delta(self, delta: Duration) -> TestHarness

Overrides the Frame::delta step hands to App::update, replacing STEP_DELTA (builder style).

For an app under test whose animation timing is calibrated against a different fixed delta than STEP_DELTA’s 16ms (a retroglyph_ui::animate::Tween/FrameClock tuned in real seconds, for example): matching that calibration here keeps however many step calls an animation takes to settle the same as what tuned it, rather than retuning the animation (or the test’s assertions) around the harness’s own default instead.

Source

pub fn push_event(&mut self, event: Event)

Queues a synthetic event for the next step call.

Only queues: the app does not see it until a frame runs. Prefer the typed helpers (click, key, mouse_move) unless the Event variant needed isn’t one of them.

Source

pub fn push_frame(&mut self, events: impl IntoIterator<Item = Event>)

Queues every event in events to land together in a single step call, rather than one per frame.

For a gesture whose events a real backend delivers within one poll (a drag’s coalesced moves, a paste, a multi-key chord) and that the app under test expects to see in the same App::update call: push_event queuing each one separately would spread them across frames instead, which is what a real backend never does and what Terminal::drain_events callers written against real input don’t expect.

Headless coalesces consecutive Event::Mouse Moved events into just the last one, matching a real backend’s own polling behavior: a push_frame of several successive moves is seen by the app as only the final position.

Source

pub fn click(&mut self, x: u16, y: u16)

Queues a left-button click (press then release) at (x, y), with no modifiers.

See “the two-frame rule” on TestHarness before asserting after a single step: use run/settle instead.

Source

pub fn click_button(&mut self, x: u16, y: u16, button: MouseButton)

Queues a click (press then release) with button at (x, y), with no modifiers.

Source

pub fn mouse_move(&mut self, x: u16, y: u16)

Queues a pointer move to (x, y), with no buttons held.

Source

pub fn key(&mut self, code: KeyCode)

Queues a key press of code, with no modifiers.

Source

pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers)

Queues a key press of code with modifiers.

Source

pub fn resize(&mut self, width: u16, height: u16)

Resizes the backend and queues the matching Event::Resize a real terminal would also deliver.

Unlike calling term_mut().resize directly, this also queues the event, matching what a real backend delivers alongside its own resize.

Source

pub fn step<A>(&mut self, app: &mut A) -> Flow
where A: App<Headless>,

Runs exactly one frame: pops at most one queued event into the backend, calls App::update, and presents unless update returned Flow::Idle or already presented.

Draining only one queued event per call, rather than the whole queue at once, is what reproduces the two-frame rule described on TestHarness instead of masking it.

Calls App::init first, on frame 0 only, mirroring what every other driver in this crate does before its own first update.

Source

pub fn settle<A>( &mut self, app: &mut A, max_steps: u32, ) -> Result<u32, RunError>
where A: App<Headless>,

Runs step once per currently-queued frame, plus one further event-free frame past that, stopping early on Flow::Exit, bounded by max_steps.

This is the “run until settled” primitive: queuing input only stages it, settle resolves the two-frame rule (see TestHarness) instead of requiring manual step calls per gesture. The trailing event-free frame matters because hit-testing (e.g. retroglyph-ui’s Interaction) reads a one-shot flag latched by the previous frame’s input: stopping right after the last queued frame’s own step would return before that flag is ever observed, leaving a queued click’s clicked() structurally unreachable through this API. The budget settle needs is therefore fixed up front, as the queue’s length (at least one, for the empty-queue case) plus that trailing frame, rather than discovered by looping on the queue’s state as it drains.

§Errors

Returns RunError::ExceededMaxSteps if that budget exceeds max_steps: a queue with more staged frames than max_steps allows is a bug in the test, not a case to loop on forever.

Source

pub fn run<A>(&mut self, app: &mut A) -> u32
where A: App<Headless>,

settle with DEFAULT_MAX_STEPS, panicking instead of returning an error.

§Panics

Panics if the queue is still non-empty after DEFAULT_MAX_STEPS steps.

Source

pub fn run_steps<A>(&mut self, app: &mut A, steps: u32)
where A: App<Headless>,

Runs a fixed number of frames, regardless of queue state or the Flow each one returns.

For tests asserting on the app still running after N frames (e.g. an idle animation) rather than on input settling; run/settle cover the input-resolution case.

Source

pub fn view(&self) -> String

The rendered view as of the last step call (see Headless::format_view).

Source

pub fn readable_view(&self) -> String

view, with Headless::SPACE_GLYPH converted back to a literal space.

view renders a blank cell as · rather than a space so layout is visible in text diffs (see Headless::format_view); that’s the right default for snapshot tests, but it defeats an ordinary plain-text assertion like view.contains("New Game"), since the label’s actual space never appears literally. Use this instead of view for that case.

Source

pub fn find_text(&self, needle: &str) -> Option<Pos<u16>>

The Pos of needle’s first occurrence in the current view, or None if it doesn’t appear.

“First” is row-major: top row before bottom, left before right within a row. needle is matched against a single row at a time (a label wrapped across rows by Headless::draw_text won’t match) and must consist of single-cell glyphs (a wide glyph’s spacer cell, per Headless::format_view, renders blank, not as a second copy of the glyph, so a needle containing one can never match).

A space in needle is matched against format_view’s own · stand-in for a blank cell, so a multi-word needle ("Save Game") matches the view’s rendered "Save·Game" without the caller having to know about that substitution.

Source

pub fn click_text(&mut self, needle: &str) -> Result<(), ClickTextError>

clicks the first occurrence of needle in the current view.

See find_text for what “first occurrence” means and its matching rules (row-major, single-row, ·-for-space).

§Errors

Returns ClickTextError::NotFound if needle doesn’t appear in the current view.

Source

pub fn from_recording(recording: &InputRecording) -> TestHarness

Creates a harness sized to recording’s recorded backend dimensions, ready for replay.

Source

pub fn replay<A>(&mut self, recording: &InputRecording, app: &mut A)
where A: App<Headless>,

Drives every event in recording into app, reproducing each recorded gap as whole STEP_DELTA steps rather than collapsing it to zero.

Each recorded (delay, event) pair becomes round(delay / STEP_DELTA) event-free run_steps calls, then event is queued and settled with run. This is what lets a replay reproduce a bug that depends on elapsed time between inputs (a FrameClock/tween-driven cooldown, a hold-to-charge mechanic): a naive replay that ran one run() per event with no delay in between would silently fail to reproduce any of those. Sub-STEP_DELTA jitter in the original recording is rounded away, which is expected and harmless – nothing in this harness has finer time resolution than one step to begin with.

§Panics

Panics if run does (an event fails to drain within DEFAULT_MAX_STEPS); see that method.

Source

pub const fn term(&self) -> &Terminal<Headless>

The underlying Terminal, for anything not wrapped directly (cursor position, Terminal::grid, a manual Terminal::draw outside the App loop).

Source

pub const fn term_mut(&mut self) -> &mut Terminal<Headless>

The underlying Terminal, mutably.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<T> Downcast for T
where T: Any,

§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.
§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Convert Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
§

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync>

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<T> WasmNotSend for T
where T: Send,

§

impl<T> WasmNotSendSync for T
where T: WasmNotSend + WasmNotSync,

§

impl<T> WasmNotSync for T
where T: Sync,

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more