use ratatui::backend::TestBackend;
use ratatui::Terminal;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use whirr::app::{App, Focus};
use whirr::sampler::ProcInfo;
use whirr::ui;
use whirr::ui::theme;
use whirr::update::Update;
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"] {
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");
}
fn demo_with_processes(n: i32) -> App {
let mut app = App::demo();
let f = app.fast.as_mut().expect("demo() ingests a fast snapshot");
f.processes = (0..n)
.map(|i| ProcInfo {
pid: 1000 + i,
name: format!("proc{i:02}"),
cpu: (n - i) as f32,
mem: 1_000_000,
})
.collect();
app
}
#[test]
fn a_tall_process_panel_fills_with_processes_instead_of_blank_rows() {
let app = demo_with_processes(30);
let g = draw_grid_app(&app, 120, 60);
let drawn = g.iter().filter(|l| l.contains("proc")).count();
assert!(
drawn > 10,
"a 26-row panel should draw more than the old 10-row cap, drew {drawn}"
);
assert!(
g.iter().any(|l| l.contains("proc19")),
"the 20th process should be on screen in a panel this tall"
);
}
fn demo_pending_kill(focus: Focus) -> App {
let mut app = demo_with_focus(focus);
app.select(0);
app.on_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(app.pending_kill.is_some(), "{focus:?} row should be killable");
app
}
#[test]
fn the_kill_dialog_lands_in_the_same_place_whichever_card_the_target_came_from() {
let rows: Vec<usize> = [Focus::Processes, Focus::Localhost]
.iter()
.map(|&focus| {
let g = draw_grid_app(&demo_pending_kill(focus), 120, 40);
g.iter()
.position(|l| l.contains("y confirm"))
.unwrap_or_else(|| panic!("{focus:?}: no dialog on screen"))
})
.collect();
assert_eq!(rows[0], rows[1], "the dialog must not move with the focused card");
assert!((15..25).contains(&rows[0]), "dialog should sit near the middle of 40 rows, at {}", rows[0]);
}
#[test]
fn the_kill_dialog_names_the_target_and_its_pid() {
let g = draw_grid_app(&demo_pending_kill(Focus::Localhost), 120, 40);
let dialog: String = g.join("\n");
assert!(dialog.contains("glassbook-frontend"), "dialog should name the target");
assert!(dialog.contains("501"), "dialog should carry the pid");
assert!(dialog.contains("3 ports"), "dialog should say how many listeners die with it");
}
#[test]
fn the_kill_dialog_sits_in_its_own_box() {
let g = draw_grid_app(&demo_pending_kill(Focus::Processes), 120, 40);
let row = g.iter().position(|l| l.contains("y confirm")).expect("dialog on screen");
let above: &str = &g[row - 1];
assert!(
above.contains('╭') || above.contains('│'),
"the confirmation line should be inside a bordered box, row above was {above:?}"
);
}
#[test]
fn a_dialog_renders_at_every_size_without_panicking() {
for (w, h) in [(200, 50), (120, 40), (80, 24), (48, 14), (30, 8), (20, 5), (6, 2), (1, 1)] {
let app = demo_pending_kill(Focus::Processes);
let content = draw_app_at(&app, w, h);
assert!(!content.is_empty(), "{w}x{h} produced nothing");
}
}
#[test]
fn a_pathological_target_name_cannot_stretch_the_dialog_across_the_screen() {
let mut app = App::demo();
let f = app.fast.as_mut().expect("demo() ingests a fast snapshot");
f.processes[0].name = "x".repeat(300);
app.on_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(app.pending_kill.is_some());
let g = draw_grid_app(&app, 120, 40);
let title_row = g.iter().find(|l| l.contains("confirm kill")).expect("dialog on screen");
let chars: Vec<char> = title_row.chars().collect();
let open = chars.iter().position(|&c| c == '╭').expect("dialog's opening corner");
let close = chars[open..].iter().position(|&c| c == '╮').expect("dialog's closing corner") + open;
let width = close - open + 1;
assert!(width <= 66, "a 300-char name stretched the dialog to {width} columns");
assert!(g.iter().any(|l| l.contains("Processes")), "the dialog ate the whole screen");
}
#[test]
fn the_footer_offers_the_dialog_keys_while_a_kill_is_pending() {
let c = draw_app_at(&demo_pending_kill(Focus::Processes), 120, 40);
assert!(c.contains("y confirm"), "footer should offer y while pending");
assert!(c.contains("n cancel"), "footer should offer n while pending");
assert!(
!c.contains("tab focus"),
"the normal hints must not stand while every other key is inert"
);
}
fn demo_pending_port_pick() -> App {
let mut app = demo_with_focus(Focus::Localhost);
app.select(0); app.on_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert!(app.pending_port_pick.is_some(), "a multi-port row should ask");
app
}
#[test]
fn the_port_picker_numbers_every_candidate_and_hides_the_ephemeral_one() {
let g = draw_grid_app(&demo_pending_port_pick(), 120, 40);
assert!(g.iter().any(|l| l.contains("glassbook-frontend")), "picker should name the row");
assert!(g.iter().any(|l| l.contains("1 :4206")), "entry 1 should open 4206");
assert!(g.iter().any(|l| l.contains("2 :6006")), "entry 2 should open 6006");
assert!(
!g.iter().any(|l| l.contains("3 :")),
"there must be no third entry — 63643 is ephemeral and never a page to visit"
);
}
#[test]
fn the_port_picker_lands_in_its_own_centred_box() {
let g = draw_grid_app(&demo_pending_port_pick(), 120, 40);
let row = g.iter().position(|l| l.contains("4206")).expect("picker on screen");
assert!((13..27).contains(&row), "picker should sit near the middle of 40 rows, at {row}");
assert!(
g[row - 1].contains('│') || g[row - 1].contains('╭'),
"the candidate list should be inside a bordered box"
);
}
#[test]
fn the_footer_offers_the_picker_keys_while_a_port_pick_is_pending() {
let c = draw_app_at(&demo_pending_port_pick(), 120, 40);
assert!(c.contains("1-2 open"), "footer should say which digits are live");
assert!(c.contains("n cancel"), "footer should offer the way out");
assert!(!c.contains("tab focus"), "the normal hints must not stand while keys are inert");
}
#[test]
fn the_port_picker_renders_at_every_size_without_panicking() {
for (w, h) in [(200, 50), (120, 40), (80, 24), (48, 14), (30, 8), (20, 5), (6, 2), (1, 1)] {
let content = draw_app_at(&demo_pending_port_pick(), w, h);
assert!(!content.is_empty(), "{w}x{h} produced nothing");
}
}
#[test]
fn the_running_version_is_on_screen_whether_or_not_an_update_exists() {
let expected = format!("v{}", env!("CARGO_PKG_VERSION"));
for app in [App::demo(), demo_with_update()] {
let c = draw_app_at(&app, 120, 40);
assert!(c.contains(&expected), "header should carry {expected}");
}
}
#[test]
fn the_version_survives_every_header_tier() {
let expected = format!("v{}", env!("CARGO_PKG_VERSION"));
for (w, h) in [(160, 45), (120, 40), (103, 45), (80, 24), (60, 15)] {
let c = draw_app_at(&App::demo(), w, h);
assert!(c.contains(&expected), "{w}x{h}: header lost the version");
}
}
fn demo_with_update() -> App {
let mut app = App::demo();
app.update =
Some(Update { latest: "9.9.9".into(), hint: "brew update && brew upgrade whirr" });
app
}
#[test]
fn an_available_update_is_announced_with_the_command_that_installs_it() {
let c = draw_app_at(&demo_with_update(), 120, 40);
assert!(c.contains("9.9.9"), "the notice should name the version");
assert!(c.contains("brew upgrade whirr"), "and say how to get it");
}
#[test]
fn the_update_notice_does_not_displace_the_key_hints() {
let c = draw_app_at(&demo_with_update(), 120, 40);
for hint in ["↑↓ select", "tab focus", "q quit"] {
assert!(c.contains(hint), "{hint} lost to the update notice");
}
}
#[test]
fn a_narrow_terminal_keeps_the_keys_and_drops_the_notice() {
let c = draw_app_at(&demo_with_update(), 60, 20);
assert!(c.contains("q quit"), "keys must survive at 60 columns");
assert!(!c.contains("9.9.9"), "the notice should stand down rather than overlap the keys");
}
#[test]
fn no_update_means_no_notice() {
let c = draw_app_at(&App::demo(), 120, 40);
assert!(!c.contains("available"), "nothing should be announced when nothing is newer");
}
#[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_open_shows_only_for_localhost() {
let c = draw_app_at(&demo_with_focus(Focus::Localhost), 120, 40);
assert!(c.contains("o open"), "Localhost should show o open");
for focus in [Focus::Processes, Focus::Sessions, Focus::Others] {
let c = draw_app_at(&demo_with_focus(focus), 120, 40);
assert!(
!c.contains("o open"),
"{focus:?} must not show o open — only a dev server has a URL"
);
}
}
#[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");
}
}