1mod lens;
34mod parse;
35mod resolve;
36pub mod step;
37
38use expectation_model::{Diagnostic, Suite};
39use std::path::{Path, PathBuf};
40
41pub const EXTENSION: &str = "expect";
42
43pub struct Loaded {
46 pub suite: Suite,
47 pub diagnostics: Vec<Diagnostic>,
48}
49
50impl Loaded {
51 pub fn has_errors(&self) -> bool {
52 expectation_model::diagnostic::has_errors(&self.diagnostics)
53 }
54
55 pub fn render_diagnostics(&self) -> String {
56 expectation_model::diagnostic::render_all(&self.diagnostics, &self.suite.sources)
57 }
58}
59
60pub fn load_sources(name: &str, sources: &[(&str, &str)]) -> Loaded {
62 load_sources_for(name, sources, None)
63}
64
65pub fn load_sources_for(name: &str, sources: &[(&str, &str)], platform: Option<&str>) -> Loaded {
68 let mut diags = vec![];
69 let mut suite = Suite { name: name.to_string(), ..Suite::default() };
70 let mut files = vec![];
71 for (file, text) in sources {
72 let mut raw = parse::parse(file, text, &mut diags);
73 raw.dir = file.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
74 files.push(raw);
75 suite.sources.insert((*file).into(), text.to_string());
76 }
77 (suite.platforms, suite.platform) = lens::apply(&mut files, platform, &mut diags);
78 resolve::resolve(&mut suite, &files, &mut diags);
79 Loaded { suite, diagnostics: diags }
80}
81
82pub fn load_dir(dir: &Path) -> std::io::Result<Loaded> {
85 load_dir_for(dir, None)
86}
87
88pub fn load_dir_for(dir: &Path, platform: Option<&str>) -> std::io::Result<Loaded> {
90 let mut paths = vec![];
91 collect(dir, &mut paths)?;
92 paths.sort();
93 let mut texts = vec![];
94 for p in &paths {
95 let rel = p.strip_prefix(dir).unwrap_or(p).to_string_lossy().replace('\\', "/");
96 texts.push((rel, std::fs::read_to_string(p)?));
97 }
98 let refs: Vec<(&str, &str)> = texts.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
99 let mut loaded = load_sources_for(&suite_name(dir), &refs, platform);
100 if paths.is_empty() {
101 loaded
102 .diagnostics
103 .push(Diagnostic::error("empty-suite", format!("no .{EXTENSION} files in {}", dir.display())));
104 }
105 Ok(loaded)
106}
107
108fn collect(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
109 for entry in std::fs::read_dir(dir)? {
110 let p = entry?.path();
111 if p.is_dir() {
112 collect(&p, out)?;
113 } else if p.extension().is_some_and(|e| e == EXTENSION) {
114 out.push(p);
115 }
116 }
117 Ok(())
118}
119
120fn suite_name(dir: &Path) -> String {
122 let base = dir.canonicalize().ok().and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()));
123 let base = base.unwrap_or_else(|| "Expectations".into()).replace(['_', '-'], " ");
124 let mut c = base.chars();
125 c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use expectation_model::{Constraint, ExpId, Keyword};
132
133 const SUITE: &str = "
134Suite: Kettle basics
135
136Vocabulary:
137 State: the kettle is off | heating | dry
138 State: the dial is 0..100 %
139 State: the child lock is on | off
140 Event: the user presses reset
141
142Background:
143 Given the kettle is off
144 And the dial is 0 %
145
146Feature: Heating
147 The kettle starts off.
148
149 Expectation: Turning the dial up starts the kettle
150 Given the kettle is off
151 When the dial is above 55 %
152 Then the kettle is heating
153
154 Expectation: The child lock holds the kettle
155 Overrides: Turning the dial up starts the kettle
156 Given the kettle is off
157 And the child lock is on
158 When the dial is above 55 %
159 Then the kettle is off
160
161 Expectation: Never dry while off
162 Given the kettle is off
163 Then the kettle is not dry
164
165Story: First cup
166 As a tea drinker
167 I want to boil the kettle
168
169 Given the dial is 0 %
170 When the dial is 80 %
171 Then the kettle is heating
172 When the dial is 0 %
173 And the user presses reset
174 Then the kettle is off
175";
176
177 #[test]
178 fn parses_a_suite() {
179 let l = load_sources("t", &[("t.expect", SUITE)]);
180 assert!(!l.has_errors(), "{}", l.render_diagnostics());
181 let s = &l.suite;
182 assert_eq!(s.name, "Kettle basics");
183 assert_eq!(s.vocabulary.variables.len(), 3);
184 assert_eq!(s.background.vars().count(), 2);
185 assert_eq!(s.features[0].description, vec!["The kettle starts off."]);
186 let [start, lock, never] = &s.expectations[..] else { panic!() };
187 assert!(start.trigger.event.is_none() && start.trigger.changes.vars().count() == 1);
188 assert_eq!(lock.overrides, vec![start.id]);
189 assert!(never.is_invariant());
190 assert_eq!(never.then.get(expectation_model::VarId(0)), Some(&Constraint::OneOf(0b011)));
191
192 let story = &s.stories[0];
193 assert_eq!(story.narrative.len(), 2);
194 let sc = &story.scenarios[0];
195 assert_eq!(sc.name, "First cup");
196 assert_eq!(sc.steps.len(), 2);
197 assert!(sc.steps[1].action.event.is_some() && sc.steps[1].action.changes.vars().count() == 1);
198 assert_eq!(sc.steps[1].steps[1].keyword, Keyword::And);
199 }
200
201 #[test]
202 fn sections_nest_by_directory_and_layer_their_setup() {
203 let vocab = "Suite: Sketch\nVocabulary:\n State: the tool is none | PEN\n State: the drawing has 0..100 shapes\n Event: the user presses PEN\n Event: the user clicks the canvas\nBackground:\n Given the tool is none\n And the drawing has 0 shapes\n";
204 let tools = "Feature: Tools\n Expectation: PEN picks the pen\n Given the tool is none\n When the user presses PEN\n Then the tool is PEN\n";
205 let drawing = "Section: Drawing\n Everything here starts with the pen.\nBackground:\n Given the tool is PEN\nFeature: Shapes\n Expectation: Clicking adds a shape\n Given the tool is PEN\n When the user clicks the canvas\n Then the drawing goes up by 1\n";
206 let l = load_sources(
207 "x",
208 &[("root.expect", vocab), ("tools.expect", tools), ("03-drawing/shapes.expect", drawing)],
209 );
210 assert!(!l.has_errors(), "{}", l.render_diagnostics());
211 let s = &l.suite;
212 assert_eq!(s.sections.len(), 2);
213 let (root, drawing) = (&s.sections[0], &s.sections[1]);
214 assert_eq!((root.name.as_str(), drawing.name.as_str()), ("Sketch", "Drawing"));
215 assert_eq!(drawing.slug, "drawing");
216 assert_eq!(drawing.parent, Some(root.id));
217 assert_eq!(drawing.description, vec!["Everything here starts with the pen."]);
218 assert_eq!(s.describe(&drawing.start), "the tool is PEN and the drawing has 0 shapes");
220 assert_eq!(s.feature(s.exp(ExpId(1)).feature).section, drawing.id);
221 assert_eq!(s.exp(ExpId(1)).shifts, vec![(expectation_model::VarId(1), expectation_model::Shift::By(1.0))]);
222 }
223
224 #[test]
225 fn phrases_say_more_with_less() {
226 let src = "Vocabulary:
227 State: the tool is pan | PEN
228 Set: pick the {value} tool
229 State: the drawing has 0..100 shapes
230 Event: the user presses PEN
231 Say: press PEN
232 Event: the user clicks the canvas
233 Phrase: the user picks the pen
234 Say: press PEN
235 When the user presses PEN
236 Phrase: the user draws two shapes
237 Say: click the canvas twice
238 When the user clicks the canvas
239 When the user clicks the canvas
240 Phrase: drawing has started
241 Given the tool is PEN
242 And the drawing has 0 shapes
243Feature: F
244 Expectation: A click adds a shape
245 Given drawing has started
246 When the user clicks the canvas
247 Then the drawing has 1 shape
248Story: S
249 As an artist
250 I want to draw
251 Drawing starts on a blank canvas.
252 When the user picks the pen
253 When the user draws two shapes
254 Then the drawing has 2 shapes
255";
256 let l = load_sources("t", &[("t.expect", src)]);
257 assert!(!l.has_errors(), "{}", l.render_diagnostics());
258 let s = &l.suite;
259 assert_eq!(s.vocabulary.events[0].say.as_deref(), Some("press PEN"));
260 assert_eq!(s.vocabulary.variables[0].set.as_deref(), Some("pick the {value} tool"));
261 assert_eq!(s.vocabulary.phrases.len(), 3);
262 assert_eq!(s.exp(ExpId(0)).given.vars().count(), 2);
264 let story = &s.stories[0];
265 assert_eq!(story.narrative.len(), 2);
266 assert_eq!(story.description, vec!["Drawing starts on a blank canvas."]);
267 let steps = &story.scenarios[0].steps;
269 assert_eq!(steps.iter().map(|st| st.group).collect::<Vec<_>>(), vec![1, 2, 2]);
270 assert_eq!(steps[1].written.len(), 2, "the When and the Then, as written");
271 assert!(steps[2].written.is_empty());
272 assert_eq!(steps[2].checks.len(), 1, "the Then checks after the last click");
273 assert!(matches!(steps[1].written[0].meaning, Some(expectation_model::Said::Phrase(_))));
274 }
275
276 #[test]
277 fn docs_lines_say_what_the_reader_sees() {
278 let src = "Vocabulary:
279 State: the kettle is off | dry
280 Shows: dry: the display says DRY
281 State: the drawing has 0..650 shapes
282 Shows: the counter reads {value} SHAPES
283 State: the ink is 0..100 %
284 Shows: at least 99 %: the ink is full
285 Event: the user starts the kettle
286Term: display
287 The panel on the front of the kettle.
288Feature: F
289 When the kettle is off, Space starts it.
290 Expectation: E
291 Docs: hidden
292 Given the kettle is off
293 When the user starts the kettle
294 Then the kettle is dry
295";
296 let l = load_sources("t", &[("t.expect", src)]);
297 assert!(!l.has_errors(), "{}", l.render_diagnostics());
298 let s = &l.suite;
299 let v = &s.vocabulary;
300 let shown = |step: &str| match step::resolve(step, &expectation_model::Span::new("t", 1), v) {
301 Ok(step::Meaning::Fact(f)) => v.var(f.var).shown(&f.constraint),
302 _ => panic!("{step} isn't a state"),
303 };
304 assert_eq!(shown("the kettle is dry").as_deref(), Some("the display says DRY"));
305 assert_eq!(shown("the kettle is off"), None);
306 assert_eq!(shown("the drawing has 3 shapes").as_deref(), Some("the counter reads 3 SHAPES"));
307 assert_eq!(shown("the ink is at least 99 %").as_deref(), Some("the ink is full"));
308 assert_eq!(s.glossary[0].name, "display");
309 assert_eq!(s.features[0].description, vec!["When the kettle is off, Space starts it."]);
311 assert!(s.expectations[0].hidden);
312 }
313
314 const PLATFORMS: &str = "Suite: Sketch
315Vocabulary:
316 Platform: desktop | iOS
317 State: the door is locked | unlocked
318 Shows: unlocked: the UNLOCKED light comes on
319 Shows[iOS]: unlocked: the UNLOCKED badge turns green
320 State: the tool is pan | PEN
321 Event: the user unlocks the door
322 Say: press UNLOCK
323 Say[iOS]: tap UNLOCK
324 Phrase: the app is open
325 Say: open it on the Mac
326 Given the door is locked
327 And the tool is pan
328 Phrase[iOS]: the app is open
329 Say: open the app on the phone
330 Given the door is locked
331Background:
332 Given the app is open
333
334Feature: Unlocking
335 Expectation: UNLOCK unlocks
336 Given the door is locked
337 When the user unlocks the door
338 Then the door is unlocked
339
340Feature: Drawing
341 On: desktop
342 Expectation: PEN picks the pen
343 Given the tool is pan
344 When the user unlocks the door
345 Then the tool is PEN
346";
347
348 #[test]
349 fn a_platform_reads_its_own_variants() {
350 let sources = [("s.expect", PLATFORMS)];
351 let desktop = load_sources_for("s", &sources, None);
352 assert!(!desktop.has_errors(), "{}", desktop.render_diagnostics());
353 let s = &desktop.suite;
354 assert_eq!((s.platforms.clone(), s.platform.as_deref()), (vec!["desktop".into(), "iOS".into()], Some("desktop")));
355 assert_eq!(s.vocabulary.events[0].say.as_deref(), Some("press UNLOCK"));
356 assert_eq!(s.expectations.len(), 2);
357 assert_eq!(s.background.vars().count(), 2, "the plain phrase sets the tool too");
358
359 let ios = load_sources_for("s", &sources, Some("iOS"));
360 assert!(!ios.has_errors(), "{}", ios.render_diagnostics());
361 let s = &ios.suite;
362 assert_eq!(s.platform.as_deref(), Some("iOS"));
363 assert_eq!(s.vocabulary.events[0].say.as_deref(), Some("tap UNLOCK"));
364 assert_eq!(s.vocabulary.phrases.len(), 1, "one `the app is open`, the iOS one");
365 assert_eq!(s.vocabulary.phrases[0].say.as_deref(), Some("open the app on the phone"));
366 assert_eq!(s.background.vars().count(), 1, "iOS has no tool in its start");
367 assert_eq!(s.expectations.len(), 1, "Drawing is on desktop only");
368 let shown = s.vocabulary.variables[0].shows.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>();
369 assert_eq!(shown, vec!["the UNLOCKED badge turns green"]);
370 }
371
372 #[test]
373 fn platform_mistakes_are_reported() {
374 let vocab = "Vocabulary:\n Platform: desktop | iOS\n State: the door is locked | unlocked\n Event: the user unlocks the door\n";
375 let with = |rest: &str| format!("{vocab}{rest}");
376 assert_eq!(errors(&with("Event: x\n Say[watch]: tap\n")), vec!["unknown-platform"]);
377 assert_eq!(errors(&with("Event: y\n Say[desktop]: tap\n")), vec!["default-platform"]);
378 assert_eq!(errors(&with("Feature: F\n On: Android\n")), vec!["unknown-platform"]);
379 assert_eq!(errors("Feature: F\n On: iOS\n"), vec!["unknown-platform"]);
380 let l = load_sources_for("t", &[("t.expect", vocab)], Some("Android"));
381 assert_eq!(l.diagnostics.iter().map(|d| d.code).collect::<Vec<_>>(), vec!["unknown-platform"]);
382 }
383
384 fn errors(src: &str) -> Vec<&'static str> {
385 let l = load_sources("t", &[("t.expect", src)]);
386 l.diagnostics.iter().filter(|d| d.is_error()).map(|d| d.code).collect()
387 }
388
389 #[test]
390 fn reports_mistakes() {
391 let vocab = "Vocabulary:\n State: the kettle is off | heating\n Event: the user presses reset\n";
392 let with = |rest: &str| format!("{vocab}{rest}");
393 assert_eq!(errors(&with("Feature: F\n Expectation: E\n Given the kettle is heating\n")), vec!["no-then"]);
394 assert_eq!(errors(&with("Feature: F\n Expectation: E\n Then the kettle is boiling\n")), vec!["bad-value"]);
395 assert_eq!(errors(&with("Feature: F\n Expectation: E\n Then the teapot is heating\n")), vec!["unknown-step"]);
396 assert_eq!(
397 errors(&with(
398 "Feature: F\n Expectation: E\n Given the kettle is heating\n And the kettle is off\n Then the kettle is heating\n"
399 )),
400 vec!["contradiction"]
401 );
402 assert_eq!(
403 errors(&with("Feature: F\n Expectation: E\n Then the user presses reset\n")),
404 vec!["misplaced-event"]
405 );
406 assert_eq!(
407 errors(&with("Feature: F\n Expectation: E\n Overrides: Nope\n Then the kettle is heating\n")),
408 vec!["bad-override"]
409 );
410 assert_eq!(errors(&with("Expectation: E\n")), vec!["syntax"]);
411 assert_eq!(errors(&with("Story: S\n Then the kettle is heating\n")), vec!["syntax", "empty-story"]);
412 let vacuous = load_sources(
413 "t",
414 &[(
415 "t.expect",
416 &with(
417 "Feature: F\n Expectation: E\n When the kettle is off or heating\n Then the kettle is heating\n",
418 ),
419 )],
420 );
421 assert_eq!(vacuous.diagnostics.iter().map(|d| d.code).collect::<Vec<_>>(), vec!["says-nothing"]);
422 let typo =
423 load_sources("t", &[("t.expect", &with("Feature: F\n Expectation: E\n Gvien the kettle is heating\n"))]);
424 let codes: Vec<_> = typo.diagnostics.iter().map(|d| d.code).collect();
425 assert_eq!(codes, vec!["maybe-step", "no-then"]);
426 }
427}