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")]
173 pub before_oracle: bool,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
178pub struct OracleMark {
179 pub commit: String,
181 pub decided_by: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
187pub struct ScenarioMark {
188 pub id: String,
190 pub state: ScenarioState,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub turned_by: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub contract: Option<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct StoryTask<'a> {
203 pub id: &'a str,
205 pub checks_only: bool,
208 pub done: bool,
210 pub links: &'a [String],
212}
213
214#[must_use]
218pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
219 tasks
220 .iter()
221 .rev()
222 .find(|t| !t.checks_only && !t.done)
223 .map(|t| t.id)
224}
225
226#[must_use]
228pub fn first<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
229 tasks
230 .iter()
231 .find(|t| !t.checks_only && !t.done)
232 .map(|t| t.id)
233}
234
235#[must_use]
240pub fn judged(tasks: &[StoryTask<'_>], task: &str, scenarios: &[String]) -> Option<Judged> {
241 let me = tasks.iter().find(|t| t.id == task)?;
242 if scenarios.is_empty() || me.checks_only {
243 return None;
244 }
245 if checkpoint(tasks) == Some(task) {
246 return Some(Judged::Checkpoint);
247 }
248 let links: Vec<String> = me
249 .links
250 .iter()
251 .filter(|l| scenarios.contains(l))
252 .cloned()
253 .collect();
254 Some(if links.is_empty() {
255 Judged::Progress
256 } else {
257 Judged::Linked { scenarios: links }
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use ScenarioState::{Green, Red, Untested};
265 use proptest::prelude::*;
266
267 fn ids(n: usize) -> Vec<String> {
268 (1..=n).map(|k| format!("US3-{k}")).collect()
269 }
270
271 fn states(s: &[ScenarioState]) -> BTreeMap<String, ScenarioState> {
272 ids(s.len()).into_iter().zip(s.iter().copied()).collect()
273 }
274
275 const NONE: &[String] = &[];
276
277 fn open(id: &str) -> StoryTask<'_> {
278 StoryTask {
279 id,
280 checks_only: false,
281 done: false,
282 links: NONE,
283 }
284 }
285
286 #[test]
287 fn a_task_claims_what_it_turned_and_nothing_else() {
288 let o = ids(3);
289 assert_eq!(
290 progress(
291 &o,
292 &states(&[Red, Red, Red]),
293 &states(&[Green, Red, Green]),
294 &[],
295 false,
296 &[],
297 ),
298 Progress::Verified {
299 turned: vec!["US3-1".into(), "US3-3".into()]
300 }
301 );
302 assert_eq!(
303 progress(
304 &o,
305 &states(&[Green, Red, Red]),
306 &states(&[Green, Red, Red]),
307 &[],
308 false,
309 &[],
310 ),
311 Progress::InProgress
312 );
313 assert_eq!(
314 progress(
315 &o,
316 &states(&[Green, Red, Red]),
317 &states(&[Red, Green, Green]),
318 &[],
319 false,
320 &[],
321 ),
322 Progress::Regressed {
323 scenarios: vec!["US3-1".into()]
324 },
325 "a regression fails whatever else turned"
326 );
327 }
328
329 #[test]
330 fn links_and_the_checkpoint_must_be_green_after() {
331 let o = ids(2);
332 let link = vec!["US3-2".to_owned()];
333 assert_eq!(
334 progress(
335 &o,
336 &states(&[Red, Red]),
337 &states(&[Green, Red]),
338 &link,
339 false,
340 &[],
341 ),
342 Progress::LinksOpen {
343 scenarios: link.clone()
344 }
345 );
346 assert_eq!(
347 progress(
348 &o,
349 &states(&[Red, Red]),
350 &states(&[Green, Untested]),
351 &[],
352 true,
353 &[],
354 ),
355 Progress::StoryOpen {
356 scenarios: vec!["US3-2".into()]
357 },
358 "untested is not green"
359 );
360 assert_eq!(
361 progress(
362 &o,
363 &states(&[Green, Green]),
364 &states(&[Green, Green]),
365 &[],
366 true,
367 &[],
368 ),
369 Progress::Closed
370 );
371 }
372
373 #[test]
374 fn a_scenario_its_author_could_not_test_never_blocks_the_story() {
375 let o = ids(2);
376 let untestable = vec!["US3-2".to_owned()];
377 assert_eq!(
378 progress(
379 &o,
380 &states(&[Red, Untested]),
381 &states(&[Green, Untested]),
382 &[],
383 true,
384 &untestable
385 ),
386 Progress::Verified {
387 turned: vec!["US3-1".into()]
388 }
389 );
390 }
391
392 #[test]
393 fn the_checkpoint_is_the_last_open_code_task_and_the_oracle_goes_first() {
394 let mut docs = open("T022");
395 docs.checks_only = true;
396 let mut done = open("T018");
397 done.done = true;
398 let link = vec!["US3-2".to_owned()];
399 let mut linked = open("T020");
400 linked.links = &link;
401 let tasks = [done, open("T019"), linked, open("T021"), docs];
402 let sc = ids(2);
403 assert_eq!(checkpoint(&tasks), Some("T021"));
404 assert_eq!(first(&tasks), Some("T019"));
405 assert_eq!(judged(&tasks, "T019", &sc), Some(Judged::Progress));
406 assert_eq!(
407 judged(&tasks, "T020", &sc),
408 Some(Judged::Linked {
409 scenarios: link.clone()
410 })
411 );
412 assert_eq!(judged(&tasks, "T021", &sc), Some(Judged::Checkpoint));
413 assert_eq!(judged(&tasks, "T022", &sc), None, "documentation");
414 assert_eq!(judged(&tasks, "T019", &[]), None, "no scenario");
415 }
416
417 fn state() -> impl Strategy<Value = ScenarioState> {
418 prop_oneof![Just(Green), Just(Red), Just(Untested)]
419 }
420
421 proptest! {
422 #[test]
426 fn progress_is_honest(
427 before in proptest::collection::vec(state(), 1..6),
428 after_seed in proptest::collection::vec(state(), 6),
429 link_mask in proptest::collection::vec(any::<bool>(), 6),
430 checkpoint in any::<bool>(),
431 ) {
432 let n = before.len();
433 let o = ids(n);
434 let b = states(&before);
435 let a = states(&after_seed[..n]);
436 let links: Vec<String> = o.iter().zip(&link_mask).filter(|(_, m)| **m).map(|(id, _)| id.clone()).collect();
437 let p = progress(&o, &b, &a, &links, checkpoint, &[]);
438 prop_assert_eq!(&p, &progress(&o, &b, &a, &links, checkpoint, &[]));
439 if let Progress::Verified { turned } = &p {
440 prop_assert!(!turned.is_empty());
441 for id in turned {
442 prop_assert!(b[id] != Green && a[id] == Green);
443 }
444 }
445 if p.passes() {
446 for id in &o {
447 prop_assert!(!(b[id] == Green && a[id] != Green));
448 }
449 for id in &links {
450 prop_assert_eq!(a[id], Green);
451 }
452 if checkpoint {
453 prop_assert!(o.iter().all(|id| a[id] == Green));
454 }
455 }
456 }
457
458 #[test]
461 fn a_scenario_is_claimed_once(
462 steps in proptest::collection::vec(proptest::collection::vec(state(), 4), 1..6),
463 ) {
464 let o = ids(4);
465 let mut now = states(&[Red, Red, Red, Red]);
466 let mut claimed: BTreeMap<String, usize> = BTreeMap::new();
467 for (i, s) in steps.iter().enumerate() {
468 let next = states(s);
469 let p = progress(&o, &now, &next, &[], false, &[]);
470 if p.passes() {
471 if let Progress::Verified { turned } = &p {
472 for id in turned {
473 prop_assert!(claimed.insert(id.clone(), i).is_none());
474 }
475 }
476 now = next;
477 }
478 }
479 }
480 }
481}