use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScenarioState {
Green,
Red,
Untested,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Progress {
Verified {
turned: Vec<String>,
},
InProgress,
Closed,
Regressed {
scenarios: Vec<String>,
},
LinksOpen {
scenarios: Vec<String>,
},
StoryOpen {
scenarios: Vec<String>,
},
}
impl Progress {
#[must_use]
pub const fn passes(&self) -> bool {
matches!(
self,
Self::Verified { .. } | Self::InProgress | Self::Closed
)
}
}
#[must_use]
pub fn progress(
order: &[String],
before: &BTreeMap<String, ScenarioState>,
after: &BTreeMap<String, ScenarioState>,
links: &[String],
checkpoint: bool,
untestable: &[String],
) -> Progress {
let state = |m: &BTreeMap<String, ScenarioState>, id: &str| {
m.get(id).copied().unwrap_or(ScenarioState::Untested)
};
let green =
|m: &BTreeMap<String, ScenarioState>, id: &str| state(m, id) == ScenarioState::Green;
let pick = |f: &dyn Fn(&str) -> bool| -> Vec<String> {
order.iter().filter(|id| f(id)).cloned().collect()
};
let regressed = pick(&|id| green(before, id) && !green(after, id));
if !regressed.is_empty() {
return Progress::Regressed {
scenarios: regressed,
};
}
if checkpoint {
let open = pick(&|id| !green(after, id) && !untestable.iter().any(|u| u == id));
if !open.is_empty() {
return Progress::StoryOpen { scenarios: open };
}
}
let open = pick(&|id| links.iter().any(|l| l == id) && !green(after, id));
if !open.is_empty() {
return Progress::LinksOpen { scenarios: open };
}
let turned = pick(&|id| !green(before, id) && green(after, id));
match (turned.is_empty(), checkpoint) {
(false, _) => Progress::Verified { turned },
(true, true) => Progress::Closed,
(true, false) => Progress::InProgress,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Judged {
Progress,
Linked {
scenarios: Vec<String>,
},
Checkpoint,
}
impl Judged {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Progress => "progress",
Self::Linked { .. } => "linked",
Self::Checkpoint => "checkpoint",
}
}
#[must_use]
pub fn links(&self) -> &[String] {
match self {
Self::Linked { scenarios } => scenarios,
_ => &[],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct StoryScenarios {
pub story: String,
pub scenarios: Vec<ScenarioMark>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oracle: Option<OracleMark>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub proven: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub before_oracle: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct OracleMark {
pub commit: String,
pub decided_by: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ScenarioMark {
pub id: String,
pub state: ScenarioState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turned_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contract: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoryTask<'a> {
pub id: &'a str,
pub checks_only: bool,
pub done: bool,
pub links: &'a [String],
}
#[must_use]
pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
tasks
.iter()
.rev()
.find(|t| !t.checks_only && !t.done)
.map(|t| t.id)
}
#[must_use]
pub fn first<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
tasks
.iter()
.find(|t| !t.checks_only && !t.done)
.map(|t| t.id)
}
#[must_use]
pub fn judged(tasks: &[StoryTask<'_>], task: &str, scenarios: &[String]) -> Option<Judged> {
let me = tasks.iter().find(|t| t.id == task)?;
if scenarios.is_empty() || me.checks_only {
return None;
}
if checkpoint(tasks) == Some(task) {
return Some(Judged::Checkpoint);
}
let links: Vec<String> = me
.links
.iter()
.filter(|l| scenarios.contains(l))
.cloned()
.collect();
Some(if links.is_empty() {
Judged::Progress
} else {
Judged::Linked { scenarios: links }
})
}
#[cfg(test)]
mod tests {
use super::*;
use ScenarioState::{Green, Red, Untested};
use proptest::prelude::*;
fn ids(n: usize) -> Vec<String> {
(1..=n).map(|k| format!("US3-{k}")).collect()
}
fn states(s: &[ScenarioState]) -> BTreeMap<String, ScenarioState> {
ids(s.len()).into_iter().zip(s.iter().copied()).collect()
}
const NONE: &[String] = &[];
fn open(id: &str) -> StoryTask<'_> {
StoryTask {
id,
checks_only: false,
done: false,
links: NONE,
}
}
#[test]
fn a_task_claims_what_it_turned_and_nothing_else() {
let o = ids(3);
assert_eq!(
progress(
&o,
&states(&[Red, Red, Red]),
&states(&[Green, Red, Green]),
&[],
false,
&[],
),
Progress::Verified {
turned: vec!["US3-1".into(), "US3-3".into()]
}
);
assert_eq!(
progress(
&o,
&states(&[Green, Red, Red]),
&states(&[Green, Red, Red]),
&[],
false,
&[],
),
Progress::InProgress
);
assert_eq!(
progress(
&o,
&states(&[Green, Red, Red]),
&states(&[Red, Green, Green]),
&[],
false,
&[],
),
Progress::Regressed {
scenarios: vec!["US3-1".into()]
},
"a regression fails whatever else turned"
);
}
#[test]
fn links_and_the_checkpoint_must_be_green_after() {
let o = ids(2);
let link = vec!["US3-2".to_owned()];
assert_eq!(
progress(
&o,
&states(&[Red, Red]),
&states(&[Green, Red]),
&link,
false,
&[],
),
Progress::LinksOpen {
scenarios: link.clone()
}
);
assert_eq!(
progress(
&o,
&states(&[Red, Red]),
&states(&[Green, Untested]),
&[],
true,
&[],
),
Progress::StoryOpen {
scenarios: vec!["US3-2".into()]
},
"untested is not green"
);
assert_eq!(
progress(
&o,
&states(&[Green, Green]),
&states(&[Green, Green]),
&[],
true,
&[],
),
Progress::Closed
);
}
#[test]
fn a_scenario_its_author_could_not_test_never_blocks_the_story() {
let o = ids(2);
let untestable = vec!["US3-2".to_owned()];
assert_eq!(
progress(
&o,
&states(&[Red, Untested]),
&states(&[Green, Untested]),
&[],
true,
&untestable
),
Progress::Verified {
turned: vec!["US3-1".into()]
}
);
}
#[test]
fn the_checkpoint_is_the_last_open_code_task_and_the_oracle_goes_first() {
let mut docs = open("T022");
docs.checks_only = true;
let mut done = open("T018");
done.done = true;
let link = vec!["US3-2".to_owned()];
let mut linked = open("T020");
linked.links = &link;
let tasks = [done, open("T019"), linked, open("T021"), docs];
let sc = ids(2);
assert_eq!(checkpoint(&tasks), Some("T021"));
assert_eq!(first(&tasks), Some("T019"));
assert_eq!(judged(&tasks, "T019", &sc), Some(Judged::Progress));
assert_eq!(
judged(&tasks, "T020", &sc),
Some(Judged::Linked {
scenarios: link.clone()
})
);
assert_eq!(judged(&tasks, "T021", &sc), Some(Judged::Checkpoint));
assert_eq!(judged(&tasks, "T022", &sc), None, "documentation");
assert_eq!(judged(&tasks, "T019", &[]), None, "no scenario");
}
fn state() -> impl Strategy<Value = ScenarioState> {
prop_oneof![Just(Green), Just(Red), Just(Untested)]
}
proptest! {
#[test]
fn progress_is_honest(
before in proptest::collection::vec(state(), 1..6),
after_seed in proptest::collection::vec(state(), 6),
link_mask in proptest::collection::vec(any::<bool>(), 6),
checkpoint in any::<bool>(),
) {
let n = before.len();
let o = ids(n);
let b = states(&before);
let a = states(&after_seed[..n]);
let links: Vec<String> = o.iter().zip(&link_mask).filter(|(_, m)| **m).map(|(id, _)| id.clone()).collect();
let p = progress(&o, &b, &a, &links, checkpoint, &[]);
prop_assert_eq!(&p, &progress(&o, &b, &a, &links, checkpoint, &[]));
if let Progress::Verified { turned } = &p {
prop_assert!(!turned.is_empty());
for id in turned {
prop_assert!(b[id] != Green && a[id] == Green);
}
}
if p.passes() {
for id in &o {
prop_assert!(!(b[id] == Green && a[id] != Green));
}
for id in &links {
prop_assert_eq!(a[id], Green);
}
if checkpoint {
prop_assert!(o.iter().all(|id| a[id] == Green));
}
}
}
#[test]
fn a_scenario_is_claimed_once(
steps in proptest::collection::vec(proptest::collection::vec(state(), 4), 1..6),
) {
let o = ids(4);
let mut now = states(&[Red, Red, Red, Red]);
let mut claimed: BTreeMap<String, usize> = BTreeMap::new();
for (i, s) in steps.iter().enumerate() {
let next = states(s);
let p = progress(&o, &now, &next, &[], false, &[]);
if p.passes() {
if let Progress::Verified { turned } = &p {
for id in turned {
prop_assert!(claimed.insert(id.clone(), i).is_none());
}
}
now = next;
}
}
}
}
}