#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
#[test]
fn handle_mouse_in_mode_without_mouse_support_is_noop() {
let (_tmp, mut app) = make_app();
app.mode = Mode::Filter {
buffer: "query".into(),
};
let before = app.mode.clone();
let ev = MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 1,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
};
let action = app.handle_mouse(ev).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.mode, before);
}
#[test]
fn a_drag_of_a_blocked_card_into_done_asks_before_it_writes() {
let (_tmp, mut app) = make_blocked_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1); let (tx, ty) = column_cell(&app, done_col(&app));
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
app.handle_mouse(up_left(tx, ty)).unwrap();
assert!(
matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"a drag into done must raise the acknowledgement prompt, got {:?}",
app.mode
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
app.board.column_cards(0).iter().any(|c| c.number == 2),
"a cancelled drag must not move the card"
);
}
#[test]
fn a_confirmed_blocked_drag_into_done_records_its_commit() {
let (_tmp, mut app) = make_blocked_mouse_app();
let dir = app.store.config.issues_dir.clone();
let (sx, sy) = card_cell(&app, 0, 1);
let (tx, ty) = column_cell(&app, done_col(&app));
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
app.handle_mouse(up_left(tx, ty)).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(
matches!(app.mode, Mode::MoveIntoDone { .. }),
"confirming must still ask for the commit hash, got {:?}",
app.mode
);
for c in "deadbee".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
let file = fs::read_to_string(dir.join("b.md")).unwrap();
assert!(file.contains("commit: deadbee"), "commit recorded:\n{file}");
assert!(
file.contains("blocked_move_acknowledged"),
"and the acknowledgement is in the same record:\n{file}"
);
}
#[test]
fn mouse_click_selects_card() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!((app.board.active_col, app.board.selected), (0, 1));
assert_eq!(
app.board.get_selected_card().map(|c| c.number),
Some(2),
"second todo card selected"
);
}
#[test]
fn mouse_click_on_empty_column_space_selects_column() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = column_cell(&app, 1);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.active_col, 1);
}
#[test]
fn mouse_double_click_opens_detail() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(x, y)).unwrap();
app.handle_mouse(up_left(x, y)).unwrap();
assert!(
matches!(app.mode, Mode::ActionMenu { .. }),
"the first click opens the action menu, got {:?}",
app.mode
);
app.handle_mouse(down_left(x, y)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert_eq!(
app.detail.as_ref().map(|d| d.issue.number),
Some(1),
"double-clicked card detail is open"
);
}
#[test]
fn mouse_drag_moves_issue_to_another_real_column() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let target = column_cell(&app, 1);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
let action = app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.active_col, 1);
assert!(
app.board.columns[1].cards.iter().any(|c| c.number == 2),
"Beta landed in 'in progress'"
);
assert!(
app.board.columns[0].cards.iter().all(|c| c.number != 2),
"Beta left 'todo'"
);
assert_eq!(app.status_msg.as_deref(), Some("Moved #2 to in progress"));
}
#[test]
fn shift_chords_reorder_the_selected_card_like_the_drag() {
let titles = |app: &App| {
app.board
.column_cards(0)
.iter()
.map(|c| c.title.clone())
.collect::<Vec<_>>()
};
for code in [KeyCode::Up, KeyCode::Char('K')] {
let (_tmp, mut app) = make_mouse_app();
app.board.active_col = 0;
app.board.selected = 1;
app.handle_key(press_with_modifiers(
code,
crossterm::event::KeyModifiers::SHIFT,
))
.unwrap();
assert_eq!(
titles(&app),
["Beta", "Alpha"],
"{code:?} with Shift moves the selected card up"
);
}
for code in [KeyCode::Down, KeyCode::Char('J')] {
let (_tmp, mut app) = make_mouse_app();
app.board.active_col = 0;
app.board.selected = 0;
app.handle_key(press_with_modifiers(
code,
crossterm::event::KeyModifiers::SHIFT,
))
.unwrap();
assert_eq!(
titles(&app),
["Beta", "Alpha"],
"{code:?} with Shift moves the selected card down"
);
}
}
#[test]
fn mouse_drop_on_source_column_is_noop() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let target = column_cell(&app, 0);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert!(
app.board.columns[0].cards.iter().any(|c| c.number == 2),
"Beta stayed in 'todo'"
);
}
#[test]
fn mouse_drag_reorders_cards_within_same_column_and_persists_order() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1);
let (tx, ty) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
let action = app.handle_mouse(up_left(tx, ty)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Beta", "Alpha"],
"dropping on another card in the same column reorders it"
);
let board_name = app.active_board_name().to_string();
let board_cfg = app
.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap();
assert_eq!(
board_cfg.card_order,
vec!["b.md".to_string(), "a.md".to_string()],
"same-column drag persists the custom order in config"
);
}
#[test]
fn mouse_drag_persists_only_cards_that_differ_from_default_order() {
let (_tmp, mut app) = make_sparse_order_mouse_app();
let (sx, sy) = card_cell(&app, 0, 3);
let (tx, ty) = card_cell(&app, 0, 1);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(tx, ty)).unwrap();
app.handle_mouse(up_left(tx, ty)).unwrap();
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Alpha", "Delta", "Beta", "Gamma"],
"dragging the last card into the middle updates the visible order"
);
let board_name = app.active_board_name().to_string();
let board_cfg = app
.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap();
assert_eq!(
board_cfg.card_order,
vec!["d.md".to_string(), "b.md".to_string(), "c.md".to_string()],
"only the cards whose positions changed are persisted"
);
}
#[test]
fn mouse_drop_on_no_column_area_is_noop() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 0);
app.handle_mouse(down_left(sx, sy)).unwrap();
let outside = (sx, 0u16); app.handle_mouse(drag_event(outside.0, outside.1)).unwrap();
app.handle_mouse(up_left(outside.0, outside.1)).unwrap();
assert!(
app.board.columns[0].cards.iter().any(|c| c.number == 1),
"Alpha stayed in 'todo'"
);
assert_eq!(app.status_msg, None);
}
#[test]
fn mouse_drop_on_mismatch_column_is_noop() {
let (_tmp, mut app) = make_app();
app.mouse.double_click_window = Duration::from_secs(60);
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "x.md", 1, "Alpha", "todo", None);
write_issue(&dir, "w.md", 9, "Oddball", "weird", None);
app.refresh_corpus().unwrap();
app.board.show_mismatch = true;
app.frame_size.set(Rect::new(0, 0, 80, 40));
let (sx, sy) = card_cell(&app, 0, 0);
let mismatch_col = app.board.columns.len();
let target = column_cell(&app, mismatch_col);
app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert!(
app.board.columns[0].cards.iter().any(|c| c.number == 1),
"Alpha stayed in 'todo' (mismatched column is not a move target)"
);
}
#[test]
fn mouse_drag_into_done_without_commit_opens_prompt() {
let (_tmp, mut app) = make_mouse_app();
let (sx, sy) = card_cell(&app, 0, 0);
let target = column_cell(&app, 3); app.handle_mouse(down_left(sx, sy)).unwrap();
app.handle_mouse(drag_event(target.0, target.1)).unwrap();
app.handle_mouse(up_left(target.0, target.1)).unwrap();
assert!(
matches!(&app.mode, Mode::MoveIntoDone { buffer } if buffer.is_empty()),
"drag into done without a commit opens the MoveIntoDone prompt, got {:?}",
app.mode
);
}
#[test]
fn mouse_hover_updates_without_selection_change() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let before = (app.board.active_col, app.board.selected);
let action = app.handle_mouse(moved_to(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.hover, Some((0, 1)));
assert_eq!((app.board.active_col, app.board.selected), before);
}
#[test]
fn mouse_hover_marks_an_empty_column_without_a_card() {
let (_tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
app.refresh_corpus().unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 40));
let (x, y) = column_cell(&app, 1);
app.handle_mouse(moved_to(x, y)).unwrap();
assert_eq!(app.board.hover, None, "no card under the pointer");
assert_eq!(
app.board.hover_col,
Some(1),
"an empty column is still marked as hovered"
);
let (ox, oy) = column_cell(&app, 0);
app.handle_mouse(moved_to(ox, oy)).unwrap();
assert_eq!(app.board.hover_col, Some(0));
app.handle_mouse(moved_to(0, 0)).unwrap();
assert_eq!(app.board.hover_col, None);
}
#[test]
fn mouse_hover_cleared_on_up_or_pointer_leaving_board() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(moved_to(x, y)).unwrap();
assert_eq!(app.board.hover, Some((0, 0)));
app.handle_mouse(moved_to(x, 0)).unwrap();
assert_eq!(app.board.hover, None);
app.handle_mouse(moved_to(x, y)).unwrap();
assert!(app.board.hover.is_some());
app.handle_mouse(up_left(x, y)).unwrap();
assert_eq!(app.board.hover, None);
}
#[test]
fn mouse_scroll_moves_board_selection_within_active_column() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(app.board.selected, 1);
let action = app.handle_mouse(scroll_up(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.selected, 0);
let action = app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.board.selected, 1);
}
#[test]
fn tab_click_switches_board_via_recorded_region() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_multi_board_app();
assert!(app.tabs.needs_bar());
assert_eq!(app.active_config, 0);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
let content = app.frame_size.get();
let tabs_area = Rect::new(content.x, content.y, content.width, 2);
let second = app.tabs.tab_rect(1, tabs_area).unwrap();
let action = app.handle_mouse(down_left(second.x + 1, 1)).unwrap();
assert_eq!(
action,
Action::SwitchBoard(1),
"tab click resolves the region"
);
if let Action::SwitchBoard(index) = action {
app.switch_board(index);
}
assert_eq!(app.active_config, 1);
assert_eq!(app.tabs.selected, 1);
assert_eq!(app.status_msg.as_deref(), Some("Switched board"));
}
#[test]
fn tab_hover_updates_hover_state_without_switching_board() {
let (_tmp, mut app) = make_multi_board_app();
let tabs_area = Rect::new(0, 0, 80, 2);
let second = app.tabs.tab_rect(1, tabs_area).unwrap();
let action = app.handle_mouse(moved_to(second.x + 1, 0)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.active_config, 0);
assert_eq!(app.tabs.selected, 0);
assert_eq!(app.tabs.hovered, Some(1));
assert_eq!(app.board.hover, None);
}
#[test]
fn confirm_delete_yes_click_deletes_issue() {
let (_tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
app.refresh_corpus().unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.confirm_delete_target = Some("a.md".into());
app.mode = Mode::ConfirmDelete;
let ((yes_x, yes_y), _) = drawn_confirm_buttons(&app);
let action = app.handle_mouse(down_left(yes_x, yes_y)).unwrap();
assert_eq!(action, Action::ConfirmDeleteYes);
let _ = app.confirm_delete_yes();
assert_eq!(app.mode, Mode::Board);
assert_eq!(app.confirm_delete_target, None);
assert_eq!(
app.status_msg.as_deref(),
Some("Deleted a.md"),
"Yes click deletes the issue (same branch as the y key)"
);
assert!(
!dir.join("a.md").exists(),
"the target file is actually removed"
);
}
#[test]
fn confirm_delete_no_click_cancels() {
let (_tmp, mut app) = make_app();
let dir = app.store.config.issues_dir.clone();
write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
app.refresh_corpus().unwrap();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.confirm_delete_target = Some("a.md".into());
app.mode = Mode::ConfirmDelete;
let (_, (no_x, no_y)) = drawn_confirm_buttons(&app);
let action = app.handle_mouse(down_left(no_x, no_y)).unwrap();
assert_eq!(action, Action::ConfirmDeleteNo);
let _ = app.confirm_delete_no();
assert_eq!(app.mode, Mode::Board);
assert_eq!(app.confirm_delete_target, None);
assert!(
dir.join("a.md").exists(),
"No click cancels: the issue is left in place (same branch as Esc)"
);
}
#[test]
fn detail_row_click_selects_and_toggles_the_row() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
let board_name = app.active_board_name().to_string();
let col = "in progress".to_string();
open_dashboard_detail(&mut app);
let (x, y) = drawn_detail_row(&app, 1);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::ConfigDetailRow(1), "detail row resolves");
app.apply_config_click(action);
let _ = app.settings_toggle_selected();
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 1);
assert!(
app.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap()
.hidden_columns
.contains(&col),
"clicking the column row runs the Enter toggle (hides the column)"
);
}
#[test]
fn detail_display_row_click_mirrors_entering_browse() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
let board_name = app.active_board_name().to_string();
open_dashboard_detail(&mut app);
let display_row = app.active_board_config().columns.len();
let (x, y) = drawn_detail_row(&app, display_row);
let action = app.handle_mouse(down_left(x, y)).unwrap();
assert_eq!(action, Action::ConfigDetailRow(display_row));
app.apply_config_click(action);
let _ = app.settings_toggle_selected();
assert_eq!(app.settings.as_ref().unwrap().detail_selected, display_row);
assert_eq!(
app.status_msg.as_deref(),
Some("Wrap titles: off"),
"display row click runs the same edit as Enter on that row"
);
assert!(
!app.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap()
.wrap_titles
);
}
#[test]
fn right_click_empty_column_space_is_a_noop() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = column_cell(&app, 1);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(
!matches!(app.mode, Mode::ActionMenu { .. }),
"no menu on empty column space"
);
assert!(app.action_menu.is_none());
}
#[test]
fn hovering_a_navigation_entry_highlights_it_without_switching() {
let (_tmp, mut app) = make_app();
let _ = draw_board(&app, 80, 24);
assert!(!app.is_hovered(&Action::SwitchMode(TopMode::Cycle)));
let action = app.handle_mouse(moved_to(0, 4)).unwrap();
assert_eq!(action, Action::None, "hover never acts");
assert!(
app.is_hovered(&Action::SwitchMode(TopMode::Cycle)),
"the F4 entry must read as hovered"
);
assert_eq!(app.top_mode, TopMode::Board, "hover never switches mode");
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(0, 4)].style().bg,
Some(ratatui::style::Color::DarkGray),
"the hovered Cycles entry is highlighted in the rail"
);
assert_eq!(
buffer[(0, 2)].style().bg,
Some(ratatui::style::Color::Cyan),
"the active entry is not overwritten by the hover"
);
app.handle_mouse(moved_to(60, 20)).unwrap();
assert!(!app.is_hovered(&Action::SwitchMode(TopMode::Cycle)));
}
#[test]
fn clicking_a_navigation_entry_switches_mode() {
let (_tmp, mut app) = make_app();
app.nav_display = NavDisplay::Default;
let _ = draw_board(&app, 80, 24);
let action = app.handle_mouse(down_left(0, 4)).unwrap();
assert_eq!(action, Action::SwitchMode(TopMode::Cycle));
app.switch_top_mode(TopMode::Cycle).unwrap();
assert!(matches!(app.mode, Mode::Cycle));
assert_eq!(app.top_mode, TopMode::Cycle);
}
#[test]
fn the_keyboard_focused_rail_row_is_highlighted_like_hover() {
let (_tmp, mut app) = make_branchy_app();
app.nav_display = NavDisplay::Default;
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(
app.nav_focus_index().map(|i| app.nav_rows()[i].clone()),
Some(Action::SwitchMode(TopMode::Triage))
);
let buffer = draw_buffer(&app, 80, 24);
assert_eq!(buffer[(0, 3)].style().bg, Some(Color::DarkGray));
}