use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::config::PlanningCycle;
use crate::planning::Milestone;
use super::modal::modal_rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlanningSegment {
#[default]
Cycles,
Milestones,
}
impl PlanningSegment {
pub fn label(self) -> &'static str {
match self {
Self::Cycles => "Cycles",
Self::Milestones => "Milestones",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanningKind {
Cycle,
Milestone,
}
impl PlanningKind {
pub fn noun(self) -> &'static str {
match self {
Self::Cycle => "cycle",
Self::Milestone => "milestone",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanningRow {
pub name: String,
pub description: String,
pub date_a: String,
pub date_b: Option<String>,
pub issues: usize,
}
pub fn issues_word(n: usize) -> &'static str {
if n == 1 { "issue" } else { "issues" }
}
pub fn row_line(row: &PlanningRow) -> String {
let mut text = format!("{} | {} | {}", row.name, row.description, row.date_a);
if let Some(date_b) = &row.date_b {
text.push_str(" | ");
text.push_str(date_b);
}
text.push_str(&format!(" | {} {}", row.issues, issues_word(row.issues)));
text
}
pub fn rows_lines(rows: &[PlanningRow], selected: usize, view_h: usize) -> Vec<Line<'static>> {
if rows.is_empty() {
return Vec::new();
}
let sel = usize::min(selected, rows.len() - 1);
let top = sel.saturating_sub(view_h.saturating_sub(1));
let end = (top + view_h.max(1)).min(rows.len());
let mut lines = Vec::new();
for (i, row) in rows
.iter()
.enumerate()
.skip(top)
.take(end.saturating_sub(top))
{
let cursor = if i == sel { "> " } else { " " };
let style = if i == sel {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(cursor, style),
Span::styled(row_line(row), style),
]));
}
lines
}
pub fn prompt_line(prompt: &PlanningPrompt) -> Line<'static> {
match prompt {
PlanningPrompt::ConfirmDelete { name, referencing } => Line::from(Span::styled(
format!(
"Delete '{name}'? {referencing} {} reference it. [y/N]",
issues_word(*referencing)
),
Style::default().fg(Color::Cyan),
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanningPrompt {
ConfirmDelete { name: String, referencing: usize },
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PlanningModal {
pub segment: PlanningSegment,
pub selected: usize,
pub prompt: Option<PlanningPrompt>,
}
impl PlanningModal {
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, len: usize) {
if len == 0 {
self.selected = 0;
return;
}
let next = self.selected as isize + delta;
self.selected = next.clamp(0, len as isize - 1) as usize;
}
pub fn switch_segment(&mut self) {
self.selected = 0;
self.segment = match self.segment {
PlanningSegment::Cycles => PlanningSegment::Milestones,
PlanningSegment::Milestones => PlanningSegment::Cycles,
};
}
pub fn move_selected_to(&mut self, row: usize, len: usize) {
if len == 0 {
self.selected = 0;
return;
}
self.selected = row.min(len - 1);
}
pub fn render(
&self,
f: &mut Frame,
area: Rect,
cycles: &[PlanningRow],
milestones: &[PlanningRow],
) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
cycles,
milestones,
&bindings,
crate::keybindings::InputContext::PlanningManagement,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
cycles: &[PlanningRow],
milestones: &[PlanningRow],
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let (rows, label) = match self.segment {
PlanningSegment::Cycles => (cycles, "Cycles"),
PlanningSegment::Milestones => (milestones, "Milestones"),
};
let rect = modal_rect(area);
let inner = rect.height.saturating_sub(2) as usize;
let view_h = if self.prompt.is_some() {
inner.saturating_sub(4)
} else {
inner.saturating_sub(3)
};
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
format!(" {label} — Tab to switch segment "),
Style::default().fg(Color::DarkGray),
)));
if rows.is_empty() {
if self.prompt.is_none() {
lines.push(Line::from(format!(
"No {label} yet — press n to create one."
)));
}
} else {
lines.extend(rows_lines(rows, self.selected, view_h));
}
lines.push(Line::from(""));
match &self.prompt {
Some(prompt) => lines.push(prompt_line(prompt)),
None => {
lines.push(Line::from(Span::styled(
format!(
"{}: move · {}: switch · {}: new · {}: edit · {}: delete · {}: close",
bindings.label(context, crate::keybindings::TuiAction::MoveDown),
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::NewPlanningItem),
bindings.label(context, crate::keybindings::TuiAction::EditPlanningItem),
bindings.label(context, crate::keybindings::TuiAction::DeletePlanningItem),
bindings.label(context, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
)));
}
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Planned items ")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[derive(Debug, Clone)]
pub struct PlanningForm {
pub kind: PlanningKind,
pub rows: Vec<(String, String)>,
pub cursor: usize,
pub editing: Option<String>,
}
impl PlanningForm {
pub fn new(kind: PlanningKind) -> Self {
let labels: Vec<&str> = match kind {
PlanningKind::Cycle => vec!["Name", "Description", "Start date", "Due date"],
PlanningKind::Milestone => vec!["Name", "Description", "Target date"],
};
Self {
kind,
rows: labels
.into_iter()
.map(|l| (l.to_string(), String::new()))
.collect(),
cursor: 0,
editing: None,
}
}
pub fn from_cycle(name: &str, cycle: &PlanningCycle) -> Self {
Self {
kind: PlanningKind::Cycle,
rows: vec![
("Name".into(), name.to_string()),
(
"Description".into(),
cycle.description.clone().unwrap_or_default(),
),
(
"Start date".into(),
cycle.start_date.clone().unwrap_or_default(),
),
(
"Due date".into(),
cycle.due_date.clone().unwrap_or_default(),
),
],
cursor: 0,
editing: Some(name.to_string()),
}
}
pub fn from_milestone(m: &Milestone) -> Self {
Self {
kind: PlanningKind::Milestone,
rows: vec![
("Name".into(), m.name.clone()),
("Description".into(), m.description.clone()),
("Target date".into(), m.target_date.clone()),
],
cursor: 0,
editing: Some(m.name.clone()),
}
}
pub fn is_new(&self) -> bool {
self.editing.is_none()
}
pub fn name(&self) -> &str {
self.field_value(0)
}
pub fn description(&self) -> &str {
self.field_value(1)
}
pub fn date_a(&self) -> &str {
self.field_value(2)
}
pub fn date_b(&self) -> &str {
match self.kind {
PlanningKind::Cycle => self.field_value(3),
PlanningKind::Milestone => "",
}
}
fn field_value(&self, index: usize) -> &str {
self.rows
.get(index)
.map(|(_, value)| value.as_str())
.unwrap_or("")
}
pub fn focused_label(&self) -> &str {
&self.rows[self.cursor].0
}
pub fn focused_value(&self) -> &str {
self.field_value(self.cursor)
}
pub fn focused_is_date(&self) -> bool {
self.cursor >= 2
}
pub fn focused_date_is_complete(&self) -> bool {
self.focused_is_date() && crate::ui::date_picker::is_complete_date(self.focused_value())
}
pub fn set_focused_date(&mut self, value: &str) {
if self.focused_is_date() {
if let Some((_, current)) = self.rows.get_mut(self.cursor) {
*current = value.to_string();
}
}
}
pub fn focus_next(&mut self) {
self.cursor = (self.cursor + 1) % self.rows.len();
}
pub fn focus_prev(&mut self) {
self.cursor = (self.cursor + self.rows.len() - 1) % self.rows.len();
}
pub fn insert_char(&mut self, c: char) {
if self.focused_is_date() {
if let Some(next) = crate::ui::date_picker::type_date(self.focused_value(), c) {
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
*value = next;
}
}
return;
}
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
value.push(c);
}
}
pub fn delete_char(&mut self) {
if self.focused_is_date() {
let next = crate::ui::date_picker::backspace_date(self.focused_value());
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
*value = next;
}
return;
}
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
value.pop();
}
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
&bindings,
crate::keybindings::InputContext::PlanningForm,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let rect = area;
let title = if self.is_new() {
format!(" New {} ", self.kind.noun())
} else {
format!(" Edit {} ", self.kind.noun())
};
let mut lines: Vec<Line> = Vec::new();
for (i, (label, value)) in self.rows.iter().enumerate() {
let cursor = if i == self.cursor { "> " } else { " " };
let style = if i == self.cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let shown = if value.is_empty() {
"(empty)"
} else {
value.as_str()
};
lines.push(Line::from(vec![
Span::styled(cursor, style),
Span::styled(format!("{label}: "), style),
Span::styled(shown, style),
]));
}
lines.push(Line::from(""));
let hint = format!(
"{}: save/pick date · {}: cancel · {}: next field · {}: date{}",
bindings.label(context, crate::keybindings::TuiAction::Submit),
bindings.label(context, crate::keybindings::TuiAction::Cancel),
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
if self.is_new() {
""
} else {
" · renaming rewrites issues"
},
);
lines.push(Line::from(Span::styled(
hint,
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EpicViewState {
pub selected: usize,
pub member: usize,
}
impl EpicViewState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, len: usize) {
self.selected = clamp_cursor(self.selected, delta, len);
self.member = 0;
}
pub fn move_member(&mut self, delta: isize, len: usize) {
self.member = clamp_cursor(self.member, delta, len);
}
#[must_use]
pub fn target_member(&self, entries: &[crate::commands::view::EpicViewEntry]) -> Option<u32> {
let entry = entries.get(self.selected)?;
entry.members.get(self.member).map(|member| member.number)
}
pub fn render(
&self,
f: &mut Frame,
area: Rect,
entries: &[crate::commands::view::EpicViewEntry],
) {
let rect = modal_rect(area);
let view_h = (rect.height.saturating_sub(2) as usize).saturating_sub(3);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
format!(" Epics — {} in declaration order ", entries.len()),
Style::default().fg(Color::DarkGray),
)));
if entries.is_empty() {
lines.push(Line::from(
"No epics yet — create one with `tix epic create`.",
));
} else {
for line in epic_lines(entries, self, view_h).into_iter().take(view_h) {
lines.push(line);
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Enter: open the member issue directly · move · ←/→: member · Esc: close",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(" Epics ")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
fn clamp_cursor(current: usize, delta: isize, len: usize) -> usize {
if len == 0 {
return 0;
}
let next = current as isize + delta;
next.clamp(0, len as isize - 1) as usize
}
#[must_use]
pub fn epic_lines(
entries: &[crate::commands::view::EpicViewEntry],
state: &EpicViewState,
view_h: usize,
) -> Vec<Line<'static>> {
if entries.is_empty() {
return Vec::new();
}
let selected = state.selected.min(entries.len() - 1);
let height = |entry: &crate::commands::view::EpicViewEntry| {
1 + entry.epic.detail.len() + 1 + entry.members.len().max(1)
};
let mut start = selected;
let mut budget = view_h.max(1).saturating_sub(height(&entries[selected]));
while start > 0 {
let above = height(&entries[start - 1]);
if above > budget {
break;
}
budget -= above;
start -= 1;
}
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, entry) in entries.iter().enumerate().skip(start) {
if lines.len() >= view_h.max(1) {
break;
}
let is_selected = index == selected;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(if is_selected { "> " } else { " " }, style),
Span::styled(entry.epic.heading.clone(), style),
]));
for detail in &entry.epic.detail {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(detail.clone(), Style::default().fg(Color::Gray)),
]));
}
if entry.members.is_empty() {
lines.push(Line::from(Span::styled(
" members: (none)",
Style::default().fg(Color::DarkGray),
)));
continue;
}
lines.push(Line::from(Span::styled(
" members:",
Style::default().fg(Color::DarkGray),
)));
let member_cursor = if is_selected {
state.member.min(entry.members.len() - 1)
} else {
usize::MAX
};
for (member_index, member) in entry.members.iter().enumerate() {
let marker = if member_index == member_cursor {
" > "
} else {
" "
};
lines.push(Line::from(Span::styled(
format!("{marker}{}", member.display),
Style::default().fg(Color::Cyan),
)));
}
}
lines
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn row(
name: &str,
description: &str,
date_a: &str,
date_b: Option<&str>,
issues: usize,
) -> PlanningRow {
PlanningRow {
name: name.into(),
description: description.into(),
date_a: date_a.into(),
date_b: date_b.map(str::to_string),
issues,
}
}
fn cycle_rows() -> Vec<PlanningRow> {
vec![
row("Sprint 3", "kickoff", "2026-10-01", Some("2026-10-14"), 1),
row("Sprint 4", "", "", Some(""), 0),
]
}
fn milestone_rows() -> Vec<PlanningRow> {
vec![
row("GA", "ship it", "2026-12-31", None, 3),
row("Beta", "", "2026-11-30", None, 2),
]
}
fn render_modal(modal: &PlanningModal) -> Vec<String> {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let cycles = cycle_rows();
let milestones = milestone_rows();
terminal
.draw(|f| modal.render(f, f.area(), &cycles, &milestones))
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
rows
}
fn render_form(form: &PlanningForm) -> Vec<String> {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| form.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area().height)
.map(|y| {
(0..buffer.area().width)
.map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect()
})
.collect()
}
#[test]
fn form_editor_fills_the_whole_content_area() {
let form = PlanningForm::new(PlanningKind::Cycle);
let rows = render_form(&form);
assert!(
rows[0].contains("New cycle"),
"the title is on the content's first row: {:?}",
rows[0]
);
assert!(
rows[0].starts_with('┌') && rows[0].ends_with('┐'),
"the border spans the full width: {:?}",
rows[0]
);
assert!(
rows[23].starts_with('└') && rows[23].ends_with('┘'),
"the border reaches the last row: {:?}",
rows[23]
);
}
#[test]
fn row_line_for_cycle_includes_all_fields() {
assert_eq!(
row_line(&row(
"Sprint 3",
"kickoff",
"2026-10-01",
Some("2026-10-14"),
1
)),
"Sprint 3 | kickoff | 2026-10-01 | 2026-10-14 | 1 issue"
);
assert_eq!(
row_line(&row(
"Sprint 5",
"next",
"2026-11-01",
Some("2026-11-15"),
2
)),
"Sprint 5 | next | 2026-11-01 | 2026-11-15 | 2 issues"
);
}
#[test]
fn row_line_for_milestone_omits_due_date() {
assert_eq!(
row_line(&row("GA", "ship it", "2026-12-31", None, 3)),
"GA | ship it | 2026-12-31 | 3 issues"
);
}
#[test]
fn row_line_renders_missing_fields_blank() {
assert_eq!(
row_line(&row("Sprint 4", "", "", Some(""), 0)),
"Sprint 4 | | | | 0 issues"
);
}
#[test]
fn renders_cycle_rows_with_cursor_and_footer() {
let modal = PlanningModal {
selected: 1,
..PlanningModal::default()
};
let rows = render_modal(&modal);
let row_of =
|needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
let sprint3 = row_of("Sprint 3 | kickoff | 2026-10-01 | 2026-10-14 | 1 issue")
.expect("first cycle renders with fields and count");
let sprint4 = row_of("Sprint 4 |").expect("second cycle renders");
assert!(sprint3 < sprint4, "cycles render in declaration order");
assert!(
rows[sprint4].contains('>'),
"selected cycle row shows the cursor"
);
assert!(
!rows[sprint3].contains('>'),
"unselected cycle row has no cursor"
);
assert!(
rows.iter().any(|r| r.contains("Down / j: move")),
"browse hint renders on the cycles segment"
);
}
#[test]
fn renders_milestone_segment_after_switch() {
let mut modal = PlanningModal::new();
modal.switch_segment();
assert_eq!(modal.segment, PlanningSegment::Milestones);
let rows = render_modal(&modal);
let row_of =
|needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
let ga = row_of("GA | ship it | 2026-12-31 | 3 issues")
.expect("milestone rows render on the milestones segment");
let footer =
row_of("Down / j: move").expect("footer hint renders beneath the milestones list");
assert!(
ga < footer,
"milestone rows render above the footer hint (ga at {ga}, footer at {footer})"
);
}
#[test]
fn renders_empty_segment_notice() {
let modal = PlanningModal::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| modal.render(f, f.area(), &[], &[]))
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
assert!(
rows.iter()
.any(|r| r.contains("No Cycles yet — press n to create one.")),
"empty segment shows a create hint: {:?}",
rows
);
}
#[test]
fn renders_confirm_delete_prompt() {
let modal = PlanningModal {
prompt: Some(PlanningPrompt::ConfirmDelete {
name: "Sprint 3".into(),
referencing: 2,
}),
..PlanningModal::default()
};
let rows = render_modal(&modal);
assert!(
rows.iter()
.any(|r| r.contains("Delete 'Sprint 3'? 2 issues reference it. [y/N]")),
"confirm prompt shows the referencing count: {:?}",
rows
);
}
#[test]
fn scroll_window_keeps_selected_in_view_on_small_terminal() {
let many: Vec<PlanningRow> = (0..40)
.map(|i| row(&format!("Item {i}"), "", "2026-01-01", Some(""), 0))
.collect();
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
let modal = PlanningModal {
selected: 39,
..PlanningModal::default()
};
terminal
.draw(|f| modal.render(f, f.area(), &many, &[]))
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
assert!(
rows.iter().any(|r| r.contains("Item 39")),
"the selected (deep) row stays visible via the scroll window"
);
assert!(
!rows.iter().any(|r| r.contains("Item 0")),
"the first row scrolls out of the window"
);
}
#[test]
fn move_selected_clamps_and_scrolls() {
let mut modal = PlanningModal::new();
modal.move_selected(-3, 2);
assert_eq!(modal.selected, 0);
modal.move_selected(5, 2);
assert_eq!(modal.selected, 1);
modal.move_selected(1, 0);
assert_eq!(modal.selected, 0);
}
#[test]
fn smoke_tiny_20x5_does_not_panic() {
let modal = PlanningModal::new();
let backend = TestBackend::new(20, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| modal.render(f, f.area(), &cycle_rows(), &milestone_rows()))
.unwrap();
}
#[test]
fn form_labels_match_kind() {
let cycle = PlanningForm::new(PlanningKind::Cycle);
assert_eq!(cycle.rows.len(), 4);
let labels: Vec<&str> = cycle.rows.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(labels, ["Name", "Description", "Start date", "Due date"]);
let milestone = PlanningForm::new(PlanningKind::Milestone);
assert_eq!(milestone.rows.len(), 3);
let labels: Vec<&str> = milestone.rows.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(labels, ["Name", "Description", "Target date"]);
assert!(milestone.is_new());
assert!(cycle.is_new());
}
#[test]
fn form_prefills_from_cycle_and_milestone() {
let cycle = PlanningForm::from_cycle(
"Sprint 3",
&PlanningCycle {
description: Some("kickoff".into()),
start_date: Some("2026-10-01".into()),
due_date: Some("2026-10-14".into()),
},
);
assert_eq!(cycle.name(), "Sprint 3");
assert_eq!(cycle.description(), "kickoff");
assert_eq!(cycle.date_a(), "2026-10-01");
assert_eq!(cycle.date_b(), "2026-10-14");
assert!(!cycle.is_new());
assert_eq!(cycle.editing.as_deref(), Some("Sprint 3"));
let milestone = PlanningForm::from_milestone(&Milestone {
name: "GA".into(),
target_date: "2026-12-31".into(),
description: "ship it".into(),
created: "2026-01-01".into(),
updated: "2026-01-02".into(),
file_name: "2026-01-01-ga.md".into(),
color: None,
});
assert_eq!(milestone.name(), "GA");
assert_eq!(milestone.description(), "ship it");
assert_eq!(milestone.date_a(), "2026-12-31");
assert_eq!(milestone.date_b(), "", "milestones have no due-date field");
assert_eq!(milestone.editing.as_deref(), Some("GA"));
}
#[test]
fn form_cursor_and_typing() {
let mut form = PlanningForm::new(PlanningKind::Cycle);
assert_eq!(form.focused_label(), "Name");
form.insert_char('S');
form.insert_char('3');
assert_eq!(form.name(), "S3");
form.focus_next();
assert_eq!(form.focused_label(), "Description");
form.insert_char('k');
assert_eq!(form.description(), "k");
form.delete_char();
assert_eq!(form.description(), "");
form.focus_prev();
assert_eq!(form.focused_label(), "Name");
form.focus_next();
form.focus_next();
form.focus_next();
form.focus_next();
assert_eq!(form.focused_label(), "Name", "Tab wraps to the first field");
form.focus_prev();
assert_eq!(
form.focused_label(),
"Due date",
"Shift+Tab wraps to the last field"
);
}
#[test]
fn form_render_smokes_at_80x24_and_tiny() {
for (w, h) in [(80, 24), (20, 5)] {
let form = PlanningForm::from_milestone(&Milestone {
name: "GA".into(),
target_date: "2026-12-31".into(),
description: "ship it".into(),
created: "2026-01-01".into(),
updated: "2026-01-02".into(),
file_name: "2026-01-01-ga.md".into(),
color: None,
});
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| form.render(f, f.area())).unwrap();
}
}
#[test]
fn edit_form_renders_edit_title() {
let form = PlanningForm::from_cycle(
"Sprint 3",
&PlanningCycle {
description: None,
start_date: None,
due_date: None,
},
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| form.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
assert!(
rows.iter().any(|r| r.contains("Edit cycle")),
"edit form titles itself: {rows:?}"
);
}
fn epic_entry(
name: &str,
id: &str,
progress: &str,
members: &[u32],
) -> crate::commands::view::EpicViewEntry {
crate::commands::view::EpicViewEntry {
epic: crate::commands::view::EpicBlockEntry {
heading: format!("{name} ({id})"),
detail: vec![
format!("progress: {progress}"),
"weighted progress: none (no weighted children)".to_string(),
"weight: unset".to_string(),
"children: 0".to_string(),
"parent: (none)".to_string(),
"depth: 0".to_string(),
"blocked: 0".to_string(),
],
},
members: members
.iter()
.map(|number| crate::commands::view::EpicMemberRef {
number: *number,
display: format!("#{number} Issue {number}"),
})
.collect(),
}
}
fn render_epic(
entries: &[crate::commands::view::EpicViewEntry],
state: &EpicViewState,
) -> Vec<String> {
let backend = TestBackend::new(90, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| state.render(f, f.area(), entries))
.unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area().height)
.map(|y| {
(0..buffer.area().width)
.map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect()
})
.collect()
}
#[test]
fn t020_the_epic_view_lists_epics_in_declaration_order_with_context_and_members() {
let entries = vec![
epic_entry("Alpha", "e-aaa111", "1/2 done", &[5, 6]),
epic_entry("Beta", "e-bbb222", "0/1 done", &[7]),
];
let rows = render_epic(&entries, &EpicViewState::new());
let text = rows.join("\n");
assert!(text.contains("Epics — 2 in declaration order"), "{text}");
assert!(text.contains("Alpha (e-aaa111)"), "{text}");
assert!(text.contains("progress: 1/2 done"), "{text}");
assert!(text.contains("members:"), "{text}");
assert!(
text.contains("#5 Issue 5") && text.contains("#6 Issue 6"),
"{text}"
);
let alpha = rows
.iter()
.position(|row| row.contains("Alpha (e-aaa111)"))
.unwrap();
let beta = rows
.iter()
.position(|row| row.contains("Beta (e-bbb222)"))
.unwrap();
assert!(alpha < beta, "the service's declaration order is kept");
assert!(
text.contains("Enter: open the member issue directly"),
"the direct issue navigation affordance is explicit (FR-004): {text}"
);
}
#[test]
fn t020_the_cursor_targets_the_selected_member_issue_directly() {
let entries = vec![
epic_entry("Alpha", "e-aaa111", "1/2 done", &[5, 6]),
epic_entry("Beta", "e-bbb222", "0/1 done", &[7]),
];
let mut state = EpicViewState::new();
assert_eq!(state.target_member(&entries), Some(5));
state.move_member(1, 2);
assert_eq!(
state.target_member(&entries),
Some(6),
"the member cursor selects the member `Enter` opens"
);
state.move_selected(1, 2);
assert_eq!(state.target_member(&entries), Some(7));
assert_eq!(EpicViewState::new().target_member(&[]), None);
assert_eq!(
EpicViewState::new().target_member(&[epic_entry("Alpha", "e-aaa111", "0/0 done", &[])]),
None
);
}
#[test]
fn t020_a_task_labelled_member_is_an_ordinary_member() {
let entries = vec![epic_entry("Alpha", "e-aaa111", "0/1 done", &[9])];
let text = epic_lines(&entries, &EpicViewState::new(), 40)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("#9 Issue 9"), "{text}");
assert!(!text.contains("task"), "no task section exists: {text}");
}
#[test]
fn t020_an_empty_registry_draws_an_empty_state_not_a_blank() {
let text = render_epic(&[], &EpicViewState::new()).join("\n");
assert!(text.contains("No epics yet"), "{text}");
}
fn row_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn t020_the_selected_epic_stays_in_the_frame_when_the_window_is_small() {
let entries: Vec<crate::commands::view::EpicViewEntry> = (0..3)
.map(|index| {
epic_entry(
&format!("E{index}"),
&format!("e-aaa{index}{index}{index}"),
"0/1 done",
&[index + 1],
)
})
.collect();
let mut state = EpicViewState::new();
state.move_selected(2, 3);
let lines = epic_lines(&entries, &state, 10);
let text = lines.iter().map(row_text).collect::<Vec<_>>().join("\n");
assert!(
text.contains("E2 (e-aaa222)"),
"the selected epic must be inside the frame: {text}"
);
assert!(
lines
.iter()
.any(|line| row_text(line).contains("> E2 (e-aaa222)")),
"and it must carry the cursor, because `Enter` acts on it: {text}"
);
let mut state = EpicViewState::new();
state.move_selected(1, 2);
let text = epic_lines(&entries[..2], &state, 10)
.iter()
.map(row_text)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("> E1 (e-aaa111)"), "{text}");
}
}