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
impl TestHarness
Sourcepub fn new(width: u16, height: u16) -> TestHarness
pub fn new(width: u16, height: u16) -> TestHarness
Creates a harness with a width x height Headless backend.
Sourcepub const fn with_step_delta(self, delta: Duration) -> TestHarness
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.
Sourcepub fn push_event(&mut self, event: Event)
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.
Sourcepub fn push_frame(&mut self, events: impl IntoIterator<Item = Event>)
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.
Sourcepub fn click(&mut self, x: u16, y: u16)
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.
Queues a click (press then release) with button at (x, y), with no modifiers.
Sourcepub fn mouse_move(&mut self, x: u16, y: u16)
pub fn mouse_move(&mut self, x: u16, y: u16)
Queues a pointer move to (x, y), with no buttons held.
Sourcepub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers)
pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers)
Queues a key press of code with modifiers.
Sourcepub fn resize(&mut self, width: u16, height: u16)
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.
Sourcepub fn step<A>(&mut self, app: &mut A) -> Flow
pub fn step<A>(&mut self, app: &mut A) -> Flow
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.
Sourcepub fn settle<A>(
&mut self,
app: &mut A,
max_steps: u32,
) -> Result<u32, RunError>
pub fn settle<A>( &mut self, app: &mut A, max_steps: u32, ) -> Result<u32, RunError>
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.
Sourcepub fn run<A>(&mut self, app: &mut A) -> u32
pub fn run<A>(&mut self, app: &mut A) -> u32
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.
Sourcepub fn view(&self) -> String
pub fn view(&self) -> String
The rendered view as of the last step call (see
Headless::format_view).
Sourcepub fn readable_view(&self) -> String
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.
Sourcepub fn find_text(&self, needle: &str) -> Option<Pos<u16>>
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.
Sourcepub fn click_text(&mut self, needle: &str) -> Result<(), ClickTextError>
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.
Sourcepub fn from_recording(recording: &InputRecording) -> TestHarness
pub fn from_recording(recording: &InputRecording) -> TestHarness
Creates a harness sized to recording’s recorded backend dimensions, ready for
replay.
Sourcepub fn replay<A>(&mut self, recording: &InputRecording, app: &mut A)
pub fn replay<A>(&mut self, recording: &InputRecording, app: &mut A)
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.
Sourcepub const fn term(&self) -> &Terminal<Headless>
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).
Auto Trait Implementations§
impl Freeze for TestHarness
impl RefUnwindSafe for TestHarness
impl Send for TestHarness
impl Sync for TestHarness
impl Unpin for TestHarness
impl UnsafeUnpin for TestHarness
impl UnwindSafe for TestHarness
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
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>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
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)
fn as_any(&self) -> &(dyn Any + 'static)
&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)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.