#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
#[test]
fn enter_opens_the_action_menu_then_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::ActionMenu { .. }),
"Enter opens the action menu, got {:?}",
app.mode
);
assert!(select_menu_action(&mut app, &IssueAction::OpenDetail));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(app.detail.is_some());
}
#[test]
fn a_action_menu_move_of_a_blocked_card_asks_before_it_writes() {
let (_tmp, mut app) = make_blocked_mouse_app();
let (sx, sy) = card_cell(&app, 0, 1); let target = app.board.column_name(done_col(&app)).to_string();
app.handle_mouse(down_right(sx, sy)).unwrap();
assert!(
matches!(app.mode, Mode::ActionMenu { .. }),
"right-click opens the menu, got {:?}",
app.mode
);
assert!(select_menu_action(
&mut app,
&IssueAction::MoveTo { column: target }
));
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"a menu move 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 menu move must not move the card"
);
}
#[test]
fn mouse_single_click_opens_the_action_menu() {
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 { .. }),
"a plain single click opens the action menu, got {:?}",
app.mode
);
assert!(app.detail.is_none());
}
#[test]
fn mouse_scroll_scrolls_detail_and_context_menu() {
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();
app.handle_mouse(down_left(x, y)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
if let Some(d) = app.detail.as_mut() {
d.viewport.set(20);
d.max_scroll.set(8);
}
let action = app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 5);
app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 8);
let action = app.handle_mouse(scroll_up(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 3);
for _ in 0..5 {
app.handle_mouse(scroll_up(x, y)).unwrap();
}
assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 0);
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(matches!(app.mode, Mode::ActionMenu { .. }));
let before = app.action_menu.as_ref().unwrap().selected_position();
let action = app.handle_mouse(scroll_down(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(
app.action_menu.as_ref().unwrap().selected_position(),
before + 1
);
let action = app.handle_mouse(scroll_up(x, y)).unwrap();
assert_eq!(action, Action::None);
assert_eq!(
app.action_menu.as_ref().unwrap().selected_position(),
before
);
}
#[test]
fn comment_round_trip_via_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
assert!(matches!(app.mode, Mode::Comment { .. }));
for c in "hello world".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
let comments = issue.comments();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].text.trim(), "hello world");
}
#[test]
fn comment_esc_cancels() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
for c in "partial".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert_eq!(issue.comments(), [] as [crate::model::Comment; 0]);
}
#[test]
fn comment_empty_discarded() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert_eq!(issue.comments(), [] as [crate::model::Comment; 0]);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("empty"))
);
}
#[test]
fn asset_round_trip_via_detail() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let asset = tmp.path().join("pic.png");
fs::write(&asset, b"fake-png").unwrap();
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Asset { .. }));
for c in asset.to_string_lossy().chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert!(
dir.join("a-assets").join("pic.png").is_file(),
"asset must be copied into the issue asset dir"
);
let issue = app
.store
.get(IssueSelector::Filename("2026-01-01-a.md".into()))
.unwrap();
assert!(
issue.body.contains("pic.png"),
"body must reference the asset, got {:?}",
issue.body
);
}
#[test]
fn asset_error_stays_in_mode() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
open_detail(&mut app);
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
for c in "/no/such/file.png".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Asset { .. }),
"asset error must stay in entry mode, got {:?}",
app.mode
);
assert!(app.status_msg.is_some());
}
#[test]
fn right_click_card_opens_menu_with_all_actions() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(x, y)).unwrap();
assert_eq!((app.board.active_col, app.board.selected), (0, 1));
let Some(menu) = app.action_menu.as_ref() else {
panic!("menu not open");
};
assert!(
matches!(
menu.selected_item().map(|item| &item.action),
Some(IssueAction::OpenDetail)
),
"Open detail is preselected even though Move is drawn first"
);
let actions = menu_actions(&app);
let current = app.board.column_name(app.board.active_col).to_string();
let expected_moves: Vec<String> = app
.active_board_config()
.columns
.iter()
.filter(|column| **column != current)
.cloned()
.collect();
let moves: Vec<String> = actions
.iter()
.filter_map(|action| match action {
IssueAction::MoveTo { column } => Some(column.clone()),
_ => None,
})
.collect();
assert_eq!(
moves, expected_moves,
"every configured column except the current one is a move target"
);
assert!(actions.contains(&IssueAction::Delete));
assert!(actions.contains(&IssueAction::Archive));
assert_eq!(
actions.last(),
Some(&IssueAction::RefreshBoard),
"the pointer surface keeps the board footer"
);
assert!(matches!(app.mode, Mode::ActionMenu { .. }));
}
#[test]
fn action_menu_open_detail_matches_enter_key() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let issue_before = app.board.column_cards(0)[1].number;
app.handle_mouse(down_right(x, y)).unwrap();
assert!(select_menu_action(&mut app, &IssueAction::OpenDetail));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(!matches!(app.mode, Mode::ActionMenu { .. }));
assert!(app.action_menu.is_none());
let Some(detail) = &app.detail else {
panic!("menu Open detail should open the detail view");
};
assert_eq!(detail.issue.number, issue_before);
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let _ = app.handle_mouse(down_left(x, y)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::ActionMenu { .. }));
assert!(select_menu_action(&mut app, &IssueAction::OpenDetail));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.detail.as_ref().map(|d| d.issue.number),
Some(issue_before),
"menu Open detail and board Enter + Open detail open the same issue"
);
}
#[test]
fn action_menu_edit_in_editor_matches_e_key() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(select_menu_action(&mut app, &IssueAction::EditInEditor));
let action = app.handle_key(press(KeyCode::Enter)).unwrap();
let selected = app.board.get_selected_card().cloned().unwrap().filename;
assert_eq!(
action,
Action::OpenEditor(selected.clone()),
"menu Edit in editor returns OpenEditor for the selected card"
);
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
let _ = app.handle_mouse(down_left(x, y)).unwrap();
let key_action = app.handle_key(press(KeyCode::Char('e'))).unwrap();
let key_filename = match app.board.get_selected_card() {
Some(card) => card.filename.clone(),
None => String::new(),
};
assert_eq!(
key_action,
Action::OpenEditor(key_filename),
"board 'e' returns OpenEditor for the same card"
);
}
#[test]
fn action_menu_move_to_matches_cardinal_move_key() {
fn moved_via_menu() -> Status {
let (_tmp, mut app) = make_mouse_app();
let target = app.board.column_name(2).to_string();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(select_menu_action(
&mut app,
&IssueAction::MoveTo {
column: target.clone()
}
));
app.handle_key(press(KeyCode::Enter)).unwrap();
app.store
.get(IssueSelector::Filename("a.md".to_string()))
.unwrap()
.status
}
fn moved_via_keys() -> Status {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
let _ = app.handle_mouse(down_left(x, y)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
app.store
.get(IssueSelector::Filename("a.md".to_string()))
.unwrap()
.status
}
assert_eq!(moved_via_menu(), moved_via_keys());
}
#[test]
fn action_menu_delete_sets_up_confirm() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(select_menu_action(&mut app, &IssueAction::Delete));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::ConfirmDelete);
assert_eq!(
app.confirm_delete_target.as_deref(),
Some("b.md"),
"delete targets the right-clicked card"
);
assert!(!matches!(&app.mode, Mode::ActionMenu { .. }));
}
#[test]
fn action_menu_refresh_rebuilds_board() {
let (_tmp, mut app) = make_mouse_app();
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(select_menu_action(&mut app, &IssueAction::RefreshBoard));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::Board);
assert_eq!(app.status_msg.as_deref(), Some("Refreshed"));
assert!(app.action_menu.is_none());
}
#[test]
fn action_menu_esc_closes_without_side_effects() {
let (_tmp, mut app) = make_mouse_app();
let (card_x, card_y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(card_x, card_y)).unwrap();
let status_before = app
.store
.get(IssueSelector::Filename("b.md".to_string()))
.unwrap()
.status
.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.mode, Mode::Board);
assert!(app.action_menu.is_none());
assert_eq!((app.board.active_col, app.board.selected), (0, 1));
assert!(app.confirm_delete_target.is_none());
assert_eq!(
app.store
.get(IssueSelector::Filename("b.md".to_string()))
.unwrap()
.status,
status_before,
"Esc closes the menu without mutating any issue"
);
}
#[test]
fn action_menu_outside_click_closes_menu() {
let (_tmp, mut app) = make_mouse_app();
let (card_x, card_y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(card_x, card_y)).unwrap();
let action = app.handle_mouse(down_left(40, 39)).unwrap();
assert_eq!(action, Action::None);
assert!(app.action_menu.is_none());
assert_eq!(app.mode, Mode::Board);
assert!(
app.mouse.drag.is_none(),
"the dismissing click must not start a drag"
);
}
#[test]
fn action_menu_item_click_activates() {
let (_tmp, mut app) = make_mouse_app();
let (card_x, card_y) = card_cell(&app, 0, 1);
app.handle_mouse(down_right(card_x, card_y)).unwrap();
let menu = app.action_menu.as_ref().unwrap();
let bounds = menu.bounds(app.frame_size.get());
let row = menu
.rows()
.iter()
.position(|row| {
matches!(
row,
crate::ui::action_menu::Row::Item(index)
if menu.item(*index).is_some_and(|item| item.action == IssueAction::Delete)
)
})
.expect("Delete is in the menu");
let item_x = bounds.x + 2;
let item_y = bounds.y + 1 + u16::try_from(row).unwrap();
let _ = app.handle_mouse(down_left(item_x, item_y)).unwrap();
assert_eq!(app.mode, Mode::ConfirmDelete);
assert_eq!(app.confirm_delete_target.as_deref(), Some("b.md"));
assert!(app.action_menu.is_none());
}
#[test]
fn action_menu_moves_to_a_hidden_column_by_name() {
let (_tmp, mut app) = make_mouse_app();
let board = app
.store
.config
.kanban
.configurations
.get_mut("default")
.unwrap();
board.hidden_columns = vec!["in progress".into()];
app.refresh_corpus().unwrap();
assert!(
app.board
.hidden
.iter()
.any(|column| column.name == "in progress"),
"the fixture hides the column"
);
let before = app.board.active_col;
let (x, y) = card_cell(&app, 0, 0);
app.handle_mouse(down_right(x, y)).unwrap();
assert!(select_menu_action(
&mut app,
&IssueAction::MoveTo {
column: "in progress".into()
}
));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let issue = app
.store
.get(IssueSelector::Filename("a.md".into()))
.unwrap();
assert_eq!(issue.status.to_string(), "in progress");
assert_eq!(
app.board.active_col, before,
"a hidden target has no visible cursor, so the cursor stays put"
);
assert!(
app.board
.hidden
.iter()
.flat_map(|column| column.cards.iter())
.any(|card| card.number == 1),
"the card is in the hidden column"
);
}