Skip to main content

speccoder_core/
story.rs

1//! What judges a task of a story — decided by code from the story's oracle,
2//! never by a test author's choice. A story's acceptance is written once,
3//! through the product, before its first code task (the oracle); every task
4//! of the story is then judged by the progress it makes on it: the
5//! scenarios it turns green. A task naming scenarios must leave them green;
6//! the story's checkpoint (its last open code task) must leave every one
7//! green. A tick claims exactly what it turned, so no scenario is claimed
8//! twice and none is left unproven when the story closes.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14/// A scenario's state on a tree: green when every oracle test citing it
15/// passes, red when one fails, untested when no kept test cites it.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum ScenarioState {
19    /// Every test citing it passes.
20    Green,
21    /// A test citing it fails.
22    Red,
23    /// No kept test cites it.
24    Untested,
25}
26
27/// What an attempt did to its story, by the oracle's states before and
28/// after it.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum Progress {
32    /// It turned these scenarios green (in the story's order).
33    Verified {
34        /// Red before, green after.
35        turned: Vec<String>,
36    },
37    /// It turned none and broke none; the story goes on.
38    InProgress,
39    /// The checkpoint, every scenario green before it already.
40    Closed,
41    /// Scenarios green before it are red after it.
42    Regressed {
43        /// Green before, not green after.
44        scenarios: Vec<String>,
45    },
46    /// Scenarios the task names are not green after it.
47    LinksOpen {
48        /// Its links still not green.
49        scenarios: Vec<String>,
50    },
51    /// The checkpoint left scenarios not green.
52    StoryOpen {
53        /// Not green after it.
54        scenarios: Vec<String>,
55    },
56}
57
58impl Progress {
59    /// Whether the attempt may tick.
60    #[must_use]
61    pub const fn passes(&self) -> bool {
62        matches!(
63            self,
64            Self::Verified { .. } | Self::InProgress | Self::Closed
65        )
66    }
67}
68
69/// What an attempt did, from the oracle's states `before` and `after` it
70/// (keyed by scenario id, iterated in the story's order by the caller's
71/// `order`), the task's `links`, and whether it is the story's checkpoint.
72/// A regression fails first; then the checkpoint's whole story — except
73/// the scenarios its author declared `untestable`, shown, never blocking —
74/// then the task's links.
75#[must_use]
76pub fn progress(
77    order: &[String],
78    before: &BTreeMap<String, ScenarioState>,
79    after: &BTreeMap<String, ScenarioState>,
80    links: &[String],
81    checkpoint: bool,
82    untestable: &[String],
83) -> Progress {
84    let state = |m: &BTreeMap<String, ScenarioState>, id: &str| {
85        m.get(id).copied().unwrap_or(ScenarioState::Untested)
86    };
87    let green =
88        |m: &BTreeMap<String, ScenarioState>, id: &str| state(m, id) == ScenarioState::Green;
89    let pick = |f: &dyn Fn(&str) -> bool| -> Vec<String> {
90        order.iter().filter(|id| f(id)).cloned().collect()
91    };
92    let regressed = pick(&|id| green(before, id) && !green(after, id));
93    if !regressed.is_empty() {
94        return Progress::Regressed {
95            scenarios: regressed,
96        };
97    }
98    if checkpoint {
99        let open = pick(&|id| !green(after, id) && !untestable.iter().any(|u| u == id));
100        if !open.is_empty() {
101            return Progress::StoryOpen { scenarios: open };
102        }
103    }
104    let open = pick(&|id| links.iter().any(|l| l == id) && !green(after, id));
105    if !open.is_empty() {
106        return Progress::LinksOpen { scenarios: open };
107    }
108    let turned = pick(&|id| !green(before, id) && green(after, id));
109    match (turned.is_empty(), checkpoint) {
110        (false, _) => Progress::Verified { turned },
111        (true, true) => Progress::Closed,
112        (true, false) => Progress::InProgress,
113    }
114}
115
116/// What judges one open task of a story.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum Judged {
120    /// The progress it makes on the story's oracle.
121    Progress,
122    /// The scenarios it names, which it must leave green.
123    Linked {
124        /// Their ids.
125        scenarios: Vec<String>,
126    },
127    /// The story's checkpoint: every scenario must be green after it.
128    Checkpoint,
129}
130
131impl Judged {
132    /// `progress`, `linked` or `checkpoint`.
133    #[must_use]
134    pub const fn kind(&self) -> &'static str {
135        match self {
136            Self::Progress => "progress",
137            Self::Linked { .. } => "linked",
138            Self::Checkpoint => "checkpoint",
139        }
140    }
141
142    /// The scenarios it must leave green: its links (none otherwise).
143    #[must_use]
144    pub fn links(&self) -> &[String] {
145        match self {
146            Self::Linked { scenarios } => scenarios,
147            _ => &[],
148        }
149    }
150}
151
152/// A story's scenarios as its oracle and ticks show them.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
154pub struct StoryScenarios {
155    /// The story (`US3`; for OpenSpec the change).
156    pub story: String,
157    /// Its scenarios in order.
158    pub scenarios: Vec<ScenarioMark>,
159    /// The task that closes it (none: every open task is planning work, or
160    /// all are done).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub checkpoint: Option<String>,
163    /// The story's oracle, once written.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub oracle: Option<OracleMark>,
166    /// Every task done and every scenario green: the spec shows it, the
167    /// report and the board leave it out.
168    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
169    pub proven: bool,
170    /// Every task of the story done.
171    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
172    pub finished: bool,
173    /// Every task done before the story had an oracle: nothing ran its
174    /// scenarios, and nothing is still to turn them.
175    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
176    pub before_oracle: bool,
177}
178
179/// Where a story's oracle was written, and who froze it.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
181pub struct OracleMark {
182    /// The `Oracle:` commit (short).
183    pub commit: String,
184    /// `speccoder run`, or the person who approved it at review.
185    pub decided_by: String,
186}
187
188/// One scenario of a story.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
190pub struct ScenarioMark {
191    /// Its id (`US3-1`).
192    pub id: String,
193    /// Its state at the branch's tip (red before the oracle exists).
194    pub state: ScenarioState,
195    /// The task whose tick turned it green, worded as it is now.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub turned_by: Option<String>,
198    /// What must hold, as the oracle states it (given → must hold).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub contract: Option<String>,
201}
202
203/// One task of the story, in document order.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct StoryTask<'a> {
206    /// Its id.
207    pub id: &'a str,
208    /// Judged by the checks alone — planning work, or a step only a person
209    /// takes (`[YOU]`): never a checkpoint.
210    pub checks_only: bool,
211    /// Ticked (or skipped, or accepted): it judges nothing any more.
212    pub done: bool,
213    /// The scenarios it names.
214    pub links: &'a [String],
215}
216
217/// The story's checkpoint: its last open task that is judged by the
218/// oracle. Whatever order the tasks run in, the one that closes the story
219/// judges all of it; none once every such task is done.
220#[must_use]
221pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
222    tasks
223        .iter()
224        .rev()
225        .find(|t| !t.checks_only && !t.done)
226        .map(|t| t.id)
227}
228
229/// The story's first open code task: where its oracle is written.
230#[must_use]
231pub fn first<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
232    tasks
233        .iter()
234        .find(|t| !t.checks_only && !t.done)
235        .map(|t| t.id)
236}
237
238/// What judges `task` of the story `tasks`, whose scenarios are
239/// `scenarios`. `None`: the story has no scenario, or the task is judged by
240/// its checks alone. Links naming no scenario of the story are ignored
241/// (the plan reports them); the checkpoint comes first.
242#[must_use]
243pub fn judged(tasks: &[StoryTask<'_>], task: &str, scenarios: &[String]) -> Option<Judged> {
244    let me = tasks.iter().find(|t| t.id == task)?;
245    if scenarios.is_empty() || me.checks_only {
246        return None;
247    }
248    if checkpoint(tasks) == Some(task) {
249        return Some(Judged::Checkpoint);
250    }
251    let links: Vec<String> = me
252        .links
253        .iter()
254        .filter(|l| scenarios.contains(l))
255        .cloned()
256        .collect();
257    Some(if links.is_empty() {
258        Judged::Progress
259    } else {
260        Judged::Linked { scenarios: links }
261    })
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use ScenarioState::{Green, Red, Untested};
268    use proptest::prelude::*;
269
270    fn ids(n: usize) -> Vec<String> {
271        (1..=n).map(|k| format!("US3-{k}")).collect()
272    }
273
274    fn states(s: &[ScenarioState]) -> BTreeMap<String, ScenarioState> {
275        ids(s.len()).into_iter().zip(s.iter().copied()).collect()
276    }
277
278    const NONE: &[String] = &[];
279
280    fn open(id: &str) -> StoryTask<'_> {
281        StoryTask {
282            id,
283            checks_only: false,
284            done: false,
285            links: NONE,
286        }
287    }
288
289    #[test]
290    fn a_task_claims_what_it_turned_and_nothing_else() {
291        let o = ids(3);
292        assert_eq!(
293            progress(
294                &o,
295                &states(&[Red, Red, Red]),
296                &states(&[Green, Red, Green]),
297                &[],
298                false,
299                &[],
300            ),
301            Progress::Verified {
302                turned: vec!["US3-1".into(), "US3-3".into()]
303            }
304        );
305        assert_eq!(
306            progress(
307                &o,
308                &states(&[Green, Red, Red]),
309                &states(&[Green, Red, Red]),
310                &[],
311                false,
312                &[],
313            ),
314            Progress::InProgress
315        );
316        assert_eq!(
317            progress(
318                &o,
319                &states(&[Green, Red, Red]),
320                &states(&[Red, Green, Green]),
321                &[],
322                false,
323                &[],
324            ),
325            Progress::Regressed {
326                scenarios: vec!["US3-1".into()]
327            },
328            "a regression fails whatever else turned"
329        );
330    }
331
332    #[test]
333    fn links_and_the_checkpoint_must_be_green_after() {
334        let o = ids(2);
335        let link = vec!["US3-2".to_owned()];
336        assert_eq!(
337            progress(
338                &o,
339                &states(&[Red, Red]),
340                &states(&[Green, Red]),
341                &link,
342                false,
343                &[],
344            ),
345            Progress::LinksOpen {
346                scenarios: link.clone()
347            }
348        );
349        assert_eq!(
350            progress(
351                &o,
352                &states(&[Red, Red]),
353                &states(&[Green, Untested]),
354                &[],
355                true,
356                &[],
357            ),
358            Progress::StoryOpen {
359                scenarios: vec!["US3-2".into()]
360            },
361            "untested is not green"
362        );
363        assert_eq!(
364            progress(
365                &o,
366                &states(&[Green, Green]),
367                &states(&[Green, Green]),
368                &[],
369                true,
370                &[],
371            ),
372            Progress::Closed
373        );
374    }
375
376    #[test]
377    fn a_scenario_its_author_could_not_test_never_blocks_the_story() {
378        let o = ids(2);
379        let untestable = vec!["US3-2".to_owned()];
380        assert_eq!(
381            progress(
382                &o,
383                &states(&[Red, Untested]),
384                &states(&[Green, Untested]),
385                &[],
386                true,
387                &untestable
388            ),
389            Progress::Verified {
390                turned: vec!["US3-1".into()]
391            }
392        );
393    }
394
395    #[test]
396    fn the_checkpoint_is_the_last_open_code_task_and_the_oracle_goes_first() {
397        let mut docs = open("T022");
398        docs.checks_only = true;
399        let mut done = open("T018");
400        done.done = true;
401        let link = vec!["US3-2".to_owned()];
402        let mut linked = open("T020");
403        linked.links = &link;
404        let tasks = [done, open("T019"), linked, open("T021"), docs];
405        let sc = ids(2);
406        assert_eq!(checkpoint(&tasks), Some("T021"));
407        assert_eq!(first(&tasks), Some("T019"));
408        assert_eq!(judged(&tasks, "T019", &sc), Some(Judged::Progress));
409        assert_eq!(
410            judged(&tasks, "T020", &sc),
411            Some(Judged::Linked {
412                scenarios: link.clone()
413            })
414        );
415        assert_eq!(judged(&tasks, "T021", &sc), Some(Judged::Checkpoint));
416        assert_eq!(judged(&tasks, "T022", &sc), None, "documentation");
417        assert_eq!(judged(&tasks, "T019", &[]), None, "no scenario");
418    }
419
420    fn state() -> impl Strategy<Value = ScenarioState> {
421        prop_oneof![Just(Green), Just(Red), Just(Untested)]
422    }
423
424    proptest! {
425        /// The rule is a function of its inputs; a verified tick names what
426        /// it turned and only that; a checkpoint never passes with a scenario
427        /// not green; a green scenario never goes red on a passing tick.
428        #[test]
429        fn progress_is_honest(
430            before in proptest::collection::vec(state(), 1..6),
431            after_seed in proptest::collection::vec(state(), 6),
432            link_mask in proptest::collection::vec(any::<bool>(), 6),
433            checkpoint in any::<bool>(),
434        ) {
435            let n = before.len();
436            let o = ids(n);
437            let b = states(&before);
438            let a = states(&after_seed[..n]);
439            let links: Vec<String> = o.iter().zip(&link_mask).filter(|(_, m)| **m).map(|(id, _)| id.clone()).collect();
440            let p = progress(&o, &b, &a, &links, checkpoint, &[]);
441            prop_assert_eq!(&p, &progress(&o, &b, &a, &links, checkpoint, &[]));
442            if let Progress::Verified { turned } = &p {
443                prop_assert!(!turned.is_empty());
444                for id in turned {
445                    prop_assert!(b[id] != Green && a[id] == Green);
446                }
447            }
448            if p.passes() {
449                for id in &o {
450                    prop_assert!(!(b[id] == Green && a[id] != Green));
451                }
452                for id in &links {
453                    prop_assert_eq!(a[id], Green);
454                }
455                if checkpoint {
456                    prop_assert!(o.iter().all(|id| a[id] == Green));
457                }
458            }
459        }
460
461        /// Ticks applied in sequence never claim a scenario twice: once
462        /// green it stays green on every later passing tick.
463        #[test]
464        fn a_scenario_is_claimed_once(
465            steps in proptest::collection::vec(proptest::collection::vec(state(), 4), 1..6),
466        ) {
467            let o = ids(4);
468            let mut now = states(&[Red, Red, Red, Red]);
469            let mut claimed: BTreeMap<String, usize> = BTreeMap::new();
470            for (i, s) in steps.iter().enumerate() {
471                let next = states(s);
472                let p = progress(&o, &now, &next, &[], false, &[]);
473                if p.passes() {
474                    if let Progress::Verified { turned } = &p {
475                        for id in turned {
476                            prop_assert!(claimed.insert(id.clone(), i).is_none());
477                        }
478                    }
479                    now = next;
480                }
481            }
482        }
483    }
484}