1use std::collections::BTreeSet;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(tag = "kind", rename_all = "snake_case")]
16pub enum Share {
17 Linked {
19 scenarios: Vec<String>,
21 },
22 Checkpoint {
25 scenarios: Vec<String>,
27 composition: bool,
29 },
30 Authored {
32 scenarios: Vec<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 checkpoint: Option<String>,
37 },
38 Deferred {
40 checkpoint: String,
42 },
43}
44
45impl Share {
46 #[must_use]
48 pub fn scenarios(&self) -> &[String] {
49 match self {
50 Self::Linked { scenarios }
51 | Self::Checkpoint { scenarios, .. }
52 | Self::Authored { scenarios, .. } => scenarios,
53 Self::Deferred { .. } => &[],
54 }
55 }
56
57 #[must_use]
59 pub const fn kind(&self) -> &'static str {
60 match self {
61 Self::Linked { .. } => "linked",
62 Self::Checkpoint { .. } => "checkpoint",
63 Self::Authored { .. } => "authored",
64 Self::Deferred { .. } => "deferred",
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
71pub struct StoryScenarios {
72 pub story: String,
74 pub scenarios: Vec<ScenarioMark>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub checkpoint: Option<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
83pub struct ScenarioMark {
84 pub id: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub verified_by: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct StoryTask<'a> {
94 pub id: &'a str,
96 pub checks_only: bool,
99 pub done: bool,
101 pub links: &'a [String],
103}
104
105#[must_use]
109pub fn checkpoint<'a>(tasks: &[StoryTask<'a>]) -> Option<&'a str> {
110 tasks
111 .iter()
112 .rev()
113 .find(|t| !t.checks_only && !t.done)
114 .map(|t| t.id)
115}
116
117#[must_use]
125pub fn share(
126 tasks: &[StoryTask<'_>],
127 task: &str,
128 scenarios: &[String],
129 verified: &BTreeSet<String>,
130 deferred: bool,
131) -> Option<Share> {
132 let me = tasks.iter().find(|t| t.id == task)?;
133 if scenarios.is_empty() || me.checks_only {
134 return None;
135 }
136 let links: Vec<String> = me
137 .links
138 .iter()
139 .filter(|l| scenarios.contains(l))
140 .cloned()
141 .collect();
142 let at = checkpoint(tasks);
143 if at == Some(task) {
144 let left: Vec<String> = scenarios
145 .iter()
146 .filter(|s| !verified.contains(*s) || links.contains(s))
147 .cloned()
148 .collect();
149 let composition = left.len() >= 2;
150 return Some(Share::Checkpoint {
151 scenarios: left,
152 composition,
153 });
154 }
155 if !links.is_empty() {
156 return Some(Share::Linked { scenarios: links });
157 }
158 if deferred && let Some(at) = at {
159 return Some(Share::Deferred {
160 checkpoint: at.to_owned(),
161 });
162 }
163 Some(Share::Authored {
164 scenarios: scenarios.to_vec(),
165 checkpoint: at.map(str::to_owned),
166 })
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use proptest::prelude::*;
173
174 fn ids(n: usize) -> Vec<String> {
175 (1..=n).map(|k| format!("US3-{k}")).collect()
176 }
177
178 const NONE: &[String] = &[];
179
180 fn open(id: &str) -> StoryTask<'_> {
181 StoryTask {
182 id,
183 checks_only: false,
184 done: false,
185 links: NONE,
186 }
187 }
188
189 #[test]
190 fn the_real_case_t018_to_t022() {
191 let mut docs = open("T022");
192 docs.checks_only = true;
193 let tasks = [open("T018"), open("T019"), open("T020"), open("T021"), docs];
194 let sc = ids(2);
195 let none = BTreeSet::new();
196 assert_eq!(checkpoint(&tasks), Some("T021"));
197 assert_eq!(
198 share(&tasks, "T018", &sc, &none, true),
199 Some(Share::Deferred {
200 checkpoint: "T021".into()
201 })
202 );
203 assert_eq!(
204 share(&tasks, "T018", &sc, &none, false),
205 Some(Share::Authored {
206 scenarios: sc.clone(),
207 checkpoint: Some("T021".into())
208 })
209 );
210 assert_eq!(
211 share(&tasks, "T021", &sc, &none, false),
212 Some(Share::Checkpoint {
213 scenarios: sc.clone(),
214 composition: true
215 })
216 );
217 assert_eq!(
218 share(&tasks, "T022", &sc, &none, false),
219 None,
220 "documentation has no share"
221 );
222 }
223
224 #[test]
225 fn a_link_beats_a_deferral_and_never_narrows_the_checkpoint() {
226 let one = vec!["US3-1".to_owned()];
227 let unknown = vec!["US9-4".to_owned()];
228 let tasks = [
229 StoryTask {
230 id: "T1",
231 checks_only: false,
232 done: false,
233 links: &one,
234 },
235 StoryTask {
236 id: "T2",
237 checks_only: false,
238 done: false,
239 links: &unknown,
240 },
241 StoryTask {
242 id: "T3",
243 checks_only: false,
244 done: false,
245 links: &one,
246 },
247 ];
248 let sc = ids(3);
249 let verified: BTreeSet<String> = ["US3-1".to_owned()].into();
250 assert_eq!(
251 share(&tasks, "T1", &sc, &verified, true),
252 Some(Share::Linked {
253 scenarios: one.clone()
254 })
255 );
256 assert_eq!(
257 share(&tasks, "T2", &sc, &verified, false),
258 Some(Share::Authored {
259 scenarios: sc.clone(),
260 checkpoint: Some("T3".into())
261 })
262 );
263 assert_eq!(
264 share(&tasks, "T3", &sc, &verified, false),
265 Some(Share::Checkpoint {
266 scenarios: ids(3),
267 composition: true
268 })
269 );
270 }
271
272 #[test]
273 fn a_person_s_step_is_never_the_checkpoint_and_a_done_one_hands_it_on() {
274 let mut you = open("T3");
275 you.checks_only = true;
276 let mut done = open("T2");
277 done.done = true;
278 let tasks = [open("T1"), done, you];
279 assert_eq!(
280 checkpoint(&tasks),
281 Some("T1"),
282 "T2 ticked first, T3 is yours"
283 );
284 assert!(matches!(
285 share(&tasks, "T1", &ids(2), &BTreeSet::new(), true),
286 Some(Share::Checkpoint { .. })
287 ));
288 assert_eq!(
289 share(&[open("T1")], "T1", &[], &BTreeSet::new(), false),
290 None,
291 "no scenario, no share"
292 );
293 }
294
295 proptest! {
296 #[test]
301 fn every_scenario_of_a_story_is_judged(
302 n in 1usize..6,
303 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),
304 order in proptest::collection::vec(any::<prop::sample::Index>(), 7),
305 ) {
306 let sc = ids(n);
307 let links: Vec<Vec<String>> = plan.iter().map(|(_, l, _, _)| l.iter().map(|k| format!("US3-{k}")).collect()).collect();
308 let names = ["T1", "T2", "T3", "T4", "T5", "T6"];
309 let mut done = vec![false; plan.len()];
310 let mut remaining: Vec<usize> = (0..plan.len()).collect();
311 let mut verified = BTreeSet::new();
312 let mut judged = BTreeSet::new();
313 let any_judged_task = plan.iter().any(|p| !p.0);
314 let mut k = 0;
315 while !remaining.is_empty() {
316 let i = remaining.remove(order[k % order.len()].index(remaining.len()));
317 k += 1;
318 let tasks: Vec<StoryTask<'_>> = (0..plan.len()).map(|j| StoryTask {
319 id: names[j], checks_only: plan[j].0, done: done[j], links: &links[j],
320 }).collect();
321 let s = share(&tasks, names[i], &sc, &verified, plan[i].2);
322 prop_assert_eq!(&s, &share(&tasks, names[i], &sc, &verified, plan[i].2));
323 if let Some(s) = &s {
324 judged.extend(s.scenarios().iter().cloned());
325 for k in &plan[i].3 {
327 let id = format!("US3-{k}");
328 if s.scenarios().contains(&id) { verified.insert(id); }
329 }
330 }
331 done[i] = true;
332 }
333 if any_judged_task {
334 let all: BTreeSet<String> = sc.iter().cloned().collect();
335 prop_assert_eq!(judged.union(&verified).cloned().collect::<BTreeSet<_>>(), all);
336 }
337 }
338 }
339}