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)) =
82                            value.trim().split_once('x')
83                            && let Ok(width) = width.parse()
84                            && let Ok(height) = height.parse()
85                        {
86                            Size::new(width, height)
87                        } else {
88                            return Err(ParseError::InvalidViewport {
89                                line: i,
90                                value: value.to_owned(),
91                            });
92                        },
93                    );
94                }
95                "mode" => {
96                    mode = Some(match value.trim().to_lowercase().as_str() {
97                        "zen" => emulator::Mode::Zen,
98                        "patient" => emulator::Mode::Patient,
99                        "immediate" => emulator::Mode::Immediate,
100                        _ => {
101                            return Err(ParseError::InvalidMode {
102                                line: i,
103                                value: value.to_owned(),
104                            });
105                        }
106                    });
107                }
108                "preset" => {
109                    preset = Some(value.trim().to_owned());
110                }
111                field => {
112                    return Err(ParseError::UnknownField {
113                        line: i,
114                        field: field.to_owned(),
115                    });
116                }
117            }
118        }
119
120        let Some(viewport) = viewport else {
121            return Err(ParseError::MissingViewport);
122        };
123
124        let Some(mode) = mode else {
125            return Err(ParseError::MissingMode);
126        };
127
128        let instructions = rest
129            .lines()
130            .skip(1)
131            .enumerate()
132            .map(|(i, line)| {
133                Instruction::parse(line).map_err(|error| {
134                    ParseError::InvalidInstruction {
135                        line: metadata.lines().count() + 1 + i,
136                        error,
137                    }
138                })
139            })
140            .collect::<Result<Vec<_>, _>>()?;
141
142        Ok(Self {
143            viewport,
144            mode,
145            preset,
146            instructions,
147        })
148    }
149}
150
151impl std::fmt::Display for Ice {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        writeln!(
154            f,
155            "viewport: {width}x{height}",
156            width = self.viewport.width as u32, // TODO
157            height = self.viewport.height as u32, // TODO
158        )?;
159
160        writeln!(f, "mode: {}", self.mode)?;
161
162        if let Some(preset) = &self.preset {
163            writeln!(f, "preset: {preset}")?;
164        }
165
166        f.write_str("-----\n")?;
167
168        for instruction in &self.instructions {
169            instruction.fmt(f)?;
170            f.write_str("\n")?;
171        }
172
173        Ok(())
174    }
175}
176
177/// An error produced during [`Ice::parse`].
178#[derive(Debug, Clone, thiserror::Error)]
179pub enum ParseError {
180    /// No metadata is present.
181    #[error("the ice test has no metadata")]
182    NoMetadata,
183
184    /// The metadata is invalid.
185    #[error("invalid metadata in line {line}: \"{content}\"")]
186    InvalidMetadata {
187        /// The number of the invalid line.
188        line: usize,
189        /// The content of the invalid line.
190        content: String,
191    },
192
193    /// The viewport is invalid.
194    #[error("invalid viewport in line {line}: \"{value}\"")]
195    InvalidViewport {
196        /// The number of the invalid line.
197        line: usize,
198
199        /// The invalid value.
200        value: String,
201    },
202
203    /// The [`emulator::Mode`] is invalid.
204    #[error("invalid mode in line {line}: \"{value}\"")]
205    InvalidMode {
206        /// The number of the invalid line.
207        line: usize,
208        /// The invalid value.
209        value: String,
210    },
211
212    /// A metadata field is unknown.
213    #[error("unknown metadata field in line {line}: \"{field}\"")]
214    UnknownField {
215        /// The number of the invalid line.
216        line: usize,
217        /// The name of the unknown field.
218        field: String,
219    },
220
221    /// Viewport metadata is missing.
222    #[error("metadata is missing the viewport field")]
223    MissingViewport,
224
225    /// [`emulator::Mode`] metadata is missing.
226    #[error("metadata is missing the mode field")]
227    MissingMode,
228
229    /// An [`Instruction`] failed to parse.
230    #[error("invalid instruction in line {line}: {error}")]
231    InvalidInstruction {
232        /// The number of the invalid line.
233        line: usize,
234        /// The parse error.
235        error: instruction::ParseError,
236    },
237}