use ratatui::backend::TestBackend;
use ratatui::Terminal;
use whirr::app::{App, Focus};
use whirr::ui;
use whirr::ui::theme;
fn draw_at(w: u16, h: u16) -> String {
let app = App::demo();
draw_app_at(&app, w, h)
}
fn draw_app_at(app: &App, w: u16, h: u16) -> String {
draw_buffer_at(app, w, h).content().iter().map(|c| c.symbol()).collect()
}
fn draw_buffer_at(app: &App, w: u16, h: u16) -> ratatui::buffer::Buffer {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| ui::draw(f, app)).unwrap();
terminal.backend().buffer().clone()
}
fn demo_with_focus(focus: Focus) -> App {
let mut app = App::demo();
app.focus = focus;
app
}
fn has_braille(s: &str) -> bool {
s.chars().any(|c| ('\u{2801}'..='\u{28FF}').contains(&c))
}
fn draw_grid(w: u16, h: u16) -> Vec<String> {
draw_grid_app(&App::demo(), w, h)
}
fn draw_grid_app(app: &App, w: u16, h: u16) -> Vec<String> {
let flat = draw_app_at(app, w, h);
let cells: Vec<char> = flat.chars().collect();
cells.chunks(w as usize).map(|row| row.iter().collect()).collect()
}
fn row_of(lines: &[String], needle: &str) -> usize {
lines.iter().position(|l| l.contains(needle)).unwrap_or_else(|| panic!("{needle:?} not found"))
}
#[test]
fn frame_background_is_base_at_sampled_positions() {
let app = App::demo();
let mut terminal = Terminal::new(TestBackend::new(160, 45)).unwrap();
terminal.draw(|f| ui::draw(f, &app)).unwrap();
let buf = terminal.backend().buffer().clone();
assert_eq!(buf[(0, 0)].bg, theme::BASE, "empty top-left corner should carry the base background");
let lines: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>())
.collect();
let cpu_row = lines.iter().position(|l| l.contains("CPU")).expect("CPU card present");
let border_x = lines[cpu_row].find(|c: char| c != ' ').expect("border corner present on CPU's title row");
assert_eq!(
buf[(border_x as u16, cpu_row as u16)].bg, theme::BASE,
"CPU card's border cell should keep the base background under its fg-only border style"
);
let processes_row = lines.iter().position(|l| l.contains("Processes")).expect("Processes card present");
let body_y = (processes_row + 2) as u16; assert_eq!(
buf[(2, body_y)].bg, theme::BASE,
"plain process row text should keep the base background, not Reset"
);
}
#[test]
fn renders_at_all_sizes_without_panic() {
for (w, h) in [(200, 50), (120, 40), (80, 24), (60, 15), (20, 5)] {
let content = draw_at(w, h);
assert!(!content.is_empty(), "{w}x{h}");
}
}
#[test]
fn full_size_shows_all_panels() {
let c = draw_at(160, 45);
for needle in [
"CPU", "Temp", "Power", "Memory", "Processes", "Network",
"localhost", "claude sessions", "others",
] {
assert!(c.contains(needle), "missing {needle}");
}
assert!(c.contains("glassbook-frontend"), "port row missing");
}
#[test]
fn tiny_size_collapses_to_essentials() {
let c = draw_at(48, 14);
assert!(c.contains("Processes"));
assert!(!c.contains("Ports"));
}
#[test]
fn stock_80x24_fits_without_starving_header_or_ports() {
let lines = draw_grid(80, 24);
assert_eq!(lines.len(), 24);
assert_eq!(row_of(&lines, "CPU"), 5, "gauges row wasn't given its fixed height");
let processes_row = row_of(&lines, "Processes");
assert_eq!(processes_row, 13, "process table didn't start where the body begins");
let ports_row = row_of(&lines, "Ports");
assert_eq!(ports_row - processes_row, 6, "process table should have shrunk to 6 rows (was 7 before the footer row was taken off the bottom), not its full 13-row cap");
assert_eq!(ports_row, 19, "ports card should start right after the shrunk process table");
assert_eq!(lines.len() - 2, ports_row + 3, "ports card should occupy exactly its Min(4) floor, one row above the global footer");
assert!(lines[23].starts_with('↑'), "last row should be the global footer");
}
#[test]
fn large_size_gives_the_three_card_row_its_full_height() {
let c = draw_at(160, 45);
for tty in ["ttys020", "ttys021", "ttys004"] {
assert!(c.contains(tty), "missing {tty} — three-card row didn't reach its full height");
}
assert!(c.contains("eye-claudius"), "fourth demo session missing at large size");
}
#[test]
fn ports_card_sits_under_processes_not_full_width() {
let (w, h) = (100u16, 45u16);
let flat = draw_at(w, h);
let cells: Vec<char> = flat.chars().collect();
let lines: Vec<String> = cells
.chunks(w as usize)
.map(|row| row.iter().collect())
.collect();
assert_eq!(lines.len(), h as usize);
let ports_row = lines
.iter()
.position(|l| l.contains("Ports"))
.expect("ports title row present");
let last_char = lines[ports_row].chars().last().unwrap();
assert_eq!(
last_char, '│',
"ports title row should end inside the network panel's right border, \
got {last_char:?} (full-width ports row?)"
);
}
#[test]
fn three_cards_render_side_by_side_at_full_width() {
let c = draw_at(120, 40);
for title in ["localhost", "claude", "others"] {
assert!(c.contains(title), "card title {title:?} missing at 120x40");
}
}
#[test]
fn narrow_terminals_get_the_single_grouped_card() {
let c = draw_at(80, 24);
assert!(c.contains("Ports"), "the grouped Ports card should render below 120 cols");
}
#[test]
fn full_tier_shows_hero_font_and_burst_fan() {
let app = App::demo();
let buf = draw_buffer_at(&app, 160, 45);
assert!(has_braille(&draw_app_at(&app, 160, 45)), "burst fan missing");
let wordmark_filled = (0..buf.area.width)
.flat_map(|x| (0..9u16).map(move |y| (x, y)))
.filter(|&(x, y)| buf[(x, y)].style().bg == Some(theme::ACCENT))
.count();
let wordmark_expected: usize =
whirr::ui::font::big_text("WHIRR").iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
assert_eq!(wordmark_filled, wordmark_expected, "header wordmark bitmap pixel count mismatch");
let cpu_hero_expected: usize =
whirr::ui::font::big_text("41%").iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
let power_hero_expected: usize =
whirr::ui::font::big_text("7.9 W").iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
let accent_filled_below_header = (0..buf.area.width)
.flat_map(|x| (9..buf.area.height).map(move |y| (x, y)))
.filter(|&(x, y)| buf[(x, y)].style().bg == Some(theme::ACCENT))
.count();
assert_eq!(
accent_filled_below_header,
cpu_hero_expected + power_hero_expected,
"cpu+power hero bitmap pixel count mismatch for \"41%\" and \"7.9 W\""
);
}
#[test]
fn compact_tier_shares_the_brand_assets_but_not_the_hero_numbers() {
let app = App::demo();
let c = draw_app_at(&app, 80, 24);
assert!(has_braille(&c), "burst fan should render at 80x24");
assert!(!c.contains('✻'), "hand-drawn fan should be gone");
assert!(!c.contains('▐'), "temp thermometer should be gone");
let buf = draw_buffer_at(&app, 80, 24);
let accent_cells = buf.content().iter().filter(|c| c.style().bg == Some(theme::ACCENT)).count();
let wordmark: usize =
whirr::ui::font::big_text("WHIRR").iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
assert_eq!(accent_cells, wordmark, "compact tier should paint the wordmark bitmap and no hero numbers");
assert!(c.contains("88.0°C"), "compact temp readout missing");
}
fn band_dimensions(w: u16, h: u16) -> (usize, usize, usize) {
let lines = draw_grid(w, h);
let processes_start = match lines.iter().position(|l| l.contains("Processes")) {
Some(r) => r,
None => return (0, 0, 0),
};
let card_start = match lines.iter().position(|l| l.contains("localhost")) {
Some(r) => r,
None => return (0, 0, 0),
};
let processes_band_height = card_start - processes_start;
let mut card_end = card_start;
for (i, line) in lines.iter().enumerate().skip(card_start + 1) {
if line.contains("╯") || line.contains("┘") || i == lines.len() - 1 {
card_end = i;
break;
}
}
let card_total_height = card_end - card_start + 1;
let card_content_rows = card_total_height.saturating_sub(2); (processes_band_height, card_total_height, card_content_rows)
}
fn card_band_dimensions(w: u16, h: u16) -> (usize, usize) {
let (_, total, content) = band_dimensions(w, h);
(total, content)
}
#[test]
fn card_band_has_adequate_space_at_120x30() {
let (total, content) = card_band_dimensions(120, 30);
eprintln!("120x30: card band total={} rows, content={} rows", total, content);
assert_eq!(total, 5, "At 120x30 card band should be 5 rows total");
assert_eq!(content, 3, "At 120x30 card band should have 3 content rows");
}
#[test]
fn card_band_reaches_full_height_at_160x45() {
let (total, content) = card_band_dimensions(160, 45);
eprintln!("160x45: card band total={} rows, content={} rows", total, content);
assert_eq!(total, 10, "At 160x45 card band should be 10 rows total");
assert_eq!(content, 8, "At 160x45 card band should have 8 content rows");
}
#[test]
fn card_band_at_120x40() {
let (total, content) = card_band_dimensions(120, 40);
eprintln!("120x40: card band total={} rows, content={} rows", total, content);
assert_eq!(total, 10, "At 120x40 card band should be 10 rows total");
assert_eq!(content, 8, "At 120x40 card band should have 8 content rows");
}
#[test]
fn tier_boundary_is_exactly_120x30() {
let first_gauge_row = |w, h| row_of(&draw_grid(w, h), "CPU");
assert_eq!(first_gauge_row(120, 30), 9, "120x30 must be full tier");
assert_eq!(first_gauge_row(119, 30), 5, "119x30 must be compact");
assert_eq!(first_gauge_row(120, 29), 5, "120x29 must be compact");
}
#[test]
fn footer_kill_shows_only_for_killable_panels() {
for focus in [Focus::Processes, Focus::Localhost] {
let c = draw_app_at(&demo_with_focus(focus), 120, 40);
assert!(c.contains("k kill"), "{focus:?} should show k kill");
}
for focus in [Focus::Sessions, Focus::Others] {
let c = draw_app_at(&demo_with_focus(focus), 120, 40);
assert!(!c.contains("k kill"), "{focus:?} must not show k kill — k is inert there");
}
}
#[test]
fn footer_sort_shows_only_for_processes() {
let c = draw_app_at(&demo_with_focus(Focus::Processes), 120, 40);
assert!(c.contains("c/m sort"), "Processes should show c/m sort");
for focus in [Focus::Localhost, Focus::Sessions, Focus::Others] {
let c = draw_app_at(&demo_with_focus(focus), 120, 40);
assert!(!c.contains("c/m sort"), "{focus:?} must not show c/m sort — sorting is a process-table concept");
}
}
#[test]
fn footer_quit_and_tab_focus_show_for_every_focus() {
for focus in [Focus::Processes, Focus::Localhost, Focus::Sessions, Focus::Others] {
let c = draw_app_at(&demo_with_focus(focus), 120, 40);
assert!(c.contains("tab focus"), "{focus:?} should show tab focus (global)");
assert!(c.contains("q quit"), "{focus:?} should show q quit (global)");
assert!(c.contains("↑↓ select"), "{focus:?} should show ↑↓ select (global)");
}
}
#[test]
fn footer_renders_on_the_last_row_of_the_screen() {
for (w, h) in [(120u16, 30u16), (160, 45), (80, 24)] {
let lines = draw_grid(w, h);
assert_eq!(lines.len(), h as usize);
let last = &lines[h as usize - 1];
assert!(
last.contains("↑↓ select") && last.contains("tab focus") && last.contains("q quit"),
"{w}x{h}: footer should render on the screen's last row, got {last:?}"
);
}
}
#[test]
fn processes_card_no_longer_contains_the_old_hint_text() {
let c = draw_app_at(&demo_with_focus(Focus::Sessions), 160, 45);
assert!(
!c.contains("↑↓ select · c/m sort · k kill · tab focus · q quit"),
"the old combined hint line should no longer live inside the Processes card"
);
}
#[test]
fn selected_process_row_highlight_is_contiguous_across_its_bars() {
let app = App::demo(); let buf = draw_buffer_at(&app, 160, 45);
let lines: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>())
.collect();
let y = lines
.iter()
.position(|l| l.contains("kernel_task"))
.expect("demo's top process row should be on screen") as u16;
let highlighted: Vec<u16> = (0..buf.area.width)
.filter(|&x| buf[(x, y)].style().bg == Some(theme::BG_CELL))
.collect();
assert!(!highlighted.is_empty(), "selected row should be highlighted at all");
let (first, last) = (highlighted[0], *highlighted.last().unwrap());
let gaps: Vec<(u16, String, Option<ratatui::style::Color>)> = (first..=last)
.filter(|&x| buf[(x, y)].style().bg != Some(theme::BG_CELL))
.map(|x| (x, buf[(x, y)].symbol().to_string(), buf[(x, y)].style().bg))
.collect();
assert!(
gaps.is_empty(),
"selected row {y} highlight breaks at {gaps:?} (row spans x={first}..={last})"
);
let unhighlighted_bars: Vec<u16> = (0..buf.area.width)
.filter(|&x| matches!(buf[(x, y)].symbol(), "▮" | "▯"))
.filter(|&x| buf[(x, y)].style().bg != Some(theme::BG_CELL))
.collect();
assert!(
unhighlighted_bars.is_empty(),
"selected row {y}: micro-bar cells at x={unhighlighted_bars:?} dropped the row background"
);
}
#[test]
fn scrolling_one_card_does_not_scroll_an_unfocused_one() {
let mut app = demo_with_focus(Focus::Sessions);
assert!(draw_app_at(&app, 120, 30).contains("kernel_task"), "top process visible to begin with");
for _ in 0..3 {
app.on_key(ratatui::crossterm::event::KeyEvent::from(
ratatui::crossterm::event::KeyCode::Down,
));
}
assert_eq!(app.selected(), 3, "the sessions cursor should have moved");
assert!(
draw_app_at(&app, 120, 30).contains("kernel_task"),
"the unfocused process table scrolled because another card's cursor moved"
);
}
#[test]
fn an_empty_port_scan_leaves_the_sessions_card_intact() {
use whirr::sampler::{SlowSnap, Snapshot};
let mut app = App::demo();
let sessions = app.sessions().to_vec();
assert!(!sessions.is_empty(), "demo has sessions to lose");
app.ingest(Snapshot::Slow(SlowSnap { rows: Vec::new(), sessions, stale: false }));
let out = draw_app_at(&app, 160, 45);
assert!(out.contains("ttys020"), "claude sessions vanished with the ports");
assert!(out.contains("no listening ports"), "the ports cards should be the empty ones");
}
#[test]
fn narrow_but_tall_gets_the_hero_design_in_a_two_by_two_grid() {
let app = App::demo();
let lines = draw_grid_app(&app, 103, 45);
assert!(has_braille(&draw_app_at(&app, 103, 45)), "burst fan missing at 103x45");
assert_eq!(row_of(&lines, "CPU"), 9, "first gauge band should start below the 9-row header");
assert_eq!(row_of(&lines, "Temp"), 9, "Temp should sit beside CPU, not below it");
assert_eq!(row_of(&lines, "Power"), 21, "Power should start the second gauge band");
assert_eq!(row_of(&lines, "Memory"), 21, "Memory should sit beside Power");
let buf = draw_buffer_at(&app, 103, 45);
let hero_cells = (0..buf.area.width)
.flat_map(|x| (9..21u16).map(move |y| (x, y)))
.filter(|&(x, y)| buf[(x, y)].style().bg == Some(theme::ACCENT))
.count();
let expected: usize =
whirr::ui::font::big_text("41%").iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
assert_eq!(hero_cells, expected, "CPU hero bitmap missing or wrong size in the 2x2 grid");
}
#[test]
fn two_by_two_grid_falls_back_below_either_floor() {
for (w, h, why) in [(103u16, 39u16, "one row below the 40-row floor"), (69, 45, "one column below the 70-col floor")] {
let app = App::demo();
let lines = draw_grid_app(&app, w, h);
assert_eq!(row_of(&lines, "CPU"), 5, "{w}x{h} ({why}): compact header is 5 rows, so gauges start at row 5");
}
}