Skip to main content

qframe/runtime/
harness.rs

1//! Driving an application in tests: no terminal, a fake clock and inline background work.
2
3use std::time::Duration;
4
5use ratatui_core::buffer::Buffer;
6use ratatui_core::layout::Rect as BufferRect;
7use ratatui_core::style::{Color, Modifier};
8
9use super::app::App;
10use super::detached::DetachedOutcome;
11use super::engine::{Engine, TaskMode};
12use super::follow::{Follow, Member, Start};
13use super::handoff::{HandoffOutcome, HandoffRequest};
14use super::open::{OpenOutcome, OpenRequest};
15use super::termination::Termination;
16use crate::color::{ColorDepth, Rgb};
17use crate::env::Env;
18use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
19use crate::icons::GlyphMode;
20use crate::keymap::Modifiers;
21use crate::widget::PointerShape;
22
23/// Time the fake clock moves before every simulated key press, so presses are never mistaken
24/// for a held key.
25const KEY_INTERVAL: Duration = Duration::from_millis(150);
26
27/// Runs an [`App`] against an in-memory screen.
28///
29/// Every input method renders afterwards, like the real runtime does. Work of
30/// [`Command::perform`](super::Command::perform) runs inline, one round per step like one pass of
31/// the terminal loop: work that performs again runs at the next step, so a chain of performs
32/// takes one [`Harness::render`] per link and an endless one never blocks a test.
33pub struct Harness<A: App> {
34    engine: Engine<A>,
35    buffer: Buffer,
36    now: Duration,
37}
38
39impl<A: App> Harness<A> {
40    /// A harness with the built-in environment and a `width` × `height` screen, already rendered.
41    ///
42    /// The first frame starts the application as the terminal runtime does: the size reaches
43    /// [`App::resized`], then [`App::init`] runs, so the focus it asks for is in place before the
44    /// first simulated key.
45    pub fn new(app: A, width: u16, height: u16) -> Self {
46        Self::with_env(app, Env::builtin(), width, height)
47    }
48
49    /// A harness with a custom environment.
50    pub fn with_env(app: A, env: Env, width: u16, height: u16) -> Self {
51        let mut harness = Self {
52            engine: Engine::new(app, env, TaskMode::Inline),
53            buffer: Buffer::empty(BufferRect::new(0, 0, width, height)),
54            now: Duration::ZERO,
55        };
56        harness.render();
57        harness
58    }
59
60    /// A harness for `app` started as member `name` of `ecosystem`, the way
61    /// [`Runtime::member_in`](super::Runtime::member_in) starts it, with `config_dir` as the
62    /// ecosystem's folder: the application's own settings and the shared preferences are read
63    /// from there and applied before the first frame, and
64    /// [`App::preferences`](super::App::preferences) hears them before [`App::init`]. A missing
65    /// shared file is written with the detected values, as it is on a first start. The built-in
66    /// environment is used; use [`member_in_with_env`](Self::member_in_with_env) to provide the
67    /// application's own environment.
68    ///
69    /// The folder is not watched: after writing a file, as another application would,
70    /// [`poll_preferences`](Self::poll_preferences) reads the files again the way the runtime
71    /// does when its watch hears them change.
72    pub fn member_in(
73        app: A,
74        ecosystem: crate::storage::Ecosystem,
75        config_dir: &std::path::Path,
76        name: &str,
77        width: u16,
78        height: u16,
79    ) -> Self {
80        Self::member_in_with_env(app, Env::builtin(), ecosystem, config_dir, name, width, height)
81    }
82
83    /// A harness for `app` started as member `name` of `ecosystem` with `env`, the way
84    /// [`Runtime::member_in`](super::Runtime::member_in) starts it, with `config_dir` as the
85    /// ecosystem's folder. The supplied environment keeps the application's own locale files and
86    /// keymap, while its settings and the shared preferences are applied before the first frame;
87    /// [`App::preferences`](super::App::preferences) hears the preferences before [`App::init`].
88    /// A missing shared file is written with the detected values, as it is on a first start.
89    ///
90    /// The folder is not watched: after writing a file, as another application would,
91    /// [`poll_preferences`](Self::poll_preferences) reads the files again the way the runtime
92    /// does when its watch hears them change. Use [`member_in`](Self::member_in) for the same
93    /// start with [`Env::builtin`].
94    pub fn member_in_with_env(
95        app: A,
96        mut env: Env,
97        ecosystem: crate::storage::Ecosystem,
98        config_dir: &std::path::Path,
99        name: &str,
100        width: u16,
101        height: u16,
102    ) -> Self {
103        let start = Start { theme: None, settings: None, preferences: None };
104        let follow = start.apply(&mut env, Some(Member::new(ecosystem, name, Some(config_dir.to_path_buf()))), false);
105        Self::started(app, env, follow, width, height)
106    }
107
108    /// A harness for `app` in `env` already started, following the member's files when `follow`
109    /// is given, rendered once.
110    pub(crate) fn started(app: A, env: Env, follow: Option<Follow>, width: u16, height: u16) -> Self {
111        let mut engine = Engine::new(app, env, TaskMode::Inline);
112        if let Some(follow) = follow {
113            engine.follow(follow);
114        }
115        let mut harness =
116            Self { engine, buffer: Buffer::empty(BufferRect::new(0, 0, width, height)), now: Duration::ZERO };
117        harness.render();
118        harness
119    }
120
121    /// Reads the member's files again and applies what changed, exactly as the runtime does when
122    /// its watch hears the ecosystem's shared file or the application's own file change; see
123    /// [`Runtime::member`](super::Runtime::member). A frame is drawn only when something changed:
124    /// a file written again with what it already said changes nothing and reaches no hook. A
125    /// harness not made with [`member_in`](Self::member_in) or
126    /// [`member_in_with_env`](Self::member_in_with_env) has nothing to read.
127    pub fn poll_preferences(&mut self) -> &mut Self {
128        self.engine.check_preferences();
129        if self.engine.dirty {
130            self.render();
131        }
132        self
133    }
134
135    /// Paints the current view.
136    pub fn render(&mut self) -> &mut Self {
137        self.settle_tasks();
138        self.engine.render(&mut self.buffer, self.now);
139        self.write_out();
140        // Settling hover or scrolling to a focused widget can ask for one more frame at once.
141        for _ in 0..3 {
142            let due = self.engine.deadline().is_some_and(|deadline| deadline <= self.now);
143            if !self.engine.dirty && !due {
144                break;
145            }
146            self.engine.render(&mut self.buffer, self.now);
147            self.write_out();
148        }
149        self
150    }
151
152    /// Paints the current view onto `screen` as the terminal runtime paints a frame, for the tests
153    /// that read what a screen writes. Answers whether anything was written.
154    #[cfg(all(test, feature = "image"))]
155    pub(crate) fn present_to<W: std::io::Write>(&mut self, screen: &mut super::present::Screen<W>) -> bool {
156        let now = self.now;
157        let engine = &mut self.engine;
158        screen
159            .present(|buffer| {
160                engine.render(buffer, now);
161                engine.painted()
162            })
163            .expect("a frame")
164    }
165
166    /// When the last frame asked to be drawn again, if it did: the time the terminal loop wakes
167    /// for it. A widget whose drawing changes at a known moment, with nothing else animating, is
168    /// only drawn then if it asked.
169    #[cfg(test)]
170    pub(crate) fn next_frame(&self) -> Option<Duration> {
171        self.engine.deadline()
172    }
173
174    /// Works out what the terminal would be sent for this frame over a cleared screen, as the
175    /// terminal runtime does before writing, so a cell no terminal can take fails the test that
176    /// drew it instead of the application that ships it.
177    fn write_out(&self) {
178        let blank = Buffer::empty(self.buffer.area);
179        let _ = blank.diff(&self.buffer);
180    }
181
182    /// Runs the perform work queued so far, then lets background tasks run up to the fake clock:
183    /// every task works until it sleeps past `now` or ends, and everything they sent is applied.
184    /// Tasks started by those messages settle too. Perform work queued meanwhile runs at the next
185    /// step, so work that performs again never keeps a step from ending.
186    fn settle_tasks(&mut self) {
187        self.engine.run_queued_work();
188        loop {
189            self.engine.task_clock.settle(self.now);
190            if self.engine.poll_tasks() == 0 {
191                break;
192            }
193        }
194    }
195
196    /// Delivers `message` to the application as if a widget had sent it, then renders.
197    pub fn send(&mut self, message: A::Msg) -> &mut Self {
198        self.engine.update(message);
199        self.render()
200    }
201
202    /// Presses a key chord such as `"ctrl+s"`, `"tab"` or `"?"`.
203    pub fn press(&mut self, chord: &str) -> &mut Self {
204        self.now += KEY_INTERVAL;
205        self.engine.handle(Event::Key(KeyEvent::press(chord)), self.now);
206        self.render()
207    }
208
209    /// Types `text` one character at a time.
210    pub fn type_text(&mut self, text: &str) -> &mut Self {
211        for c in text.chars() {
212            let chord = match c {
213                ' ' => "space".to_owned(),
214                '+' => "+".to_owned(),
215                c if c.is_uppercase() => format!("shift+{}", c.to_lowercase()),
216                c => c.to_string(),
217            };
218            self.press(&chord);
219        }
220        self
221    }
222
223    /// Delivers `events` in order at the current clock time and renders once afterwards, the
224    /// way the terminal loop handles every event waiting between two frames: several keys a fast
225    /// typist, a terminal multiplexer or a paste without bracketed paste sent in one read. Each
226    /// event meets what the ones before it did, as the view is rebuilt off screen between them,
227    /// so four keys typed into a controlled [`TextInput`](crate::widgets::TextInput) at once all
228    /// arrive.
229    pub fn events(&mut self, events: &[Event]) -> &mut Self {
230        for event in events {
231            self.engine.handle(event.clone(), self.now);
232        }
233        self.render()
234    }
235
236    /// Delivers `event` at an exact clock time, without moving the clock.
237    #[cfg(test)]
238    pub(crate) fn inject(&mut self, event: Event, at: Duration) -> &mut Self {
239        self.engine.handle(event, at);
240        self.render()
241    }
242
243    /// Pastes `text`.
244    pub fn paste(&mut self, text: &str) -> &mut Self {
245        self.engine.handle(Event::Paste(text.to_owned()), self.now);
246        self.render()
247    }
248
249    /// Clicks the left button on a cell.
250    pub fn click(&mut self, x: i32, y: i32) -> &mut Self {
251        self.mouse(MouseKind::Down(MouseButton::Left), x, y);
252        self.mouse(MouseKind::Up(MouseButton::Left), x, y)
253    }
254
255    /// Clicks the first cell of the first occurrence of `text` on screen.
256    ///
257    /// # Panics
258    ///
259    /// Panics when `text` is not on screen.
260    pub fn click_text(&mut self, text: &str) -> &mut Self {
261        let (x, y) = self.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", self.screen()));
262        self.click(x, y)
263    }
264
265    /// Presses the left button on `from`, drags to `to` and releases there.
266    pub fn drag(&mut self, from: (i32, i32), to: (i32, i32)) -> &mut Self {
267        self.mouse(MouseKind::Down(MouseButton::Left), from.0, from.1);
268        self.mouse(MouseKind::Drag(MouseButton::Left), to.0, to.1);
269        self.mouse(MouseKind::Up(MouseButton::Left), to.0, to.1)
270    }
271
272    /// Moves the pointer to a cell.
273    pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
274        self.mouse(MouseKind::Moved, x, y)
275    }
276
277    /// Sends a mouse event.
278    pub fn mouse(&mut self, kind: MouseKind, x: i32, y: i32) -> &mut Self {
279        self.engine.handle(Event::Mouse(MouseEvent { kind, x, y, mods: Modifiers::default() }), self.now);
280        self.render()
281    }
282
283    /// Moves the fake clock forward and renders. Idleness moves with it: what
284    /// [`View::idle_for`](crate::widget::View::idle_for) reads grows by `duration`, and a
285    /// [`View::on_idle`](crate::widget::View::on_idle) watch whose silence is reached is told.
286    /// Every simulated input (a key, the mouse, a paste) starts the silence again; `send`,
287    /// `resize` and theme or language changes do not.
288    ///
289    /// A termination whose [`Termination::grace`] is over by then quits, as it does in the
290    /// runtime.
291    pub fn advance(&mut self, duration: Duration) -> &mut Self {
292        self.now += duration;
293        self.engine.tick(self.now);
294        self.engine.end_when_due(self.now);
295        self.render()
296    }
297
298    /// Simulates the signal behind `cause`, the way the terminal runtime hears a `SIGTERM` or a
299    /// `SIGHUP`, then renders. The application hears it through
300    /// [`App::terminating`](super::App::terminating) exactly as it would in a terminal, so a test
301    /// can check its answer:
302    ///
303    /// - An answer of `None` quits at once: [`Harness::quit_requested`] is true.
304    /// - A message is applied; the application stays until it quits or until
305    ///   [`Harness::advance`] moves the clock past [`Termination::grace`].
306    /// - Calling this again with [`Termination::Terminate`] quits, as a second signal does. A
307    ///   repeated [`Termination::Hangup`] changes nothing, and one during a pending terminate is
308    ///   told to the application again.
309    ///
310    /// The harness keeps drawing after a hangup, so a test can still read the screen; the
311    /// runtime stops drawing, since the terminal is gone.
312    ///
313    /// ```
314    /// use qframe::prelude::*;
315    /// use qframe::runtime::Termination;
316    ///
317    /// struct Editor;
318    ///
319    /// impl App for Editor {
320    ///     type Msg = ();
321    ///     fn update(&mut self, (): ()) -> Command<()> {
322    ///         Command::none()
323    ///     }
324    ///     fn view(&self, ui: &mut View<'_, ()>) {
325    ///         ui.add(Text::new("notes.md"));
326    ///     }
327    /// }
328    ///
329    /// // An application that implements nothing quits cleanly on either signal.
330    /// let mut app = Harness::new(Editor, 20, 3);
331    /// app.terminate(Termination::Terminate);
332    /// assert!(app.quit_requested());
333    /// ```
334    pub fn terminate(&mut self, cause: Termination) -> &mut Self {
335        self.engine.terminate(cause, self.now);
336        self.render()
337    }
338
339    /// Delivers a key event exactly as given, without moving the clock: a
340    /// [`KeyKind::Repeat`](crate::event::KeyKind::Repeat) or
341    /// [`KeyKind::Release`](crate::event::KeyKind::Release) from a terminal with the kitty
342    /// keyboard protocol, or a press repeated by a held key.
343    pub fn key(&mut self, event: KeyEvent) -> &mut Self {
344        self.engine.handle(Event::Key(event), self.now);
345        self.render()
346    }
347
348    /// Switches theme, as `Command::set_theme` would.
349    pub fn set_theme(&mut self, id: &str) -> &mut Self {
350        self.engine.env.set_theme(id);
351        self.render()
352    }
353
354    /// Switches language, as `Command::set_locale` would.
355    pub fn set_locale(&mut self, code: &str) -> &mut Self {
356        self.engine.env.set_locale(code);
357        self.render()
358    }
359
360    /// Sets the region, as `Command::set_region` would.
361    pub fn set_region(&mut self, region: Option<&str>) -> &mut Self {
362        self.engine.env.set_region(region);
363        self.render()
364    }
365
366    /// Turns reduced motion on or off.
367    pub fn set_reduced_motion(&mut self, reduced: bool) -> &mut Self {
368        self.engine.env.set_reduced_motion(reduced);
369        self.render()
370    }
371
372    /// Draws as a terminal with `depth` colours would. Cells then carry palette indices instead of
373    /// colours, which [`Harness::fg`] and [`Harness::bg`] cannot read; compare
374    /// [`Harness::buffer`] cells for those.
375    pub fn set_depth(&mut self, depth: ColorDepth) -> &mut Self {
376        self.engine.env.set_depth(depth);
377        self.render()
378    }
379
380    /// Answers the graphics probe as a terminal that shows pictures with `graphics` would. A
381    /// harness asks no terminal, so until this is called it answers
382    /// [`Graphics::HalfBlock`](crate::graphics::Graphics::HalfBlock). The rules of
383    /// [`Env::graphics`](crate::env::Env::graphics) still apply: with [`Harness::set_depth`] at
384    /// 16 colours or [`Harness::set_glyph_mode`] at ASCII no picture is drawn, whatever is set here.
385    pub fn set_graphics(&mut self, graphics: crate::graphics::Graphics) -> &mut Self {
386        self.engine.env.set_terminal_graphics(graphics);
387        self.render()
388    }
389
390    /// Draws as a terminal at the other end of a remote connection would:
391    /// [`Env::remote`](crate::env::Env::remote) answers `remote` in every view that follows. A
392    /// harness is local until this is called, so a test draws the same wherever it runs, over
393    /// SSH included.
394    pub fn set_remote(&mut self, remote: bool) -> &mut Self {
395        self.engine.env.set_remote(remote);
396        self.render()
397    }
398
399    /// Draws as a terminal whose cells are `cell` pixels wide and high would:
400    /// [`Env::cell_pixels`](crate::env::Env::cell_pixels) answers it in every view that follows,
401    /// and sixel pictures are shrunk to it. A harness asks no terminal, so until this is called
402    /// the answer is `None`. Calling it again is a font size changing under the same columns
403    /// and rows.
404    pub fn set_cell_pixels(&mut self, cell: Option<(u16, u16)>) -> &mut Self {
405        self.engine.env.set_cell_pixels(cell);
406        self.render()
407    }
408
409    /// Switches the glyph column drawn.
410    pub fn set_glyph_mode(&mut self, mode: GlyphMode) -> &mut Self {
411        self.engine.env.set_glyph_mode(mode);
412        self.render()
413    }
414
415    /// Resizes the screen to `width` × `height` and renders, as a terminal resize does in the
416    /// runtime: the backend hands the engine a fresh, empty buffer of the new size and the next
417    /// frame is drawn in full. A new size reaches [`App::resized`] before that frame is built.
418    pub fn resize(&mut self, width: u16, height: u16) -> &mut Self {
419        self.buffer = Buffer::empty(BufferRect::new(0, 0, width, height));
420        self.engine.dirty = true;
421        self.render()
422    }
423
424    /// The screen as text, one line per row, trailing spaces removed. A double-width character
425    /// reads as itself, without the cell it covers, so `防火墙` is found as it is written.
426    #[must_use]
427    pub fn screen(&self) -> String {
428        let mut out = String::new();
429        for y in 0..self.buffer.area.height {
430            out.push_str(self.row(y).0.trim_end());
431            out.push('\n');
432        }
433        out
434    }
435
436    /// The screen as a self-contained HTML fragment with colours and weights, for looking at
437    /// renders in a browser. Wrap fragments with [`html_page`] to get a document.
438    #[must_use]
439    pub fn html(&self, caption: &str) -> String {
440        let area = self.buffer.area;
441        let escape = |text: &str| text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
442        let css = |color: Color| rgb(color).map_or_else(|| "inherit".to_owned(), |c| c.to_string());
443        let mut out = format!("<figure><figcaption>{}</figcaption><div class=\"screen\">", escape(caption));
444        for y in 0..area.height {
445            out.push_str("<div class=\"row\">");
446            for x in visible_columns(&self.buffer, y) {
447                let cell = &self.buffer[(x, y)];
448                let modifier = cell.modifier;
449                let weight = if modifier.contains(Modifier::BOLD) { "font-weight:700;" } else { "" };
450                let style = if modifier.contains(Modifier::ITALIC) { "font-style:italic;" } else { "" };
451                let line = if modifier.contains(Modifier::UNDERLINED) { "text-decoration:underline;" } else { "" };
452                out.push_str(&format!(
453                    "<span style=\"color:{};background:{};width:{}ch;{weight}{style}{line}\">{}</span>",
454                    css(cell.fg),
455                    css(cell.bg),
456                    crate::text::width(cell.symbol()).max(1),
457                    escape(cell.symbol())
458                ));
459            }
460            out.push_str("</div>");
461        }
462        out.push_str("</div></figure>");
463        out
464    }
465
466    /// Screen position of the first occurrence of `text`, in cells; text after a double-width
467    /// character is found at the column it is drawn in.
468    #[must_use]
469    pub fn find(&self, text: &str) -> Option<(i32, i32)> {
470        (0..self.buffer.area.height).find_map(|y| {
471            let (line, columns) = self.row(y);
472            line.find(text).map(|byte| (i32::from(columns[byte]), i32::from(y)))
473        })
474    }
475
476    /// Row `y` of the screen as text, with the column each byte of that text was drawn in.
477    fn row(&self, y: u16) -> (String, Vec<u16>) {
478        let mut line = String::new();
479        let mut columns = Vec::new();
480        for x in visible_columns(&self.buffer, y) {
481            let symbol = self.buffer[(x, y)].symbol();
482            columns.extend(std::iter::repeat_n(x, symbol.len()));
483            line.push_str(symbol);
484        }
485        (line, columns)
486    }
487
488    /// Text colour of a cell.
489    ///
490    /// # Panics
491    ///
492    /// Panics when the cell is outside the screen.
493    #[must_use]
494    pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
495        rgb(self.buffer[(x, y)].fg)
496    }
497
498    /// Background colour of a cell.
499    ///
500    /// # Panics
501    ///
502    /// Panics when the cell is outside the screen.
503    #[must_use]
504    pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
505        rgb(self.buffer[(x, y)].bg)
506    }
507
508    /// Whether a cell is bold.
509    ///
510    /// # Panics
511    ///
512    /// Panics when the cell is outside the screen.
513    #[must_use]
514    pub fn is_bold(&self, x: u16, y: u16) -> bool {
515        self.buffer[(x, y)].modifier.contains(Modifier::BOLD)
516    }
517
518    /// The rendered buffer.
519    #[must_use]
520    pub fn buffer(&self) -> &Buffer {
521        &self.buffer
522    }
523
524    /// The application.
525    #[must_use]
526    pub fn app(&self) -> &A {
527        &self.engine.app
528    }
529
530    /// The environment.
531    #[must_use]
532    pub fn env(&self) -> &Env {
533        &self.engine.env
534    }
535
536    /// The pointer shape the last frame asks for where the pointer is, as the terminal runtime
537    /// would send it to a terminal that understands pointer shapes. The harness records the
538    /// request whatever terminal a real run would meet; nothing is written anywhere.
539    #[must_use]
540    pub fn pointer_shape(&self) -> PointerShape {
541        self.engine.pointer_shape()
542    }
543
544    /// Texts copied to the clipboard so far.
545    #[must_use]
546    pub fn copied(&self) -> &[String] {
547        &self.engine.clipboard
548    }
549
550    /// The in-process clipboard: the text copied last, if any.
551    #[must_use]
552    pub fn clipboard(&self) -> Option<&str> {
553        self.engine.clipboard_text.as_deref()
554    }
555
556    /// Stands in for the system clipboard that pasting reads first: `Some` text as if the user
557    /// had copied it in another program, `None` for an empty clipboard (the start). A harness
558    /// never reads the real clipboard or asks a terminal, so without this pasting uses the text
559    /// the application copied last.
560    pub fn set_system_clipboard(&mut self, text: Option<&str>) -> &mut Self {
561        let system = super::clipboard::SystemClipboard::Fixed(text.map(str::to_owned));
562        self.engine.clipboard_reader.set_system(system);
563        self
564    }
565
566    /// The handoffs of [`Command::handoff`](super::Command::handoff) the application asked for,
567    /// oldest first. A harness has no terminal to hand over, so it records the request and
568    /// answers it with the outcome of [`Harness::set_handoff_outcome`] instead of running the
569    /// program.
570    #[must_use]
571    pub fn handoffs(&self) -> &[HandoffRequest] {
572        self.engine.handoff_requests()
573    }
574
575    /// The outcome every handoff from now on ends with; `Finished { code: Some(0) }` without
576    /// this.
577    pub fn set_handoff_outcome(&mut self, outcome: HandoffOutcome) -> &mut Self {
578        self.engine.set_handoff_outcome(outcome);
579        self
580    }
581
582    /// The handoffs of [`Command::handoff_detached`](super::Command::handoff_detached) the
583    /// application asked for, oldest first. Like [`Harness::handoffs`] they are recorded, not
584    /// run, and answered with the outcome of [`Harness::set_detached_outcome`].
585    #[must_use]
586    pub fn detached_handoffs(&self) -> &[HandoffRequest] {
587        self.engine.detached_requests()
588    }
589
590    /// The outcome every detached handoff from now on ends with; `Finished { code: Some(0) }`
591    /// without this. A [`DetachedOutcome::Detached`] with the child of
592    /// [`LiveChild::for_tests`](super::LiveChild::for_tests) lets the test play the program: what
593    /// the application writes is recorded on its [`TestChild`](super::TestChild), and the lines
594    /// the test says there reach [`DetachedHandoff::on_line`](super::DetachedHandoff::on_line)
595    /// at the next step.
596    ///
597    /// The harness keeps the outcome, and with it a clone of the child, until it is given
598    /// another or dropped; the child's input closes then at the latest, as it does when a real
599    /// run ends.
600    pub fn set_detached_outcome(&mut self, outcome: DetachedOutcome) -> &mut Self {
601        self.engine.set_detached_outcome(outcome);
602        self
603    }
604
605    /// The openings of [`Command::open`](super::Command::open) and
606    /// [`Command::open_with`](super::Command::open_with) the application asked for, oldest first.
607    ///
608    /// A harness reaches no desktop: the opening is recorded and answered with the outcome of
609    /// [`Harness::set_open_outcome`] instead of starting anything. [`OpenRequest::target`] is
610    /// what was asked to be opened, so a test reads the address without knowing which opener
611    /// this system has.
612    #[must_use]
613    pub fn opens(&self) -> &[OpenRequest] {
614        self.engine.open_requests()
615    }
616
617    /// The outcome every opening from now on ends with; [`OpenOutcome::Opened`] without this.
618    pub fn set_open_outcome(&mut self, outcome: OpenOutcome) -> &mut Self {
619        self.engine.set_open_outcome(outcome);
620        self
621    }
622
623    /// The questions for a newer version of
624    /// [`Command::check_for_update`](super::Command::check_for_update) the application asked,
625    /// oldest first. A harness reaches no network and reads or writes none of the check's
626    /// folders: the question is recorded and answered with the version of
627    /// [`set_latest_version`](Self::set_latest_version), if any.
628    #[cfg(feature = "updates")]
629    #[must_use]
630    pub fn update_checks(&self) -> &[super::UpdateCheckRequest] {
631        self.engine.update_checks()
632    }
633
634    /// Answers the questions for a newer version as if the registry had named `latest` the newest,
635    /// the ones asked already and every one from now on, then renders. With `None`, the default,
636    /// a question gets no answer, as when the network is down. The application's message arrives
637    /// only when the version is newer than the one it runs.
638    #[cfg(feature = "updates")]
639    pub fn set_latest_version(&mut self, latest: Option<&str>) -> &mut Self {
640        self.engine.set_latest_version(latest.map(str::to_owned));
641        self.render()
642    }
643
644    /// Whether the application asked to quit.
645    #[must_use]
646    pub fn quit_requested(&self) -> bool {
647        self.engine.quit
648    }
649
650    /// Whether the widget named `name` has keyboard focus.
651    #[must_use]
652    pub fn is_focused(&self, name: &str) -> bool {
653        self.engine.interaction.focused.is_some_and(|id| self.engine.frame.names.get(&id).is_some_and(|n| n == name))
654    }
655}
656
657/// Wraps [`Harness::html`] fragments in an HTML document that lays screens out on a dark page.
658#[must_use]
659pub fn html_page(fragments: &[String]) -> String {
660    format!(
661        "<!doctype html><meta charset=\"utf-8\"><title>Quvyta review</title><style>\
662         body{{background:#050507;margin:24px;font-family:'JetBrainsMono Nerd Font Mono','JetBrains Mono',monospace}}\
663         figure{{margin:0 0 28px}}figcaption{{color:#8a8f99;font:12px sans-serif;margin-bottom:6px}}\
664         .screen{{display:inline-block;font-size:14px;line-height:19px;white-space:pre}}\
665         .row{{display:flex;height:19px}}.row span{{display:inline-block;overflow:hidden}}</style>{}",
666        fragments.concat()
667    )
668}
669
670/// The columns of row `y` a terminal shows a symbol of: every one except those a wide
671/// character before them covers. Such a cell holds nothing or, when ratatui or a painter unaware
672/// of the character drew it, a space; reading it would split `防火墙` into `防 火 墙`.
673fn visible_columns(buffer: &Buffer, y: u16) -> impl Iterator<Item = u16> + '_ {
674    let mut covered = 0u16;
675    (0..buffer.area.width).filter(move |&x| {
676        if covered > 0 {
677            covered -= 1;
678            return false;
679        }
680        let symbol = buffer[(x, y)].symbol();
681        covered = crate::text::width(symbol).saturating_sub(1);
682        !symbol.is_empty()
683    })
684}
685
686fn rgb(color: Color) -> Option<Rgb> {
687    match color {
688        Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
689        _ => None,
690    }
691}
692
693#[cfg(test)]
694mod resize_tests {
695    use super::Harness;
696    use crate::runtime::{App, Command};
697    use crate::widget::View;
698    use crate::widgets::Text;
699
700    struct Greeting;
701
702    impl App for Greeting {
703        type Msg = ();
704        fn update(&mut self, (): ()) -> Command<()> {
705            Command::none()
706        }
707        fn view(&self, ui: &mut View<'_, ()>) {
708            ui.add(Text::new("container engines"));
709        }
710    }
711
712    #[test]
713    fn resize_redraws_the_whole_screen_at_the_new_size() {
714        let mut harness = Harness::new(Greeting, 30, 2);
715        assert_eq!(harness.screen(), "container engines\n\n");
716        harness.resize(9, 1);
717        assert_eq!(harness.screen(), "container\n");
718        harness.resize(0, 0);
719        assert_eq!(harness.screen(), "");
720        harness.resize(40, 3);
721        assert_eq!((harness.buffer().area.width, harness.buffer().area.height), (40, 3));
722        assert_eq!(harness.screen(), "container engines\n\n\n");
723    }
724
725    struct Raw;
726
727    impl App for Raw {
728        type Msg = ();
729        fn update(&mut self, (): ()) -> Command<()> {
730            Command::none()
731        }
732        fn view(&self, ui: &mut View<'_, ()>) {
733            ui.add(Text::new("bell\u{7} tab\t\u{1b}[1mé\r"));
734        }
735    }
736
737    #[test]
738    fn a_control_character_handed_to_any_widget_never_reaches_a_cell() {
739        let harness = Harness::new(Raw, 30, 1);
740        assert_eq!(harness.screen(), "bell  tab  [1mé\n", "each control character is a blank cell");
741    }
742}
743
744#[cfg(test)]
745mod handoff_tests {
746    use std::ffi::OsString;
747
748    use super::Harness;
749    use crate::runtime::{App, Command, Handoff, HandoffOutcome};
750    use crate::widget::View;
751    use crate::widgets::{Button, Text};
752
753    /// Asks for the authorization ticket and shows how the handoff ended.
754    #[derive(Default)]
755    struct Installer {
756        outcomes: Vec<HandoffOutcome>,
757    }
758
759    #[derive(Clone)]
760    enum Msg {
761        Authorize,
762        Done(HandoffOutcome),
763    }
764
765    impl App for Installer {
766        type Msg = Msg;
767        fn update(&mut self, msg: Msg) -> Command<Msg> {
768            match msg {
769                Msg::Authorize => Command::handoff(
770                    Handoff::new("sudo", Msg::Done).arg("-v").notice("Authorizing the installation").pause(false),
771                ),
772                Msg::Done(outcome) => {
773                    self.outcomes.push(outcome);
774                    Command::none()
775                }
776            }
777        }
778        fn view(&self, ui: &mut View<'_, Msg>) {
779            ui.add(Button::new("Authorize").on_press(Msg::Authorize)).id("authorize");
780            let text = match self.outcomes.last() {
781                None => "not asked yet".to_owned(),
782                Some(HandoffOutcome::Finished { code }) => format!("finished {code:?}"),
783                Some(HandoffOutcome::Failed(reason)) => format!("failed {reason}"),
784            };
785            ui.add(Text::new(text));
786        }
787    }
788
789    #[test]
790    fn a_handoff_is_recorded_and_answered_with_the_outcome_the_test_set() {
791        let mut harness = Harness::new(Installer::default(), 40, 3);
792        assert!(harness.handoffs().is_empty(), "nothing was asked for yet");
793        harness.send(Msg::Authorize);
794        let asked = harness.handoffs();
795        assert_eq!(asked.len(), 1);
796        assert_eq!(asked[0].program, OsString::from("sudo"));
797        assert_eq!(asked[0].args, vec![OsString::from("-v")]);
798        assert_eq!(asked[0].notice.as_deref(), Some("Authorizing the installation"));
799        assert!(!asked[0].pause);
800        // No program ran: the outcome the harness holds answered the request.
801        assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }]);
802        assert!(harness.screen().contains("finished Some(0)"), "{}", harness.screen());
803    }
804
805    #[test]
806    fn the_outcome_a_test_sets_reaches_the_application() {
807        let mut harness = Harness::new(Installer::default(), 40, 3);
808        harness.set_handoff_outcome(HandoffOutcome::Finished { code: Some(1) });
809        harness.send(Msg::Authorize);
810        assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(1) }]);
811        harness.set_handoff_outcome(HandoffOutcome::Failed("sudo is not installed".to_owned()));
812        harness.send(Msg::Authorize);
813        assert_eq!(harness.app().outcomes.len(), 2);
814        assert!(harness.screen().contains("failed sudo is not installed"), "{}", harness.screen());
815        assert_eq!(harness.handoffs().len(), 2, "both requests are kept, oldest first");
816    }
817
818    #[test]
819    fn several_handoffs_are_answered_one_after_another() {
820        let mut harness = Harness::new(Installer::default(), 40, 3);
821        harness.send(Msg::Authorize).send(Msg::Authorize).send(Msg::Authorize);
822        assert_eq!(harness.handoffs().len(), 3);
823        assert_eq!(harness.app().outcomes.len(), 3);
824    }
825}
826
827#[cfg(test)]
828mod wide_text_tests {
829    use super::Harness;
830    use crate::runtime::{App, Command};
831    use crate::widget::View;
832    use crate::widgets::{Button, Text};
833
834    /// A Chinese status line and a button with a Chinese label that counts its presses.
835    #[derive(Default)]
836    struct Firewall {
837        presses: u32,
838    }
839
840    impl App for Firewall {
841        type Msg = ();
842        fn update(&mut self, (): ()) -> Command<()> {
843            self.presses += 1;
844            Command::none()
845        }
846        fn view(&self, ui: &mut View<'_, ()>) {
847            ui.add(Text::new("状态 防火墙 on"));
848            ui.add(Button::new("启用").on_press(()));
849        }
850    }
851
852    /// The screen as ratatui leaves it when it draws wide text itself: the cell each wide
853    /// character covers holds a space, as does a cell drawn by any painter that knows nothing of
854    /// the character before it.
855    fn with_covered_cells_as_spaces(harness: &mut Harness<Firewall>) {
856        let area = harness.buffer.area;
857        for y in 0..area.height {
858            let mut covered = 0;
859            for x in 0..area.width {
860                let cell = &mut harness.buffer[(x, y)];
861                if covered > 0 {
862                    covered -= 1;
863                    cell.reset();
864                    continue;
865                }
866                covered = crate::text::width(cell.symbol()).saturating_sub(1);
867            }
868        }
869    }
870
871    fn firewall() -> Harness<Firewall> {
872        let mut harness = Harness::new(Firewall::default(), 30, 3);
873        with_covered_cells_as_spaces(&mut harness);
874        harness
875    }
876
877    #[test]
878    fn the_screen_reads_wide_text_without_gaps() {
879        let harness = firewall();
880        let screen = harness.screen();
881        assert!(screen.starts_with("状态 防火墙 on\n"), "{screen}");
882        assert!(screen.contains("防火墙"), "{screen}");
883    }
884
885    #[test]
886    fn find_gives_the_column_a_wide_text_is_drawn_in() {
887        let harness = firewall();
888        assert_eq!(harness.find("防火墙"), Some((5, 0)));
889        assert_eq!(harness.find("on"), Some((12, 0)), "text after wide characters keeps its column");
890        let (x, y) = harness.find("启用").expect("the button label is on screen");
891        assert_eq!(harness.buffer()[(u16::try_from(x).unwrap(), u16::try_from(y).unwrap())].symbol(), "启");
892    }
893
894    #[test]
895    fn click_text_presses_a_wide_label() {
896        let mut harness = firewall();
897        harness.click_text("启用");
898        assert_eq!(harness.app().presses, 1);
899    }
900
901    #[test]
902    fn html_draws_a_wide_character_once() {
903        let harness = firewall();
904        let html = harness.html("wide");
905        let first_row = html.split("<div class=\"row\">").nth(1).expect("a first row");
906        assert_eq!(first_row.matches("<span").count(), 30 - 5, "five characters take two cells each: {first_row}");
907        assert!(first_row.contains(">防</span><span"), "{first_row}");
908    }
909}
910
911#[cfg(test)]
912mod graphics_tests {
913    use super::Harness;
914    use crate::color::ColorDepth;
915    use crate::graphics::Graphics;
916    use crate::icons::GlyphMode;
917    use crate::runtime::{App, Command};
918    use crate::widget::View;
919    use crate::widgets::Text;
920
921    /// Shows the graphics its view reads from the environment.
922    struct Pictures;
923
924    impl App for Pictures {
925        type Msg = ();
926        fn update(&mut self, (): ()) -> Command<()> {
927            Command::none()
928        }
929        fn view(&self, ui: &mut View<'_, ()>) {
930            let graphics = ui.env().graphics();
931            ui.add(Text::new(graphics.name()));
932        }
933    }
934
935    #[test]
936    fn a_harness_answers_half_blocks_until_told_otherwise() {
937        let mut harness = Harness::new(Pictures, 20, 1);
938        assert_eq!(harness.screen(), "halfblock\n", "no terminal is asked in a test");
939        harness.set_graphics(Graphics::Kitty);
940        assert_eq!(harness.screen(), "kitty\n");
941        harness.set_graphics(Graphics::Sixel);
942        assert_eq!(harness.screen(), "sixel\n");
943    }
944
945    #[test]
946    fn the_rules_still_apply_to_what_a_harness_is_told() {
947        let mut harness = Harness::new(Pictures, 20, 1);
948        harness.set_graphics(Graphics::Kitty).set_depth(ColorDepth::Ansi16);
949        assert_eq!(harness.screen(), "none\n", "16 colours show no picture");
950        harness.set_depth(ColorDepth::TrueColor).set_glyph_mode(GlyphMode::Ascii);
951        assert_eq!(harness.screen(), "none\n", "ASCII glyphs show no picture");
952        harness.set_glyph_mode(GlyphMode::Unicode);
953        assert_eq!(harness.screen(), "kitty\n");
954    }
955
956    /// Says whether its view is drawn for a remote connection.
957    struct Link;
958
959    impl App for Link {
960        type Msg = ();
961        fn update(&mut self, (): ()) -> Command<()> {
962            Command::none()
963        }
964        fn view(&self, ui: &mut View<'_, ()>) {
965            ui.add(Text::new(if ui.env().remote() { "remote" } else { "local" }));
966        }
967    }
968
969    #[test]
970    fn a_harness_draws_a_remote_screen_when_told_so() {
971        let mut harness = Harness::new(Link, 20, 1);
972        assert_eq!(harness.screen(), "local\n", "a test draws the same wherever it runs");
973        harness.set_remote(true);
974        assert_eq!(harness.screen(), "remote\n");
975        harness.set_remote(false);
976        assert_eq!(harness.screen(), "local\n");
977    }
978
979    /// Writes the size of a cell in pixels its view is told, or `none`.
980    struct Cells;
981
982    impl App for Cells {
983        type Msg = ();
984        fn update(&mut self, (): ()) -> Command<()> {
985            Command::none()
986        }
987        fn view(&self, ui: &mut View<'_, ()>) {
988            let told = ui.env().cell_pixels().map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}"));
989            ui.add(Text::new(told));
990        }
991    }
992
993    #[test]
994    fn a_harness_knows_no_cell_size_until_told_and_then_the_latest_one() {
995        let mut harness = Harness::new(Cells, 20, 1);
996        assert_eq!(
997            harness.screen(),
998            "none
999",
1000            "no terminal is asked in a test"
1001        );
1002        harness.set_cell_pixels(Some((9, 19)));
1003        assert_eq!(
1004            harness.screen(),
1005            "9x19
1006"
1007        );
1008        harness.set_cell_pixels(Some((12, 26)));
1009        assert_eq!(
1010            harness.screen(),
1011            "12x26
1012",
1013            "a larger font, the same columns and rows"
1014        );
1015        harness.set_cell_pixels(None);
1016        assert_eq!(
1017            harness.screen(),
1018            "none
1019"
1020        );
1021    }
1022}