use ratatui::buffer::Buffer;
use ratatui::style::Modifier;
use mach::app::{App, Focus, Mode};
use mach::form::CategoryForm;
use mach::model::{Block, Category, Task};
use mach::store::Store;
use mach::ui;
#[path = "common/render.rs"]
mod render_common;
use render_common::draw;
fn render(app: &mut App, width: u16, height: u16) -> String {
buffer_text(&draw(app, width, height))
}
fn row_text(buffer: &Buffer, y: u16) -> String {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect()
}
fn is_bold(buffer: &Buffer, needle: &str) -> bool {
let (x0, y) = find_cells(buffer, needle);
(x0..x0 + needle.chars().count() as u16)
.map(|x| &buffer[(x, y)])
.filter(|cell| !cell.symbol().trim().is_empty())
.all(|cell| cell.modifier.contains(Modifier::BOLD))
}
fn find_cells(buffer: &Buffer, needle: &str) -> (u16, u16) {
let width = needle.chars().count() as u16;
for y in 0..buffer.area.height {
for x in 0..buffer.area.width.saturating_sub(width) {
let run: String = (x..x + width).map(|x| buffer[(x, y)].symbol()).collect();
if run == needle {
return (x, y);
}
}
}
panic!("{needle:?} is not on screen:\n{}", buffer_text(buffer));
}
fn buffer_text(buffer: &Buffer) -> String {
(0..buffer.area.height)
.map(|y| row_text(buffer, y))
.collect::<Vec<_>>()
.join("\n")
}
fn sample_app() -> App {
let mut store = Store::open_in_memory_with_paths(
std::env::temp_dir().join(format!("mach-render-test-{}", uuid::Uuid::new_v4())),
)
.unwrap();
let categories = vec![
Category {
id: "c-work".into(),
name: "Work".into(),
description: "what pays".into(),
},
Category {
id: "c-home".into(),
name: "Home".into(),
description: String::new(),
},
];
let mut flagged = Task::new(
"ship the release",
3,
Some("c-work".into()),
"2030-01-02 09:00",
);
flagged.description = vec![
Block::text("with a note"),
Block::todo("step one", true),
Block::todo("step two", false),
Block::link("https://example.com"),
Block::bullet("a point"),
Block::number("first"),
];
let mut done = Task::new("已完成的任务", 0, Some("c-home".into()), "");
done.done = true;
let tasks = vec![flagged, done, Task::new("no category", 1, None, "09:00")];
store
.update(|data| {
data.categories = categories;
data.tasks = tasks;
Ok(())
})
.unwrap();
let mut app = App::with_store("test", store).unwrap();
app.mode = Mode::Normal;
app
}
#[test]
fn draws_the_main_screen() {
let mut app = sample_app();
let screen = render(&mut app, 100, 30);
assert!(screen.contains("Categories"), "{screen}");
assert!(screen.contains("Tasks"), "{screen}");
assert!(screen.contains("ship the release"), "{screen}");
assert!(screen.contains("Work"), "{screen}");
assert!(screen.contains("[✓]") && screen.contains("[ ]"), "{screen}");
assert!(screen.contains("⚑"), "{screen}");
}
#[test]
fn draws_every_overlay() {
for (mode, expect) in [
(Mode::Help, "MOVING AROUND"),
(Mode::Settings, "Sort"),
(Mode::Welcome, "Welcome to mach"),
(Mode::WhatsNew, "What's new in mach"),
(Mode::Slash, "search tasks by text or label"),
] {
let mut app = sample_app();
app.mode = mode;
let screen = render(&mut app, 100, 30);
assert!(
screen.contains(expect),
"{mode:?} missing {expect:?}:\n{screen}"
);
}
}
#[test]
fn whats_new_shows_complete_highlight_descriptions() {
for (width, height) in [(60, 16), (100, 30)] {
let mut app = sample_app();
app.mode = Mode::WhatsNew;
let screen = render(&mut app, width, height);
for ending in ["agents.", "want.", "images."] {
assert!(
screen.contains(ending),
"{ending:?} was clipped at {width}x{height}:\n{screen}"
);
}
}
}
#[test]
fn help_marks_both_section_headings() {
let mut app = sample_app();
app.mode = Mode::Help;
let buffer = draw(&mut app, 110, 40);
assert!(is_bold(&buffer, "MOVING AROUND"), "first heading not bold");
assert!(
is_bold(&buffer, "TASKS, CATEGORIES & LABELS"),
"first heading not bold"
);
assert!(
is_bold(&buffer, "COMMANDS (press /)"),
"second heading not bold"
);
assert!(is_bold(&buffer, "TASK PREVIEW"), "second heading not bold");
assert!(
!is_bold(&buffer, "/quit"),
"description row should not be bold"
);
}
#[test]
fn draws_the_task_dialog_with_a_description() {
let mut app = sample_app();
app.open_edit_task();
let screen = render(&mut app, 100, 48);
assert!(screen.contains("Edit task"), "{screen}");
assert!(
screen.contains("Title") && screen.contains("Due"),
"{screen}"
);
assert!(screen.contains("ship the release"), "{screen}");
assert!(screen.contains("[✓] step one"), "{screen}");
assert!(screen.contains("• a point"), "{screen}");
assert!(screen.contains("1. first"), "{screen}");
assert!(screen.contains("↗ https://example.com"), "{screen}");
assert!(
screen.contains("Task preview") || screen.contains("Esc list"),
"{screen}"
);
}
#[test]
fn preview_wraps_every_label_and_rows_compact_only_whole_label_tokens() {
let mut app = sample_app();
let labels = ["bug", "backend", "release", "customer-visible"]
.into_iter()
.map(|name| app.create_label(name).unwrap())
.collect::<Vec<_>>();
let task = app.selected_task().unwrap().id.clone();
app.set_task_labels(&task, labels).unwrap();
let labelled = app.tasks.iter_mut().find(|item| item.id == task).unwrap();
labelled.due.clear();
labelled.description.clear();
app.invalidate_preview();
app.rebuild_view();
let wide_buffer = draw(&mut app, 100, 30);
let wide = buffer_text(&wide_buffer);
for label in ["bug", "backend", "release", "customer-visible"] {
assert!(
wide.contains(label),
"missing {label:?} in preview:\n{wide}"
);
}
assert!(!wide.contains("#bug"), "TUI labels no longer use #: {wide}");
let (bug_x, bug_y) = find_cells(&wide_buffer, "bug");
let (title_x, title_y) = find_cells(&wide_buffer, "ship the release");
assert_eq!(
bug_y, title_y,
"first label match should be on the task row"
);
let expected_badge = app.theme().label_badge(app.labels[0].color, false);
assert_eq!(
wide_buffer[(bug_x, bug_y)].bg,
expected_badge.bg.unwrap_or(ratatui::style::Color::Reset),
"the focused task row must preserve the label badge background"
);
if expected_badge.bg.is_some() {
assert_ne!(
wide_buffer[(bug_x, bug_y)].bg,
wide_buffer[(title_x, title_y)].bg,
"the label color belongs to its badge, not its text"
);
}
app.settings.preview_position = "right".into();
let narrow = render(&mut app, 90, 30);
let task_row = narrow
.lines()
.find(|line| line.contains("[ ]") && line.contains("ship the"))
.expect("task row");
assert!(task_row.contains("bug"), "{task_row}");
assert!(
task_row.contains("+"),
"hidden labels need a count: {task_row}"
);
assert!(
!task_row.contains("back…"),
"label tokens must not truncate: {task_row}"
);
}
#[test]
fn draws_labels_manager_and_task_label_picker_at_supported_sizes() {
for (width, height) in [(60, 16), (100, 30)] {
let mut app = sample_app();
app.create_label("bug").unwrap();
app.create_label("backend").unwrap();
if height == 16 {
for name in ["frontend", "design", "docs", "release", "blocked", "later"] {
app.create_label(name).unwrap();
}
}
app.mode = Mode::Labels;
let manager_buffer = draw(&mut app, width, height);
let manager = buffer_text(&manager_buffer);
assert!(manager.contains("Labels"), "{manager}");
assert!(manager.contains("bug"), "{manager}");
let (_, bug_y) = find_cells(&manager_buffer, "bug");
let (_, backend_y) = find_cells(&manager_buffer, "backend");
assert_eq!(bug_y, backend_y, "labels should flow across the row");
app.begin_rename_label();
let editor = render(&mut app, width, height);
for field in ["Edit label", "Name", "Color"] {
assert!(editor.contains(field), "missing {field:?}:\n{editor}");
}
app.close_labels();
app.open_edit_task();
app.form
.as_mut()
.unwrap()
.set_field(mach::form::Field::Labels);
app.form.as_mut().unwrap().open_label_picker();
let picker_buffer = draw(&mut app, width, height);
let picker = buffer_text(&picker_buffer);
let form = app.form.as_ref().unwrap();
let picker_area = form.label_picker_area().unwrap();
if height == 16 {
assert!(
picker_area.y >= form.areas.labels.bottom(),
"short label picker must shrink below its field:\n{picker}"
);
}
let title_area = form.areas.title;
let overlaps_title = picker_area.x < title_area.right()
&& picker_area.right() > title_area.x
&& picker_area.y < title_area.bottom()
&& picker_area.bottom() > title_area.y;
assert!(
!overlaps_title,
"label picker must shrink instead of covering Title:\n{picker}"
);
if width >= 100 {
assert!(picker.contains("bug"), "{picker}");
}
}
}
#[test]
fn task_form_overlays_replace_overlapped_description_images_with_a_marker() {
for field in [mach::form::Field::Labels, mach::form::Field::Due] {
let mut app = sample_app();
app.create_label("bug").unwrap();
app.tasks[0].description = vec![
Block::text("one"),
Block::text("two"),
Block::image("missing.png"),
];
app.rebuild_view();
app.open_edit_task();
let form = app.form.as_mut().unwrap();
match field {
mach::form::Field::Labels => form.open_label_picker(),
mach::form::Field::Due => form.open_due_picker(),
_ => unreachable!(),
}
let screen = render(&mut app, 140, 50);
assert!(
screen.contains("[image]"),
"{field:?} overlay must hide the image protocol it covers:\n{screen}"
);
}
}
#[test]
fn labels_manager_occludes_underlying_task_preview_images() {
let mut app = sample_app();
app.settings.preview_position = "right".into();
app.tasks[0].description = (0..14)
.map(|_| Block::text("line"))
.chain(std::iter::once(Block::image("missing.png")))
.collect();
app.rebuild_view();
app.open_labels();
app.begin_new_label();
let screen = render(&mut app, 140, 50);
assert!(
screen.contains("[image]"),
"the labels manager must hide the preview image protocol it covers:\n{screen}"
);
}
#[test]
fn narrow_side_preview_uses_a_stacked_in_place_editor() {
let mut app = sample_app();
app.settings.preview_position = "right".into();
app.open_edit_task();
let buffer = draw(&mut app, 90, 30);
let preview = app.areas.preview;
let (edit_x, _) = find_cells(&buffer, " Edit task ");
assert!(
edit_x >= preview.x && edit_x < preview.right(),
"editor should stay inside the task preview: preview={preview:?}\n{}",
buffer_text(&buffer)
);
let (_, title_y) = find_cells(&buffer, " Title ");
let (_, category_y) = find_cells(&buffer, " Category ");
let (_, labels_y) = find_cells(&buffer, " Labels ");
let (_, due_y) = find_cells(&buffer, " Due ");
let (_, flags_y) = find_cells(&buffer, " Flags ");
assert!(
title_y < category_y && category_y < labels_y && labels_y < due_y && due_y < flags_y,
"compact metadata fields should stack vertically\n{}",
buffer_text(&buffer)
);
}
#[test]
fn modal_task_editor_keeps_the_task_preview_visible_behind_it() {
let mut app = sample_app();
app.settings.preview_position = "right".into();
app.tasks[0].title = format!("{}UNDERLAY", " ".repeat(32));
app.invalidate_preview();
app.rebuild_view();
app.open_edit_task();
let buffer = draw(&mut app, 120, 16);
let preview = app.areas.preview;
let (edit_x, _) = find_cells(&buffer, " Edit task ");
assert!(edit_x < preview.x, "fixture should use the modal fallback");
let edge_start = buffer.area.width.saturating_sub(14);
let mut right_edge = String::new();
for y in 0..buffer.area.height {
for x in edge_start..buffer.area.width {
right_edge.push_str(buffer[(x, y)].symbol());
}
right_edge.push('\n');
}
assert!(
right_edge.contains("UNDERLAY"),
"modal fallback should preserve the task preview\n{}",
buffer_text(&buffer)
);
}
#[test]
fn draws_the_new_task_dialog_and_its_calendar() {
let mut app = sample_app();
app.select_category(1);
app.open_new_task();
let screen = render(&mut app, 100, 30);
assert!(screen.contains("New task"), "{screen}");
assert!(
screen.contains("Category") && screen.contains("Work"),
"{screen}"
);
app.form.as_mut().unwrap().open_due_picker();
let screen = render(&mut app, 100, 30);
assert!(
screen.contains("Su") && screen.contains("Sa"),
"weekday header:\n{screen}"
);
assert!(screen.contains("clear(x)"), "{screen}");
}
#[test]
fn draws_the_category_dialog() {
let mut app = sample_app();
app.focus = Focus::Sidebar;
app.select_category(1);
app.open_edit_category();
let screen = render(&mut app, 100, 30);
assert!(screen.contains("Edit category"), "{screen}");
assert!(
screen.contains("Work") && screen.contains("what pays"),
"{screen}"
);
app.category_form = Some(CategoryForm::new());
assert!(render(&mut app, 100, 30).contains("New category"));
}
#[test]
fn draws_search_and_its_empty_result() {
let mut app = sample_app();
app.start_search("ship");
let screen = render(&mut app, 100, 30);
assert!(screen.contains("ship the release"), "{screen}");
assert!(
!screen.contains("no category"),
"other tasks filtered out:\n{screen}"
);
app.start_search("nothing matches this");
let screen = render(&mut app, 100, 30);
assert!(screen.contains("No tasks found"), "{screen}");
}
#[test]
fn draws_an_empty_store() {
let mut app = sample_app();
app.tasks.clear();
app.rebuild_view();
assert!(render(&mut app, 100, 30).contains("No active tasks"));
}
#[test]
fn survives_sizes_from_tiny_to_wide() {
let mut app = sample_app();
assert!(render(&mut app, 20, 5).contains("too small"));
assert!(render(&mut app, 30, 8).contains("60×16"));
for (width, height) in [(60, 16), (80, 24), (200, 60)] {
for mode in [
Mode::Normal,
Mode::Help,
Mode::Settings,
Mode::Labels,
Mode::Welcome,
Mode::WhatsNew,
Mode::Slash,
Mode::TaskForm,
Mode::CategoryForm,
] {
let mut app = sample_app();
match mode {
Mode::TaskForm => app.open_edit_task(),
Mode::CategoryForm => {
app.select_category(1);
app.open_edit_category();
}
_ => app.mode = mode,
}
render(&mut app, width, height);
}
}
}
#[test]
fn minimum_size_keeps_both_primary_panels_visible() {
let mut app = sample_app();
let screen = render(&mut app, 60, 16);
assert!(screen.contains("Categories"), "{screen}");
assert!(screen.contains("Tasks"), "{screen}");
assert!(screen.contains("ship the re"), "{screen}");
}
#[test]
fn wide_help_uses_paired_columns_and_content_height() {
let mut app = sample_app();
app.mode = Mode::Help;
let buffer = draw(&mut app, 120, 40);
let (_, title_y) = find_cells(&buffer, " mach ");
let (_, moving_y) = find_cells(&buffer, "MOVING AROUND");
let (_, tasks_y) = find_cells(&buffer, "TASKS, CATEGORIES & LABELS");
assert_eq!(moving_y, tasks_y, "wide help sections must share a row");
assert!(title_y > 0, "wide help must not fill the terminal height");
}
#[test]
fn narrow_help_scrolls_to_every_section() {
let mut app = sample_app();
app.mode = Mode::Help;
let top = render(&mut app, 80, 24);
assert!(top.contains("MOVING AROUND"), "{top}");
app.help_scroll = usize::MAX;
let bottom = render(&mut app, 80, 24);
assert!(bottom.contains("PREVIEW"), "{bottom}");
assert!(bottom.contains("github.com/Q1CHENL/mach"), "{bottom}");
}
#[test]
fn hidden_geometry_is_cleared_on_an_undersized_frame() {
let mut app = sample_app();
let _ = render(&mut app, 100, 30);
assert!(!app.areas.tasks.is_empty());
assert!(!app.areas.command_bar.is_empty());
let _ = render(&mut app, 20, 5);
assert!(app.areas.tasks.is_empty());
assert!(app.areas.sidebar.is_empty());
assert!(app.areas.command_bar.is_empty());
assert!(app.areas.slash_menu.is_empty());
}
#[test]
fn a_clipped_read_only_preview_says_that_more_content_exists() {
let mut app = sample_app();
app.tasks[0].description = (0..40)
.map(|index| Block::text(&format!("preview line {index}")))
.collect();
app.invalidate_preview();
app.rebuild_view();
let screen = render(&mut app, 80, 24);
assert!(screen.contains("↓ more · Enter to edit"), "{screen}");
}
#[test]
fn survives_every_sort_order() {
for sort in mach::settings::SORTS {
let mut app = sample_app();
app.settings.sort = sort.to_string();
app.rebuild_view();
assert!(
render(&mut app, 100, 30).contains("ship the release"),
"sort {sort}"
);
}
}
#[test]
fn a_picture_is_letterboxed_into_its_box_never_cropped() {
use image::DynamicImage;
use ratatui::layout::{Rect, Size};
use ratatui_image::picker::Picker;
use ratatui_image::{FontSize, Resize};
let cell = FontSize::new(8, 16);
let (iw, ih) = (1873u32, 1050u32);
#[allow(deprecated, reason = "the only way to pin a cell size for a test")]
let picker = Picker::from_fontsize(cell);
let protocol = picker.new_resize_protocol(DynamicImage::new_rgba8(iw, ih));
for box_size in [
Size::new(128, 55),
Size::new(120, 50),
Size::new(200, 60),
Size::new(60, 40),
Size::new(10, 4),
] {
let fitted = protocol.size_for(Resize::Scale(None), box_size);
let area = Rect {
x: 0,
y: 0,
width: box_size.width,
height: box_size.height,
};
let picture = ui::centered(
area,
fitted.width.min(area.width),
fitted.height.min(area.height),
);
assert!(
picture.width <= area.width && picture.height <= area.height,
"{box_size:?}: picture {picture:?} spills out of its box"
);
assert!(
picture.right() <= area.right() && picture.bottom() <= area.bottom(),
"{box_size:?}: picture {picture:?} would be clipped"
);
let want_h = (box_size.width as f32 * cell.width as f32 * ih as f32)
/ (iw as f32 * cell.height as f32);
let capped = want_h.min(box_size.height as f32);
assert!(
(picture.height as f32 - capped).abs() <= 1.5,
"{box_size:?}: {} rows, expected about {capped:.1}",
picture.height
);
}
}
#[test]
fn categories_scrollbar_track_uses_accent_when_focused() {
let mut app = sample_app();
app.focus = Focus::Sidebar;
for i in 0..40 {
app.categories.push(Category {
id: format!("c-{i}"),
name: format!("Cat{i}"),
description: String::new(),
});
}
app.rebuild_view();
let buffer = draw(&mut app, 100, 20);
let accent = app.theme().accent;
let x = mach::ui::SIDEBAR_WIDTH - 1;
let mut found_track = false;
for y in 2..18 {
let cell = &buffer[(x, y)];
let sym = cell.symbol();
if matches!(sym, "│" | "┃" | "║") {
found_track = true;
assert_eq!(
cell.style().fg,
Some(accent),
"track at ({x},{y}) should be accent, got {:?} sym={sym:?}",
cell.style().fg
);
}
if sym == "█" || sym == "▐" {
assert_eq!(cell.style().fg, Some(accent), "thumb should be accent");
}
}
assert!(
found_track,
"expected a scrollbar track on the categories border"
);
}
#[test]
fn preview_can_sit_on_the_right() {
let mut app = sample_app();
app.settings.preview_position = "right".into();
let screen = render(&mut app, 120, 30);
assert!(screen.contains("Task preview"), "{screen}");
assert!(screen.contains("ship the release"), "{screen}");
let lines: Vec<&str> = screen.lines().collect();
let tasks_y = lines
.iter()
.position(|l| l.contains("Tasks"))
.expect("Tasks");
let preview_y = lines
.iter()
.position(|l| l.contains("Task preview"))
.expect("Task preview");
assert!(
preview_y.abs_diff(tasks_y) <= 2,
"preview should be beside tasks, not far below: tasks={tasks_y} preview={preview_y}\n{screen}"
);
}