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")]
169 pub proven: bool,
170 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
172 pub finished: bool,
173 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
176 pub before_oracle: bool,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
181pub struct OracleMark {
182 pub commit: String,
184 pub decided_by: String,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
190pub struct ScenarioMark {
191 pub id: String,
193 pub state: ScenarioState,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub turned_by: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub contract: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct StoryTask<'a> {
206 pub id: &'a str,
208 pub checks_only: bool,
211 pub done: bool,
213 pub links: &'a [String],
215}
216
217#[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#[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#[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 #[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 #[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}