use std::{cell::Cell, rc::Rc, sync::mpsc};
use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Modifier};
use tuicore::{
AnimationSettings, ChildKey, EventCtx, EventRoute, FocusCtx, FocusId, FocusRequest,
FocusTarget, Key, KeyEvent, KeyModifiers, LayoutCtx, Propagation, RenderCtx, TreePath,
TuiEvent, TuiNode,
};
use super::{
components::{backlog_tree, backlog_tree_with_filters},
page::{
BacklogPage, MAX_UNCONFIRMED_TRANSFER_REFRESHES, PendingRank, PendingRankReconciliation,
PendingTransfer, PendingTransferReconciliation, move_work_items_to_edge,
recalculate_capacity, reconcile_pending_rank, reconcile_pending_transfer, should_poll,
source_transfer_highlight, source_transfer_highlight_key, sprint_report,
transfer_destinations, transfer_reconciliation_highlight, velocity_dialog,
velocity_share_report,
},
};
use crate::app_settings::{BacklogFilter, BacklogFilterSettings, BacklogRunwaySettings};
use crate::store::work_items::{
BacklogSnapshot, RunwayCapacitySource, Sprint, SubtaskProgress, VelocityReport, VelocitySprint,
WorkItem, apply_capacity, rank_plan,
};
fn work_item(key: &str, title: &str) -> WorkItem {
WorkItem {
key: key.into(),
title: title.into(),
kind: "Story".into(),
status: "To Do".into(),
done: false,
priority: "High".into(),
assignee: "Ada".into(),
parent_key: None,
parent_title: None,
has_children: false,
subtask_progress: None,
labels: Vec::new(),
fix_versions: Vec::new(),
epic_name: None,
story_points: None,
status_changed_at: None,
}
}
#[test]
fn sprint_report_uses_compact_ticket_points_without_dates() {
let mut estimated = work_item("FIN-8", "Estimate this work");
estimated.story_points = Some(8.0);
estimated.done = true;
estimated.status = "Done".into();
let mut unestimated_bug = work_item("FIN-9", "Fix a bug");
unestimated_bug.kind = "Bug".into();
unestimated_bug.done = true;
unestimated_bug.status = "Done".into();
let mut unestimated_story = work_item("FIN-10", "Unestimated story");
unestimated_story.done = true;
unestimated_story.status = "Done".into();
let mut in_review = work_item("FIN-12", "Review report format");
in_review.status = "In Review".into();
in_review.story_points = Some(5.0);
let mut selected = work_item("FIN-13", "Select report release");
selected.status = "Selected for Development".into();
let mut subtask = work_item("FIN-11", "Hidden subtask");
subtask.kind = "Sub-task".into();
subtask.done = true;
let sprint = Sprint {
id: 1,
name: "Sprint 1".into(),
state: "active".into(),
goal: Some("Ship it".into()),
start_date: Some("2026-07-02T09:00:00.000Z".into()),
end_date: Some("2026-07-16T17:00:00.000Z".into()),
work_items: vec![
estimated,
unestimated_bug,
unestimated_story,
in_review,
selected,
subtask,
],
capacity: None,
};
let report = sprint_report(&sprint, Some("https://jira.example"));
assert!(report.starts_with("Sprint 1\n\nGoal: Ship it\nPoints: 8/13 pts completed"));
assert!(!report.contains("2026-07-02"));
assert!(report.contains("Points: 8/13 pts completed"));
assert!(report.contains("Tickets: 3/5 done"));
assert!(report.contains("Estimated stories/tasks: 1/2"));
assert!(
report
.contains("✓ [S] Estimate this work - 8pts - Done - https://jira.example/browse/FIN-8")
);
assert!(report.contains("✓ [B] Fix a bug - Done - https://jira.example/browse/FIN-9"));
assert!(
report
.contains("✓ [S] Unestimated story - ?pts - Done - https://jira.example/browse/FIN-10")
);
assert!(report.contains(
"~ [S] Review report format - 5pts - In Review - https://jira.example/browse/FIN-12"
));
assert!(
report.contains(
"· [S] Select report release - ?pts - Selected for Development - https://jira.example/browse/FIN-13"
)
);
assert!(!report.contains("Hidden subtask"));
}
#[test]
fn velocity_report_uses_loaded_historical_sprint_tickets() {
let mut item = work_item("FIN-12", "Ship the report");
item.done = true;
item.status = "Done".into();
item.story_points = Some(3.0);
let sprint = VelocitySprint {
id: 12,
name: "Sprint 12".into(),
completed: 3.0,
goal: Some("Report accurately".into()),
work_items: Some(vec![item]),
};
let report = velocity_share_report(&sprint, None, Some("https://jira.example"));
assert!(report.contains("Points: 3/3 pts completed"));
assert!(report.contains("Tickets: 1/1 done"));
assert!(report.contains("Estimated stories/tasks: 1/1"));
assert!(
report.contains("✓ [S] Ship the report - 3pts - Done - https://jira.example/browse/FIN-12")
);
}
fn snapshot() -> BacklogSnapshot {
BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: vec![Sprint {
id: 7,
name: "Sprint 7".into(),
state: "active".into(),
goal: None,
start_date: Some("2026-06-18T09:00:00.000Z".into()),
end_date: Some("2026-07-02T09:00:00.000Z".into()),
work_items: vec![work_item("FIN-7", "Ship sprint work")],
capacity: None,
}],
work_items: vec![work_item("FIN-8", "Plan next sprint")],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
runway: None,
velocity: None,
}
}
fn data_focus_target() -> FocusTarget {
FocusTarget {
id: FocusId::new("data-view"),
path: TreePath::from_keys([ChildKey::new("data")]),
area: Rect::default(),
enabled: true,
tab_stop: true,
control: true,
hotkey: None,
hotkeys: Vec::new(),
hotkey_sequences: Vec::new(),
suppress_global_hotkeys: false,
focused_events_before_global_hotkeys: false,
}
}
#[test]
fn backlog_shows_loading_indicator_before_the_initial_snapshot_arrives() {
tuicore::init();
let mut page = BacklogPage::with_initial_loading_for_test();
let area = Rect::new(0, 0, 80, 16);
page.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
page.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains("Loading Jira backlog…"));
}
#[test]
fn backlog_hides_the_existing_data_view_while_reloading() {
tuicore::init();
let mut page = BacklogPage::with_snapshot_loading_for_test(snapshot());
let area = Rect::new(0, 0, 80, 16);
page.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
page.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains("Loading Jira backlog…"));
assert!(!text.contains("FIN-8"));
}
#[test]
fn backlog_refresh_shows_a_loader_while_reloading() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
view.set_loading(true);
let area = Rect::new(0, 0, 80, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut lines = rendered_lines(&terminal, area);
let header = lines.remove(0);
assert!(header.contains("⠋"));
assert!(header.contains("Refresh"));
assert!(cell_position(&header, "Refresh") > cell_position(&header, "⠋"));
}
#[test]
fn backlog_header_places_web_menu_before_the_right_aligned_refresh_button() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let header = rendered_lines(&terminal, area).remove(0);
assert!(cell_position(&header, "Web") < cell_position(&header, "Velocity"));
assert!(cell_position(&header, "Velocity") < cell_position(&header, "Refresh"));
assert!(cell_position(&header, "Refresh") < cell_position(&header, "Filter"));
}
#[test]
fn unpointed_filter_only_shows_unpointed_stories_and_tasks() {
tuicore::init();
let mut snapshot = snapshot();
let unpointed_parent = work_item("FIN-8", "Unpointed parent");
let unpointed_task = WorkItem {
kind: "Task".into(),
..work_item("FIN-9", "Unpointed task")
};
let bug = WorkItem {
kind: "Bug".into(),
..work_item("FIN-10", "Unpointed bug")
};
let subtask = WorkItem {
kind: "Sub-task".into(),
..work_item("FIN-11", "Unpointed subtask")
};
let pointed_story = WorkItem {
story_points: Some(8.0),
..work_item("FIN-12", "Pointed story")
};
snapshot.work_items = vec![
unpointed_parent,
unpointed_task,
bug,
subtask,
pointed_story,
];
apply_capacity(
&mut snapshot,
9.1,
Some((3.0, false)),
RunwayCapacitySource::Fixed,
20,
);
let mut filters = BacklogFilterSettings::default();
filters.set_selected(vec![BacklogFilter::Unpointed]);
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree_with_filters(&snapshot, sender, Default::default(), filters);
let area = Rect::new(0, 0, 100, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains("Unpointed parent"));
assert!(text.contains("Unpointed task"));
assert!(!text.contains("Unpointed bug"));
assert!(!text.contains("Unpointed subtask"));
assert!(!text.contains("Pointed story"));
assert!(!text.contains("┃"));
}
#[test]
fn done_filter_matches_done_status_case_insensitively() {
tuicore::init();
let mut snapshot = snapshot();
snapshot.work_items = vec![
WorkItem {
status: "Done".into(),
..work_item("FIN-8", "Uppercase done")
},
WorkItem {
status: "done".into(),
..work_item("FIN-9", "Lowercase done")
},
WorkItem {
status: "Closed".into(),
..work_item("FIN-10", "Closed ticket")
},
];
let mut filters = BacklogFilterSettings::default();
filters.set_selected(vec![BacklogFilter::Done]);
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree_with_filters(&snapshot, sender, Default::default(), filters);
let area = Rect::new(0, 0, 100, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains("Uppercase done"));
assert!(text.contains("Lowercase done"));
assert!(!text.contains("Closed ticket"));
}
#[test]
fn backlog_refresh_is_focusable_with_shift_r() {
tuicore::init();
let (sender, receiver) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let area = Rect::new(0, 0, 80, 16);
let mut layout = LayoutCtx::new();
view.layout(area, &mut layout);
let refresh = layout
.focus_targets()
.iter()
.find(|target| target.id.as_str() == "button")
.unwrap();
assert_eq!(layout.focus_targets()[0].id.as_str(), "data-view");
assert!(refresh.tab_stop);
assert_eq!(refresh.hotkey_sequences, ["shift+r"]);
assert_eq!(
refresh.path,
TreePath::from_keys([ChildKey::new("refresh")])
);
let mut ctx = EventCtx::new(AnimationSettings::default());
view.dispatch_event(
&EventRoute::new(TreePath::from_keys([ChildKey::new("refresh")])),
&TuiEvent::Key(KeyEvent {
code: Key::Char('r'),
modifiers: KeyModifiers::SHIFT,
}),
&mut ctx,
);
assert!(matches!(
receiver.try_recv(),
Ok(super::components::BacklogSectionEvent::Refresh)
));
}
#[test]
fn backlog_data_view_retains_focus_when_unfocused() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
for key in [
KeyEvent::from(Key::Esc),
KeyEvent {
code: Key::Char('['),
modifiers: KeyModifiers::CONTROL,
},
] {
let mut ctx = EventCtx::new(AnimationSettings::default());
assert_eq!(
view.dispatch_event(&route, &TuiEvent::Key(key), &mut ctx),
tuicore::EventOutcome::Handled
);
assert_eq!(ctx.propagation(), Propagation::Stopped);
assert_eq!(ctx.focus_request(), None);
}
}
#[test]
fn backlog_header_controls_return_focus_to_the_data_view_when_unfocused() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
for control in ["refresh", "velocity", "filters", "web"] {
for key in [
KeyEvent::from(Key::Esc),
KeyEvent {
code: Key::Char('['),
modifiers: KeyModifiers::CONTROL,
},
] {
let mut ctx = EventCtx::new(AnimationSettings::default());
assert_eq!(
view.dispatch_event(
&EventRoute::new(TreePath::from_keys([ChildKey::new(control)])),
&TuiEvent::Key(key),
&mut ctx,
),
tuicore::EventOutcome::Handled
);
assert_eq!(
ctx.focus_request(),
Some(&FocusRequest::Target(FocusId::new("data-view")))
);
assert_eq!(ctx.propagation(), Propagation::Stopped);
}
}
}
#[test]
fn backlog_velocity_is_focusable_with_shift_v() {
tuicore::init();
let (sender, receiver) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let area = Rect::new(0, 0, 80, 16);
let mut layout = LayoutCtx::new();
view.layout(area, &mut layout);
let velocity = layout
.focus_targets()
.iter()
.find(|target| target.path == TreePath::from_keys([ChildKey::new("velocity")]))
.unwrap();
assert_eq!(velocity.hotkey_sequences, ["shift+v"]);
let mut ctx = EventCtx::new(AnimationSettings::default());
view.dispatch_event(
&EventRoute::new(TreePath::from_keys([ChildKey::new("velocity")])),
&TuiEvent::Key(KeyEvent {
code: Key::Char('v'),
modifiers: KeyModifiers::SHIFT,
}),
&mut ctx,
);
assert!(matches!(
receiver.try_recv(),
Ok(super::components::BacklogSectionEvent::OpenVelocity)
));
}
#[test]
fn backlog_filter_is_focusable_with_shift_f() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
let mut layout = LayoutCtx::new();
view.layout(area, &mut layout);
let filter = layout
.focus_targets()
.iter()
.find(|target| {
target.path == TreePath::from_keys([ChildKey::new("filters")])
&& target.id.as_str() == "field"
})
.unwrap();
assert_eq!(filter.hotkey_sequences, ["shift+f"]);
}
#[test]
fn backlog_web_menu_opens_with_shift_w_and_emits_board_event() {
tuicore::init();
let (sender, receiver) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
let mut layout = LayoutCtx::new();
view.layout(area, &mut layout);
let web = layout
.focus_targets()
.iter()
.find(|target| {
target.path == TreePath::from_keys([ChildKey::new("web"), ChildKey::new("trigger")])
})
.unwrap();
assert_eq!(web.hotkey_sequences, ["shift+w"]);
let mut ctx = EventCtx::new(AnimationSettings::default());
view.dispatch_event(
&EventRoute::new(TreePath::from_keys([ChildKey::new("web")])),
&TuiEvent::Key(KeyEvent::from(Key::Enter)),
&mut ctx,
);
view.layout(area, &mut LayoutCtx::new());
view.dispatch_event(
&EventRoute::new(TreePath::from_keys([
ChildKey::new("web"),
ChildKey::new("menu"),
])),
&TuiEvent::Key(KeyEvent::from(Key::Enter)),
&mut ctx,
);
assert_eq!(
receiver.try_recv(),
Ok(super::components::BacklogSectionEvent::OpenBoard)
);
}
#[test]
fn selecting_a_web_menu_item_returns_focus_to_the_backlog_data_view() {
tuicore::init();
let mut page = BacklogPage::with_snapshot_for_test(snapshot());
let area = Rect::new(0, 0, 100, 16);
page.layout(area, &mut LayoutCtx::new());
page.dispatch_event(
&EventRoute::new(TreePath::from_keys([
ChildKey::first(),
ChildKey::first(),
ChildKey::new("web"),
])),
&TuiEvent::Key(KeyEvent::from(Key::Enter)),
&mut EventCtx::new(AnimationSettings::default()),
);
page.layout(area, &mut LayoutCtx::new());
let mut ctx = EventCtx::new(AnimationSettings::default());
page.dispatch_event(
&EventRoute::new(TreePath::from_keys([
ChildKey::first(),
ChildKey::first(),
ChildKey::new("web"),
ChildKey::new("menu"),
])),
&TuiEvent::Key(KeyEvent::from(Key::Enter)),
&mut ctx,
);
assert_eq!(
ctx.focus_request(),
Some(&FocusRequest::TargetAt {
path: TreePath::from_keys([
ChildKey::first(),
ChildKey::first(),
ChildKey::new("data")
]),
id: FocusId::new("data-view"),
})
);
}
#[test]
fn closing_the_web_menu_returns_focus_to_the_backlog_data_view() {
tuicore::init();
let mut page = BacklogPage::with_snapshot_for_test(snapshot());
let area = Rect::new(0, 0, 100, 16);
page.layout(area, &mut LayoutCtx::new());
page.dispatch_event(
&EventRoute::new(TreePath::from_keys([
ChildKey::first(),
ChildKey::first(),
ChildKey::new("web"),
])),
&TuiEvent::Key(KeyEvent::from(Key::Enter)),
&mut EventCtx::new(AnimationSettings::default()),
);
page.layout(area, &mut LayoutCtx::new());
let mut ctx = EventCtx::new(AnimationSettings::default());
page.dispatch_event(
&EventRoute::new(TreePath::from_keys([
ChildKey::first(),
ChildKey::first(),
ChildKey::new("web"),
ChildKey::new("menu"),
])),
&TuiEvent::Key(KeyEvent::from(Key::Tab)),
&mut ctx,
);
assert_eq!(
ctx.focus_request(),
Some(&FocusRequest::TargetAt {
path: TreePath::from_keys([
ChildKey::first(),
ChildKey::first(),
ChildKey::new("data")
]),
id: FocusId::new("data-view"),
})
);
}
#[test]
fn unified_backlog_tree_shows_collapsed_sprints_and_expanded_backlog() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut snapshot = snapshot();
snapshot.story_points_configured = true;
snapshot.work_items[0].assignee = "Unassigned".into();
snapshot.sprints.push(Sprint {
id: 8,
name: "Sprint 8".into(),
state: "future".into(),
goal: None,
start_date: Some("2026-07-03T09:00:00.000Z".into()),
end_date: Some("2026-07-17T09:00:00.000Z".into()),
work_items: Vec::new(),
capacity: None,
});
let mut view = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 80, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains(" Sprint 7 • 18 Jun – 2 Jul"));
assert!(text.contains(" Sprint 8 • 3 Jul – 17 Jul"));
assert!(text.contains(" Backlog • 1(1) items"));
assert!(!text.contains("Finery"));
assert!(!text.contains("Ship sprint work"));
assert!(text.contains("Plan next sprint"));
assert!(text.contains("FIN-8 Plan next sprint"));
assert!(text.contains("- • @-- • To Do"));
}
#[test]
fn expanded_sprint_shows_subtasks_under_their_parent() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut snapshot = snapshot();
let mut subtask = work_item("FIN-9", "Finish sprint work");
subtask.kind = "Sub-task".into();
subtask.parent_key = Some("FIN-7".into());
subtask.parent_title = Some("Ship sprint work".into());
snapshot.sprints[0].work_items.push(subtask);
let mut view = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
view.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
view.highlight("section:sprint-7");
view.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Right)),
&mut EventCtx::new(AnimationSettings::default()),
);
let area = Rect::new(0, 0, 80, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains("FIN-7 Ship sprint work"));
assert!(text.contains("FIN-9 Finish sprint work"));
assert!(text.contains("1(2) items"));
}
#[test]
fn orphaned_subtasks_are_hidden_from_the_backlog_and_sprints() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut snapshot = snapshot();
let mut sprint_subtask = work_item("FIN-9", "Hidden sprint child");
sprint_subtask.kind = "Sub-task".into();
sprint_subtask.parent_key = Some("MISSING-1".into());
snapshot.sprints[0].work_items.push(sprint_subtask);
let mut backlog_subtask = work_item("FIN-10", "Hidden backlog child");
backlog_subtask.kind = "Sub-task".into();
backlog_subtask.parent_key = Some("MISSING-2".into());
snapshot.work_items.push(backlog_subtask);
let mut view = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 80, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(!text.contains("Hidden sprint child"));
assert!(!text.contains("Hidden backlog child"));
}
#[test]
fn subtasks_cannot_enter_reorder_mode() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut snapshot = snapshot();
let mut subtask = work_item("FIN-9", "Finish sprint work");
subtask.kind = "Sub-task".into();
subtask.parent_key = Some("FIN-7".into());
snapshot.sprints[0].work_items.push(subtask);
let mut view = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
view.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
view.highlight("ticket:FIN-9");
view.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent {
code: Key::Char('m'),
modifiers: KeyModifiers::CONTROL,
}),
&mut EventCtx::new(AnimationSettings::default()),
);
assert!(!view.is_reordering_for_test());
}
#[test]
fn refreshed_backlog_falls_back_to_the_missing_ticket_parent() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
view.highlight("ticket:FIN-8");
let mut refreshed = snapshot();
refreshed.work_items.clear();
view.set_snapshot(&refreshed);
assert_eq!(
view.highlighted_id_for_test().as_deref(),
Some("section:backlog")
);
}
#[test]
fn refreshed_backlog_keeps_the_highlighted_ticket_and_expanded_sprint() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot(), sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
view.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
view.highlight("section:sprint-7");
view.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Right)),
&mut EventCtx::new(AnimationSettings::default()),
);
view.highlight("ticket:FIN-7");
view.set_snapshot(&snapshot());
let area = Rect::new(0, 0, 80, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
assert!(rendered_lines(&terminal, area).concat().contains("FIN-7"));
assert_eq!(
view.highlighted_id_for_test().as_deref(),
Some("ticket:FIN-7")
);
}
#[test]
fn page_refresh_keeps_the_highlighted_ticket() {
let mut page = BacklogPage::with_snapshot_for_test(snapshot());
page.view_for_test()
.base_mut()
.base_mut()
.highlight("ticket:FIN-8");
page.refresh_snapshot_for_test(snapshot());
assert_eq!(
page.view_for_test()
.base_mut()
.base_mut()
.highlighted_id_for_test()
.as_deref(),
Some("ticket:FIN-8")
);
}
#[test]
fn backlog_story_rows_show_identity_then_subtask_release_and_epic_metadata() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut snapshot = snapshot();
snapshot.story_points_configured = true;
let story = &mut snapshot.work_items[0];
story.story_points = Some(3.0);
story.assignee = "Maya Voss".into();
story.subtask_progress = Some(SubtaskProgress {
completed: 0,
total: 2,
});
story.labels = vec!["AB".into(), "CD".into(), "Refinery".into()];
story.fix_versions = vec!["1.4.0".into()];
story.epic_name = Some("Shopping cart".into());
let mut view = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let lines = rendered_lines(&terminal, area);
let text = lines.concat();
assert!(text.contains("FIN-8 Plan next sprint"));
assert!(text.contains("3 • @MV • 0/2 • To Do • AB|CD|Refinery • Shopping cart • 1.4.0"));
let (ticket_y, ticket_line) = lines
.iter()
.enumerate()
.find(|(_, line)| line.contains("FIN-8 Plan next sprint"))
.unwrap();
let key_x = cell_position(ticket_line, "FIN-8").unwrap() as u16;
assert_eq!(
terminal
.backend()
.buffer()
.cell((key_x, ticket_y as u16))
.unwrap()
.fg,
tuicore::theme().muted_fg()
);
assert!(
terminal
.backend()
.buffer()
.cell((key_x, ticket_y as u16))
.unwrap()
.modifier
.contains(Modifier::BOLD)
);
let title_x = cell_position(ticket_line, "Plan next sprint").unwrap() as u16;
assert!(
terminal
.backend()
.buffer()
.cell((title_x, ticket_y as u16))
.unwrap()
.modifier
.contains(Modifier::BOLD)
);
let (metadata_y, metadata_line) = lines
.iter()
.enumerate()
.find(|(_, line)| line.contains("1.4.0"))
.unwrap();
let version_x = cell_position(metadata_line, "1.4.0").unwrap() as u16;
let epic_x = cell_position(metadata_line, "Shopping cart").unwrap() as u16;
let metadata_cell = terminal
.backend()
.buffer()
.cell((version_x, metadata_y as u16))
.unwrap();
assert!(metadata_cell.modifier.contains(Modifier::BOLD));
assert_eq!(metadata_cell.fg, tuicore::theme().accent_fg());
assert_eq!(
terminal
.backend()
.buffer()
.cell((epic_x, metadata_y as u16))
.unwrap()
.fg,
tuicore::theme().warning_fg()
);
}
#[test]
fn backlog_shows_capacity_markers_without_a_velocity_indicator() {
tuicore::init();
let mut snapshot = snapshot();
snapshot.work_items = vec![
WorkItem {
story_points: Some(8.0),
..work_item("FIN-8", "Plan next sprint")
},
WorkItem {
story_points: Some(14.0),
..work_item("FIN-9", "Refine the next sprint")
},
work_item("FIN-10", "Estimate this ticket"),
];
snapshot.sprints[0].work_items[0].story_points = Some(18.0);
apply_capacity(
&mut snapshot,
20.0,
Some((3.0, false)),
RunwayCapacitySource::Fixed,
20,
);
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains("Refresh"));
assert!(text.contains("Velocity"));
assert!(text.contains("┃"));
assert!(text.contains("3 • @AD • To Do"));
let lines = rendered_lines(&terminal, area);
let capacity_line = lines.iter().find(|line| line.contains("✓ 1/1")).unwrap();
assert!(capacity_line.contains(" 18/20 pts • ✓ 1/1 • 1(1) items"));
assert_eq!(cell_position(capacity_line, ""), Some(2));
assert!(!text.contains("assumed"));
let ticket_position = |key| {
lines
.iter()
.enumerate()
.find_map(|(y, line)| cell_position(line, key).map(|x| (x as u16, y as u16)))
.unwrap()
};
let (first_x, first_y) = ticket_position("FIN-8");
let (second_x, second_y) = ticket_position("FIN-9");
let (third_x, third_y) = ticket_position("FIN-10");
assert_eq!(first_x, third_x);
assert_eq!(
terminal
.backend()
.buffer()
.cell((first_x, first_y))
.unwrap()
.bg,
tuicore::theme().surface_bg()
);
assert_ne!(
terminal
.backend()
.buffer()
.cell((second_x, second_y))
.unwrap()
.bg,
tuicore::theme().surface_bg()
);
assert_eq!(
terminal
.backend()
.buffer()
.cell((third_x, third_y))
.unwrap()
.bg,
tuicore::theme().surface_bg()
);
snapshot.sprints[0].work_items[0].story_points = Some(5.4);
apply_capacity(
&mut snapshot,
20.0,
Some((5.4, true)),
RunwayCapacitySource::JiraVelocity,
20,
);
view.set_snapshot(&snapshot);
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
text.clear();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains("Velocity"));
assert!(text.contains(" Sprint 7 • 18 Jun – 2 Jul"));
assert!(text.contains(" ~5.4/20 pts • ✓ 1/1 • 1(1) items"));
assert!(text.contains("5.4 • @AD • To Do"));
snapshot.sprints[0].work_items[0].story_points = None;
apply_capacity(
&mut snapshot,
20.0,
Some((5.4, true)),
RunwayCapacitySource::JiraVelocity,
20,
);
view.set_snapshot(&snapshot);
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains(" ~5.4/20 pts • 0/1 • 1(1) items"));
apply_capacity(
&mut snapshot,
20.0,
Some((5.4, true)),
RunwayCapacitySource::FixedFallback,
20,
);
view.set_snapshot(&snapshot);
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains(" 5.4/20 pts • 0/1 • 1(1) items"));
assert!(!text.contains("~5.4/20 pts"));
}
#[test]
fn sprint_estimation_coverage_excludes_bugs_and_counts_all_sprint_items() {
tuicore::init();
let mut snapshot = snapshot();
snapshot.story_points_configured = true;
snapshot.sprints[0].work_items = vec![
WorkItem {
story_points: Some(5.0),
..work_item("FIN-7", "Estimated story")
},
WorkItem {
kind: "BUG".into(),
..work_item("FIN-9", "Unestimated sprint bug")
},
WorkItem {
kind: "bug".into(),
story_points: Some(2.0),
..work_item("FIN-10", "Estimated sprint bug")
},
WorkItem {
kind: "Custom issue type".into(),
..work_item("FIN-11", "Unestimated custom item")
},
];
snapshot.work_items = vec![WorkItem {
kind: "Bug".into(),
..work_item("FIN-8", "Unestimated backlog bug")
}];
apply_capacity(
&mut snapshot,
20.0,
Some((3.0, true)),
RunwayCapacitySource::JiraVelocity,
20,
);
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains(" ~7/20 pts • ✓ 1/1 • 4(4) items"));
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(' '))),
&mut EventCtx::new(AnimationSettings::default()),
);
tree.layout(area, &mut LayoutCtx::new());
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains("Unestimated sprint bug"));
assert!(text.contains("Unestimated backlog bug"));
}
#[test]
fn sprint_load_marks_zero_valued_average_assumptions() {
tuicore::init();
let mut snapshot = snapshot();
apply_capacity(
&mut snapshot,
20.0,
Some((0.0, true)),
RunwayCapacitySource::JiraVelocity,
20,
);
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 100, 16);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
assert!(
rendered_lines(&terminal, area)
.concat()
.contains(" ~0/20 pts • 0/1 • 1(1) items")
);
}
#[test]
fn space_toggles_the_highlighted_backlog_section() {
tuicore::init();
let (sender, _) = mpsc::channel();
let mut snapshot = snapshot();
snapshot.story_points_configured = true;
apply_capacity(
&mut snapshot,
20.0,
Some((5.4, true)),
RunwayCapacitySource::Fixed,
20,
);
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(' '))),
&mut ctx,
);
let area = Rect::new(0, 0, 80, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains("Ship sprint work"));
assert!(text.contains("~5.4 • @AD • To Do"));
}
#[test]
fn long_backlog_titles_wrap_to_the_available_viewport_width() {
tuicore::init();
let snapshot = BacklogSnapshot {
board_name: "Finery".into(),
sprints: Vec::new(),
work_items: vec![work_item(
"FIN-1",
"A backlog title that wraps at the viewport edge",
)],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
story_points_configured: false,
runway: None,
velocity: None,
};
let (sender, _) = mpsc::channel();
let mut view = backlog_tree(&snapshot, sender, Default::default());
let area = Rect::new(0, 0, 40, 8);
view.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
view.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let lines = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| terminal.backend().buffer().cell((x, y)).unwrap().symbol())
.collect::<String>()
})
.collect::<Vec<_>>();
let title_line = lines
.iter()
.find(|line| line.contains("A backlog title"))
.unwrap();
let title_start = cell_position(title_line, "A backlog title").unwrap();
let continuation = lines.iter().find(|line| line.contains("viewport")).unwrap();
assert_eq!(
continuation.chars().position(|character| character != ' '),
Some(title_start),
);
}
#[test]
fn ticket_number_prefixes_wait_for_enter_and_underline_each_matching_number() {
tuicore::init();
let mut snapshot = snapshot();
snapshot.sprints[0].work_items = vec![work_item("KAN-34", "Sprint ticket")];
snapshot.work_items = vec![work_item("KAN-342", "Backlog ticket")];
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
for digit in ['3', '4'] {
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(digit))),
&mut ctx,
);
}
let area = Rect::new(0, 0, 80, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let lines = rendered_lines(&terminal, area);
let (y, line) = lines
.iter()
.enumerate()
.find(|(_, line)| line.contains("KAN-34 Sprint ticket"))
.unwrap();
let key_x = cell_position(line, "KAN-34").unwrap() as u16;
assert!(
!terminal
.backend()
.buffer()
.cell((key_x, y as u16))
.unwrap()
.modifier
.contains(Modifier::UNDERLINED)
);
assert!((key_x + 4..key_x + 6).all(|x| {
terminal
.backend()
.buffer()
.cell((x, y as u16))
.unwrap()
.modifier
.contains(Modifier::UNDERLINED)
}));
tree.dispatch_event(&route, &TuiEvent::Key(KeyEvent::from(Key::Enter)), &mut ctx);
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
assert!(
!terminal
.backend()
.buffer()
.cell((key_x + 4, y as u16))
.unwrap()
.modifier
.contains(Modifier::UNDERLINED)
);
}
fn cell_position(line: &str, content: &str) -> Option<usize> {
line.find(content)
.map(|position| line[..position].chars().count())
}
fn rendered_lines(terminal: &Terminal<TestBackend>, area: Rect) -> Vec<String> {
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| terminal.backend().buffer().cell((x, y)).unwrap().symbol())
.collect()
})
.collect()
}
#[test]
fn velocity_dialog_shows_goals_in_alternating_two_line_rows() {
tuicore::init();
let report = VelocityReport {
sprints: vec![
VelocitySprint {
id: 1,
name: "Sprint one".into(),
completed: 22.0,
goal: Some("Ship release".into()),
work_items: None,
},
VelocitySprint {
id: 2,
name: "Sprint two".into(),
completed: 20.0,
goal: None,
work_items: None,
},
VelocitySprint {
id: 3,
name: "Sprint three".into(),
completed: 18.0,
goal: Some("Finish migration".into()),
work_items: None,
},
],
dynamic_capacity: Some(21.0),
configured_sprints: 2,
};
let mut dialog = velocity_dialog(
Some(&report),
None,
None,
&BacklogRunwaySettings::default(),
None,
Rc::new(Cell::new(false)),
);
let area = Rect::new(0, 0, 80, 24);
dialog.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
dialog.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let lines = rendered_lines(&terminal, area);
let sprint_one_y = lines
.iter()
.position(|line| line.contains("Sprint one"))
.unwrap() as u16;
let release_y = lines
.iter()
.position(|line| line.contains("Ship release"))
.unwrap() as u16;
let sprint_two_y = lines
.iter()
.position(|line| line.contains("Sprint two"))
.unwrap() as u16;
let missing_goal_y = lines
.iter()
.position(|line| line.contains("(no sprint goal)"))
.unwrap() as u16;
let migration_y = lines
.iter()
.position(|line| line.contains("Finish migration"))
.unwrap() as u16;
let sprint_one_x =
cell_position(&lines[usize::from(sprint_one_y)], "Sprint one").unwrap() as u16;
let sprint_two_x =
cell_position(&lines[usize::from(sprint_two_y)], "Sprint two").unwrap() as u16;
let missing_goal_x =
cell_position(&lines[usize::from(missing_goal_y)], "(no sprint goal)").unwrap() as u16;
let migration_x =
cell_position(&lines[usize::from(migration_y)], "Finish migration").unwrap() as u16;
assert_eq!(release_y, sprint_one_y + 1);
assert_eq!(missing_goal_y, sprint_two_y + 1);
assert!(
terminal
.backend()
.buffer()
.cell((sprint_one_x, sprint_one_y))
.unwrap()
.modifier
.contains(Modifier::BOLD)
);
assert!(
terminal
.backend()
.buffer()
.cell((sprint_two_x, sprint_two_y))
.unwrap()
.modifier
.contains(Modifier::BOLD)
);
assert!(
!terminal
.backend()
.buffer()
.cell((missing_goal_x, missing_goal_y))
.unwrap()
.modifier
.contains(Modifier::BOLD)
);
assert_eq!(
terminal
.backend()
.buffer()
.cell((migration_x, migration_y))
.unwrap()
.bg,
tuicore::theme().background_bg()
);
assert_eq!(
terminal
.backend()
.buffer()
.cell((missing_goal_x, missing_goal_y))
.unwrap()
.fg,
tuicore::theme().muted_fg()
);
}
#[test]
fn velocity_dialog_wraps_long_sprint_goals() {
tuicore::init();
let report = VelocityReport {
sprints: vec![VelocitySprint {
id: 1,
name: "Sprint one".into(),
completed: 22.0,
goal: Some("Deliver the migration with reliable error handling".into()),
work_items: None,
}],
dynamic_capacity: Some(22.0),
configured_sprints: 1,
};
let mut dialog = velocity_dialog(
Some(&report),
None,
None,
&BacklogRunwaySettings::default(),
None,
Rc::new(Cell::new(false)),
);
let area = Rect::new(0, 0, 46, 24);
dialog.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
dialog.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let lines = rendered_lines(&terminal, area);
let goal_start = lines
.iter()
.position(|line| line.contains("Deliver the"))
.unwrap();
let goal_end = lines
.iter()
.position(|line| line.contains("handling"))
.unwrap();
assert!(goal_end > goal_start);
}
#[test]
fn backlog_search_filters_tickets_and_hides_runway_bands() {
tuicore::init();
let mut snapshot = BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: Vec::new(),
work_items: vec![
work_item("FIN-1", "Plan next sprint"),
work_item("FIN-2", "Ship release"),
],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
runway: None,
velocity: None,
};
apply_capacity(
&mut snapshot,
9.1,
Some((3.0, false)),
RunwayCapacitySource::JiraVelocity,
20,
);
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char('/'))),
&mut ctx,
);
for key in "FIN-2".chars() {
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(key))),
&mut ctx,
);
}
let area = Rect::new(0, 0, 80, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains("FIN-2"));
assert!(!text.contains("FIN-1"));
assert!(!text.contains("┃"));
let matching_y = rendered_lines(&terminal, area)
.iter()
.position(|line| line.contains("FIN-2"))
.unwrap() as u16;
assert!(
(matching_y..matching_y + 2).all(|y| {
(0..area.width).all(|x| {
terminal.backend().buffer().cell((x, y)).unwrap().bg
!= tuicore::theme().surface_bg()
})
}),
"search results must not retain virtual sprint background bands"
);
}
#[test]
fn backlog_search_keeps_subtasks_visible_when_the_parent_matches() {
tuicore::init();
let mut snapshot = snapshot();
let parent = work_item("KAN-22", "Catalog browsing supports discovery");
let mut matching_child = work_item("KAN-34", "Frontend integration");
matching_child.kind = "Sub-task".into();
matching_child.parent_key = Some("KAN-22".into());
let mut non_matching_child = work_item("KAN-35", "Schema foundations");
non_matching_child.kind = "Sub-task".into();
non_matching_child.parent_key = Some("KAN-22".into());
snapshot.work_items = vec![
parent,
matching_child,
non_matching_child,
work_item("KAN-30", "Checkout"),
];
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char('/'))),
&mut ctx,
);
for key in "KAN-22".chars() {
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(key))),
&mut ctx,
);
}
let area = Rect::new(0, 0, 100, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(text.contains("KAN-22 Catalog browsing supports discovery"));
assert!(text.contains("KAN-34 Frontend integration"));
assert!(text.contains("KAN-35 Schema foundations"));
assert!(!text.contains("KAN-30 Checkout"));
}
#[test]
fn backlog_search_requires_contiguous_text() {
tuicore::init();
let snapshot = BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: Vec::new(),
work_items: vec![work_item("FIN-1", "A shopper can narrow products")],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
runway: None,
velocity: None,
};
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char('/'))),
&mut ctx,
);
for key in "shoppersa".chars() {
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(key))),
&mut ctx,
);
}
let area = Rect::new(0, 0, 80, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
}
}
assert!(text.contains("No stories"));
}
#[test]
fn backlog_search_matches_epic_names() {
tuicore::init();
let mut item = work_item("FIN-1", "Improve deployment reporting");
item.epic_name = Some("Operations".into());
let snapshot = BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: Vec::new(),
work_items: vec![item],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
runway: None,
velocity: None,
};
let (sender, _) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char('/'))),
&mut ctx,
);
for key in "operations".chars() {
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char(key))),
&mut ctx,
);
}
let area = Rect::new(0, 0, 80, 16);
tree.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
tree.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
assert!(
rendered_lines(&terminal, area)
.concat()
.contains("Improve deployment reporting")
);
}
#[test]
fn unified_tree_uses_same_section_transient_selection_for_the_quick_menu() {
tuicore::init();
let snapshot = BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: Vec::new(),
work_items: vec![work_item("FIN-1", "First"), work_item("FIN-2", "Second")],
top_level_backlog_keys: vec!["FIN-1".into(), "FIN-2".into()],
warnings: Vec::new(),
runway: None,
velocity: None,
};
let (sender, receiver) = mpsc::channel();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.dispatch_event(&route, &TuiEvent::Key(KeyEvent::from(Key::Down)), &mut ctx);
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent {
code: Key::Down,
modifiers: KeyModifiers::SHIFT,
}),
&mut ctx,
);
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent::from(Key::Char('.'))),
&mut ctx,
);
assert!(
matches!(receiver.try_recv(), Ok(super::components::BacklogSectionEvent::OpenQuickMenu { section_id, keys, source_order }) if section_id == "backlog" && keys == ["FIN-1", "FIN-2"] && source_order == ["FIN-1", "FIN-2"])
);
}
#[test]
fn ctrl_enter_opens_the_highlighted_ticket() {
tuicore::init();
let (sender, receiver) = mpsc::channel();
let mut snapshot = snapshot();
snapshot.work_items[0].kind = "Bug".into();
let mut tree = backlog_tree(&snapshot, sender, Default::default());
let route = EventRoute::new(TreePath::from_keys([ChildKey::new("data")]));
tree.dispatch_focus(
&data_focus_target(),
true,
&mut FocusCtx::new(AnimationSettings::default()),
);
let mut ctx = EventCtx::new(AnimationSettings::default());
tree.highlight("ticket:FIN-8");
tree.dispatch_event(
&route,
&TuiEvent::Key(KeyEvent {
code: Key::Enter,
modifiers: KeyModifiers::CONTROL,
}),
&mut ctx,
);
assert!(matches!(
receiver.try_recv(),
Ok(super::components::BacklogSectionEvent::OpenTicket { key }) if key == "FIN-8"
));
}
#[test]
fn backlog_rank_plan_uses_section_order_anchors() {
let plan = rank_plan(
vec!["FIN-2".into(), "FIN-3".into()],
&[
"FIN-1".into(),
"FIN-2".into(),
"FIN-3".into(),
"FIN-4".into(),
],
)
.unwrap()
.unwrap();
assert_eq!(plan.issues, ["FIN-2", "FIN-3"]);
assert_eq!(plan.rank_before_issue.as_deref(), Some("FIN-4"));
}
#[test]
fn successful_direct_rank_keeps_the_optimistic_order_without_reconciliation() {
tuicore::init();
let rollback = BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: Vec::new(),
work_items: vec![
work_item("FIN-1", "First"),
work_item("FIN-2", "Second"),
work_item("FIN-3", "Third"),
],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
runway: None,
velocity: None,
};
let final_order = vec!["FIN-3".into(), "FIN-1".into(), "FIN-2".into()];
let mut optimistic = rollback.clone();
optimistic.work_items.rotate_right(1);
let plan = rank_plan(vec!["FIN-3".into()], &final_order)
.unwrap()
.unwrap();
let mut page = BacklogPage::with_snapshot_for_test(optimistic);
let generation = page.begin_rank_result_for_test(
plan,
PendingRank {
rollback_snapshot: rollback,
section_id: "backlog".into(),
final_order,
unconfirmed_refreshes: 0,
},
);
assert!(page.apply_rank_result_for_test(generation, Ok(())));
assert!(!page.is_loading_for_test());
assert!(!page.is_ranking_for_test());
assert!(!page.move_is_locked_for_test());
assert!(!page.has_active_rank_plan_for_test());
assert!(!page.has_pending_rank_for_test());
assert!(!page.rank_refresh_retry_is_pending_for_test());
let area = Rect::new(0, 0, 80, 16);
page.layout(area, &mut LayoutCtx::new());
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| {
let mut render = RenderCtx::new();
page.render(frame, area, &mut render);
render.flush(frame);
})
.unwrap();
let text = rendered_lines(&terminal, area).concat();
assert!(
cell_position(&text, "FIN-3 Third").expect("optimistic moved ticket is visible")
< cell_position(&text, "FIN-1 First").expect("following ticket is visible")
);
page.tick(
std::time::Duration::from_secs(2),
AnimationSettings::default(),
);
assert!(!page.is_loading_for_test());
assert!(!page.rank_refresh_retry_is_pending_for_test());
assert!(!page.is_ranking_for_test());
}
#[test]
fn quick_menu_omits_its_current_section_from_transfer_destinations() {
let snapshot = snapshot();
assert_eq!(
transfer_destinations(Some(&snapshot), "backlog")
.iter()
.map(|destination| destination.section_id.as_str())
.collect::<Vec<_>>(),
["sprint-7"]
);
assert_eq!(
transfer_destinations(Some(&snapshot), "sprint-7")
.iter()
.map(|destination| destination.section_id.as_str())
.collect::<Vec<_>>(),
["backlog"]
);
}
#[test]
fn stale_rank_refresh_keeps_the_optimistic_order() {
let rollback = BacklogSnapshot {
board_name: "Finery".into(),
story_points_configured: false,
sprints: Vec::new(),
work_items: vec![
work_item("FIN-1", "First"),
work_item("FIN-2", "Second"),
work_item("FIN-3", "Third"),
],
top_level_backlog_keys: Vec::new(),
warnings: Vec::new(),
runway: None,
velocity: None,
};
let mut optimistic = rollback.clone();
optimistic.work_items.swap(0, 1);
let mut pending = Some(PendingRank {
rollback_snapshot: rollback.clone(),
section_id: "backlog".into(),
final_order: vec!["FIN-2".into(), "FIN-1".into(), "FIN-3".into()],
unconfirmed_refreshes: 0,
});
assert_eq!(
reconcile_pending_rank(&mut optimistic, &mut pending, rollback),
PendingRankReconciliation::Unconfirmed
);
assert_eq!(
optimistic
.work_items
.iter()
.map(|item| item.key.as_str())
.collect::<Vec<_>>(),
["FIN-2", "FIN-1", "FIN-3"]
);
}
#[test]
fn optimistic_transfer_moves_selected_items_between_sections() {
let mut snapshot = snapshot();
assert!(move_work_items_to_edge(
&mut snapshot,
"backlog",
"sprint-7",
&["FIN-8".into()],
false,
));
assert!(snapshot.work_items.is_empty());
assert_eq!(
snapshot.sprints[0]
.work_items
.iter()
.map(|item| item.key.as_str())
.collect::<Vec<_>>(),
["FIN-7", "FIN-8"]
);
}
#[test]
fn optimistic_transfer_recalculates_destination_sprint_capacity() {
let mut snapshot = snapshot();
snapshot.sprints[0].work_items[0].story_points = Some(3.0);
snapshot.work_items[0].story_points = Some(6.0);
let mut nine_point_ticket = work_item("FIN-9", "Nine-point ticket");
nine_point_ticket.story_points = Some(9.0);
snapshot.work_items.push(nine_point_ticket);
apply_capacity(
&mut snapshot,
9.1,
Some((3.0, false)),
RunwayCapacitySource::Fixed,
10,
);
assert!(move_work_items_to_edge(
&mut snapshot,
"backlog",
"sprint-7",
&["FIN-8".into(), "FIN-9".into()],
false,
));
recalculate_capacity(&mut snapshot, &BacklogRunwaySettings::default());
assert_eq!(
snapshot.sprints[0]
.capacity
.as_ref()
.unwrap()
.effective_points,
18.0
);
}
#[test]
fn optimistic_transfer_places_items_at_the_selected_destination_edge() {
let mut snapshot = snapshot();
snapshot.sprints[0]
.work_items
.push(work_item("FIN-9", "Existing sprint work"));
assert!(move_work_items_to_edge(
&mut snapshot,
"backlog",
"sprint-7",
&["FIN-8".into()],
true,
));
assert_eq!(
snapshot.sprints[0]
.work_items
.iter()
.map(|item| item.key.as_str())
.collect::<Vec<_>>(),
["FIN-8", "FIN-7", "FIN-9"]
);
}
#[test]
fn transfer_refresh_keeps_optimistic_snapshot_until_destination_confirms() {
let rollback_snapshot = snapshot();
let mut optimistic_snapshot = rollback_snapshot.clone();
assert!(move_work_items_to_edge(
&mut optimistic_snapshot,
"backlog",
"sprint-7",
&["FIN-8".into()],
false,
));
let mut pending = Some(PendingTransfer {
rollback_snapshot,
source_section_id: "backlog".into(),
destination_section_id: "sprint-7".into(),
destination_order: vec!["FIN-7".into(), "FIN-8".into()],
keys: vec!["FIN-8".into()],
source_highlight_key: None,
ambiguous: false,
unconfirmed_refreshes: 0,
});
let stale = pending.as_ref().unwrap().rollback_snapshot.clone();
assert_eq!(
reconcile_pending_transfer(&mut optimistic_snapshot, &mut pending, stale),
PendingTransferReconciliation::Unconfirmed
);
assert!(pending.is_some());
let confirmed = optimistic_snapshot.clone();
assert_eq!(
reconcile_pending_transfer(&mut optimistic_snapshot, &mut pending, confirmed),
PendingTransferReconciliation::ConfirmedDestination
);
assert!(pending.is_none());
}
#[test]
fn transfer_highlight_prefers_remaining_source_ticket_then_section() {
assert_eq!(
source_transfer_highlight_key(
&["FIN-1".into(), "FIN-2".into(), "FIN-3".into()],
&["FIN-2".into()]
),
Some("FIN-3".into())
);
assert_eq!(
source_transfer_highlight("backlog", Some("FIN-3")),
("backlog".into(), "ticket:FIN-3".into())
);
assert_eq!(
source_transfer_highlight("sprint-7", None),
("sprint-7".into(), "section:sprint-7".into())
);
}
#[test]
fn unconfirmed_transfer_refreshes_exhaust() {
let refreshed = snapshot();
let mut optimistic = refreshed.clone();
assert!(move_work_items_to_edge(
&mut optimistic,
"backlog",
"sprint-7",
&["FIN-8".into()],
false,
));
let mut pending = Some(PendingTransfer {
rollback_snapshot: refreshed.clone(),
source_section_id: "backlog".into(),
destination_section_id: "sprint-7".into(),
destination_order: vec!["FIN-7".into(), "FIN-8".into()],
keys: vec!["FIN-8".into()],
source_highlight_key: None,
ambiguous: false,
unconfirmed_refreshes: 0,
});
for _ in 1..MAX_UNCONFIRMED_TRANSFER_REFRESHES {
assert_eq!(
reconcile_pending_transfer(&mut optimistic, &mut pending, refreshed.clone()),
PendingTransferReconciliation::Unconfirmed
);
}
assert_eq!(
reconcile_pending_transfer(&mut optimistic, &mut pending, refreshed),
PendingTransferReconciliation::Exhausted
);
}
#[test]
fn polling_runs_only_while_work_is_pending() {
assert!(should_poll(true, false, false));
assert!(should_poll(false, true, false));
assert!(!should_poll(false, false, false));
}
#[test]
fn confirmed_transfer_highlight_remains_available() {
let transfer = PendingTransfer {
rollback_snapshot: snapshot(),
source_section_id: "backlog".into(),
destination_section_id: "sprint-7".into(),
destination_order: vec!["FIN-7".into(), "FIN-8".into()],
keys: vec!["FIN-8".into()],
source_highlight_key: None,
ambiguous: false,
unconfirmed_refreshes: 0,
};
assert_eq!(
transfer_reconciliation_highlight(
PendingTransferReconciliation::ConfirmedDestination,
&transfer
),
Some(("backlog".into(), "section:backlog".into()))
);
}