1use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum ScenarioState {
19 Green,
21 Red,
23 Untested,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum Progress {
32 Verified {
34 turned: Vec<String>,
36 },
37 InProgress,
39 Closed,
41 Regressed {
43 scenarios: Vec<String>,
45 },
46 LinksOpen {
48 scenarios: Vec<String>,
50 },
51 StoryOpen {
53 scenarios: Vec<String>,
55 },
56}
57
58impl Progress {
59 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum Judged {
120 Progress,
122 Linked {
124 scenarios: Vec<String>,
126 },
127 Checkpoint,
129}
130
131impl Judged {
132 #[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 #[must_use]
144 pub fn links(&self) -> &[String] {
145 match self {
146 Self::Linked { scenarios } => scenarios,
147 _ => &[],
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
154pub struct StoryScenarios {
155 pub story: String,
157 pub scenarios: Vec<ScenarioMark>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub checkpoint: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub oracle: Option<OracleMark>,
166 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
170 pub proven: bool,
171 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173 pub finished: bool,
174 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
177 pub before_oracle: bool,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
182pub struct OracleMark {
183 pub commit: String,
185 pub decided_by: String,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
191pub struct ScenarioMark {
192 pub id: String,
194 pub state: ScenarioState,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub turned_by: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub contract: Option<String>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct StoryTask<'a> {
207 pub id: &'a str,
209 pub checks_only: bool,
212 pub done: bool,
214 pub links: &'a [String],
216}
217
218#[must_use]
222pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
223 tasks
224 .iter()
225 .rev()
226 .find(|t| !t.checks_only && !t.done)
227 .map(|t| t.id)
228}
229
230#[must_use]
232pub fn first<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
233 tasks
234 .iter()
235 .find(|t| !t.checks_only && !t.done)
236 .map(|t| t.id)
237}
238
239#[must_use]
244pub fn judged(tasks: &[StoryTask<'_>], task: &str, scenarios: &[String]) -> Option<Judged> {
245 let me = tasks.iter().find(|t| t.id == task)?;
246 if scenarios.is_empty() || me.checks_only {
247 return None;
248 }
249 if checkpoint(tasks) == Some(task) {
250 return Some(Judged::Checkpoint);
251 }
252 let links: Vec<String> = me
253 .links
254 .iter()
255 .filter(|l| scenarios.contains(l))
256 .cloned()
257 .collect();
258 Some(if links.is_empty() {
259 Judged::Progress
260 } else {
261 Judged::Linked { scenarios: links }
262 })
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use ScenarioState::{Green, Red, Untested};
269 use proptest::prelude::*;
270
271 fn ids(n: usize) -> Vec<String> {
272 (1..=n).map(|k| format!("US3-{k}")).collect()
273 }
274
275 fn states(s: &[ScenarioState]) -> BTreeMap<String, ScenarioState> {
276 ids(s.len()).into_iter().zip(s.iter().copied()).collect()
277 }
278
279 const NONE: &[String] = &[];
280
281 fn open(id: &str) -> StoryTask<'_> {
282 StoryTask {
283 id,
284 checks_only: false,
285 done: false,
286 links: NONE,
287 }
288 }
289
290 #[test]
291 fn a_task_claims_what_it_turned_and_nothing_else() {
292 let o = ids(3);
293 assert_eq!(
294 progress(
295 &o,
296 &states(&[Red, Red, Red]),
297 &states(&[Green, Red, Green]),
298 &[],
299 false,
300 &[],
301 ),
302 Progress::Verified {
303 turned: vec!["US3-1".into(), "US3-3".into()]
304 }
305 );
306 assert_eq!(
307 progress(
308 &o,
309 &states(&[Green, Red, Red]),
310 &states(&[Green, Red, Red]),
311 &[],
312 false,
313 &[],
314 ),
315 Progress::InProgress
316 );
317 assert_eq!(
318 progress(
319 &o,
320 &states(&[Green, Red, Red]),
321 &states(&[Red, Green, Green]),
322 &[],
323 false,
324 &[],
325 ),
326 Progress::Regressed {
327 scenarios: vec!["US3-1".into()]
328 },
329 "a regression fails whatever else turned"
330 );
331 }
332
333 #[test]
334 fn links_and_the_checkpoint_must_be_green_after() {
335 let o = ids(2);
336 let link = vec!["US3-2".to_owned()];
337 assert_eq!(
338 progress(
339 &o,
340 &states(&[Red, Red]),
341 &states(&[Green, Red]),
342 &link,
343 false,
344 &[],
345 ),
346 Progress::LinksOpen {
347 scenarios: link.clone()
348 }
349 );
350 assert_eq!(
351 progress(
352 &o,
353 &states(&[Red, Red]),
354 &states(&[Green, Untested]),
355 &[],
356 true,
357 &[],
358 ),
359 Progress::StoryOpen {
360 scenarios: vec!["US3-2".into()]
361 },
362 "untested is not green"
363 );
364 assert_eq!(
365 progress(
366 &o,
367 &states(&[Green, Green]),
368 &states(&[Green, Green]),
369 &[],
370 true,
371 &[],
372 ),
373 Progress::Closed
374 );
375 }
376
377 #[test]
378 fn a_scenario_its_author_could_not_test_never_blocks_the_story() {
379 let o = ids(2);
380 let untestable = vec!["US3-2".to_owned()];
381 assert_eq!(
382 progress(
383 &o,
384 &states(&[Red, Untested]),
385 &states(&[Green, Untested]),
386 &[],
387 true,
388 &untestable
389 ),
390 Progress::Verified {
391 turned: vec!["US3-1".into()]
392 }
393 );
394 }
395
396 #[test]
397 fn the_checkpoint_is_the_last_open_code_task_and_the_oracle_goes_first() {
398 let mut docs = open("T022");
399 docs.checks_only = true;
400 let mut done = open("T018");
401 done.done = true;
402 let link = vec!["US3-2".to_owned()];
403 let mut linked = open("T020");
404 linked.links = &link;
405 let tasks = [done, open("T019"), linked, open("T021"), docs];
406 let sc = ids(2);
407 assert_eq!(checkpoint(&tasks), Some("T021"));
408 assert_eq!(first(&tasks), Some("T019"));
409 assert_eq!(judged(&tasks, "T019", &sc), Some(Judged::Progress));
410 assert_eq!(
411 judged(&tasks, "T020", &sc),
412 Some(Judged::Linked {
413 scenarios: link.clone()
414 })
415 );
416 assert_eq!(judged(&tasks, "T021", &sc), Some(Judged::Checkpoint));
417 assert_eq!(judged(&tasks, "T022", &sc), None, "documentation");
418 assert_eq!(judged(&tasks, "T019", &[]), None, "no scenario");
419 }
420
421 fn state() -> impl Strategy<Value = ScenarioState> {
422 prop_oneof![Just(Green), Just(Red), Just(Untested)]
423 }
424
425 proptest! {
426 #[test]
430 fn progress_is_honest(
431 before in proptest::collection::vec(state(), 1..6),
432 after_seed in proptest::collection::vec(state(), 6),
433 link_mask in proptest::collection::vec(any::<bool>(), 6),
434 checkpoint in any::<bool>(),
435 ) {
436 let n = before.len();
437 let o = ids(n);
438 let b = states(&before);
439 let a = states(&after_seed[..n]);
440 let links: Vec<String> = o.iter().zip(&link_mask).filter(|(_, m)| **m).map(|(id, _)| id.clone()).collect();
441 let p = progress(&o, &b, &a, &links, checkpoint, &[]);
442 prop_assert_eq!(&p, &progress(&o, &b, &a, &links, checkpoint, &[]));
443 if let Progress::Verified { turned } = &p {
444 prop_assert!(!turned.is_empty());
445 for id in turned {
446 prop_assert!(b[id] != Green && a[id] == Green);
447 }
448 }
449 if p.passes() {
450 for id in &o {
451 prop_assert!(!(b[id] == Green && a[id] != Green));
452 }
453 for id in &links {
454 prop_assert_eq!(a[id], Green);
455 }
456 if checkpoint {
457 prop_assert!(o.iter().all(|id| a[id] == Green));
458 }
459 }
460 }
461
462 #[test]
465 fn a_scenario_is_claimed_once(
466 steps in proptest::collection::vec(proptest::collection::vec(state(), 4), 1..6),
467 ) {
468 let o = ids(4);
469 let mut now = states(&[Red, Red, Red, Red]);
470 let mut claimed: BTreeMap<String, usize> = BTreeMap::new();
471 for (i, s) in steps.iter().enumerate() {
472 let next = states(s);
473 let p = progress(&o, &now, &next, &[], false, &[]);
474 if p.passes() {
475 if let Progress::Verified { turned } = &p {
476 for id in turned {
477 prop_assert!(claimed.insert(id.clone(), i).is_none());
478 }
479 }
480 now = next;
481 }
482 }
483 }
484 }
485}