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