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