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 _ = writeln!(
325 s,
326 " round {} {} @ {} {raised} finding(s), {} blocking, {e2e}{}",
327 r.round,
328 if r.clean {
329 green("clean")
330 } else {
331 yellow("open")
332 },
333 short(&r.head),
334 r.blocking,
335 r.fix.as_ref().map_or(String::new(), |f| match &f.failed {
336 Some(reason) => format!(
341 " fix: {}{}",
342 yellow(&format!("adoption report lost ({reason})")),
343 if f.committed {
344 String::new()
345 } else {
346 red(" (NO COMMIT)")
347 }
348 ),
349 None => format!(
350 " fix: {} addressed / {} rejected{}",
351 f.addressed.len(),
352 f.rejected.len(),
353 if f.committed {
354 String::new()
355 } else {
356 red(" (NO COMMIT)")
357 }
358 ),
359 })
360 );
361 for rec in &r.reviews {
362 for f in &rec.findings {
363 let adopted = r
364 .fix
365 .as_ref()
366 .is_some_and(|fix| fix.addressed.contains(&f.id));
367 let _ = writeln!(
368 s,
369 " {} [{:?}] {}{}",
370 dim(&f.id),
371 f.severity,
372 f.title,
373 if adopted {
374 green(" fixed")
375 } else {
376 String::new()
377 }
378 );
379 }
380 }
381 }
382 }
383
384 if !state.gate.is_empty() {
385 let _ = writeln!(s, "\n{}", bold("gate"));
386 for o in &state.gate {
387 let _ = writeln!(
388 s,
389 " {} {}",
390 if o.ok() { green("pass") } else { red("FAIL") },
391 o.command
392 );
393 }
394 }
395
396 if let Some(m) = &state.merge {
397 let _ = writeln!(s, "\n{}", bold("merge"));
398 if m.mode == MergeMode::None {
399 let _ = writeln!(
403 s,
404 " mode None {}",
405 cyan("not landed — nothing to do by design")
406 );
407 if let Some(w) = state.winner() {
408 let _ = writeln!(
409 s,
410 " branch {} still exists, unmerged into {}",
411 w.branch, state.base_branch
412 );
413 }
414 let _ = writeln!(
415 s,
416 " rebase onto {} before merging by hand, and pass an explicit \
417 commit message — a squash merge otherwise inherits the \
418 candidate's placeholder subject",
419 state.base_branch
420 );
421 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
422 } else {
423 let _ = writeln!(
424 s,
425 " mode {:?} {}\n {}",
426 m.mode,
427 if m.ok {
428 green("ok")
429 } else {
430 yellow("not merged")
431 },
432 m.detail.lines().next().unwrap_or("")
433 );
434 }
435 }
436
437 if !state.leaks.is_empty() {
438 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
439 for l in &state.leaks {
440 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
441 }
442 }
443
444 if let Some(w) = state.winner()
445 && !w.folded
446 {
447 let _ = writeln!(
448 s,
449 "\n{} {}\n branch {}",
450 bold("winner worktree"),
451 w.worktree.display(),
452 w.branch
453 );
454 }
455 s
456}
457
458pub fn active_seats(state: &RunState, live: bool) -> String {
473 if state.active.is_empty() {
474 return String::new();
475 }
476 let mut s = String::new();
477 let _ = writeln!(s, "\n{}", bold("running now"));
478 if !live {
479 let _ = writeln!(
480 s,
481 " {}",
482 yellow(
483 "no live daemon claims this run right now — likely left behind by a killed process"
484 )
485 );
486 }
487 let now = jiff::Timestamp::now();
488 for (seat, a) in &state.active {
489 let retry = if a.attempt > 0 {
490 format!(" retry {}", a.attempt)
491 } else {
492 String::new()
493 };
494 let _ = writeln!(
495 s,
496 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
497 seat,
498 a.node,
499 a.elapsed_secs(now),
500 a.remaining_secs(now),
501 a.timeout_secs
502 );
503 }
504 s
505}
506
507pub fn stats(stats: &Stats) -> String {
509 let t = &stats.totals;
510 let mut s = String::new();
511 let _ = writeln!(s, "{}", bold("runs"));
512 let _ = writeln!(
513 s,
514 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
515 t.runs,
516 t.merged,
517 t.ready,
518 t.blocked,
519 t.failed,
520 t.completion_rate()
521 );
522 if t.tallied > 0 {
523 let _ = writeln!(
524 s,
525 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
526 {} of those changed a mind, {} converged to unanimous",
527 t.tallied,
528 t.split,
529 t.split_rate(),
530 t.deliberated,
531 t.minds_changed,
532 t.converged
533 );
534 }
535
536 if !stats.agents.is_empty() {
537 let _ = writeln!(
538 s,
539 "\n{}",
540 bold("implementation (relative, on this workload)")
541 );
542 let _ = writeln!(
543 s,
544 " {:<14}{:>6}{:>8}{:>8}{:>8}",
545 "agent", "won", "entered", "rate", "empty"
546 );
547 for a in &stats.agents {
548 let _ = writeln!(
549 s,
550 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
551 a.agent,
552 a.wins,
553 a.entered,
554 a.win_rate(),
555 a.empty
556 );
557 }
558 }
559
560 if !stats.reviewers.is_empty() {
561 let _ = writeln!(s, "\n{}", bold("review"));
562 let _ = writeln!(
563 s,
564 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}",
565 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique"
566 );
567 for r in &stats.reviewers {
568 let _ = writeln!(
569 s,
570 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%",
571 r.agent,
572 r.rounds,
573 r.submitted,
574 r.adopted_per_round(),
575 r.precision(),
576 r.unique_rate()
577 );
578 }
579 }
580
581 if stats.e2e.rounds > 0 {
582 let _ = writeln!(s, "\n{}", bold("verification"));
583 let _ = writeln!(
584 s,
585 " {} rounds ran e2e, {} failed, {} of those with a clean static \
586 review ({:.0}% sole detections)",
587 stats.e2e.rounds,
588 stats.e2e.failures,
589 stats.e2e.sole_detections,
590 stats.e2e.sole_rate()
591 );
592 }
593 s
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use crate::config::Config;
600 use crate::run::{
601 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRound, RunState, Tally,
602 };
603 use std::collections::BTreeMap;
604 use std::path::PathBuf;
605 use std::sync::{Mutex, MutexGuard};
606
607 static SERIAL: Mutex<()> = Mutex::new(());
609
610 fn plain() -> MutexGuard<'static, ()> {
611 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
612 set_color(false);
613 guard
614 }
615
616 fn state() -> RunState {
617 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
622 let mut s = RunState::new(
623 PathBuf::from("/repo"),
624 "main".to_owned(),
625 "abcdef1234".to_owned(),
626 "add retries to the uploader".to_owned(),
627 Config::default(),
628 );
629 s.candidates = vec![Candidate {
630 index: 0,
631 label: 'A',
632 agent: "opus".to_owned(),
633 branch: "magi/x/A".to_owned(),
634 worktree: PathBuf::from("/wt/A"),
635 summary: String::new(),
636 stat: String::new(),
637 files: 3,
638 commits: 2,
639 empty: false,
640 failed: None,
641 duration_ms: 42_000,
642 folded: false,
643 }];
644 s.tally = Some(Tally {
645 first_choice: BTreeMap::from([('A', 3)]),
646 borda: BTreeMap::new(),
647 winner: 'A',
648 rankings: 3,
649 unanimous_initial: true,
650 deliberated: false,
651 changed_votes: 0,
652 unanimous_final: true,
653 tie_break: None,
654 judges: 3,
655 present: 3,
656 quorum: 2,
657 met_quorum: true,
658 uncontested: None,
659 });
660 s
661 }
662
663 #[test]
664 fn run_report_names_the_winner_and_its_author() {
665 let _guard = plain();
666 let text = run(&state());
667 assert!(text.contains("<- winner"), "{text}");
668 assert!(text.contains("opus"));
669 assert!(text.contains("3 files, 2 commits"));
670 assert!(text.contains("winner A"));
671 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
672 }
673
674 #[test]
675 fn colour_is_emitted_only_when_enabled() {
676 let _guard = plain();
677 set_color(true);
678 let coloured = run(&state());
679 set_color(false);
680 let plain = run(&state());
681 assert!(coloured.contains('\x1b'));
682 assert!(!plain.contains('\x1b'));
683 assert!(coloured.len() > plain.len());
684 }
685
686 #[test]
687 fn list_line_is_single_line() {
688 let _guard = plain();
689 let l = line(&state());
690 assert_eq!(l.lines().count(), 1);
691 assert!(l.contains("add retries"));
692 assert!(l.contains("win A (opus)"));
693 }
694
695 #[test]
696 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
697 let _guard = plain();
698 let mut s = state();
699 s.tally = Some(Tally {
700 first_choice: BTreeMap::from([('A', 0)]),
701 borda: BTreeMap::new(),
702 winner: 'A',
703 rankings: 0,
704 unanimous_initial: false,
705 deliberated: false,
706 changed_votes: 0,
707 unanimous_final: false,
708 tie_break: None,
709 judges: 0,
710 present: 0,
711 quorum: 0,
712 met_quorum: true,
713 uncontested: Some(
714 "only candidate A produced a usable change; no panel was asked".to_owned(),
715 ),
716 });
717 let text = run(&s);
718 assert!(
719 !text.contains("0/3"),
720 "no panel sat, so the judges line must not read as one that collapsed: {text}"
721 );
722 assert!(!text.contains("no usable ranking"), "{text}");
723 assert!(!text.contains("still split"), "{text}");
724 assert!(!text.contains("BELOW QUORUM"), "{text}");
725 assert!(
726 text.contains("not needed"),
727 "the report must say judging was skipped, not silent: {text}"
728 );
729 assert!(text.contains("winner A"));
730 }
731
732 #[test]
733 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
734 let _guard = plain();
735 let mut s = state();
736 s.tally = Some(Tally {
737 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
738 borda: BTreeMap::new(),
739 winner: 'A',
740 rankings: 1,
741 unanimous_initial: false,
742 deliberated: false,
743 changed_votes: 0,
744 unanimous_final: false,
745 tie_break: None,
746 judges: 3,
747 present: 1,
748 quorum: 2,
749 met_quorum: false,
750 uncontested: None,
751 });
752 let text = run(&s);
753 assert!(text.contains("1/3"), "{text}");
754 assert!(
755 text.contains("BELOW QUORUM"),
756 "a real collapse must still be flagged: {text}"
757 );
758 assert!(
759 !text.contains("not needed"),
760 "a collapsed panel must not be described as one that was never asked: {text}"
761 );
762 }
763
764 #[test]
765 fn a_mode_none_merge_does_not_read_as_landed() {
766 let _guard = plain();
767 let mut s = state();
768 s.merge = Some(MergeOutcome {
769 mode: crate::config::MergeMode::None,
770 ok: true,
771 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
772 });
773 let text = run(&s);
774 assert!(
775 !text.contains(" ok"),
776 "mode none must not be shown as a landed merge: {text}"
777 );
778 assert!(text.contains("not landed"), "{text}");
779 assert!(
780 text.contains("branch magi/x/A"),
781 "the report must say what's left behind: {text}"
782 );
783 assert!(
784 text.contains("rebase"),
785 "the report must point at the hand-landing steps: {text}"
786 );
787 }
788
789 #[test]
790 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
791 let _guard = plain();
792 let mut s = state();
793 s.tally = Some(Tally {
794 first_choice: BTreeMap::from([('A', 0)]),
795 borda: BTreeMap::new(),
796 winner: 'A',
797 rankings: 0,
798 unanimous_initial: false,
799 deliberated: false,
800 changed_votes: 0,
801 unanimous_final: false,
802 tie_break: None,
803 judges: 0,
804 present: 0,
805 quorum: 0,
806 met_quorum: true,
807 uncontested: Some("only candidate A produced a usable change".to_owned()),
808 });
809 let l = line(&s);
810 assert!(
811 !l.contains("judges") && !l.contains("quorum"),
812 "an uncontested run must not carry the same badge a short panel gets: {l}"
813 );
814 }
815
816 #[test]
817 fn long_instructions_are_elided() {
818 let _guard = plain();
819 let mut s = state();
820 s.instruction = "x".repeat(200);
821 assert!(line(&s).contains('…'));
822 }
823
824 #[test]
825 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
826 let _guard = plain();
827 let mut lost = state();
828 lost.reviews = vec![ReviewRound {
829 round: 1,
830 head: "abc1234".to_owned(),
831 reviews: Vec::new(),
832 e2e: Vec::new(),
833 verify_retried: false,
834 fix: Some(FixRecord {
835 agent: "opus".to_owned(),
836 addressed: Vec::new(),
837 rejected: Vec::new(),
838 notes: String::new(),
839 committed: true,
840 failed: Some("timed out".to_owned()),
841 duration_ms: 0,
842 }),
843 blocking: 3,
844 clean: false,
845 }];
846 let text = run(&lost);
847 assert!(text.contains("adoption report lost (timed out)"), "{text}");
848 assert!(
849 !text.contains("0 addressed"),
850 "a lost report must never read as `0 addressed`: {text}"
851 );
852
853 let mut rejected_all = state();
854 rejected_all.reviews = vec![ReviewRound {
855 round: 1,
856 head: "abc1234".to_owned(),
857 reviews: Vec::new(),
858 e2e: Vec::new(),
859 verify_retried: false,
860 fix: Some(FixRecord {
861 agent: "opus".to_owned(),
862 addressed: Vec::new(),
863 rejected: Vec::new(),
864 notes: String::new(),
865 committed: true,
866 failed: None,
867 duration_ms: 0,
868 }),
869 blocking: 3,
870 clean: false,
871 }];
872 let text2 = run(&rejected_all);
873 assert!(
874 text2.contains("0 addressed / 0 rejected"),
875 "a round the fixer actually reported on keeps the count: {text2}"
876 );
877 }
878
879 #[test]
880 fn a_build_failure_is_not_reported_as_a_test_failure() {
881 let _guard = plain();
882 let mut s = state();
883 s.reviews = vec![ReviewRound {
884 round: 1,
885 head: "abc1234".to_owned(),
886 reviews: Vec::new(),
887 e2e: vec![CommandOutcome {
888 command: "cargo test".to_owned(),
889 code: Some(1),
890 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
891 duration_ms: 100,
892 }],
893 verify_retried: true,
894 fix: None,
895 blocking: 0,
896 clean: false,
897 }];
898 let text = run(&s);
899 assert!(text.contains("could not run"), "{text}");
900 assert!(text.contains("retried once"), "{text}");
901 assert!(!text.contains("e2e RED"), "{text}");
902 }
903
904 #[test]
905 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
906 let _guard = plain();
907 let mut s = state();
908 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
909 let text = active_seats(&s, true);
910 assert!(text.contains("running now"));
911 assert!(text.contains("judge-2"));
912 assert!(text.contains("judge"));
913 assert!(!text.contains("no live daemon"), "{text}");
914 }
915
916 #[test]
917 fn active_seats_flags_a_leftover_from_a_dead_process() {
918 let _guard = plain();
919 let mut s = state();
920 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
921 let text = active_seats(&s, false);
922 assert!(
923 text.contains("no live daemon"),
924 "a stale entry must not read as running: {text}"
925 );
926 }
927
928 #[test]
929 fn active_seats_is_empty_when_nothing_is_running() {
930 let _guard = plain();
931 assert_eq!(active_seats(&state(), true), "");
932 }
933
934 #[test]
935 fn stats_table_renders_without_runs() {
936 let _guard = plain();
937 let text = stats(&Stats::default());
938 assert!(text.contains("0 total"));
939 assert!(!text.contains("implementation"));
940 }
941}