Skip to main content

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 widget with the given identifier.
328    Id(String),
329    /// A UI element containing the given text.
330    Text(String),
331    /// A specific point of the viewport.
332    Point(Point),
333}
334
335impl fmt::Display for Target {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        match self {
338            Self::Id(id) => f.write_str(&format::id(id)),
339            Self::Point(point) => f.write_str(&format::point(*point)),
340            Self::Text(text) => f.write_str(&format::string(text)),
341        }
342    }
343}
344
345/// A keyboard interaction.
346#[derive(Debug, Clone, PartialEq)]
347pub enum Keyboard {
348    /// A key was pressed.
349    Press(Key),
350    /// A key was released.
351    Release(Key),
352    /// A key was "typed" (press and released).
353    Type(Key),
354    /// A bunch of text was typed.
355    Typewrite(String),
356}
357
358impl fmt::Display for Keyboard {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        match self {
361            Keyboard::Press(key) => {
362                write!(f, "press {}", format::key(*key))
363            }
364            Keyboard::Release(key) => {
365                write!(f, "release {}", format::key(*key))
366            }
367            Keyboard::Type(key) => {
368                write!(f, "type {}", format::key(*key))
369            }
370            Keyboard::Typewrite(text) => {
371                write!(f, "type \"{text}\"")
372            }
373        }
374    }
375}
376
377/// A keyboard key.
378///
379/// Only a small subset of keys is supported currently!
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381#[allow(missing_docs)]
382pub enum Key {
383    Enter,
384    Escape,
385    Tab,
386    Backspace,
387}
388
389impl From<Key> for keyboard::Key {
390    fn from(key: Key) -> Self {
391        match key {
392            Key::Enter => Self::Named(keyboard::key::Named::Enter),
393            Key::Escape => Self::Named(keyboard::key::Named::Escape),
394            Key::Tab => Self::Named(keyboard::key::Named::Tab),
395            Key::Backspace => Self::Named(keyboard::key::Named::Backspace),
396        }
397    }
398}
399
400mod format {
401    use super::*;
402
403    pub fn button_at(button: mouse::Button, at: Option<&Target>) -> String {
404        let button = self::button(button);
405
406        if let Some(at) = at {
407            if button.is_empty() {
408                at.to_string()
409            } else {
410                format!("{} {}", button, at)
411            }
412        } else {
413            button.to_owned()
414        }
415    }
416
417    pub fn button(button: mouse::Button) -> &'static str {
418        match button {
419            mouse::Button::Left => "",
420            mouse::Button::Right => "right",
421            mouse::Button::Middle => "middle",
422            mouse::Button::Back => "back",
423            mouse::Button::Forward => "forward",
424            mouse::Button::Other(_) => "other",
425        }
426    }
427
428    pub fn point(point: Point) -> String {
429        format!("({:.2}, {:.2})", point.x, point.y)
430    }
431
432    pub fn key(key: Key) -> &'static str {
433        match key {
434            Key::Enter => "enter",
435            Key::Escape => "escape",
436            Key::Tab => "tab",
437            Key::Backspace => "backspace",
438        }
439    }
440
441    pub fn string(text: &str) -> String {
442        format!("\"{}\"", text.escape_default())
443    }
444
445    pub fn id(id: &str) -> String {
446        format!("#{id}")
447    }
448}
449
450/// A testing assertion.
451///
452/// Expectations are instructions that verify the current state of
453/// the user interface of an application.
454#[derive(Debug, Clone, PartialEq)]
455pub enum Expectation {
456    /// Expect some element to contain some text.
457    Text(String),
458}
459
460impl fmt::Display for Expectation {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        match self {
463            Expectation::Text(text) => {
464                write!(f, "expect {}", format::string(text))
465            }
466        }
467    }
468}
469
470pub use parser::Error as ParseError;
471
472mod parser {
473    use super::*;
474
475    use nom::branch::alt;
476    use nom::bytes::complete::tag;
477    use nom::bytes::{is_not, take_while_m_n};
478    use nom::character::complete::{alphanumeric1, char, multispace0, multispace1};
479    use nom::combinator::{map, map_opt, map_res, opt, recognize, success, value, verify};
480    use nom::error::ParseError;
481    use nom::multi::{fold, many1_count};
482    use nom::number::float;
483    use nom::sequence::{delimited, preceded, separated_pair};
484    use nom::{Finish, IResult, Parser};
485
486    /// A parsing error.
487    #[derive(Debug, Clone, thiserror::Error)]
488    #[error("parse error: {0}")]
489    pub struct Error(nom::error::Error<String>);
490
491    pub fn run(input: &str) -> Result<Instruction, Error> {
492        match instruction.parse_complete(input).finish() {
493            Ok((_rest, instruction)) => Ok(instruction),
494            Err(error) => Err(Error(error.cloned())),
495        }
496    }
497
498    fn instruction(input: &str) -> IResult<&str, Instruction> {
499        alt((
500            map(interaction, Instruction::Interact),
501            map(expectation, Instruction::Expect),
502        ))
503        .parse(input)
504    }
505
506    fn interaction(input: &str) -> IResult<&str, Interaction> {
507        alt((
508            map(mouse, Interaction::Mouse),
509            map(keyboard, Interaction::Keyboard),
510        ))
511        .parse(input)
512    }
513
514    fn mouse(input: &str) -> IResult<&str, Mouse> {
515        let mouse_move = preceded(tag("move "), target).map(Mouse::Move);
516
517        alt((mouse_move, mouse_click, mouse_press, mouse_release)).parse(input)
518    }
519
520    fn mouse_click(input: &str) -> IResult<&str, Mouse> {
521        let (input, _) = tag("click ")(input)?;
522        let (input, (button, target)) = mouse_button_at(input)?;
523
524        Ok((input, Mouse::Click { button, target }))
525    }
526
527    fn mouse_press(input: &str) -> IResult<&str, Mouse> {
528        let (input, _) = tag("press ")(input)?;
529        let (input, (button, target)) = mouse_button_at(input)?;
530
531        Ok((input, Mouse::Press { button, target }))
532    }
533
534    fn mouse_release(input: &str) -> IResult<&str, Mouse> {
535        let (input, _) = tag("release ")(input)?;
536        let (input, (button, target)) = mouse_button_at(input)?;
537
538        Ok((input, Mouse::Release { button, target }))
539    }
540
541    fn mouse_button_at(input: &str) -> IResult<&str, (mouse::Button, Option<Target>)> {
542        let (input, button) = mouse_button(input)?;
543        let (input, at) = opt(target).parse(input)?;
544
545        Ok((input, (button, at)))
546    }
547
548    fn target(input: &str) -> IResult<&str, Target> {
549        alt((
550            id.map(String::from).map(Target::Id),
551            string.map(Target::Text),
552            point.map(Target::Point),
553        ))
554        .parse(input)
555    }
556
557    fn mouse_button(input: &str) -> IResult<&str, mouse::Button> {
558        alt((
559            tag("right").map(|_| mouse::Button::Right),
560            success(mouse::Button::Left),
561        ))
562        .parse(input)
563    }
564
565    fn keyboard(input: &str) -> IResult<&str, Keyboard> {
566        alt((
567            map(preceded(tag("type "), string), Keyboard::Typewrite),
568            map(preceded(tag("type "), key), Keyboard::Type),
569        ))
570        .parse(input)
571    }
572
573    fn expectation(input: &str) -> IResult<&str, Expectation> {
574        map(preceded(tag("expect "), string), |text| {
575            Expectation::Text(text)
576        })
577        .parse(input)
578    }
579
580    fn key(input: &str) -> IResult<&str, Key> {
581        alt((
582            map(tag("enter"), |_| Key::Enter),
583            map(tag("escape"), |_| Key::Escape),
584            map(tag("tab"), |_| Key::Tab),
585            map(tag("backspace"), |_| Key::Backspace),
586        ))
587        .parse(input)
588    }
589
590    fn id(input: &str) -> IResult<&str, &str> {
591        preceded(
592            char('#'),
593            recognize(many1_count(alt((alphanumeric1, tag("_"), tag("-"))))),
594        )
595        .parse(input)
596    }
597
598    fn point(input: &str) -> IResult<&str, Point> {
599        let comma = whitespace(char(','));
600
601        map(
602            delimited(
603                char('('),
604                separated_pair(float(), comma, float()),
605                char(')'),
606            ),
607            |(x, y)| Point { x, y },
608        )
609        .parse(input)
610    }
611
612    pub fn whitespace<'a, O, E: ParseError<&'a str>, F>(
613        inner: F,
614    ) -> impl Parser<&'a str, Output = O, Error = E>
615    where
616        F: Parser<&'a str, Output = O, Error = E>,
617    {
618        delimited(multispace0, inner, multispace0)
619    }
620
621    // Taken from https://github.com/rust-bakery/nom/blob/51c3c4e44fa78a8a09b413419372b97b2cc2a787/examples/string.rs
622    //
623    // Copyright (c) 2014-2019 Geoffroy Couprie
624    //
625    // Permission is hereby granted, free of charge, to any person obtaining
626    // a copy of this software and associated documentation files (the
627    // "Software"), to deal in the Software without restriction, including
628    // without limitation the rights to use, copy, modify, merge, publish,
629    // distribute, sublicense, and/or sell copies of the Software, and to
630    // permit persons to whom the Software is furnished to do so, subject to
631    // the following conditions:
632    //
633    // The above copyright notice and this permission notice shall be
634    // included in all copies or substantial portions of the Software.
635    //
636    // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
637    // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
638    // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
639    // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
640    // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
641    // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
642    // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
643    fn string(input: &str) -> IResult<&str, String> {
644        #[derive(Debug, Clone, Copy)]
645        enum Fragment<'a> {
646            Literal(&'a str),
647            EscapedChar(char),
648            EscapedWS,
649        }
650
651        fn fragment(input: &str) -> IResult<&str, Fragment<'_>> {
652            alt((
653                map(string_literal, Fragment::Literal),
654                map(escaped_char, Fragment::EscapedChar),
655                value(Fragment::EscapedWS, escaped_whitespace),
656            ))
657            .parse(input)
658        }
659
660        fn string_literal<'a, E: ParseError<&'a str>>(
661            input: &'a str,
662        ) -> IResult<&'a str, &'a str, E> {
663            let not_quote_slash = is_not("\"\\");
664
665            verify(not_quote_slash, |s: &str| !s.is_empty()).parse(input)
666        }
667
668        fn unicode(input: &str) -> IResult<&str, char> {
669            let parse_hex = take_while_m_n(1, 6, |c: char| c.is_ascii_hexdigit());
670
671            let parse_delimited_hex =
672                preceded(char('u'), delimited(char('{'), parse_hex, char('}')));
673
674            let parse_u32 = map_res(parse_delimited_hex, move |hex| u32::from_str_radix(hex, 16));
675
676            map_opt(parse_u32, std::char::from_u32).parse(input)
677        }
678
679        fn escaped_char(input: &str) -> IResult<&str, char> {
680            preceded(
681                char('\\'),
682                alt((
683                    unicode,
684                    value('\n', char('n')),
685                    value('\r', char('r')),
686                    value('\t', char('t')),
687                    value('\u{08}', char('b')),
688                    value('\u{0C}', char('f')),
689                    value('\\', char('\\')),
690                    value('/', char('/')),
691                    value('"', char('"')),
692                )),
693            )
694            .parse(input)
695        }
696
697        fn escaped_whitespace(input: &str) -> IResult<&str, &str> {
698            preceded(char('\\'), multispace1).parse(input)
699        }
700
701        let build_string = fold(0.., fragment, String::new, |mut string, fragment| {
702            match fragment {
703                Fragment::Literal(s) => string.push_str(s),
704                Fragment::EscapedChar(c) => string.push(c),
705                Fragment::EscapedWS => {}
706            }
707            string
708        });
709
710        delimited(char('"'), build_string, char('"')).parse(input)
711    }
712}