Skip to main content

expectation_docs/
lib.rs

1//! Documentation from a suite, written as a manual: stories become step-by-step tutorials,
2//! features become topic pages, and sections say what to do before you begin. The test
3//! output (each test's steps, checks and verdicts, and the map of the expectation DAG) goes
4//! into collapsed "How this is tested" blocks and a separate test report. Two outputs from the
5//! same facts:
6//!
7//! ```text
8//! README.md, tutorials/, features/,      Markdown that GitHub renders
9//!   glossary.md, tests.md, tests/
10//! site/                                   the same as an HTML site with nested navigation
11//! screenshots/…                           written by the harness while the tests run
12//! ```
13//!
14//! The wording comes from the specs: `Say:` on an event or phrase tells the reader what to do
15//! ("press Space"), `Set:` on a state tells them how to change it, and a story's text under its
16//! narrative is the tutorial's introduction. `voice` turns steps into instructions with them.
17//! A step without a screenshot (its test isn't supported yet, didn't run, or failed before it
18//! got there) shows a placeholder instead.
19
20mod html;
21mod manual;
22mod map;
23mod markdown;
24pub mod outline;
25mod voice;
26
27use expectation_graph::Analysis;
28use expectation_model::{ExpId, Feature, StepText, Suite, VarKind, Variable};
29use expectation_story::{Because, Plan, Report, Status, Verdict, coverage};
30use std::path::{Path, PathBuf};
31
32pub struct Docs<'a> {
33    pub suite: &'a Suite,
34    pub analysis: &'a Analysis,
35    pub plans: &'a [Plan],
36    /// Results of running `plans`, if they ran.
37    pub report: Option<&'a Report>,
38    /// Where the suite's files are, so each page can link to the one to edit.
39    pub suite_dir: Option<&'a Path>,
40    /// The other platforms' docs (when the suite has several), so each page can offer the same
41    /// page there. Their trees are `platforms/<name>/` (the default platform's is the root).
42    pub siblings: &'a [Sibling],
43    /// The manual's parts and hand-written pages. Set by the docs themselves from the suite's
44    /// `outline.txt`; leave it `None`.
45    pub outline: Option<&'a outline::Outline>,
46}
47
48/// The pages of the same manual for another platform.
49pub struct Sibling {
50    pub platform: String,
51    /// Its HTML pages, relative to its tree: `site/feature-x.html`.
52    pub pages: std::collections::HashSet<String>,
53}
54
55pub struct Page {
56    /// Relative to the docs directory.
57    pub path: String,
58    pub content: String,
59}
60
61/// Where the placeholders live, relative to the docs directory.
62pub const NOT_SUPPORTED: &str = "site/assets/not-supported.svg";
63pub const NO_SCREENSHOT: &str = "site/assets/no-screenshot.svg";
64
65/// Removes the pages an earlier run wrote that this one did not: a renamed feature or story
66/// would otherwise leave its old page in the manual's folders.
67fn prune(dir: &Path, written: &[PathBuf]) -> std::io::Result<()> {
68    let kinds: [(&str, &str, &[&str]); 4] = [
69        ("features", "md", &[""]),
70        ("tutorials", "md", &[""]),
71        ("tests", "md", &[""]),
72        ("site", "html", &["feature-", "section-", "test-", "tutorial-"]),
73    ];
74    for (sub, ext, prefixes) in kinds {
75        let Ok(entries) = std::fs::read_dir(dir.join(sub)) else { continue };
76        for entry in entries.flatten() {
77            let path = entry.path();
78            let name = entry.file_name().to_string_lossy().into_owned();
79            let ours = path.is_file()
80                && path.extension().is_some_and(|e| e == ext)
81                && prefixes.iter().any(|p| name.starts_with(p));
82            if ours && !written.contains(&path) {
83                std::fs::remove_file(&path)?;
84            }
85        }
86    }
87    Ok(())
88}
89
90impl Docs<'_> {
91    /// Write the Markdown pages and the HTML site under `dir`. Pages of stories or features
92    /// that no longer exist are removed (only the generated kinds, in the folders this writes
93    /// to); other files in the directory are left alone.
94    pub fn write(&self, dir: &Path) -> std::io::Result<Vec<PathBuf>> {
95        // A screenshot the results name but the directory doesn't have (an interrupted run
96        // cleared it) is a placeholder, not a broken image.
97        let checked = self.report.map(|r| {
98            let mut r = r.clone();
99            for result in &mut r.results {
100                for step in std::iter::once(&mut result.start).chain(&mut result.steps) {
101                    if step.screenshot.as_ref().is_some_and(|p| !dir.join(p).exists()) {
102                        step.screenshot = None;
103                    }
104                }
105            }
106            r
107        });
108        let docs = Docs { report: checked.as_ref(), ..*self };
109        std::fs::create_dir_all(dir)?;
110        let source = self.suite_dir.and_then(|s| relative(dir, s));
111        let mut written = vec![];
112        for page in docs.pages_with(source) {
113            let path = dir.join(&page.path);
114            if let Some(parent) = path.parent() {
115                std::fs::create_dir_all(parent)?;
116            }
117            std::fs::write(&path, page.content)?;
118            written.push(path);
119        }
120        prune(dir, &written)?;
121        Ok(written)
122    }
123
124    pub fn pages(&self) -> Vec<Page> {
125        self.pages_with(None)
126    }
127
128    /// This manual's HTML pages, for the other platforms' dropdowns.
129    pub fn sibling(&self) -> Sibling {
130        Sibling {
131            platform: self.suite.platform.clone().unwrap_or_default(),
132            pages: self.pages().into_iter().map(|p| p.path).filter(|p| p.ends_with(".html")).collect(),
133        }
134    }
135
136    /// The pages, with `source` the suite's directory relative to the docs directory.
137    fn pages_with(&self, source: Option<String>) -> Vec<Page> {
138        let outline = outline::of(self);
139        let docs = Docs { outline: outline.as_ref(), ..*self };
140        let mut facts = Facts::new(&docs);
141        facts.source = source;
142        let mut pages = markdown::pages(&docs, &facts);
143        pages.extend(html::pages(&docs, &facts));
144        pages
145    }
146}
147
148/// What the pages say about each expectation and test, worked out once.
149struct Facts {
150    /// Per expectation, if anything ran.
151    verdicts: Option<Vec<Verdict>>,
152    /// Per expectation, the (plan, step) pairs that exercise it; step `None` is the start.
153    coverage: Vec<Vec<(usize, Option<usize>)>>,
154    /// Per plan, its status (`NotRun` without a report).
155    status: Vec<Status>,
156    /// The suite's directory relative to the docs directory, for "Edit this page" links.
157    source: Option<String>,
158}
159
160impl Facts {
161    fn new(d: &Docs) -> Self {
162        let status = (0..d.plans.len())
163            .map(|i| d.report.and_then(|r| r.results.get(i)).map_or(Status::NotRun, |r| r.status))
164            .collect();
165        Facts {
166            verdicts: d.report.map(|r| r.verdicts(d.suite, d.plans)),
167            coverage: coverage(d.suite, d.plans),
168            status,
169            source: None,
170        }
171    }
172
173    fn verdict(&self, e: ExpId) -> Option<Verdict> {
174        self.verdicts.as_ref().map(|v| expectation_story::report::verdict_of(v, e))
175    }
176
177    /// The tests that exercise `e`, each once.
178    fn tested_by(&self, e: ExpId) -> Vec<usize> {
179        let mut by: Vec<usize> = self.coverage[e.0 as usize].iter().map(|(p, _)| *p).collect();
180        by.dedup();
181        by
182    }
183
184    /// The best picture of `e` in action: the screenshot after a step whose checks for it
185    /// passed, else after any step that exercises it. Returns (plan, step) with the path.
186    fn shot_of(&self, d: &Docs, e: ExpId) -> Option<(usize, Option<usize>, String)> {
187        let report = d.report?;
188        let mut fallback = None;
189        for &(p, step) in &self.coverage[e.0 as usize] {
190            let result = &report.results[p];
191            let (r, checks) = match step {
192                None => (&result.start, &d.plans[p].start_checks),
193                Some(s) => (&result.steps[s], &d.plans[p].steps[s].checks),
194            };
195            let Some(shot) = &r.screenshot else { continue };
196            let verified =
197                checks.iter().zip(&r.checks).any(|(c, cr)| c.because == Because::Expectation(e) && cr.passed);
198            if verified {
199                return Some((p, step, shot.clone()));
200            }
201            fallback.get_or_insert((p, step, shot.clone()));
202        }
203        fallback
204    }
205}
206
207/// `to` relative to `from`, both directories: "../expectations".
208fn relative(from: &Path, to: &Path) -> Option<String> {
209    let (from, to) = (from.canonicalize().ok()?, to.canonicalize().ok()?);
210    let common = from.components().zip(to.components()).take_while(|(a, b)| a == b).count();
211    let mut parts: Vec<String> = vec!["..".into(); from.components().count() - common];
212    parts.extend(to.components().skip(common).map(|c| c.as_os_str().to_string_lossy().into_owned()));
213    Some(if parts.is_empty() { ".".into() } else { parts.join("/") })
214}
215
216/// A feature's slug, unique in the suite: under its section's.
217fn feature_slug(suite: &Suite, f: &Feature) -> String {
218    let section = &suite.section(f.section).slug;
219    if section.is_empty() { f.slug.clone() } else { format!("{section}--{}", f.slug) }
220}
221
222/// "As a tea drinker / I want … / So that …" as one sentence.
223fn narrative(lines: &[String]) -> String {
224    let parts: Vec<String> = lines
225        .iter()
226        .enumerate()
227        .map(|(i, l)| {
228            let l = l.trim_end_matches(['.', ',']);
229            let mut c = l.chars();
230            match c.next() {
231                Some(f) if i > 0 && !l.starts_with("I ") => f.to_lowercase().chain(c).collect(),
232                _ => l.to_string(),
233            }
234        })
235        .collect();
236    format!("{}.", parts.join(", "))
237}
238
239/// "When the dial is 0 % and the user presses reset": the action, without its Thens.
240fn action_heading(steps: &[StepText]) -> String {
241    use expectation_model::Keyword;
242    let mut out = String::new();
243    for t in steps.iter().take_while(|t| t.keyword != Keyword::Then) {
244        match t.keyword {
245            Keyword::And => out.push_str(" and "),
246            Keyword::But => out.push_str(" but "),
247            k => out.push_str(&format!("{} ", k.as_str())),
248        }
249        out.push_str(&t.text);
250    }
251    out
252}
253
254fn values(v: &Variable) -> String {
255    match &v.kind {
256        VarKind::Enum(names) => names.join(" · "),
257        VarKind::Number { min, max, unit } => {
258            let u = unit.as_deref().map(|u| format!(" {u}")).unwrap_or_default();
259            format!(
260                "a number from {} to {}{u}",
261                expectation_model::text::number(*min),
262                expectation_model::text::number(*max)
263            )
264        }
265    }
266}
267
268fn verdict_label(v: Option<Verdict>) -> &'static str {
269    match v {
270        Some(Verdict::Verified) => "verified",
271        Some(Verdict::Failed) => "failed",
272        Some(Verdict::Untested) => "untested",
273        Some(Verdict::Unsupported) => "not supported yet",
274        Some(Verdict::NotRun) | None => "not run",
275    }
276}
277
278fn status_label(s: Status) -> &'static str {
279    match s {
280        Status::Passed => "passed",
281        Status::Failed => "failed",
282        Status::Skipped => "skipped",
283        Status::Unsupported => "not supported yet",
284        Status::NotRun => "not run",
285    }
286}
287
288/// Why a step has no screenshot, for the placeholder's caption.
289fn missing_shot(status: Status) -> &'static str {
290    match status {
291        Status::Unsupported => "Screenshot to come: the tests can't run this step yet.",
292        Status::Skipped => "No screenshot: the test stopped at an earlier step.",
293        Status::NotRun => "No screenshot yet: the tests haven't run.",
294        Status::Failed => "No screenshot: the test failed before this step.",
295        Status::Passed => "No screenshot of this step.",
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use expectation_model::{Keyword, Span};
303
304    #[test]
305    fn narratives_read_as_one_sentence() {
306        let lines = ["As a tea drinker", "I want to boil the kettle.", "So that I can make tea"].map(String::from);
307        assert_eq!(narrative(&lines), "As a tea drinker, I want to boil the kettle, so that I can make tea.");
308    }
309
310    #[test]
311    fn headings_show_the_whole_action() {
312        let step =
313            |keyword, text: &str| StepText { keyword, text: text.into(), span: Span::new("t", 1), meaning: None };
314        let steps = [
315            step(Keyword::When, "the dial is 0 %"),
316            step(Keyword::And, "the user presses reset"),
317            step(Keyword::Then, "the kettle is off"),
318        ];
319        assert_eq!(action_heading(&steps), "When the dial is 0 % and the user presses reset");
320    }
321}