Skip to main content

magi/
report.rs

1//! Terminal rendering.
2//!
3//! A run produces a lot of state; the report exists so the operator can decide
4//! what to do next without opening `run.json`. It leads with the disagreement,
5//! because that is the part that carries information: three judges agreeing
6//! tells you nothing the winner's diff does not.
7//!
8//! Colour is a six-line local implementation rather than a crate. The
9//! alternatives all decide *for* you whether the stream supports colour, which
10//! makes the output untestable — `assert!(text.contains("winner  A"))` fails on
11//! an escape sequence the test never asked for.
12use 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
21/// Turn colour on or off for every subsequent render.
22pub 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
53/// Colour for a status word.
54///
55/// `Stalled` is deliberately not green: a run whose judges were taken out by a
56/// rate limit must not look like a healthy `Ready` in a one-line listing.
57fn 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
69/// One-line summary, for `magi list`.
70pub 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    // A below-quorum verdict carries an explicit stamp so a row in a listing
77    // reads "stalled" and "2/3 judges" without opening the report.
78    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
114/// Full report for one run.
115pub 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        // A tally with no panel (`uncontested`) must not fall through the
220        // judges/first-choice/after-votes lines below: they are written
221        // unconditionally and every one of them reads, in the words a panel
222        // that collapsed would also produce, as a run that lost its judges
223        // rather than one that never needed them.
224        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            // A build/link failure is not a verdict on the patch (a shared
308            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
309            // must not read the same as a real test failure.
310            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                    // Never the same shape as "N addressed / M rejected": the
337                    // fixer's diff may well have landed (see the `fix` node's
338                    // own event), but whether it addressed anything is
339                    // unknown, not zero.
340                    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            // `ok: true` here means "magi did nothing, as configured", not
400            // "landed" — a green `ok` next to a shell command reads as done,
401            // and the branch is still sitting unmerged.
402            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
458/// The seats currently mid-answer, for `magi show` and the raw report route.
459///
460/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
461/// run` / `magi review`'s own `execute()`, and by then this process has
462/// nothing left in flight to report; the TUI does not track daemon liveness
463/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
464/// the web UI's raw-report route — needs this, and both already know how to
465/// ask whether a daemon is currently driving it.
466///
467/// `live` is whether a daemon's heartbeat currently names this run
468/// (`daemon::is_working_on`). An [`ActiveSeat`](crate::run::ActiveSeat) left
469/// behind by a killed process is not lied about as running just because
470/// nobody has cleared it from disk yet — see that type's own docs for why an
471/// entry alone is not proof of anything.
472pub 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
507/// Aggregate tables, for `magi stats`.
508pub 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    /// `COLOR` is process-global, so these tests cannot run concurrently.
608    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        // `run()` prints `state.dir()`, which reads the process-global home;
618        // pinning it here keeps this test off the operator's real one. The
619        // directory itself is never read, only its path printed, so nothing
620        // needs to create or clean it up.
621        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}