1use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::MergeMode;
16use crate::run::{CommandOutcome, RunState, RunStatus};
17use crate::stats::Stats;
18
19static COLOR: AtomicBool = AtomicBool::new(true);
20
21pub fn set_color(on: bool) {
23 COLOR.store(on, Ordering::Relaxed);
24}
25
26fn paint(text: &str, code: &str) -> String {
27 if COLOR.load(Ordering::Relaxed) {
28 format!("\x1b[{code}m{text}\x1b[0m")
29 } else {
30 text.to_owned()
31 }
32}
33
34fn bold(t: &str) -> String {
35 paint(t, "1")
36}
37fn dim(t: &str) -> String {
38 paint(t, "2")
39}
40fn red(t: &str) -> String {
41 paint(t, "31")
42}
43fn green(t: &str) -> String {
44 paint(t, "32")
45}
46fn yellow(t: &str) -> String {
47 paint(t, "33")
48}
49fn cyan(t: &str) -> String {
50 paint(t, "36")
51}
52
53fn status_word(status: RunStatus) -> String {
58 let text = format!("{status:?}").to_lowercase();
59 match status {
60 RunStatus::Merged => bold(&green(&text)),
61 RunStatus::Ready => green(&text),
62 RunStatus::Stalled => bold(&yellow(&text)),
63 RunStatus::Blocked => yellow(&text),
64 RunStatus::Failed => red(&text),
65 _ => cyan(&text),
66 }
67}
68
69pub fn line(state: &RunState) -> String {
71 let winner = state
72 .tally
73 .as_ref()
74 .map_or("-".to_owned(), |t| t.winner.to_string());
75 let agent = state.winner().map_or("-", |c| c.agent.as_str());
76 let quorum = match state.tally.as_ref() {
79 Some(t) if !t.met_quorum => format!(
80 " {}",
81 bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
82 ),
83 Some(t) if t.present > 0 && t.present < t.judges => format!(
84 " {}",
85 yellow(&format!("judges {}/{}", t.present, t.judges))
86 ),
87 _ => String::new(),
88 };
89 format!(
90 "{} {:<20} {:>2}c {:>2}j win {} ({}){quorum} {}",
91 dim(&state.id),
92 status_word(state.status),
93 state.candidates.len(),
94 state.judgements.len(),
95 winner,
96 agent,
97 first_line(&state.instruction)
98 )
99}
100
101fn first_line(text: &str) -> String {
102 let line = text.lines().next().unwrap_or_default();
103 if line.chars().count() > 68 {
104 format!("{}…", line.chars().take(67).collect::<String>())
105 } else {
106 line.to_owned()
107 }
108}
109
110fn short(commit: &str) -> String {
111 commit.chars().take(7).collect()
112}
113
114pub fn run(state: &RunState) -> String {
116 let mut s = String::new();
117 let _ = writeln!(
118 s,
119 "{} {} {}",
120 bold("magi run"),
121 bold(&state.id),
122 status_word(state.status)
123 );
124 let _ = writeln!(
125 s,
126 " repo {} ({} @ {})",
127 state.repo.display(),
128 state.base_branch,
129 short(&state.base_commit)
130 );
131 let _ = writeln!(s, " created {}", state.created_local());
132 let _ = writeln!(s, " task {}", first_line(&state.instruction));
133 let _ = writeln!(s, " state {}", state.dir().display());
134
135 let _ = writeln!(s, "\n{}", bold("candidates"));
136 for c in &state.candidates {
137 let flag = match (&c.failed, c.empty) {
138 (Some(e), _) => red(&format!("failed: {e}")),
139 (None, true) => yellow("no change"),
140 _ => format!("{} files, {} commits", c.files, c.commits),
141 };
142 let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
143 bold(&green(" <- winner"))
144 } else {
145 String::new()
146 };
147 let _ = writeln!(
148 s,
149 " {} {:<12} {:<30} {:>5}s{}",
150 bold(&c.label.to_string()),
151 c.agent,
152 flag,
153 c.duration_ms / 1000,
154 crown
155 );
156 }
157
158 if !state.judgements.is_empty() {
159 let _ = writeln!(s, "\n{}", bold("blind judging"));
160 for j in &state.judgements {
161 match &j.failed {
162 Some(e) => {
163 let _ = writeln!(
164 s,
165 " judge {} {}",
166 j.judge,
167 red(&format!("no ranking: {e}"))
168 );
169 }
170 None => {
171 let _ = writeln!(
172 s,
173 " judge {} {:<12} {} confidence {}",
174 j.judge,
175 j.agent,
176 bold(&j.ranking.iter().collect::<String>()),
177 j.confidence.map_or("-".to_owned(), |c| c.to_string())
178 );
179 }
180 }
181 }
182 }
183
184 if let Some(t) = &state.tally {
185 if t.deliberated {
186 let _ = writeln!(s, "\n{}", bold("deliberation"));
187 for round in &state.deliberation {
188 for turn in &round.turns {
189 let _ = writeln!(
190 s,
191 " r{} judge {} -> {}",
192 round.round,
193 turn.judge,
194 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
195 );
196 }
197 }
198 }
199
200 if !state.votes.is_empty() {
201 let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
202 for v in &state.votes {
203 let _ = writeln!(
204 s,
205 " judge {} {:<12} {}{}",
206 v.judge,
207 v.agent,
208 bold(&v.vote.unwrap_or('?').to_string()),
209 if v.changed {
210 yellow(" (changed after deliberation)")
211 } else {
212 String::new()
213 }
214 );
215 }
216 }
217
218 let _ = writeln!(s, "\n{}", bold("tally"));
219 match &t.uncontested {
225 Some(reason) => {
226 let _ = writeln!(
227 s,
228 " judging {}",
229 cyan(&format!("not needed — {reason}"))
230 );
231 }
232 None => {
233 let _ = writeln!(
234 s,
235 " judges {} present{}",
236 if t.met_quorum {
237 green(&format!("{}/{}", t.present, t.judges))
238 } else {
239 red(&format!("{}/{}", t.present, t.judges))
240 },
241 if t.quorum > 0 {
242 format!(" ({quorum} required)", quorum = t.quorum)
243 } else {
244 String::new()
245 }
246 );
247 if !t.met_quorum {
248 let _ = writeln!(
249 s,
250 " {}",
251 bold(&red("BELOW QUORUM — verdict is not trustworthy"))
252 );
253 }
254 let _ = writeln!(
255 s,
256 " first choice {}",
257 t.first_choice
258 .iter()
259 .map(|(k, v)| format!("{k}:{v}"))
260 .collect::<Vec<_>>()
261 .join(" ")
262 );
263 let _ = writeln!(
264 s,
265 " initial {}",
266 match (t.rankings, t.unanimous_initial) {
267 (0, _) => red("no usable ranking"),
268 (1, _) => yellow("one usable ranking - not a consensus"),
269 (_, true) => green("unanimous"),
270 (_, false) => yellow("split"),
271 }
272 );
273 let _ = writeln!(
274 s,
275 " after votes {} ({} judge(s) moved)",
276 if t.unanimous_final {
277 green("unanimous")
278 } else {
279 yellow("still split")
280 },
281 t.changed_votes
282 );
283 if let Some(tb) = &t.tie_break {
284 let _ = writeln!(s, " tie break {tb}");
285 }
286 }
287 }
288 if !state.quota.is_empty() {
289 let _ = writeln!(
290 s,
291 " rate limited {}",
292 state
293 .quota
294 .iter()
295 .map(|q| q.seat.as_str())
296 .collect::<Vec<_>>()
297 .join(", ")
298 );
299 }
300 let _ = writeln!(s, " winner {}", bold(&green(&t.winner.to_string())));
301 }
302
303 if !state.reviews.is_empty() {
304 let _ = writeln!(s, "\n{}", bold("review + verification"));
305 for r in &state.reviews {
306 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
307 let e2e = if r.e2e.is_empty() {
311 dim("no e2e")
312 } else if r.e2e.iter().all(|o| o.ok()) {
313 green("e2e green")
314 } else if r.e2e.iter().any(CommandOutcome::build_failed) {
315 yellow("e2e could not run (build/link failure)")
316 } else {
317 red("e2e RED")
318 };
319 let e2e = if r.verify_retried {
320 format!("{e2e}, retried once")
321 } else {
322 e2e
323 };
324 let status = if r.incomplete() {
328 yellow("incomplete")
329 } else if r.clean {
330 green("clean")
331 } else {
332 yellow("open")
333 };
334 let panel = if r.incomplete() {
338 let missing: Vec<String> = r
339 .reviews
340 .iter()
341 .filter_map(|x| {
342 x.failed
343 .as_ref()
344 .map(|why| format!("review-{}: {why}", x.reviewer))
345 })
346 .collect();
347 format!(
348 " {}/{} reviewers answered ({})",
349 r.answered,
350 r.expected,
351 missing.join(", ")
352 )
353 } else {
354 String::new()
355 };
356 let _ = writeln!(
357 s,
358 " round {} {} @ {}{panel} {raised} finding(s), {} blocking, {e2e}{}",
359 r.round,
360 status,
361 short(&r.head),
362 r.blocking,
363 r.fix.as_ref().map_or(String::new(), |f| match &f.failed {
364 Some(reason) => format!(
369 " fix: {}{}",
370 yellow(&format!("adoption report lost ({reason})")),
371 if f.committed {
372 String::new()
373 } else {
374 red(" (NO COMMIT)")
375 }
376 ),
377 None => format!(
378 " fix: {} addressed / {} rejected{}",
379 f.addressed.len(),
380 f.rejected.len(),
381 if f.committed {
382 String::new()
383 } else {
384 red(" (NO COMMIT)")
385 }
386 ),
387 })
388 );
389 for rec in &r.reviews {
390 for f in &rec.findings {
391 let adopted = r
392 .fix
393 .as_ref()
394 .is_some_and(|fix| fix.addressed.contains(&f.id));
395 let _ = writeln!(
396 s,
397 " {} [{:?}] {}{}",
398 dim(&f.id),
399 f.severity,
400 f.title,
401 if adopted {
402 green(" fixed")
403 } else {
404 String::new()
405 }
406 );
407 }
408 }
409 }
410 }
411
412 if let Some(bs) = &state.base_sync {
413 let _ = writeln!(s, "\n{}", bold("base sync"));
414 let status = if let Some(c) = &bs.conflict {
415 red(&format!("conflict: {}", first_line(c)))
416 } else if bs.behind == 0 {
417 green("in sync")
418 } else {
419 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
420 };
421 let _ = writeln!(
422 s,
423 " {} @ {} {status}{}",
424 state.base_branch,
425 short(&bs.tip),
426 if bs.attempts > 0 {
427 format!(" ({} rebase attempt(s))", bs.attempts)
428 } else {
429 String::new()
430 }
431 );
432 }
433
434 if !state.gate.is_empty() {
435 let _ = writeln!(s, "\n{}", bold("gate"));
436 for o in &state.gate {
437 let _ = writeln!(
438 s,
439 " {} {}",
440 if o.ok() { green("pass") } else { red("FAIL") },
441 o.command
442 );
443 }
444 }
445
446 if let Some(m) = &state.merge {
447 let _ = writeln!(s, "\n{}", bold("merge"));
448 if m.mode == MergeMode::None {
449 let _ = writeln!(
453 s,
454 " mode None {}",
455 cyan("not landed — nothing to do by design")
456 );
457 if let Some(w) = state.winner() {
458 let _ = writeln!(
459 s,
460 " branch {} still exists, unmerged into {}",
461 w.branch, state.base_branch
462 );
463 }
464 let _ = writeln!(
465 s,
466 " rebase onto {} before merging by hand, and pass an explicit \
467 commit message — a squash merge otherwise inherits the \
468 candidate's placeholder subject",
469 state.base_branch
470 );
471 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
472 } else {
473 let _ = writeln!(
474 s,
475 " mode {:?} {}\n {}",
476 m.mode,
477 if m.ok {
478 green("ok")
479 } else {
480 yellow("not merged")
481 },
482 m.detail.lines().next().unwrap_or("")
483 );
484 }
485 }
486
487 if !state.leaks.is_empty() {
488 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
489 for l in &state.leaks {
490 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
491 }
492 }
493
494 if let Some(w) = state.winner()
495 && !w.folded
496 {
497 let _ = writeln!(
498 s,
499 "\n{} {}\n branch {}",
500 bold("winner worktree"),
501 w.worktree.display(),
502 w.branch
503 );
504 }
505 s
506}
507
508pub fn active_seats(state: &RunState, live: bool) -> String {
523 if state.active.is_empty() {
524 return String::new();
525 }
526 let mut s = String::new();
527 let _ = writeln!(s, "\n{}", bold("running now"));
528 if !live {
529 let _ = writeln!(
530 s,
531 " {}",
532 yellow(
533 "no live daemon claims this run right now — likely left behind by a killed process"
534 )
535 );
536 }
537 let now = jiff::Timestamp::now();
538 for (seat, a) in &state.active {
539 let retry = if a.attempt > 0 {
540 format!(" retry {}", a.attempt)
541 } else {
542 String::new()
543 };
544 let _ = writeln!(
545 s,
546 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
547 seat,
548 a.node,
549 a.elapsed_secs(now),
550 a.remaining_secs(now),
551 a.timeout_secs
552 );
553 }
554 s
555}
556
557pub fn stats(stats: &Stats) -> String {
559 let t = &stats.totals;
560 let mut s = String::new();
561 let _ = writeln!(s, "{}", bold("runs"));
562 let _ = writeln!(
563 s,
564 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
565 t.runs,
566 t.merged,
567 t.ready,
568 t.blocked,
569 t.failed,
570 t.completion_rate()
571 );
572 if t.tallied > 0 {
573 let _ = writeln!(
574 s,
575 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
576 {} of those changed a mind, {} converged to unanimous",
577 t.tallied,
578 t.split,
579 t.split_rate(),
580 t.deliberated,
581 t.minds_changed,
582 t.converged
583 );
584 }
585
586 if !stats.agents.is_empty() {
587 let _ = writeln!(
588 s,
589 "\n{}",
590 bold("implementation (relative, on this workload)")
591 );
592 let _ = writeln!(
593 s,
594 " {:<14}{:>6}{:>8}{:>8}{:>8}",
595 "agent", "won", "entered", "rate", "empty"
596 );
597 for a in &stats.agents {
598 let _ = writeln!(
599 s,
600 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
601 a.agent,
602 a.wins,
603 a.entered,
604 a.win_rate(),
605 a.empty
606 );
607 }
608 }
609
610 if !stats.reviewers.is_empty() {
611 let _ = writeln!(s, "\n{}", bold("review"));
612 let _ = writeln!(
613 s,
614 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
615 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
616 );
617 for r in &stats.reviewers {
618 let _ = writeln!(
619 s,
620 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
621 r.agent,
622 r.rounds,
623 r.submitted,
624 r.adopted_per_round(),
625 r.precision(),
626 r.unique_rate(),
627 r.timeout_rate()
628 );
629 }
630 }
631
632 if stats.e2e.rounds > 0 {
633 let _ = writeln!(s, "\n{}", bold("verification"));
634 let _ = writeln!(
635 s,
636 " {} rounds ran e2e, {} failed, {} of those with a clean static \
637 review ({:.0}% sole detections)",
638 stats.e2e.rounds,
639 stats.e2e.failures,
640 stats.e2e.sole_detections,
641 stats.e2e.sole_rate()
642 );
643 }
644 s
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::config::Config;
651 use crate::run::{
652 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
653 Tally,
654 };
655 use std::collections::BTreeMap;
656 use std::path::PathBuf;
657 use std::sync::{Mutex, MutexGuard};
658
659 static SERIAL: Mutex<()> = Mutex::new(());
661
662 fn plain() -> MutexGuard<'static, ()> {
663 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
664 set_color(false);
665 guard
666 }
667
668 fn state() -> RunState {
669 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
674 let mut s = RunState::new(
675 PathBuf::from("/repo"),
676 "main".to_owned(),
677 "abcdef1234".to_owned(),
678 "add retries to the uploader".to_owned(),
679 Config::default(),
680 );
681 s.candidates = vec![Candidate {
682 index: 0,
683 label: 'A',
684 agent: "opus".to_owned(),
685 branch: "magi/x/A".to_owned(),
686 worktree: PathBuf::from("/wt/A"),
687 summary: String::new(),
688 stat: String::new(),
689 files: 3,
690 commits: 2,
691 empty: false,
692 failed: None,
693 duration_ms: 42_000,
694 folded: false,
695 }];
696 s.tally = Some(Tally {
697 first_choice: BTreeMap::from([('A', 3)]),
698 borda: BTreeMap::new(),
699 winner: 'A',
700 rankings: 3,
701 unanimous_initial: true,
702 deliberated: false,
703 changed_votes: 0,
704 unanimous_final: true,
705 tie_break: None,
706 judges: 3,
707 present: 3,
708 quorum: 2,
709 met_quorum: true,
710 uncontested: None,
711 });
712 s
713 }
714
715 #[test]
716 fn run_report_names_the_winner_and_its_author() {
717 let _guard = plain();
718 let text = run(&state());
719 assert!(text.contains("<- winner"), "{text}");
720 assert!(text.contains("opus"));
721 assert!(text.contains("3 files, 2 commits"));
722 assert!(text.contains("winner A"));
723 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
724 }
725
726 #[test]
727 fn colour_is_emitted_only_when_enabled() {
728 let _guard = plain();
729 set_color(true);
730 let coloured = run(&state());
731 set_color(false);
732 let plain = run(&state());
733 assert!(coloured.contains('\x1b'));
734 assert!(!plain.contains('\x1b'));
735 assert!(coloured.len() > plain.len());
736 }
737
738 #[test]
739 fn list_line_is_single_line() {
740 let _guard = plain();
741 let l = line(&state());
742 assert_eq!(l.lines().count(), 1);
743 assert!(l.contains("add retries"));
744 assert!(l.contains("win A (opus)"));
745 }
746
747 #[test]
748 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
749 let _guard = plain();
750 let mut s = state();
751 s.tally = Some(Tally {
752 first_choice: BTreeMap::from([('A', 0)]),
753 borda: BTreeMap::new(),
754 winner: 'A',
755 rankings: 0,
756 unanimous_initial: false,
757 deliberated: false,
758 changed_votes: 0,
759 unanimous_final: false,
760 tie_break: None,
761 judges: 0,
762 present: 0,
763 quorum: 0,
764 met_quorum: true,
765 uncontested: Some(
766 "only candidate A produced a usable change; no panel was asked".to_owned(),
767 ),
768 });
769 let text = run(&s);
770 assert!(
771 !text.contains("0/3"),
772 "no panel sat, so the judges line must not read as one that collapsed: {text}"
773 );
774 assert!(!text.contains("no usable ranking"), "{text}");
775 assert!(!text.contains("still split"), "{text}");
776 assert!(!text.contains("BELOW QUORUM"), "{text}");
777 assert!(
778 text.contains("not needed"),
779 "the report must say judging was skipped, not silent: {text}"
780 );
781 assert!(text.contains("winner A"));
782 }
783
784 #[test]
785 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
786 let _guard = plain();
787 let mut s = state();
788 s.tally = Some(Tally {
789 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
790 borda: BTreeMap::new(),
791 winner: 'A',
792 rankings: 1,
793 unanimous_initial: false,
794 deliberated: false,
795 changed_votes: 0,
796 unanimous_final: false,
797 tie_break: None,
798 judges: 3,
799 present: 1,
800 quorum: 2,
801 met_quorum: false,
802 uncontested: None,
803 });
804 let text = run(&s);
805 assert!(text.contains("1/3"), "{text}");
806 assert!(
807 text.contains("BELOW QUORUM"),
808 "a real collapse must still be flagged: {text}"
809 );
810 assert!(
811 !text.contains("not needed"),
812 "a collapsed panel must not be described as one that was never asked: {text}"
813 );
814 }
815
816 #[test]
817 fn a_mode_none_merge_does_not_read_as_landed() {
818 let _guard = plain();
819 let mut s = state();
820 s.merge = Some(MergeOutcome {
821 mode: crate::config::MergeMode::None,
822 ok: true,
823 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
824 });
825 let text = run(&s);
826 assert!(
827 !text.contains(" ok"),
828 "mode none must not be shown as a landed merge: {text}"
829 );
830 assert!(text.contains("not landed"), "{text}");
831 assert!(
832 text.contains("branch magi/x/A"),
833 "the report must say what's left behind: {text}"
834 );
835 assert!(
836 text.contains("rebase"),
837 "the report must point at the hand-landing steps: {text}"
838 );
839 }
840
841 #[test]
842 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
843 let _guard = plain();
844 let mut s = state();
845 s.tally = Some(Tally {
846 first_choice: BTreeMap::from([('A', 0)]),
847 borda: BTreeMap::new(),
848 winner: 'A',
849 rankings: 0,
850 unanimous_initial: false,
851 deliberated: false,
852 changed_votes: 0,
853 unanimous_final: false,
854 tie_break: None,
855 judges: 0,
856 present: 0,
857 quorum: 0,
858 met_quorum: true,
859 uncontested: Some("only candidate A produced a usable change".to_owned()),
860 });
861 let l = line(&s);
862 assert!(
863 !l.contains("judges") && !l.contains("quorum"),
864 "an uncontested run must not carry the same badge a short panel gets: {l}"
865 );
866 }
867
868 #[test]
869 fn long_instructions_are_elided() {
870 let _guard = plain();
871 let mut s = state();
872 s.instruction = "x".repeat(200);
873 assert!(line(&s).contains('…'));
874 }
875
876 #[test]
877 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
878 let _guard = plain();
879 let mut lost = state();
880 lost.reviews = vec![ReviewRound {
881 round: 1,
882 head: "abc1234".to_owned(),
883 reviews: Vec::new(),
884 e2e: Vec::new(),
885 verify_retried: false,
886 fix: Some(FixRecord {
887 agent: "opus".to_owned(),
888 addressed: Vec::new(),
889 rejected: Vec::new(),
890 notes: String::new(),
891 committed: true,
892 failed: Some("timed out".to_owned()),
893 duration_ms: 0,
894 }),
895 blocking: 3,
896 answered: 0,
897 expected: 0,
898 clean: false,
899 }];
900 let text = run(&lost);
901 assert!(text.contains("adoption report lost (timed out)"), "{text}");
902 assert!(
903 !text.contains("0 addressed"),
904 "a lost report must never read as `0 addressed`: {text}"
905 );
906
907 let mut rejected_all = state();
908 rejected_all.reviews = vec![ReviewRound {
909 round: 1,
910 head: "abc1234".to_owned(),
911 reviews: Vec::new(),
912 e2e: Vec::new(),
913 verify_retried: false,
914 fix: Some(FixRecord {
915 agent: "opus".to_owned(),
916 addressed: Vec::new(),
917 rejected: Vec::new(),
918 notes: String::new(),
919 committed: true,
920 failed: None,
921 duration_ms: 0,
922 }),
923 blocking: 3,
924 answered: 0,
925 expected: 0,
926 clean: false,
927 }];
928 let text2 = run(&rejected_all);
929 assert!(
930 text2.contains("0 addressed / 0 rejected"),
931 "a round the fixer actually reported on keeps the count: {text2}"
932 );
933 }
934
935 #[test]
936 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
937 let _guard = plain();
943 let mut s = state();
944 s.reviews = vec![ReviewRound {
945 round: 1,
946 head: "abc1234".to_owned(),
947 reviews: vec![
948 ReviewRecord {
949 reviewer: 1,
950 agent: "alpha".to_owned(),
951 summary: String::new(),
952 findings: Vec::new(),
953 failed: None,
954 duration_ms: 0,
955 },
956 ReviewRecord {
957 reviewer: 2,
958 agent: "beta".to_owned(),
959 summary: String::new(),
960 findings: Vec::new(),
961 failed: Some("agent timed out".to_owned()),
962 duration_ms: 0,
963 },
964 ],
965 e2e: Vec::new(),
966 verify_retried: false,
967 fix: Some(FixRecord {
968 agent: "opus".to_owned(),
969 addressed: Vec::new(),
970 rejected: Vec::new(),
971 notes: String::new(),
972 committed: true,
973 failed: Some("timed out".to_owned()),
974 duration_ms: 0,
975 }),
976 blocking: 0,
977 answered: 1,
978 expected: 2,
979 clean: false,
980 }];
981 let text = run(&s);
982 assert!(text.contains("incomplete"), "{text}");
983 assert!(text.contains("1/2 reviewers answered"), "{text}");
984 assert!(text.contains("review-2: agent timed out"), "{text}");
985 assert!(text.contains("adoption report lost (timed out)"), "{text}");
986 assert!(
987 !text.contains("clean"),
988 "a round missing half its panel must never render as clean: {text}"
989 );
990 }
991
992 #[test]
993 fn a_build_failure_is_not_reported_as_a_test_failure() {
994 let _guard = plain();
995 let mut s = state();
996 s.reviews = vec![ReviewRound {
997 round: 1,
998 head: "abc1234".to_owned(),
999 reviews: Vec::new(),
1000 e2e: vec![CommandOutcome {
1001 command: "cargo test".to_owned(),
1002 code: Some(1),
1003 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1004 duration_ms: 100,
1005 }],
1006 verify_retried: true,
1007 fix: None,
1008 blocking: 0,
1009 answered: 0,
1010 expected: 0,
1011 clean: false,
1012 }];
1013 let text = run(&s);
1014 assert!(text.contains("could not run"), "{text}");
1015 assert!(text.contains("retried once"), "{text}");
1016 assert!(!text.contains("e2e RED"), "{text}");
1017 }
1018
1019 #[test]
1020 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1021 let _guard = plain();
1022 let mut s = state();
1023 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1024 let text = active_seats(&s, true);
1025 assert!(text.contains("running now"));
1026 assert!(text.contains("judge-2"));
1027 assert!(text.contains("judge"));
1028 assert!(!text.contains("no live daemon"), "{text}");
1029 }
1030
1031 #[test]
1032 fn active_seats_flags_a_leftover_from_a_dead_process() {
1033 let _guard = plain();
1034 let mut s = state();
1035 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1036 let text = active_seats(&s, false);
1037 assert!(
1038 text.contains("no live daemon"),
1039 "a stale entry must not read as running: {text}"
1040 );
1041 }
1042
1043 #[test]
1044 fn active_seats_is_empty_when_nothing_is_running() {
1045 let _guard = plain();
1046 assert_eq!(active_seats(&state(), true), "");
1047 }
1048
1049 #[test]
1050 fn stats_table_renders_without_runs() {
1051 let _guard = plain();
1052 let text = stats(&Stats::default());
1053 assert!(text.contains("0 total"));
1054 assert!(!text.contains("implementation"));
1055 }
1056}