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                    error,
135                })
136            })
137            .collect::<Result<Vec<_>, _>>()?;
138
139        Ok(Self {
140            viewport,
141            mode,
142            preset,
143            instructions,
144        })
145    }
146}
147
148impl std::fmt::Display for Ice {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        writeln!(
151            f,
152            "viewport: {width}x{height}",
153            width = self.viewport.width as u32,   // TODO
154            height = self.viewport.height as u32, // TODO
155        )?;
156
157        writeln!(f, "mode: {}", self.mode)?;
158
159        if let Some(preset) = &self.preset {
160            writeln!(f, "preset: {preset}")?;
161        }
162
163        f.write_str("-----\n")?;
164
165        for instruction in &self.instructions {
166            instruction.fmt(f)?;
167            f.write_str("\n")?;
168        }
169
170        Ok(())
171    }
172}
173
174/// An error produced during [`Ice::parse`].
175#[derive(Debug, Clone, thiserror::Error)]
176pub enum ParseError {
177    /// No metadata is present.
178    #[error("the ice test has no metadata")]
179    NoMetadata,
180
181    /// The metadata is invalid.
182    #[error("invalid metadata in line {line}: \"{content}\"")]
183    InvalidMetadata {
184        /// The number of the invalid line.
185        line: usize,
186        /// The content of the invalid line.
187        content: String,
188    },
189
190    /// The viewport is invalid.
191    #[error("invalid viewport in line {line}: \"{value}\"")]
192    InvalidViewport {
193        /// The number of the invalid line.
194        line: usize,
195
196        /// The invalid value.
197        value: String,
198    },
199
200    /// The [`emulator::Mode`] is invalid.
201    #[error("invalid mode in line {line}: \"{value}\"")]
202    InvalidMode {
203        /// The number of the invalid line.
204        line: usize,
205        /// The invalid value.
206        value: String,
207    },
208
209    /// A metadata field is unknown.
210    #[error("unknown metadata field in line {line}: \"{field}\"")]
211    UnknownField {
212        /// The number of the invalid line.
213        line: usize,
214        /// The name of the unknown field.
215        field: String,
216    },
217
218    /// Viewport metadata is missing.
219    #[error("metadata is missing the viewport field")]
220    MissingViewport,
221
222    /// [`emulator::Mode`] metadata is missing.
223    #[error("metadata is missing the mode field")]
224    MissingMode,
225
226    /// An [`Instruction`] failed to parse.
227    #[error("invalid instruction in line {line}: {error}")]
228    InvalidInstruction {
229        /// The number of the invalid line.
230        line: usize,
231        /// The parse error.
232        error: instruction::ParseError,
233    },
234}