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