1use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::MergeMode;
16use crate::run::{CommandOutcome, RunState, RunStatus, tail};
17use crate::stats::Stats;
18use crate::verdict::ReviewVote;
19
20static COLOR: AtomicBool = AtomicBool::new(true);
21
22pub fn set_color(on: bool) {
24 COLOR.store(on, Ordering::Relaxed);
25}
26
27fn paint(text: &str, code: &str) -> String {
28 if COLOR.load(Ordering::Relaxed) {
29 format!("\x1b[{code}m{text}\x1b[0m")
30 } else {
31 text.to_owned()
32 }
33}
34
35fn bold(t: &str) -> String {
36 paint(t, "1")
37}
38fn dim(t: &str) -> String {
39 paint(t, "2")
40}
41fn red(t: &str) -> String {
42 paint(t, "31")
43}
44fn green(t: &str) -> String {
45 paint(t, "32")
46}
47fn yellow(t: &str) -> String {
48 paint(t, "33")
49}
50fn cyan(t: &str) -> String {
51 paint(t, "36")
52}
53
54fn status_word(status: RunStatus) -> String {
59 let text = format!("{status:?}").to_lowercase();
60 match status {
61 RunStatus::Merged => bold(&green(&text)),
62 RunStatus::Ready => green(&text),
63 RunStatus::Stalled => bold(&yellow(&text)),
64 RunStatus::Blocked => yellow(&text),
65 RunStatus::Failed => red(&text),
66 _ => cyan(&text),
67 }
68}
69
70fn vote_tag(vote: ReviewVote) -> String {
74 let text = vote.label();
75 match vote {
76 ReviewVote::Approve => green(text),
77 ReviewVote::ApproveWithFindings => yellow(text),
78 ReviewVote::Reject => red(text),
79 }
80}
81
82pub fn line(state: &RunState) -> String {
84 let winner = state
85 .tally
86 .as_ref()
87 .map_or("-".to_owned(), |t| t.winner.to_string());
88 let agent = state.winner().map_or("-", |c| c.agent.as_str());
89 let quorum = match state.tally.as_ref() {
92 Some(t) if !t.met_quorum => format!(
93 " {}",
94 bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
95 ),
96 Some(t) if t.present > 0 && t.present < t.judges => format!(
97 " {}",
98 yellow(&format!("judges {}/{}", t.present, t.judges))
99 ),
100 _ => String::new(),
101 };
102 format!(
103 "{} {:<20} {:>2}c {:>2}j win {} ({}){quorum} {}",
104 dim(&state.id),
105 status_word(state.status),
106 state.candidates.len(),
107 state.judgements.len(),
108 winner,
109 agent,
110 first_line(&state.instruction)
111 )
112}
113
114fn first_line(text: &str) -> String {
115 let line = text.lines().next().unwrap_or_default();
116 if line.chars().count() > 68 {
117 format!("{}…", line.chars().take(67).collect::<String>())
118 } else {
119 line.to_owned()
120 }
121}
122
123fn short(commit: &str) -> String {
124 commit.chars().take(7).collect()
125}
126
127pub fn run(state: &RunState) -> String {
129 let mut s = String::new();
130 let _ = writeln!(
131 s,
132 "{} {} {}",
133 bold("magi run"),
134 bold(&state.id),
135 status_word(state.status)
136 );
137 let _ = writeln!(
138 s,
139 " repo {} ({} @ {})",
140 state.repo.display(),
141 state.base_branch,
142 short(&state.base_commit)
143 );
144 let _ = writeln!(s, " created {}", state.created_local());
145 let _ = writeln!(s, " task {}", first_line(&state.instruction));
146 let _ = writeln!(s, " state {}", state.dir().display());
147
148 let _ = writeln!(s, "\n{}", bold("candidates"));
149 for c in &state.candidates {
150 let flag = match (&c.failed, c.empty) {
151 (Some(e), _) => red(&format!("failed: {e}")),
152 (None, true) => yellow("no change"),
153 _ => format!("{} files, {} commits", c.files, c.commits),
154 };
155 let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
156 bold(&green(" <- winner"))
157 } else {
158 String::new()
159 };
160 let _ = writeln!(
161 s,
162 " {} {:<12} {:<30} {:>5}s{}",
163 bold(&c.label.to_string()),
164 c.agent,
165 flag,
166 c.duration_ms / 1000,
167 crown
168 );
169 }
170
171 if !state.judgements.is_empty() {
172 let _ = writeln!(s, "\n{}", bold("blind judging"));
173 for j in &state.judgements {
174 match &j.failed {
175 Some(e) => {
176 let _ = writeln!(
177 s,
178 " judge {} {}",
179 j.judge,
180 red(&format!("no ranking: {e}"))
181 );
182 }
183 None => {
184 let _ = writeln!(
185 s,
186 " judge {} {:<12} {} confidence {}",
187 j.judge,
188 j.agent,
189 bold(&j.ranking.iter().collect::<String>()),
190 j.confidence.map_or("-".to_owned(), |c| c.to_string())
191 );
192 }
193 }
194 }
195 }
196
197 if let Some(t) = &state.tally {
198 if t.deliberated {
199 let _ = writeln!(s, "\n{}", bold("deliberation"));
200 for round in &state.deliberation {
201 for turn in &round.turns {
202 let _ = writeln!(
203 s,
204 " r{} judge {} -> {}",
205 round.round,
206 turn.judge,
207 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
208 );
209 }
210 }
211 }
212
213 if !state.votes.is_empty() {
214 let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
215 for v in &state.votes {
216 let _ = writeln!(
217 s,
218 " judge {} {:<12} {}{}",
219 v.judge,
220 v.agent,
221 bold(&v.vote.unwrap_or('?').to_string()),
222 if v.changed {
223 yellow(" (changed after deliberation)")
224 } else {
225 String::new()
226 }
227 );
228 }
229 }
230
231 let _ = writeln!(s, "\n{}", bold("tally"));
232 match &t.uncontested {
238 Some(reason) => {
239 let _ = writeln!(
240 s,
241 " judging {}",
242 cyan(&format!("not needed — {reason}"))
243 );
244 }
245 None => {
246 let _ = writeln!(
247 s,
248 " judges {} present{}",
249 if t.met_quorum {
250 green(&format!("{}/{}", t.present, t.judges))
251 } else {
252 red(&format!("{}/{}", t.present, t.judges))
253 },
254 if t.quorum > 0 {
255 format!(" ({quorum} required)", quorum = t.quorum)
256 } else {
257 String::new()
258 }
259 );
260 if !t.met_quorum {
261 let _ = writeln!(
262 s,
263 " {}",
264 bold(&red("BELOW QUORUM — verdict is not trustworthy"))
265 );
266 }
267 let _ = writeln!(
268 s,
269 " first choice {}",
270 t.first_choice
271 .iter()
272 .map(|(k, v)| format!("{k}:{v}"))
273 .collect::<Vec<_>>()
274 .join(" ")
275 );
276 let _ = writeln!(
277 s,
278 " initial {}",
279 match (t.rankings, t.unanimous_initial) {
280 (0, _) => red("no usable ranking"),
281 (1, _) => yellow("one usable ranking - not a consensus"),
282 (_, true) => green("unanimous"),
283 (_, false) => yellow("split"),
284 }
285 );
286 let _ = writeln!(
287 s,
288 " after votes {} ({} judge(s) moved)",
289 if t.unanimous_final {
290 green("unanimous")
291 } else {
292 yellow("still split")
293 },
294 t.changed_votes
295 );
296 if let Some(tb) = &t.tie_break {
297 let _ = writeln!(s, " tie break {tb}");
298 }
299 }
300 }
301 if !state.quota.is_empty() {
302 let _ = writeln!(
303 s,
304 " rate limited {}",
305 state
306 .quota
307 .iter()
308 .map(|q| q.seat.as_str())
309 .collect::<Vec<_>>()
310 .join(", ")
311 );
312 }
313 let _ = writeln!(s, " winner {}", bold(&green(&t.winner.to_string())));
314 }
315
316 if !state.reviews.is_empty() {
317 let _ = writeln!(s, "\n{}", bold("review + verification"));
318 for r in &state.reviews {
319 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
320 let e2e = if r.e2e.is_empty() {
324 dim("no e2e")
325 } else if r.e2e.iter().all(|o| o.ok()) {
326 green("e2e green")
327 } else if r.e2e.iter().any(CommandOutcome::build_failed) {
328 yellow("e2e could not run (build/link failure)")
329 } else {
330 red("e2e RED")
331 };
332 let e2e = if r.verify_retried {
333 format!("{e2e}, retried once")
334 } else {
335 e2e
336 };
337 let status = if r.incomplete() {
341 yellow("incomplete")
342 } else if r.clean {
343 green("clean")
344 } else {
345 yellow("open")
346 };
347 let panel = if r.incomplete() {
351 let missing: Vec<String> = r
352 .reviews
353 .iter()
354 .filter_map(|x| {
355 x.failed
356 .as_ref()
357 .map(|why| format!("review-{}: {why}", x.reviewer))
358 })
359 .collect();
360 format!(
361 " {}/{} reviewers answered ({})",
362 r.answered,
363 r.expected,
364 missing.join(", ")
365 )
366 } else {
367 String::new()
368 };
369 let verdict = r.verdict.map_or(String::new(), |v| {
375 format!(
376 ", verdict {}{}",
377 vote_tag(v),
378 if r.vote_split { " (panel split)" } else { "" }
379 )
380 });
381 let _ = writeln!(
382 s,
383 " round {} {} @ {}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
384 r.round,
385 status,
386 short(&r.head),
387 r.blocking,
388 r.fix.as_ref().map_or(String::new(), |f| {
389 let tree = if r.progressed {
390 green("changed")
391 } else {
392 yellow("unchanged")
393 };
394 match &f.failed {
395 Some(reason) => format!(
400 " fix: {}, tree {tree}{}",
401 yellow(&format!("adoption report lost ({reason})")),
402 if f.committed {
403 String::new()
404 } else {
405 red(" (NO COMMIT)")
406 }
407 ),
408 None => format!(
409 " fix: {} addressed / {} rejected, tree {tree}{}",
410 f.addressed.len(),
411 f.rejected.len(),
412 if f.committed {
413 String::new()
414 } else {
415 red(" (NO COMMIT)")
416 }
417 ),
418 }
419 })
420 );
421 for rec in &r.reviews {
422 if let Some(vote) = rec.vote {
423 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
424 }
425 for f in &rec.findings {
426 let adopted = r
427 .fix
428 .as_ref()
429 .is_some_and(|fix| fix.addressed.contains(&f.id));
430 let _ = writeln!(
431 s,
432 " {} [{:?}] {}{}",
433 dim(&f.id),
434 f.severity,
435 f.title,
436 if adopted {
437 green(" fixed")
438 } else {
439 String::new()
440 }
441 );
442 }
443 }
444 if let Some(fix) = &r.fix {
445 for rej in &fix.rejected {
446 let _ = writeln!(
447 s,
448 " {} {}: {}",
449 dim(&rej.id),
450 yellow("declined"),
451 rej.why
452 );
453 }
454 }
455 if !r.reconsideration.is_empty() {
459 let _ = writeln!(s, " {}", dim("reconsideration:"));
460 for rv in &r.reconsideration {
461 match rv.vote {
462 Some(v) => {
463 let _ = writeln!(
464 s,
465 " review-{} -> {} {}",
466 rv.reviewer,
467 vote_tag(v),
468 rv.reason
469 );
470 }
471 None => {
472 let _ = writeln!(
473 s,
474 " review-{} -> {}",
475 rv.reviewer,
476 red(&format!(
477 "no revote ({})",
478 rv.failed.as_deref().unwrap_or("unknown")
479 ))
480 );
481 }
482 }
483 }
484 }
485 }
486 if state.handed_off_with_open_findings() {
487 let _ = writeln!(
488 s,
489 "\n {}",
490 yellow(&format!(
491 "handed off with {} finding(s) still open — gate and e2e were green; \
492 see above for what a person should still look at",
493 state.open_findings().len()
494 ))
495 );
496 }
497 }
498
499 if let Some(bs) = &state.base_sync {
500 let _ = writeln!(s, "\n{}", bold("base sync"));
501 let status = if let Some(c) = &bs.conflict {
502 red(&format!("conflict: {}", first_line(c)))
503 } else if bs.behind == 0 {
504 green("in sync")
505 } else {
506 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
507 };
508 let _ = writeln!(
509 s,
510 " {} @ {} {status}{}",
511 state.base_branch,
512 short(&bs.tip),
513 if bs.attempts > 0 {
514 format!(" ({} rebase attempt(s))", bs.attempts)
515 } else {
516 String::new()
517 }
518 );
519 }
520
521 if !state.gate.is_empty() {
522 let _ = writeln!(s, "\n{}", bold("gate"));
523 for o in &state.gate {
524 let _ = writeln!(
525 s,
526 " {} {}",
527 if o.ok() { green("pass") } else { red("FAIL") },
528 o.command
529 );
530 if !o.ok() {
531 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
532 }
533 }
534 }
535
536 if let Some(m) = &state.merge {
537 let _ = writeln!(s, "\n{}", bold("merge"));
538 if m.mode == MergeMode::None {
539 let _ = writeln!(
543 s,
544 " mode None {}",
545 cyan("not landed — nothing to do by design")
546 );
547 if let Some(w) = state.winner() {
548 let _ = writeln!(
549 s,
550 " branch {} still exists, unmerged into {}",
551 w.branch, state.base_branch
552 );
553 }
554 let _ = writeln!(
555 s,
556 " rebase onto {} before merging by hand, and pass an explicit \
557 commit message — a squash merge otherwise inherits the \
558 candidate's placeholder subject",
559 state.base_branch
560 );
561 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
562 } else {
563 let _ = writeln!(
564 s,
565 " mode {:?} {}\n {}",
566 m.mode,
567 if m.ok {
568 green("ok")
569 } else {
570 yellow("not merged")
571 },
572 m.detail.lines().next().unwrap_or("")
573 );
574 }
575 }
576
577 if !state.leaks.is_empty() {
578 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
579 for l in &state.leaks {
580 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
581 }
582 }
583
584 if let Some(w) = state.winner()
585 && !w.folded
586 {
587 let _ = writeln!(
588 s,
589 "\n{} {}\n branch {}",
590 bold("winner worktree"),
591 w.worktree.display(),
592 w.branch
593 );
594 }
595 s
596}
597
598pub fn active_seats(state: &RunState, live: bool) -> String {
613 if state.active.is_empty() {
614 return String::new();
615 }
616 let mut s = String::new();
617 let _ = writeln!(s, "\n{}", bold("running now"));
618 if !live {
619 let _ = writeln!(
620 s,
621 " {}",
622 yellow(
623 "no live daemon claims this run right now — likely left behind by a killed process"
624 )
625 );
626 }
627 let now = jiff::Timestamp::now();
628 for (seat, a) in &state.active {
629 let retry = if a.attempt > 0 {
630 format!(" retry {}", a.attempt)
631 } else {
632 String::new()
633 };
634 let _ = writeln!(
635 s,
636 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
637 seat,
638 a.node,
639 a.elapsed_secs(now),
640 a.remaining_secs(now),
641 a.timeout_secs
642 );
643 }
644 s
645}
646
647pub fn stats(stats: &Stats) -> String {
649 let t = &stats.totals;
650 let mut s = String::new();
651 let _ = writeln!(s, "{}", bold("runs"));
652 let _ = writeln!(
653 s,
654 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
655 t.runs,
656 t.merged,
657 t.ready,
658 t.blocked,
659 t.failed,
660 t.completion_rate()
661 );
662 if t.tallied > 0 {
663 let _ = writeln!(
664 s,
665 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
666 {} of those changed a mind, {} converged to unanimous",
667 t.tallied,
668 t.split,
669 t.split_rate(),
670 t.deliberated,
671 t.minds_changed,
672 t.converged
673 );
674 }
675
676 if !stats.agents.is_empty() {
677 let _ = writeln!(
678 s,
679 "\n{}",
680 bold("implementation (relative, on this workload)")
681 );
682 let _ = writeln!(
683 s,
684 " {:<14}{:>6}{:>8}{:>8}{:>8}",
685 "agent", "won", "entered", "rate", "empty"
686 );
687 for a in &stats.agents {
688 let _ = writeln!(
689 s,
690 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
691 a.agent,
692 a.wins,
693 a.entered,
694 a.win_rate(),
695 a.empty
696 );
697 }
698 }
699
700 if !stats.reviewers.is_empty() {
701 let _ = writeln!(s, "\n{}", bold("review"));
702 let _ = writeln!(
703 s,
704 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
705 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
706 );
707 for r in &stats.reviewers {
708 let _ = writeln!(
709 s,
710 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
711 r.agent,
712 r.rounds,
713 r.submitted,
714 r.adopted_per_round(),
715 r.precision(),
716 r.unique_rate(),
717 r.timeout_rate()
718 );
719 }
720 }
721
722 if stats.e2e.rounds > 0 {
723 let _ = writeln!(s, "\n{}", bold("verification"));
724 let _ = writeln!(
725 s,
726 " {} rounds ran e2e, {} failed, {} of those with a clean static \
727 review ({:.0}% sole detections)",
728 stats.e2e.rounds,
729 stats.e2e.failures,
730 stats.e2e.sole_detections,
731 stats.e2e.sole_rate()
732 );
733 }
734 s
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use crate::config::Config;
741 use crate::run::{
742 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
743 Tally,
744 };
745 use std::collections::BTreeMap;
746 use std::path::PathBuf;
747 use std::sync::{Mutex, MutexGuard};
748
749 static SERIAL: Mutex<()> = Mutex::new(());
751
752 fn plain() -> MutexGuard<'static, ()> {
753 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
754 set_color(false);
755 guard
756 }
757
758 fn state() -> RunState {
759 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
764 let mut s = RunState::new(
765 PathBuf::from("/repo"),
766 "main".to_owned(),
767 "abcdef1234".to_owned(),
768 "add retries to the uploader".to_owned(),
769 Config::default(),
770 );
771 s.candidates = vec![Candidate {
772 index: 0,
773 label: 'A',
774 agent: "opus".to_owned(),
775 branch: "magi/x/A".to_owned(),
776 worktree: PathBuf::from("/wt/A"),
777 summary: String::new(),
778 stat: String::new(),
779 files: 3,
780 commits: 2,
781 empty: false,
782 failed: None,
783 duration_ms: 42_000,
784 folded: false,
785 }];
786 s.tally = Some(Tally {
787 first_choice: BTreeMap::from([('A', 3)]),
788 borda: BTreeMap::new(),
789 winner: 'A',
790 rankings: 3,
791 unanimous_initial: true,
792 deliberated: false,
793 changed_votes: 0,
794 unanimous_final: true,
795 tie_break: None,
796 judges: 3,
797 present: 3,
798 quorum: 2,
799 met_quorum: true,
800 uncontested: None,
801 });
802 s
803 }
804
805 #[test]
806 fn run_report_names_the_winner_and_its_author() {
807 let _guard = plain();
808 let text = run(&state());
809 assert!(text.contains("<- winner"), "{text}");
810 assert!(text.contains("opus"));
811 assert!(text.contains("3 files, 2 commits"));
812 assert!(text.contains("winner A"));
813 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
814 }
815
816 #[test]
817 fn colour_is_emitted_only_when_enabled() {
818 let _guard = plain();
819 set_color(true);
820 let coloured = run(&state());
821 set_color(false);
822 let plain = run(&state());
823 assert!(coloured.contains('\x1b'));
824 assert!(!plain.contains('\x1b'));
825 assert!(coloured.len() > plain.len());
826 }
827
828 #[test]
829 fn list_line_is_single_line() {
830 let _guard = plain();
831 let l = line(&state());
832 assert_eq!(l.lines().count(), 1);
833 assert!(l.contains("add retries"));
834 assert!(l.contains("win A (opus)"));
835 }
836
837 #[test]
838 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
839 let _guard = plain();
840 let mut s = state();
841 s.tally = Some(Tally {
842 first_choice: BTreeMap::from([('A', 0)]),
843 borda: BTreeMap::new(),
844 winner: 'A',
845 rankings: 0,
846 unanimous_initial: false,
847 deliberated: false,
848 changed_votes: 0,
849 unanimous_final: false,
850 tie_break: None,
851 judges: 0,
852 present: 0,
853 quorum: 0,
854 met_quorum: true,
855 uncontested: Some(
856 "only candidate A produced a usable change; no panel was asked".to_owned(),
857 ),
858 });
859 let text = run(&s);
860 assert!(
861 !text.contains("0/3"),
862 "no panel sat, so the judges line must not read as one that collapsed: {text}"
863 );
864 assert!(!text.contains("no usable ranking"), "{text}");
865 assert!(!text.contains("still split"), "{text}");
866 assert!(!text.contains("BELOW QUORUM"), "{text}");
867 assert!(
868 text.contains("not needed"),
869 "the report must say judging was skipped, not silent: {text}"
870 );
871 assert!(text.contains("winner A"));
872 }
873
874 #[test]
875 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
876 let _guard = plain();
877 let mut s = state();
878 s.tally = Some(Tally {
879 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
880 borda: BTreeMap::new(),
881 winner: 'A',
882 rankings: 1,
883 unanimous_initial: false,
884 deliberated: false,
885 changed_votes: 0,
886 unanimous_final: false,
887 tie_break: None,
888 judges: 3,
889 present: 1,
890 quorum: 2,
891 met_quorum: false,
892 uncontested: None,
893 });
894 let text = run(&s);
895 assert!(text.contains("1/3"), "{text}");
896 assert!(
897 text.contains("BELOW QUORUM"),
898 "a real collapse must still be flagged: {text}"
899 );
900 assert!(
901 !text.contains("not needed"),
902 "a collapsed panel must not be described as one that was never asked: {text}"
903 );
904 }
905
906 #[test]
907 fn a_mode_none_merge_does_not_read_as_landed() {
908 let _guard = plain();
909 let mut s = state();
910 s.merge = Some(MergeOutcome {
911 mode: crate::config::MergeMode::None,
912 ok: true,
913 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
914 });
915 let text = run(&s);
916 assert!(
917 !text.contains(" ok"),
918 "mode none must not be shown as a landed merge: {text}"
919 );
920 assert!(text.contains("not landed"), "{text}");
921 assert!(
922 text.contains("branch magi/x/A"),
923 "the report must say what's left behind: {text}"
924 );
925 assert!(
926 text.contains("rebase"),
927 "the report must point at the hand-landing steps: {text}"
928 );
929 }
930
931 #[test]
932 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
933 let _guard = plain();
934 let mut s = state();
935 s.tally = Some(Tally {
936 first_choice: BTreeMap::from([('A', 0)]),
937 borda: BTreeMap::new(),
938 winner: 'A',
939 rankings: 0,
940 unanimous_initial: false,
941 deliberated: false,
942 changed_votes: 0,
943 unanimous_final: false,
944 tie_break: None,
945 judges: 0,
946 present: 0,
947 quorum: 0,
948 met_quorum: true,
949 uncontested: Some("only candidate A produced a usable change".to_owned()),
950 });
951 let l = line(&s);
952 assert!(
953 !l.contains("judges") && !l.contains("quorum"),
954 "an uncontested run must not carry the same badge a short panel gets: {l}"
955 );
956 }
957
958 #[test]
959 fn long_instructions_are_elided() {
960 let _guard = plain();
961 let mut s = state();
962 s.instruction = "x".repeat(200);
963 assert!(line(&s).contains('…'));
964 }
965
966 #[test]
967 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
968 let _guard = plain();
969 let mut lost = state();
970 lost.reviews = vec![ReviewRound {
971 round: 1,
972 head: "abc1234".to_owned(),
973 reviews: Vec::new(),
974 e2e: Vec::new(),
975 verify_retried: false,
976 fix: Some(FixRecord {
977 agent: "opus".to_owned(),
978 addressed: Vec::new(),
979 rejected: Vec::new(),
980 notes: String::new(),
981 committed: true,
982 failed: Some("timed out".to_owned()),
983 duration_ms: 0,
984 }),
985 blocking: 3,
986 answered: 0,
987 expected: 0,
988 clean: false,
989 progressed: false,
990 vote_split: false,
991 reconsideration: Vec::new(),
992 verdict: None,
993 }];
994 let text = run(&lost);
995 assert!(text.contains("adoption report lost (timed out)"), "{text}");
996 assert!(
997 !text.contains("0 addressed"),
998 "a lost report must never read as `0 addressed`: {text}"
999 );
1000
1001 let mut rejected_all = state();
1002 rejected_all.reviews = vec![ReviewRound {
1003 round: 1,
1004 head: "abc1234".to_owned(),
1005 reviews: Vec::new(),
1006 e2e: Vec::new(),
1007 verify_retried: false,
1008 fix: Some(FixRecord {
1009 agent: "opus".to_owned(),
1010 addressed: Vec::new(),
1011 rejected: Vec::new(),
1012 notes: String::new(),
1013 committed: true,
1014 failed: None,
1015 duration_ms: 0,
1016 }),
1017 blocking: 3,
1018 answered: 0,
1019 expected: 0,
1020 clean: false,
1021 progressed: false,
1022 vote_split: false,
1023 reconsideration: Vec::new(),
1024 verdict: None,
1025 }];
1026 let text2 = run(&rejected_all);
1027 assert!(
1028 text2.contains("0 addressed / 0 rejected"),
1029 "a round the fixer actually reported on keeps the count: {text2}"
1030 );
1031 }
1032
1033 #[test]
1034 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1035 use crate::run::ReviewRevoteRecord;
1036 use crate::verdict::ReviewVote;
1037
1038 let _guard = plain();
1039 let mut s = state();
1040 s.reviews = vec![ReviewRound {
1041 round: 1,
1042 head: "abc1234".to_owned(),
1043 reviews: vec![
1044 ReviewRecord {
1045 reviewer: 1,
1046 agent: "alpha".to_owned(),
1047 summary: String::new(),
1048 findings: Vec::new(),
1049 vote: Some(ReviewVote::Approve),
1050 failed: None,
1051 duration_ms: 0,
1052 },
1053 ReviewRecord {
1054 reviewer: 2,
1055 agent: "beta".to_owned(),
1056 summary: String::new(),
1057 findings: Vec::new(),
1058 vote: Some(ReviewVote::Reject),
1059 failed: None,
1060 duration_ms: 0,
1061 },
1062 ],
1063 e2e: Vec::new(),
1064 verify_retried: false,
1065 fix: None,
1066 blocking: 0,
1067 answered: 2,
1068 expected: 2,
1069 clean: false,
1070 progressed: false,
1071 vote_split: true,
1072 reconsideration: vec![ReviewRevoteRecord {
1073 reviewer: 2,
1074 agent: "beta".to_owned(),
1075 vote: Some(ReviewVote::ApproveWithFindings),
1076 reason: "the other seat's read holds up".to_owned(),
1077 failed: None,
1078 }],
1079 verdict: Some(ReviewVote::ApproveWithFindings),
1080 }];
1081 let text = run(&s);
1082 assert!(text.contains("review-1 vote"), "{text}");
1083 assert!(text.contains("review-2 vote"), "{text}");
1084 assert!(text.contains("panel split"), "{text}");
1085 assert!(text.contains("reconsideration"), "{text}");
1086 assert!(text.contains("the other seat's read holds up"), "{text}");
1087 }
1088
1089 #[test]
1090 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1091 let _guard = plain();
1097 let mut s = state();
1098 s.reviews = vec![ReviewRound {
1099 round: 1,
1100 head: "abc1234".to_owned(),
1101 reviews: vec![
1102 ReviewRecord {
1103 reviewer: 1,
1104 agent: "alpha".to_owned(),
1105 summary: String::new(),
1106 findings: Vec::new(),
1107 vote: None,
1108 failed: None,
1109 duration_ms: 0,
1110 },
1111 ReviewRecord {
1112 reviewer: 2,
1113 agent: "beta".to_owned(),
1114 summary: String::new(),
1115 findings: Vec::new(),
1116 vote: None,
1117 failed: Some("agent timed out".to_owned()),
1118 duration_ms: 0,
1119 },
1120 ],
1121 e2e: Vec::new(),
1122 verify_retried: false,
1123 fix: Some(FixRecord {
1124 agent: "opus".to_owned(),
1125 addressed: Vec::new(),
1126 rejected: Vec::new(),
1127 notes: String::new(),
1128 committed: true,
1129 failed: Some("timed out".to_owned()),
1130 duration_ms: 0,
1131 }),
1132 blocking: 0,
1133 answered: 1,
1134 expected: 2,
1135 clean: false,
1136 progressed: true,
1137 vote_split: false,
1138 reconsideration: Vec::new(),
1139 verdict: None,
1140 }];
1141 let text = run(&s);
1142 assert!(text.contains("incomplete"), "{text}");
1143 assert!(text.contains("1/2 reviewers answered"), "{text}");
1144 assert!(text.contains("review-2: agent timed out"), "{text}");
1145 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1146 assert!(
1147 !text.contains("clean"),
1148 "a round missing half its panel must never render as clean: {text}"
1149 );
1150 }
1151
1152 #[test]
1153 fn a_build_failure_is_not_reported_as_a_test_failure() {
1154 let _guard = plain();
1155 let mut s = state();
1156 s.reviews = vec![ReviewRound {
1157 round: 1,
1158 head: "abc1234".to_owned(),
1159 reviews: Vec::new(),
1160 e2e: vec![CommandOutcome {
1161 command: "cargo test".to_owned(),
1162 code: Some(1),
1163 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1164 duration_ms: 100,
1165 }],
1166 verify_retried: true,
1167 fix: None,
1168 blocking: 0,
1169 answered: 0,
1170 expected: 0,
1171 clean: false,
1172 progressed: false,
1173 vote_split: false,
1174 reconsideration: Vec::new(),
1175 verdict: None,
1176 }];
1177 let text = run(&s);
1178 assert!(text.contains("could not run"), "{text}");
1179 assert!(text.contains("retried once"), "{text}");
1180 assert!(!text.contains("e2e RED"), "{text}");
1181 }
1182
1183 #[test]
1184 fn a_declined_finding_shows_its_reason() {
1185 use crate::verdict::{Finding, Rejection, Severity};
1186
1187 let _guard = plain();
1188 let mut s = state();
1189 s.status = RunStatus::Ready;
1190 s.reviews = vec![ReviewRound {
1191 round: 1,
1192 head: "deadbee".to_owned(),
1193 reviews: vec![ReviewRecord {
1194 reviewer: 1,
1195 agent: "alpha".to_owned(),
1196 summary: String::new(),
1197 findings: vec![Finding {
1198 id: "R1-1-1".to_owned(),
1199 severity: Severity::Major,
1200 file: None,
1201 line: None,
1202 title: "still open".to_owned(),
1203 detail: String::new(),
1204 }],
1205 vote: None,
1206 failed: None,
1207 duration_ms: 0,
1208 }],
1209 e2e: vec![CommandOutcome {
1210 command: "cargo test".to_owned(),
1211 code: Some(0),
1212 output_tail: String::new(),
1213 duration_ms: 0,
1214 }],
1215 verify_retried: false,
1216 fix: Some(FixRecord {
1217 agent: "alpha".to_owned(),
1218 addressed: Vec::new(),
1219 rejected: vec![Rejection {
1220 id: "R1-1-2".to_owned(),
1221 why: "cannot be triggered from any caller".to_owned(),
1222 }],
1223 notes: String::new(),
1224 committed: true,
1225 failed: None,
1226 duration_ms: 0,
1227 }),
1228 blocking: 1,
1229 answered: 1,
1230 expected: 1,
1231 clean: false,
1232 progressed: true,
1233 vote_split: false,
1234 reconsideration: Vec::new(),
1235 verdict: None,
1236 }];
1237
1238 let text = run(&s);
1239 assert!(text.contains("R1-1-2"), "{text}");
1240 assert!(text.contains("cannot be triggered"), "{text}");
1241 assert!(text.contains("still open"), "{text}");
1242 assert!(
1243 text.contains("handed off"),
1244 "a mergeable run with an open round must say so: {text}"
1245 );
1246 }
1247
1248 #[test]
1249 fn a_failing_gate_command_shows_its_output() {
1250 let _guard = plain();
1251 let mut s = state();
1252 s.status = RunStatus::Blocked;
1253 s.gate = vec![CommandOutcome {
1254 command: "cargo make check".to_owned(),
1255 code: Some(101),
1256 output_tail: "error[E0308]: mismatched types".to_owned(),
1257 duration_ms: 0,
1258 }];
1259
1260 let text = run(&s);
1261 assert!(text.contains("mismatched types"), "{text}");
1262 }
1263
1264 #[test]
1265 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1266 let _guard = plain();
1267 let mut s = state();
1268 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1269 let text = active_seats(&s, true);
1270 assert!(text.contains("running now"));
1271 assert!(text.contains("judge-2"));
1272 assert!(text.contains("judge"));
1273 assert!(!text.contains("no live daemon"), "{text}");
1274 }
1275
1276 #[test]
1277 fn active_seats_flags_a_leftover_from_a_dead_process() {
1278 let _guard = plain();
1279 let mut s = state();
1280 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1281 let text = active_seats(&s, false);
1282 assert!(
1283 text.contains("no live daemon"),
1284 "a stale entry must not read as running: {text}"
1285 );
1286 }
1287
1288 #[test]
1289 fn active_seats_is_empty_when_nothing_is_running() {
1290 let _guard = plain();
1291 assert_eq!(active_seats(&state(), true), "");
1292 }
1293
1294 #[test]
1295 fn stats_table_renders_without_runs() {
1296 let _guard = plain();
1297 let text = stats(&Stats::default());
1298 assert!(text.contains("0 total"));
1299 assert!(!text.contains("implementation"));
1300 }
1301}