iced_test/
instruction.rs

1//! A step in an end-to-end test.
2use crate::core::keyboard;
3use crate::core::mouse;
4use crate::core::{Event, Point};
5use crate::simulator;
6
7use std::fmt;
8
9/// A step in an end-to-end test.
10///
11/// An [`Instruction`] can be run by an [`Emulator`](crate::Emulator).
12#[derive(Debug, Clone, PartialEq)]
13pub enum Instruction {
14    /// A user [`Interaction`].
15    Interact(Interaction),
16    /// A testing [`Expectation`].
17    Expect(Expectation),
18}
19
20impl Instruction {
21    /// Parses an [`Instruction`] from its textual representation.
22    pub fn parse(line: &str) -> Result<Self, ParseError> {
23        parser::run(line)
24    }
25}
26
27impl fmt::Display for Instruction {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Instruction::Interact(interaction) => interaction.fmt(f),
31            Instruction::Expect(expectation) => expectation.fmt(f),
32        }
33    }
34}
35
36/// A user interaction.
37#[derive(Debug, Clone, PartialEq)]
38pub enum Interaction {
39    /// A mouse interaction.
40    Mouse(Mouse),
41    /// A keyboard interaction.
42    Keyboard(Keyboard),
43}
44
45impl Interaction {
46    /// Creates an [`Interaction`] from a runtime [`Event`].
47    ///
48    /// This can be useful for recording tests during real usage.
49    pub fn from_event(event: &Event) -> Option<Self> {
50        Some(match event {
51            Event::Mouse(mouse) => Self::Mouse(match mouse {
52                mouse::Event::CursorMoved { position } => Mouse::Move(Target::Point(*position)),
53                mouse::Event::ButtonPressed(button) => Mouse::Press {
54                    button: *button,
55                    target: None,
56                },
57                mouse::Event::ButtonReleased(button) => Mouse::Release {
58                    button: *button,
59                    target: None,
60                },
61                _ => None?,
62            }),
63            Event::Keyboard(keyboard) => Self::Keyboard(match keyboard {
64                keyboard::Event::KeyPressed { key, text, .. } => match key {
65                    keyboard::Key::Named(keyboard::key::Named::Enter) => {
66                        Keyboard::Press(Key::Enter)
67                    }
68                    keyboard::Key::Named(keyboard::key::Named::Escape) => {
69                        Keyboard::Press(Key::Escape)
70                    }
71                    keyboard::Key::Named(keyboard::key::Named::Tab) => Keyboard::Press(Key::Tab),
72                    keyboard::Key::Named(keyboard::key::Named::Backspace) => {
73                        Keyboard::Press(Key::Backspace)
74                    }
75                    _ => Keyboard::Typewrite(text.as_ref()?.to_string()),
76                },
77                keyboard::Event::KeyReleased { key, .. } => match key {
78                    keyboard::Key::Named(keyboard::key::Named::Enter) => {
79                        Keyboard::Release(Key::Enter)
80                    }
81                    keyboard::Key::Named(keyboard::key::Named::Escape) => {
82                        Keyboard::Release(Key::Escape)
83                    }
84                    keyboard::Key::Named(keyboard::key::Named::Tab) => Keyboard::Release(Key::Tab),
85                    keyboard::Key::Named(keyboard::key::Named::Backspace) => {
86                        Keyboard::Release(Key::Backspace)
87                    }
88                    _ => None?,
89                },
90                keyboard::Event::ModifiersChanged(_) => None?,
91            }),
92            _ => None?,
93        })
94    }
95
96    /// Merges two interactions together, if possible.
97    ///
98    /// This method can turn certain sequences of interactions into a single one.
99    /// For instance, a mouse movement, left button press, and left button release
100    /// can all be merged into a single click interaction.
101    ///
102    /// Merging is lossy and, therefore, it is not always desirable if you are recording
103    /// a test and want full reproducibility.
104    ///
105    /// If the interactions cannot be merged, the `next` interaction will be
106    /// returned as the second element of the tuple.
107    pub fn merge(self, next: Self) -> (Self, Option<Self>) {
108        match (self, next) {
109            (Self::Mouse(current), Self::Mouse(next)) => match (current, next) {
110                (Mouse::Move(_), Mouse::Move(to)) => (Self::Mouse(Mouse::Move(to)), None),
111                (
112                    Mouse::Move(to),
113                    Mouse::Press {
114                        button,
115                        target: None,
116                    },
117                ) => (
118                    Self::Mouse(Mouse::Press {
119                        button,
120                        target: Some(to),
121                    }),
122                    None,
123                ),
124                (
125                    Mouse::Move(to),
126                    Mouse::Release {
127                        button,
128                        target: None,
129                    },
130                ) => (
131                    Self::Mouse(Mouse::Release {
132                        button,
133                        target: Some(to),
134                    }),
135                    None,
136                ),
137                (
138                    Mouse::Press {
139                        button: press,
140                        target: press_at,
141                    },
142                    Mouse::Release {
143                        button: release,
144                        target: release_at,
145                    },
146                ) if press == release
147                    && release_at
148                        .as_ref()
149                        .is_none_or(|release_at| Some(release_at) == press_at.as_ref()) =>
150                {
151                    (
152                        Self::Mouse(Mouse::Click {
153                            button: press,
154                            target: press_at,
155                        }),
156                        None,
157                    )
158                }
159                (
160                    Mouse::Press {
161                        button,
162                        target: Some(press_at),
163                    },
164                    Mouse::Move(move_at),
165                ) if press_at == move_at => (
166                    Self::Mouse(Mouse::Press {
167                        button,
168                        target: Some(press_at),
169                    }),
170                    None,
171                ),
172                (
173                    Mouse::Click {
174                        button,
175                        target: Some(click_at),
176                    },
177                    Mouse::Move(move_at),
178                ) if click_at == move_at => (
179                    Self::Mouse(Mouse::Click {
180                        button,
181                        target: Some(click_at),
182                    }),
183                    None,
184                ),
185                (current, next) => (Self::Mouse(current), Some(Self::Mouse(next))),
186            },
187            (Self::Keyboard(current), Self::Keyboard(next)) => match (current, next) {
188                (Keyboard::Typewrite(current), Keyboard::Typewrite(next)) => (
189                    Self::Keyboard(Keyboard::Typewrite(format!("{current}{next}"))),
190                    None,
191                ),
192                (Keyboard::Press(current), Keyboard::Release(next)) if current == next => {
193                    (Self::Keyboard(Keyboard::Type(current)), None)
194                }
195                (current, next) => (Self::Keyboard(current), Some(Self::Keyboard(next))),
196            },
197            (current, next) => (current, Some(next)),
198        }
199    }
200
201    /// Returns a list of runtime events representing the [`Interaction`].
202    ///
203    /// The `find_target` closure must convert a [`Target`] into its screen
204    /// coordinates.
205    pub fn events(&self, find_target: impl FnOnce(&Target) -> Option<Point>) -> Option<Vec<Event>> {
206        let mouse_move_ = |to| Event::Mouse(mouse::Event::CursorMoved { position: to });
207
208        let mouse_press = |button| Event::Mouse(mouse::Event::ButtonPressed(button));
209
210        let mouse_release = |button| Event::Mouse(mouse::Event::ButtonReleased(button));
211
212        let key_press = |key| simulator::press_key(key, None);
213
214        let key_release = |key| simulator::release_key(key);
215
216        Some(match self {
217            Interaction::Mouse(mouse) => match mouse {
218                Mouse::Move(to) => vec![mouse_move_(find_target(to)?)],
219                Mouse::Press {
220                    button,
221                    target: Some(at),
222                } => vec![mouse_move_(find_target(at)?), mouse_press(*button)],
223                Mouse::Press {
224                    button,
225                    target: None,
226                } => {
227                    vec![mouse_press(*button)]
228                }
229                Mouse::Release {
230                    button,
231                    target: Some(at),
232                } => {
233                    vec![mouse_move_(find_target(at)?), mouse_release(*button)]
234                }
235                Mouse::Release {
236                    button,
237                    target: None,
238                } => {
239                    vec![mouse_release(*button)]
240                }
241                Mouse::Click {
242                    button,
243                    target: Some(at),
244                } => {
245                    vec![
246                        mouse_move_(find_target(at)?),
247                        mouse_press(*button),
248                        mouse_release(*button),
249                    ]
250                }
251                Mouse::Click {
252                    button,
253                    target: None,
254                } => {
255                    vec![mouse_press(*button), mouse_release(*button)]
256                }
257            },
258            Interaction::Keyboard(keyboard) => match keyboard {
259                Keyboard::Press(key) => vec![key_press(*key)],
260                Keyboard::Release(key) => vec![key_release(*key)],
261                Keyboard::Type(key) => vec![key_press(*key), key_release(*key)],
262                Keyboard::Typewrite(text) => simulator::typewrite(text).collect(),
263            },
264        })
265    }
266}
267
268impl fmt::Display for Interaction {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match self {
271            Interaction::Mouse(mouse) => mouse.fmt(f),
272            Interaction::Keyboard(keyboard) => keyboard.fmt(f),
273        }
274    }
275}
276
277/// A mouse interaction.
278#[derive(Debug, Clone, PartialEq)]
279pub enum Mouse {
280    /// The mouse was moved.
281    Move(Target),
282    /// A button was pressed.
283    Press {
284        /// The button.
285        button: mouse::Button,
286        /// The location of the press.
287        target: Option<Target>,
288    },
289    /// A button was released.
290    Release {
291        /// The button.
292        button: mouse::Button,
293        /// The location of the release.
294        target: Option<Target>,
295    },
296    /// A button was clicked.
297    Click {
298        /// The button.
299        button: mouse::Button,
300        /// The location of the click.
301        target: Option<Target>,
302    },
303}
304
305impl fmt::Display for Mouse {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        match self {
308            Mouse::Move(target) => {
309                write!(f, "move {}", target)
310            }
311            Mouse::Press { button, target } => {
312                write!(f, "press {}", format::button_at(*button, target.as_ref()))
313            }
314            Mouse::Release { button, target } => {
315                write!(f, "release {}", format::button_at(*button, target.as_ref()))
316            }
317            Mouse::Click { button, target } => {
318                write!(f, "click {}", format::button_at(*button, target.as_ref()))
319            }
320        }
321    }
322}
323
324/// The target of an interaction.
325#[derive(Debug, Clone, PartialEq)]
326pub enum Target {
327    /// A specific point of the viewport.
328    Point(Point),
329    /// A UI element containing the given text.
330    Text(String),
331}
332
333impl fmt::Display for Target {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        match self {
336            Self::Point(point) => f.write_str(&format::point(*point)),
337            Self::Text(text) => f.write_str(&format::string(text)),
338        }
339    }
340}
341
342/// A keyboard interaction.
343#[derive(Debug, Clone, PartialEq)]
344pub enum Keyboard {
345    /// A key was pressed.
346    Press(Key),
347    /// A key was released.
348    Release(Key),
349    /// A key was "typed" (press and released).
350    Type(Key),
351    /// A bunch of text was typed.
352    Typewrite(String),
353}
354
355impl fmt::Display for Keyboard {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        match self {
358            Keyboard::Press(key) => {
359                write!(f, "press {}", format::key(*key))
360            }
361            Keyboard::Release(key) => {
362                write!(f, "release {}", format::key(*key))
363            }
364            Keyboard::Type(key) => {
365                write!(f, "type {}", format::key(*key))
366            }
367            Keyboard::Typewrite(text) => {
368                write!(f, "type \"{text}\"")
369            }
370        }
371    }
372}
373
374/// A keyboard key.
375///
376/// Only a small subset of keys is supported currently!
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378#[allow(missing_docs)]
379pub enum Key {
380    Enter,
381    Escape,
382    Tab,
383    Backspace,
384}
385
386impl From<Key> for keyboard::Key {
387    fn from(key: Key) -> Self {
388        match key {
389            Key::Enter => Self::Named(keyboard::key::Named::Enter),
390            Key::Escape => Self::Named(keyboard::key::Named::Escape),
391            Key::Tab => Self::Named(keyboard::key::Named::Tab),
392            Key::Backspace => Self::Named(keyboard::key::Named::Backspace),
393        }
394    }
395}
396
397mod format {
398    use super::*;
399
400    pub fn button_at(button: mouse::Button, at: Option<&Target>) -> String {
401        let button = self::button(button);
402
403        if let Some(at) = at {
404            if button.is_empty() {
405                at.to_string()
406            } else {
407                format!("{} {}", button, at)
408            }
409        } else {
410            button.to_owned()
411        }
412    }
413
414    pub fn button(button: mouse::Button) -> &'static str {
415        match button {
416            mouse::Button::Left => "",
417            mouse::Button::Right => "right",
418            mouse::Button::Middle => "middle",
419            mouse::Button::Back => "back",
420            mouse::Button::Forward => "forward",
421            mouse::Button::Other(_) => "other",
422        }
423    }
424
425    pub fn point(point: Point) -> String {
426        format!("({:.2}, {:.2})", point.x, point.y)
427    }
428
429    pub fn key(key: Key) -> &'static str {
430        match key {
431            Key::Enter => "enter",
432            Key::Escape => "escape",
433            Key::Tab => "tab",
434            Key::Backspace => "backspace",
435        }
436    }
437
438    pub fn string(text: &str) -> String {
439        format!("\"{}\"", text.escape_default())
440    }
441}
442
443/// A testing assertion.
444///
445/// Expectations are instructions that verify the current state of
446/// the user interface of an application.
447#[derive(Debug, Clone, PartialEq)]
448pub enum Expectation {
449    /// Expect some element to contain some text.
450    Text(String),
451}
452
453impl fmt::Display for Expectation {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        match self {
456            Expectation::Text(text) => {
457                write!(f, "expect {}", format::string(text))
458            }
459        }
460    }
461}
462
463pub use parser::Error as ParseError;
464
465mod parser {
466    use super::*;
467
468    use nom::branch::alt;
469    use nom::bytes::complete::tag;
470    use nom::bytes::{is_not, take_while_m_n};
471    use nom::character::complete::{char, multispace0, multispace1};
472    use nom::combinator::{map, map_opt, map_res, opt, success, value, verify};
473    use nom::error::ParseError;
474    use nom::multi::fold;
475    use nom::number::float;
476    use nom::sequence::{delimited, preceded, separated_pair};
477    use nom::{Finish, IResult, Parser};
478
479    /// A parsing error.
480    #[derive(Debug, Clone, thiserror::Error)]
481    #[error("parse error: {0}")]
482    pub struct Error(nom::error::Error<String>);
483
484    pub fn run(input: &str) -> Result<Instruction, Error> {
485        match instruction.parse_complete(input).finish() {
486            Ok((_rest, instruction)) => Ok(instruction),
487            Err(error) => Err(Error(error.cloned())),
488        }
489    }
490
491    fn instruction(input: &str) -> IResult<&str, Instruction> {
492        alt((
493            map(interaction, Instruction::Interact),
494            map(expectation, Instruction::Expect),
495        ))
496        .parse(input)
497    }
498
499    fn interaction(input: &str) -> IResult<&str, Interaction> {
500        alt((
501            map(mouse, Interaction::Mouse),
502            map(keyboard, Interaction::Keyboard),
503        ))
504        .parse(input)
505    }
506
507    fn mouse(input: &str) -> IResult<&str, Mouse> {
508        let mouse_move = preceded(tag("move "), target).map(Mouse::Move);
509
510        alt((mouse_move, mouse_click, mouse_press, mouse_release)).parse(input)
511    }
512
513    fn mouse_click(input: &str) -> IResult<&str, Mouse> {
514        let (input, _) = tag("click ")(input)?;
515        let (input, (button, target)) = mouse_button_at(input)?;
516
517        Ok((input, Mouse::Click { button, target }))
518    }
519
520    fn mouse_press(input: &str) -> IResult<&str, Mouse> {
521        let (input, _) = tag("press ")(input)?;
522        let (input, (button, target)) = mouse_button_at(input)?;
523
524        Ok((input, Mouse::Press { button, target }))
525    }
526
527    fn mouse_release(input: &str) -> IResult<&str, Mouse> {
528        let (input, _) = tag("release ")(input)?;
529        let (input, (button, target)) = mouse_button_at(input)?;
530
531        Ok((input, Mouse::Release { button, target }))
532    }
533
534    fn mouse_button_at(input: &str) -> IResult<&str, (mouse::Button, Option<Target>)> {
535        let (input, button) = mouse_button(input)?;
536        let (input, at) = opt(target).parse(input)?;
537
538        Ok((input, (button, at)))
539    }
540
541    fn target(input: &str) -> IResult<&str, Target> {
542        alt((string.map(Target::Text), point.map(Target::Point))).parse(input)
543    }
544
545    fn mouse_button(input: &str) -> IResult<&str, mouse::Button> {
546        alt((
547            tag("right").map(|_| mouse::Button::Right),
548            success(mouse::Button::Left),
549        ))
550        .parse(input)
551    }
552
553    fn keyboard(input: &str) -> IResult<&str, Keyboard> {
554        alt((
555            map(preceded(tag("type "), string), Keyboard::Typewrite),
556            map(preceded(tag("type "), key), Keyboard::Type),
557        ))
558        .parse(input)
559    }
560
561    fn expectation(input: &str) -> IResult<&str, Expectation> {
562        map(preceded(tag("expect "), string), |text| {
563            Expectation::Text(text)
564        })
565        .parse(input)
566    }
567
568    fn key(input: &str) -> IResult<&str, Key> {
569        alt((
570            map(tag("enter"), |_| Key::Enter),
571            map(tag("escape"), |_| Key::Escape),
572            map(tag("tab"), |_| Key::Tab),
573            map(tag("backspace"), |_| Key::Backspace),
574        ))
575        .parse(input)
576    }
577
578    fn point(input: &str) -> IResult<&str, Point> {
579        let comma = whitespace(char(','));
580
581        map(
582            delimited(
583                char('('),
584                separated_pair(float(), comma, float()),
585                char(')'),
586            ),
587            |(x, y)| Point { x, y },
588        )
589        .parse(input)
590    }
591
592    pub fn whitespace<'a, O, E: ParseError<&'a str>, F>(
593        inner: F,
594    ) -> impl Parser<&'a str, Output = O, Error = E>
595    where
596        F: Parser<&'a str, Output = O, Error = E>,
597    {
598        delimited(multispace0, inner, multispace0)
599    }
600
601    // Taken from https://github.com/rust-bakery/nom/blob/51c3c4e44fa78a8a09b413419372b97b2cc2a787/examples/string.rs
602    //
603    // Copyright (c) 2014-2019 Geoffroy Couprie
604    //
605    // Permission is hereby granted, free of charge, to any person obtaining
606    // a copy of this software and associated documentation files (the
607    // "Software"), to deal in the Software without restriction, including
608    // without limitation the rights to use, copy, modify, merge, publish,
609    // distribute, sublicense, and/or sell copies of the Software, and to
610    // permit persons to whom the Software is furnished to do so, subject to
611    // the following conditions:
612    //
613    // The above copyright notice and this permission notice shall be
614    // included in all copies or substantial portions of the Software.
615    //
616    // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
617    // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
618    // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
619    // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
620    // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
621    // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
622    // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
623    fn string(input: &str) -> IResult<&str, String> {
624        #[derive(Debug, Clone, Copy)]
625        enum Fragment<'a> {
626            Literal(&'a str),
627            EscapedChar(char),
628            EscapedWS,
629        }
630
631        fn fragment(input: &str) -> IResult<&str, Fragment<'_>> {
632            alt((
633                map(string_literal, Fragment::Literal),
634                map(escaped_char, Fragment::EscapedChar),
635                value(Fragment::EscapedWS, escaped_whitespace),
636            ))
637            .parse(input)
638        }
639
640        fn string_literal<'a, E: ParseError<&'a str>>(
641            input: &'a str,
642        ) -> IResult<&'a str, &'a str, E> {
643            let not_quote_slash = is_not("\"\\");
644
645            verify(not_quote_slash, |s: &str| !s.is_empty()).parse(input)
646        }
647
648        fn unicode(input: &str) -> IResult<&str, char> {
649            let parse_hex = take_while_m_n(1, 6, |c: char| c.is_ascii_hexdigit());
650
651            let parse_delimited_hex =
652                preceded(char('u'), delimited(char('{'), parse_hex, char('}')));
653
654            let parse_u32 = map_res(parse_delimited_hex, move |hex| u32::from_str_radix(hex, 16));
655
656            map_opt(parse_u32, std::char::from_u32).parse(input)
657        }
658
659        fn escaped_char(input: &str) -> IResult<&str, char> {
660            preceded(
661                char('\\'),
662                alt((
663                    unicode,
664                    value('\n', char('n')),
665                    value('\r', char('r')),
666                    value('\t', char('t')),
667                    value('\u{08}', char('b')),
668                    value('\u{0C}', char('f')),
669                    value('\\', char('\\')),
670                    value('/', char('/')),
671                    value('"', char('"')),
672                )),
673            )
674            .parse(input)
675        }
676
677        fn escaped_whitespace(input: &str) -> IResult<&str, &str> {
678            preceded(char('\\'), multispace1).parse(input)
679        }
680
681        let build_string = fold(0.., fragment, String::new, |mut string, fragment| {
682            match fragment {
683                Fragment::Literal(s) => string.push_str(s),
684                Fragment::EscapedChar(c) => string.push(c),
685                Fragment::EscapedWS => {}
686            }
687            string
688        });
689
690        delimited(char('"'), build_string, char('"')).parse(input)
691    }
692}