use crate::brain::mission_control::types::{
McAnalytics, McBrainFile, McBrainVerifyStats, McModelToolStat, McPhantomStats,
McStreamingStats, McToolStat, TimeWindow,
};
use crate::tui::app::mission_control::McPanel;
use crate::tui::app::mission_control::McState;
use crate::tui::app::mission_control::input::{KeyOutcome, decide};
use crate::tui::render::mission_control::render_analytics;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::empty())
}
fn render_buf(a: &McAnalytics, window: TimeWindow, scroll: u16, w: u16, h: u16) -> Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, w, h);
terminal
.draw(|f| render_analytics(f, a, window, scroll, area, true))
.unwrap();
terminal.backend().buffer().clone()
}
fn buf_text(buf: &Buffer) -> String {
let area = buf.area;
(0..area.height)
.map(|row| row_text(buf, row, area.width))
.collect::<Vec<_>>()
.join("\n")
}
fn row_text(buf: &Buffer, row: u16, width: u16) -> String {
(0..width)
.map(|col| {
buf.cell((col, row))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
}
fn full_fixture() -> McAnalytics {
McAnalytics {
tool_total_calls: 1000,
tool_total_fails: 50,
top_tools: vec![McToolStat {
name: "bash".into(),
total: 500,
failures: 10,
fail_rate: 2.0,
}],
flakiest_tools: vec![McToolStat {
name: "flaky_tool".into(),
total: 10,
failures: 5,
fail_rate: 50.0,
}],
rsi_applied_total: 12,
rsi_top_dimensions: vec![("voice".into(), 4)],
brain_files: vec![McBrainFile {
name: "MEMORY.md".into(),
kb: 8.0,
}],
brain_total_kb: 8.0,
phantom: McPhantomStats {
total: 20,
resolved: 15,
resolved_pct: 75.0,
by_model: vec![("claude-opus-4".into(), 10, 8)],
},
streaming: McStreamingStats {
total: 7,
..Default::default()
},
brain_verify: McBrainVerifyStats {
passes: 42,
rollbacks: 3,
..Default::default()
},
model_tools: vec![McModelToolStat {
model: "claude-opus-4".into(),
total: 100,
failures: 5,
fail_rate: 5.0,
}],
..Default::default()
}
}
#[test]
fn tabs_render_all_four_windows_with_hotkey_hint() {
let buf = render_buf(&McAnalytics::default(), TimeWindow::Month, 0, 80, 24);
let text = buf_text(&buf);
assert!(text.contains("[D]"), "missing Day tab");
assert!(text.contains("[W]"), "missing Week tab");
assert!(text.contains("[M]"), "missing Month tab");
assert!(text.contains("[All]"), "missing All tab");
assert!(text.contains("D/W/M/A to switch"), "missing hotkey hint");
}
#[test]
fn active_tab_is_bold_others_are_not() {
let buf = render_buf(&McAnalytics::default(), TimeWindow::Month, 0, 80, 24);
let mut d_bold = false;
let mut m_bold = false;
for col in 0..18u16 {
if let Some(cell) = buf.cell((col, 1)) {
let bold = cell.style().add_modifier.contains(Modifier::BOLD);
match cell.symbol() {
"D" => d_bold = bold,
"M" => m_bold = bold,
_ => {}
}
}
}
assert!(m_bold, "active Month tab should be bold");
assert!(!d_bold, "inactive Day tab should not be bold");
}
#[test]
fn totals_show_recovery_and_verify_counters() {
let a = McAnalytics {
tool_total_calls: 100,
tool_total_fails: 5,
streaming: McStreamingStats {
total: 7,
..Default::default()
},
brain_verify: McBrainVerifyStats {
passes: 42,
rollbacks: 3,
..Default::default()
},
..Default::default()
};
let text = buf_text(&render_buf(&a, TimeWindow::Month, 0, 80, 30));
assert!(text.contains("Totals"), "missing Totals card title");
assert!(text.contains("100 calls"), "missing tool call total");
assert!(text.contains("5 (5.0%)"), "missing fail count/rate");
assert!(text.contains("7 recovered"), "missing recovery counter");
assert!(text.contains("42 ok / 3 rollbk"), "missing verify counter");
}
#[test]
fn model_comparison_row_shows_fail_and_phantom_rate() {
let a = McAnalytics {
model_tools: vec![McModelToolStat {
model: "claude-opus-4".into(),
total: 100,
failures: 5,
fail_rate: 5.0,
}],
phantom: McPhantomStats {
total: 10,
resolved: 8,
resolved_pct: 80.0,
by_model: vec![("claude-opus-4".into(), 10, 8)],
},
..Default::default()
};
let text = buf_text(&render_buf(&a, TimeWindow::Month, 0, 80, 30));
assert!(
text.contains("Model reliability"),
"missing model card title"
);
assert!(text.contains("claude-opus-4"), "missing model name");
assert!(text.contains("fail 5.0%"), "missing fail rate");
assert!(text.contains("ph 10.0%"), "missing phantom rate");
assert!(
text.contains("✗"),
"phantom-heavy model should carry the ✗ glyph"
);
}
#[test]
fn healthy_model_row_shows_check_glyph() {
let a = McAnalytics {
model_tools: vec![McModelToolStat {
model: "gpt-5".into(),
total: 200,
failures: 2,
fail_rate: 1.0,
}],
..Default::default()
};
let text = buf_text(&render_buf(&a, TimeWindow::Month, 0, 80, 30));
assert!(text.contains("gpt-5"), "missing model name");
assert!(text.contains("✓"), "healthy model should carry the ✓ glyph");
assert!(
!text.contains("✗"),
"healthy model must not carry the ✗ glyph"
);
}
#[test]
fn phantom_panel_renders_detected_resolved_and_per_model() {
let a = McAnalytics {
phantom: McPhantomStats {
total: 20,
resolved: 15,
resolved_pct: 75.0,
by_model: vec![("gpt-5".into(), 12, 9)],
},
..Default::default()
};
let text = buf_text(&render_buf(&a, TimeWindow::Month, 0, 80, 30));
assert!(text.contains("Phantoms"), "missing Phantoms card title");
assert!(
text.contains("20 detected, 15 resolved (75.0%)"),
"missing detected/resolved line"
);
assert!(text.contains("gpt-5"), "missing per-model name");
assert!(text.contains("12 (9 resolved)"), "missing per-model counts");
}
#[test]
fn flakiest_card_title_tracks_active_window() {
let a = McAnalytics {
flakiest_tools: vec![McToolStat {
name: "flaky_tool".into(),
total: 10,
failures: 5,
fail_rate: 50.0,
}],
..Default::default()
};
let cases = [
(TimeWindow::Day, "24h"),
(TimeWindow::Week, "7d"),
(TimeWindow::Month, "30d"),
(TimeWindow::All, "all-time"),
];
for (window, label) in cases {
let text = buf_text(&render_buf(&a, window, 0, 80, 30));
assert!(text.contains("Flakiest"), "missing Flakiest card title");
assert!(
text.contains(label),
"window {window:?} should label flakiest as {label}"
);
}
}
#[test]
fn empty_sections_are_hidden_but_totals_and_tabs_remain() {
let text = buf_text(&render_buf(
&McAnalytics::default(),
TimeWindow::Month,
0,
80,
40,
));
assert!(text.contains("Totals"), "Totals card should always render");
assert!(text.contains("[M]"), "tabs should always render");
for absent in [
"Model reliability",
"Phantoms",
"Flakiest",
"Top tools",
"Brain files",
"RSI applied",
] {
assert!(
!text.contains(absent),
"{absent} card should be hidden when empty"
);
}
}
#[test]
fn populated_sections_render_as_distinct_cards() {
let text = buf_text(&render_buf(&full_fixture(), TimeWindow::Month, 0, 120, 30));
for title in [
"Totals",
"Model reliability",
"Phantoms",
"Top tools",
"Brain files",
"RSI applied",
] {
assert!(text.contains(title), "missing {title} card");
}
assert!(
text.contains("Flakiest (30d)"),
"missing labelled Flakiest card"
);
}
#[test]
fn renders_at_80_and_120_col_without_panic() {
let a = full_fixture();
for width in [80u16, 120] {
let buf = render_buf(&a, TimeWindow::Month, 0, width, 24);
let text = buf_text(&buf);
assert!(text.contains("[All]"), "tabs should render at {width}-col");
assert!(
text.contains("Totals"),
"Totals card should render at {width}-col"
);
}
}
#[test]
fn letter_keys_switch_window_from_any_panel() {
let mut s = McState {
focused_panel: McPanel::Activity,
..Default::default()
};
assert_eq!(s.analytics_window, TimeWindow::Month);
let cases = [
(KeyCode::Char('d'), TimeWindow::Day),
(KeyCode::Char('w'), TimeWindow::Week),
(KeyCode::Char('A'), TimeWindow::All),
(KeyCode::Char('m'), TimeWindow::Month),
];
for (code, want) in cases {
let out = decide(&mut s, 5, key(code));
assert_eq!(out, KeyOutcome::AnalyticsWindowChanged, "key {code:?}");
assert_eq!(s.analytics_window, want, "key {code:?}");
}
}
#[test]
fn same_window_key_is_consumed_not_changed() {
let mut s = McState {
analytics_window: TimeWindow::Day,
..Default::default()
};
let out = decide(&mut s, 5, key(KeyCode::Char('d')));
assert_eq!(out, KeyOutcome::Consumed);
assert_eq!(s.analytics_window, TimeWindow::Day);
}
#[test]
fn filter_keys_are_global_across_panels() {
let mut s = McState {
focused_panel: McPanel::Inbox,
..Default::default()
};
let out = decide(&mut s, 5, key(KeyCode::Char('w')));
assert_eq!(out, KeyOutcome::AnalyticsWindowChanged);
assert_eq!(s.analytics_window, TimeWindow::Week);
}
#[test]
fn capital_a_is_all_but_lowercase_a_applies_in_inbox() {
let mut s = McState {
focused_panel: McPanel::Inbox,
..Default::default()
};
assert_eq!(
decide(&mut s, 3, key(KeyCode::Char('a'))),
KeyOutcome::ApplySelected
);
assert_eq!(
s.analytics_window,
TimeWindow::Month,
"apply must not touch the window"
);
assert_eq!(
decide(&mut s, 3, key(KeyCode::Char('A'))),
KeyOutcome::AnalyticsWindowChanged
);
assert_eq!(s.analytics_window, TimeWindow::All);
}
#[test]
fn scroll_keys_move_offset_in_analytics() {
let mut s = McState {
focused_panel: McPanel::Analytics,
..Default::default()
};
assert_eq!(s.scroll_offset, 0);
assert_eq!(
decide(&mut s, 5, key(KeyCode::Char('j'))),
KeyOutcome::Consumed
);
assert_eq!(s.scroll_offset, 1, "j should scroll down");
assert_eq!(
decide(&mut s, 5, key(KeyCode::Char('k'))),
KeyOutcome::Consumed
);
assert_eq!(s.scroll_offset, 0, "k should scroll back up");
assert_eq!(
decide(&mut s, 5, key(KeyCode::Char('k'))),
KeyOutcome::Consumed
);
assert_eq!(s.scroll_offset, 0);
}
#[test]
fn home_resets_scroll_in_analytics() {
let mut s = McState {
focused_panel: McPanel::Analytics,
scroll_offset: 10,
..Default::default()
};
decide(&mut s, 5, key(KeyCode::Home));
assert_eq!(s.scroll_offset, 0, "Home should reset scroll");
}