Skip to main content

speccoder_core/
story.rs

1//! What judges a task of a story — decided by code, never by a test
2//! author's choice. A task is judged by the scenarios it names; the story's
3//! checkpoint (its last task that is not planning work) by every scenario
4//! no earlier tick verified, together; a task a person deferred by its
5//! regression checks, its scenarios going to the checkpoint; any other by
6//! the story's scenarios, the author choosing. However the earlier tasks
7//! went, every scenario of a finished story was judged.
8
9use std::collections::BTreeSet;
10
11use serde::{Deserialize, Serialize};
12
13/// What judges one task.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(tag = "kind", rename_all = "snake_case")]
16pub enum Share {
17    /// The scenarios the task names.
18    Linked {
19        /// Their ids.
20        scenarios: Vec<String>,
21    },
22    /// The story's checkpoint: every scenario no earlier tick verified,
23    /// with its links.
24    Checkpoint {
25        /// Their ids, in the story's order.
26        scenarios: Vec<String>,
27        /// Also one test of these scenarios together (two or more left).
28        composition: bool,
29    },
30    /// The story's scenarios; the test author chooses.
31    Authored {
32        /// Their ids.
33        scenarios: Vec<String>,
34        /// The story's checkpoint, where what this task leaves is judged.
35        #[serde(default, skip_serializing_if = "Option::is_none")]
36        checkpoint: Option<String>,
37    },
38    /// A person moved its acceptance to the checkpoint.
39    Deferred {
40        /// The task that judges its scenarios.
41        checkpoint: String,
42    },
43}
44
45impl Share {
46    /// The scenarios a test author is given (none when deferred).
47    #[must_use]
48    pub fn scenarios(&self) -> &[String] {
49        match self {
50            Self::Linked { scenarios }
51            | Self::Checkpoint { scenarios, .. }
52            | Self::Authored { scenarios, .. } => scenarios,
53            Self::Deferred { .. } => &[],
54        }
55    }
56
57    /// `linked`, `checkpoint`, `authored` or `deferred`.
58    #[must_use]
59    pub const fn kind(&self) -> &'static str {
60        match self {
61            Self::Linked { .. } => "linked",
62            Self::Checkpoint { .. } => "checkpoint",
63            Self::Authored { .. } => "authored",
64            Self::Deferred { .. } => "deferred",
65        }
66    }
67}
68
69/// A story's scenarios, each verified by a tick or left for its checkpoint.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
71pub struct StoryScenarios {
72    /// The story (`US3`; for OpenSpec the change).
73    pub story: String,
74    /// Its scenarios in order.
75    pub scenarios: Vec<ScenarioMark>,
76    /// The task that judges what is left (none: every task is planning work).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub checkpoint: Option<String>,
79}
80
81/// One scenario of a story.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
83pub struct ScenarioMark {
84    /// Its id (`US3-1`).
85    pub id: String,
86    /// The task whose tick verified it, worded as it is now.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub verified_by: Option<String>,
89}
90
91/// One task of the story, in document order.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct StoryTask<'a> {
94    /// Its id.
95    pub id: &'a str,
96    /// Judged by the checks alone — planning work, or a step only a person
97    /// takes (`[YOU]`): never a checkpoint.
98    pub checks_only: bool,
99    /// Ticked (or skipped, or accepted): it judges nothing any more.
100    pub done: bool,
101    /// The scenarios it names.
102    pub links: &'a [String],
103}
104
105/// The story's checkpoint: its last open task that is judged by acceptance
106/// tests. Whatever order the tasks run in, the one that closes the story
107/// judges what is left; none once every such task is done.
108#[must_use]
109pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
110    tasks
111        .iter()
112        .rev()
113        .find(|t| !t.checks_only && !t.done)
114        .map(|t| t.id)
115}
116
117/// What judges `task` of the story `tasks`, whose scenarios are `scenarios`
118/// (in the story's order), of which earlier ticks verified `verified`, and
119/// which a person deferred to the checkpoint when `deferred`. `None`: the
120/// story has no scenario, or the task is judged by its checks alone.
121///
122/// Links naming no scenario of the story are ignored (the plan reports
123/// them). The checkpoint comes first: a link never narrows what it judges.
124#[must_use]
125pub fn share(
126    tasks: &[StoryTask<'_>],
127    task: &str,
128    scenarios: &[String],
129    verified: &BTreeSet<String>,
130    deferred: bool,
131) -> Option<Share> {
132    let me = tasks.iter().find(|t| t.id == task)?;
133    if scenarios.is_empty() || me.checks_only {
134        return None;
135    }
136    let links: Vec<String> = me
137        .links
138        .iter()
139        .filter(|l| scenarios.contains(l))
140        .cloned()
141        .collect();
142    let at = checkpoint(tasks);
143    if at == Some(task) {
144        let left: Vec<String> = scenarios
145            .iter()
146            .filter(|s| !verified.contains(*s) || links.contains(s))
147            .cloned()
148            .collect();
149        let composition = left.len() >= 2;
150        return Some(Share::Checkpoint {
151            scenarios: left,
152            composition,
153        });
154    }
155    if !links.is_empty() {
156        return Some(Share::Linked { scenarios: links });
157    }
158    if deferred && let Some(at) = at {
159        return Some(Share::Deferred {
160            checkpoint: at.to_owned(),
161        });
162    }
163    Some(Share::Authored {
164        scenarios: scenarios.to_vec(),
165        checkpoint: at.map(str::to_owned),
166    })
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use proptest::prelude::*;
173
174    fn ids(n: usize) -> Vec<String> {
175        (1..=n).map(|k| format!("US3-{k}")).collect()
176    }
177
178    const NONE: &[String] = &[];
179
180    fn open(id: &str) -> StoryTask<'_> {
181        StoryTask {
182            id,
183            checks_only: false,
184            done: false,
185            links: NONE,
186        }
187    }
188
189    #[test]
190    fn the_real_case_t018_to_t022() {
191        let mut docs = open("T022");
192        docs.checks_only = true;
193        let tasks = [open("T018"), open("T019"), open("T020"), open("T021"), docs];
194        let sc = ids(2);
195        let none = BTreeSet::new();
196        assert_eq!(checkpoint(&tasks), Some("T021"));
197        assert_eq!(
198            share(&tasks, "T018", &sc, &none, true),
199            Some(Share::Deferred {
200                checkpoint: "T021".into()
201            })
202        );
203        assert_eq!(
204            share(&tasks, "T018", &sc, &none, false),
205            Some(Share::Authored {
206                scenarios: sc.clone(),
207                checkpoint: Some("T021".into())
208            })
209        );
210        assert_eq!(
211            share(&tasks, "T021", &sc, &none, false),
212            Some(Share::Checkpoint {
213                scenarios: sc.clone(),
214                composition: true
215            })
216        );
217        assert_eq!(
218            share(&tasks, "T022", &sc, &none, false),
219            None,
220            "documentation has no share"
221        );
222    }
223
224    #[test]
225    fn a_link_beats_a_deferral_and_never_narrows_the_checkpoint() {
226        let one = vec!["US3-1".to_owned()];
227        let unknown = vec!["US9-4".to_owned()];
228        let tasks = [
229            StoryTask {
230                id: "T1",
231                checks_only: false,
232                done: false,
233                links: &one,
234            },
235            StoryTask {
236                id: "T2",
237                checks_only: false,
238                done: false,
239                links: &unknown,
240            },
241            StoryTask {
242                id: "T3",
243                checks_only: false,
244                done: false,
245                links: &one,
246            },
247        ];
248        let sc = ids(3);
249        let verified: BTreeSet<String> = ["US3-1".to_owned()].into();
250        assert_eq!(
251            share(&tasks, "T1", &sc, &verified, true),
252            Some(Share::Linked {
253                scenarios: one.clone()
254            })
255        );
256        assert_eq!(
257            share(&tasks, "T2", &sc, &verified, false),
258            Some(Share::Authored {
259                scenarios: sc.clone(),
260                checkpoint: Some("T3".into())
261            })
262        );
263        assert_eq!(
264            share(&tasks, "T3", &sc, &verified, false),
265            Some(Share::Checkpoint {
266                scenarios: ids(3),
267                composition: true
268            })
269        );
270    }
271
272    #[test]
273    fn a_person_s_step_is_never_the_checkpoint_and_a_done_one_hands_it_on() {
274        let mut you = open("T3");
275        you.checks_only = true;
276        let mut done = open("T2");
277        done.done = true;
278        let tasks = [open("T1"), done, you];
279        assert_eq!(
280            checkpoint(&tasks),
281            Some("T1"),
282            "T2 ticked first, T3 is yours"
283        );
284        assert!(matches!(
285            share(&tasks, "T1", &ids(2), &BTreeSet::new(), true),
286            Some(Share::Checkpoint { .. })
287        ));
288        assert_eq!(
289            share(&[open("T1")], "T1", &[], &BTreeSet::new(), false),
290            None,
291            "no scenario, no share"
292        );
293    }
294
295    proptest! {
296        /// Same inputs, same share; and in ANY order the tasks finish — lanes,
297        /// `--task`, a person's ticks — every scenario of the story is
298        /// given to some task's test author before the story closes:
299        /// verified by an earlier tick, or judged at the checkpoint.
300        #[test]
301        fn every_scenario_of_a_story_is_judged(
302            n in 1usize..6,
303            plan in proptest::collection::vec((any::<bool>(), proptest::collection::vec(1usize..7, 0..3), any::<bool>(), proptest::collection::vec(1usize..7, 0..3)), 1..7),
304            order in proptest::collection::vec(any::<prop::sample::Index>(), 7),
305        ) {
306            let sc = ids(n);
307            let links: Vec<Vec<String>> = plan.iter().map(|(_, l, _, _)| l.iter().map(|k| format!("US3-{k}")).collect()).collect();
308            let names = ["T1", "T2", "T3", "T4", "T5", "T6"];
309            let mut done = vec![false; plan.len()];
310            let mut remaining: Vec<usize> = (0..plan.len()).collect();
311            let mut verified = BTreeSet::new();
312            let mut judged = BTreeSet::new();
313            let any_judged_task = plan.iter().any(|p| !p.0);
314            let mut k = 0;
315            while !remaining.is_empty() {
316                let i = remaining.remove(order[k % order.len()].index(remaining.len()));
317                k += 1;
318                let tasks: Vec<StoryTask<'_>> = (0..plan.len()).map(|j| StoryTask {
319                    id: names[j], checks_only: plan[j].0, done: done[j], links: &links[j],
320                }).collect();
321                let s = share(&tasks, names[i], &sc, &verified, plan[i].2);
322                prop_assert_eq!(&s, &share(&tasks, names[i], &sc, &verified, plan[i].2));
323                if let Some(s) = &s {
324                    judged.extend(s.scenarios().iter().cloned());
325                    // The model verifies some of what it was given, never more.
326                    for k in &plan[i].3 {
327                        let id = format!("US3-{k}");
328                        if s.scenarios().contains(&id) { verified.insert(id); }
329                    }
330                }
331                done[i] = true;
332            }
333            if any_judged_task {
334                let all: BTreeSet<String> = sc.iter().cloned().collect();
335                prop_assert_eq!(judged.union(&verified).cloned().collect::<BTreeSet<_>>(), all);
336            }
337        }
338    }
339}