1use std::collections::BTreeMap;
18
19pub const LIST_CAP: usize = 20;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27enum ReviewBucket {
28 ReReviewRequired,
31 ChangesRequested,
33 ApprovedAwaitingLanding,
39 AwaitingVerdicts,
41 Unassigned,
43 ApprovedUnbound,
45 Landed,
47 Archived,
49}
50
51impl ReviewBucket {
52 fn name(self) -> &'static str {
53 match self {
54 Self::ReReviewRequired => "re-review-required",
55 Self::ChangesRequested => "changes-requested",
56 Self::ApprovedAwaitingLanding => "approved-awaiting-landing",
57 Self::AwaitingVerdicts => "awaiting-verdicts",
58 Self::Unassigned => "unassigned",
59 Self::ApprovedUnbound => "approved-unbound",
60 Self::Landed => "landed",
61 Self::Archived => "archived",
62 }
63 }
64}
65
66struct ReviewFacts<'a> {
68 target: Option<&'a str>,
69 target_ref: Option<&'a str>,
70 archived: bool,
71 approved: bool,
72 complete: bool,
73 re_review_required: bool,
74 reviewers: Vec<&'a str>,
75 answered: Vec<&'a str>,
76 changes_requested_by: Vec<&'a str>,
77 viewed: Vec<&'a str>,
79 landed: Option<bool>,
83}
84
85impl<'a> ReviewFacts<'a> {
86 fn read(
87 row: &'a serde_json::Value,
88 refs: Option<&'a serde_json::Map<String, serde_json::Value>>,
89 ) -> Self {
90 let target = row["target"].as_str();
91 let target_ref = row["target_ref"].as_str();
92 let reviewers: Vec<&str> = row["reviewers"]
93 .as_array()
94 .map(|list| list.iter().filter_map(serde_json::Value::as_str).collect())
95 .unwrap_or_default();
96 let verdicts = row["verdicts"].as_object();
97 let answered: Vec<&str> = verdicts
98 .map(|map| map.keys().map(String::as_str).collect())
99 .unwrap_or_default();
100 let changes_requested_by: Vec<&str> = verdicts
101 .map(|map| {
102 map.iter()
103 .filter(|(_, v)| v["verdict"].as_str() == Some("RequestChanges"))
104 .map(|(who, _)| who.as_str())
105 .collect()
106 })
107 .unwrap_or_default();
108 let landed = match (target_ref, target) {
109 (Some(name), Some(commit)) => refs
110 .and_then(|map| map.get(name))
111 .map(|current| current.as_str() == Some(commit)),
112 _ => None,
113 };
114 Self {
115 target,
116 target_ref,
117 archived: row["archived"].as_bool().unwrap_or(false),
118 approved: row["approved"].as_bool().unwrap_or(false),
119 complete: row["complete"].as_bool().unwrap_or(false),
120 re_review_required: row["re_review_required"].as_bool().unwrap_or(false),
121 reviewers,
122 answered,
123 changes_requested_by,
124 viewed: row["viewed"]
125 .as_object()
126 .map(|map| map.keys().map(String::as_str).collect())
127 .unwrap_or_default(),
128 landed,
129 }
130 }
131
132 fn missing(&self) -> Vec<&'a str> {
133 self.reviewers
134 .iter()
135 .filter(|r| !self.answered.contains(r))
136 .copied()
137 .collect()
138 }
139
140 fn bucket(&self) -> ReviewBucket {
141 if self.re_review_required {
142 return ReviewBucket::ReReviewRequired;
143 }
144 if self.landed == Some(true) {
145 return ReviewBucket::Landed;
146 }
147 if self.archived {
148 return ReviewBucket::Archived;
149 }
150 if self.approved {
151 return if self.target_ref.is_none() {
154 ReviewBucket::ApprovedUnbound
155 } else {
156 ReviewBucket::ApprovedAwaitingLanding
157 };
158 }
159 if self.reviewers.is_empty() {
160 return ReviewBucket::Unassigned;
161 }
162 if !self.complete {
163 return ReviewBucket::AwaitingVerdicts;
164 }
165 ReviewBucket::ChangesRequested
166 }
167
168 fn row_json(&self, bucket: ReviewBucket) -> serde_json::Value {
169 serde_json::json!({
170 "bucket": bucket.name(),
171 "target": self.target,
172 "target_ref": self.target_ref,
173 "landed": self.landed,
174 "missing_verdicts": capped_list(&self.missing()),
175 "changes_requested_by": capped_list(&self.changes_requested_by),
176 })
177 }
178}
179
180fn capped_list(items: &[&str]) -> serde_json::Value {
182 serde_json::json!({
183 "items": items.iter().take(LIST_CAP).collect::<Vec<_>>(),
184 "omitted": items.len().saturating_sub(LIST_CAP),
185 })
186}
187
188fn reviews_by_target(
190 reviews: Option<&serde_json::Map<String, serde_json::Value>>,
191) -> BTreeMap<&str, Vec<&str>> {
192 let mut by_target: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
193 for (id, row) in reviews.into_iter().flatten() {
194 if let Some(target) = row["target"].as_str() {
195 by_target.entry(target).or_default().push(id);
196 }
197 }
198 by_target
199}
200
201fn change_row(
204 row: &serde_json::Value,
205 by_target: &BTreeMap<&str, Vec<&str>>,
206) -> (&'static str, Vec<String>) {
207 if row["active_workspace"].is_null() {
208 return ("archived", Vec::new());
209 }
210 let revision = row["revision_id"].as_str().unwrap_or_default();
211 if row["base_revision"].as_str() == Some(revision) {
212 return ("unstarted", Vec::new());
213 }
214 match by_target.get(revision) {
215 Some(ids) => (
216 "in-review",
217 ids.iter().take(LIST_CAP).map(ToString::to_string).collect(),
218 ),
219 None => ("unreviewed", Vec::new()),
220 }
221}
222
223#[must_use]
227pub fn triage(view: &serde_json::Value) -> serde_json::Value {
228 let refs = view["refs"].as_object();
229 let reviews = view["reviews"].as_object();
230 let mut rows: Vec<(ReviewBucket, &str, serde_json::Value)> = reviews
231 .into_iter()
232 .flatten()
233 .map(|(id, row)| {
234 let facts = ReviewFacts::read(row, refs);
235 let bucket = facts.bucket();
236 (bucket, id.as_str(), facts.row_json(bucket))
237 })
238 .collect();
239 rows.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
240
241 let mut bucket_counts: BTreeMap<&str, usize> = BTreeMap::new();
242 for (bucket, _, _) in &rows {
243 *bucket_counts.entry(bucket.name()).or_insert(0) += 1;
244 }
245 let review_total = rows.len();
246 let review_rows: serde_json::Map<String, serde_json::Value> = rows
247 .into_iter()
248 .take(LIST_CAP)
249 .map(|(_, id, row)| (id.to_string(), row))
250 .collect();
251
252 let by_target = reviews_by_target(reviews);
253 let changes = view["changes"].as_object();
254 let mut change_counts: BTreeMap<&str, usize> = BTreeMap::new();
255 let mut change_rows = serde_json::Map::new();
256 let change_total = changes.map_or(0, serde_json::Map::len);
257 for (id, row) in changes.into_iter().flatten() {
258 let (bucket, review_ids) = change_row(row, &by_target);
259 *change_counts.entry(bucket).or_insert(0) += 1;
260 if change_rows.len() < LIST_CAP {
261 change_rows.insert(
262 id.clone(),
263 serde_json::json!({ "bucket": bucket, "reviews": review_ids }),
264 );
265 }
266 }
267
268 serde_json::json!({
269 "format_version": 1,
270 "review_buckets": bucket_counts,
271 "reviews": review_rows,
272 "reviews_omitted": review_total.saturating_sub(LIST_CAP),
273 "change_buckets": change_counts,
274 "changes": change_rows,
275 "changes_omitted": change_total.saturating_sub(LIST_CAP),
276 "refs_visible": refs.is_some(),
279 })
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
287enum ActionKind {
288 AnswerReview,
290 Land,
292 Revise,
294 RequestReview,
296 Checkpoint,
298}
299
300impl ActionKind {
301 fn describe(self) -> (&'static str, bool, bool) {
302 match self {
303 Self::AnswerReview => ("answer-review", true, true),
304 Self::Land => ("land", true, true),
305 Self::Revise => ("revise", true, false),
306 Self::RequestReview => ("request-review", true, true),
307 Self::Checkpoint => ("checkpoint", true, true),
308 }
309 }
310}
311
312fn action_json(
313 kind: ActionKind,
314 subject: (&str, &str),
315 command: String,
316 why: String,
317) -> serde_json::Value {
318 let (name, mutates, needs_network) = kind.describe();
319 let (subject_kind, subject_id) = subject;
320 serde_json::json!({
321 "action": name,
322 subject_kind: subject_id,
323 "command": command,
324 "mutates": mutates,
325 "needs_network": needs_network,
326 "why": why,
327 })
328}
329
330#[must_use]
336pub fn next_actions(view: &serde_json::Value, api: &str, channel: &str) -> serde_json::Value {
337 let refs = view["refs"].as_object();
338 let reviews = view["reviews"].as_object();
339 let mut actions: Vec<(ActionKind, &str, serde_json::Value)> = Vec::new();
340 let mut waiting: Vec<serde_json::Value> = Vec::new();
341
342 for (id, row) in reviews.into_iter().flatten() {
343 let facts = ReviewFacts::read(row, refs);
344 if facts.archived {
345 continue;
346 }
347 if facts.reviewers.contains(&channel) && !facts.answered.contains(&channel) {
348 actions.push((
349 ActionKind::AnswerReview,
350 id,
351 action_json(
352 ActionKind::AnswerReview,
353 ("review", id),
354 format!("choir verdict {api} <key-file> {channel} {id} approve|request-changes [note]"),
355 "assigned to you and unanswered; the review cannot complete without you".into(),
356 ),
357 ));
358 }
359 }
360
361 let by_target = reviews_by_target(reviews);
362 for (change_id, row) in view["changes"].as_object().into_iter().flatten() {
363 if row["owner"].as_str() != Some(channel) || row["active_workspace"].is_null() {
364 continue;
365 }
366 let revision = row["revision_id"].as_str().unwrap_or_default();
367 let workspace = row["workspace_id"].as_str().unwrap_or_default();
368 if row["base_revision"].as_str() == Some(revision) {
369 actions.push((
370 ActionKind::Checkpoint,
371 change_id,
372 action_json(
373 ActionKind::Checkpoint,
374 ("change", change_id),
375 format!("choir checkpoint {api} <key-file> {channel} {change_id} {workspace} <git-oid>"),
376 "no revision published beyond the base; commit and push, then checkpoint".into(),
377 ),
378 ));
379 continue;
380 }
381 let Some(review_ids) = by_target.get(revision) else {
382 actions.push((
383 ActionKind::RequestReview,
384 change_id,
385 action_json(
386 ActionKind::RequestReview,
387 ("change", change_id),
388 format!("choir review {api} <key-file> {channel} <review-id> {revision} [--ref <repo:ref>]"),
389 "latest revision has no review; name no reviewers and the node draws them".into(),
390 ),
391 ));
392 continue;
393 };
394 for id in review_ids {
395 let facts = ReviewFacts::read(&reviews.expect("indexed from reviews")[*id], refs);
396 match facts.bucket() {
397 ReviewBucket::ChangesRequested | ReviewBucket::ReReviewRequired => {
398 actions.push((
399 ActionKind::Revise,
400 change_id,
401 action_json(
402 ActionKind::Revise,
403 ("change", change_id),
404 format!("choir checkpoint {api} <key-file> {channel} {change_id} {workspace} <git-oid>"),
405 format!(
406 "review {id}: changes requested by {:?}; revise, checkpoint, request a fresh review",
407 facts.changes_requested_by
408 ),
409 ),
410 ));
411 }
412 ReviewBucket::ApprovedAwaitingLanding => {
413 let target_ref = facts.target_ref.unwrap_or("<repo:ref>");
414 actions.push((
415 ActionKind::Land,
416 change_id,
417 action_json(
418 ActionKind::Land,
419 ("review", id),
420 format!(
421 "choir submit {api} <key-file> {channel} '{{\"format_version\":1,\"kind\":{{\"SetRef\":{{\"name\":\"{target_ref}\",\"commit\":\"{revision}\",\"prev\":<current-or-null>}}}}}}'"
422 ),
423 "approved and unlanded; check the ref has not moved past work \
424 this commit lacks (the view holds no commit graph) before \
425 landing — a protected ref needs this exact (ref, commit)"
426 .into(),
427 ),
428 ));
429 }
430 ReviewBucket::AwaitingVerdicts => {
431 let missing = facts.missing();
432 let read: Vec<&str> = missing
436 .iter()
437 .filter(|r| facts.viewed.contains(r))
438 .copied()
439 .collect();
440 waiting.push(serde_json::json!({
441 "review": id,
442 "why": format!("awaiting verdicts from {missing:?}"),
443 "read_by": read,
444 }));
445 }
446 ReviewBucket::Unassigned => {
447 waiting.push(serde_json::json!({
448 "review": id,
449 "why": "unassigned; the node's reviewer draw has not filled the list",
450 }));
451 }
452 _ => {}
453 }
454 }
455 }
456
457 actions.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
458 let action_total = actions.len();
459 let waiting_total = waiting.len();
460 serde_json::json!({
461 "format_version": 1,
462 "channel": channel,
463 "log": view["log"],
465 "actions": actions.into_iter().take(LIST_CAP).map(|(_, _, a)| a).collect::<Vec<_>>(),
466 "actions_omitted": action_total.saturating_sub(LIST_CAP),
467 "waiting": waiting.iter().take(LIST_CAP).collect::<Vec<_>>(),
468 "waiting_omitted": waiting_total.saturating_sub(LIST_CAP),
469 "refs_visible": refs.is_some(),
470 })
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn sample_view() -> serde_json::Value {
480 serde_json::json!({
481 "log": { "node": "01-aa", "head": null, "scope_required": false },
482 "refs": { "demo.git:refs/heads/main": "01-c1" },
483 "reviews": {
484 "r-landed": { "target": "01-c1", "target_ref": "demo.git:refs/heads/main",
485 "reviewers": ["ops/ana"], "verdicts": { "ops/ana": { "verdict": "Approve", "note": "" } },
486 "slashes": {}, "complete": true, "approved": true, "approval_weight": 1,
487 "re_review_required": false, "archived": false, "comments": [] },
488 "r-moved": { "target": "01-c0", "target_ref": "demo.git:refs/heads/main",
489 "reviewers": ["ops/ana"], "verdicts": { "ops/ana": { "verdict": "Approve", "note": "" } },
490 "slashes": {}, "complete": true, "approved": true, "approval_weight": 1,
491 "re_review_required": false, "archived": false, "comments": [] },
492 "r-pending": { "target": "01-c2", "target_ref": null,
493 "reviewers": ["ops/ana", "ops/bob"],
494 "verdicts": { "ops/ana": { "verdict": "Approve", "note": "" } },
495 "slashes": {}, "complete": false, "approved": false, "approval_weight": 1,
496 "re_review_required": false, "archived": false, "comments": [] },
497 "r-rejected": { "target": "01-c3", "target_ref": null,
498 "reviewers": ["ops/bob"],
499 "verdicts": { "ops/bob": { "verdict": "RequestChanges", "note": "no" } },
500 "slashes": {}, "complete": true, "approved": false, "approval_weight": 0,
501 "re_review_required": false, "archived": false, "comments": [] },
502 "r-unassigned": { "target": "01-c4", "target_ref": null,
503 "reviewers": [], "verdicts": {}, "slashes": {}, "complete": false,
504 "approved": false, "approval_weight": 0,
505 "re_review_required": false, "archived": false, "comments": [] },
506 },
507 "changes": {
508 "ch-idle": { "owner": "dev/kim", "workspace_id": "ws-1",
509 "active_workspace": "ws-1", "base_revision": "01-b0", "revision_id": "01-b0" },
510 "ch-reviewed": { "owner": "dev/kim", "workspace_id": "ws-2",
511 "active_workspace": "ws-2", "base_revision": "01-b0", "revision_id": "01-c3" },
512 },
513 })
514 }
515
516 #[test]
517 fn buckets_cover_the_sample() {
518 let doc = triage(&sample_view());
519 let bucket = |id: &str| doc["reviews"][id]["bucket"].as_str().unwrap().to_string();
520 assert_eq!(bucket("r-landed"), "landed");
521 assert_eq!(bucket("r-moved"), "approved-awaiting-landing");
522 assert_eq!(bucket("r-pending"), "awaiting-verdicts");
523 assert_eq!(bucket("r-rejected"), "changes-requested");
524 assert_eq!(bucket("r-unassigned"), "unassigned");
525 assert_eq!(doc["review_buckets"]["landed"], 1);
526 assert_eq!(doc["reviews_omitted"], 0);
527 assert_eq!(doc["changes"]["ch-idle"]["bucket"], "unstarted");
528 assert_eq!(doc["changes"]["ch-reviewed"]["bucket"], "in-review");
529 assert_eq!(doc["refs_visible"], true);
530 }
531
532 #[test]
533 fn missing_refs_section_yields_null_landed_not_a_guess() {
534 let mut view = sample_view();
535 view.as_object_mut().unwrap().remove("refs");
536 let doc = triage(&view);
537 assert_eq!(doc["refs_visible"], false);
538 assert!(doc["reviews"]["r-landed"]["landed"].is_null());
539 assert_eq!(
540 doc["reviews"]["r-landed"]["bucket"],
541 "approved-awaiting-landing"
542 );
543 }
544
545 #[test]
546 fn reviewer_owes_a_verdict() {
547 let doc = next_actions(&sample_view(), "http://n", "ops/bob");
548 let kinds: Vec<&str> = doc["actions"]
549 .as_array()
550 .unwrap()
551 .iter()
552 .map(|a| a["action"].as_str().unwrap())
553 .collect();
554 assert_eq!(kinds, ["answer-review"]);
555 assert!(doc["actions"][0]["command"]
556 .as_str()
557 .unwrap()
558 .contains("choir verdict http://n <key-file> ops/bob r-pending"));
559 }
560
561 #[test]
562 fn owner_sees_change_work_ranked() {
563 let doc = next_actions(&sample_view(), "http://n", "dev/kim");
564 let kinds: Vec<&str> = doc["actions"]
565 .as_array()
566 .unwrap()
567 .iter()
568 .map(|a| a["action"].as_str().unwrap())
569 .collect();
570 assert_eq!(kinds, ["revise", "checkpoint"]);
573 assert_eq!(doc["actions_omitted"], 0);
574 }
575
576 #[test]
577 fn boundaries_hold_on_an_empty_view() {
578 let empty = serde_json::json!({});
579 let doc = triage(&empty);
580 assert_eq!(doc["review_buckets"], serde_json::json!({}));
581 assert_eq!(doc["refs_visible"], false);
582 let actions = next_actions(&empty, "http://n", "nobody");
583 assert_eq!(actions["actions"], serde_json::json!([]));
584 assert_eq!(actions["waiting"], serde_json::json!([]));
585 }
586}
587
588#[must_use]
612pub fn funnel(view: &serde_json::Value) -> serde_json::Value {
613 let object = |key: &str| {
614 view.get(key)
615 .and_then(serde_json::Value::as_object)
616 .cloned()
617 .unwrap_or_default()
618 };
619 let bindings = object("bindings");
620 let changes = object("changes");
621 let reviews = object("reviews");
622
623 let admitted: Option<usize> = (!bindings.is_empty()).then(|| {
636 bindings
637 .values()
638 .filter_map(|b| b.get("channel").and_then(serde_json::Value::as_str))
639 .collect::<std::collections::BTreeSet<_>>()
640 .len()
641 });
642
643 let proposing: std::collections::BTreeSet<String> = changes
649 .values()
650 .filter_map(|c| c.get("owner").and_then(serde_json::Value::as_str))
651 .map(ToString::to_string)
652 .collect();
653
654 let checkpointed = changes
655 .values()
656 .filter(|c| c.get("revision_id") != c.get("base_revision"))
657 .count();
658 let (mut answered, mut assigned) = (0usize, 0usize);
659 for review in reviews.values() {
660 let reviewers = review
661 .get("reviewers")
662 .and_then(serde_json::Value::as_array)
663 .map(Vec::as_slice)
664 .unwrap_or_default();
665 if !reviewers.is_empty() {
666 assigned += 1;
667 }
668 if reviewers.iter().any(|who| {
669 who.as_str().is_some_and(|who| {
670 review
671 .get("verdicts")
672 .and_then(|v| v.get(who))
673 .and_then(|v| v.get("verdict"))
674 .is_some()
675 })
676 }) {
677 answered += 1;
678 }
679 }
680
681 let stage = |name: &str, boundary: &str, count: Option<usize>| {
682 serde_json::json!({
683 "stage": name,
684 "boundary": boundary,
685 "count": count,
686 })
687 };
688 let proposing = proposing.len();
689 serde_json::json!({
690 "stages": [
691 stage(
692 "S0 land",
693 "read a page before asking for an invite (not in the view)",
694 None::<usize>,
695 ),
696 stage(
697 "S1 admit",
698 "actor key bound to a channel by a BindKey op; null on a node whose keys are \
699 registered in the operator's trusted-keys file, which the view cannot see",
700 admitted,
701 ),
702 stage("S2 equip", "opened at least one change", Some(proposing)),
703 stage("S3 propose", "change published a revision past its base", Some(checkpointed)),
704 stage("S4 review", "review has drawn reviewers", Some(assigned)),
705 stage("S5 verdict", "at least one drawn reviewer answered", Some(answered)),
706 ],
707 "largest_fall": largest_fall(&[
711 ("S1 admit -> S2 equip", admitted, Some(proposing)),
712 ("S2 equip -> S3 propose", Some(proposing), Some(checkpointed)),
713 ("S3 propose -> S4 review", Some(checkpointed), Some(assigned)),
714 ("S4 review -> S5 verdict", Some(assigned), Some(answered)),
715 ]),
716 "note": "counts are of what this credential may read (D29); S0 is not in the view \
717 and is reported as null rather than zero",
718 })
719}
720
721fn largest_fall(transitions: &[(&str, Option<usize>, Option<usize>)]) -> serde_json::Value {
729 let worst = transitions
734 .iter()
735 .filter_map(|(name, from, to)| Some((name, (*from)?, (*to)?)))
736 .filter(|(_, from, _)| *from > 0)
737 .max_by_key(|(_, from, to)| from.saturating_sub(*to));
738 match worst {
739 Some((name, from, to)) if from > to => serde_json::json!({
740 "transition": name,
741 "from": from,
742 "to": to,
743 "lost": from - to,
744 }),
745 _ => serde_json::Value::Null,
746 }
747}
748
749#[cfg(test)]
750mod funnel_tests {
751 use super::funnel;
752
753 fn view() -> serde_json::Value {
754 serde_json::json!({
755 "bindings": {
756 "k1": { "channel": "ana/agent" },
757 "k2": { "channel": "bo/agent" },
758 "k3": { "channel": "cy/agent" },
759 },
760 "changes": {
761 "c1": { "owner": "ana/agent", "base_revision": "11-aa", "revision_id": "11-bb" },
762 "c2": { "owner": "bo/agent", "base_revision": "11-cc", "revision_id": "11-cc" },
763 },
764 "reviews": {
765 "c1": { "reviewers": ["bo/agent"], "verdicts": { "bo/agent": { "verdict": "Approve" } } },
766 "c2": { "reviewers": [], "verdicts": {} },
767 },
768 })
769 }
770
771 fn count(doc: &serde_json::Value, stage: &str) -> serde_json::Value {
772 doc["stages"]
773 .as_array()
774 .expect("stages")
775 .iter()
776 .find(|s| s["stage"] == stage)
777 .expect("named stage")["count"]
778 .clone()
779 }
780
781 #[test]
782 fn counts_each_stage_from_the_view() {
783 let doc = funnel(&view());
784 assert_eq!(count(&doc, "S1 admit"), 3);
785 assert_eq!(count(&doc, "S2 equip"), 2);
786 assert_eq!(count(&doc, "S3 propose"), 1);
788 assert_eq!(count(&doc, "S4 review"), 1);
789 assert_eq!(count(&doc, "S5 verdict"), 1);
790 }
791
792 #[test]
793 fn s0_is_null_not_zero() {
794 assert!(count(&funnel(&view()), "S0 land").is_null());
796 }
797
798 #[test]
799 fn names_the_steepest_drop() {
800 let doc = funnel(&view());
801 let fall = &doc["largest_fall"];
804 assert_eq!(fall["lost"], 1);
805 assert!(fall["transition"].as_str().is_some());
806 }
807
808 #[test]
809 fn a_file_registered_node_reports_admission_as_null_not_zero() {
810 let file_registered = serde_json::json!({
815 "bindings": {},
816 "changes": {
817 "c1": { "owner": "ana/agent", "base_revision": "11-aa", "revision_id": "11-bb" }
818 },
819 "reviews": {
820 "c1": { "reviewers": ["bo/agent"], "verdicts": {} }
821 },
822 });
823 let doc = funnel(&file_registered);
824 assert!(
825 count(&doc, "S1 admit").is_null(),
826 "an unmeasurable stage was reported as zero"
827 );
828 assert_eq!(count(&doc, "S2 equip"), 1);
829 assert_eq!(doc["largest_fall"]["transition"], "S4 review -> S5 verdict");
832 }
833
834 #[test]
835 fn an_empty_node_names_no_fall() {
836 let empty = serde_json::json!({});
838 assert!(funnel(&empty)["largest_fall"].is_null());
839 assert!(count(&funnel(&empty), "S1 admit").is_null());
840 assert_eq!(count(&funnel(&empty), "S2 equip"), 0);
841 }
842
843 #[test]
844 fn a_stage_that_did_not_lose_anyone_is_not_reported_as_a_fall() {
845 let perfect = serde_json::json!({
846 "bindings": { "k1": { "channel": "ana/agent" } },
847 "changes": {
848 "c1": { "owner": "ana/agent", "base_revision": "11-aa", "revision_id": "11-bb" }
849 },
850 "reviews": {
851 "c1": { "reviewers": ["bo/agent"], "verdicts": { "bo/agent": { "verdict": "Approve" } } }
852 },
853 });
854 assert!(funnel(&perfect)["largest_fall"].is_null());
855 }
856}