//! Tests for [`super`]. use super::*; use ratatui::style::Color; fn theme() -> Theme { crate::theme::test_theme(crate::theme::Mode::Dark) } fn list_of(n: usize, selected: Option) -> AlloyList<'static> { // Leaked so the test list can hold a 'static theme reference; the // widget borrows rather than owns, and these are per-test one-offs. let theme: &'static Theme = Box::leak(Box::new(theme())); let items: Vec> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); AlloyList::new(theme, items).selected(selected) } #[test] fn short_list_never_scrolls() { assert_eq!(list_of(3, Some(2)).offset(10), 0); } // Selection near the top must not scroll past the start of the list — a // naive `selected - height/2` underflows or shows blank rows above row 0. #[test] fn offset_clamps_at_the_top() { assert_eq!(list_of(50, Some(0)).offset(10), 0); assert_eq!(list_of(50, Some(2)).offset(10), 0); } // Selection at the end must land the last row on the last visible line, // not scroll into empty space past the end of the list. #[test] fn offset_clamps_at_the_bottom() { assert_eq!(list_of(50, Some(49)).offset(10), 40); } #[test] fn offset_centers_a_midlist_selection() { assert_eq!(list_of(50, Some(25)).offset(10), 20); } #[test] fn row_y_maps_visible_items_to_screen_rows() { let area = Rect::new(0, 5, 20, 10); assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5)); assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7)); } // After a scroll the mapping has to follow the offset. A connector using a // separate copy of the scroll rule is exactly what this prevents. #[test] fn row_y_accounts_for_scrolling() { let area = Rect::new(0, 0, 20, 10); // 50 items, selection at 25 => offset 20, so item 20 is the top row. assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0)); assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5)); } #[test] fn row_y_is_none_for_rows_scrolled_out_of_view() { let area = Rect::new(0, 0, 20, 10); assert_eq!( list_row_y(area, 50, Some(25), 0), None, "above the viewport" ); assert_eq!( list_row_y(area, 50, Some(25), 49), None, "below the viewport" ); assert_eq!( list_row_y(area, 3, Some(0), 9), None, "past the end of the list" ); } fn render_tabs(selected: usize, width: u16) -> String { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, width, 1)); AlloyTabs::new(&theme, ["installed", "boxes", "system"]) .selected(selected) .render(Rect::new(0, 0, width, 1), &mut buf); buf.content() .iter() .map(ratatui::buffer::Cell::symbol) .collect() } #[test] fn selected_tab_is_bracketed_and_others_are_not() { let rendered = render_tabs(0, 60); assert!( rendered.contains("[ installed ]"), "selected tab is bracketed" ); assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); assert!(rendered.contains("boxes"), "unselected labels still render"); } // The bar must not shift horizontally as selection moves, or every tab // change reads as the whole row twitching. Unselected labels pad to the // bracket width for exactly this reason. #[test] fn labels_hold_their_columns_across_selections() { let first = render_tabs(0, 60); let last = render_tabs(2, 60); assert_eq!( first.find("system"), last.find("system"), "a label sits in the same columns whichever tab is selected" ); } // FocusRing::focus ignores out-of-range slots rather than clamping, and the // bar has to agree: showing a neighbouring tab as current would misreport // which screen the user is looking at. #[test] fn out_of_range_selection_brackets_nothing() { let rendered = render_tabs(9, 60); assert!(!rendered.contains('['), "no tab is marked current"); assert!(rendered.contains("installed"), "labels still render"); } #[test] fn zero_height_area_renders_nothing_rather_than_panicking() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1)); AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf); AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf); } fn render_modal(area: Rect) -> Vec { let theme = theme(); let mut buf = Buffer::empty(area); AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf); (0..area.height) .map(|y| { (0..area.width) .map(|x| buf[(x, y)].symbol()) .collect::() }) .collect() } #[test] fn modal_shows_its_message_and_both_keys() { let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n"); assert!(rows.contains("Remove tailscale?"), "message renders"); assert!(rows.contains("remove"), "title renders"); assert!(rows.contains("Enter"), "confirm key renders"); assert!(rows.contains("Esc"), "cancel key renders"); } // The keys are pinned to the last inner row rather than flowing after the // message. A prompt whose dismiss keys move with message length, or fall // off a short box, is a modal the user cannot get out of. #[test] fn keys_sit_on_the_last_row_whatever_the_message_length() { for height in [5, 7, 12] { let rows = render_modal(Rect::new(0, 0, 40, height)); let last_inner = &rows[height as usize - 2]; assert!( last_inner.contains("Enter") && last_inner.contains("Esc"), "height {height}: keys belong on the last inner row, got {last_inner:?}" ); } } #[test] fn modal_survives_an_area_too_small_to_draw_in() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7)); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf); } fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); draw(&mut buf, area); (0..height) .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::()) .collect() } /// Column of `needle` in `row`, counted in characters. /// /// `str::find` counts bytes, and the focus marker is three of them, so a /// byte offset says a focused row's value starts two columns right of an /// unfocused one when both are in the same column. fn column(row: &str, needle: &str) -> Option { let at = row.find(needle)?; Some(row[..at].chars().count()) } fn field_rows(theme: &Theme) -> Vec> { vec![ FormRow::Section { label: "cursor", open: true, }, FormRow::Field( AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" }) .help(Some("Cursor shape.")), ), FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))), FormRow::Section { label: "colors", open: false, }, ] } #[test] fn a_toggle_reads_without_color_or_a_patched_font() { let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf); }); assert!(rows[0].contains("[x]"), "{rows:?}"); let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf); }); assert!(rows[0].contains("[ ]"), "{rows:?}"); } #[test] fn a_color_field_draws_a_swatch_beside_its_hex() { let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" }) .render(area, buf); }); assert!(rows[0].contains("██ #e4ded6"), "{rows:?}"); } // A hex the widget cannot parse still shows its text. The swatch helps read // a value; it is not the value, and dropping the text would hide the one // thing the user needs to see to fix it. #[test] fn an_unparseable_color_still_renders_its_text() { let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" }) .render(area, buf); }); assert!(rows[0].contains("e4ded6"), "{rows:?}"); assert!( !rows[0].contains('█'), "no swatch for a value it cannot parse" ); } #[test] fn swatch_parses_both_hex_lengths_and_rejects_the_rest() { assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6))); assert_eq!( swatch("#e4ded6ff"), Some(Color::Rgb(0xe4, 0xde, 0xd6)), "alpha is dropped, not refused", ); assert_eq!(swatch("e4ded6"), None, "no hash"); assert_eq!(swatch("#e4ded"), None, "wrong length"); assert_eq!(swatch("#gggggg"), None, "not hex"); } // An edited row shows the buffer and its caret, not the committed value. #[test] fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() { let mut buffer = TextField::new(); buffer.set("Departure Mono"); buffer.home(); let rows = render_to(60, 1, |buf, area| { AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm")) .edit(Some(&buffer)) .render(area, buf); }); assert!(rows[0].contains("Departure Mono"), "{rows:?}"); assert!( !rows[0].contains("IosevkaTerm"), "the committed value is not drawn" ); } // The caret has to be visible while appending, which is where it spends // most of its life. Past the end of the line there is no character under // it, so it draws on a space. #[test] fn a_caret_past_the_end_of_the_line_still_has_a_cell() { let mut buffer = TextField::new(); buffer.set("alloy"); let (before, under, after) = buffer.split(); assert_eq!((before, under, after), ("alloy", None, "")); let spans = caret_spans(&theme(), &buffer, Style::default()); assert_eq!(spans[1].content, " ", "the caret sits on a space"); } #[test] fn a_form_lines_its_value_column_up_across_rows() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(1) .render(area, buf); }); // "blinking" is the longest label, so both values start in the same // column despite "shape" being three characters shorter. let shape = column(&rows[1], "block").expect("enum label renders"); let blinking = column(&rows[2], "[ ]").expect("toggle renders"); assert_eq!(shape, blinking, "{rows:?}"); } #[test] fn a_section_header_shows_whether_it_is_folded() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(area, buf); }); assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}"); assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}"); } #[test] fn the_focused_row_carries_the_same_marker_a_list_row_would() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(2) .render(area, buf); }); assert!(rows[2].starts_with(MARKER), "{rows:?}"); assert!(!rows[1].starts_with(MARKER), "only one row is focused"); } // The footer belongs to whichever row is focused, and a diagnostic beats // help: it is the reason an edit did not commit. #[test] fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(1) .render(area, buf); }); assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}"); let rows = render_to(50, 6, |buf, area| { let mut rows = field_rows(&theme); rows[1] = FormRow::Field( AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" }) .help(Some("Cursor shape.")) .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))), ); AlloyForm::new(&theme, rows).selected(1).render(area, buf); }); assert!(rows[5].contains("not a declared value"), "{rows:?}"); assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins"); } // The footer line is reserved whether or not it has anything in it. A form // whose rows reflow as focus moves is one where the row under the cursor // moves out from under it. #[test] fn rows_hold_their_lines_whether_the_footer_has_content_or_not() { let theme = theme(); let with_help = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(1) .render(area, buf); }); let without = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(2) .render(area, buf); }); assert_eq!( with_help[0], without[0], "the section header sits on the same line either way", ); assert!(without[5].trim().is_empty(), "no help, an empty footer"); } // One line per row is what keeps the stateless scroll math valid, so a form // longer than its area scrolls exactly the way a list does. #[test] fn a_form_longer_than_its_area_scrolls_like_a_list() { let theme = theme(); let many: Vec = (0..50) .map(|i| { FormRow::Field(AlloyField::new( &theme, "slot", FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }), )) }) .collect(); let rows = render_to(50, 11, |buf, area| { AlloyForm::new(&theme, many).selected(25).render(area, buf); }); // 50 rows, 10 row-lines after the footer, selection 25 => offset 20. assert!(rows[5].contains("twentyfive"), "{rows:?}"); } #[test] fn a_form_survives_an_area_too_small_to_draw_in() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6)); AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(Rect::new(0, 0, 40, 0), &mut buf); AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(Rect::new(0, 0, 0, 6), &mut buf); // One line tall: the row wins over the footer. let rows = render_to(40, 1, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(area, buf); }); assert!(rows[0].contains("cursor"), "{rows:?}"); } fn picker_rows() -> Vec> { vec![ PickRow::new("Block").description(Some("Solid block.")), PickRow::new("Beam").description(Some("Thin vertical bar.")), ] } fn render_picker(filter: &TextField, rows: Vec>, height: u16) -> Vec { let theme = theme(); render_to(50, height, |buf, area| { AlloyPicker::new(&theme, "cursor.shape", filter, rows) .selected(Some(0)) .render(area, buf); }) } #[test] fn a_picker_shows_its_choices_with_what_they_mean() { let rows = render_picker(&TextField::new(), picker_rows(), 8); let joined = rows.join("\n"); assert!(joined.contains("cursor.shape"), "title: {joined}"); assert!(joined.contains("Block"), "{joined}"); assert!(joined.contains("Solid block."), "{joined}"); assert!(joined.contains("Thin vertical bar."), "{joined}"); } #[test] fn the_filter_row_carries_a_caret() { let mut filter = TextField::new(); filter.set("bl"); let rows = render_picker(&filter, picker_rows(), 8); assert!(rows[1].contains("/ bl"), "{rows:?}"); } // A picker that goes blank when the filter matches nothing reads as broken // rather than as narrowed. #[test] fn a_filter_that_matches_nothing_says_so() { let rows = render_picker(&TextField::new(), Vec::new(), 8); assert!(rows.join("\n").contains("no matches"), "{rows:?}"); } // Same rule AlloyModal follows: a floating thing whose dismiss keys move // with its content is one the user can lose. #[test] fn the_keys_sit_on_the_last_row_whatever_the_choice_count() { for height in [6, 8, 14] { let rows = render_picker(&TextField::new(), picker_rows(), height); let last = &rows[height as usize - 2]; assert!( last.contains("enter") && last.contains("esc"), "height {height}: {last:?}" ); } } #[test] fn a_picker_is_wide_enough_for_its_widest_choice() { let theme = theme(); let filter = TextField::new(); let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width(); // "Beam" plus the gap plus "Thin vertical bar." is the longest row. assert_eq!(width as usize, 4 + 2 + 18 + 4); assert_eq!( AlloyPicker::height(2), 6, "two rows, a filter, keys, borders" ); } #[test] fn a_picker_survives_an_area_too_small_to_draw_in() { let theme = theme(); let filter = TextField::new(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8)); for area in [ Rect::new(0, 0, 40, 0), Rect::new(0, 0, 0, 8), Rect::new(0, 0, 4, 2), Rect::new(0, 0, 40, 3), ] { AlloyPicker::new(&theme, "t", &filter, picker_rows()).render(area, &mut buf); } } #[test] fn a_table_aligns_its_columns_under_its_headers() { let theme = theme(); let rows = render_to(60, 3, |buf, area| { AlloyTable::new( &theme, ["key", "action"], [ vec!["ctrl+shift+t".to_string(), "CreateTab".to_string()], vec!["ctrl+w".to_string(), "CloseTab".to_string()], ], ) .render(area, buf); }); let action = column(&rows[0], "action").expect("header renders"); assert_eq!(column(&rows[1], "CreateTab"), Some(action), "{rows:?}"); assert_eq!(column(&rows[2], "CloseTab"), Some(action), "{rows:?}"); } // A file can hold a record the schema does not describe. Dropping the extra // cell would hide exactly the disagreement worth seeing. #[test] fn a_row_wider_than_the_headers_still_renders_every_cell() { let theme = theme(); let rows = render_to(60, 2, |buf, area| { AlloyTable::new( &theme, ["key"], [vec!["ctrl+w".to_string(), "CloseTab".to_string()]], ) .render(area, buf); }); assert!(rows[1].contains("CloseTab"), "{rows:?}"); } // A log longer than its pane shows the newest entries. Showing the head // instead would freeze the pane on startup noise and never display the // command the user just triggered. #[test] fn log_renders_the_newest_entries() { let theme = theme(); let entries: Vec = (0..10) .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy)) .collect(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4)); AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf); let rendered = buf .content() .iter() .map(ratatui::buffer::Cell::symbol) .collect::(); assert!( rendered.contains("nmcli run 9"), "newest entry must be visible" ); assert!( !rendered.contains("nmcli run 0"), "oldest entry must have scrolled off" ); } // ---- button ---- fn render_button(button: AlloyButton, w: u16, h: u16) -> (Buffer, Rect) { let area = Rect::new(0, 0, w, h); let mut buf = Buffer::empty(area); button.render(area, &mut buf); (buf, area) } fn rows(buf: &Buffer, area: Rect) -> Vec { (area.y..area.bottom()) .map(|y| { (area.x..area.right()) .map(|x| buf[(x, y)].symbol()) .collect::() }) .collect() } #[test] fn a_button_is_a_beveled_surface_with_a_centered_label() { let theme = theme(); let (buf, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3); assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); } // The pressed state is the same button lit from the other corner. Asserted // as a relationship rather than against literals, because that is what // makes it one swap instead of a second widget. #[test] fn pressing_a_button_inverts_its_light_and_recesses_its_face() { let theme = theme(); let (up, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3); let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3); assert_eq!(rows(&up, area), rows(&down, area)); assert_eq!(up[(0u16, 0u16)].fg, theme.makeover.bevel_light); assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark); assert_eq!(up[(3u16, 1u16)].bg, theme.makeover.surface_raised); // The well, not `surface_sunken`. A theme may author sunken darker than // raised while a well always inverts away from the text, so the two are // only interchangeable on themes where the substitution happens not to // bite. Pinned as the well so it stays that way. assert_eq!(down[(3u16, 1u16)].bg, theme.makeover.surface_well.unwrap()); assert_ne!(down[(3u16, 1u16)].bg, theme.makeover.surface_sunken); } // A theme that gave makeover nothing to derive a well from still has to // produce a legible pressed state. No fill is painted and the inverted edge // carries it alone, which is the whole reason the renderer declines rather // than substituting some other surface. #[test] fn a_button_pressed_on_a_theme_with_no_well_keeps_its_inverted_edge() { let mut theme = theme(); theme.makeover.surface_well = None; let (down, area) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3); assert_eq!(rows(&down, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark); } // Dimmed and still there, so the layout keeps teaching itself. #[test] fn a_disabled_button_keeps_its_bevel_and_mutes_only_its_label() { let theme = theme(); let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3); assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light); assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.content_muted); } #[test] fn a_primary_button_inverts_polarity_without_touching_the_bevel() { let theme = theme(); let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3); assert_eq!(buf[(3u16, 1u16)].bg, theme.makeover.content_primary); assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.surface_raised); assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light); } // ---- floating surfaces ---- // The shadow lands outside the modal, one cell down and right, so the page // has to be bigger than the modal for it to exist at all. #[test] fn a_modal_casts_a_shadow_onto_the_page_behind_it() { let theme = theme(); let page = Rect::new(0, 0, 24, 8); let modal = Rect::new(2, 1, 18, 5); let mut buf = Buffer::empty(page); AlloyModal::new(&theme, "remove", "Remove tailscale?").render(modal, &mut buf); // Directly under the modal's bottom edge, offset one to the right. let below = &buf[(3u16, 6u16)]; assert_eq!(below.symbol(), "▒"); assert_eq!(below.fg, theme.border_strong); // The page well away from the modal is untouched. assert_eq!(buf[(23u16, 7u16)].symbol(), " "); }