1mod 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 pub report: Option<&'a Report>,
38 pub suite_dir: Option<&'a Path>,
40 pub siblings: &'a [Sibling],
43 pub outline: Option<&'a outline::Outline>,
46}
47
48pub struct Sibling {
50 pub platform: String,
51 pub pages: std::collections::HashSet<String>,
53}
54
55pub struct Page {
56 pub path: String,
58 pub content: String,
59}
60
61pub const NOT_SUPPORTED: &str = "site/assets/not-supported.svg";
63pub const NO_SCREENSHOT: &str = "site/assets/no-screenshot.svg";
64
65fn 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 pub fn write(&self, dir: &Path) -> std::io::Result<Vec<PathBuf>> {
95 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 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 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
148struct Facts {
150 verdicts: Option<Vec<Verdict>>,
152 coverage: Vec<Vec<(usize, Option<usize>)>>,
154 status: Vec<Status>,
156 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 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 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
207fn 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
216fn 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
222fn 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
239fn 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
288fn 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}