use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Share {
Linked {
scenarios: Vec<String>,
},
Checkpoint {
scenarios: Vec<String>,
composition: bool,
},
Authored {
scenarios: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
checkpoint: Option<String>,
},
Deferred {
checkpoint: String,
},
}
impl Share {
#[must_use]
pub fn scenarios(&self) -> &[String] {
match self {
Self::Linked { scenarios }
| Self::Checkpoint { scenarios, .. }
| Self::Authored { scenarios, .. } => scenarios,
Self::Deferred { .. } => &[],
}
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Linked { .. } => "linked",
Self::Checkpoint { .. } => "checkpoint",
Self::Authored { .. } => "authored",
Self::Deferred { .. } => "deferred",
}
}
}
#[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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ScenarioMark {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verified_by: 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 share(
tasks: &[StoryTask<'_>],
task: &str,
scenarios: &[String],
verified: &BTreeSet<String>,
deferred: bool,
) -> Option<Share> {
let me = tasks.iter().find(|t| t.id == task)?;
if scenarios.is_empty() || me.checks_only {
return None;
}
let links: Vec<String> = me
.links
.iter()
.filter(|l| scenarios.contains(l))
.cloned()
.collect();
let at = checkpoint(tasks);
if at == Some(task) {
let left: Vec<String> = scenarios
.iter()
.filter(|s| !verified.contains(*s) || links.contains(s))
.cloned()
.collect();
let composition = left.len() >= 2;
return Some(Share::Checkpoint {
scenarios: left,
composition,
});
}
if !links.is_empty() {
return Some(Share::Linked { scenarios: links });
}
if deferred && let Some(at) = at {
return Some(Share::Deferred {
checkpoint: at.to_owned(),
});
}
Some(Share::Authored {
scenarios: scenarios.to_vec(),
checkpoint: at.map(str::to_owned),
})
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn ids(n: usize) -> Vec<String> {
(1..=n).map(|k| format!("US3-{k}")).collect()
}
const NONE: &[String] = &[];
fn open(id: &str) -> StoryTask<'_> {
StoryTask {
id,
checks_only: false,
done: false,
links: NONE,
}
}
#[test]
fn the_real_case_t018_to_t022() {
let mut docs = open("T022");
docs.checks_only = true;
let tasks = [open("T018"), open("T019"), open("T020"), open("T021"), docs];
let sc = ids(2);
let none = BTreeSet::new();
assert_eq!(checkpoint(&tasks), Some("T021"));
assert_eq!(
share(&tasks, "T018", &sc, &none, true),
Some(Share::Deferred {
checkpoint: "T021".into()
})
);
assert_eq!(
share(&tasks, "T018", &sc, &none, false),
Some(Share::Authored {
scenarios: sc.clone(),
checkpoint: Some("T021".into())
})
);
assert_eq!(
share(&tasks, "T021", &sc, &none, false),
Some(Share::Checkpoint {
scenarios: sc.clone(),
composition: true
})
);
assert_eq!(
share(&tasks, "T022", &sc, &none, false),
None,
"documentation has no share"
);
}
#[test]
fn a_link_beats_a_deferral_and_never_narrows_the_checkpoint() {
let one = vec!["US3-1".to_owned()];
let unknown = vec!["US9-4".to_owned()];
let tasks = [
StoryTask {
id: "T1",
checks_only: false,
done: false,
links: &one,
},
StoryTask {
id: "T2",
checks_only: false,
done: false,
links: &unknown,
},
StoryTask {
id: "T3",
checks_only: false,
done: false,
links: &one,
},
];
let sc = ids(3);
let verified: BTreeSet<String> = ["US3-1".to_owned()].into();
assert_eq!(
share(&tasks, "T1", &sc, &verified, true),
Some(Share::Linked {
scenarios: one.clone()
})
);
assert_eq!(
share(&tasks, "T2", &sc, &verified, false),
Some(Share::Authored {
scenarios: sc.clone(),
checkpoint: Some("T3".into())
})
);
assert_eq!(
share(&tasks, "T3", &sc, &verified, false),
Some(Share::Checkpoint {
scenarios: ids(3),
composition: true
})
);
}
#[test]
fn a_person_s_step_is_never_the_checkpoint_and_a_done_one_hands_it_on() {
let mut you = open("T3");
you.checks_only = true;
let mut done = open("T2");
done.done = true;
let tasks = [open("T1"), done, you];
assert_eq!(
checkpoint(&tasks),
Some("T1"),
"T2 ticked first, T3 is yours"
);
assert!(matches!(
share(&tasks, "T1", &ids(2), &BTreeSet::new(), true),
Some(Share::Checkpoint { .. })
));
assert_eq!(
share(&[open("T1")], "T1", &[], &BTreeSet::new(), false),
None,
"no scenario, no share"
);
}
proptest! {
#[test]
fn every_scenario_of_a_story_is_judged(
n in 1usize..6,
plan in proptest::collection::vec((any::<bool>(), proptest::collection::vec(1usize..7, 0..3), any::<bool>(), proptest::collection::vec(1usize..7, 0..3)), 1..7),
order in proptest::collection::vec(any::<prop::sample::Index>(), 7),
) {
let sc = ids(n);
let links: Vec<Vec<String>> = plan.iter().map(|(_, l, _, _)| l.iter().map(|k| format!("US3-{k}")).collect()).collect();
let names = ["T1", "T2", "T3", "T4", "T5", "T6"];
let mut done = vec![false; plan.len()];
let mut remaining: Vec<usize> = (0..plan.len()).collect();
let mut verified = BTreeSet::new();
let mut judged = BTreeSet::new();
let any_judged_task = plan.iter().any(|p| !p.0);
let mut k = 0;
while !remaining.is_empty() {
let i = remaining.remove(order[k % order.len()].index(remaining.len()));
k += 1;
let tasks: Vec<StoryTask<'_>> = (0..plan.len()).map(|j| StoryTask {
id: names[j], checks_only: plan[j].0, done: done[j], links: &links[j],
}).collect();
let s = share(&tasks, names[i], &sc, &verified, plan[i].2);
prop_assert_eq!(&s, &share(&tasks, names[i], &sc, &verified, plan[i].2));
if let Some(s) = &s {
judged.extend(s.scenarios().iter().cloned());
for k in &plan[i].3 {
let id = format!("US3-{k}");
if s.scenarios().contains(&id) { verified.insert(id); }
}
}
done[i] = true;
}
if any_judged_task {
let all: BTreeSet<String> = sc.iter().cloned().collect();
prop_assert_eq!(judged.union(&verified).cloned().collect::<BTreeSet<_>>(), all);
}
}
}
}