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