#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
#[test]
fn startup_shows_rollover_dialog_when_plan_exists() {
let (_tmp, app) = make_app_with_rollover_dialog();
assert!(matches!(app.mode, Mode::RolloverDialog));
assert!(app.rollover_dialog.is_some());
}
#[test]
fn rollover_dialog_enter_applies_rollover() {
let (_tmp, mut app) = make_app_with_rollover_dialog();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app.store.list().unwrap().into_iter().next().unwrap();
let cycles = issue.cycles.unwrap_or_default();
assert!(cycles.iter().any(|c| c == "Cycle 2"));
assert!(!cycles.iter().any(|c| c == "Cycle 1"));
}
#[test]
fn cursor_clamp_empty_column() {
let (_tmp, mut app) = make_app();
app.board.move_left();
assert_eq!(app.board.active_col, app.board.total_cols() - 1);
app.board.move_right();
assert_eq!(app.board.active_col, 0);
}
#[test]
fn cursor_clamp_single_card() {
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());
assert_eq!(app.board.column_cards(0).len(), 1);
app.board.move_down();
assert_eq!(app.board.selected, 0);
app.board.move_up();
assert_eq!(app.board.selected, 0);
}
#[test]
fn wrap_around_columns() {
let (_tmp, mut app) = make_app();
let total = app.board.total_cols();
for _ in 0..total + 2 {
app.board.move_right();
}
assert_eq!(app.board.active_col, 2 % total);
for _ in 0..total + 2 {
app.board.move_left();
}
}
#[test]
fn d_opens_confirm_delete() {
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::Char('d'))).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
assert!(app.confirm_delete_target.is_some());
}
#[test]
fn move_to_done_triggers_commit_prompt() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(
matches!(
&app.mode,
Mode::MoveIntoDone { buffer } if buffer.is_empty()
),
"expected MoveIntoDone, got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "in review");
assert!(issue.commit.is_none());
}
#[test]
fn move_into_done_with_commit_applies() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
for c in "abc123".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
assert_eq!(issue.commit.as_deref(), Some("abc123"));
}
#[test]
fn move_into_done_with_empty_commit_moves_without_hash() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(2);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
assert!(
issue.commit.is_none(),
"no commit recorded when moving to done without a hash"
);
}
#[test]
fn move_out_of_done_clears_commit() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(3);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "in review");
assert!(issue.commit.is_none());
}
#[test]
fn move_right_wraps_across_real_columns_skipping_mismatch() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(3); app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert_eq!(app.board.active_col, 0);
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "todo");
}
#[test]
fn move_left_wraps_across_real_columns_skipping_mismatch() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.board.select_column(0);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert_eq!(app.board.active_col, 3);
let issue = app
.store
.get(crate::storage::IssueSelector::Filename(
"2026-01-01-a.md".into(),
))
.unwrap();
assert_eq!(issue.status.display(), "done");
}
#[test]
fn move_left_right_board() {
let (_tmp, mut app) = make_app();
let initial = app.board.active_col;
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.board.active_col, initial + 1);
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(app.board.active_col, initial);
}
#[test]
fn confirm_delete_y_removes_issue() {
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::Char('d'))).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 0);
}
#[test]
fn confirm_delete_n_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());
let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.confirm_delete_target.is_none());
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn ctrl_up_and_down_reorder_the_selected_card() {
let (_tmp, mut app) = make_mouse_app();
app.board.active_col = 0;
app.board.selected = 1;
let up = app
.handle_key(press_with_modifiers(
KeyCode::Up,
crossterm::event::KeyModifiers::CONTROL,
))
.unwrap();
assert_eq!(up, Action::None);
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Beta", "Alpha"],
"ctrl-up moves the selected card one slot earlier"
);
assert_eq!(app.board.selected, 0);
let down = app
.handle_key(press_with_modifiers(
KeyCode::Down,
crossterm::event::KeyModifiers::CONTROL,
))
.unwrap();
assert_eq!(down, Action::None);
assert_eq!(
app.board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect::<Vec<_>>(),
["Alpha", "Beta"],
"ctrl-down restores the original order"
);
}
#[test]
fn board_page_keys_move_the_selection_by_a_screen() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app_with_cards(30);
let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
let page = app.board_page.get();
assert!(
page > 1,
"a 20-row column must hold more than one card, got {page}"
);
assert_eq!(app.board.selected, 0);
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.board.selected, page, "one screen down");
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.board.selected, page * 2, "two screens down");
app.handle_key(press(KeyCode::PageUp)).unwrap();
assert_eq!(app.board.selected, page, "and back one");
for _ in 0..20 {
app.handle_key(press(KeyCode::PageDown)).unwrap();
}
assert_eq!(
app.board.selected, 29,
"paging past the end lands on the last card"
);
for _ in 0..20 {
app.handle_key(press(KeyCode::PageUp)).unwrap();
}
assert_eq!(app.board.selected, 0, "and paging up lands on the first");
}
#[test]
fn board_page_keys_do_nothing_in_an_empty_column() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_app();
let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
assert_eq!(app.board_page.get(), 0, "nothing to page");
app.handle_key(press(KeyCode::PageDown)).unwrap();
assert_eq!(app.board.selected, 0);
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn sub_issues_are_shown_by_default_and_u_toggles_them() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-parent.md", 1, "Parent", "todo", None);
fs::write(
dir.join("2026-01-02-child.md"),
"---\nnumber: 2\ntitle: \"Child\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-02\"\nupdated: \"2026-01-02\"\nparent: 1\n---\n\nBody text.",
)
.unwrap();
app.refresh_corpus().unwrap();
let shape = |app: &App| -> Vec<(u32, u8)> {
app.board
.column_cards(0)
.iter()
.map(|c| (c.number, c.depth))
.collect()
};
assert!(app.show_children, "sub-issues are shown by default");
assert_eq!(
shape(&app),
vec![(1, 0), (2, 1)],
"the default build nests the sub-issue beneath its parent"
);
let _ = app.handle_key(press(KeyCode::Char('u'))).unwrap();
assert!(!app.show_children);
assert_eq!(app.status_msg.as_deref(), Some("Hiding sub-issues"));
assert_eq!(
shape(&app),
vec![(2, 0), (1, 0)],
"toggling off restores the flat order"
);
let _ = app.handle_key(press(KeyCode::Char('u'))).unwrap();
assert!(app.show_children);
assert_eq!(app.status_msg.as_deref(), Some("Showing sub-issues"));
assert_eq!(
shape(&app),
vec![(1, 0), (2, 1)],
"toggling back nests the sub-issue again"
);
}
#[test]
fn enter_and_f2_open_the_status_and_priority_dropdowns() {
let (_tmp, mut app) = make_app();
app.handle_key(press(KeyCode::F(1))).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); assert_eq!(
app.form.as_ref().unwrap().focus,
crate::ui::form::FormFocus::Status
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.form_picker
.as_ref()
.is_some_and(|p| p.field == FormPickerField::Status),
"Enter on Status opens its dropdown"
);
assert!(
matches!(app.mode, Mode::Form),
"Enter on Status must not submit the form"
);
assert!(app.store.list().unwrap().is_empty(), "nothing was written");
let picker = app.form_picker.as_ref().unwrap();
assert_eq!(
match &picker.state.selection {
SelectionModel::Single(current) => current.clone(),
SelectionModel::Multi(_) => panic!("Status must be single-select"),
},
Some("todo".to_string()),
"the dropdown opens on the row's current value"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.form_picker.is_none(), "Esc closes the dropdown");
app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(
app.form_picker
.as_ref()
.is_some_and(|p| p.field == FormPickerField::Status),
"F2 opens the Status dropdown too"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); assert_eq!(
app.form.as_ref().unwrap().focus,
crate::ui::form::FormFocus::Priority
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.form_picker
.as_ref()
.is_some_and(|p| p.field == FormPickerField::Priority),
"Enter on Priority opens its dropdown"
);
assert!(
matches!(app.mode, Mode::Form),
"Enter on Priority must not submit"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(
app.form_picker
.as_ref()
.is_some_and(|p| p.field == FormPickerField::Priority),
"F2 opens the Priority dropdown too"
);
}
#[test]
fn choosing_a_status_creates_the_issue_in_that_column() {
let (_tmp, mut app) = make_app();
app.handle_key(press(KeyCode::F(1))).unwrap();
type_text(&mut app, "Placed");
app.handle_key(press(KeyCode::Down)).unwrap(); app.handle_key(press(KeyCode::Enter)).unwrap(); app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.form.as_ref().unwrap().status, "in progress");
app.handle_key(press(KeyCode::Esc)).unwrap(); app.form.as_mut().unwrap().focus = crate::ui::form::FormFocus::Body;
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Placed")
.expect("the mode created the issue");
assert_eq!(issue.status.to_string(), "in progress");
let names: Vec<String> = (0..app.board.total_cols())
.map(|i| app.board.column_name(i).to_string())
.collect();
let idx = names
.iter()
.position(|n| n == "in progress")
.expect("in progress column exists");
assert_eq!(
app.board.column_cards(idx).len(),
1,
"the chosen status lands the issue in 'in progress'"
);
}
#[test]
fn mode_labels_pluralize_by_configured_count() {
let (_tmp, mut app) = make_app();
assert_eq!(app.mode_label(TopMode::Board), "Board");
assert_eq!(app.mode_label(TopMode::Cycle), "Cycle");
assert_eq!(app.mode_label(TopMode::Milestones), "Milestone");
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 2).starts_with("F3 Board"),
"{:?}",
flush_line(&cells, 80, 2)
);
assert!(
flush_line(&cells, 80, 4).starts_with("F4 Cycle"),
"{:?}",
flush_line(&cells, 80, 4)
);
assert!(
flush_line(&cells, 80, 5).starts_with("F5 Milestone"),
"{:?}",
flush_line(&cells, 80, 5)
);
app.store
.config
.cycles
.insert("Solo".into(), crate::config::PlanningCycle::default());
assert_eq!(app.mode_label(TopMode::Cycle), "Cycle");
app.store
.config
.cycles
.insert("Pair".into(), crate::config::PlanningCycle::default());
assert_eq!(app.mode_label(TopMode::Cycle), "Cycles");
let (_tmp, app) = make_branchy_app();
assert_eq!(app.mode_label(TopMode::Board), "Boards");
let cells = draw_board(&app, 80, 24);
let board_row = (0..24u16)
.find(|row| flush_line(&cells, 80, *row).starts_with("F3 "))
.expect("the Board trunk is drawn");
assert!(
flush_line(&cells, 80, board_row).starts_with("F3 Boards"),
"{:?}",
flush_line(&cells, 80, board_row)
);
let (_tmp, app) = make_app();
let planner =
crate::planning::Planner::from_config(app.store.config.clone(), app.project_root());
planner
.create_milestone_file("Backlog", None, None)
.unwrap();
assert_eq!(app.mode_label(TopMode::Milestones), "Milestone");
planner.create_milestone_file("Q4", None, None).unwrap();
assert_eq!(app.mode_label(TopMode::Milestones), "Milestones");
}