Skip to main content

expectation_story/
plan.rs

1//! User stories to acceptance-test plans.
2//!
3//! A story says what the user does ("When the dial is 80 %") and, optionally, what they
4//! see ("Then the kettle is heating"). Planning walks the story through the expectations:
5//! each step works out which expectations fire, and their Thens become the checks to run.
6//! So a story only has to tell the journey; the expectations supply what must be true at each
7//! step. Planning also refuses stories that rely on behaviour nobody has specified, or that
8//! contradict the expectations.
9
10use expectation_graph::semantics::perform_among;
11use expectation_graph::{Analysis, Precedence};
12use expectation_model::text;
13use expectation_model::{
14    Condition, Constraint, Diagnostic, ExpId, Fact, Keyword, SectionId, Span, StepText, StoryId, Suite, Trigger, VarId,
15};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Origin {
19    /// A scenario someone wrote.
20    Story { story: StoryId, scenario: usize },
21    /// Generated to exercise an expectation no story reaches.
22    Coverage { target: ExpId },
23}
24
25/// Why a check is there.
26#[derive(Clone, Debug, PartialEq)]
27pub enum Because {
28    /// The plan arranged it before the first step.
29    Arranged,
30    /// An expectation's Then (or an invariant) promises it.
31    Expectation(ExpId),
32    /// The story says so in a Then.
33    Story(Span),
34}
35
36#[derive(Clone, Debug)]
37pub struct Check {
38    pub var: VarId,
39    pub expect: Constraint,
40    pub because: Because,
41}
42
43#[derive(Clone, Debug)]
44pub struct PlanStep {
45    pub action: Trigger,
46    pub text: Vec<StepText>,
47    /// The written step it belongs to: docs show a group as one step.
48    pub group: usize,
49    /// The group's lines as written, on its first step.
50    pub written: Vec<StepText>,
51    /// Transitions that fire.
52    pub fired: Vec<ExpId>,
53    /// Invariants in force afterwards.
54    pub invariants: Vec<ExpId>,
55    pub checks: Vec<Check>,
56    /// The predicted state afterwards.
57    pub state: Condition,
58}
59
60impl PlanStep {
61    pub fn exercises(&self) -> impl Iterator<Item = ExpId> + '_ {
62        self.fired.iter().chain(&self.invariants).copied()
63    }
64}
65
66/// One acceptance test: arrange `start`, check it, then perform each step and check it.
67#[derive(Clone, Debug)]
68pub struct Plan {
69    pub origin: Origin,
70    /// The section it belongs to, whose setup it starts from.
71    pub section: SectionId,
72    pub title: String,
73    pub slug: String,
74    pub start: Condition,
75    pub given: Vec<StepText>,
76    pub start_checks: Vec<Check>,
77    /// Invariants in force at the start.
78    pub start_invariants: Vec<ExpId>,
79    pub steps: Vec<PlanStep>,
80}
81
82pub struct Planned {
83    pub plans: Vec<Plan>,
84    pub diagnostics: Vec<Diagnostic>,
85}
86
87/// One input step: what to do, what the story checks afterwards, and how it was written.
88struct Input<'a> {
89    action: Trigger,
90    checks: &'a [Fact],
91    text: Vec<StepText>,
92    span: Span,
93    group: usize,
94    written: Vec<StepText>,
95}
96
97/// A plan for every scenario of every story.
98pub fn plan_stories(suite: &Suite, analysis: &Analysis) -> Planned {
99    let mut plans = vec![];
100    let mut diagnostics = vec![];
101    for story in &suite.stories {
102        for (i, sc) in story.scenarios.iter().enumerate() {
103            let title = if story.scenarios.len() == 1 && sc.name == story.name {
104                story.name.clone()
105            } else {
106                format!("{} › {}", story.name, sc.name)
107            };
108            let slug = if title == story.name { story.slug.clone() } else { format!("{}--{}", story.slug, sc.slug) };
109            let section = suite.section(story.section);
110            let slug = prefixed(&section.slug, &slug);
111            let inputs = sc.steps.iter().map(|s| Input {
112                action: s.action.clone(),
113                checks: &s.checks,
114                text: s.steps.clone(),
115                span: s.span.clone(),
116                group: s.group,
117                written: s.written.clone(),
118            });
119            let start = section.start.overlay(&sc.given);
120            let mut given: Vec<StepText> =
121                suite.chain(section.id).iter().flat_map(|s| s.setup_steps.iter().cloned()).collect();
122            given.extend(sc.given_steps.iter().cloned());
123            let origin = Origin::Story { story: story.id, scenario: i };
124            let head = Head { origin, section: section.id, title, slug, start, given };
125            plans.push(build(suite, &analysis.precedence, head, inputs, &mut diagnostics));
126        }
127    }
128    Planned { plans, diagnostics }
129}
130
131/// `slug` under a section: "app--drawing--first-sketch".
132fn prefixed(section: &str, slug: &str) -> String {
133    if section.is_empty() { slug.to_string() } else { format!("{section}--{slug}") }
134}
135
136/// What a plan is, before its steps are worked out.
137struct Head {
138    origin: Origin,
139    section: SectionId,
140    title: String,
141    slug: String,
142    start: Condition,
143    given: Vec<StepText>,
144}
145
146fn build<'a>(
147    suite: &Suite,
148    prec: &Precedence,
149    head: Head,
150    inputs: impl Iterator<Item = Input<'a>>,
151    diags: &mut Vec<Diagnostic>,
152) -> Plan {
153    let Head { origin, section, title, slug, start, given } = head;
154    let v = &suite.vocabulary;
155    let (mut state, start_invariants) = expectation_graph::semantics::settle(suite, prec, &start, &[]);
156    let mut start_checks: Vec<Check> =
157        start.iter().map(|(var, c)| Check { var, expect: *c, because: Because::Arranged }).collect();
158    for &inv in &start_invariants {
159        promise(&mut start_checks, suite, inv, &start);
160    }
161
162    // A top-level section is a system of its own: only its expectations apply to its stories.
163    let scope = expectation_graph::flow::in_scope(suite, section);
164    let mut steps = vec![];
165    for input in inputs {
166        let errors = diags.iter().filter(|d| d.is_error()).count();
167        let candidates = suite.transitions().filter(|e| scope[e.id.0 as usize]);
168        let out = perform_among(suite, prec, &state, &input.action, candidates);
169        if let Some(&(a, b, var)) = out.clashes.first() {
170            let (a, b) = (suite.exp(a), suite.exp(b));
171            diags.push(
172                Diagnostic::error(
173                    "clash",
174                    format!("«{}» and «{}» both apply here and disagree about {}", a.name, b.name, v.var(var).subject),
175                )
176                .at(&input.span, "")
177                .help("settle the conflict between them first"),
178            );
179            break;
180        }
181        for &u in &out.uncertain {
182            let e = suite.exp(u);
183            let mut d = Diagnostic::error("unclear-step", format!("it isn't clear whether «{}» applies here", e.name));
184            let given = expectation_graph::semantics::seen_by(&state, &input.action, &e.trigger).unproven(&e.given);
185            if !given.is_unconstrained() {
186                d = d.at(&input.span, format!("nothing establishes that {}", v.describe(&given)));
187            }
188            let trig = input.action.changes.unproven(&e.trigger.changes);
189            if !trig.is_unconstrained() {
190                d = d.at(&input.span, format!("the step doesn't say whether {}", v.describe(&trig)));
191            }
192            diags.push(
193                d.at(&e.span, "").help("pin it down: arrange it with a `Given`, or give the When an exact value"),
194            );
195        }
196        if let (true, true, Some(e)) = (out.fired.is_empty(), out.uncertain.is_empty(), input.action.event) {
197            let event = v.event(e);
198            let line = input.text.iter().find(|t| text::key(&t.text) == event.key).map_or(&input.span, |t| &t.span);
199            diags.push(
200                Diagnostic::error("unspecified", format!("nothing is expected to happen when {}", event.text))
201                    .at(line, format!("at this point {}", v.describe(&state)))
202                    .help("no expectation covers this; write one, or the story relies on unspecified behaviour"),
203            );
204        }
205
206        let mut checks = vec![];
207        let before = state.overlay(&input.action.changes);
208        for &f in out.fired.iter().chain(&out.invariants) {
209            promise(&mut checks, suite, f, &before);
210        }
211        let mut after = out.after;
212        for fact in input.checks {
213            let mut want = Condition::new();
214            want.constrain(v.var(fact.var), fact.constraint);
215            let stated = v.var(fact.var).describe(&fact.constraint);
216            if !after.overlaps(&want) {
217                let said = v.describe(&after.restrict([fact.var]));
218                let mut d = Diagnostic::error(
219                    "contradicts",
220                    format!("the story expects {stated}, but the expectations say {said}"),
221                )
222                .at(&fact.span, "");
223                if let Some(&by) = out.fired.iter().find(|&&f| suite.exp(f).then.get(fact.var).is_some()) {
224                    d = d.at(&suite.exp(by).span, format!("«{}» says so", suite.exp(by).name));
225                }
226                diags.push(d);
227                continue;
228            }
229            if !after.implies(&want) {
230                diags.push(
231                    Diagnostic::warning("unpromised", format!("no expectation promises that {stated}"))
232                        .at(&fact.span, "")
233                        .help("it's checked anyway; write an expectation so it's specified"),
234                );
235            }
236            after = after.and(&want);
237            if !checks.iter().any(|c: &Check| c.var == fact.var && c.expect == fact.constraint) {
238                checks.push(Check {
239                    var: fact.var,
240                    expect: fact.constraint,
241                    because: Because::Story(fact.span.clone()),
242                });
243            }
244        }
245        steps.push(PlanStep {
246            action: input.action,
247            text: input.text,
248            group: input.group,
249            written: input.written,
250            fired: out.fired,
251            invariants: out.invariants,
252            checks,
253            state: after.clone(),
254        });
255        state = after;
256        // Stop at a scenario's first error: later steps would be judged from a state that's wrong.
257        if diags.iter().filter(|d| d.is_error()).count() > errors {
258            break;
259        }
260    }
261    Plan { origin, section, title, slug, start, given, start_checks, start_invariants, steps }
262}
263
264/// Add an expectation's Then to `checks`, skipping what's already checked.
265fn promise(checks: &mut Vec<Check>, suite: &Suite, e: ExpId, before: &Condition) {
266    for (var, c) in suite.exp(e).effect(&suite.vocabulary, before).iter() {
267        if !checks.iter().any(|k| k.var == var && k.expect == *c) {
268            checks.push(Check { var, expect: *c, because: Because::Expectation(e) });
269        }
270    }
271}
272
273/// For each expectation, the (plan, step) pairs that exercise it; step `None` is the start.
274pub fn coverage(suite: &Suite, plans: &[Plan]) -> Vec<Vec<(usize, Option<usize>)>> {
275    let mut out = vec![vec![]; suite.expectations.len()];
276    for (p, plan) in plans.iter().enumerate() {
277        for &inv in &plan.start_invariants {
278            out[inv.0 as usize].push((p, None));
279        }
280        for (s, step) in plan.steps.iter().enumerate() {
281            for e in step.exercises() {
282                out[e.0 as usize].push((p, Some(s)));
283            }
284        }
285    }
286    out
287}
288
289/// Warnings for expectations no plan exercises, each with a route that would.
290pub fn untested(suite: &Suite, analysis: &Analysis, plans: &[Plan]) -> Vec<Diagnostic> {
291    let cov = coverage(suite, plans);
292    let mut diags = vec![];
293    for e in &suite.expectations {
294        if !cov[e.id.0 as usize].is_empty() {
295            continue;
296        }
297        let Some(route) = &analysis.flow.routes[e.id.0 as usize] else { continue };
298        let mut lines: Vec<String> = route
299            .start
300            .iter()
301            .filter(|(v, c)| suite.background.get(*v) != Some(c))
302            .map(|(v, c)| format!("Given {}", suite.vocabulary.var(v).describe(c)))
303            .collect();
304        lines.extend(route.steps.iter().map(|s| format!("When {}", suite.describe_trigger(&suite.exp(*s).trigger))));
305        diags.push(Diagnostic::warning("untested", format!("no story exercises «{}»", e.name)).at(&e.span, "").help(
306            if lines.is_empty() {
307                "it holds from the start; any story checks it".into()
308            } else {
309                format!("a story that would: {}", lines.join(" / "))
310            },
311        ));
312    }
313    diags
314}
315
316/// Generated plans for expectations no plan in `plans` exercises, following each one's
317/// shortest route from the flow graph. Routes that can't be made into a clean plan are skipped.
318pub fn coverage_plans(suite: &Suite, analysis: &Analysis, plans: &[Plan]) -> Vec<Plan> {
319    let cov = coverage(suite, plans);
320    let mut covered: Vec<bool> = cov.iter().map(|c| !c.is_empty()).collect();
321    let mut out = vec![];
322    // Routes from each section's own start, so a generated test starts where the section does.
323    let mut flows: Vec<Option<expectation_graph::Flow>> = suite.sections.iter().map(|_| None).collect();
324    for e in &suite.expectations {
325        if covered[e.id.0 as usize] {
326            continue;
327        }
328        let section = suite.section_of(e.id);
329        let flow = flows[section.id.0 as usize].get_or_insert_with(|| {
330            let roots = [(section.start.clone(), section.id)];
331            expectation_graph::flow::explore(suite, &analysis.precedence, &roots, expectation_graph::Limits::default())
332        });
333        let Some(route) = flow.routes[e.id.0 as usize].as_ref().or(analysis.flow.routes[e.id.0 as usize].as_ref())
334        else {
335            continue;
336        };
337        if route.steps.is_empty() {
338            continue;
339        }
340        let inputs = route.steps.iter().enumerate().map(|(n, &s)| {
341            let x = suite.exp(s);
342            let text: Vec<StepText> = x
343                .when_steps()
344                .cloned()
345                .enumerate()
346                .map(|(i, mut t)| {
347                    t.keyword = if i == 0 { Keyword::When } else { Keyword::And };
348                    t
349                })
350                .collect();
351            Input {
352                action: x.trigger.clone(),
353                checks: &[],
354                written: text.clone(),
355                text,
356                span: x.span.clone(),
357                group: n + 1,
358            }
359        });
360        let mut given: Vec<StepText> =
361            suite.chain(section.id).iter().flat_map(|s| s.setup_steps.iter().cloned()).collect();
362        for (var, c) in route.start.iter().filter(|(v, c)| section.start.get(*v) != Some(c)) {
363            let span = e.span.clone();
364            let (text, meaning) = (suite.vocabulary.var(var).describe(c), Some(expectation_model::Said::Fact(var, *c)));
365            given.push(StepText { keyword: Keyword::Given, text, span, meaning });
366        }
367        let mut diags = vec![];
368        let title = format!("Reaching «{}»", e.name);
369        let head = Head {
370            origin: Origin::Coverage { target: e.id },
371            section: section.id,
372            title,
373            slug: prefixed(&section.slug, &format!("coverage--{}", text::slug(&e.name))),
374            start: route.start.clone(),
375            given,
376        };
377        let plan = build(suite, &analysis.precedence, head, inputs, &mut diags);
378        if diags.iter().any(|d| d.is_error()) {
379            continue;
380        }
381        for step in &plan.steps {
382            for x in step.exercises() {
383                covered[x.0 as usize] = true;
384            }
385        }
386        out.push(plan);
387    }
388    out
389}