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