#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
#[test]
fn filter_modal_arrows_navigate_rows() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
use crate::search::FilterField::*;
assert_eq!(filter_modal_focus(&app), Assignee);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(filter_modal_focus(&app), Label);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(filter_modal_focus(&app), Assignee);
for _ in 0..13 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(filter_modal_focus(&app), Assignee, "Down wraps forward");
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(
filter_modal_focus(&app),
crate::search::FilterField::Epic,
"Up wraps backward, to the last row — which is now `Epic` (WP08 \
appended `Category` after `Parent`, WP06 T026 appended `Project` after \
`Category`, and WP06 T028 appended `Epic` after `Project`)"
);
}
#[test]
fn filter_modal_arrow_press_keeps_criterion_value() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(filter_modal_focus(&app), FilterField::Status);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(app.filter_picker.is_some(), "enter opens the row's picker");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let before = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.status
.clone();
assert!(before.is_some(), "choosing an option sets the criterion");
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let after = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.status
.clone();
assert_eq!(after, before, "arrow press must not alter the value");
use crate::search::FilterField::*;
assert_eq!(filter_modal_focus(&app), Priority);
}
#[test]
fn filter_modal_typing_does_not_enter_a_criterion() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
for c in "box".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let modal = app.filter_modal.as_ref().unwrap();
assert_eq!(
modal.active_filter().assignee,
None,
"rows are picker-only: typed characters must not become a criterion value"
);
assert_eq!(modal.focus(), FilterField::Assignee);
assert!(
app.filter_picker.is_none(),
"an unbound letter opens nothing"
);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert!(
app.filter_picker.is_some(),
"`l` opens the focused row's picker"
);
for c in "bo".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(
app.filter_picker.as_ref().unwrap().state.filter,
"bo",
"characters typed while the list is open filter it"
);
assert_eq!(
app.filter_modal.as_ref().unwrap().active_filter().assignee,
None,
"filtering the list still must not set the criterion"
);
}
#[test]
fn filter_modal_date_field_opens_the_calendar() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
use crate::search::FilterField::*;
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(filter_modal_focus(&app), StartDate);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.start_date,
None,
"cancelling the calendar leaves the criterion unset"
);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::DatePicker { .. }));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
let chosen = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.start_date
.clone();
assert!(chosen.is_some(), "a chosen day sets the criterion");
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Delete)).unwrap();
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.start_date,
None,
"clearing the calendar unsets the criterion"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
let picked = app
.filter_modal
.as_ref()
.unwrap()
.active_filter()
.start_date
.clone();
assert!(picked.is_some(), "picking a day sets the criterion");
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.structured.start_date, picked);
}
#[test]
fn filter_narrows_board_live() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
assert_eq!(app.board.column_cards(0).len(), 2);
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
assert!(matches!(app.mode, Mode::Filter { .. }));
for c in "arget".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.filter, Some("arget".into()));
assert_eq!(app.board.column_cards(0).len(), 1);
assert_eq!(app.board.column_cards(0)[0].title, "Target");
assert!(matches!(app.mode, Mode::Filter { .. }));
}
#[test]
fn filter_esc_clears_and_returns_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.board.column_cards(0).len(), 0);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, None);
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn filter_enter_commits_and_stays_filtered() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "arget".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::ActionMenu { .. }),
"Enter on a sole result opens the action menu, got {:?}",
app.mode
);
assert_eq!(app.filter, Some("arget".into()));
assert_eq!(app.board.column_cards(0).len(), 1);
}
#[test]
fn filter_enter_opens_the_sole_result_in_another_column() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 7, "Target", "done", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "7".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert!(app.board.sole_visible_card().is_some());
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::ActionMenu { .. }),
"Enter opens the sole result's menu, got {:?}",
app.mode
);
assert_eq!(
app.board.get_selected_card().map(|card| card.number),
Some(7),
"the menu targets the sole displayed card"
);
}
#[test]
fn filter_enter_with_several_results_stays_on_the_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
write_issue(&dir, "2026-01-01-b.md", 2, "Targa", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "Tar".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"a multi-result search returns to the board, got {:?}",
app.mode
);
}
#[test]
fn filter_zero_match_reports_status() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.board.column_cards(0).len(), 0);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No matches")),
"got {:?}",
app.status_msg
);
}
#[test]
fn filter_zero_match_number_reports_status() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 7, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "999".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(app.filter, Some("999".into()));
assert_eq!(app.board.column_cards(0).len(), 0);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No matches")),
"got {:?}",
app.status_msg
);
}
#[test]
fn render_board_unfiltered_has_no_filter_row() {
let (_tmp, app) = make_app();
let cells = draw_board(&app, 80, 24);
assert!(
!cells.iter().any(|s| s.contains("Search:")),
"no filter row expected"
);
}
#[test]
fn render_board_filtered_shows_filter_row_beneath_tabs() {
let (_tmp, mut app) = make_app();
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Search: #55"),
"row 1 should read the filter, got {}",
flush_line(&cells, 80, 1)
);
}
#[test]
fn render_board_structured_filter_shows_row() {
let (_tmp, mut app) = make_app();
app.filter = None;
app.structured = search::StructuredFilter {
status: Some("todo".into()),
..Default::default()
};
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Search:"),
"got {}",
flush_line(&cells, 80, 1)
);
}
#[test]
fn render_board_filter_input_draws_row_and_prompt() {
let (_tmp, mut app) = make_app();
app.mode = Mode::Filter {
buffer: "55".to_string(),
};
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 1).contains("Search: #55"),
"row 1 should read the filter while typing"
);
assert!(
flush_line(&cells, 80, 23).contains("Search: 55"),
"bottom row should hold the input prompt"
);
}
#[test]
fn render_board_tiny_terminal_filtered_does_not_panic() {
let (_tmp, mut app) = make_app();
app.filter = Some("55".into());
app.board = BoardState::build(&app.store, app.store.config.default_config());
app.mode = Mode::Filter {
buffer: "55".to_string(),
};
for h in [2_u16, 3, 4, 5] {
let _ = draw_board(&app, 80, h);
}
}
#[test]
fn filter_esc_clears_row_and_resolves_to_board() {
let (tmp, mut app) = make_app();
let dir = tmp.path().join("issues");
write_issue(&dir, "2026-01-01-a.md", 55, "Target", "todo", None);
let config = app.store.config.clone();
app.board = BoardState::build(&app.store, config.default_config());
app.refresh_corpus().unwrap();
let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
for c in "55".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let cells = draw_board(&app, 80, 24);
assert!(
cells.concat().contains("Search:"),
"filtered board must show the row; filter={:?} cells_row1={}",
app.filter,
flush_line(&cells, 80, 1)
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.filter, None);
let after = draw_board(&app, 80, 24).concat();
assert!(
!after.contains("Search:"),
"esc cleared the filter, row must disappear, got {after:?}"
);
}
#[test]
fn filter_modal_row_click_focuses_and_opens_the_picker() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.filter_modal = Some(crate::ui::filter_modal::FilterModal::new());
app.mode = Mode::FilterModal;
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
let rect = crate::ui::modal::modal_rect(app.frame_size.get());
let status_row = rect.y + 1 + 2;
let status_field = FilterField::ALL[2];
assert_eq!(status_field, FilterField::Status);
assert_ne!(
app.filter_modal.as_ref().unwrap().focus(),
FilterField::Status
);
let action = app.handle_mouse(down_left(rect.x + 2, status_row)).unwrap();
assert_eq!(
action,
Action::FilterModalFocus(FilterField::Status),
"row click resolves the criterion region"
);
app.apply_filter_modal_action(action).unwrap();
assert_eq!(
app.filter_modal.as_ref().unwrap().focus(),
FilterField::Status,
"clicked row becomes the focused criterion"
);
assert!(
app.filter_picker
.as_ref()
.is_some_and(|picker| picker.field == FilterField::Status),
"a row click opens that row's picker, exactly as enter would"
);
}
#[test]
fn filter_picker_offers_the_current_value() {
let (_tmp, mut app) = make_app();
app.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
search::StructuredFilter {
label: Some("ghost".into()),
..Default::default()
},
));
app.mode = Mode::FilterModal;
focus_filter_field(&mut app, FilterField::Label);
assert!(
app.filter_picker_options(FilterField::Label)
.iter()
.any(|option| option.id == "ghost"),
"the current value must always be offered"
);
}
#[test]
fn filter_picker_seeds_on_the_current_value() {
let (_tmp, mut app) = make_app();
let target = app.active_board_config().columns[2].clone();
app.structured.status = Some(target.clone());
app.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
app.structured.clone(),
));
app.mode = Mode::FilterModal;
focus_filter_field(&mut app, FilterField::Status);
app.open_filter_picker();
let picker = app.filter_picker.as_ref().expect("picker open");
let highlighted = picker.state.visible_indices()[picker.state.cursor];
assert_eq!(
picker.state.options[highlighted].id, target,
"the list starts on the criterion's current value"
);
}
#[test]
fn filter_picker_refuses_inline_create() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(app.filter_picker.is_some(), "enter opens the picker");
let _ = app.handle_key(press(KeyCode::Char('+'))).unwrap();
assert_ne!(
app.filter_picker.as_ref().unwrap().state.mode,
PickerMode::InlineCreate,
"a filter lists values that exist; it must not offer to invent one"
);
}
#[test]
fn filter_picker_applies_ad_hoc_and_esc_keeps_it() {
let (_tmp, mut app) = make_app();
let columns = app.active_board_config().columns.clone();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.status
.as_deref(),
Some(columns[0].as_str()),
"the highlighted option becomes the criterion"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(
app.structured.status.as_deref(),
Some(columns[0].as_str()),
"closing the modal keeps the choice"
);
}
#[test]
fn filter_reset_then_esc_persists_the_clear() {
let (_tmp, mut app) = make_app();
let columns = app.active_board_config().columns.clone();
app.structured.status = Some(columns[0].clone());
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(
app.structured,
search::StructuredFilter::default(),
"reset followed by close must persist the cleared filter"
);
}
#[test]
fn filter_picker_esc_closes_the_list_and_the_apply_key_commits() {
let (_tmp, mut app) = make_app();
let columns = app.active_board_config().columns.clone();
let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(app.filter_picker.is_some());
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.filter_picker.is_none());
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.status
.as_deref(),
Some(columns[0].as_str()),
"closing the list keeps the criterion"
);
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.structured.status.as_deref(), Some(columns[0].as_str()));
}
#[test]
fn filter_picker_mouse_selects_and_outside_closes() {
let (_tmp, mut app) = make_app();
app.frame_size.set(Rect::new(0, 0, 80, 24));
app.filter_modal = Some(crate::ui::filter_modal::FilterModal::new());
app.mode = Mode::FilterModal;
focus_filter_field(&mut app, FilterField::Status);
app.open_filter_picker();
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
let columns = app.active_board_config().columns.clone();
let modal = crate::ui::modal::modal_rect(app.frame_size.get());
let (_, rows) =
crate::ui::filter_modal::picker_layout(modal, &app.filter_picker.as_ref().unwrap().state)
.expect("the picker lays out inside the modal");
let first = rows[0];
let action = app.handle_mouse(down_left(first.x + 1, first.y)).unwrap();
assert_eq!(action, Action::FilterPickerOption(0));
app.apply_filter_modal_action(action).unwrap();
assert!(app.filter_picker.is_none(), "choosing closes the list");
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.status
.as_deref(),
Some(columns[0].as_str()),
"the clicked option becomes the criterion"
);
app.open_filter_picker();
let outside = down_left(modal.x + modal.width - 2, modal.y + 1);
let action = app.handle_mouse(outside).unwrap();
assert_eq!(action, Action::FilterPickerClose);
app.apply_filter_modal_action(action).unwrap();
assert!(
app.filter_picker.is_none(),
"an outside click closes the list"
);
assert_eq!(
app.filter_modal
.as_ref()
.unwrap()
.active_filter()
.status
.as_deref(),
Some(columns[0].as_str()),
"an outside click leaves the criterion alone"
);
}
#[test]
fn filter_picker_renders_without_panicking_on_a_tiny_terminal() {
let (_tmp, mut app) = make_app();
app.filter_modal = Some(crate::ui::filter_modal::FilterModal::new());
app.mode = Mode::FilterModal;
app.open_filter_picker();
for (w, h) in [(80, 24), (20, 5)] {
app.frame_size.set(Rect::new(0, 0, w, h));
let backend = ratatui::backend::TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
}
}