#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::Config;
#[test]
fn compute_column_rects_splits_into_equal_columns() {
let area = Rect::new(5, 7, 90, 30);
let rects = compute_column_rects(area, 3);
assert_eq!(rects.len(), 3);
assert_eq!((rects[0].x, rects[0].width), (5, 30));
assert_eq!((rects[1].x, rects[1].width), (35, 30));
assert_eq!((rects[2].x, rects[2].width), (65, 30));
assert_eq!((rects[0].y, rects[0].height), (7, 30));
assert_eq!((rects[2].y, rects[2].height), (7, 30));
}
#[test]
fn compute_column_rects_zero_columns_is_empty() {
assert_eq!(
compute_column_rects(Rect::new(0, 0, 10, 10), 0),
[] as [ratatui::prelude::Rect; 0]
);
}
#[allow(clippy::too_many_lines)]
#[test]
fn card_line_count_matches_rendered_card_heights() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let wrapped = "A deliberately long card title that cannot fit on a single line inside the narrow column and must wrap over several rows";
let cards = vec![
Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "Short".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "1.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(2),
number: 2,
title: wrapped.into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "2.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(3),
number: 3,
title: "Another long title card that also wraps".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "3.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
];
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards,
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
let display = BoardDisplay {
wrap_titles: true,
title_lines: 3,
};
let width = 80u16;
let height = 30u16;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let col_width = compute_column_rects(Rect::new(0, 0, width, height), 1)[0].width as usize;
let mut starts: Vec<(u32, u16)> = Vec::new();
for y in 0..height {
let line: String = (1..width.min(24))
.map(|x| buffer[(x, y)].symbol())
.collect();
for card in &board.columns[0].cards {
if line.starts_with(&format!("#{} ", card.number))
&& !starts.iter().any(|(n, _)| *n == card.number)
{
starts.push((card.number, y));
break;
}
}
}
starts.sort_by_key(|(_, y)| *y);
assert_eq!(
starts.len(),
3,
"every card found in the buffer: {starts:?}"
);
for (idx, (number, y)) in starts.iter().enumerate() {
let span: u16 = if idx + 1 < starts.len() {
starts[idx + 1].1 - y
} else {
u16::try_from(
(*y..height)
.filter(|&rw| {
rw != height - 1
&& (1..(width - 1)).any(|x| buffer[(x, rw)].symbol() != " ")
})
.count(),
)
.unwrap()
};
let card = board.columns[0]
.cards
.iter()
.find(|c| c.number == *number)
.expect("card on the board");
let expected =
widgets::card_line_count(card, col_width, display.wrap_titles, display.title_lines);
assert_eq!(
span, expected,
"rendered height of card #{number} must match card_line_count"
);
}
}
#[allow(clippy::too_many_lines)]
#[test]
fn hit_test_cards_and_column_fallback() {
let tall = "A deliberately long card title that cannot fit on a single line inside the narrow column and wraps over several rows";
let cards = vec![
Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "one".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "1.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(2),
number: 2,
title: tall.into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "2.md".into(),
commit: None,
created: "2026-01-02".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
];
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards,
},
BoardColumn {
name: "done".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(3),
number: 3,
title: "three".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "3.md".into(),
commit: None,
created: "2026-01-03".into(),
due_date: None,
status: "done".into(),
category: None,
category_color: None,
done: true,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 0, 80, 30);
let widths = compute_column_rects(area, 2);
let col0 = widths[0].width as usize;
let card1_height = widgets::card_line_count(&board.columns[0].cards[0], col0, true, 3);
assert_eq!(
board.hit_test(area, display, false, 3, 1),
Some(HitTarget::Card { col: 0, row: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 3, 1 + card1_height),
Some(HitTarget::Card { col: 0, row: 1 })
);
assert_eq!(
board.hit_test(area, display, false, 3, 10),
Some(HitTarget::Column { col: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 0, 5),
Some(HitTarget::Column { col: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 55, 0),
Some(HitTarget::Column { col: 1 })
);
assert_eq!(
board.hit_test(area, display, false, 55, 1),
Some(HitTarget::Card { col: 1, row: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 3, 29),
Some(HitTarget::Column { col: 0 })
);
}
#[test]
fn hit_test_outside_area_is_none() {
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "one".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "1.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 5, 40, 12);
assert_eq!(board.hit_test(area, display, false, 3, 3), None);
assert_eq!(
board.hit_test(area, display, false, 3, 6),
Some(HitTarget::Card { col: 0, row: 0 })
);
assert_eq!(board.hit_test(area, display, false, 44, 7), None);
}
#[test]
fn hit_test_respects_status_row() {
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "one".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "1.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 0, 80, 30);
assert_eq!(board.hit_test(area, display, true, 3, 29), None);
assert_eq!(
board.hit_test(area, display, true, 3, 1),
Some(HitTarget::Card { col: 0, row: 0 })
);
}
#[test]
fn hit_test_group_header_is_not_a_card() {
let cycle_card = |number: u32, cycle: &str| Card {
reference: crate::id::IssueRef::Number(number),
health: None,
number,
title: format!("task {number}"),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: format!("{number}.md"),
commit: None,
created: format!("2026-01-{number:02}"),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: Some(cycle.into()),
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
};
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards: vec![cycle_card(1, "alpha"), cycle_card(2, "beta")],
},
BoardColumn {
name: "done".into(),
cards: vec![],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: Some("group:cycle".into()),
hover: None,
hover_col: None,
show_mismatch: false,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 0, 80, 30);
let col0 = area.width / 2;
let first_height = widgets::card_line_count(&board.columns[0].cards[0], col0 as usize, true, 3);
assert_eq!(
board.hit_test(area, display, false, 5, 1),
Some(HitTarget::Column { col: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 5, 2),
Some(HitTarget::Card { col: 0, row: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 5, 2 + first_height),
Some(HitTarget::Column { col: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 5, 3 + first_height),
Some(HitTarget::Card { col: 0, row: 1 })
);
}
#[test]
fn hit_test_mismatch_column_resolves() {
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![],
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(9),
number: 9,
title: "oddball".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "9.md".into(),
commit: None,
created: "2026-01-09".into(),
due_date: None,
status: "weird".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: true,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 0, 90, 30);
let mismatch_col = board.columns.len();
assert_eq!(
board.hit_test(area, display, false, 70, 1),
Some(HitTarget::Card {
col: mismatch_col,
row: 0
})
);
}
#[test]
fn render_highlights_hovered_card_without_selecting() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![
Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "Alpha".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "1.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(2),
number: 2,
title: "Beta".into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: "2.md".into(),
commit: None,
created: "2026-01-02".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
],
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
show_mismatch: false,
active_col: 0,
selected: 0,
hover: Some((0, 1)),
hover_col: Some(0),
filter: None,
};
let display = BoardDisplay::default();
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows = [None, None];
for y in 0..30u16 {
let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
if line.starts_with("#1") {
rows[0] = Some(y);
}
if line.starts_with("#2") {
rows[1] = Some(y);
}
}
let y1 = rows[0].expect("first card rendered");
let y2 = rows[1].expect("second card rendered");
assert_eq!(buffer[(3, y1)].style().bg, Some(Color::DarkGray));
assert_eq!(buffer[(3, y2)].style().fg, Some(Color::Cyan));
assert_eq!((board.active_col, board.selected), (0, 0));
}
#[test]
fn render_tints_marked_cards_and_keeps_the_cursor_distinct() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let card = |number: u32, title: &str| Card {
health: None,
reference: crate::id::IssueRef::Number(number),
number,
title: title.into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: format!("{number}.md"),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
};
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![card(1, "Alpha"), card(2, "Beta")],
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
show_mismatch: false,
active_col: 0,
selected: 1, hover: None,
hover_col: None,
filter: None,
};
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
BoardDisplay::default(),
&crate::ui::color::Theme::default(),
None,
None,
Some(&[1u32]),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows = [None, None];
for y in 0..30u16 {
let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
if line.starts_with("#1") {
rows[0] = Some(y);
}
if line.starts_with("#2") {
rows[1] = Some(y);
}
}
let y1 = rows[0].expect("first card rendered");
let y2 = rows[1].expect("second card rendered");
assert_eq!(
buffer[(3, y1)].style().fg,
Some(Color::Cyan),
"the marked card uses cyan text"
);
assert_eq!(
buffer[(3, y1)].style().bg,
Some(Color::DarkGray),
"the marked card gets a dark-gray background"
);
assert_eq!(
buffer[(3, y2)].style().bg,
Some(Color::DarkGray),
"the cursor keeps its own selection style"
);
assert_ne!(
buffer[(3, y2)].style().fg,
Some(Color::Cyan),
"the cursor-only card is not painted as a marked one"
);
}
#[test]
fn render_highlights_the_hovered_cards_column() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let card = |number: u32, title: &str, status: &str| Card {
health: None,
reference: crate::id::IssueRef::Number(number),
number,
title: title.into(),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: format!("{number}.md"),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: status.into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
};
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards: vec![card(1, "Alpha", "todo")],
},
BoardColumn {
name: "doing".into(),
cards: vec![card(2, "Beta", "doing")],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
show_mismatch: false,
active_col: 0,
selected: 0,
hover: Some((1, 0)),
hover_col: Some(1),
filter: None,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 0, 80, 30);
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let chunks = compute_column_rects(area, board.total_cols());
let hovered = chunks[1];
let active = chunks[0];
assert_eq!(
buffer[(hovered.x, hovered.y)].style().fg,
Some(Color::Blue),
"the hovered column is outlined"
);
assert_eq!(
buffer[(active.x, active.y)].style().fg,
Some(Color::Cyan),
"the active column keeps its own colour"
);
}
#[test]
fn render_marks_an_empty_hovered_column() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards: vec![],
},
BoardColumn {
name: "doing".into(),
cards: vec![],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
show_mismatch: false,
active_col: 0,
selected: 0,
hover: None,
hover_col: Some(1),
filter: None,
};
let display = BoardDisplay::default();
let area = Rect::new(0, 0, 80, 30);
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let chunks = compute_column_rects(area, board.total_cols());
assert_eq!(
buffer[(chunks[1].x, chunks[1].y)].style().fg,
Some(Color::Blue),
"an empty column with no cards is still outlined when hovered"
);
assert_eq!(
buffer[(chunks[0].x, chunks[0].y)].style().fg,
Some(Color::Cyan),
"the active column keeps its own colour"
);
}
#[test]
fn total_cols_includes_mismatch() {
let board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
assert_eq!(board.total_cols(), 3);
}
#[test]
fn column_name_mismatch_index() {
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
assert_eq!(board.column_name(0), "todo");
assert_eq!(board.column_name(1), "Mismatched");
}
#[test]
fn move_left_wraps_around() {
let mut board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: true,
};
board.move_left();
assert_eq!(board.active_col, 1);
}
#[test]
fn move_right_wraps_around() {
let mut board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 2,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
board.move_right();
assert_eq!(board.active_col, 0);
}
#[test]
fn clamp_selection_empty_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 5,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
board.clamp_selection();
assert_eq!(board.selected, 0);
}
#[test]
fn clamp_selection_single_card() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 5,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
board.clamp_selection();
assert_eq!(board.selected, 0);
}
#[test]
fn move_up_clamps_at_top() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
board.move_up();
assert_eq!(board.selected, 0);
}
#[test]
fn move_down_clamps_at_bottom() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
board.move_down();
assert_eq!(board.selected, 0);
}
#[test]
fn render_smoke_does_not_panic_on_tiny_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
BoardDisplay::default(),
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
}
#[test]
fn render_smoke_full_board() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards: vec![],
},
BoardColumn {
name: "done".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&Some("test msg".into()),
None,
BoardDisplay::default(),
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
}
#[test]
fn render_selected_multiline_card_highlights_all_wrapped_lines() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let title = "This is a deliberately long card title that will not fit on a single line inside the column and must wrap more than once";
let card = crate::ui::board::Card {
health: None,
reference: crate::id::IssueRef::Number(7),
number: 7,
title: title.into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![card],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: false,
};
let display = BoardDisplay {
wrap_titles: true,
title_lines: 3,
};
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut highlighted = 0;
for y in 1..buffer.area().height - 1 {
let cell = &buffer[(2, y)];
if cell.bg != Color::DarkGray {
break;
}
assert!(
cell.modifier.contains(Modifier::BOLD),
"highlighted continuation line must stay bold: {cell:?}"
);
highlighted += 1;
}
assert!(
highlighted >= 2,
"wrapped card highlights every line, got {highlighted}"
);
assert!(
highlighted <= display.title_lines,
"highlight never exceeds the line limit: {highlighted}"
);
}
#[allow(clippy::too_many_lines)]
#[test]
fn render_grouped_mode_shows_section_headers() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![
Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "A".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "a.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: Some("Cycle 1".into()),
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(2),
number: 2,
title: "B".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "b.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: true,
cycle: Some("Cycle 1".into()),
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(3),
number: 3,
title: "C".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "c.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: Some("Cycle 2".into()),
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: Some("group:cycle".into()),
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: false,
};
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
BoardDisplay::default(),
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut lines = Vec::new();
for y in 0..buffer.area.height {
let line = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>();
lines.push(line);
}
let rendered = lines.join("\n");
assert!(rendered.contains("-- cycle: Cycle 1 (1/2 done, 50%) --"));
assert!(rendered.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"));
}
#[test]
fn build_filtered_keeps_only_fuzzy_matches() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str| Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let corpus = Corpus::with_texts(
vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
vec!["Fix login bug".to_string(), "Polish exports".to_string()],
);
let config = Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board_config = config.default_config();
let board = BoardState::build_filtered(
board_config,
&config,
&corpus,
"log",
&StructuredFilter::default(),
&resolution,
);
assert_eq!(board.columns.len(), board_config.columns.len());
assert_eq!(board.column_cards(0).len(), 1);
assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
assert_eq!(board.filter.as_deref(), Some("log"));
let none = BoardState::build_filtered(
board_config,
&config,
&corpus,
"zzw",
&StructuredFilter::default(),
&resolution,
);
assert!(none.is_empty());
let empty = BoardState::build_filtered(
board_config,
&config,
&corpus,
"",
&StructuredFilter::default(),
&resolution,
);
assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
assert!(empty.filter.is_some());
}
#[test]
fn build_filtered_group_cycle_keeps_original_titles() {
use crate::model::Issue;
use crate::search::Corpus;
let issue = |number: u32, title: &str, status: &str, cycles: &[&str]| Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new(status),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: Some(cycles.iter().map(|c| (*c).to_string()).collect()),
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let corpus = Corpus::with_texts(
vec![
issue(1, "A", "todo", &["Cycle 1"]),
issue(2, "B", "done", &["Cycle 1"]),
issue(3, "C", "todo", &["Cycle 2"]),
],
vec![
"cycle: Cycle 1\nstatus: todo".to_string(),
"cycle: Cycle 1\nstatus: done".to_string(),
"cycle: Cycle 2\nstatus: todo".to_string(),
],
);
let config = Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"group:cycle",
&StructuredFilter::default(),
&resolution,
);
let titles: Vec<String> = board
.columns
.iter()
.flat_map(|col| col.cards.iter().map(|c| c.title.clone()))
.collect();
assert!(titles.iter().any(|t| t == "A"));
assert!(titles.iter().any(|t| t == "B"));
assert!(titles.iter().any(|t| t == "C"));
}
fn project_fixture() -> (Config, crate::search::Corpus) {
use crate::config::ProjectConfiguration;
use crate::model::Issue;
use crate::search::Corpus;
let issue = |number: u32, title: &str, projects: &[&str]| Issue {
projects: if projects.is_empty() {
None
} else {
Some(projects.iter().map(|p| (*p).to_string()).collect())
},
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let issues = vec![
issue(1, "Platform work", &["p-1"]),
issue(2, "Infra work", &["p-2"]),
issue(3, "Spans both", &["p-1", "p-2"]),
issue(4, "Homeless", &[]),
];
let texts: Vec<String> = issues.iter().map(Issue::frontmatter).collect();
let corpus = Corpus::with_texts(issues, texts);
let mut config = Config::default();
for (id, name) in [("p-1", "Platform"), ("p-2", "Infra")] {
config.projects.insert(
id.to_string(),
ProjectConfiguration {
name: name.to_string(),
..ProjectConfiguration::default()
},
);
}
(config, corpus)
}
fn epic_fixture() -> (Config, crate::search::Corpus) {
use crate::config::EpicConfiguration;
use crate::model::Issue;
use crate::search::Corpus;
let make = |number: u32, title: &str, epics: &[&str]| {
let list = if epics.is_empty() {
String::new()
} else {
let ids = epics
.iter()
.map(|id| format!("\"{id}\""))
.collect::<Vec<_>>()
.join(", ");
format!("epics: [{ids}]\n")
};
let text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: todo\nlabels: []\n\
priority: medium\n{list}created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
);
(Issue::parse(&text).unwrap(), text)
};
let (i1, t1) = make(1, "Alpha work", &["e-aaa111"]);
let (i2, t2) = make(2, "Beta work", &["e-bbb222"]);
let (i3, t3) = make(3, "Spans both", &["e-aaa111", "e-bbb222"]);
let (i4, t4) = make(4, "Homeless", &[]);
let corpus = Corpus::with_texts(vec![i1, i2, i3, i4], vec![t1, t2, t3, t4]);
let mut config = Config::default();
for (id, name) in [("e-aaa111", "Alpha"), ("e-bbb222", "Beta")] {
config.epics.insert(
id.to_string(),
EpicConfiguration {
name: name.to_string(),
..EpicConfiguration::default()
},
);
}
(config, corpus)
}
fn epic_board_text(board: &BoardState, width: u16, height: u16) -> String {
use ratatui::backend::TestBackend;
let display = BoardDisplay {
wrap_titles: true,
title_lines: 3,
};
let backend = TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn t030_the_board_partitions_by_epic_with_a_labelled_no_epic_bucket() {
let (config, corpus) = epic_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"group:epic sort:epic",
&StructuredFilter::default(),
&resolution,
);
let cards = &board.columns[0].cards;
let titles: Vec<&str> = cards.iter().map(|c| c.title.as_str()).collect();
assert_eq!(
titles,
vec!["Alpha work", "Beta work", "Spans both", "Homeless"],
"declaration order, then the multi-epic bucket, then no-epic last"
);
let sections =
BoardState::group_sections(cards, Some("epic")).expect("group:epic must produce sections");
let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["e-aaa111", "e-bbb222", "(multiple epics)", "(no epic)"],
"one bucket per epic in declaration order, then multi, then the \
labelled no-epic bucket last"
);
let footer = BoardState::group_footer(cards, Some("epic")).expect("a footer");
assert!(footer.contains("e-aaa111 0/1 0%"), "got {footer:?}");
assert!(footer.contains("(no epic) 0/1 0%"), "got {footer:?}");
}
#[test]
fn t030_an_unresolved_epic_reference_is_diagnosed_not_a_bucket() {
use crate::model::Issue;
use crate::search::Corpus;
let text = "---\nnumber: 1\ntitle: Stale\nstatus: todo\nlabels: []\npriority: medium\nepics:\n- e-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.";
let issue = Issue::parse(text).unwrap();
let corpus = Corpus::with_texts(vec![issue], vec![text.to_string()]);
let (config, _) = epic_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let filter = StructuredFilter {
epic: Some("e-gone".to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&filter,
&resolution,
);
assert_eq!(
board.columns[0].cards.len(),
1,
"a stored reference to a deleted epic must still be findable"
);
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"group:epic sort:epic",
&StructuredFilter::default(),
&resolution,
);
assert_eq!(
board.columns[0].cards[0].epics,
[] as [std::string::String; 0]
);
let sections = BoardState::group_sections(&board.columns[0].cards, Some("epic"))
.expect("group:epic must produce sections");
let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["(no epic)"],
"an unresolvable id is not a bucket"
);
}
#[test]
fn t030_the_epic_filter_narrows_the_board_to_the_members() {
let (config, corpus) = epic_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let members = |id: &str| {
let filter = StructuredFilter {
epic: Some(id.to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&filter,
&resolution,
);
let mut titles: Vec<String> = board.columns[0]
.cards
.iter()
.map(|c| c.title.clone())
.collect();
titles.sort();
titles
};
assert_eq!(
members("e-aaa111"),
vec!["Alpha work", "Spans both"],
"a member matches and an issue in two epics matches both"
);
assert_eq!(members("e-bbb222"), vec!["Beta work", "Spans both"]);
for unknown in ["e-ccc333", "e-does-not-exist"] {
let filter = StructuredFilter {
epic: Some(unknown.to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&filter,
&resolution,
);
assert!(board.is_empty(), "epic:{unknown} must match nothing");
}
}
#[test]
fn t030_a_grouped_board_still_opens_the_member_issue_directly() {
let (config, corpus) = epic_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let mut board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"group:epic sort:epic",
&StructuredFilter::default(),
&resolution,
);
board.selected = 2;
let selected = board.get_selected_card().expect("a card under the cursor");
assert_eq!(
selected.number, 3,
"the cursor addresses the issue itself, not an epic"
);
let text = epic_board_text(&board, 140, 40);
assert!(text.contains("-- epic: e-aaa111"), "got {text:?}");
let footer = BoardState::group_footer(&board.columns[0].cards, Some("epic"))
.expect("a grouped board has a footer");
assert!(
footer.contains(GROUP_OPEN_AFFORDANCE),
"the grouped board advertises the direct issue route: {footer:?}"
);
}
#[test]
fn t028_the_board_partitions_by_project_with_a_labelled_no_project_bucket() {
let (config, corpus) = project_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"group:project sort:project",
&StructuredFilter::default(),
&resolution,
);
let cards = &board.columns[0].cards;
let titles: Vec<&str> = cards.iter().map(|c| c.title.as_str()).collect();
assert_eq!(
titles,
vec!["Platform work", "Infra work", "Spans both", "Homeless"],
"declaration order, then the multi-project bucket, then no-project last"
);
let sections = BoardState::group_sections(cards, Some("project"))
.expect("group:project must produce sections");
let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["p-1", "p-2", "(multiple projects)", "(no project)"],
"one bucket per project in declaration order, then multi, then the \
labelled no-project bucket last"
);
let footer = BoardState::group_footer(cards, Some("project")).expect("a footer");
assert!(footer.contains("p-1 0/1 0%"), "got {footer:?}");
assert!(footer.contains("(no project) 0/1 0%"), "got {footer:?}");
}
#[test]
fn t028_an_unresolved_association_is_diagnosed_not_a_bucket() {
use crate::model::Issue;
use crate::search::Corpus;
let text = "---\nnumber: 1\ntitle: Stale\nstatus: todo\nlabels: []\npriority: medium\nprojects:\n- p-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.";
let issue = Issue::parse(text).unwrap();
let corpus = Corpus::with_texts(vec![issue], vec![text.to_string()]);
let (config, _) = project_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let filter = StructuredFilter {
project: Some("p-gone".to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&filter,
&resolution,
);
assert_eq!(
board.columns[0].cards.len(),
1,
"a stored reference to a deleted project must still be findable"
);
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"group:project sort:project",
&StructuredFilter::default(),
&resolution,
);
assert_eq!(
board.columns[0].cards[0].projects,
[] as [std::string::String; 0]
);
let sections = BoardState::group_sections(&board.columns[0].cards, Some("project"))
.expect("group:project must produce sections");
let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["(no project)"],
"an unresolvable id is diagnosed, not a bucket"
);
}
#[test]
fn t028_the_project_filter_narrows_the_board_to_the_members() {
let (config, corpus) = project_fixture();
let (_, resolution) = crate::workflow::active_resolution(&config);
let filter = StructuredFilter {
project: Some("p-1".to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&filter,
&resolution,
);
let titles: Vec<&str> = board.columns[0]
.cards
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(
titles,
vec!["Platform work", "Spans both"],
"a member matches and an issue in two projects matches both — so the \
multi-project issue is visible under either project"
);
let other = StructuredFilter {
project: Some("p-2".to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&other,
&resolution,
);
let titles: Vec<&str> = board.columns[0]
.cards
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, vec!["Infra work", "Spans both"]);
for unknown in ["p-3", "p-does-not-exist"] {
let filter = StructuredFilter {
project: Some(unknown.to_string()),
..StructuredFilter::default()
};
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&filter,
&resolution,
);
assert!(
board.is_empty(),
"project:{unknown} must match nothing, got cards"
);
}
}
#[test]
fn group_footer_reports_done_progress() {
let cards = vec![
Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "A".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "a.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: Some("Cycle 1".into()),
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
Card {
health: None,
reference: crate::id::IssueRef::Number(2),
number: 2,
title: "B".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "b.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "done".into(),
category: None,
category_color: None,
done: true,
cycle: Some("Cycle 1".into()),
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
},
];
let text = BoardState::group_footer(&cards, Some("cycle")).unwrap();
assert!(text.contains("Cycle 1 1/2 50%"));
}
#[test]
fn build_filtered_bare_number_matches_exact_issue_only() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str, status: &str| Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new(status),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let corpus = Corpus::with_texts(
vec![
to_issue(55, "Concrete target", "todo"),
to_issue(155, "Digit-rich title", "todo"),
to_issue(3, "Login bug", "todo"),
],
vec![
"Concrete target".to_string(),
"Digit-rich title".to_string(),
"Login bug".to_string(),
],
);
let config = Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board_config = config.default_config();
let exact = BoardState::build_filtered(
board_config,
&config,
&corpus,
"55",
&StructuredFilter::default(),
&resolution,
);
assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
assert_eq!(
exact.column_cards(0)[0].number,
55,
"issue 155's digit sequence must not qualify"
);
assert_eq!(exact.filter.as_deref(), Some("55"));
let miss = BoardState::build_filtered(
board_config,
&config,
&corpus,
"999",
&StructuredFilter::default(),
&resolution,
);
assert!(miss.is_empty(), "no issue 999 => zero matches");
let fuzzy = BoardState::build_filtered(
board_config,
&config,
&corpus,
"login",
&StructuredFilter::default(),
&resolution,
);
assert_eq!(fuzzy.column_cards(0).len(), 1);
assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
}
#[test]
fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str| Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let corpus = Corpus::with_texts(
vec![
to_issue(55, "Concrete target"),
to_issue(155, "Digit-rich title"),
],
vec![
"Concrete target".to_string(),
"Digit-rich title".to_string(),
],
);
let config = Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board_config = config.default_config();
let mixed = BoardState::build_filtered(
board_config,
&config,
&corpus,
"55bug",
&StructuredFilter::default(),
&resolution,
);
assert!(mixed.is_empty(), "mixed query is not an exact-number query");
}
#[test]
fn build_filtered_number_composes_with_structured_filter_and() {
use crate::model::Issue;
use crate::search::Corpus;
let cases = [
(55u32, "Issue 55 todo", "todo"),
(156u32, "Issue 156 todo", "todo"),
(57u32, "Issue 57 done", "done"),
];
let corpus = Corpus::with_texts(
cases
.iter()
.map(|&(number, title, status)| Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new(status),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{}.md", number),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
})
.collect(),
cases
.iter()
.map(|(_, title, _)| title.to_string())
.collect(),
);
let config = Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board_config = config.default_config();
let status_todo = StructuredFilter {
status: Some("todo".to_string()),
..Default::default()
};
let status_done = StructuredFilter {
status: Some("done".to_string()),
..Default::default()
};
let matches = BoardState::build_filtered(
board_config,
&config,
&corpus,
"55",
&status_todo,
&resolution,
);
assert_eq!(matches.column_cards(0).len(), 1);
assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
let done_criteria = BoardState::build_filtered(
board_config,
&config,
&corpus,
"55",
&status_done,
&resolution,
);
assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
}
#[test]
fn is_empty_true_only_with_no_cards_anywhere() {
let board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
assert!(board.is_empty());
let populated = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
hidden: vec![],
show_mismatch: true,
};
assert!(!populated.is_empty());
}
#[test]
fn build_splits_hidden_columns_out_of_visible_surface() {
use crate::config::{BoardConfiguration, Config};
let mut config = Config::default();
config.kanban.configurations.insert(
"default".to_string(),
BoardConfiguration {
columns: vec!["todo".into(), "done".into()],
hidden_columns: vec!["done".into()],
..BoardConfiguration::default()
},
);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.columns.len(), 1);
assert_eq!(board.columns[0].name, "todo");
assert_eq!(board.hidden.len(), 1);
assert_eq!(board.hidden[0].name, "done");
assert_eq!(board.total_cols(), 1);
}
#[test]
fn move_left_skips_hidden_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "visible-a".into(),
cards: vec![],
}],
hidden: vec![BoardColumn {
name: "hidden-b".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: true,
};
board.move_left();
assert_eq!(board.active_col, 0, "single visible column wraps to itself");
}
#[test]
fn move_right_skips_hidden_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "visible-a".into(),
cards: vec![],
}],
hidden: vec![BoardColumn {
name: "hidden-b".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: true,
};
board.move_right();
assert_eq!(board.active_col, 0, "single visible column wraps to itself");
}
#[test]
fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
let mut board = BoardState {
columns: vec![],
hidden: vec![BoardColumn {
name: "only-hidden".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
assert_eq!(board.total_cols(), 0);
board.move_left();
board.move_right();
assert_eq!(board.active_col, 0, "zero-column board must not panic");
let board2 = BoardState {
columns: vec![],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
assert_eq!(board2.total_cols(), 0);
}
fn write_issue_md(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
created: &str,
) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
);
std::fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn order_by_sort_desc_asc_none() {
let mk = |created: &str| Card {
health: None,
reference: crate::id::IssueRef::Number(1),
number: 1,
title: created.to_string(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: created.to_string(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: None,
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
};
let dates =
|cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
crate::config::OrderBy::None.sort(&mut cards);
assert_eq!(
dates(&cards),
["2026-03-01", "2026-01-01"],
"none is a no-op"
);
}
#[test]
fn build_orders_most_recent_first_by_default() {
use crate::config::Config;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
let config = Config {
issues_dir: PathBuf::from(&dir),
..Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let titles: Vec<&str> = board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
}
#[test]
fn build_applies_created_at_asc_and_none() {
use crate::config::OrderBy;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
let config = crate::config::Config {
issues_dir: PathBuf::from(&dir),
..crate::config::Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let mut cfg_asc = config.clone();
cfg_asc
.kanban
.configurations
.get_mut("default")
.unwrap()
.order_by = OrderBy::CreatedAtAsc;
let board_asc = BoardState::build(&store, cfg_asc.default_config());
let titles: Vec<&str> = board_asc
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, ["old", "mid", "new"]);
let mut cfg_none = config;
cfg_none
.kanban
.configurations
.get_mut("default")
.unwrap()
.order_by = OrderBy::None;
let board_none = BoardState::build(&store, cfg_none.default_config());
let titles: Vec<&str> = board_none
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(
titles,
["old", "mid", "new"],
"none keeps the store's filename-ascending order"
);
}
#[test]
fn build_applies_sparse_manual_card_order_when_present() {
use crate::config::Config;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
let config = Config {
issues_dir: PathBuf::from(&dir),
..Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let mut board_cfg = config.default_config().clone();
board_cfg.card_order = vec!["2026-02-01-mid.md".into(), "2026-03-01-new.md".into()];
let board = BoardState::build(&store, &board_cfg);
let titles: Vec<&str> = board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, ["mid", "new", "old"], "manual order wins");
}
#[test]
fn sparse_card_order_only_returns_changed_cards() {
use crate::config::Config;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
let config = Config {
issues_dir: PathBuf::from(&dir),
..Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let board_cfg = config.default_config().clone();
let mut cards = BoardState::build(&store, &board_cfg)
.column_cards(0)
.to_vec();
cards.swap(0, 1);
let sparse = sparse_card_order(&board_cfg, &cards);
assert_eq!(sparse, ["2026-02-01-mid.md", "2026-03-01-new.md"]);
}
#[test]
fn build_filtered_applies_order_by() {
use crate::model::Issue;
use crate::search::{Corpus, StructuredFilter};
let to_issue = |number: u32, title: &str, created: &str| Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: created.into(),
type_: None,
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let corpus = Corpus::with_texts(
vec![
to_issue(1, "old", "2026-01-01"),
to_issue(2, "new", "2026-03-01"),
to_issue(3, "mid", "2026-02-01"),
],
vec!["old".into(), "new".into(), "mid".into()],
);
let config = crate::config::Config::default();
let (_, resolution) = crate::workflow::active_resolution(&config);
let board = BoardState::build_filtered(
config.default_config(),
&config,
&corpus,
"",
&StructuredFilter::default(),
&resolution,
);
let titles: Vec<&str> = board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(
titles,
["new", "mid", "old"],
"default desc applies to filtered results too"
);
}
fn tree_card(number: u32, parent: Option<u32>) -> Card {
Card {
reference: crate::id::IssueRef::Number(number),
health: None,
number,
title: format!("Issue {number}"),
priority: Priority::Medium,
labels: vec![],
assignee: None,
filename: format!("{number}.md"),
commit: None,
created: "2026-01-01".into(),
due_date: None,
status: "todo".into(),
category: None,
category_color: None,
done: false,
cycle: None,
milestone: None,
blocked: None,
children: None,
parent: parent.map(crate::id::IssueRef::Number),
depth: 0,
archived: false,
related: 0,
projects: vec![],
epics: Vec::new(),
}
}
#[test]
fn nest_children_places_a_child_after_its_parent() {
let mut cards = vec![
tree_card(2, Some(1)),
tree_card(1, None),
tree_card(3, None),
];
nest_children(&mut cards);
let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
assert_eq!(
shape,
vec![(1, 0), (2, 1), (3, 0)],
"the child moves up under its parent and is marked depth 1"
);
}
#[test]
fn nest_children_keeps_adopted_siblings_in_their_existing_order() {
let mut cards = vec![
tree_card(1, None),
tree_card(3, Some(1)),
tree_card(2, Some(1)),
];
nest_children(&mut cards);
let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
assert_eq!(shape, vec![(1, 0), (3, 1), (2, 1)]);
}
#[test]
fn nest_children_does_not_nest_a_grandchild() {
let mut cards = vec![
tree_card(1, None),
tree_card(2, Some(1)),
tree_card(3, Some(2)),
];
nest_children(&mut cards);
let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
assert_eq!(shape, vec![(1, 0), (2, 1), (3, 0)]);
}
#[test]
fn nest_children_leaves_a_child_whose_parent_is_in_another_column() {
let mut cards = vec![tree_card(2, Some(1)), tree_card(5, None)];
nest_children(&mut cards);
let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
assert_eq!(shape, vec![(2, 0), (5, 0)]);
}
#[test]
fn nest_children_keeps_every_card_exactly_once() {
let mut cards = vec![
tree_card(4, None),
tree_card(3, Some(1)),
tree_card(1, None),
tree_card(2, Some(1)),
];
nest_children(&mut cards);
let mut numbers: Vec<u32> = cards.iter().map(|c| c.number).collect();
numbers.sort_unstable();
assert_eq!(
numbers,
vec![1, 2, 3, 4],
"no card is duplicated or dropped"
);
}
#[test]
fn render_indents_a_sub_issue_without_changing_its_line_count() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let width = 80u16;
let height = 30u16;
let mut child = tree_card(2, Some(1));
child.title = "a sub-issue whose deliberately long title wraps across more \
than a single rendered row inside the column"
.into();
child.depth = 1;
let parent = tree_card(1, None);
let mut flat_child = child.clone();
flat_child.depth = 0;
assert_eq!(
widgets::card_line_count(&child, width as usize, true, 3),
widgets::card_line_count(&flat_child, width as usize, true, 3),
"the indent must not change the card's rendered line count"
);
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![parent, child],
}],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
show_mismatch: false,
};
let display = BoardDisplay {
wrap_titles: true,
title_lines: 3,
};
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&None,
None,
display,
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let row = |y: u16| -> String { (0..width).map(|x| buffer[(x, y)].symbol()).collect() };
let symbol = |x: u16, y: u16| buffer[(x, y)].symbol().to_string();
assert!(
row(1).contains("#1"),
"the parent draws first: {:?}",
row(1)
);
assert_eq!(
symbol(1, 1),
"#",
"a top-level card is flush with the column border: {:?}",
row(1)
);
assert_eq!(
(symbol(1, 2), symbol(2, 2)),
(" ".to_string(), "2".to_string()),
"the sub-issue draws a one-character ASCII whitespace margin and drops \
the `#` (tix #1536): {:?}",
row(2)
);
assert_eq!(
(symbol(2, 1), symbol(2, 2)),
("1".to_string(), "2".to_string()),
"the parent's and sub-issue's digits align at the same column: {:?} / {:?}",
row(1),
row(2)
);
let area = Rect::new(0, 0, width, height);
assert_eq!(
board.hit_test(area, display, false, 3, 1),
Some(HitTarget::Card { col: 0, row: 0 })
);
assert_eq!(
board.hit_test(area, display, false, 3, 2),
Some(HitTarget::Card { col: 0, row: 1 })
);
}
fn write_issue_with_parent_md(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
created: &str,
parent: Option<u32>,
) {
let parent_line = parent.map(|p| format!("parent: {p}\n")).unwrap_or_default();
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n{parent_line}---\n\nBody text."
);
std::fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn build_with_show_children_nests_a_sub_issue_below_its_parent() {
use crate::config::Config;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_with_parent_md(
&dir,
"2026-01-01-parent.md",
1,
"parent",
"todo",
"2026-01-01",
None,
);
write_issue_with_parent_md(
&dir,
"2026-01-02-child.md",
2,
"child",
"todo",
"2026-01-02",
Some(1),
);
let config = Config {
issues_dir: PathBuf::from(&dir),
..Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let board_cfg = config.default_config().clone();
let flat = BoardState::build(&store, &board_cfg);
let flat_shape: Vec<(u32, u8)> = flat
.column_cards(0)
.iter()
.map(|c| (c.number, c.depth))
.collect();
assert_eq!(flat_shape, vec![(2, 0), (1, 0)]);
let tree = BoardState::build_with(&store, &board_cfg, false, true);
let tree_shape: Vec<(u32, u8)> = tree
.column_cards(0)
.iter()
.map(|c| (c.number, c.depth))
.collect();
assert_eq!(
tree_shape,
vec![(1, 0), (2, 1)],
"the sub-issue follows its parent, one level deep"
);
assert_eq!(
tree.column_cards(0)[1].parent,
Some(crate::id::IssueRef::Number(1))
);
}
#[test]
fn build_from_issues_matches_build_with() {
use crate::config::Config;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_with_parent_md(
&dir,
"2026-01-01-parent.md",
1,
"parent",
"todo",
"2026-01-01",
None,
);
write_issue_with_parent_md(
&dir,
"2026-01-02-child.md",
2,
"child",
"todo",
"2026-01-02",
Some(1),
);
write_issue_md(
&dir,
"2026-01-03-other.md",
3,
"other",
"in progress",
"2026-01-03",
);
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let board_cfg = config.default_config().clone();
let shape = |state: &BoardState| -> Vec<(String, Vec<(String, u8)>)> {
state
.columns
.iter()
.chain(state.hidden.iter())
.chain(std::iter::once(&state.mismatch))
.map(|col| {
(
col.name.clone(),
col.cards
.iter()
.map(|c| (c.filename.clone(), c.depth))
.collect(),
)
})
.collect()
};
for show_children in [false, true] {
let from_store = BoardState::build_with(&store, &board_cfg, false, show_children);
let issues = store.list().unwrap();
let resolution = BoardState::resolution_for(&store, &board_cfg);
let from_issues =
BoardState::build_from_issues(&issues, &board_cfg, &config, &resolution, show_children);
assert_eq!(
shape(&from_issues),
shape(&from_store),
"show_children={show_children}"
);
}
}