Skip to main content

iced_test/
ice.rs

1//! A shareable, simple format of end-to-end tests.
2use crate::Instruction;
3use crate::core::Size;
4use crate::emulator;
5use crate::instruction;
6
7/// An end-to-end test for iced applications.
8///
9/// Ice tests encode a certain configuration together with a sequence of instructions.
10/// An ice test passes if all the instructions can be executed successfully.
11///
12/// Normally, ice tests are run by an [`Emulator`](crate::Emulator) in continuous
13/// integration pipelines.
14///
15/// Ice tests can be easily run by saving them as `.ice` files in a folder and simply
16/// calling [`run`](crate::run). These test files can be recorded by enabling the `tester`
17/// feature flag in the root crate.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Ice {
20    /// The viewport [`Size`] that must be used for the test.
21    pub viewport: Size,
22    /// The [`emulator::Mode`] that must be used for the test.
23    pub mode: emulator::Mode,
24    /// The name of the [`Preset`](crate::program::Preset) that must be used for the test.
25    pub preset: Option<String>,
26    /// The sequence of instructions of the test.
27    pub instructions: Vec<Instruction>,
28}
29
30impl Ice {
31    /// Parses an [`Ice`] test from its textual representation.
32    ///
33    /// Here is an example of the [`Ice`] test syntax:
34    ///
35    /// ```text
36    /// viewport: 500x800
37    /// mode: Immediate
38    /// preset: Empty
39    /// -----
40    /// click "What needs to be done?"
41    /// type "Create the universe"
42    /// type enter
43    /// type "Make an apple pie"
44    /// type enter
45    /// expect "2 tasks left"
46    /// click "Create the universe"
47    /// expect "1 task left"
48    /// click "Make an apple pie"
49    /// expect "0 tasks left"
50    /// ```
51    ///
52    /// This syntax is _very_ experimental and extremely likely to change often.
53    /// For this reason, it is reserved for advanced users that want to early test it.
54    ///
55    /// Currently, in order to use it, you will need to earn the right and prove you understand
56    /// its experimental nature by reading the code!
57    pub fn parse(content: &str) -> Result<Self, ParseError> {
58        let Some((metadata, rest)) = content.split_once("-") else {
59            return Err(ParseError::NoMetadata);
60        };
61
62        let mut viewport = None;
63        let mut mode = None;
64        let mut preset = None;
65
66        for (i, line) in metadata.lines().enumerate() {
67            if line.trim().is_empty() {
68                continue;
69            }
70
71            let Some((field, value)) = line.split_once(':') else {
72                return Err(ParseError::InvalidMetadata {
73                    line: i,
74                    content: line.to_owned(),
75                });
76            };
77
78            match field.trim() {
79                "viewport" => {
80                    viewport = Some(
81                        if let Some((width, height)) = value.trim().split_once('x')
82                            && let Ok(width) = width.parse()
83                            && let Ok(height) = height.parse()
84                        {
85                            Size::new(width, height)
86                        } else {
87                            return Err(ParseError::InvalidViewport {
88                                line: i,
89                                value: value.to_owned(),
90                            });
91                        },
92                    );
93                }
94                "mode" => {
95                    mode = Some(match value.trim().to_lowercase().as_str() {
96                        "zen" => emulator::Mode::Zen,
97                        "patient" => emulator::Mode::Patient,
98                        "immediate" => emulator::Mode::Immediate,
99                        _ => {
100                            return Err(ParseError::InvalidMode {
101                                line: i,
102                                value: value.to_owned(),
103                            });
104                        }
105                    });
106                }
107                "preset" => {
108                    preset = Some(value.trim().to_owned());
109                }
110                field => {
111                    return Err(ParseError::UnknownField {
112                        line: i,
113                        field: field.to_owned(),
114                    });
115                }
116            }
117        }
118
119        let Some(viewport) = viewport else {
120            return Err(ParseError::MissingViewport);
121        };
122
123        let Some(mode) = mode else {
124            return Err(ParseError::MissingMode);
125        };
126
127        let instructions = rest
128            .lines()
129            .skip(1)
130            .enumerate()
131            .map(|(i, line)| {
132                Instruction::parse(line).map_err(|error| ParseError::InvalidInstruction {
133                    line: metadata.lines().count() + 1 + i,
134                    instruction: line.to_owned(),
135                    error,
136                })
137            })
138            .collect::<Result<Vec<_>, _>>()?;
139
140        Ok(Self {
141            viewport,
142            mode,
143            preset,
144            instructions,
145        })
146    }
147}
148
149impl std::fmt::Display for Ice {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        writeln!(
152            f,
153            "viewport: {width}x{height}",
154            width = self.viewport.width as u32,   // TODO
155            height = self.viewport.height as u32, // TODO
156        )?;
157
158        writeln!(f, "mode: {}", self.mode)?;
159
160        if let Some(preset) = &self.preset {
161            writeln!(f, "preset: {preset}")?;
162        }
163
164        f.write_str("-----\n")?;
165
166        for instruction in &self.instructions {
167            instruction.fmt(f)?;
168            f.write_str("\n")?;
169        }
170
171        Ok(())
172    }
173}
174
175/// An error produced during [`Ice::parse`].
176#[derive(Debug, Clone, thiserror::Error)]
177pub enum ParseError {
178    /// No metadata is present.
179    #[error("the ice test has no metadata")]
180    NoMetadata,
181
182    /// The metadata is invalid.
183    #[error("invalid metadata in line {line}: \"{content}\"")]
184    InvalidMetadata {
185        /// The number of the invalid line.
186        line: usize,
187        /// The content of the invalid line.
188        content: String,
189    },
190
191    /// The viewport is invalid.
192    #[error("invalid viewport in line {line}: \"{value}\"")]
193    InvalidViewport {
194        /// The number of the invalid line.
195        line: usize,
196
197        /// The invalid value.
198        value: String,
199    },
200
201    /// The [`emulator::Mode`] is invalid.
202    #[error("invalid mode in line {line}: \"{value}\"")]
203    InvalidMode {
204        /// The number of the invalid line.
205        line: usize,
206        /// The invalid value.
207        value: String,
208    },
209
210    /// A metadata field is unknown.
211    #[error("unknown metadata field in line {line}: \"{field}\"")]
212    UnknownField {
213        /// The number of the invalid line.
214        line: usize,
215        /// The name of the unknown field.
216        field: String,
217    },
218
219    /// Viewport metadata is missing.
220    #[error("metadata is missing the viewport field")]
221    MissingViewport,
222
223    /// [`emulator::Mode`] metadata is missing.
224    #[error("metadata is missing the mode field")]
225    MissingMode,
226
227    /// An [`Instruction`] failed to parse.
228    #[error("invalid instruction in line {line}: {error}")]
229    InvalidInstruction {
230        /// The number of the invalid line.
231        line: usize,
232        /// The invalid instruction.
233        instruction: String,
234        /// The parse error.
235        error: instruction::ParseError,
236    },
237}