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