#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
#[test]
fn boards_limb_lists_every_board_and_marks_the_active_one() {
let (_tmp, app) = make_app();
let board = app
.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::Board)
.expect("the Board trunk exists");
assert_eq!(
board
.branches
.iter()
.map(|b| b.label.as_str())
.collect::<Vec<_>>(),
vec!["default"]
);
assert_eq!(board.branches[0].kind, crate::ui::nav::BranchKind::Board);
assert!(board.branches[0].active, "the only board is active");
let (_tmp, app) = make_branchy_app();
let board = app
.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::Board)
.expect("the Board trunk exists");
assert_eq!(
board
.branches
.iter()
.map(|b| b.label.as_str())
.collect::<Vec<_>>(),
vec!["default", "release"]
);
assert_eq!(
board
.branches
.iter()
.filter(|b| b.active)
.map(|b| b.label.as_str())
.collect::<Vec<_>>(),
vec!["default"],
"exactly the active board is marked"
);
}
#[test]
fn active_board_limb_is_drawn_bold() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (_tmp, mut app) = make_branchy_app();
app.nav_display = NavDisplay::Default;
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!(
buffer[(2, 5)].style().add_modifier.contains(Modifier::BOLD),
"the active board limb is bold: {:?}",
buffer[(2, 5)].style()
);
assert!(
!buffer[(2, 6)].style().add_modifier.contains(Modifier::BOLD),
"a non-active board limb is not bold: {:?}",
buffer[(2, 6)].style()
);
}
#[test]
fn template_cycle_and_milestone_limbs_carry_their_actions() {
let (_tmp, app) = make_branchy_app();
let templates = app
.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::NewIssue)
.expect("the New Issue trunk exists");
assert_eq!(
templates
.branches
.iter()
.map(|b| b.action.clone())
.collect::<Vec<_>>(),
vec![
Action::NewFromTemplate("bug".into()),
Action::NewFromTemplate("feature".into()),
]
);
let (_tmp, app) = make_cycle_app();
let cycles = app
.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::Cycle)
.expect("the Cycles trunk exists");
assert_eq!(
cycles
.branches
.iter()
.map(|b| b.label.as_str())
.collect::<Vec<_>>(),
vec!["Historical", "Ongoing", "Later"]
);
let planner =
crate::planning::Planner::from_config(app.store.config.clone(), app.project_root());
planner
.create_milestone_file("Backlog", None, None)
.expect("milestone file is created");
let milestones = app
.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::Milestones)
.expect("the Milestones trunk exists");
assert_eq!(
milestones
.branches
.iter()
.map(|b| b.action.clone())
.collect::<Vec<_>>(),
vec![Action::SelectMilestone("Backlog".into())]
);
}
#[test]
fn rail_draws_indented_limbs_with_click_regions() {
let (_tmp, mut app) = make_branchy_app();
app.nav_display = NavDisplay::Default;
let cells = draw_board(&app, 80, 24);
assert!(
flush_line(&cells, 80, 0).starts_with("F1 New Issue"),
"the New Issue trunk is the first row: {:?}",
flush_line(&cells, 80, 0)
);
assert!(
flush_line(&cells, 80, 1).starts_with(" bug"),
"the first template limb is indented: {:?}",
flush_line(&cells, 80, 1)
);
assert!(flush_line(&cells, 80, 2).starts_with(" feature"));
let hovered = app.handle_mouse(moved_to(0, 1)).unwrap();
assert_eq!(hovered, Action::None, "hover never acts");
assert!(
app.is_hovered(&Action::NewFromTemplate("bug".into())),
"the template row must read as hovered"
);
let clicked = app.handle_mouse(down_left(0, 1)).unwrap();
assert_eq!(clicked, Action::NewFromTemplate("bug".into()));
}
#[test]
fn small_navigation_abbreviates_limb_labels() {
let (_tmp, mut app) = make_branchy_app();
app.nav_display = NavDisplay::Small;
let cells = draw_board(&app, 80, 24);
let row0 = flush_line(&cells, 80, 0);
assert!(
row0.starts_with("F1") && !row0.starts_with("F1 New Issue"),
"small trunk shows only the chord: {row0:?}"
);
assert!(
flush_line(&cells, 80, 1).starts_with(" bu"),
"the template limb is abbreviated: {:?}",
flush_line(&cells, 80, 1)
);
assert!(flush_line(&cells, 80, 2).starts_with(" fe"));
}
#[test]
fn milestone_branch_focuses_and_renders_the_registry() {
let (_tmp, mut app) = make_app();
let planner =
crate::planning::Planner::from_config(app.store.config.clone(), app.project_root());
planner
.create_milestone_file("Backlog", None, Some("first things first"))
.unwrap();
planner.create_milestone_file("Q4", None, None).unwrap();
app.activate_branch(&Action::SelectMilestone("Q4".into()))
.unwrap();
assert!(matches!(app.mode, Mode::Settings));
assert_eq!(app.top_mode, TopMode::Milestones);
assert!(app.milestone_mode);
assert_eq!(
app.settings.as_ref().map(|s| s.section),
Some(ConfigSection::Milestones)
);
assert_eq!(app.planning.as_ref().map(|m| m.selected), Some(1));
let cells = draw_board(&app, 80, 24);
let text = cells.concat();
assert!(
text.contains("Backlog") && text.contains("Q4"),
"both milestones render: {text:?}"
);
assert!(
!text.contains(" Dashboards — "),
"the Milestones mode draws no section rail"
);
}
#[test]
fn f4_opens_the_cycle_mode_and_highlights_it() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
assert!(matches!(app.mode, Mode::Cycle));
assert_eq!(
app.top_mode,
TopMode::Cycle,
"the navigation highlights Cycle"
);
assert!(app.cycle_state.is_some(), "the mode owns its cursor");
}
#[test]
fn cycle_mode_groups_cycles_chronologically() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
let groups = app.cycle_groups();
let group_of = |name: &str| {
groups
.iter()
.find(|group| group.entries.iter().any(|entry| entry.name == name))
.map(|group| group.group)
};
assert_eq!(
group_of("Ongoing"),
Some(crate::ui::cycle::CycleGroup::Current),
"a start date with no end date is the current cycle"
);
assert_eq!(
group_of("Default"),
Some(crate::ui::cycle::CycleGroup::Current),
"the implicit Default cycle is current"
);
assert_eq!(
group_of("Later"),
Some(crate::ui::cycle::CycleGroup::Future)
);
assert_eq!(
group_of("Historical"),
Some(crate::ui::cycle::CycleGroup::Archived)
);
let order: Vec<&str> = crate::ui::cycle::flat_entries(&groups)
.into_iter()
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(
order,
vec!["Default", "Ongoing", "Later", "Historical"],
"current first, then future, then archived"
);
}
#[test]
fn cycle_mode_statistics_are_progress_and_zero_for_an_empty_cycle() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
let entries = app.cycle_entries();
let progress = |name: &str| {
entries
.iter()
.find(|entry| entry.name == name)
.map(|entry| (entry.progress.done, entry.progress.total))
.unwrap()
};
assert_eq!(progress("Ongoing"), (1, 2), "one of two issues complete");
assert_eq!(
progress("Later"),
(0, 0),
"an empty cycle renders zeros, not an error"
);
}
#[test]
fn cycle_mode_renders_group_headings_and_selected_statistics() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
app.select_cycle_row(1);
let text = draw_board(&app, 80, 24).concat();
assert!(text.contains("Ongoing"), "the cycle row is listed: {text}");
assert!(
text.contains("Completed: 1/2"),
"the selected cycle's progress renders: {text}"
);
assert!(text.contains("Current"), "current heading: {text}");
assert!(text.contains("Future"), "future heading: {text}");
assert!(text.contains("Archived"), "archived heading: {text}");
}
#[test]
fn cycle_mode_cursor_moves_with_keys() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.cycle_state.unwrap().selected, 1);
app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(app.cycle_state.unwrap().selected, 0);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board), "Esc leaves the mode");
}
#[test]
fn cycle_mode_h_and_l_jump_between_groups_and_clamp() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
let selected = |app: &App| app.cycle_state.unwrap().selected;
assert_eq!(selected(&app), 0);
app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(selected(&app), 2, "`l` jumps to the first Future entry");
app.handle_key(press(KeyCode::Right)).unwrap();
assert_eq!(selected(&app), 3, "the arrow jumps to Archived");
app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(selected(&app), 3, "the last group clamps");
app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(selected(&app), 2, "`h` jumps back to Future");
app.handle_key(press(KeyCode::Left)).unwrap();
assert_eq!(selected(&app), 0, "the arrow jumps back to Current");
app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(selected(&app), 0, "the first group clamps");
}
#[test]
fn cycle_mode_h_from_inside_a_group_lands_on_its_first_entry() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
app.select_cycle_row(1);
app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(
app.cycle_state.unwrap().selected,
0,
"the previous group is clamped to Current, whose first entry is ordinal 0"
);
app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(
app.cycle_state.unwrap().selected,
2,
"and `l` still reaches the next rendered group"
);
}
#[test]
fn clicking_a_cycle_row_selects_it() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
let _ = draw_board(&app, 80, 24);
let action = app.handle_mouse(down_left(20, 3)).unwrap();
assert_eq!(
action,
Action::CycleSelect(1),
"a click on the second row maps to the second cycle"
);
app.select_cycle_row(1);
assert_eq!(app.cycle_state.unwrap().selected, 1);
assert!(matches!(app.mode, Mode::Cycle));
}
#[test]
fn cycle_mode_renders_on_a_tiny_terminal() {
let (_tmp, mut app) = make_cycle_app();
app.handle_key(press(KeyCode::F(4))).unwrap();
for (width, height) in [(20_u16, 5_u16), (1, 1), (80, 3), (15, 24)] {
let _ = draw_board(&app, width, height);
}
}
#[test]
fn active_cycle_limb_is_marked_and_drawn_bold() {
let (_tmp, app) = make_cycle_app();
assert_eq!(app.active_cycle_name(), None);
let cycle_node = |app: &App| {
app.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::Cycle)
.expect("the Cycles trunk exists")
};
assert!(
cycle_node(&app)
.branches
.iter()
.all(|branch| !branch.active)
);
let (_tmp, mut app) = make_cycle_app();
app.activate_branch(&Action::SelectCycle("Ongoing".into()))
.unwrap();
assert_eq!(app.active_cycle_name().as_deref(), Some("Ongoing"));
let marked: Vec<String> = cycle_node(&app)
.branches
.iter()
.filter(|branch| branch.active)
.map(|branch| branch.label.clone())
.collect();
assert_eq!(marked, vec!["Ongoing".to_string()]);
let cells = draw_board(&app, 80, 24);
let row_of = |prefix: &str| {
(0..24u16)
.find(|row| flush_line(&cells, 80, *row).starts_with(prefix))
.unwrap_or_else(|| panic!("no rail row starts with {prefix:?}"))
};
let ongoing = row_of(" Ongoing");
let later = row_of(" Later");
let buffer = draw_buffer(&app, 80, 24);
assert!(
buffer[(2, ongoing)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"the focused cycle limb is bold"
);
assert!(
!buffer[(2, later)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"a non-focused cycle limb is not bold"
);
}
#[test]
fn active_milestone_limb_is_marked_and_drawn_bold() {
let (_tmp, mut 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();
planner.create_milestone_file("Q4", None, None).unwrap();
app.open_settings(None);
app.config_set_section(ConfigSection::Milestones);
assert_eq!(app.active_milestone_name(), None);
app.switch_top_mode(TopMode::Milestones).unwrap();
assert_eq!(app.active_milestone_name().as_deref(), Some("Backlog"));
let milestone_node = |app: &App| {
app.nav_tree()
.into_iter()
.find(|node| node.mode == TopMode::Milestones)
.expect("the Milestones trunk exists")
};
let marked: Vec<String> = milestone_node(&app)
.branches
.iter()
.filter(|branch| branch.active)
.map(|branch| branch.label.clone())
.collect();
assert_eq!(marked, vec!["Backlog".to_string()]);
let cells = draw_board(&app, 80, 24);
let row_of = |prefix: &str| {
(0..24u16)
.find(|row| flush_line(&cells, 80, *row).starts_with(prefix))
.unwrap_or_else(|| panic!("no rail row starts with {prefix:?}"))
};
let backlog = row_of(" Backlog");
let q4 = row_of(" Q4");
let buffer = draw_buffer(&app, 80, 24);
assert!(
buffer[(2, backlog)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"the focused milestone limb is bold"
);
assert!(
!buffer[(2, q4)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"a non-focused milestone limb is not bold"
);
app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(app.active_milestone_name().as_deref(), Some("Q4"));
}
#[test]
fn cycle_mode_lists_the_focused_cycles_issues_and_opens_them() {
let (_tmp, mut app) = make_cycle_app();
app.activate_branch(&Action::SelectCycle("Historical".into()))
.unwrap();
let _ = draw_board(&app, 100, 30);
assert_eq!(open_issue_regions(&app), vec!["alice/2026-01-03-old.md"]);
let (_tmp, mut app) = make_cycle_app();
app.activate_branch(&Action::SelectCycle("Later".into()))
.unwrap();
let cells = draw_board(&app, 100, 30);
assert_eq!(open_issue_regions(&app), [] as [std::string::String; 0]);
assert!(
cells.concat().contains("No issues."),
"an empty cycle says so"
);
let (_tmp, mut app) = make_cycle_app();
app.activate_branch(&Action::SelectCycle("Ongoing".into()))
.unwrap();
let cells = draw_board(&app, 100, 30);
assert_eq!(
open_issue_regions(&app),
vec![
"alice/2026-01-01-done.md".to_string(),
"alice/2026-01-02-open.md".to_string(),
],
"exactly the Ongoing issues, and not Historical's"
);
let text = cells.concat();
assert!(
text.contains("issues — Ongoing"),
"the table is titled: {text:?}"
);
assert!(text.contains("Issue 1") && text.contains("Issue 2"));
assert!(
!text.contains("Issue 3"),
"another cycle's issue is not listed"
);
let (region, filename) = {
let regions = app.frame_hit_regions.borrow();
let region = regions
.iter()
.find(|region| region.action == Action::OpenIssue("alice/2026-01-02-open.md".into()))
.expect("the open issue recorded a click region");
(region.rect, "alice/2026-01-02-open.md".to_string())
};
let _ = app.handle_mouse(moved_to(region.x + 1, region.y)).unwrap();
assert!(
app.is_hovered(&Action::OpenIssue(filename.clone())),
"the hovered table row reads as hovered"
);
let action = app.handle_mouse(down_left(region.x + 1, region.y)).unwrap();
assert_eq!(action, Action::OpenIssue(filename.clone()));
app.open_issue_by_filename(&filename).unwrap();
assert!(matches!(app.mode, Mode::Detail), "the row opens the issue");
assert_eq!(app.detail.as_ref().unwrap().issue.number, 2);
}
#[test]
fn milestones_mode_lists_the_focused_milestones_issues_and_opens_them() {
let (tmp, mut 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();
planner.create_milestone_file("Q4", None, None).unwrap();
let dir = tmp.path().join("issues");
write_planning_issue(
&dir,
"2026-01-01-backlog.md",
1,
"Backlog work",
"todo",
&[],
&["Backlog"],
);
write_planning_issue(&dir, "2026-01-02-q4.md", 2, "Q4 work", "todo", &[], &["Q4"]);
app.handle_key(press(KeyCode::F(5))).unwrap();
let cells = draw_board(&app, 100, 30);
assert_eq!(open_issue_regions(&app), vec!["2026-01-01-backlog.md"]);
let text = cells.concat();
assert!(
text.contains("issues — Backlog"),
"the table is titled: {text:?}"
);
assert!(text.contains("Backlog work") && !text.contains("Q4 work"));
app.handle_key(press(KeyCode::Down)).unwrap();
let _ = draw_board(&app, 100, 30);
assert_eq!(open_issue_regions(&app), vec!["2026-01-02-q4.md"]);
let (region, filename) = {
let regions = app.frame_hit_regions.borrow();
let region = regions
.iter()
.find(|region| region.action == Action::OpenIssue("2026-01-02-q4.md".into()))
.expect("the Q4 issue recorded a click region");
(region.rect, "2026-01-02-q4.md".to_string())
};
let _ = app.handle_mouse(moved_to(region.x + 1, region.y)).unwrap();
assert!(app.is_hovered(&Action::OpenIssue(filename.clone())));
let action = app.handle_mouse(down_left(region.x + 1, region.y)).unwrap();
assert_eq!(action, Action::OpenIssue(filename.clone()));
app.open_issue_by_filename(&filename).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert_eq!(app.detail.as_ref().unwrap().issue.number, 2);
}
#[test]
fn rail_focus_walks_rows_and_activates_trunks_and_limbs() {
let (_tmp, mut app) = make_branchy_app();
let rows = app.nav_rows();
let board_trunk = rows
.iter()
.position(|action| *action == Action::SwitchMode(TopMode::Board))
.expect("the Board trunk is a rail row");
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(app.nav_focus_index(), Some(board_trunk));
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.nav_focus_index(), Some(board_trunk + 1));
assert_eq!(
app.nav_rows()[board_trunk + 1],
Action::SelectBoard("default".into())
);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(app.nav_focus_index(), Some(board_trunk));
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(
app.nav_rows()[board_trunk + 2],
Action::SelectBoard("release".into())
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.active_board_name(), "release");
assert_eq!(app.top_mode, TopMode::Board);
assert_eq!(
app.nav_focus_index(),
None,
"Enter opens the row and moves the keyboard right"
);
}
#[test]
fn hovering_a_trunk_and_a_limb_row_highlights_each() {
let (_tmp, mut app) = make_branchy_app();
app.nav_display = NavDisplay::Default;
let _ = draw_board(&app, 80, 24);
let _ = app.handle_mouse(moved_to(0, 0)).unwrap();
assert!(app.is_hovered(&Action::SwitchMode(TopMode::NewIssue)));
let buffer = draw_buffer(&app, 80, 24);
assert_eq!(
buffer[(0, 0)].style().bg,
Some(Color::DarkGray),
"the hovered trunk is highlighted"
);
let _ = app.handle_mouse(moved_to(2, 1)).unwrap();
assert!(app.is_hovered(&Action::NewFromTemplate("bug".into())));
let buffer = draw_buffer(&app, 80, 24);
assert_eq!(
buffer[(2, 1)].style().bg,
Some(Color::DarkGray),
"the hovered limb is highlighted"
);
}