use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use crate::app::{Action, App, Mode};
use crate::config::{BoardConfiguration, Config};
use crate::ui::action_menu::IssueAction;
use crate::ui::board::BoardState;
use crate::ui::form::FormFocus;
use crate::ui::views::ViewsPrompt;
use crate::views::divergence::ViewSurface;
use crate::views::service::ViewService;
use crate::watcher::FsDelta;
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use tempfile::TempDir;
fn make_fixture() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.move_on_change = false;
config.save(&config_path).unwrap();
(tmp, dir)
}
fn load_config(tmp: &TempDir) -> Config {
let config_path = tmp.path().join("tix.toml");
Config::load_with_warning(&config_path, |_| {}).unwrap()
}
fn write_issue(dir: &std::path::Path, name: &str, number: u32, title: &str, status: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
fn write_issue_with_cycle(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
cycle: &str,
) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncycles: [\"{cycle}\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn press_char(c: char) -> KeyEvent {
press(KeyCode::Char(c))
}
fn open_detail(app: &mut App) {
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::ActionMenu { .. }),
"Enter opens the action menu, got {:?}",
app.mode
);
for _ in 0..64 {
let selected = app
.action_menu
.as_ref()
.and_then(|menu| menu.selected_item())
.map(|item| item.action == IssueAction::OpenDetail)
.unwrap_or(false);
if selected {
break;
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Detail),
"Open detail must open the pane, got {:?}",
app.mode
);
}
fn collect_md(dir: &std::path::Path) -> Vec<PathBuf> {
let mut md = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(next) = stack.pop() {
for entry in fs::read_dir(&next).unwrap() {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().to_string();
if path.is_dir() {
if name.starts_with('.')
|| name.starts_with(".tix-tmp-")
|| name.ends_with("-assets")
{
continue;
}
stack.push(path);
} else if path.extension().is_some_and(|e| e == "md") {
md.push(path);
}
}
}
md.sort();
md
}
fn render_rows(app: &App) -> Vec<String> {
render_rows_in(app, 80, 24)
}
fn render_rows_in(app: &App, width: u16, height: u16) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let buf = terminal.backend().buffer();
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
fn rendered_assignee(backend: &ratatui::backend::TestBackend) -> String {
const LABEL: &str = "assignee:";
let buf = backend.buffer();
for y in 0..buf.area.height {
let mut line = String::new();
for x in 0..buf.area.width {
line.push_str(buf[(x, y)].symbol());
}
if let Some(idx) = line.find(LABEL) {
let after = &line[idx + LABEL.len()..];
return after.split("│").next().unwrap_or(after).trim().to_string();
}
}
String::new()
}
#[test]
fn app_opens_and_builds_board() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.board.total_cols(), 4); }
#[test]
fn app_handles_quit_keypress() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let action = app.handle_key(press_char('q')).unwrap();
assert!(matches!(action, Action::Quit));
assert!(app.quit);
}
#[test]
fn fresh_project_equals_v2_default_config() {
let fresh = TempDir::new().unwrap();
let dir = fresh.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let default_config = Config::default();
let explicit = TempDir::new().unwrap();
let dir2 = explicit.path().join("issues");
fs::create_dir_all(&dir2).unwrap();
std::fs::write(
explicit.path().join("tix.toml"),
"issues_dir = \"issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"in progress\", \"in review\", \"done\"]\ndone_column = \"done\"\n",
)
.unwrap();
let explicit_config =
crate::config::Config::load_with_warning(&explicit.path().join("tix.toml"), |_| {})
.unwrap();
assert_eq!(
explicit_config.default_config(),
default_config.default_config()
);
for config in [&default_config, &explicit_config] {
let store = crate::storage::Store::open(config).unwrap();
let board = BoardState::build(&store, store.config.default_config());
assert_eq!(board.columns.len(), 4);
assert_eq!(board.total_cols(), 4);
assert!(!board.show_mismatch);
}
}
#[test]
fn board_state_distributes_to_configured_and_mismatch_columns() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "B", "done");
write_issue(&dir, "2026-01-01-c.md", 3, "C", "renamed-col");
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.columns[0].name, "todo");
assert_eq!(board.columns[0].cards.len(), 1);
assert_eq!(board.columns[1].cards.len(), 0);
assert_eq!(board.columns[2].cards.len(), 0);
assert_eq!(board.columns[3].cards.len(), 1); assert_eq!(board.mismatch.name, "Mismatched");
assert_eq!(board.mismatch.cards.len(), 1); assert_eq!(board.mismatch.cards[0].number, 3);
assert!(!board.mismatch.cards.is_empty());
}
#[test]
fn move_selected_to_triggers_commit_prompt_into_done() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(2); let _ = app.handle_key(press_char('>')).unwrap(); assert!(
matches!(&app.mode, Mode::MoveIntoDone { buffer } if buffer.is_empty()),
"expected MoveIntoDone(done), got {:?}",
app.mode
);
}
#[test]
fn board_state_mismatch_column_gated_on_show_mismatch() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-c.md", 3, "C", "renamed-col");
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.columns.len(), config.default_config().columns.len());
assert_eq!(board.total_cols(), board.columns.len());
assert_eq!(board.column_name(board.columns.len()), "Unknown");
assert_eq!(
board.mismatch.cards.len(),
1,
"orphaned cards stay collected"
);
let mut visible = config;
visible
.kanban
.configurations
.get_mut("default")
.unwrap()
.show_mismatch = true;
let store = crate::storage::Store::open(&visible).unwrap();
let board = BoardState::build(&store, visible.default_config());
assert_eq!(board.total_cols(), board.columns.len() + 1);
assert_eq!(board.column_name(board.columns.len()), "Mismatched");
assert_eq!(board.column_cards(board.columns.len()).len(), 1);
}
#[test]
fn render_smoke_full_columns_small_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "B", "in progress");
write_issue(&dir, "2026-01-01-c.md", 3, "C", "done");
write_issue(&dir, "2026-01-01-d.md", 4, "D", "mismatched-status");
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let backend = TestBackend::new(20, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
crate::ui::board::render(
&board,
f,
f.area(),
&None,
None,
crate::ui::board::BoardDisplay::default(),
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
}
#[test]
fn render_smoke_mismatched_board_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tmp, dir) = make_fixture();
for i in 1..6 {
write_issue(
&dir,
&format!("2026-01-0{i}-issue-{i}.md"),
i,
&format!("Issue {i}"),
"orphaned",
);
}
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.mismatch.cards.len(), 5);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
crate::ui::board::render(
&board,
f,
f.area(),
&Some("5 orphaned".into()),
None,
crate::ui::board::BoardDisplay::default(),
&crate::ui::color::Theme::default(),
None,
None,
None,
);
})
.unwrap();
}
#[test]
fn form_submit_persists_issue_file() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
assert!(matches!(app.mode, Mode::Form));
for c in "Fully formed".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.starts_with("Created ")),
"got {:?}",
app.status_msg
);
let created = collect_md(&tmp.path().join("issues"));
assert_eq!(
created.len(),
1,
"form submit must persist exactly one file"
);
let name = created[0]
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(name.ends_with("-fully-formed.md"), "got {name}");
}
#[test]
fn detail_view_opens_and_edit_requests_action() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail(&mut app);
let action = app.handle_key(press_char('e')).unwrap();
assert!(
matches!(&action, Action::OpenEditor(name) if name == "2026-01-01-a.md"),
"got {action:?}"
);
}
#[test]
#[ignore = "requires a controlling terminal; run under `script -qec ...`"]
fn negative_mode_no_tty_exits_zero_without_panicking() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs").join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: PathBuf::from("docs/issues"),
..Config::default()
};
config.save(&config_path).unwrap();
let mut child = std::process::Command::new(tix_bin())
.current_dir(tmp.path())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
panic!("binary did not exit within 5s under closed stdin");
}
std::thread::sleep(Duration::from_millis(10));
};
let stderr = child.stderr.as_mut().and_then(|s| {
use std::io::Read;
let mut buf = String::new();
s.read_to_string(&mut buf).ok().map(|_| buf)
});
assert!(
status.code().is_some(),
"binary must terminate normally (no signal), got {status:?}"
);
let stderr = stderr.unwrap_or_default();
assert!(
!stderr.contains("panicked"),
"raw-mode path must not panic:\n{stderr}"
);
}
fn tix_bin() -> PathBuf {
if let Ok(path) = std::env::var("CARGO_BIN_EXE_tix") {
return PathBuf::from(path).canonicalize().unwrap();
}
let mut dir = std::env::current_exe().unwrap();
dir.pop();
if dir.ends_with("deps") {
dir.pop();
}
dir.join(if cfg!(windows) { "tix.exe" } else { "tix" })
}
#[test]
fn build_board_public_seam() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.column_cards(0).len(), 1);
assert_eq!(board.column_cards(3).len(), 0);
}
#[test]
fn key_release_events_are_ignored() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let key = KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
let _ = app.handle_key(key).unwrap();
assert!(!app.quit, "release events must not trigger actions");
}
#[test]
fn form_prefills_git_identity_default_and_derives_segment() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let expected_default = crate::assignee::git_identity().map(|id| id.to_string());
let _ = app.handle_key(press_char('n')).unwrap();
assert!(matches!(app.mode, Mode::Form));
let prefilled = app.form.as_ref().unwrap().assignee.clone();
assert_eq!(
prefilled,
expected_default.clone().unwrap_or_default(),
"form must pre-fill the git identity (empty without git)"
);
for c in "Prefilled task".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app.store.list().unwrap();
assert_eq!(
created.len(),
1,
"form submit must persist exactly one issue"
);
let issue = &created[0];
match &expected_default {
Some(identity) => {
assert_eq!(issue.assignee.as_deref(), Some(identity.as_str()));
}
None => assert_eq!(issue.assignee.as_deref(), None),
}
let segment = crate::assignee::derive_segment(issue.assignee.as_deref());
assert!(
issue.filename.starts_with(&format!("{segment}/")),
"created file {} must live under segment {segment}",
issue.filename
);
assert_eq!(issue.status.display(), "todo");
}
#[test]
fn detail_renders_identity_and_bare_handle_with_correct_prefixing() {
use crate::model::Issue;
use crate::ui::detail::{DetailState, render};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let render_assignee = |assignee: Option<String>| {
let issue = Issue {
projects: None,
id: None,
series: None,
health: None,
number: 1,
title: "T".into(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
type_: None,
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: "2026-01-01-t.md".into(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&issue),
"done",
issue.number,
),
issue,
relationships: Vec::new(),
chain: crate::relationships::DependencyChain {
root: 0,
paths: vec![],
truncated: false,
},
blocked: None,
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
rendered_assignee(terminal.backend())
};
let identity = render_assignee(Some("Herbert Maschke <thyseus@pm.me>".into()));
assert!(
identity.contains("Herbert Maschke <thyseus@pm.me>"),
"full identity must render verbatim, got {identity:?}"
);
assert!(
!identity.contains("@Herbert"),
"identity must not be @-prefixed, got {identity:?}"
);
let bare = render_assignee(Some("alice".into()));
assert_eq!(bare, "@alice", "bare handle gets a cosmetic @ prefix");
let none = render_assignee(None);
assert_eq!(none, "", "no assignee must render no assignee line");
}
#[test]
fn after_editor_reassign_moves_file_between_segments() {
let (tmp, dir) = make_fixture();
fs::create_dir_all(dir.join("alice")).unwrap();
write_issue(&dir, "alice/2026-01-01-x.md", 1, "X", "todo");
{
let path = dir.join("alice").join("2026-01-01-x.md");
let contents = fs::read_to_string(&path).unwrap().replace(
"created: \"2026-01-01\"",
"created: \"2026-01-01\"\nassignee: \"@alice\"",
);
fs::write(path, contents).unwrap();
}
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let path = dir.join("alice").join("2026-01-01-x.md");
let edited = fs::read_to_string(&path)
.unwrap()
.replace("assignee: \"@alice\"", "assignee: \"@bob\"");
fs::write(&path, edited).unwrap();
app.after_editor("alice/2026-01-01-x.md");
assert!(
!path.exists(),
"old path alice/2026-01-01-x.md must be gone after reassign"
);
let listed = app.store.list().unwrap();
assert_eq!(listed.len(), 1);
assert!(
listed[0].filename.starts_with("bob/"),
"the file moves under the new assignee's directory: {}",
listed[0].filename
);
assert!(
dir.join(&listed[0].filename).is_file(),
"the reassigned file exists at {}",
listed[0].filename
);
assert_eq!(listed[0].assignee.as_deref(), Some("bob"));
assert_eq!(listed[0].number, 1);
assert_eq!(listed[0].title, "X");
assert_eq!(listed[0].status.display(), "todo");
}
#[test]
fn board_renders_hint_bar_with_help_binding() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
let rows = render_rows(&app);
let last = rows.last().cloned().unwrap_or_default();
assert!(
last.contains("? help"),
"board hint bar must advertise ? help, got {last:?}"
);
assert!(
last.contains("j/k nav"),
"board hint bar must advertise j/k nav, got {last:?}"
);
}
#[test]
fn help_overlay_renders_and_closes() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('?')).unwrap();
assert!(matches!(app.mode, Mode::Help { .. }));
let needed = crate::ui::commandbar::help_overlay(&app.bindings, app.top_mode).len() as u16 + 6;
let text = render_rows_in(&app, 80, needed).join("\n");
assert!(
text.contains("Help"),
"overlay must be titled, got {text:?}"
);
for needle in [
"comment",
"asset",
"filter",
"scroll",
"board",
"detail",
"new relationship",
"edit relationship",
"remove relationship",
] {
assert!(
text.contains(needle),
"help overlay must list {needle:?}, got {text:?}"
);
}
assert!(
!text.contains("more — resize"),
"a frame tall enough must not claim anything is hidden:\n{text}"
);
let short = render_rows_in(&app, 80, 24).join("\n");
assert!(
short.contains("rows 1-") && short.contains(" below"),
"a truncated help overlay must announce its position and what is hidden:\n{short}"
);
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn detail_renders_hint_bar() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail(&mut app);
let rows = render_rows(&app);
let last = rows.last().cloned().unwrap_or_default();
assert!(
last.contains("c comment"),
"detail hint bar must advertise c comment, got {last:?}"
);
assert!(
last.contains("a asset"),
"detail hint bar must advertise a asset, got {last:?}"
);
}
#[test]
fn comment_entry_renders_prompt() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail(&mut app);
let _ = app.handle_key(press_char('c')).unwrap();
assert!(matches!(app.mode, Mode::Comment { .. }));
for c in "hello".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let rows = render_rows(&app);
let last = rows.last().cloned().unwrap_or_default();
assert!(
last.contains("Comment: hello"),
"comment prompt must show the buffer, got {last:?}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
}
#[test]
fn asset_entry_renders_prompt_and_handles_error() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail(&mut app);
let _ = app.handle_key(press_char('a')).unwrap();
assert!(matches!(app.mode, Mode::Asset { .. }));
for c in "/no/such/file.png".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let rows = render_rows(&app);
let last = rows.last().cloned().unwrap_or_default();
assert!(
last.contains("Asset: /no/such/file.png"),
"asset prompt must show the buffer, got {last:?}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Asset { .. }),
"asset error must stay in entry mode"
);
}
#[test]
fn filter_entry_narrows_and_persists_across_help() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha browser", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta renderer", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert_eq!(app.board.column_cards(0).len(), 2);
let _ = app.handle_key(press_char('/')).unwrap();
assert!(matches!(app.mode, Mode::Filter { .. }));
for c in "lpha".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let rows = render_rows(&app);
let last = rows.last().cloned().unwrap_or_default();
assert!(
last.contains("Search: lpha"),
"filter prompt must show the query, got {last:?}"
);
assert_eq!(app.board.column_cards(0).len(), 1);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::ActionMenu { .. }));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.board.column_cards(0).len(), 1);
let _ = app.handle_key(press_char('?')).unwrap();
assert!(matches!(app.mode, Mode::Help { .. }));
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.board.column_cards(0).len(), 1);
let last = render_rows(&app).last().cloned().unwrap_or_default();
assert!(
last.contains("j/k nav") && last.contains("? help"),
"board hint bar must survive filter + help round trip, got {last:?}"
);
}
#[test]
fn filter_group_cycle_renders_swimlane_headers_via_app_state() {
let (tmp, dir) = make_fixture();
write_issue_with_cycle(&dir, "2026-01-01-a.md", 1, "Alpha", "todo", "Cycle 1");
write_issue_with_cycle(&dir, "2026-01-01-b.md", 2, "Beta", "todo", "Cycle 1");
write_issue_with_cycle(&dir, "2026-01-01-c.md", 3, "Gamma", "todo", "Cycle 2");
let mut config = load_config(&tmp);
let default = config.kanban.configurations.get_mut("default").unwrap();
default.columns = vec!["todo".into(), "done".into()];
default.done_column = Some("done".into());
let mut app = App::new(&config).unwrap();
assert_eq!(app.board.column_cards(0).len(), 3);
let _ = app.handle_key(press_char('/')).unwrap();
for c in "group:cycle sort:cycle".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let text = render_rows(&app).join("\n");
assert!(
text.contains("-- cycle: Cycle 1"),
"grouped board should render Cycle 1 header, got {text:?}"
);
assert!(
text.contains("-- cycle: Cycle 2"),
"grouped board should render Cycle 2 header, got {text:?}"
);
}
#[test]
fn form_picker_inline_create_cycle_persists_through_submit() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker cycle test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..6 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let overlay = render_rows(&app).join("\n");
assert!(
overlay.contains("Cycles picker"),
"expected cycles picker overlay, got {overlay:?}"
);
let _ = app.handle_key(press_char('+')).unwrap();
for c in "Cycle 77".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issues = app.store.list().unwrap();
let created = issues
.iter()
.find(|i| i.title == "Picker cycle test")
.expect("created issue");
assert!(
created
.cycles
.as_deref()
.unwrap_or(&[])
.iter()
.any(|c| c == "Cycle 77")
);
assert!(app.store.config.cycles.contains_key("Cycle 77"));
}
#[test]
fn form_picker_selects_existing_label_and_persists_it() {
let (tmp, dir) = make_fixture();
let seed = "---\nnumber: 1\ntitle: \"Seed\"\nstatus: \"todo\"\nlabels: [\"bug\"]\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::write(dir.join("2026-01-01-seed.md"), seed).unwrap();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker labels test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issues = app.store.list().unwrap();
let created = issues
.iter()
.find(|i| i.title == "Picker labels test")
.expect("created issue");
assert!(created.labels.iter().any(|label| label == "bug"));
}
#[test]
fn form_picker_inline_create_milestone_persists_and_sets_issue_field() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker milestone test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..7 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press_char('+')).unwrap();
for c in "Ship V1".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Picker milestone test")
.expect("created issue");
assert!(
created
.milestones
.as_deref()
.unwrap_or(&[])
.iter()
.any(|m| m == "Ship V1")
);
let planner =
crate::planning::Planner::from_config(app.store.config.clone(), tmp.path().to_path_buf());
let milestones = planner.milestone_names().unwrap();
assert!(milestones.iter().any(|m| m == "Ship V1"));
}
#[test]
fn form_picker_inline_create_reviewer_adds_default_team_member() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker reviewer test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..8 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press_char('+')).unwrap();
for c in "qa-bot".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Picker reviewer test")
.expect("created issue");
assert_eq!(created.reviewer.as_deref(), Some("qa-bot"));
let members = app
.store
.config
.teams
.get("default")
.map(|team| team.members.clone())
.unwrap_or_default();
assert!(members.iter().any(|m| m == "qa-bot"));
}
#[test]
fn form_picker_inline_create_assignee_sets_issue_and_team_member() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker assignee test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..4 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press_char('+')).unwrap();
for c in "ops-bot".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Picker assignee test")
.expect("created issue");
assert_eq!(created.assignee.as_deref(), Some("ops-bot"));
let members = app
.store
.config
.teams
.get("default")
.map(|team| team.members.clone())
.unwrap_or_default();
assert!(members.iter().any(|m| m == "ops-bot"));
}
#[test]
fn form_picker_escape_cancels_overlay_and_keeps_form_open() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker cancel test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..6 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let before = render_rows(&app).join("\n");
assert!(before.contains("Cycles picker"));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Form));
let after = render_rows(&app).join("\n");
assert!(!after.contains("Cycles picker"));
}
#[test]
fn form_picker_f2_on_plain_text_field_is_noop() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "No picker on title".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(matches!(app.mode, Mode::Form));
let text = render_rows(&app).join("\n");
assert!(
!text.contains("Cycles picker")
&& !text.contains("Labels picker")
&& !text.contains("Assignee picker")
&& !text.contains("Milestones picker")
&& !text.contains("Reviewer picker"),
"no picker overlay should render on title focus, got {text:?}"
);
}
#[test]
fn form_picker_filter_narrows_options_before_selection() {
let (tmp, dir) = make_fixture();
let seed_bug = "---\nnumber: 1\ntitle: \"Seed bug\"\nstatus: \"todo\"\nlabels: [\"bug\"]\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
let seed_ops = "---\nnumber: 2\ntitle: \"Seed ops\"\nstatus: \"todo\"\nlabels: [\"ops\"]\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
fs::write(dir.join("2026-01-01-seed-bug.md"), seed_bug).unwrap();
fs::write(dir.join("2026-01-01-seed-ops.md"), seed_ops).unwrap();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker filter test".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press_char('o')).unwrap();
let _ = app.handle_key(press_char('p')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Picker filter test")
.expect("created issue");
assert_eq!(created.labels, vec!["ops".to_string()]);
}
#[test]
fn form_picker_reviewer_includes_current_free_text_value() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Reviewer picker current value".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..8 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
for c in "freestyle-reviewer".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let text = render_rows(&app).join("\n");
assert!(
text.contains("freestyle-reviewer"),
"picker options should include current reviewer value, got {text:?}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Form));
}
#[test]
fn form_picker_inline_create_label_persists_custom_label() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Picker label create".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press_char('+')).unwrap();
for c in "custom-tag".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Picker label create")
.expect("created issue");
assert!(created.labels.iter().any(|l| l == "custom-tag"));
}
#[test]
fn form_picker_assignee_includes_current_free_text_value() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Assignee picker current value".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..4 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
for c in "freestyle-assignee".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let text = render_rows_in(&app, 100, 24).join("\n");
assert!(
text.contains("freestyle-assigne"),
"picker options should include current assignee value, got {text:?}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Form));
}
#[test]
fn form_picker_assignee_merges_members_across_all_teams() {
let (tmp, _dir) = make_fixture();
let mut config = load_config(&tmp);
config.teams.insert(
"default".to_string(),
crate::config::Team {
members: vec!["alice".to_string()],
},
);
config.teams.insert(
"platform".to_string(),
crate::config::Team {
members: vec!["bob".to_string()],
},
);
config.planning.default_team = Some("default".to_string());
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for _ in 0..4 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let text = render_rows(&app).join("\n");
assert!(
text.contains("alice"),
"expected default team member, got {text:?}"
);
assert!(
text.contains("bob"),
"expected non-default team member, got {text:?}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Form));
}
fn make_fixture_two_boards() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.kanban.configurations.insert(
"sprint".into(),
BoardConfiguration {
columns: vec!["backlog".into(), "in progress".into(), "done".into()],
done_column: Some("done".into()),
hidden_columns: vec![],
show_mismatch: false,
..Default::default()
},
);
config.layout.move_on_change = false;
config.save(&config_path).unwrap();
(tmp, dir)
}
#[test]
fn tabs_switch_between_named_configs_and_preserve_active() {
let (tmp, dir) = make_fixture_two_boards();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "in progress");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert_eq!(app.active_config, 0, "first launch selects the first tab");
assert_eq!(app.tabs.selected, 0);
assert_eq!(app.board.columns[0].name, "todo");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(app.active_config, 1);
assert_eq!(
app.board.columns[0].name, "backlog",
"board rebuilt from tab B"
);
let _ = app.handle_key(press_char('/')).unwrap();
for c in "zzw".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.active_config, 1, "filter reset must not change the tab");
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(app.active_config, 0);
assert_eq!(app.board.columns[0].name, "todo");
}
#[test]
fn visibility_toggle_hides_column_persists_and_keeps_cards() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "B", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert_eq!(app.board.total_cols(), 4);
assert_eq!(app.board.column_cards(3).len(), 1, "done card visible");
for _ in 0..3 {
let _ = app.handle_key(press_char('l')).unwrap();
}
assert_eq!(app.board.active_col, 3);
let _ = app.handle_key(press_char('c')).unwrap();
assert_eq!(
app.board.total_cols(),
3,
"hidden column excluded from render surface"
);
assert_eq!(
app.board
.hidden
.iter()
.map(|c| c.cards.len())
.sum::<usize>(),
1,
"cards stay in board state"
);
assert_eq!(
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>()
+ app
.board
.hidden
.iter()
.map(|c| c.cards.len())
.sum::<usize>(),
2,
"total card count unaffected"
);
let config2 = load_config(&tmp);
let app2 = App::new(&config2).unwrap();
assert_eq!(app2.board.total_cols(), 3, "hidden column survives restart");
let hidden_names: Vec<&str> = app2.board.hidden.iter().map(|c| c.name.as_str()).collect();
assert!(
hidden_names.contains(&"done"),
"done must be hidden on reload, got {hidden_names:?}"
);
assert_eq!(
app.board.active_column_name(),
"done",
"cursor must stay on the just-hidden column after rebuild"
);
let _ = app.handle_key(press_char('c')).unwrap();
assert_eq!(app.board.total_cols(), 4, "toggle off restores the column");
assert_eq!(
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>(),
2,
"both cards visible again"
);
let config3 = load_config(&tmp);
let app3 = App::new(&config3).unwrap();
assert_eq!(
app3.board.total_cols(),
4,
"un-hide must survive restart too"
);
}
#[test]
fn tab_bar_renders_labels_on_top_row() {
let (tmp, _dir) = make_fixture_two_boards();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
let rows = render_rows(&app);
let top = rows.first().cloned().unwrap_or_default();
assert!(
top.contains("default") && top.contains("sprint"),
"tab bar must show every board name on the top row, got {top:?}"
);
assert!(
top.contains("[default]"),
"the active tab must be bracketed, got {top:?}"
);
let border = rows.get(1).cloned().unwrap_or_default();
assert!(
border.contains('─'),
"row 1 must be the tab bar's bottom border, got {border:?}"
);
}
#[test]
fn hover_line_shows_selected_card_title() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-02-b.md", 2, "Beta export", "done");
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
let rows = render_rows(&app);
let hover = rows.first().cloned().unwrap_or_default();
assert!(
hover.contains("#1 Alpha login"),
"top-row hover line must show the first selected card, got {hover:?}"
);
assert!(
!hover.contains("#2"),
"hover line shows the selected card only, got {hover:?}"
);
}
#[test]
fn hover_line_tracks_selection_movement() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-02-b.md", 2, "Beta export", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Right)).unwrap();
}
let rows = render_rows(&app);
let hover = rows.first().cloned().unwrap_or_default();
assert!(
hover.contains("#2 Beta export"),
"hover line follows the moved selection, got {hover:?}"
);
}
#[test]
fn hover_line_placeholder_when_nothing_selected() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
let rows = render_rows(&app);
let hover = rows.first().cloned().unwrap_or_default();
assert!(
hover.trim().ends_with('—'),
"empty board shows the placeholder in the hover line, got {hover:?}"
);
}
#[test]
fn hover_line_shows_in_filter_mode() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
assert!(matches!(app.mode, Mode::Filter { .. }));
let rows = render_rows(&app);
assert!(
rows.first()
.cloned()
.unwrap_or_default()
.contains("#1 Alpha login"),
"hover line renders in filter mode, got {rows:?}"
);
}
#[test]
fn single_board_omits_tab_bar() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
let rows = render_rows(&app);
let top = rows.first().cloned().unwrap_or_default();
assert!(
!top.contains("[default]") && !top.contains("default"),
"top row must not be a tab bar for a single board, got {top:?}"
);
assert!(
top.trim().ends_with('—'),
"top row must be the hovered-issue line, got {top:?}"
);
}
#[test]
fn settings_hotkey_opens_checkbox_list() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
assert!(matches!(app.mode, Mode::Settings));
let text = render_rows(&app).join("\n");
assert!(
text.contains("Sections") && text.contains("Dashboards"),
"the Config mode shows its section rail and dashboards index, got {text:?}"
);
let _ = app.handle_key(press_char('l')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
let text = render_rows(&app).join("\n");
assert!(
text.contains("[x] todo"),
"visible column checkbox, got {text:?}"
);
assert!(
text.contains("new column"),
"the detail must advertise column add"
);
}
#[test]
fn settings_toggles_column_visibility_and_persists() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
for _ in 0..3 {
let _ = app.handle_key(press_char('j')).unwrap();
}
let _ = app.handle_key(press(KeyCode::Char(' '))).unwrap();
let config2 = load_config(&tmp);
let app2 = App::new(&config2).unwrap();
assert_eq!(
app2.board.total_cols(),
3,
"done column hidden and persisted"
);
assert!(
app2.board.hidden.iter().any(|c| c.name == "done"),
"hidden column must be 'done'"
);
}
#[test]
fn settings_flips_wrap_titles_and_persists() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
for _ in 0..4 {
let _ = app.handle_key(press_char('j')).unwrap();
}
let _ = app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert_eq!(app.status_msg.as_deref(), Some("Wrap titles: off"));
let config2 = load_config(&tmp);
assert!(
!config2.default_config().wrap_titles,
"wrap toggle must persist"
);
let on_disk = std::fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
assert!(
on_disk.contains("wrap_titles"),
"tix.toml must gain the wrap_titles key: {on_disk}"
);
}
#[test]
fn settings_cycles_title_lines_and_order_by_and_persists() {
use crate::config::OrderBy;
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
let _ = app.handle_key(press_char('l')).unwrap();
for _ in 0..5 {
let _ = app.handle_key(press_char('j')).unwrap();
}
let _ = app.handle_key(press(KeyCode::Char(' '))).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press(KeyCode::Char(' '))).unwrap();
let config2 = load_config(&tmp);
let board = config2.default_config();
assert_eq!(board.title_lines, 4, "3 -> 4 via the 1-5 cycle");
assert_eq!(
board.order_by,
OrderBy::CreatedAtAsc,
"default desc -> asc on first ordering toggle"
);
let mut app2 = App::new(&config2).unwrap();
let _ = app2.handle_key(press_char('s')).unwrap();
let _ = app2.handle_key(press_char('l')).unwrap();
let _ = app2.handle_key(press_char('l')).unwrap();
let text = render_rows(&app2).join("\n");
assert!(
text.contains("Title lines: 4"),
"restart shows the persisted line limit: {text:?}"
);
assert!(
text.contains("Order by: created_at_asc"),
"restart shows the persisted ordering: {text:?}"
);
let mut app3 = App::new(&config2).unwrap();
let _ = app3.handle_key(press_char('s')).unwrap();
let _ = app3.handle_key(press_char('l')).unwrap();
let _ = app3.handle_key(press_char('l')).unwrap();
for _ in 0..6 {
let _ = app3.handle_key(press_char('j')).unwrap();
}
let _ = app3.handle_key(press(KeyCode::Char(' '))).unwrap();
let config3 = load_config(&tmp);
assert_eq!(
config3.default_config().order_by,
OrderBy::None,
"asc -> none on the second ordering toggle"
);
}
#[test]
fn settings_adds_new_column_type_and_persists() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
let _ = app.handle_key(press_char('a')).unwrap();
for c in "blocked".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let config2 = load_config(&tmp);
assert!(
config2
.default_config()
.columns
.contains(&"blocked".to_string()),
"new column must persist to tix.toml"
);
assert!(app.board.columns.iter().any(|c| c.name == "blocked"));
}
#[test]
fn new_board_hotkey_creates_and_persists() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('b')).unwrap();
assert!(matches!(app.mode, Mode::Settings));
for c in "scratch".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.tabs.names[app.active_config], "scratch",
"new board must become active"
);
let config2 = load_config(&tmp);
assert!(
config2.kanban.configurations.contains_key("scratch"),
"new board must persist"
);
}
#[test]
fn settings_deletes_board_but_refuses_last() {
let (tmp, _dir) = make_fixture_two_boards();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
let _ = app.handle_key(press_char('x')).unwrap();
let _ = app.handle_key(press_char('y')).unwrap();
assert_eq!(app.store.config.kanban.configurations.len(), 1);
assert_eq!(app.tabs.names.len(), 1);
let _ = app.handle_key(press_char('x')).unwrap();
assert_eq!(app.store.config.kanban.configurations.len(), 1);
let config2 = load_config(&tmp);
assert_eq!(config2.kanban.configurations.len(), 1);
}
#[test]
fn settings_esc_closes_to_board() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
assert!(matches!(app.mode, Mode::Settings));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.settings.is_none());
}
#[test]
fn filter_modal_edits_commits_and_resets_criteria() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta polish", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma login", "in progress");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let total = |app: &App| {
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>()
};
assert_eq!(total(&app), 3);
let _ = app.handle_key(press_char('f')).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
for _ in 0..2 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "progress".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(total(&app), 1, "status criterion narrows live");
let _ = app.handle_key(press_char('a'));
assert!(matches!(app.mode, Mode::Board));
assert_eq!(total(&app), 1, "an applied criterion keeps filtering");
let _ = app.handle_key(press_char('f')).unwrap();
let _ = app.handle_key(press_char('r')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(
total(&app),
3,
"reset followed by close restores the full board"
);
}
#[test]
fn filter_modal_composes_with_text_query() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta", "in progress");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma", "in progress");
let b_path = dir.join("2026-01-01-b.md");
let b_orig = fs::read_to_string(&b_path).unwrap();
fs::write(
&b_path,
b_orig.replace("Body text.", "Feeds the ostrich at dawn."),
)
.unwrap();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('f')).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
for _ in 0..2 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "progress".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let total = |app: &App| {
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>()
};
assert_eq!(
total(&app),
2,
"status criterion narrows to in-progress cards"
);
let _ = app.handle_key(press_char('a'));
let _ = app.handle_key(press_char('/')).unwrap();
for c in "ostrich".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
assert_eq!(total(&app), 1, "AND with text query narrows further");
let titles: Vec<&str> = app
.board
.columns
.iter()
.flat_map(|c| c.cards.iter().map(|k| k.title.as_str()))
.collect();
assert_eq!(titles, vec!["Beta"], "only the ostrich card remains");
}
#[test]
fn hidden_column_cards_appear_under_matching_filter() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha todo", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta done", "done");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma todo too", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
for _ in 0..3 {
let _ = app
.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
}
assert_eq!(app.board.active_col, 3);
let _ = app.handle_key(press_char('c')).unwrap();
assert_eq!(app.board.total_cols(), 3, "done hidden");
assert_eq!(
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>(),
2,
"only todo cards visible"
);
let _ = app.handle_key(press_char('f')).unwrap();
for _ in 0..2 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "done".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>(),
1,
"hidden column's card appears under a matching status filter"
);
assert!(
app.board
.columns
.iter()
.any(|c| c.name == "done" && c.cards.len() == 1),
"done column revealed with its matching card"
);
}
#[test]
fn board_editor_hotkey_fires_for_selected_issue() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "B", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert_eq!(app.board.column_cards(0).len(), 2);
let action = app.handle_key(press_char('e')).unwrap();
assert!(
matches!(&action, Action::OpenEditor(name) if name == "2026-01-01-a.md"),
"board e must open the selected issue's editor, got {action:?}"
);
}
#[test]
fn form_renders_as_the_mode_content_beside_the_navigation() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
assert!(matches!(app.mode, Mode::Form));
let rows = render_rows(&app);
let title_row = rows
.iter()
.position(|r| r.contains("New Issue"))
.expect("the form renders its title");
assert_eq!(
title_row, 0,
"the mode content starts at the top, above the old modal inset"
);
assert!(
rows[0].starts_with("F1"),
"the vertical navigation keeps the left column, got {:?}",
rows[0]
);
assert!(
rows.iter().any(|r| r.contains("Submit")),
"the form's own key hint is drawn"
);
let tiny = TestBackend::new(20, 5);
let mut terminal = Terminal::new(tiny).unwrap();
terminal
.draw(|f| crate::app::render(&app, f, f.area()))
.unwrap();
}
fn make_blocked_fixture() -> (TempDir, PathBuf) {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Blocker", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Subject", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Friend", "todo");
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
store
.link("1", "2", crate::relationships::RelationKind::Blocks)
.unwrap();
store
.link("3", "2", crate::relationships::RelationKind::Related)
.unwrap();
(tmp, dir)
}
fn status_of(dir: &std::path::Path, number: u32) -> String {
let contents = fs::read_to_string(dir.join(format!(
"2026-01-01-{}.md",
(b'a' + number as u8 - 1) as char
)))
.unwrap();
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("status: ") {
return rest.trim().trim_matches('"').to_string();
}
}
panic!("no status in {contents}");
}
fn acknowledged_count(dir: &std::path::Path, number: u32) -> usize {
let path = dir.join(format!(
"2026-01-01-{}.md",
(b'a' + number as u8 - 1) as char
));
fs::read_to_string(path)
.unwrap()
.matches("blocked_move_acknowledged")
.count()
}
#[test]
fn a_keyboard_move_of_a_blocked_issue_reaches_the_confirmation_and_applies_nothing() {
let (tmp, dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
app.board.selected = 1;
for _ in 0..2 {
let _ = app.handle_key(press_char('>')).unwrap();
}
let _ = app.handle_key(press_char('>')).unwrap();
match &app.mode {
Mode::ConfirmBlockedMove { warning } => {
assert!(
warning.contains("Blocked by #1"),
"the warning names the blocker and uses the shared wording: {warning}"
);
}
other => panic!("expected ConfirmBlockedMove, got {other:?}"),
}
assert_eq!(
status_of(&dir, 2),
"in review",
"the move into the completion column must not have been applied"
);
assert_eq!(acknowledged_count(&dir, 2), 0, "nothing recorded yet");
}
fn open_detail_for_number(app: &mut App, number: u32) {
app.mode = crate::app::Mode::Detail;
let issue = app
.store
.get(crate::storage::IssueSelector::Number(number))
.unwrap();
let number_of = issue.number;
let relationships = app.store.relationships_for(number_of).unwrap();
let (all, done_column) = app.store.graph_source().unwrap();
let graph = crate::commands::graph_over(&all, &done_column);
app.detail = Some(crate::ui::detail::DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(&all, &done_column, number_of),
issue,
relationships,
chain: graph.dependency_chain(number_of),
blocked: crate::commands::blocked_note(&graph.blocked_state(number_of)),
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
});
}
fn type_and_submit(app: &mut App, text: &str) {
for c in text.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
fn stored_blocks(app: &App) -> Vec<(u32, u32)> {
app.store
.list()
.unwrap()
.into_iter()
.flat_map(|i| i.relationships)
.filter(|r| r.kind == crate::relationships::RelationKind::Blocks)
.map(|r| {
(
r.from.as_number().unwrap_or_default(),
r.to.as_number().unwrap_or_default(),
)
})
.collect()
}
#[test]
fn the_detail_view_creates_a_relationship_through_the_real_key_path() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 3);
assert!(
!stored_blocks(&app).contains(&(3, 2)),
"precondition: 3 has no blocks edge yet, got {:?}",
stored_blocks(&app)
);
let _ = app.handle_key(press_char('n')).unwrap();
assert!(
matches!(
app.mode,
crate::app::Mode::LinkCreate {
step: crate::app::LinkStep::Target,
..
}
),
"n must open the create prompt on the target step, got {:?}",
app.mode
);
type_and_submit(&mut app, "2");
assert!(
matches!(
app.mode,
crate::app::Mode::LinkCreate {
step: crate::app::LinkStep::Kind,
..
}
),
"submitting a target must advance to the kind step, got {:?}",
app.mode
);
type_and_submit(&mut app, "blocks");
assert!(
matches!(app.mode, crate::app::Mode::Detail),
"the prompt closes after the create, got {:?}",
app.mode
);
assert!(
stored_blocks(&app).contains(&(3, 2)),
"the edge must be written to disk, got {:?}",
stored_blocks(&app)
);
assert!(
stored_blocks(&app).contains(&(1, 2)),
"the fixture's own edge must survive, got {:?}",
stored_blocks(&app)
);
let shown = app.detail.as_ref().unwrap().relationships.len();
assert_eq!(
shown,
app.store.relationships_for(3).unwrap().len(),
"the pane must show what the service resolves now"
);
assert!(
app.detail
.as_ref()
.unwrap()
.relationships
.iter()
.any(|r| r.kind == crate::relationships::RelationKind::Blocks),
"and the new blocks edge must be one of them"
);
}
#[test]
fn the_detail_view_sets_changes_and_clears_a_parent_through_the_real_key_path() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 3);
let parent_of = |app: &App, number: u32| {
app.store
.get(crate::storage::IssueSelector::Number(number))
.unwrap()
.parent
};
assert_eq!(parent_of(&app, 3), None, "precondition: #3 has no parent");
let _ = app.handle_key(press_char('P')).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::ParentPrompt { .. }),
"P must open the parent prompt, got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, crate::app::Mode::Detail));
assert_eq!(parent_of(&app, 3), None);
assert!(
!app.store
.get(crate::storage::IssueSelector::Number(3))
.unwrap()
.history
.events()
.iter()
.any(|e| e.kind == crate::model::HistoryEventKind::ParentSet),
"a no-op clear must not append an event"
);
let _ = app.handle_key(press_char('P')).unwrap();
type_and_submit(&mut app, "1");
assert!(matches!(app.mode, crate::app::Mode::Detail));
assert_eq!(
parent_of(&app, 3),
Some(crate::id::IssueRef::Number(1)),
"the parent must be written"
);
let child = app
.store
.get(crate::storage::IssueSelector::Number(3))
.unwrap();
let events = child.history.events();
assert_eq!(
events.last().map(|e| e.kind),
Some(crate::model::HistoryEventKind::ParentSet),
"one parent_set event must be appended: {events:?}"
);
assert_eq!(
app.detail.as_ref().unwrap().issue.parent,
Some(crate::id::IssueRef::Number(1)),
"the pane must show the parent the service now resolves"
);
let _ = app.handle_key(press_char('P')).unwrap();
type_and_submit(&mut app, "3");
assert!(
matches!(app.mode, crate::app::Mode::ParentPrompt { .. }),
"a refused parent must keep the prompt open, got {:?}",
app.mode
);
assert_eq!(
parent_of(&app, 3),
Some(crate::id::IssueRef::Number(1)),
"the refused write changed nothing"
);
}
#[test]
fn f6_opens_the_repos_mode_and_enter_targets_the_configured_repo() {
let (tmp, _dir) = make_fixture();
let mut config = load_config(&tmp);
config
.repos
.insert("child".to_string(), PathBuf::from("/tmp/child"));
config.parent_repo = Some(PathBuf::from("/tmp/parent"));
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(6))).unwrap();
assert!(
matches!(app.mode, Mode::Repos),
"F6 must open the Repos mode, got {:?}",
app.mode
);
assert_eq!(app.top_mode, crate::ui::nav::TopMode::Repos);
let labels: Vec<String> = app
.nav_tree()
.into_iter()
.find(|node| node.mode == crate::ui::nav::TopMode::Repos)
.map(|node| node.branches.into_iter().map(|b| b.label).collect())
.unwrap_or_default();
assert_eq!(labels, vec!["child".to_string(), "parent".to_string()]);
let action = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(action, Action::OpenRepo(PathBuf::from("/tmp/child")));
let mut empty = App::new(&load_config(&tmp)).unwrap();
let _ = empty.handle_key(press(KeyCode::F(6))).unwrap();
assert!(matches!(empty.mode, Mode::Repos));
assert_eq!(empty.repo_entries().len(), 0);
assert_eq!(
empty.handle_key(press(KeyCode::Enter)).unwrap(),
Action::None
);
}
#[test]
fn the_create_prompt_refuses_to_infer_a_kind() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 3);
let _ = app.handle_key(press_char('n')).unwrap();
type_and_submit(&mut app, "2");
type_and_submit(&mut app, "");
assert!(
matches!(
app.mode,
crate::app::Mode::LinkCreate {
step: crate::app::LinkStep::Kind,
..
}
),
"an empty kind must stay on the kind step, got {:?}",
app.mode
);
let before = stored_blocks(&app);
assert_eq!(stored_blocks(&app), before, "and must not write an edge");
type_and_submit(&mut app, "blocksish");
assert!(
matches!(
app.mode,
crate::app::Mode::LinkCreate {
step: crate::app::LinkStep::Kind,
..
}
),
"an unknown kind must stay on the kind step, got {:?}",
app.mode
);
assert_eq!(stored_blocks(&app), before, "and must not write an edge");
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("blocks") && status.contains("related"),
"the refusal must name the valid set, got {status:?}"
);
}
#[test]
fn the_create_prompt_refuses_an_unresolvable_selector_with_the_clis_own_message() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 3);
let _ = app.handle_key(press_char('n')).unwrap();
type_and_submit(&mut app, "no-such-issue-anywhere");
assert!(
matches!(
app.mode,
crate::app::Mode::LinkCreate {
step: crate::app::LinkStep::Target,
..
}
),
"an unresolvable selector must stay on the target step, got {:?}",
app.mode
);
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("no-such-issue-anywhere"),
"the message must name what failed to resolve, got {status:?}"
);
}
#[test]
fn the_detail_view_re_kinds_the_relationship_under_the_cursor() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 1);
assert_eq!(
app.detail.as_ref().unwrap().relationships.len(),
1,
"precondition: the fixture links one edge onto #1"
);
let _ = app.handle_key(press_char('E')).unwrap();
assert!(
matches!(
app.mode,
crate::app::Mode::LinkEdit {
step: crate::app::LinkStep::Target,
..
}
),
"E must open the edit prompt, got {:?}",
app.mode
);
type_and_submit(&mut app, "3");
type_and_submit(&mut app, "related");
assert!(
matches!(app.mode, crate::app::Mode::Detail),
"the prompt closes after the edit, got {:?}",
app.mode
);
assert!(
stored_blocks(&app).is_empty(),
"the blocks edge must be gone, got {:?}",
stored_blocks(&app)
);
let related: Vec<(u32, u32)> = app
.store
.list()
.unwrap()
.into_iter()
.flat_map(|i| i.relationships)
.filter(|r| r.kind == crate::relationships::RelationKind::Related)
.map(|r| {
(
r.from.as_number().unwrap_or_default(),
r.to.as_number().unwrap_or_default(),
)
})
.collect();
assert!(
related.contains(&(1, 3)),
"the re-kinded edge must exist, got {related:?}"
);
}
#[test]
fn the_detail_view_removes_a_relationship_only_after_confirmation() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 1);
assert_eq!(stored_blocks(&app), vec![(1, 2)], "precondition");
let _ = app.handle_key(press_char('d')).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::ConfirmRemoveRelationship { .. }),
"d must ask for confirmation, got {:?}",
app.mode
);
let asked = app.status_msg.clone().unwrap_or_default();
assert!(
asked.contains("blocks") && asked.contains("#2"),
"the confirmation must name the edge, got {asked:?}"
);
assert_eq!(
stored_blocks(&app),
vec![(1, 2)],
"nothing is written before the confirmation"
);
let _ = app.handle_key(press_char('n')).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::Detail),
"the confirmation closes on cancel, got {:?}",
app.mode
);
assert_eq!(
stored_blocks(&app),
vec![(1, 2)],
"and a cancelled removal writes nothing"
);
let _ = app.handle_key(press_char('d')).unwrap();
let _ = app.handle_key(press_char('y')).unwrap();
assert!(
stored_blocks(&app).is_empty(),
"the confirmed removal must apply, got {:?}",
stored_blocks(&app)
);
assert_eq!(
app.detail.as_ref().unwrap().relationships.len(),
0,
"and the pane must be refreshed"
);
}
#[test]
fn the_relationship_cursor_moves_and_clamps() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "B", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "C", "todo");
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
store
.link("1", "2", crate::relationships::RelationKind::Blocks)
.unwrap();
store
.link("1", "3", crate::relationships::RelationKind::Related)
.unwrap();
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 1);
assert_eq!(app.detail.as_ref().unwrap().relationships.len(), 2);
let cursor = |a: &App| a.detail.as_ref().unwrap().rel_cursor;
assert_eq!(cursor(&app), 0, "the cursor starts at the first edge");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(cursor(&app), 1, "Tab moves down");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(cursor(&app), 1, "and clamps at the end");
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(cursor(&app), 0, "BackTab moves back");
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(cursor(&app), 0, "and clamps at the start");
}
#[test]
fn edit_and_remove_say_so_when_no_edge_is_selected() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Lonely", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_detail_for_number(&mut app, 1);
assert!(
app.detail.as_ref().unwrap().relationships.is_empty(),
"precondition: the issue has no relationships"
);
for key in ['E', 'd'] {
let _ = app.handle_key(press_char(key)).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::Detail),
"{key} must not open a prompt with nothing selected, got {:?}",
app.mode
);
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("no relationship selected"),
"{key} must say why it did nothing, got {status:?}"
);
}
}
#[test]
fn the_confirmation_key_applies_the_move_and_records_the_acknowledgement() {
let (tmp, dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
app.board.selected = 1;
for _ in 0..3 {
let _ = app.handle_key(press_char('>')).unwrap();
}
assert!(matches!(app.mode, Mode::ConfirmBlockedMove { .. }));
let _ = app.handle_key(press_char('y')).unwrap();
assert!(
matches!(app.mode, Mode::MoveIntoDone { .. }),
"a confirmed blocked move into done asks for its commit, got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"the prompt closes after the decision, got {:?}",
app.mode
);
assert_eq!(status_of(&dir, 2), "done", "the move applied");
assert_eq!(
acknowledged_count(&dir, 2),
1,
"exactly one acknowledgement, recorded by the shared path"
);
}
#[test]
fn a_cancel_key_leaves_no_trace() {
let (tmp, dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
app.board.selected = 1;
for _ in 0..3 {
let _ = app.handle_key(press_char('>')).unwrap();
}
assert!(matches!(app.mode, Mode::ConfirmBlockedMove { .. }));
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"cancel returns to the board, got {:?}",
app.mode
);
assert_eq!(
status_of(&dir, 2),
"in review",
"cancelling must leave the issue exactly where the prompt found it"
);
assert_eq!(acknowledged_count(&dir, 2), 0, "and nothing was recorded");
}
#[test]
fn an_unrecognised_key_cancels_rather_than_confirming() {
let (tmp, dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
app.board.selected = 1;
for _ in 0..3 {
let _ = app.handle_key(press_char('>')).unwrap();
}
assert!(matches!(app.mode, Mode::ConfirmBlockedMove { .. }));
let _ = app.handle_key(press_char('z')).unwrap();
assert_eq!(
status_of(&dir, 2),
"in review",
"an unknown key must not confirm"
);
assert_eq!(acknowledged_count(&dir, 2), 0);
}
#[test]
fn confirming_twice_does_not_duplicate_the_acknowledgement() {
let (tmp, dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
app.board.selected = 1;
for _ in 0..3 {
let _ = app.handle_key(press_char('>')).unwrap();
}
assert!(matches!(app.mode, Mode::ConfirmBlockedMove { .. }));
let _ = app.handle_key(press_char('y')).unwrap();
if matches!(app.mode, Mode::MoveIntoDone { .. }) {
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
assert_eq!(acknowledged_count(&dir, 2), 1);
assert_eq!(status_of(&dir, 2), "done", "precondition: the move applied");
app.board.select_column(3);
let _ = app.handle_key(press_char('>')).unwrap();
assert!(
!matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"a move already applied must not raise the prompt again, got {:?}",
app.mode
);
if matches!(app.mode, Mode::MoveIntoDone { .. }) {
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
let _ = app.handle_key(press_char('y')).unwrap();
assert_eq!(
acknowledged_count(&dir, 2),
1,
"a re-applied move must not append a second acknowledgement"
);
}
#[test]
fn an_unblocked_issue_moves_without_any_prompt() {
let (tmp, dir) = make_blocked_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
app.board.selected = 0;
for _ in 0..2 {
let _ = app.handle_key(press_char('>')).unwrap();
}
let _ = app.handle_key(press_char('>')).unwrap();
assert!(
!matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"the commit prompt is the expected one, got {:?}",
app.mode
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
!matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
"an unblocked issue must never see the prompt, got {:?}",
app.mode
);
assert_eq!(status_of(&dir, 1), "done", "the move applied directly");
assert_eq!(acknowledged_count(&dir, 1), 0, "and recorded nothing");
}
#[test]
fn the_board_marks_a_blocked_card_and_not_an_unblocked_one() {
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let cards: Vec<&crate::ui::board::Card> = board.columns[0].cards.iter().collect();
let subject = cards
.iter()
.find(|c| c.number == 2)
.expect("the subject is on the board");
let blocker = cards
.iter()
.find(|c| c.number == 1)
.expect("the blocker is on the board");
let friend = cards
.iter()
.find(|c| c.number == 3)
.expect("the friend is on the board");
assert!(
subject.blocked.is_some(),
"the blocked issue carries the service's reason: {:?}",
subject.blocked
);
assert!(
subject.blocked.as_deref().unwrap().contains("#1"),
"and the reason names the blocker"
);
assert!(blocker.blocked.is_none(), "a blocker is not itself blocked");
assert!(
friend.blocked.is_none(),
"`related` must not imply blocked — the distinction is the point"
);
}
#[test]
fn the_detail_view_shows_the_blocked_reason() {
use crate::ui::detail::{DetailState, render as render_detail};
let (tmp, _dir) = make_blocked_fixture();
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let (issues, done_column) = store.graph_source().unwrap();
let graph = crate::commands::graph_over(&issues, &done_column);
let subject = store.get(crate::storage::IssueSelector::Number(2)).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(&issues, &done_column, 2),
issue: subject,
relationships: store.relationships_for(2).unwrap(),
chain: graph.dependency_chain(2),
blocked: crate::commands::blocked_note(&graph.blocked_state(2)),
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
let terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut terminal = terminal;
terminal
.draw(|f| render_detail(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("Blocked by #1"),
"the detail view must carry the shared reason:\n{text}"
);
}
fn make_hierarchy_fixture() -> (TempDir, PathBuf) {
let (tmp, dir) = make_fixture();
for (n, title) in [(1, "Epic"), (2, "Task A"), (3, "Task B"), (4, "Nested")] {
write_issue(
&dir,
&format!("2026-01-01-{}.md", (b'a' + n as u8 - 1) as char),
n,
title,
"todo",
);
}
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
store.set_parent("2", "1").unwrap();
store.set_parent("3", "1").unwrap();
store.set_parent("4", "2").unwrap();
crate::commands::mv::run(
&config,
&store,
"3",
"done",
Some("abc1234"),
None,
false,
false,
)
.unwrap();
(tmp, dir)
}
#[test]
fn t018_the_board_marks_a_parent_with_a_child_count_and_rollup() {
let (tmp, _dir) = make_hierarchy_fixture();
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let card = |n: u32| {
board
.columns
.iter()
.flat_map(|c| c.cards.iter())
.find(|c| c.number == n)
.unwrap_or_else(|| panic!("issue {n} is not on the board"))
.clone()
};
let epic = card(1);
assert_eq!(
epic.children.as_deref(),
Some("2 1/2"),
"a parent carries its child count and its rollup"
);
assert_eq!(
card(2).children.as_deref(),
Some("1 0/1"),
"an intermediate parent carries its own marker"
);
assert_eq!(card(3).children, None, "a leaf has no child marker");
assert_eq!(card(4).children, None);
}
#[test]
fn t018_the_card_marker_renders_in_the_board_line() {
let (tmp, _dir) = make_hierarchy_fixture();
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let epic = board
.columns
.iter()
.flat_map(|c| c.cards.iter())
.find(|c| c.number == 1)
.unwrap()
.clone();
let tokens = crate::ui::widgets::card_tokens(&epic);
let texts: Vec<&str> = tokens.iter().map(|(t, _)| t.as_str()).collect();
assert!(
texts.contains(&"[2 1/2]"),
"the marker must be on the card: {texts:?}"
);
}
#[test]
fn t018_the_detail_pane_shows_the_same_facts_as_the_cli() {
let (tmp, _dir) = make_hierarchy_fixture();
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let (issues, done_column) = store.graph_source().unwrap();
let graph = crate::commands::graph_over(&issues, &done_column);
let subject = store.get(crate::storage::IssueSelector::Number(1)).unwrap();
let detail = crate::ui::detail::DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(&issues, &done_column, 1),
issue: subject,
relationships: store.relationships_for(1).unwrap(),
chain: graph.dependency_chain(1),
blocked: crate::commands::blocked_note(&graph.blocked_state(1)),
scroll_offset: std::cell::Cell::new(0),
body_start: std::cell::Cell::new(0),
mention_body: None,
focus_pending: std::cell::Cell::new(false),
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
terminal
.draw(|f| {
crate::ui::detail::render(&detail, f, f.area(), &crate::ui::color::Theme::default())
})
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>();
let screen: String = {
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
};
for expected in [
"hierarchy:",
"parent: none",
"children: 2",
"completed: 1/2",
] {
assert!(
screen.contains(expected),
"the detail pane must show {expected:?}:\n{screen}"
);
}
assert!(
screen.contains("#2 Task A") && screen.contains("#3 Task B"),
"the children must be listed by the pane:\n{screen}"
);
assert!(
!screen.contains("#4 Nested"),
"the pane must not list a grandchild as a direct child:\n{screen}"
);
let _ = rendered;
}
#[test]
fn t019_nfr003_the_rollup_is_equal_across_every_read_path() {
use assert_cmd::Command;
let (_tmp, dir) = make_hierarchy_fixture();
let ok = |args: &[&str]| -> String {
let out = Command::new(tix_bin())
.current_dir(&dir)
.env("NO_COLOR", "1")
.env("TERM", "dumb")
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
};
let show = ok(&["show", "1"]);
let parent_show = ok(&["parent", "show", "1"]);
let list = ok(&["list", "--all", "--show", "progress"]);
let completed = |text: &str| -> Option<String> {
text.lines()
.find_map(|l| {
l.trim_start()
.trim_start_matches("- ")
.strip_prefix("completed: ")
})
.and_then(|rest| rest.split_whitespace().next())
.map(str::to_string)
};
let listed = |text: &str, number: u32| -> Option<String> {
text.lines().skip(1).find_map(|l| {
let mut cells = l.split_whitespace();
let num = cells.next()?;
if num.parse::<u32>().ok()? != number {
return None;
}
cells.find(|t| t.contains('/')).map(str::to_string)
})
};
assert_eq!(
completed(&parent_show).as_deref(),
Some("1/2"),
"`tix parent show` must report the rollup:\n{parent_show}"
);
assert_eq!(
completed(&show).as_deref(),
Some("1/2"),
"`tix show` must agree:\n{show}"
);
assert_eq!(
listed(&list, 1).as_deref(),
Some("1/2"),
"`tix list --show progress` must agree:\n{list}"
);
assert_eq!(
listed(&list, 2).as_deref(),
Some("0/1"),
"the intermediate parent's own rollup must be there too:\n{list}"
);
}
#[test]
fn t019_nfr003_the_rollup_is_stable_across_repeated_reads() {
let (tmp, _dir) = make_hierarchy_fixture();
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let (issues, done_column) = store.graph_source().unwrap();
let index = crate::commands::parent::index_over(&issues);
let first = crate::hierarchy::rollup(&index, 1, &done_column);
for _ in 0..16 {
assert_eq!(
crate::hierarchy::rollup(&index, 1, &done_column),
first,
"NFR-003: the rollup must not drift between reads"
);
}
assert_eq!(
first.direct_children, 2,
"the fixture really has two children"
);
assert_eq!(
first.completed_children, 1,
"and exactly one of them is done"
);
assert_eq!(first.progress.done, 1);
assert_eq!(first.progress.total, 2);
assert_eq!(first.parent, 1);
}
#[test]
fn t019_nfr003_a_custom_completion_column_is_resolved_through_config() {
let (tmp, _dir) = make_hierarchy_fixture();
let config_path = tmp.path().join("tix.toml");
let mut renamed = Config::load_with_warning(&config_path, |_| {}).unwrap();
let mut board = renamed.default_config().clone();
board.columns = vec!["todo".into(), "shipped".into()];
board.done_column = Some("shipped".into());
for configuration in renamed.kanban.configurations.values_mut() {
*configuration = board.clone();
}
renamed.save(&config_path).unwrap();
let renamed = Config::load_with_warning(&config_path, |_| {}).unwrap();
let done_column = renamed.default_config().effective_done_column();
assert_eq!(done_column, Some("shipped"), "the rename must take effect");
let store = crate::storage::Store::open(&renamed).unwrap();
let (issues, _) = store.graph_source().unwrap();
let index = crate::commands::parent::index_over(&issues);
let r = crate::hierarchy::rollup(&index, 1, done_column.unwrap());
assert_eq!(r.completed_children, 0, "no child is in `shipped`");
assert_eq!(r.progress.total, 2);
let literal = crate::hierarchy::rollup(&index, 1, "done");
assert_eq!(literal.completed_children, 1, "the parameter decides");
}
fn focus_field(app: &mut App, target: crate::ui::form::FormFocus) {
for _ in 0..crate::ui::form::FormFocus::RING.len() {
if app.form.as_ref().map(|f| f.focus) == Some(target) {
return;
}
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(
app.form.as_ref().map(|f| f.focus),
Some(target),
"the form's focus ring must reach {target:?}"
);
}
fn focus_submitting_field(app: &mut App) {
focus_field(app, crate::ui::form::FormFocus::Title);
}
fn focus_parent(app: &mut App) {
focus_field(app, crate::ui::form::FormFocus::Parent);
}
#[test]
fn t027_the_parent_picker_sets_the_chosen_parent_and_it_persists() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-epic.md", 7, "Epic", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Child".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
focus_parent(&mut app);
assert_eq!(
app.form.as_ref().map(|f| f.parent.clone()),
Some(String::new())
);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let chosen = app
.form
.as_ref()
.map(|f| f.parent.clone())
.expect("the form is still open");
assert!(
!chosen.is_empty(),
"confirming a picker option must set the field"
);
assert!(
!chosen.contains(','),
"a parent is one number, never a list (C-001): got {chosen:?}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Child")
.expect("the created issue");
assert_eq!(
created.parent,
Some(crate::id::IssueRef::Number(7)),
"the created issue must store the chosen parent"
);
let events = created.history.events();
let parent_events = events
.iter()
.filter(|e| e.kind == crate::model::HistoryEventKind::ParentSet)
.count();
assert_eq!(
parent_events, 1,
"exactly one parent_set entry, written with the create: {events:?}"
);
let detail = events
.iter()
.find(|e| e.kind == crate::model::HistoryEventKind::ParentSet)
.map(|e| e.detail.as_str())
.unwrap_or_default();
assert!(
detail.contains("#7"),
"the entry must name the parent, using the service's spelling: {detail:?}"
);
}
#[test]
fn t027_cancelling_the_parent_picker_leaves_the_field_empty_and_the_issue_parentless() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-epic.md", 7, "Epic", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Child".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
focus_parent(&mut app);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(
app.form.as_ref().map(|f| f.parent.clone()),
Some(String::new()),
"cancelling must not choose anything"
);
assert!(matches!(app.mode, Mode::Form), "the form is still open");
focus_submitting_field(&mut app);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Child")
.expect("the created issue");
assert_eq!(created.parent, None, "an unset field means no parent");
let events = created.history.events();
assert!(
!events
.iter()
.any(|e| e.kind == crate::model::HistoryEventKind::ParentSet),
"and nothing is recorded, because nothing was set: {events:?}"
);
}
#[test]
fn t027_a_rejected_parent_keeps_the_form_open_and_shows_the_services_own_message() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Child".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
focus_parent(&mut app);
app.form.as_mut().expect("the form is open").parent = "999".to_string();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Form),
"a rejected parent must not close the form"
);
assert_eq!(
app.form.as_ref().map(|f| f.parent.clone()),
Some("999".to_string()),
"and must not clear the field, or the user loses their choice with no way back"
);
assert_eq!(
app.store.list().unwrap().len(),
0,
"a refused parent creates nothing at all — not a file, not a number"
);
let msg = app.status_msg.clone().expect("a message must be shown");
let expected = app
.store
.resolve_issue("999")
.expect_err("999 is not in the fixture, so resolution must fail")
.to_string();
assert_eq!(
msg, expected,
"the TUI must show exactly what the shared path shows for the same input"
);
assert!(
msg.contains("#999"),
"and it must name the parent it rejected: {msg}"
);
}
#[test]
fn t027_enter_on_the_parent_field_opens_the_picker_until_a_parent_is_chosen() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-epic.md", 7, "Epic", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Child".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
focus_parent(&mut app);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Form),
"Enter on an unchosen Parent opens the picker; it must not submit the form"
);
assert!(
app.store.list().unwrap().iter().all(|i| i.title != "Child"),
"and nothing may be created by that keystroke"
);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_ne!(app.form.as_ref().expect("form").parent.len(), 0);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"a chosen parent must not trap the submit key"
);
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Child")
.expect("the created issue");
assert_eq!(created.parent, Some(crate::id::IssueRef::Number(7)));
}
#[test]
fn t027_the_parent_field_refuses_typed_characters_through_real_keys() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Child".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
focus_parent(&mut app);
for c in "999".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(
app.form.as_ref().map(|f| f.parent.clone()),
Some(String::new()),
"a parent is picked, not typed: the service validates by resolved number, so \
free text here could only ever be a value that fails at submit"
);
assert_eq!(
app.form.as_ref().map(|f| f.title.clone()),
Some("Child".to_string())
);
}
#[test]
fn t027_picking_twice_still_leaves_exactly_one_parent() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 3, "Alpha", "todo");
write_issue(&dir, "2026-01-01-b.md", 5, "Bravo", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Child".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
focus_parent(&mut app);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let after_first = app.form.as_ref().expect("form").parent.clone();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let after_second = app.form.as_ref().expect("form").parent.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(!after_first.is_empty(), "the first pick must land");
assert!(
!after_second.contains(','),
"two picks must not concatenate into a list: {after_second:?}"
);
assert!(
["3", "5"].contains(&after_second.as_str()),
"the field must hold one of the two real issues, not a blend: {after_second:?}"
);
focus_submitting_field(&mut app);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|i| i.title == "Child")
.expect("the created issue");
assert_eq!(
created.parent,
Some(after_second.parse().expect("a number"))
);
}
fn mapped_fixture() -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let dir = root.join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"triage".into(),
"wip".into(),
"review".into(),
"shipped".into(),
"abandoned".into(),
],
done_column: Some("shipped".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("review".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
("abandoned".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
layout: crate::config::LayoutConfig {
move_on_change: false,
..Default::default()
},
..Config::default()
};
let config_path = root.join("tix.toml");
config.save(&config_path).unwrap();
(tmp, root, dir)
}
fn mapped_app(status: &str) -> (TempDir, App) {
let (tmp, root, dir) = mapped_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", status);
let config = Config::load_with_warning(&root.join("tix.toml"), |_| {}).unwrap();
let mut app = App::new(&config).unwrap();
app.board = crate::ui::board::BoardState::build(&app.store, config.default_config());
app.board.select_column(0);
app.board.selected = 0;
(tmp, app)
}
fn history_of_issue_1(dir: &std::path::Path) -> Vec<String> {
let text = fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap();
text.lines()
.skip_while(|l| *l != "## History")
.filter(|l| l.starts_with("- "))
.map(|l| {
let parts: Vec<&str> = l.trim_start_matches("- ").split(" \u{2014} ").collect();
format!("{} | {}", parts[2], parts[3])
})
.collect()
}
fn status_of_issue_1(dir: &std::path::Path) -> String {
let raw = fs::read_to_string(dir.join("2026-01-01-a.md"))
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("status: "))
.unwrap()
.trim()
.to_string();
raw.trim_matches('"').to_string()
}
#[test]
fn t025_a_backwards_keyboard_move_warns_and_is_not_applied() {
let (_tmp, mut app) = mapped_app("wip");
let dir = app.store.issues_dir.clone();
let before = fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap();
app.board.select_column(1);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::TransitionReason { .. }),
"a refused transition must reach the override prompt, got {:?}",
app.mode
);
assert_eq!(
status_of_issue_1(&dir),
"wip",
"and must not be applied while the prompt is open"
);
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap(),
before,
"the file is untouched: no status, no date, no History"
);
assert!(
history_of_issue_1(&dir).is_empty(),
"and no decision was recorded for a move that did not happen"
);
}
#[test]
fn t025_the_override_prompt_shows_the_shared_service_diagnostic() {
let (_tmp, mut app) = mapped_app("wip");
app.board.select_column(1);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
let crate::app::Mode::TransitionReason { reason, .. } = &app.mode else {
panic!("expected the override prompt, got {:?}", app.mode);
};
assert!(
reason.contains("invalid_transition"),
"the shared diagnostic, verbatim: {reason}"
);
assert!(
reason.contains("not permitted by the category policy"),
"and the rule it names: {reason}"
);
}
#[test]
fn t025_typing_a_reason_and_confirming_applies_the_move_and_records_it() {
let (_tmp, mut app) = mapped_app("shipped");
let dir = app.store.issues_dir.clone();
app.board.select_column(3);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::TransitionReason { .. }),
"leaving a terminal column prompts, got {:?}",
app.mode
);
for c in "reopening by hand".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::Board),
"the prompt closes, got {:?}",
app.mode
);
assert_eq!(status_of_issue_1(&dir), "review", "the move applied");
let history = history_of_issue_1(&dir);
assert_eq!(history.len(), 1, "exactly one decision: {history:?}");
assert!(
history[0].contains("override: reopening by hand"),
"and the reason is in the audit trail, or the override is unexplained: {history:?}"
);
assert!(
history[0].starts_with("transition_accepted"),
"recorded as the same event kind the CLI writes: {history:?}"
);
}
#[test]
fn t025_a_blank_reason_is_refused_and_the_prompt_stays_open() {
let (_tmp, mut app) = mapped_app("wip");
let dir = app.store.issues_dir.clone();
app.board.select_column(1);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
for c in " ".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::TransitionReason { .. }),
"the prompt stays open, got {:?}",
app.mode
);
assert_eq!(status_of_issue_1(&dir), "wip", "and nothing moved");
}
#[test]
fn t025_cancelling_the_override_prompt_changes_nothing() {
let (_tmp, mut app) = mapped_app("wip");
let dir = app.store.issues_dir.clone();
let before = fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap();
app.board.select_column(1);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::Board),
"back to the board"
);
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap(),
before,
"a cancelled prompt is byte-identical: no write, no append, no date"
);
}
#[test]
fn t025_a_same_column_keyboard_move_is_inert() {
let (_tmp, mut app) = mapped_app("wip");
let dir = app.store.issues_dir.clone();
let before = fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap();
app.board.select_column(1);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('='))).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::Board),
"no prompt for a no-op move, got {:?}",
app.mode
);
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap(),
before
);
}
#[test]
fn t025_a_forward_keyboard_move_applies_without_prompting() {
let (_tmp, mut app) = mapped_app("triage");
let dir = app.store.issues_dir.clone();
app.board.select_column(0);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::Board),
"a permitted move does not prompt, got {:?}",
app.mode
);
assert_eq!(status_of_issue_1(&dir), "wip");
assert_eq!(history_of_issue_1(&dir).len(), 1, "one decision recorded");
}
fn split_completion_fixture() -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let dir = root.join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".into(), "shipped".into(), "done".into()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::from([
("todo".to_string(), "unstarted".to_string()),
("shipped".to_string(), "completed".to_string()),
("done".to_string(), "active".to_string()),
]),
..Default::default()
},
)]),
},
layout: crate::config::LayoutConfig {
move_on_change: false,
..Default::default()
},
..Config::default()
};
let config_path = root.join("tix.toml");
config.save(&config_path).unwrap();
(tmp, root, dir)
}
fn split_completion_app(status: &str) -> (TempDir, App) {
let (tmp, root, dir) = split_completion_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", status);
let config = Config::load_with_warning(&root.join("tix.toml"), |_| {}).unwrap();
let mut app = App::new(&config).unwrap();
app.board = crate::ui::board::BoardState::build(&app.store, config.default_config());
app.board.selected = 0;
(tmp, app)
}
#[test]
fn t025_the_card_completion_uses_the_service_not_the_done_column_name() {
let (_tmp, app) = split_completion_app("shipped");
let card = app
.board
.column_cards(1)
.first()
.expect("`shipped` is column 1");
assert_eq!(
card.category.as_deref(),
Some("Done"),
"`shipped` is `completed`"
);
assert!(
card.done,
"so the card is complete — while a comparison against `done_column` (= \
`done`) would say it is not. The two disagree here, which is why the \
fixture exists."
);
}
#[test]
fn t025_a_complete_card_is_not_marked_overdue() {
let (_tmp, app) = split_completion_app("shipped");
let mut dated = app.board.column_cards(1)[0].clone();
dated.due_date = Some("2000-01-01".to_string());
let rendered: String = crate::ui::widgets::card_lines(&dated, 80, false, 3)
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.chars().collect::<String>())
.collect();
assert!(
!rendered.contains("[overdue]"),
"a complete issue is not overdue, whatever the column is called: {rendered}"
);
}
#[test]
fn t025_the_commit_prompt_fires_for_a_service_complete_column() {
let (_tmp, mut app) = split_completion_app("todo");
app.board.select_column(0);
app.board.selected = 0;
let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert!(
matches!(app.mode, crate::app::Mode::MoveIntoDone { .. }),
"reaching completion prompts for a commit even though `shipped` is not the \
`done_column`, got {:?}",
app.mode
);
}
#[test]
fn t025_the_board_card_shows_the_category_label() {
let (_tmp, app) = mapped_app("triage");
let card = app.board.column_cards(0)[0].clone();
assert_eq!(
card.category.as_deref(),
Some("Backlog"),
"the service's own label for `backlog`"
);
let lines = crate::ui::widgets::card_lines(&card, 60, false, 3);
let rendered: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.chars().collect::<String>())
.collect();
assert!(
rendered.contains("[Backlog]"),
"the card carries the label as text: {rendered}"
);
}
#[test]
fn t025_the_label_is_present_without_a_color_hint() {
let (text, _style) = crate::ui::widgets::category_token(Some("In progress"), None);
assert_eq!(text, "[In progress]", "the label is unconditional");
let (tinted, _s) = crate::ui::widgets::category_token(
Some("In progress"),
Some(crate::workflow::ColorHint::Accent),
);
assert_eq!(
text, tinted,
"the hint changes only the style, never the text"
);
}
#[test]
fn t025_an_unmapped_column_shows_unmapped_rather_than_an_error() {
let (_tmp, app) = mapped_app("not-a-column");
let card = &app.board.mismatch.cards[0];
assert_eq!(card.category, None, "no category is invented");
let (text, _s) = crate::ui::widgets::category_token(card.category.as_deref(), None);
assert_eq!(text, "[unmapped]", "and the chip says so");
}
#[test]
fn t025_the_detail_view_shows_the_same_label_as_the_card() {
let (_tmp, mut app) = mapped_app("wip");
let dir = app.store.issues_dir.clone();
app.board.select_column(1);
app.board.selected = 0;
open_detail(&mut app);
let detail = app.detail.as_ref().expect("Open detail opens the pane");
assert_eq!(
detail.category.as_ref().map(|(label, _)| label.as_str()),
Some("In progress"),
"and it is the service's label for `active`"
);
assert_eq!(
app.board.column_cards(1)[0].category.as_deref(),
Some("In progress"),
"the board card and the detail view agree, on the same board, in the same \
session (fixture: {})",
dir.display()
);
let rows = render_rows_in(&app, 80, 40);
let rendered = rows.join("\n");
assert!(
rendered.contains("category:") && rendered.contains("In progress"),
"the detail view renders the category row:\n{rendered}"
);
}
use crate::bulk::PatchValue;
use crate::ui::bulk::{BatchAction, BulkFocus, BulkModal, BulkState, MAX_BATCH_SELECTION};
fn make_batch_fixture() -> (TempDir, PathBuf) {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma", "in progress");
write_issue(&dir, "2026-01-01-d.md", 4, "Delta", "in review");
(tmp, dir)
}
fn type_row(app: &mut App, row: usize, text: &str) {
let rows = modal(app).row_names().len();
for _ in 0..rows {
if modal(app).cursor == row {
break;
}
app.handle_key(press(KeyCode::Tab)).unwrap();
}
assert_eq!(
modal(app).cursor,
row,
"the cursor reached the row under test"
);
app.handle_key(press(KeyCode::Enter)).unwrap(); assert_eq!(modal(app).focus, BulkFocus::Edit);
for c in text.chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap(); assert_eq!(modal(app).focus, BulkFocus::Row, "the edit was committed");
}
fn marked(app: &App) -> Vec<u32> {
app.bulk
.as_ref()
.map(|m| m.selection.numbers().to_vec())
.unwrap_or_default()
}
fn modal(app: &App) -> &BulkModal {
app.bulk.as_ref().expect("the batch modal should be open")
}
fn issue_facts(dir: &std::path::Path) -> Vec<(u32, String, String)> {
let mut facts = Vec::new();
for path in collect_md(dir) {
let text = fs::read_to_string(&path).unwrap();
let number = text
.lines()
.find_map(|l| l.strip_prefix("number: "))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
let status = text
.lines()
.find_map(|l| l.strip_prefix("status: "))
.map(|v| v.trim().trim_matches('"').to_string())
.unwrap_or_default();
let title = text
.lines()
.find_map(|l| l.strip_prefix("title: "))
.map(|v| v.trim().trim_matches('"').to_string())
.unwrap_or_default();
facts.push((number, status, title));
}
facts.sort();
facts
}
fn apply_now(app: &mut App) {
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(modal(app).is_armed(), "the first y only arms");
app.handle_key(press(KeyCode::Char('y'))).unwrap();
}
#[test]
fn space_marks_the_card_under_the_cursor_without_opening_the_modal() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
assert!(matches!(app.mode, Mode::Board));
app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"marking is a board gesture, not a modal: {:?}",
app.mode
);
assert_eq!(marked(&app), vec![1], "the card under the cursor is marked");
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("1 selected"),
"the status line names the count: {status:?}"
);
}
#[test]
fn the_first_space_is_not_silent_because_the_status_line_names_the_count() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
let rows = render_rows(&app).join("\n");
assert!(
rows.contains("1 selected"),
"marking is visible on the very first press:\n{rows}"
);
}
#[test]
fn a_second_space_on_the_same_card_unmarks_it() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert!(
marked(&app).is_empty(),
"`space` toggles the mark, so a second press removes it: {:?}",
marked(&app)
);
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("unmarked"),
"the removal says so rather than doing nothing: {status:?}"
);
}
#[test]
fn the_selection_keeps_the_order_the_user_marked_in() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.select_column(2); app.board.selected = 0;
app.handle_key(press(KeyCode::Char(' '))).unwrap(); app.board.select_column(0); app.board.selected = 1; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 0; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
assert_eq!(
marked(&app),
vec![4, 2, 1],
"the selection keeps the order the user marked, not numeric and not board order"
);
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
let order: Vec<u32> = match &modal(&app).state {
BulkState::Preview(plan) => plan.items.iter().map(|i| i.number).collect(),
other => panic!("expected a preview, got {other:?}"),
};
assert_eq!(
order,
vec![4, 2, 1],
"the plan reads in the order the user marked, not numeric and not board order"
);
}
#[test]
fn x_unmarks_a_card_and_reports_when_the_card_was_not_selected() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1; app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert_eq!(marked(&app), vec![1, 2]);
app.board.select_column(1); app.board.selected = 0;
app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert_eq!(marked(&app), vec![1, 2], "x left the marked cards alone");
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("#3 was not marked"),
"unmarking a card that was not marked names the card: {status:?}"
);
app.board.select_column(0);
app.board.selected = 1; app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert_eq!(marked(&app), vec![1], "x on a selected card removes it");
}
#[test]
fn x_uppercase_empties_the_whole_selection() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert_eq!(marked(&app).len(), 2);
app.handle_key(press(KeyCode::Char('X'))).unwrap();
assert!(
marked(&app).is_empty(),
"X empties the selection, got {:?}",
marked(&app)
);
}
#[test]
fn the_hint_bar_names_the_marked_count_and_the_clear_key() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
let rows = render_rows(&app).join("\n");
assert!(
rows.contains("2 marked"),
"the count is in the bar:\n{rows}"
);
assert!(
rows.contains("clear"),
"the clear-all key is announced once a card is marked:\n{rows}"
);
}
#[test]
fn bulk_move_moves_every_marked_issue_and_keeps_the_selection() {
use crate::app::MoveTarget;
use crate::storage::IssueSelector;
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.start_move(MoveTarget::Named("in progress".into()))
.unwrap();
for number in [1, 2] {
let issue = app.store.get(IssueSelector::Number(number)).unwrap();
assert_eq!(
issue.status.to_string(),
"in progress",
"every marked issue moved"
);
}
assert_eq!(
marked(&app),
vec![1, 2],
"the selection survives the move, so the next action covers the same set"
);
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("Moved 2 of 2"),
"the batch reports its outcome: {status:?}"
);
}
#[test]
fn bulk_comment_applies_to_every_marked_issue() {
use crate::storage::IssueSelector;
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.run_issue_action(IssueAction::Comment).unwrap();
for c in "ship it".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
for number in [1, 2] {
let issue = app.store.get(IssueSelector::Number(number)).unwrap();
assert!(
issue.comments().iter().any(|c| c.text.trim() == "ship it"),
"#{number} has the comment"
);
}
}
#[test]
fn bulk_parent_sets_the_parent_on_every_marked_issue() {
use crate::storage::IssueSelector;
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.run_issue_action(IssueAction::SetParent).unwrap();
for c in "3".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
for number in [1, 2] {
let issue = app.store.get(IssueSelector::Number(number)).unwrap();
assert_eq!(
issue.parent.and_then(|parent| parent.as_number()),
Some(3),
"#{number}'s parent was set"
);
}
}
#[test]
fn bulk_restore_restores_every_file_the_prompt_names() {
use crate::storage::IssueSelector;
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
let first = app.store.get(IssueSelector::Number(1)).unwrap().filename;
let second = app.store.get(IssueSelector::Number(2)).unwrap().filename;
app.store.archive(&first).unwrap();
app.store.archive(&second).unwrap();
app.refresh_corpus().unwrap();
app.mode = Mode::ConfirmArchive {
filenames: vec![first, second],
restore: true,
};
app.handle_key(press(KeyCode::Char('y'))).unwrap();
for number in [1, 2] {
assert!(
app.store.get(IssueSelector::Number(number)).is_ok(),
"#{number} is active again"
);
}
}
#[test]
fn bulk_health_records_the_same_state_on_every_marked_issue() {
use crate::app::stats_health::HealthForm;
use crate::storage::IssueSelector;
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.bulk_action_targets = Some(vec![1, 2]);
app.health_form = Some(HealthForm::new(1));
app.mode = Mode::HealthForm;
for c in "on_track".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Tab)).unwrap(); app.handle_key(press(KeyCode::Tab)).unwrap(); for c in "sweep".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
for number in [1, 2] {
let issue = app.store.get(IssueSelector::Number(number)).unwrap();
assert_eq!(
issue
.health
.as_ref()
.and_then(|health| health.state.clone()),
Some("on_track".to_string()),
"#{number} carries the recorded state"
);
}
}
#[test]
fn a_selection_past_the_bound_is_refused_and_the_message_carries_the_bound() {
let mut selection = crate::ui::bulk::BulkSelection::new();
for number in 1..=MAX_BATCH_SELECTION as u32 {
selection
.toggle(number)
.expect("up to the bound every toggle succeeds");
}
assert_eq!(selection.len(), MAX_BATCH_SELECTION);
let problem = selection
.toggle(9999)
.expect_err("the toggle past the bound must be refused");
assert_eq!(problem.as_str(), "selection_full");
let message = problem.to_string();
assert!(
message.contains(&MAX_BATCH_SELECTION.to_string()),
"the refusal names the bound, so a user can act on it: {message}"
);
assert_eq!(
selection.len(),
MAX_BATCH_SELECTION,
"a refused toggle changes nothing"
);
}
#[test]
fn esc_closes_the_modal_and_the_board_is_exactly_as_it_was() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.select_column(1); app.board.selected = 0;
let before_cursor = (app.board.active_col, app.board.selected);
let before_files = issue_facts(&dir);
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
assert!(matches!(app.mode, Mode::BulkModal));
type_row(&mut app, 0, "done");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(modal(&app).is_armed(), "armed before the cancel");
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"one esc closes the modal, as it does for every other modal here"
);
assert!(
app.bulk.is_none(),
"and the half-built selection goes with it"
);
assert_eq!(
(app.board.active_col, app.board.selected),
before_cursor,
"T031: the board cursor is untouched by the modal"
);
assert_eq!(issue_facts(&dir), before_files, "and nothing was written");
}
#[test]
fn esc_closes_without_a_confirm_prompt_because_nothing_was_written() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(
!app.quit,
"esc inside the modal closes the modal, not the application"
);
}
#[test]
fn a_tui_row_and_a_cli_flag_produce_the_same_patch_value() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
assert_eq!(
modal(&app).patch().unwrap().status,
PatchValue::Set("done".to_string()),
"a TUI row produces the typed value the CLI's --status would"
);
}
#[test]
fn a_blank_tui_row_is_unchanged_never_clear_and_never_set_to_empty() {
let mut modal = BulkModal::new(BatchAction::Edit);
for row in 0..modal.row_names().len() {
modal.set_row(row, String::new()).unwrap();
}
assert_eq!(
modal.flags.status, None,
"a blank row stores no flag at all"
);
assert_eq!(modal.flags.estimate, None);
assert_eq!(modal.flags.labels, None);
assert_eq!(modal.flags.clear.len(), 0);
let patch = modal.patch().unwrap();
assert_eq!(patch.status, PatchValue::Unchanged);
assert_eq!(patch.estimate, PatchValue::Unchanged);
assert_eq!(patch.assignee, PatchValue::Unchanged);
assert_eq!(
patch.labels,
PatchValue::Unchanged,
"a blank labels row is Unchanged, not Clear"
);
assert!(
patch.is_empty(),
"a patch of all-unchanged asks for nothing, and says so — which is why a \
preview with no field set is a refusal rather than a no-op write"
);
}
#[test]
fn clearing_a_field_and_then_typing_it_is_absent_again_not_clear() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
let labels_row = modal(&app)
.row_names()
.iter()
.position(|n| *n == "labels")
.unwrap();
type_row(&mut app, labels_row, "bug");
assert_eq!(
modal(&app).patch().unwrap().labels,
PatchValue::Set(vec!["bug".to_string()])
);
app.handle_key(press(KeyCode::Enter)).unwrap();
for _ in 0..3 {
app.handle_key(press(KeyCode::Backspace)).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
modal(&app).patch().unwrap().labels,
PatchValue::Unchanged,
"backspacing the last character away leaves the field **absent**, not Clear"
);
assert_eq!(
modal(&app).row_value(labels_row),
"—",
"and the row reads blank"
);
type_row(&mut app, labels_row, "bug, wip ,");
assert_eq!(
modal(&app).patch().unwrap().labels,
PatchValue::Set(vec!["bug".to_string(), "wip".to_string()]),
"the CLI's list rule applies: split on `,`, trim each, drop the empty ones"
);
}
#[test]
fn the_modal_holds_the_cli_flag_struct_and_not_a_built_patch() {
let modal = BulkModal::new(BatchAction::Edit);
let flags: &crate::commands::bulk::PatchFlags = &modal.flags;
assert!(
flags.status.is_none() && flags.priority.is_none() && flags.clear.is_empty(),
"a fresh modal's flags are the CLI's, and they start empty"
);
assert_eq!(
modal.patch().unwrap(),
crate::commands::bulk::PatchFlags::default()
.to_patch()
.expect("an empty patch is a patch object, not an error"),
"an empty modal's patch is the CLI's empty patch, not a TUI-local one"
);
}
#[test]
fn a_preview_writes_no_file() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
let before = issue_facts(&dir);
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
assert!(matches!(modal(&app).state, BulkState::Preview(_)));
assert_eq!(
issue_facts(&dir),
before,
"a preview is read-only: no status, no title, no file moved"
);
}
#[test]
fn a_preview_with_nothing_to_change_says_so_rather_than_faking_a_change() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
let before = issue_facts(&dir);
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Char('p'))).unwrap();
let body = modal(&app).body();
assert!(
body.contains("no fields to change") && body.contains("name at least one of"),
"a plan that changes nothing is refused with the fields it would accept: {body}"
);
assert!(
!body.contains("nothing written"),
"and it is not dressed up as a preview: {body}"
);
assert_eq!(issue_facts(&dir), before);
}
#[test]
fn a_pair_without_a_target_is_refused_by_name() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert_eq!(modal(&app).action, BatchAction::Link);
let error = modal(&app).patch_for().expect_err("a pair needs a target");
let message = error.to_string();
assert!(
message.contains("target"),
"the refusal names the row that is empty: {message}"
);
}
#[test]
fn a_kind_the_link_service_will_not_accept_is_refused() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('a'))).unwrap();
app.bulk.as_mut().unwrap().target = Some("2".to_string());
app.bulk.as_mut().unwrap().kind = Some("best-friends".to_string());
let error = modal(&app)
.relationship_for(&[1], None)
.expect_err("the link service's own vocabulary is the gate");
let message = error.to_string();
assert!(
message.contains("blocks"),
"the refusal lists the kinds the service accepts: {message}"
);
}
#[test]
fn a_pair_targets_the_card_the_user_is_standing_on_and_the_row_stays_editable() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0; app.board.select_column(2); app.board.selected = 0;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Char('a'))).unwrap(); app.handle_key(press(KeyCode::Char('t'))).unwrap();
assert_eq!(
modal(&app).target.as_deref(),
Some("4"),
"`t` takes the cursor's card"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
app.handle_key(press(KeyCode::Backspace)).unwrap();
app.handle_key(press(KeyCode::Char('2'))).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
modal(&app).target.as_deref(),
Some("2"),
"and the row overrides the `t` default"
);
}
#[test]
fn an_archive_modal_has_no_field_rows_because_a_restore_needs_a_target() {
let modal = BulkModal::new(BatchAction::Archive);
assert!(
modal.row_names().is_empty(),
"archive names no target status, so it has no rows — a restore field here \
would offer the request #190's C-003 does not allow"
);
assert!(
modal.body().contains("the selection is the whole request"),
"and the modal says so rather than showing an empty field list:\n{}",
modal.body()
);
}
#[test]
fn a_pair_has_a_target_and_a_kind_row_and_no_field_rows() {
assert_eq!(
BulkModal::new(BatchAction::Link).row_names(),
vec!["target", "kind"]
);
assert_eq!(
BulkModal::new(BatchAction::Unlink).row_names(),
vec!["target", "kind"]
);
let edit = BulkModal::new(BatchAction::Edit).row_names();
assert_eq!(edit.last(), Some(&"clear"), "clear is the last row");
assert!(
!edit.contains(&"target") && !edit.contains(&"kind"),
"a field patch and a pair are one-of, so they share no rows: {edit:?}"
);
}
#[test]
fn a_clear_row_names_a_field_only_once_and_refuses_a_second() {
let mut modal = BulkModal::new(BatchAction::Edit);
let clear_row = modal
.row_names()
.iter()
.position(|name| *name == "clear")
.expect("an edit modal has a clear row");
modal.set_row(clear_row, "labels".to_string()).unwrap();
assert_eq!(modal.flags.clear, vec!["labels".to_string()]);
let error = modal
.set_row(clear_row, "assignee".to_string())
.expect_err("one batch clears one field");
assert!(
error.to_string().contains("one batch clears one field"),
"the refusal states the rule: {error}"
);
assert_eq!(
modal.flags.clear,
vec!["labels".to_string()],
"a refused second clear leaves the first in place"
);
modal.set_row(clear_row, "labels".to_string()).unwrap();
assert_eq!(modal.flags.clear, vec!["labels".to_string()]);
}
#[test]
fn clearing_a_field_the_contract_does_not_own_is_refused() {
let mut modal = BulkModal::new(BatchAction::Edit);
let clear_row = modal
.row_names()
.iter()
.position(|n| *n == "clear")
.unwrap();
let error = modal
.set_row(clear_row, "title".to_string())
.expect_err("title is not a batch-cleared field");
let message = error.to_string();
assert!(
message.contains("clearable fields"),
"the refusal names what *is* clearable: {message}"
);
assert_eq!(modal.flags.clear.len(), 0);
}
fn cursor_to(app: &mut App, row: usize) {
let rows = modal(app).row_names().len();
for _ in 0..rows {
if modal(app).cursor == row {
break;
}
app.handle_key(press(KeyCode::Tab)).unwrap();
}
assert_eq!(
modal(app).cursor,
row,
"the cursor reached the row under test"
);
}
#[test]
fn c_names_the_row_for_clear_instead_of_setting_it_to_its_own_name() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
cursor_to(&mut app, 1); app.handle_key(press(KeyCode::Char('c'))).unwrap();
let m = modal(&app);
assert_eq!(
m.flags.clear,
vec!["priority".to_string()],
"`c` empties the field; it does not write the field's own name into it"
);
assert!(
m.flags.priority.is_none(),
"clearing and setting are two different flags: priority = {:?}",
m.flags.priority
);
app.handle_key(press(KeyCode::Char('p'))).unwrap();
assert_eq!(
modal(&app).state_label(),
"preview — nothing written",
"naming a clearable field previews instead of refusing"
);
}
#[test]
fn c_on_the_status_row_is_refused_and_the_refusal_lists_what_is_clearable() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
assert_eq!(modal(&app).row_name(0), "status");
app.handle_key(press(KeyCode::Char('c'))).unwrap();
let m = modal(&app);
let note = m.note.clone().unwrap_or_default();
assert!(
note.contains("cannot clear 'status'"),
"the refusal says what it refused: {note:?}"
);
assert!(
note.contains("clearable fields"),
"and it names what *is* clearable instead of only what is not: {note:?}"
);
assert!(m.flags.status.is_none(), "status is untouched");
assert!(m.flags.clear.is_empty(), "nothing was named either");
}
#[test]
fn c_on_the_clear_row_with_nothing_named_says_so_instead_of_naming_an_em_dash() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
let clear_row = modal(&app)
.row_names()
.iter()
.position(|n| *n == "clear")
.expect("an edit modal has a clear row");
cursor_to(&mut app, clear_row);
app.handle_key(press(KeyCode::Char('c'))).unwrap();
let m = modal(&app);
let note = m.note.clone().unwrap_or_default();
assert!(
!note.contains("cannot clear"),
"the em dash the renderer shows for an absent value is not a field name: {note:?}"
);
assert!(
note.contains("no field is named"),
"an empty `clear` row says so rather than refusing silently: {note:?}"
);
assert_eq!(m.flags.clear.len(), 0);
}
#[test]
fn c_on_a_pair_says_a_pair_has_no_clearable_fields() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Char('a'))).unwrap(); assert_eq!(modal(&app).action, BatchAction::Link);
app.handle_key(press(KeyCode::Char('c'))).unwrap();
let m = modal(&app);
let note = m.note.clone().unwrap_or_default();
assert!(
note.contains("no clearable fields"),
"a pair has a target and a kind, not fields to empty: {note:?}"
);
assert!(
m.target.is_none(),
"`c` never writes the row's own name into `target`: {:?}",
m.target
);
assert_eq!(m.flags.clear.len(), 0);
}
#[test]
fn the_edit_footer_names_the_c_key() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
let footer = modal(&app).footer();
assert!(
footer.contains("c clear field"),
"a key nobody can see is a key nobody tests: {footer:?}"
);
}
#[test]
fn a_preview_names_every_selected_issue_and_says_nothing_was_written() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
let frame = render_rows_in(&app, 120, 40).join("\n");
for expected in ["#1", "#2", "in review", "nothing written"] {
assert!(
frame.contains(expected),
"the preview shows {expected:?}:\n{frame}"
);
}
}
#[test]
fn one_y_arms_and_the_second_y_applies() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
let armed_by = issue_facts(&dir);
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(
modal(&app).is_armed(),
"the first y arms: {:?}",
modal(&app).state_label()
);
assert_eq!(
issue_facts(&dir),
armed_by,
"arming writes nothing — the write is the second y"
);
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert_eq!(issue_facts(&dir)[0].1, "in review", "the second y applies");
assert!(
matches!(app.mode, Mode::Board),
"a completed apply closes the modal so the board shows the result"
);
}
#[test]
fn a_cancelled_armed_preview_leaves_state_and_files_unchanged() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(modal(&app).is_armed());
let armed = issue_facts(&dir);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(
issue_facts(&dir),
armed,
"cancelling the armed state writes nothing"
);
assert!(matches!(app.mode, Mode::Board), "and closes the modal");
assert!(app.bulk.is_none(), "the plan goes with it");
}
#[test]
fn changing_a_field_after_a_preview_refuses_to_apply_the_old_plan() {
let (tmp, dir) = make_batch_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
let store = crate::storage::Store::open(&config).unwrap();
let error = {
let modal = app.bulk.as_mut().unwrap();
modal.set_row(0, "in review".to_string()).unwrap();
modal
.confirm(&store)
.expect_err("a stale plan must not be applied")
};
let message = error.to_string();
assert!(
message.contains("changed since the preview"),
"the refusal says the plan is stale: {message}"
);
assert_eq!(
issue_facts(&dir)[0].1,
"todo",
"and the refused confirm wrote nothing"
);
}
#[test]
fn changing_the_selection_after_a_preview_also_stales_it() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.board.selected = 1;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert!(modal(&app).plan_stale, "marking after a preview stales it");
let before = issue_facts(&dir);
let store = crate::storage::Store::open(&load_config(&tmp)).unwrap();
let error = app
.bulk
.as_mut()
.unwrap()
.confirm(&store)
.expect_err("a stale plan must not be applied");
assert!(error.to_string().contains("changed since the preview"));
assert_eq!(issue_facts(&dir), before);
}
#[test]
fn a_result_names_each_item_its_outcome_and_closes() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
apply_now(&mut app);
let facts = issue_facts(&dir);
for number in [1u32, 2] {
assert_eq!(
facts
.iter()
.find(|(n, _, _)| *n == number)
.map(|(_, s, _)| s.as_str()),
Some("in review"),
"#{number} moved; the whole board: {facts:?}"
);
}
assert_eq!(
facts
.iter()
.find(|(n, _, _)| *n == 3)
.map(|(_, s, _)| s.as_str()),
Some("in progress"),
"an unmarked issue is untouched: {facts:?}"
);
let status = app.status_msg.clone().unwrap_or_default();
assert!(!status.is_empty(), "a completed batch reports something");
}
#[test]
fn an_archive_batch_applies_and_reports_each_issue() {
let (tmp, dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.board.selected = 1; app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
for _ in 0..3 {
app.handle_key(press(KeyCode::Char('a'))).unwrap();
}
assert_eq!(modal(&app).action, BatchAction::Archive);
apply_now(&mut app);
assert!(
matches!(app.mode, Mode::Board),
"an applied archive closes the modal"
);
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("selection"),
"the status line reports the batch: {status}"
);
let archived: Vec<String> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.filter(|name| name.starts_with('.'))
.collect();
assert_eq!(
archived.len(),
2,
"two files gained the marker: {archived:?}"
);
let facts = issue_facts(&dir);
for number in 1..=4u32 {
assert!(
facts.iter().any(|(n, _, _)| *n == number),
"issue {number} survives the archive: {facts:?}"
);
}
}
#[test]
fn a_refusal_can_be_retried_after_a_re_preview() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Char('p'))).unwrap();
assert!(matches!(modal(&app).state, BulkState::Refused(_)));
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
assert!(
matches!(modal(&app).state, BulkState::Preview(_)),
"a corrected request previews: {:?}",
modal(&app).state_label()
);
}
#[test]
fn an_edit_to_the_selection_after_a_refusal_re_previews_cleanly() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
assert_eq!(marked(&app), vec![1]);
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
assert!(
matches!(modal(&app).state, BulkState::Preview(_)),
"marking a card and filling a field turns the ask into a plan: {:?}",
modal(&app).state_label()
);
}
#[test]
fn an_unbound_key_while_editing_is_typed_and_never_executed() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(modal(&app).focus, BulkFocus::Edit);
for c in "inprogress".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert!(
matches!(modal(&app).state, BulkState::Selecting),
"`p` inside a field typed a p; it did not preview: {:?}",
modal(&app).state_label()
);
assert_eq!(modal(&app).draft, "inprogress");
}
#[test]
fn abandoning_a_half_typed_field_restores_the_value_it_had() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
assert_eq!(modal(&app).row_value(0), "done");
app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "XYZ".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert_eq!(modal(&app).draft, "doneXYZ");
app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(
modal(&app).focus,
BulkFocus::Row,
"esc leaves the edit, not the modal"
);
assert_eq!(
modal(&app).row_value(0),
"done",
"an abandoned edit does not commit the half-typed value"
);
assert_eq!(
modal(&app).draft,
"",
"and the draft is discarded rather than left where only `begin_edit` can \
clear it"
);
assert!(
matches!(app.mode, Mode::BulkModal),
"and the modal is still open"
);
}
#[test]
fn backspace_edits_the_draft_and_not_the_committed_value() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "donee".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(modal(&app).draft, "done");
assert_eq!(
modal(&app).row_value(0),
"—",
"the committed value is untouched"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(modal(&app).row_value(0), "done");
}
#[test]
fn a_space_typed_into_a_field_is_kept_rather_than_marking_a_card() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
assert_eq!(
marked(&app),
vec![1],
"typing a space did not mark a second card"
);
assert_eq!(
modal(&app).row_value(0),
"in review",
"and the space is in the value"
);
}
#[test]
fn the_cursor_wraps_over_the_rows_and_stops_on_an_action_with_none() {
let mut modal = BulkModal::new(BatchAction::Edit);
let count = modal.row_names().len();
assert!(count > 1);
for _ in 0..count {
modal.move_cursor(1);
}
assert_eq!(modal.cursor, 0, "the cursor wraps forward");
modal.move_cursor(-1);
assert_eq!(modal.cursor, count - 1, "and backward");
let mut archive = BulkModal::new(BatchAction::Archive);
assert_eq!(archive.row_names().len(), 0);
archive.move_cursor(1);
archive.move_cursor(-1);
assert_eq!(
archive.cursor, 0,
"an action with no rows has no cursor to move"
);
}
#[test]
fn cycling_the_action_keeps_the_selection_and_the_fields() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert_eq!(modal(&app).action, BatchAction::Link);
assert_eq!(
marked(&app),
vec![1],
"the selection survives the verb change"
);
assert_eq!(
modal(&app).flags.status.as_deref(),
Some("done"),
"and so does the typed field — asserted on the flags, because row 0 is now \
the pair's `target` and reading row 0 would be reading a different field"
);
assert_eq!(
modal(&app).row_names(),
vec!["target", "kind"],
"the rows changed with the verb"
);
for _ in 0..3 {
app.handle_key(press(KeyCode::Char('a'))).unwrap();
}
assert_eq!(
modal(&app).action,
BatchAction::Edit,
"`a` is a repeatable cycle"
);
}
#[test]
fn the_cycle_discards_a_shown_plan_so_a_stale_confirm_is_impossible() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
assert!(modal(&app).is_armed());
app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(
!modal(&app).is_armed(),
"changing the verb disarms: the armed plan was for a different action"
);
assert!(
modal(&app).plan_stale,
"and the plan is stale, so `y` cannot apply the old one"
);
}
#[test]
fn the_modal_renders_at_normal_and_tiny_sizes_without_panicking() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "in review");
app.handle_key(press(KeyCode::Char('p'))).unwrap();
app.handle_key(press(KeyCode::Char('y'))).unwrap();
for (width, height) in [(80u16, 24u16), (120, 40), (20, 5)] {
let rows = render_rows_in(&app, width, height);
assert_eq!(
rows.len(),
height as usize,
"the buffer is {width}x{height}"
);
}
let rendered = render_rows_in(&app, 120, 40).join("\n");
assert!(
rendered.contains("in review"),
"the preview is on screen at a size that can show it:\n{rendered}"
);
}
#[test]
fn the_modal_renders_in_edit_mode_with_the_draft_visible() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "don".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let rendered = render_rows_in(&app, 120, 40).join("\n");
assert!(
rendered.contains("don"),
"the half-typed draft is on screen, so the user can see what they are \
editing:\n{rendered}"
);
assert!(
rendered.contains("enter commit"),
"and the footer names the keys that mean something while editing:\n{rendered}"
);
}
#[test]
fn t031_audit_the_existing_board_navigation_is_unchanged() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.selected = 0;
app.handle_key(press(KeyCode::Char('B'))).unwrap();
assert!(
matches!(app.mode, Mode::BulkModal),
"`B` is the board's entry point to the batch surface, got {:?}",
app.mode
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board), "and `esc` comes back");
app.board.select_column(0);
app.handle_key(press_char('l')).unwrap();
assert_eq!(app.board.active_col, 1, "`l` still moves right");
app.handle_key(press_char('h')).unwrap();
assert_eq!(app.board.active_col, 0, "`h` still moves left");
app.board.selected = 0;
app.handle_key(press_char('j')).unwrap();
assert_eq!(app.board.selected, 1, "`j` still moves down");
app.handle_key(press_char('k')).unwrap();
assert_eq!(app.board.selected, 0, "`k` still moves up");
app.handle_key(press_char('d')).unwrap();
assert!(
matches!(app.mode, Mode::ConfirmDelete),
"`d` still opens the confirm-delete prompt, got {:?}",
app.mode
);
app.handle_key(press(KeyCode::Esc)).unwrap();
app.board.select_column(0);
app.board.selected = 0;
app.handle_key(press_char('>')).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"`>` into a non-terminal column just moves, got {:?}",
app.mode
);
app.board.select_column(2); app.board.selected = 0;
app.handle_key(press_char('>')).unwrap();
assert!(
matches!(app.mode, Mode::MoveIntoDone { .. }),
"`>` still prompts into a terminal column, got {:?}",
app.mode
);
}
#[test]
fn t031_audit_marking_never_moves_the_cursor() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.board.select_column(0);
app.board.selected = 1;
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('x'))).unwrap();
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert_eq!(
(app.board.active_col, app.board.selected),
(0, 1),
"marking and unmarking are board-level, and neither writes the cursor"
);
}
#[test]
fn the_footer_names_only_the_keys_that_do_something_in_the_current_state() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
type_row(&mut app, 0, "done");
let choosing = modal(&app).footer();
assert!(
choosing.contains("enter edit") && choosing.contains("p preview"),
"choosing: the footer names the edit and preview keys: {choosing}"
);
app.handle_key(press(KeyCode::Char('p'))).unwrap();
let preview = modal(&app).footer();
assert!(
preview.contains("y to confirm") && !preview.contains("a action"),
"preview: the footer drops the keys that do nothing here: {preview}"
);
app.handle_key(press(KeyCode::Char('y'))).unwrap();
let armed = modal(&app).footer();
assert!(
armed.contains("APPLIES THIS WRITE"),
"armed: the footer says the write is next: {armed}"
);
}
#[test]
fn the_hint_bar_is_capped_so_the_batch_keys_live_in_the_help_overlay() {
let (tmp, _dir) = make_batch_fixture();
let app = App::new(&load_config(&tmp)).unwrap();
let rows = render_rows_in(&app, 200, 50);
let hint_line = rows
.iter()
.find(|line| line.contains("move up/down") || line.contains("new issue"))
.expect("the board hint bar is rendered somewhere in the frame");
assert!(
hint_line.trim().chars().count() <= 80,
"the hint line is capped at 80: {:?}",
hint_line.trim()
);
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.unwrap();
let help = crate::ui::commandbar::help_overlay(&bindings, crate::ui::nav::TopMode::Board);
for expected in ["mark for batch", "unmark for batch", "batch select"] {
assert!(
help.iter()
.any(|(_, description, _)| description == expected),
"the help overlay names {expected:?}, or the key is undiscoverable: {help:?}"
);
}
}
#[test]
fn the_help_overlay_covers_the_batch_surface() {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.unwrap();
let rows = crate::ui::commandbar::help_overlay(&bindings, crate::ui::nav::TopMode::Board);
assert!(
rows.iter()
.any(|(_, _description, mode)| mode == "bulk_modal"),
"the help overlay has a bulk_modal section:\n{rows:?}"
);
}
#[test]
fn a_clear_row_refusal_stays_on_the_modal_instead_of_ending_the_session() {
let (tmp, _dir) = make_batch_fixture();
let mut app = App::new(&load_config(&tmp)).unwrap();
app.handle_key(press(KeyCode::Char(' '))).unwrap();
app.handle_key(press(KeyCode::Char('B'))).unwrap();
let clear_row = modal(&app)
.row_names()
.iter()
.position(|n| *n == "clear")
.expect("an edit modal has a clear row");
cursor_to(&mut app, clear_row);
app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "title".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let action = app
.handle_key(press(KeyCode::Enter))
.expect("a refused clear must not end the session");
assert!(matches!(action, Action::None));
assert!(
matches!(app.mode, Mode::BulkModal),
"the modal is still up with the refusal on it"
);
let note = modal(&app).note.clone().unwrap_or_default();
assert!(
note.contains("cannot clear 'title'"),
"the refusal is readable where it happened: {note:?}"
);
assert!(modal(&app).flags.clear.is_empty(), "nothing was named");
}
use std::path::Path;
use crate::ui::triage::{DecisionField, TriageControl};
const TRIAGE_BOARD: &str = r#"issues_dir = "ISSUES_DIR"
[kanban.default]
columns = ["triage", "todo", "in progress", "in review", "done", "wontfix"]
done_column = "done"
category_map = { triage = "backlog", todo = "unstarted", "in progress" = "active", "in review" = "active", done = "completed", wontfix = "canceled" }
"#;
const TRIAGE_SECTION: &str = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\naccepted_status = \"todo\"\n";
fn triage_repo(section: &str) -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let dir = root.join("issues");
fs::create_dir_all(&dir).unwrap();
let config = TRIAGE_BOARD.replace("ISSUES_DIR", &dir.display().to_string());
fs::write(
root.join("tix.toml"),
format!("{config}{section}\n[layout]\nmove_on_change = false\n"),
)
.unwrap();
(tmp, root, dir)
}
fn write_triage_issue(
dir: &Path,
file: &str,
number: u32,
title: &str,
status: &str,
entered_at: Option<&str>,
) {
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if let Some(at) = entered_at {
text.push_str(&format!("entered_at: {at}\nsource: test\n"));
}
text.push_str("---\n\nBody text.");
fs::write(dir.join(file), text).unwrap();
}
fn triage_inbox_repo() -> (TempDir, PathBuf, PathBuf) {
let (tmp, root, dir) = triage_repo(TRIAGE_SECTION);
write_triage_issue(
&dir,
"2026-01-01-alpha.md",
1,
"Alpha waits",
"triage",
Some("2020-01-01T00:00:00Z"),
);
write_triage_issue(
&dir,
"2026-01-01-bravo.md",
2,
"Bravo legacy",
"triage",
None,
);
write_triage_issue(
&dir,
"2026-01-01-charlie.md",
3,
"Charlie recent",
"triage",
Some("2026-09-30T00:00:00Z"),
);
write_triage_issue(
&dir,
"2026-01-01-delta.md",
4,
"Delta outside",
"in review",
Some("2020-01-01T00:00:00Z"),
);
(tmp, root, dir)
}
fn triage_app(root: &Path) -> App {
let config = Config::load_with_warning(&root.join("tix.toml"), |_| {}).unwrap();
App::new(&config).unwrap()
}
fn open_inbox(app: &mut App) {
app.handle_key(press_char('t')).unwrap();
assert!(
matches!(app.mode, Mode::TriageInbox),
"`t` opens the inbox, got {:?}",
app.mode
);
}
fn triage_state(app: &App) -> &crate::ui::triage::TriageState {
app.triage.as_ref().expect("the inbox is open")
}
fn triage_modal(app: &App) -> &crate::ui::triage::TriageModal {
triage_state(app)
.modal
.as_ref()
.expect("a decision modal is open")
}
fn triage_screen(app: &App) -> String {
render_rows_in(app, 160, 40).join("\n")
}
fn fill_field(app: &mut App, field: DecisionField, text: &str) {
let index = triage_modal(app)
.fields
.iter()
.position(|f| *f == field)
.unwrap_or_else(|| {
panic!(
"a {} decision has no {} row: {:?}",
triage_modal(app).kind,
field.label(triage_modal(app).kind),
triage_modal(app).fields
)
});
for _ in 0..=triage_modal(app).fields.len() {
if !triage_modal(app).ok_focused && triage_modal(app).cursor == index {
break;
}
app.handle_key(press(KeyCode::Tab)).unwrap();
}
assert!(
!triage_modal(app).ok_focused && triage_modal(app).cursor == index,
"the cursor reached the row"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(triage_modal(app).editing, "`enter` begins the edit");
let draft_len = triage_modal(app).draft.chars().count();
for _ in 0..draft_len {
app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in text.chars() {
app.handle_key(press_char(c)).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(!triage_modal(app).editing, "the edit was committed");
}
fn decide_in_tui(app: &mut App, key: char, fields: &[(DecisionField, &str)]) {
app.handle_key(press_char(key)).unwrap();
for (field, text) in fields {
fill_field(app, *field, text);
}
app.handle_key(press_char('p')).unwrap();
assert!(
matches!(
triage_modal(app).state,
crate::ui::triage::ModalState::Preview(_)
),
"`p` previews, it does not write: {:?}",
triage_modal(app).state
);
app.handle_key(press_char('y')).unwrap();
assert!(triage_modal(app).is_armed(), "the first `y` only arms");
app.handle_key(press_char('y')).unwrap();
assert!(
triage_state(app).modal.is_none(),
"the second `y` applies and closes the modal"
);
}
fn tree_snapshot(root: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
out.push((path.clone(), fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
fn status_value(path: &Path) -> String {
fs::read_to_string(path)
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("status: "))
.unwrap_or_default()
.trim()
.trim_matches('"')
.to_string()
}
fn history_lines(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.unwrap()
.lines()
.skip_while(|l| *l != "## History")
.filter(|l| l.starts_with("- "))
.map(|l| {
let parts: Vec<&str> = l.trim_start_matches("- ").split(" \u{2014} ").collect();
parts[1..].join(" \u{2014} ")
})
.collect()
}
fn tix_ok(root: &Path, args: &[&str]) -> String {
let out = std::process::Command::new(tix_bin())
.current_dir(root)
.env("NO_COLOR", "1")
.env("TERM", "dumb")
.args(args)
.output()
.expect("the tix binary runs");
assert!(
out.status.success(),
"tix {args:?} exited {:?}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn tix_code(root: &Path, args: &[&str], want: i32) -> (String, String) {
let out = std::process::Command::new(tix_bin())
.current_dir(root)
.env("NO_COLOR", "1")
.env("TERM", "dumb")
.args(args)
.output()
.expect("the tix binary runs");
assert_eq!(
out.status.code(),
Some(want),
"tix {args:?} exited {:?}, wanted {want}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn triage_tui_the_inbox_opens_on_t_and_esc_leaves_the_board_where_it_was() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
app.board.select_column(1);
app.board.selected = 0;
let board_before = (app.board.active_col, app.board.selected);
let before = tree_snapshot(&root);
open_inbox(&mut app);
assert_eq!(
triage_state(&app).rows.len(),
3,
"the queue is the inbox entries, not every card: Delta is in `in review`"
);
assert_eq!(
before,
tree_snapshot(&root),
"opening the inbox reads the tree, it never writes to it"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"`esc` returns to the board"
);
assert!(app.triage.is_none(), "the view is dropped, not hidden");
assert_eq!(
(app.board.active_col, app.board.selected),
board_before,
"the board, its tab and its card cursor are exactly where they were"
);
}
#[test]
fn triage_tui_without_a_triage_section_t_is_refused_and_the_board_does_not_move() {
let (_tmp, root, dir) = triage_repo("");
write_triage_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "triage", None);
let mut app = triage_app(&root);
let before = tree_snapshot(&root);
app.handle_key(press_char('t')).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"a project that does not use triage does not get an inbox, got {:?}",
app.mode
);
assert!(app.triage.is_none());
let note = app.status_msg.clone().unwrap_or_default();
assert!(
note.contains("[triage]") && note.contains("tix.toml"),
"the refusal names the section that is missing rather than being silent: {note:?}"
);
assert_eq!(before, tree_snapshot(&root), "and it wrote nothing");
app.handle_key(press_char('r')).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn triage_tui_the_inbox_cursor_and_mark_are_not_the_boards() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
app.board.select_column(1);
app.board.selected = 0;
open_inbox(&mut app);
app.handle_key(press_char('j')).unwrap();
app.handle_key(press_char(' ')).unwrap();
assert_eq!(triage_state(&app).cursor, 1, "the inbox has its own cursor");
assert_eq!(
triage_state(&app).selection.numbers(),
&[2],
"and its own selection, marking the row under that cursor"
);
assert_eq!(
(app.board.active_col, app.board.selected),
(1, 0),
"while the board's cursor has not moved at all"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(
(app.board.active_col, app.board.selected),
(1, 0),
"and closing brings the board back untouched — T029 from the other side"
);
}
#[test]
fn triage_tui_space_toggles_a_mark_and_x_no_longer_clears_the_set() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char(' ')).unwrap();
assert_eq!(triage_state(&app).selection.numbers(), &[1]);
app.handle_key(press_char(' ')).unwrap();
assert!(
triage_state(&app).selection.is_empty(),
"pressing space on a marked issue unmarks it"
);
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('X')).unwrap();
assert_eq!(
triage_state(&app).selection.numbers(),
&[1],
"X has no clear-selection behavior"
);
assert!(
!triage_state(&app).footer().contains("X clear"),
"the footer does not advertise a removed key"
);
}
#[test]
fn triage_tui_the_boards_own_keys_still_work_after_the_inbox_closes() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press(KeyCode::Esc)).unwrap();
let selected_before = app.board.selected;
app.handle_key(press_char('j')).unwrap();
assert_ne!(
app.board.selected, selected_before,
"the card cursor still moves on the board"
);
app.handle_key(press_char(' ')).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"and `space` marks on the board rather than opening the inbox: {:?}",
app.mode
);
assert_eq!(marked(&app).len(), 1, "the card under the cursor is marked");
}
#[test]
fn triage_tui_the_inbox_counts_are_the_clis_counts_for_the_same_snapshot() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let summary = triage_state(&app).summary.clone();
let count = tix_ok(&root, &["triage", "list", "--count"]);
assert_eq!(
summary.header(),
count.lines().next().unwrap(),
"the inbox header is the CLI's own line"
);
let status = tix_ok(&root, &["triage", "status"]);
let figure = |label: &str| -> usize {
status
.lines()
.find_map(|l| l.trim().strip_prefix(label))
.unwrap_or_else(|| panic!("`tix triage status` reports {label:?}:\n{status}"))
.trim()
.parse()
.unwrap()
};
assert_eq!(summary.total, 3, "Three entries; Delta is not one");
assert_eq!(summary.awaiting, figure("awaiting:"));
assert_eq!(summary.deferred, figure("deferred:"));
assert_eq!(summary.accepted, figure("accepted:"));
assert_eq!(summary.declined, figure("declined:"));
assert_eq!(summary.duplicated, figure("duplicated:"));
assert_eq!(summary.unknown_age, figure("unknown age:"));
let cli = status
.lines()
.find_map(|l| l.trim().strip_prefix("age: "))
.expect("`tix triage status` prints the histogram");
let cli_buckets: Vec<&str> = cli.split(", ").collect();
let tui = summary.age_line();
let tui_buckets: Vec<&str> = tui.trim_start_matches("age: ").split(" · ").collect();
assert_eq!(tui_buckets, cli_buckets, "one histogram: {tui}");
assert_eq!(
summary.unknown_age, 1,
"Bravo has no entry stamp, and the inbox says so instead of calling it today"
);
let list = tix_ok(&root, &["triage", "list"]);
let mut cli_rows: Vec<String> = list
.lines()
.skip(2) .map(str::to_string)
.collect();
cli_rows.sort();
let body = triage_state(&app).body();
let mut tui_rows: Vec<String> = body
.lines()
.filter(|line| line.chars().nth(2).is_some_and(|c| c.is_ascii_digit()))
.map(|line| line.chars().skip(2).collect())
.collect();
tui_rows.sort();
assert_eq!(
tui_rows, cli_rows,
"the inbox's rows are the CLI's rows:\n{body}\n---\n{list}"
);
}
#[test]
fn triage_tui_a_queue_row_shows_the_age_and_the_last_recorded_decision() {
let (_tmp, root, dir) = triage_inbox_repo();
let path = dir.join("2026-01-01-charlie.md");
let text = fs::read_to_string(&path).unwrap().replace(
"Body text.",
"## History\n\n- 2026-09-29T10:00:00Z \u{2014} alice \u{2014} triage_deferred \u{2014} \
triage -> triage; source: defer; reason: later\n\nBody text.",
);
fs::write(&path, text).unwrap();
let mut app = triage_app(&root);
open_inbox(&mut app);
let rows = &triage_state(&app).rows;
let alpha = rows
.iter()
.find(|r| r.issue.number == 1)
.expect("Alpha is in the queue");
assert!(
alpha.age_days.is_some_and(|days| days > 365),
"a 2020 entry stamp is an age, not a guess: {:?}",
alpha.age_days
);
assert!(
matches!(
alpha.last_decision,
crate::triage::history::LastDecision::None
),
"nothing has decided Alpha"
);
let charlie = rows
.iter()
.find(|r| r.issue.number == 3)
.expect("Charlie is in the queue");
match &charlie.last_decision {
crate::triage::history::LastDecision::Readable(record) => {
assert_eq!(record.kind.as_str(), "triage_deferred");
assert_eq!(record.result, "triage");
assert_eq!(record.reason.as_deref(), Some("later"));
assert_eq!(record.actor, "alice");
}
other => panic!(
"the row names the recorded event, its result, its reason and who \
decided, so Charlie's decision must parse: {other:?}"
),
}
assert_eq!(
triage_state(&app)
.rows
.iter()
.find(|r| r.issue.number == 2)
.and_then(|r| r.age_days),
None,
"Bravo never recorded entering, and the inbox does not invent a stamp"
);
app.handle_key(press_char('j')).unwrap();
app.handle_key(press_char('j')).unwrap();
assert_eq!(
triage_state(&app).current().map(|r| r.issue.number),
Some(3),
"the cursor is on Charlie"
);
let screen = triage_screen(&app);
for expected in [
"inbox: 3 entries",
"#3 \"Charlie recent\" \u{2014} triage",
"last decision: triage_deferred -> triage \u{2014} later (alice, 2026-09-29)",
"2 unknown Bravo legacy",
] {
assert!(
screen.contains(expected),
"the inbox renders {expected:?}:\n{screen}"
);
}
}
#[test]
fn triage_tui_a_long_queue_title_wraps_under_the_title_column() {
let (_tmp, root, dir) = triage_repo(TRIAGE_SECTION);
write_triage_issue(
&dir,
"2026-01-01-long.md",
1,
"A deliberately long queue title that wraps under its title column",
"triage",
Some("2020-01-01T00:00:00Z"),
);
write_triage_issue(
&dir,
"2026-01-01-short.md",
2,
"Short neighbor",
"triage",
Some("2020-01-01T00:00:00Z"),
);
let mut app = triage_app(&root);
open_inbox(&mut app);
let rendered = render_rows_in(&app, 72, 24);
let first = rendered
.iter()
.find(|line| line.contains("A deliberately long queue title"))
.unwrap_or_else(|| panic!("the first title line is rendered:\n{}", rendered.join("\n")));
let continuation = rendered
.iter()
.find(|line| line.contains("wraps under its title column"))
.unwrap_or_else(|| panic!("the wrapped title is rendered:\n{}", rendered.join("\n")));
let title_column = first.find("A deliberately").unwrap();
assert_eq!(
continuation.find("wraps under").unwrap(),
title_column,
"the continuation starts directly under the first title character"
);
assert!(
first.contains("> 1"),
"the cursor remains on the issue's numbered first line: {first:?}"
);
assert!(
rendered
.iter()
.any(|line| line.contains(" 2 ") && line.contains("Short neighbor")),
"the next issue keeps its own row:\n{}",
rendered.join("\n")
);
}
#[test]
fn triage_tui_a_toggles_oldest_first_and_an_unknown_age_stays_last() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
assert_eq!(
triage_state(&app).rows.first().map(|r| r.issue.number),
Some(1),
"the default order is the store's"
);
app.handle_key(press_char('A')).unwrap();
assert!(triage_state(&app).by_age, "`A` is the TUI's `--age`");
let order: Vec<u32> = triage_state(&app)
.rows
.iter()
.map(|r| r.issue.number)
.collect();
assert_eq!(
order.first(),
Some(&1),
"oldest first: Alpha entered in 2020"
);
assert_eq!(
order.last(),
Some(&2),
"and the entry nobody can age is last, not sorted as 'today': {order:?}"
);
assert_eq!(triage_state(&app).rows.len(), 3, "no row was dropped");
app.handle_key(press_char('A')).unwrap();
assert!(!triage_state(&app).by_age, "`A` toggles back");
}
#[test]
fn triage_tui_a_decline_without_a_reason_is_refused_with_the_clis_kind() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let before = tree_snapshot(&root);
app.handle_key(press_char('d')).unwrap();
app.handle_key(press(KeyCode::Tab)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
app.handle_key(press_char('p')).unwrap();
let body = triage_modal(&app).body();
assert!(
body.contains("missing_reason") && body.contains("requires a reason"),
"the contract's own kind and message, not a TUI rewording:\n{body}"
);
let (stdout, _stderr) = tix_code(&root, &["triage", "decline", "1"], 1);
assert!(
stdout.contains("missing_reason"),
"the CLI names the kind too: {stdout}"
);
app.handle_key(press_char('y')).unwrap();
app.handle_key(press_char('y')).unwrap();
assert_eq!(
before,
tree_snapshot(&root),
"a refused item is not written, and it does not take its neighbours with it"
);
assert!(
triage_state(&app)
.current()
.is_some_and(|r| r.issue.number == 1),
"the queue is unchanged"
);
}
#[test]
fn triage_tui_a_decline_lands_the_same_state_and_event_as_the_cli() {
let (_tui_tmp, tui_root, tui_dir) = triage_inbox_repo();
let mut app = triage_app(&tui_root);
open_inbox(&mut app);
decide_in_tui(
&mut app,
'd',
&[
(DecisionField::Reason, "no budget"),
(DecisionField::Actor, "tester"),
],
);
let (_cli_tmp, cli_root, cli_dir) = triage_inbox_repo();
let out = tix_ok(
&cli_root,
&[
"triage",
"decline",
"1",
"--reason",
"no budget",
"--actor",
"tester",
],
);
let tui_path = tui_dir.join("2026-01-01-alpha.md");
let cli_path = cli_dir.join("2026-01-01-alpha.md");
assert_eq!(
status_value(&tui_path),
status_value(&cli_path),
"the resulting state is the service's answer, whichever surface asked"
);
assert_eq!(status_value(&tui_path), "wontfix");
assert_eq!(
history_lines(&tui_path),
history_lines(&cli_path),
"one event, the same kind and the same detail on both surfaces"
);
assert_eq!(history_lines(&tui_path).len(), 1, "exactly one event");
assert!(
out.contains("triage_declined"),
"the CLI reports the event it wrote: {out}"
);
let report = triage_state(&app).display_note().unwrap_or_default();
assert!(
report.contains("applied") && report.contains("wontfix"),
"the TUI reports the outcome and where the issue landed:\n{report}"
);
}
#[test]
fn triage_tui_a_decline_writes_only_on_the_second_y() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let before = tree_snapshot(&root);
let path = dir.join("2026-01-01-alpha.md");
app.handle_key(press_char('d')).unwrap();
fill_field(&mut app, DecisionField::Reason, "no budget");
fill_field(&mut app, DecisionField::Actor, "tester");
app.handle_key(press_char('p')).unwrap();
assert_eq!(
before,
tree_snapshot(&root),
"a preview is read-only, and this is the assertion that makes it so"
);
assert!(
matches!(
triage_modal(&app).state,
crate::ui::triage::ModalState::Preview(_)
),
"the plan is on screen and nothing is armed"
);
app.handle_key(press_char('y')).unwrap();
assert!(triage_modal(&app).is_armed(), "the first `y` arms");
assert_eq!(before, tree_snapshot(&root), "arming is still not a write");
app.handle_key(press_char('y')).unwrap();
assert!(triage_state(&app).modal.is_none());
assert_eq!(
status_value(&path),
"wontfix",
"the second `y` is the write and closes the modal"
);
assert_eq!(history_lines(&path).len(), 1, "one event, not two");
assert_eq!(
status_value(&dir.join("2026-01-01-charlie.md")),
"triage",
"and the other queue entries were not touched"
);
}
#[test]
fn triage_tui_cancelling_a_modal_or_the_inbox_leaves_every_file_byte_identical() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
let before = tree_snapshot(&root);
open_inbox(&mut app);
app.handle_key(press_char('d')).unwrap();
app.handle_key(press(KeyCode::Tab)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "half a reason".chars() {
app.handle_key(press_char(c)).unwrap();
}
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
!triage_modal(&app).editing,
"esc while editing reverts the draft"
);
assert_eq!(
triage_modal(&app).reason,
"",
"the row never saw the abandoned draft"
);
fill_field(&mut app, DecisionField::Reason, "no budget");
app.handle_key(press_char('p')).unwrap();
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
triage_state(&app).modal.is_none(),
"esc drops the modal and the plan with it"
);
assert_eq!(before, tree_snapshot(&root), "a cancel is not a write");
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.triage.is_none());
assert_eq!(
before,
tree_snapshot(&root),
"and neither is closing the view"
);
}
#[test]
fn triage_tui_an_edit_after_the_preview_makes_the_confirmation_refuse() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let before = tree_snapshot(&root);
app.handle_key(press_char('d')).unwrap();
fill_field(&mut app, DecisionField::Reason, "no budget");
fill_field(&mut app, DecisionField::Actor, "tester");
app.handle_key(press_char('p')).unwrap();
assert!(!triage_modal(&app).plan_stale);
fill_field(&mut app, DecisionField::Reason, " changed");
assert!(triage_modal(&app).plan_stale, "the plan is labelled stale");
let body = triage_modal(&app).body();
assert!(
body.contains("press p to re-preview"),
"the modal says the plan needs re-previewing:\n{body}"
);
app.handle_key(press_char('y')).unwrap();
let body = triage_modal(&app).body();
assert!(
body.contains("press `p` again") || body.contains("preview again"),
"the refusal says what to do about it:\n{body}"
);
assert!(
!triage_state(&app).modal_finished(),
"and nothing was applied"
);
assert_eq!(before, tree_snapshot(&root), "nothing was written");
}
#[test]
fn triage_tui_accepting_an_entry_moves_it_to_the_target_and_records_the_event() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
decide_in_tui(
&mut app,
'a',
&[
(DecisionField::Actor, "tester"),
(DecisionField::Assignee, "bob"),
],
);
let path = dir.join("2026-01-01-alpha.md");
assert_eq!(
status_value(&path),
"todo",
"the default accepted_status column"
);
let text = fs::read_to_string(&path).unwrap();
assert!(
text.lines()
.any(|line| line.starts_with("assignee:") && line.contains("bob")),
"the optional owner is applied with the accept:\n{text}"
);
assert_eq!(
history_lines(&path),
vec!["tester \u{2014} triage_accepted \u{2014} triage -> todo"],
"one event naming the move"
);
assert!(
triage_state(&app).rows.iter().all(|r| r.issue.number != 1),
"and the queue the modal refreshed no longer lists it"
);
}
#[test]
fn triage_tui_an_action_uses_every_marked_issue_after_a_counted_confirmation() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('j')).unwrap();
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('a')).unwrap();
assert!(
matches!(
triage_modal(&app).scope,
crate::ui::triage::ModalScope::Many(_)
),
"a marked set takes precedence over the cursor row"
);
let question = "Are you sure you want to apply accept to 2 selected issues?";
assert!(
triage_modal(&app).body().contains(question),
"the modal names the operation and count"
);
fill_field(&mut app, DecisionField::Actor, "tester");
app.handle_key(press_char('p')).unwrap();
assert!(
triage_modal(&app).body().contains(question),
"the preview repeats the confirmation"
);
app.handle_key(press_char('y')).unwrap();
assert!(
triage_modal(&app).is_armed(),
"the first `y` only arms the counted operation"
);
assert!(
triage_modal(&app).body().contains(question),
"the armed state repeats the confirmation"
);
app.handle_key(press_char('y')).unwrap();
assert_eq!(status_value(&dir.join("2026-01-01-alpha.md")), "todo");
assert_eq!(status_value(&dir.join("2026-01-01-bravo.md")), "todo");
assert_eq!(
status_value(&dir.join("2026-01-01-charlie.md")),
"triage",
"an unmarked row is not part of the operation"
);
}
#[test]
fn triage_tui_accept_prefills_and_labels_the_decision_fields() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('a')).unwrap();
let modal = triage_modal(&app);
assert_eq!(
modal.fields,
vec![
DecisionField::Target,
DecisionField::Actor,
DecisionField::Reason,
DecisionField::Assignee,
],
"required fields lead and optional fields follow"
);
assert_eq!(modal.target, "todo", "accepted_status is prefilled");
assert!(modal.ok_focused, "OK owns the initial keyboard focus");
assert_eq!(
modal.actor,
crate::storage::actor_identity(),
"the actor is the configured git identity"
);
let body = modal.body();
for label in [
"column (required)",
"actor (required)",
"reason (optional)",
"assignee (optional)",
] {
assert!(body.contains(label), "the modal labels {label}:\n{body}");
}
assert!(
body.contains("todo ▾"),
"the target row is a picker:\n{body}"
);
}
#[test]
fn triage_tui_the_accept_target_is_a_board_column_dropdown() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('a')).unwrap();
app.handle_key(press(KeyCode::Tab)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
let dropdown = triage_state(&app)
.dropdown
.as_ref()
.expect("`enter` on the target row opens a dropdown");
assert_eq!(
dropdown.kind,
crate::ui::triage::TriageDropdownKind::DecisionTarget
);
let labels: Vec<&str> = dropdown
.state
.options
.iter()
.map(|option| option.label.as_str())
.collect();
assert_eq!(
labels,
vec![
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix"
],
"the dropdown offers every active-board column"
);
assert_eq!(
dropdown
.state
.options
.get(dropdown.state.visible_indices()[dropdown.state.cursor])
.map(|option| option.id.as_str()),
Some("todo"),
"todo is preselected"
);
assert!(
render_rows_in(&app, 160, 40)
.join("\n")
.contains("choose column"),
"the picker is rendered as a column dropdown"
);
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(triage_state(&app).dropdown.is_none());
assert_eq!(triage_modal(&app).target, "in progress");
assert!(triage_modal(&app).plan_stale);
app.handle_key(press_char('p')).unwrap();
assert!(
matches!(
triage_modal(&app).state,
crate::ui::triage::ModalState::Preview(_)
),
"the chosen column reaches the plan: {:?}",
triage_modal(&app).state
);
app.handle_key(press_char('y')).unwrap();
app.handle_key(press_char('y')).unwrap();
assert_eq!(
status_value(&dir.join("2026-01-01-alpha.md")),
"in progress",
"the applied accept uses the chosen column"
);
}
#[test]
fn triage_tui_the_ok_button_previews_arms_and_applies_the_decision() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('a')).unwrap();
let area = triage_content_area();
let ok = crate::ui::triage::modal_ok_rect(area);
let (x, y) = (ok.x + 1, ok.y);
let click_ok = |app: &mut App| {
let screen = render_rows_in(app, 160, 40).join("\n");
assert!(
screen.contains("[ OK ]"),
"the button is visible:\n{screen}"
);
let action = app.handle_mouse(mouse_down(x, y)).unwrap();
assert_eq!(action, crate::app::Action::TriageModalOk);
app.apply_triage_action(action).unwrap();
};
click_ok(&mut app);
assert!(
matches!(
triage_modal(&app).state,
crate::ui::triage::ModalState::Preview(_)
),
"the first click previews without writing"
);
assert_eq!(
status_value(&dir.join("2026-01-01-alpha.md")),
"triage",
"preview does not move the issue"
);
click_ok(&mut app);
assert!(
triage_modal(&app).is_armed(),
"the second click arms the reviewed plan"
);
click_ok(&mut app);
assert!(
triage_state(&app).modal.is_none(),
"the third click applies the armed plan and closes the modal"
);
assert_eq!(
status_value(&dir.join("2026-01-01-alpha.md")),
"todo",
"the OK path uses the default destination"
);
}
#[test]
fn triage_tui_enter_on_the_default_ok_button_applies_the_default_decision() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('a')).unwrap();
assert!(triage_modal(&app).ok_focused);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(
triage_modal(&app).state,
crate::ui::triage::ModalState::Preview(_)
),
"Enter on the default OK previews the decision"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
triage_modal(&app).is_armed(),
"the next Enter arms the reviewed plan"
);
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(triage_state(&app).modal.is_none());
assert_eq!(
status_value(&dir.join("2026-01-01-alpha.md")),
"todo",
"the default decision was applied from the keyboard"
);
}
#[test]
fn triage_tui_ok_is_top_positioned_and_highlights_for_keyboard_and_mouse_focus() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('a')).unwrap();
let area = triage_content_area();
let ok = crate::ui::triage::modal_ok_rect(area);
assert!(
ok.y < area.y + area.height / 2,
"OK sits near the top rather than at the bottom: {ok:?}"
);
let button_style = |app: &App| {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(160, 40)).unwrap();
terminal
.draw(|frame| crate::app::render(app, frame, frame.area()))
.unwrap();
terminal.backend().buffer()[(ok.x, ok.y)].style()
};
let focused = button_style(&app);
assert_eq!(focused.bg, Some(ratatui::style::Color::Cyan));
assert!(
focused
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
app.handle_key(press(KeyCode::Tab)).unwrap();
assert!(
!triage_modal(&app).ok_focused && triage_modal(&app).cursor == 0,
"Tab leaves OK for the first field"
);
let unfocused = button_style(&app);
assert_ne!(unfocused.bg, Some(ratatui::style::Color::Cyan));
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: ok.x + 1,
row: ok.y,
modifiers: KeyModifiers::NONE,
})
.unwrap();
let hovered = button_style(&app);
assert_eq!(hovered.bg, Some(ratatui::style::Color::Cyan));
assert!(
hovered
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
app.handle_key(press(KeyCode::BackTab)).unwrap();
assert!(
triage_modal(&app).ok_focused,
"BackTab returns keyboard focus to OK"
);
}
#[test]
fn triage_tui_deferring_keeps_the_entry_in_the_queue_with_a_new_stamp() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('j')).unwrap();
assert_eq!(
triage_state(&app).current().map(|r| r.issue.number),
Some(2)
);
decide_in_tui(
&mut app,
'f',
&[
(DecisionField::Reason, "later"),
(DecisionField::Actor, "tester"),
],
);
let path = dir.join("2026-01-01-bravo.md");
assert_eq!(
status_value(&path),
"triage",
"a defer stays in the inbox; it is not an accept and not a decline"
);
let text = fs::read_to_string(&path).unwrap();
assert!(
text.contains("entered_at: ") && text.contains("source: defer"),
"a defer refreshes the entry stamp and says why:\n{text}"
);
let events = history_lines(&path);
assert_eq!(events.len(), 1, "one event, not a second decision on top");
assert!(events[0].contains("triage_deferred"), "{events:?}");
assert!(
events[0].contains("reason: later"),
"the reason is on the event: {events:?}"
);
assert_eq!(
history_lines(&dir.join("2026-01-01-alpha.md")),
Vec::<String>::new(),
"and the row the cursor was not on was not decided"
);
assert!(
triage_state(&app).rows.iter().any(|r| r.issue.number == 2),
"the refreshed queue still holds it"
);
}
#[test]
fn triage_tui_duplicating_creates_the_typed_relation_and_keeps_both_files() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let source = dir.join("2026-01-01-alpha.md");
let target = dir.join("2026-01-01-bravo.md");
let target_before = fs::read(&target).unwrap();
decide_in_tui(
&mut app,
'u',
&[
(DecisionField::Reason, "same as two"),
(DecisionField::Actor, "tester"),
(DecisionField::DuplicateOf, "2"),
],
);
let written = fs::read_to_string(&source).unwrap();
assert!(
written.contains("kind: duplicates") && written.contains("to: 2"),
"a typed edge, not a merge:\n{written}"
);
assert_eq!(
status_value(&source),
"triage",
"a duplicate decision moves nothing"
);
let events = history_lines(&source);
assert_eq!(
events.len(),
1,
"one event per applied decision: {events:?}"
);
assert!(
events[0].starts_with("tester \u{2014} triage_duplicated \u{2014} triage -> duplicates #2")
&& events[0].contains("target: #2")
&& events[0].contains("reason: same as two"),
"the event names the relation, its target and the reason: {events:?}"
);
assert_eq!(
fs::read(&target).unwrap(),
target_before,
"and the duplicate's target was only read: #190 owns merging"
);
}
#[test]
fn triage_tui_a_batch_preview_reports_every_item_and_a_partial_apply_keeps_its_writes() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let bravo = dir.join("2026-01-01-bravo.md");
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('j')).unwrap();
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('p')).unwrap();
assert!(
matches!(
triage_modal(&app).scope,
crate::ui::triage::ModalScope::Many(_)
),
"`p` over a non-empty selection is the batch modal"
);
app.handle_key(press_char('a')).unwrap();
assert_eq!(triage_modal(&app).kind.as_str(), "decline");
fill_field(&mut app, DecisionField::Reason, "bulk decline");
fill_field(&mut app, DecisionField::Actor, "tester");
let before = tree_snapshot(&root);
app.handle_key(press_char('p')).unwrap();
let plan = triage_modal(&app).body();
assert_eq!(
before,
tree_snapshot(&root),
"the batch preview is read-only too"
);
assert!(
plan.contains("2 of 2 would change") && plan.contains("#1 Alpha waits"),
"one entry per selected issue:\n{plan}"
);
assert!(
plan.contains("history: triage_declined"),
"and the plan names the History event it would write — T032's \"corresponding \
History summary\" at the moment it is still only a proposal:\n{plan}"
);
fs::write(
&bravo,
fs::read_to_string(&bravo)
.unwrap()
.replace("status: \"triage\"", "status: \"done\""),
)
.unwrap();
let bravo_after_our_write = fs::read(&bravo).unwrap();
app.handle_key(press_char('y')).unwrap();
app.handle_key(press_char('y')).unwrap();
assert!(triage_state(&app).modal.is_none());
let report = triage_state(&app).display_note().unwrap_or_default();
assert!(
report.contains("1 of 2 applied") && report.contains("1 refused"),
"the counts name the zeros and the refusal:\n{report}"
);
assert!(
report.contains("not_in_triage"),
"and the refusal is the service's own kind:\n{report}"
);
assert_eq!(
status_value(&dir.join("2026-01-01-alpha.md")),
"wontfix",
"the item that applied kept its write"
);
assert_eq!(
fs::read(&bravo).unwrap(),
bravo_after_our_write,
"and the refused item was not written to, or undone"
);
}
#[test]
fn triage_tui_a_batch_with_nothing_marked_is_refused_and_writes_nothing() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let before = tree_snapshot(&root);
app.handle_key(press_char('p')).unwrap();
assert!(
triage_state(&app).modal.is_none(),
"there is no batch to preview, so there is no modal"
);
let note = triage_state(&app).note.clone().unwrap_or_default();
assert!(
note.contains("mark"),
"the refusal says what to do first: {note:?}"
);
assert_eq!(before, tree_snapshot(&root), "and nothing was written");
}
#[test]
fn triage_tui_cancelling_a_batch_leaves_the_selection_and_the_files_alone() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let before = tree_snapshot(&root);
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('j')).unwrap();
app.handle_key(press_char(' ')).unwrap();
app.handle_key(press_char('p')).unwrap();
app.handle_key(press_char('a')).unwrap();
fill_field(&mut app, DecisionField::Reason, "bulk decline");
app.handle_key(press_char('p')).unwrap();
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(
triage_state(&app).modal.is_none(),
"esc drops the modal and its plan"
);
assert_eq!(
triage_state(&app).selection.numbers(),
&[1, 2],
"the marks survive the cancel, so the user does not re-mark them"
);
assert_eq!(before, tree_snapshot(&root), "a cancel is not a write");
}
#[test]
fn triage_tui_the_help_overlay_covers_the_triage_surface() {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.unwrap();
let rows: Vec<_> = [
crate::ui::nav::TopMode::Board,
crate::ui::nav::TopMode::Triage,
]
.into_iter()
.flat_map(|mode| crate::ui::commandbar::help_overlay(&bindings, mode))
.collect();
assert!(
rows.iter().any(|(_, _, mode)| mode == "triage"),
"the help overlay has a triage section:\n{rows:?}"
);
assert!(
rows.iter()
.any(|(_, description, _)| description == "triage inbox"),
"and the key that opens it is discoverable:\n{rows:?}"
);
assert!(
crate::keybindings::InputContext::Triage.as_str() == "triage",
"the section name is the context's own, not a second spelling"
);
}
fn mouse_down(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
}
}
fn triage_content_area() -> ratatui::layout::Rect {
let nav = crate::ui::nav::NavDisplay::default().width();
ratatui::layout::Rect::new(nav, 0, 160 - nav, 40)
}
#[test]
fn triage_tui_f2_opens_the_mode_and_renders_the_queue() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
app.handle_key(press(KeyCode::F(2))).unwrap();
assert!(
matches!(app.mode, Mode::TriageInbox),
"F2 opens the Triage mode, got {:?}",
app.mode
);
assert_eq!(
app.top_mode,
crate::ui::nav::TopMode::Triage,
"the vertical navigation highlights Triage"
);
let screen = triage_screen(&app);
assert!(
screen.contains("inbox: 3 entries"),
"the queue is the mode content:\n{screen}"
);
assert!(
screen.contains("Alpha waits"),
"one row per awaiting issue:\n{screen}"
);
}
#[test]
fn triage_tui_the_status_picker_offers_exactly_the_board_columns() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('s')).unwrap();
let dropdown = triage_state(&app)
.dropdown
.as_ref()
.expect("`s` opens the status picker");
assert_eq!(
dropdown.kind,
crate::ui::triage::TriageDropdownKind::Status,
"and it is the status picker, not the assignee one"
);
let labels: Vec<&str> = dropdown
.state
.options
.iter()
.map(|option| option.label.as_str())
.collect();
assert_eq!(
labels,
vec![
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix"
],
"the picker is exactly the active board's columns"
);
}
#[test]
fn triage_tui_choosing_a_status_moves_the_focused_issue() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let path = dir.join("2026-01-01-alpha.md");
app.handle_key(press_char('s')).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
triage_state(&app).dropdown.is_none(),
"the picker closes on a choice"
);
assert_eq!(status_value(&path), "todo", "the focused issue moved");
assert!(
triage_state(&app)
.rows
.iter()
.all(|row| row.issue.number != 1),
"and it left the triage queue with the move"
);
}
#[test]
fn triage_tui_the_assignee_picker_offers_storage_folders() {
let (_tmp, root, dir) = triage_inbox_repo();
fs::create_dir_all(dir.join("alice")).unwrap();
fs::create_dir_all(dir.join("bob")).unwrap();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('@')).unwrap();
let dropdown = triage_state(&app)
.dropdown
.as_ref()
.expect("`@` opens the assignee picker");
let labels: Vec<&str> = dropdown
.state
.options
.iter()
.map(|option| option.label.as_str())
.collect();
assert_eq!(
labels,
vec!["alice", "bob"],
"the storage folders are the options"
);
}
#[test]
fn triage_tui_choosing_an_assignee_sets_it_and_relocates_the_file() {
let (_tmp, root, dir) = triage_inbox_repo();
fs::create_dir_all(dir.join("alice")).unwrap();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('@')).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
let listed = app.store.list().unwrap();
let issue = listed
.iter()
.find(|i| i.filename.contains("alpha"))
.expect("the triaged issue");
assert!(
issue.filename.starts_with("alice/"),
"the file moves into the assignee's directory: {}",
issue.filename
);
let moved = dir.join(&issue.filename);
assert!(moved.exists(), "the file moved into the assignee's folder");
assert!(
!dir.join("2026-01-01-alpha.md").exists(),
"and did not stay behind"
);
let text = fs::read_to_string(&moved).unwrap();
assert!(
text.contains("assignee: alice")
|| text.contains("assignee: \"alice\"")
|| text.contains("assignee: '@alice'"),
"the field is set:\n{text}"
);
assert!(
triage_state(&app)
.rows
.iter()
.any(|row| row.issue.number == 1),
"the issue is still in the triage queue after the relocation"
);
}
#[test]
fn triage_tui_creating_an_issue_enters_the_entry_status_and_the_queue() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let before = triage_state(&app).rows.len();
app.handle_key(press_char('n')).unwrap();
for c in "Fresh arrival".chars() {
app.handle_key(press_char(c)).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(!triage_state(&app).creating, "the prompt closed on create");
assert_eq!(
triage_state(&app).rows.len(),
before + 1,
"the created issue appears in the queue"
);
let created = triage_state(&app)
.rows
.iter()
.find(|row| row.issue.title == "Fresh arrival")
.expect("the created issue is listed");
assert_eq!(
created.issue.status.display(),
"triage",
"it entered the configured entry status"
);
}
#[test]
fn triage_tui_the_pickers_are_operable_by_mouse_hit_region() {
let (_tmp, root, dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let area = triage_content_area();
let _ = render_rows_in(&app, 160, 40);
let rects = crate::ui::triage::control_rects(area, triage_state(&app));
let status_rect = rects
.iter()
.find(|(control, _)| *control == TriageControl::Status)
.expect("the status control has a rect")
.1;
let action = app
.handle_mouse(mouse_down(status_rect.x + 1, status_rect.y))
.unwrap();
assert_eq!(
action,
Action::TriageOpenStatus,
"the status control is a click target"
);
app.apply_triage_action(action).unwrap();
assert!(
triage_state(&app).dropdown.is_some(),
"clicking the control opens the picker"
);
let _ = render_rows_in(&app, 160, 40);
let (_, rows) = crate::ui::triage::dropdown_layout(area, triage_state(&app))
.expect("the open picker has a layout");
let action = app
.handle_mouse(mouse_down(rows[1].x + 1, rows[1].y))
.unwrap();
assert_eq!(
action,
Action::TriagePickOption(1),
"an option row is a click target"
);
app.apply_triage_action(action).unwrap();
let path = dir.join("2026-01-01-alpha.md");
assert_eq!(
status_value(&path),
"todo",
"the clicked option moved the focused issue"
);
}
fn triage_control_rect(app: &App, control: TriageControl) -> ratatui::layout::Rect {
crate::ui::triage::control_rects(triage_content_area(), triage_state(app))
.into_iter()
.find(|(candidate, _)| *candidate == control)
.map(|(_, rect)| rect)
.expect("every control has a rect")
}
fn render_buffer(app: &App, width: u16, height: u16) -> ratatui::buffer::Buffer {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
terminal.backend().buffer().clone()
}
fn control_is_focused(app: &App, control: TriageControl, buffer: &ratatui::buffer::Buffer) -> bool {
let rect = triage_control_rect(app, control);
buffer[(rect.x, rect.y)]
.modifier
.contains(ratatui::style::Modifier::REVERSED)
}
#[test]
fn triage_tui_h_and_l_walk_the_control_bar_and_clamp() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
assert_eq!(
triage_state(&app).focus,
TriageControl::Queue,
"the queue is the default focus"
);
app.handle_key(press_char('l')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Status);
app.handle_key(press(KeyCode::Right)).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Assignee);
app.handle_key(press_char('l')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::New);
app.handle_key(press_char('l')).unwrap();
assert_eq!(
triage_state(&app).focus,
TriageControl::New,
"the right end clamps instead of wrapping"
);
app.handle_key(press_char('h')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Assignee);
app.handle_key(press(KeyCode::Left)).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Status);
app.handle_key(press_char('h')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Queue);
app.handle_key(press_char('h')).unwrap();
assert_eq!(
triage_state(&app).focus,
TriageControl::Queue,
"the left end does not wrap the control bar"
);
assert!(
app.nav_focus_index().is_some(),
"at the left edge, h moves keyboard focus into the F1-F12 rail"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.nav_focus_index(), None);
app.handle_key(press(KeyCode::Left)).unwrap();
assert!(
app.nav_focus_index().is_some(),
"Left reaches the rail from the same left edge"
);
}
#[test]
fn triage_tui_j_and_k_still_move_the_queue_while_the_queue_is_focused() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
assert_eq!(triage_state(&app).focus, TriageControl::Queue);
app.handle_key(press_char('j')).unwrap();
assert_eq!(triage_state(&app).cursor, 1, "raw `j` moves the queue");
app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(triage_state(&app).cursor, 2, "the arrow is the same move");
app.handle_key(press_char('k')).unwrap();
assert_eq!(triage_state(&app).cursor, 1, "raw `k` moves it back");
app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(triage_state(&app).cursor, 0);
app.handle_key(press_char('l')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Status);
app.handle_key(press_char('j')).unwrap();
assert_eq!(triage_state(&app).cursor, 1);
}
#[test]
fn triage_tui_enter_activates_the_focused_control() {
let (_tmp, root, dir) = triage_inbox_repo();
fs::create_dir_all(dir.join("alice")).unwrap();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('l')).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
let dropdown = triage_state(&app)
.dropdown
.as_ref()
.expect("`enter` on the status control opens the status picker");
assert_eq!(dropdown.kind, crate::ui::triage::TriageDropdownKind::Status);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(triage_state(&app).dropdown.is_none());
app.handle_key(press_char('l')).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
let dropdown = triage_state(&app)
.dropdown
.as_ref()
.expect("`enter` on the assignee control opens the assignee picker");
assert_eq!(
dropdown.kind,
crate::ui::triage::TriageDropdownKind::Assignee
);
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press_char('l')).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
triage_state(&app).creating,
"`enter` on the new control starts the create prompt"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(!triage_state(&app).creating);
for _ in 0..3 {
app.handle_key(press_char('h')).unwrap();
}
assert_eq!(triage_state(&app).focus, TriageControl::Queue);
app.handle_key(press(KeyCode::Enter)).unwrap();
let state = triage_state(&app);
assert!(state.modal.is_none() && state.dropdown.is_none() && !state.creating);
assert!(matches!(app.mode, Mode::TriageInbox), "still in the inbox");
}
#[test]
fn triage_tui_the_direct_keys_still_open_the_pickers_and_the_prompt() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('s')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Status);
assert!(triage_state(&app).dropdown.is_some());
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press_char('@')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Assignee);
assert!(triage_state(&app).dropdown.is_some());
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press_char('n')).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::New);
assert!(triage_state(&app).creating);
}
#[test]
fn triage_tui_h_l_j_k_typed_into_a_prompt_stay_text() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
app.handle_key(press_char('d')).unwrap();
fill_field(&mut app, DecisionField::Reason, "hjkl hk lj");
assert_eq!(
triage_modal(&app).reason,
"hjkl hk lj",
"the letters reached the reason field"
);
assert_eq!(
triage_state(&app).focus,
TriageControl::Queue,
"no focus move"
);
assert_eq!(triage_state(&app).cursor, 0, "no cursor move");
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press_char('u')).unwrap();
fill_field(&mut app, DecisionField::DuplicateOf, "hjkl");
assert_eq!(
triage_modal(&app).duplicate_of,
"hjkl",
"the duplicate target kept the letters"
);
assert!(
!triage_modal(&app).editing,
"the edit committed the typed text rather than a command"
);
}
#[test]
fn triage_tui_the_focused_control_is_rendered_distinctly() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let screen = triage_screen(&app);
assert!(
screen.contains("queue"),
"the queue control is drawn:\n{screen}"
);
assert!(screen.contains("[s] status: triage"), "{screen}");
assert!(screen.contains("[@] assignee: unassigned"), "{screen}");
assert!(screen.contains("[n] new issue"), "{screen}");
let buffer = render_buffer(&app, 160, 40);
assert!(
control_is_focused(&app, TriageControl::Queue, &buffer),
"the default focus is visible on the queue"
);
for control in [
TriageControl::Status,
TriageControl::Assignee,
TriageControl::New,
] {
assert!(
!control_is_focused(&app, control, &buffer),
"{control:?} is not the focused control"
);
}
app.handle_key(press_char('l')).unwrap();
let buffer = render_buffer(&app, 160, 40);
assert!(control_is_focused(&app, TriageControl::Status, &buffer));
assert!(!control_is_focused(&app, TriageControl::Queue, &buffer));
}
#[test]
fn triage_tui_clicking_a_control_focuses_it_as_well_as_activating_it() {
let (_tmp, root, _dir) = triage_inbox_repo();
let mut app = triage_app(&root);
open_inbox(&mut app);
let _ = render_rows_in(&app, 160, 40);
let assignee = triage_control_rect(&app, TriageControl::Assignee);
let action = app
.handle_mouse(mouse_down(assignee.x + 1, assignee.y))
.unwrap();
assert_eq!(action, Action::TriageOpenAssignee);
app.apply_triage_action(action).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Assignee);
assert!(triage_state(&app).dropdown.is_some());
app.handle_key(press(KeyCode::Esc)).unwrap();
let queue = triage_control_rect(&app, TriageControl::Queue);
let action = app.handle_mouse(mouse_down(queue.x, queue.y)).unwrap();
assert_eq!(action, Action::TriageFocusQueue);
app.apply_triage_action(action).unwrap();
assert_eq!(triage_state(&app).focus, TriageControl::Queue);
assert!(
triage_state(&app).dropdown.is_none() && !triage_state(&app).creating,
"focusing the queue does not open anything"
);
}
#[test]
fn triage_tui_the_new_issue_form_stamps_an_issue_created_into_the_inbox() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let dir = root.join("issues");
fs::create_dir_all(&dir).unwrap();
fs::write(
root.join("tix.toml"),
format!(
"issues_dir = \"{}\"\n\n[kanban.default]\ncolumns = [\"triage\", \"todo\", \"in progress\", \"in review\", \"done\", \"wontfix\"]\ndone_column = \"done\"\ncategory_map = {{ triage = \"backlog\", todo = \"unstarted\", \"in progress\" = \"active\", \"in review\" = \"active\", done = \"completed\", wontfix = \"canceled\" }}\n\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\naccepted_status = \"todo\"\n",
dir.display()
),
)
.unwrap();
let config = Config::load_with_warning(&root.join("tix.toml"), |_| {}).unwrap();
let mut app = App::new(&config).unwrap();
app.handle_key(press_char('n')).unwrap();
assert!(matches!(app.mode, Mode::Form), "`n` opens the form");
for c in "TuiPicked".chars() {
app.handle_key(press_char(c)).unwrap();
}
app.handle_key(press(KeyCode::Tab)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap(); app.handle_key(press(KeyCode::Enter)).unwrap(); app.handle_key(press(KeyCode::Esc)).unwrap(); app.handle_key(press(KeyCode::Tab)).unwrap();
app.handle_key(press(KeyCode::Tab)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board), "enter submits");
let created = collect_md(&dir)
.into_iter()
.map(|path| fs::read_to_string(&path).unwrap())
.find(|body| body.contains("title: TuiPicked"))
.expect("the form persisted the issue");
assert!(
created.contains("entered_at: ") && created.contains("source: new"),
"an issue the TUI creates into the inbox records when it entered:\n{created}"
);
let mut app =
App::new(&Config::load_with_warning(&root.join("tix.toml"), |_| {}).unwrap()).unwrap();
app.handle_key(press_char('t')).unwrap();
let inbox = app.triage.as_ref().expect("the inbox is open");
let row = inbox
.rows
.iter()
.find(|row| row.issue.title == "TuiPicked")
.expect("the new issue is in the queue");
assert_eq!(
row.age_days,
Some(0),
"a just-created entry is today old, not unknown"
);
}
fn write_full_issue(dir: &std::path::Path, name: &str, number: u32) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Source issue\"\nstatus: \"in progress\"\n\
labels: [\"bug\", \"ui\"]\npriority: \"high\"\nassignee: \"alice\"\nestimate: \"3\"\n\
cycles: [\"September 2024\"]\nmilestones: [\"v1\"]\nreviewer: \"bob\"\n\
start_date: \"2026-09-01\"\ndue_date: \"2026-09-30\"\nparent: 3\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn clone_prefills_the_create_form_from_the_selected_issue() {
let (tmp, dir) = make_fixture();
write_full_issue(&dir, "2026-01-01-source.md", 7);
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert!(app.form.is_none(), "no form before the key");
app.board.move_right();
assert_eq!(
app.board.get_selected_card().map(|card| card.number),
Some(7),
"the source is the selected card"
);
let _ = app.handle_key(press_char('C')).unwrap();
assert!(
matches!(app.mode, Mode::Form),
"clone opens the create form"
);
let form = app.form.as_ref().expect("clone opens a form");
assert_eq!(form.title, "Source issue");
assert_eq!(form.status, "in progress");
assert_eq!(form.priority, "high");
assert_eq!(form.labels, "bug,ui");
assert_eq!(form.assignee, "alice");
assert_eq!(form.estimate, "3");
assert_eq!(form.cycles, "September 2024");
assert_eq!(form.milestones, "v1");
assert_eq!(form.reviewer, "bob");
assert_eq!(form.start_date, "2026-09-01");
assert_eq!(form.due_date, "2026-09-30");
assert_eq!(form.parent, "3");
assert_eq!(form.focus, crate::ui::form::FormFocus::Title);
}
#[test]
fn clone_without_a_selected_issue_opens_nothing() {
let (tmp, dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('C')).unwrap();
assert!(app.form.is_none(), "no form without a source");
assert!(matches!(app.mode, Mode::Board));
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("No issue selected")),
"the key reports why it did nothing: {:?}",
app.status_msg
);
drop(dir);
}
#[test]
fn submitting_a_clone_creates_a_new_issue_and_keeps_the_source() {
let (tmp, dir) = make_fixture();
write_full_issue(&dir, "2026-01-01-source.md", 7);
write_issue(&dir, "2026-01-01-parent.md", 3, "Parent issue", "todo");
let source_before = fs::read_to_string(dir.join("2026-01-01-source.md")).unwrap();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.move_right();
let _ = app.handle_key(press_char('C')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let store = crate::storage::Store::open(&config).unwrap();
let issues = store.list().unwrap();
assert_eq!(
issues.len(),
3,
"the clone is an addition, not an edit: {:?}",
app.status_msg
);
let cloned = issues
.iter()
.find(|i| i.number != 7 && i.number != 3)
.expect("the clone carries a new number");
assert_eq!(cloned.number, 8, "numbers keep counting up");
assert_eq!(cloned.title, "Source issue");
assert_eq!(cloned.status.to_string(), "in progress");
assert_eq!(cloned.priority.to_string(), "high");
assert_eq!(cloned.labels, vec!["bug", "ui"]);
assert_eq!(cloned.assignee.as_deref(), Some("alice"));
assert_eq!(cloned.estimate.as_deref(), Some("3"));
assert_eq!(
cloned.cycles.as_deref(),
Some(&["September 2024".to_string()][..])
);
assert_eq!(cloned.milestones.as_deref(), Some(&["v1".to_string()][..]));
assert_eq!(cloned.reviewer.as_deref(), Some("bob"));
assert_eq!(cloned.start_date.as_deref(), Some("2026-09-01"));
assert_eq!(cloned.due_date.as_deref(), Some("2026-09-30"));
assert_eq!(cloned.parent, Some(crate::id::IssueRef::Number(3)));
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-source.md")).unwrap(),
source_before,
"the source file is untouched by cloning it"
);
}
#[test]
fn the_archive_toggle_loads_archived_issues_on_demand() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-live.md", 1, "Live issue", "todo");
write_issue(&dir, ".2026-01-01-old.md", 2, "Old issue", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let titles = |app: &App| -> Vec<String> {
app.board
.column_cards(0)
.iter()
.map(|card| card.title.clone())
.collect()
};
assert_eq!(
titles(&app),
vec!["Live issue".to_string()],
"the archived file is not on the board before the toggle"
);
let _ = app.handle_key(press_char('a')).unwrap();
assert!(app.show_archived, "the key turns the toggle on");
assert_eq!(
titles(&app),
vec!["Old issue".to_string(), "Live issue".to_string()],
"the archived issue is loaded on demand"
);
let archived = app
.board
.column_cards(0)
.iter()
.find(|card| card.number == 2)
.expect("the archived card is on the board");
assert!(archived.archived, "the card carries its archive state");
assert!(
crate::ui::widgets::card_tokens(archived)
.iter()
.any(|(text, _)| text == "[archived]"),
"the archived card renders the marker"
);
let _ = app.handle_key(press_char('a')).unwrap();
assert!(!app.show_archived, "the key toggles back off");
assert_eq!(
titles(&app),
vec!["Live issue".to_string()],
"hiding them again leaves the live set"
);
}
#[test]
fn search_finds_archived_issues_without_the_toggle() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-live.md", 1, "Live issue", "todo");
write_issue(&dir, ".2026-01-01-old.md", 2, "archived needle", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert!(!app.show_archived, "the toggle starts off");
let titles = |app: &App| -> Vec<String> {
app.board
.column_cards(0)
.iter()
.map(|card| card.title.clone())
.collect()
};
let _ = app.handle_key(press_char('/')).unwrap();
for c in "needle".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
assert_eq!(
titles(&app),
vec!["archived needle".to_string()],
"the archived issue is found by search despite the toggle being off"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(!app.show_archived);
assert_eq!(
titles(&app),
vec!["Live issue".to_string()],
"an empty search shows the active board again"
);
}
fn write_issue_with_related(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
related: &[&str],
) {
let list = related
.iter()
.map(|r| format!("\"{r}\""))
.collect::<Vec<_>>()
.join(", ");
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nrelated_issues: [{list}]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
fn select_board_card(app: &mut App, number: u32) {
for (col, column) in app.board.columns.iter().enumerate() {
if let Some(row) = column.cards.iter().position(|card| card.number == number) {
app.board.active_col = col;
app.board.selected = row;
return;
}
}
panic!("issue {number} is not on the board");
}
#[test]
fn t008_the_board_card_carries_a_related_count_marker() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "One", "todo");
write_issue_with_related(
&dir,
"2026-01-01-b.md",
2,
"Two",
&["1", "#3", "https://x.example/a#4"],
);
let config = load_config(&tmp);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let card = |number: u32| {
board
.columns
.iter()
.flat_map(|column| column.cards.iter())
.find(|card| card.number == number)
.unwrap_or_else(|| panic!("issue {number} is not on the board"))
.clone()
};
let marked = crate::ui::widgets::card_tokens(&card(2));
assert_eq!(marked[0].0, "#2", "the number stays the first token");
assert_eq!(
marked[1].0, "[↔ 3]",
"the related count sits right after the number: {marked:?}"
);
let plain = crate::ui::widgets::card_tokens(&card(1));
assert!(
plain.iter().all(|(text, _)| !text.starts_with("[↔")),
"an issue with no related_issues carries no marker: {plain:?}"
);
}
#[test]
fn t009_following_a_local_related_entry_and_nav_back() {
let (tmp, dir) = make_fixture();
write_issue_with_related(&dir, "2026-01-01-a.md", 1, "One", &["2", "3"]);
write_issue(&dir, "2026-01-01-b.md", 2, "Two", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Three", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
select_board_card(&mut app, 1);
open_detail(&mut app);
{
let detail = app.detail.as_ref().unwrap();
assert_eq!(detail.issue.number, 1);
assert_eq!(detail.related.len(), 2, "the block carries both entries");
assert_eq!(detail.related_cursor, 0);
}
let rows = render_rows(&app);
assert!(
rows.iter().any(|row| row.contains("related: 2")),
"the pane must draw the related block: {rows:?}"
);
assert!(
rows.iter().any(|row| row.contains("> 2")),
"the selected related entry must carry its marker: {rows:?}"
);
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(
app.detail.as_ref().unwrap().related_cursor,
0,
"j/k must not hijack the related selection"
);
let _ = app.handle_key(press_char('o')).unwrap();
assert_eq!(app.detail.as_ref().unwrap().issue.number, 2, "o follows #2");
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
let back = app.detail.as_ref().unwrap();
assert_eq!(back.issue.number, 1, "back returns to the previous pane");
assert_eq!(back.related.len(), 2, "the previous pane is restored whole");
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().issue.number, 1);
assert!(
app.status_msg
.as_deref()
.unwrap_or("")
.contains("no previous issue"),
"an exhausted back stack reports rather than silently doing nothing: {:?}",
app.status_msg
);
}
#[test]
fn t009_the_related_picker_selects_and_follows_the_second_entry() {
let (tmp, dir) = make_fixture();
write_issue_with_related(&dir, "2026-01-01-a.md", 1, "One", &["2", "3"]);
write_issue(&dir, "2026-01-01-b.md", 2, "Two", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Three", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
select_board_card(&mut app, 1);
open_detail(&mut app);
let _ = app.handle_key(press_char('r')).unwrap();
let rows = render_rows(&app);
assert!(
rows.iter().any(|row| row.contains("Related issues")),
"the related picker must render its own popup: {rows:?}"
);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.detail.as_ref().unwrap().issue.number,
3,
"the picker follows the entry it selected"
);
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().related_cursor, 1);
let _ = app.handle_key(press_char('r')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Detail));
assert_eq!(app.detail.as_ref().unwrap().issue.number, 1);
}
#[test]
fn t009_the_related_cursor_is_clamped_on_every_mutation() {
let (tmp, dir) = make_fixture();
write_issue_with_related(&dir, "2026-01-01-a.md", 1, "One", &["2", "3"]);
write_issue(&dir, "2026-01-01-b.md", 2, "Two", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Three", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
select_board_card(&mut app, 1);
open_detail(&mut app);
let mut detail = app.detail.clone().unwrap();
detail.related_cursor = 99;
detail.clamp_related_cursor();
assert_eq!(detail.related_cursor, 1, "clamped to the last entry");
detail.related.clear();
detail.clamp_related_cursor();
assert_eq!(detail.related_cursor, 0, "an empty list clamps to zero");
}
#[test]
fn t009_a_path_related_entry_open_read_only() {
let (tmp, dir) = make_fixture();
write_issue_with_related(&dir, "2026-01-01-a.md", 1, "One", &["../outside.md"]);
write_issue(tmp.path(), "outside.md", 99, "Outside", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
select_board_card(&mut app, 1);
open_detail(&mut app);
let _ = app.handle_key(press_char('o')).unwrap();
let external = app.detail.as_ref().unwrap();
assert_eq!(
external.issue.number, 99,
"o opens the file the path points at"
);
assert!(
external.read_only,
"a file outside the store opens read-only"
);
let action = app.handle_key(press_char('e')).unwrap();
assert!(
matches!(action, Action::None),
"a read-only pane must not hand back an editor action, got {action:?}"
);
assert!(
app.status_msg
.as_deref()
.unwrap_or("")
.contains("read-only"),
"the refusal is stated: {:?}",
app.status_msg
);
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
assert_eq!(app.detail.as_ref().unwrap().issue.number, 1);
assert!(!app.detail.as_ref().unwrap().read_only);
}
fn issue_bytes(dir: &std::path::Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut files = collect_md(dir);
files.sort();
files
.into_iter()
.map(|path| {
let bytes = fs::read(&path).unwrap();
(path, bytes)
})
.collect()
}
fn board_numbers(app: &App) -> Vec<u32> {
app.board
.columns
.iter()
.chain(app.board.hidden.iter())
.chain(std::iter::once(&app.board.mismatch))
.flat_map(|column| column.cards.iter().map(|card| card.number))
.collect()
}
fn config_bytes(tmp: &TempDir) -> Vec<u8> {
fs::read(tmp.path().join("tix.toml")).unwrap()
}
fn type_text(app: &mut App, text: &str) {
for c in text.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
}
fn save_live_board_as_view(app: &mut App, name: &str) {
let _ = app.handle_key(press_char('v')).unwrap();
assert!(matches!(app.mode, Mode::Views), "`v` opens the view list");
let _ = app.handle_key(press_char('n')).unwrap();
type_text(app, name);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
#[test]
fn saved_view_round_trips_through_the_view_modal() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta polish", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma login", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert_eq!(board_numbers(&app), vec![1, 2, 3]);
let _ = app.handle_key(press_char('/')).unwrap();
type_text(&mut app, "login");
assert_eq!(board_numbers(&app), vec![1, 3], "the live query narrows");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
save_live_board_as_view(&mut app, "Login");
let row = app.views.as_ref().unwrap().rows[0].clone();
assert_eq!(row.name, "Login");
assert!(
row.id.as_str().starts_with("v-"),
"a generated stable id, got {}",
row.id
);
assert!(!row.active, "creating a view does not apply it");
assert!(app.active_view_id().is_none(), "no marker before applying");
let after_create = config_bytes(&tmp);
let text = String::from_utf8(after_create.clone()).unwrap();
assert!(text.contains(&format!("[views.{}]", row.id)), "{text}");
assert!(text.contains("name = \"Login\""), "{text}");
let issues_before = issue_bytes(&dir);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let _ = app.handle_key(press_char('/')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(board_numbers(&app), vec![1, 2, 3], "the query is cleared");
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
board_numbers(&app),
vec![1, 3],
"applying restores the plan"
);
assert_eq!(app.active_view_id(), Some(&row.id), "the marker is the id");
assert!(app.view_divergence().unwrap().in_sync());
let indicator = app.view_indicator().unwrap();
assert!(
indicator.contains("Login") && indicator.contains("in sync"),
"{indicator}"
);
assert_eq!(config_bytes(&tmp), after_create, "apply writes no config");
assert_eq!(issue_bytes(&dir), issues_before, "apply touches no issue");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("view Login")),
"the board marks the active view: {rows:?}"
);
let _ = app.handle_key(press_char('/')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(
board_numbers(&app),
vec![1, 2, 3],
"the live board is not silently reconciled to the view"
);
let divergence = app.view_divergence().unwrap();
assert!(!divergence.in_sync(), "clearing the query is a divergence");
assert!(
app.view_indicator().unwrap().contains("diverged"),
"the divergence is visible"
);
assert_eq!(
app.active_view_id(),
Some(&row.id),
"the marker still names the view"
);
assert_eq!(
issue_bytes(&dir),
issues_before,
"divergence mutates nothing"
);
}
#[test]
fn applying_a_view_restores_hidden_columns_and_keeps_the_selection() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
for _ in 0..3 {
let _ = app.handle_key(press_char('l')).unwrap();
}
let _ = app.handle_key(press_char('c')).unwrap();
assert_eq!(app.board.total_cols(), 3, "`c` hides the done column");
let hidden_config = config_bytes(&tmp);
save_live_board_as_view(&mut app, "NoDone");
let id = app.views.as_ref().unwrap().rows[0].id.clone();
let saved_config = config_bytes(&tmp);
assert_ne!(
saved_config, hidden_config,
"creating writes the definition"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('c')).unwrap();
assert_eq!(app.board.total_cols(), 4, "the column is back");
app.board.active_col = 0;
app.board.selected = 1;
assert_eq!(app.board.get_selected_card().map(|c| c.number), Some(2));
let before_apply = config_bytes(&tmp);
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.board.total_cols(),
3,
"the view's snapshot hides it again"
);
assert_eq!(
app.board
.hidden
.iter()
.map(|column| column.name.clone())
.collect::<Vec<_>>(),
vec!["done".to_string()]
);
assert_eq!(app.active_view_id(), Some(&id));
assert_eq!(
(app.board.active_col, app.board.selected),
(0, 1),
"the cursor did not move"
);
assert_eq!(
app.board.get_selected_card().map(|c| c.number),
Some(2),
"the selected card survived the apply"
);
assert_eq!(
config_bytes(&tmp),
before_apply,
"the overlay writes nothing"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('c')).unwrap();
assert_eq!(
app.board
.hidden
.iter()
.map(|column| column.name.clone())
.collect::<Vec<_>>(),
vec!["todo".to_string()],
"the hand toggle is in force, not the view's snapshot"
);
assert_eq!(app.active_view_id(), Some(&id), "the marker stays");
assert!(
!app.view_divergence().unwrap().in_sync(),
"reported, not hidden"
);
}
#[test]
fn a_project_scoped_view_is_refused_visibly_and_writes_nothing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta", "todo");
let config_path = tmp.path().join("tix.toml");
fs::write(
&config_path,
format!(
"issues_dir = \"{}\"\n\n[views.v-project]\nid = \"v-project\"\nname = \"Project view\"\n\n\
[views.v-project.scope]\nkind = \"project\"\nproject_id = \"p-platform\"\n\n\
[views.v-project.plan]\nsort = \"due\"\n",
dir.display()
),
)
.unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let mut app = App::new(&config).unwrap();
let before = config_bytes(&tmp);
let issues_before = issue_bytes(&dir);
let board_before = board_numbers(&app);
let _ = app.handle_key(press_char('v')).unwrap();
let modal = app.views.as_ref().unwrap();
assert_eq!(modal.rows.len(), 1);
assert!(
modal.rows[0].scope.contains("project p-platform (missing)"),
"the scope and its resolution are rendered: {}",
modal.rows[0].scope
);
let feedback = modal.feedback.as_ref().expect("feedback for the selection");
assert_eq!(feedback.indicator, "view Project view · id v-project");
assert!(
feedback
.lines
.iter()
.any(|line| line.contains("not in the project registry")),
"the missing project is diagnosed: {feedback:?}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(board_numbers(&app), board_before, "nothing applied");
assert!(app.active_view_id().is_none(), "no marker was set");
let note = app.views.as_ref().unwrap().note.clone().unwrap_or_default();
assert!(note.contains("not in the project registry"), "{note}");
assert_eq!(config_bytes(&tmp), before, "a refusal writes no config");
assert_eq!(
issue_bytes(&dir),
issues_before,
"a refusal touches no issue"
);
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("Project view")),
"the refusal is on screen: {rows:?}"
);
}
#[test]
fn view_management_renames_duplicates_and_deletes_definitions_only() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let issues_before = issue_bytes(&dir);
save_live_board_as_view(&mut app, "First");
let original = app.views.as_ref().unwrap().rows[0].id.clone();
let _ = app.handle_key(press_char('e')).unwrap();
for _ in 0.."First".len() {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
type_text(&mut app, "Second");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let rows = &app.views.as_ref().unwrap().rows;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "Second");
assert_eq!(rows[0].id, original, "identity survives the rename (C-002)");
let _ = app.handle_key(press_char('d')).unwrap();
let rows = &app.views.as_ref().unwrap().rows;
assert_eq!(rows.len(), 2, "duplicating adds one row");
let copy = rows[1].id.clone();
assert_ne!(copy, original, "the duplicate gets its own id");
assert_eq!(rows[1].name, "Second", "a duplicate keeps the label");
let after_duplicate = config_bytes(&tmp);
assert!(
String::from_utf8(after_duplicate.clone())
.unwrap()
.contains(&format!("[views.{}]", copy)),
"the duplicate is persisted"
);
let _ = app.handle_key(press_char('x')).unwrap();
let rows = render_rows(&app);
assert!(
rows.iter()
.any(|line| line.contains("Delete view 'Second'")),
"the confirmation names the definition: {rows:?}"
);
let _ = app.handle_key(press_char('y')).unwrap();
let rows = &app.views.as_ref().unwrap().rows;
assert_eq!(rows.len(), 1, "only the definition was removed");
assert_eq!(rows[0].id, original, "the other view is untouched");
let text = String::from_utf8(config_bytes(&tmp)).unwrap();
assert!(!text.contains(&format!("[views.{}]", copy)), "{text}");
assert!(text.contains(&format!("[views.{}]", original)), "{text}");
assert_eq!(issue_bytes(&dir), issues_before, "no issue was deleted");
let _ = app.handle_key(press_char('x')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.views.as_ref().unwrap().rows.len(), 1);
assert!(app.views.as_ref().unwrap().prompt.is_none());
}
#[test]
fn a_click_on_a_view_row_applies_that_view() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta polish", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
type_text(&mut app, "login");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
save_live_board_as_view(&mut app, "Login");
save_live_board_as_view(&mut app, "Login again");
let rows = app.views.as_ref().unwrap().rows.clone();
assert_eq!(rows.len(), 2);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(board_numbers(&app), vec![1, 2], "the query is cleared");
let _ = app.handle_key(press_char('v')).unwrap();
let frame = render_rows(&app);
let row = frame
.iter()
.position(|line| line.contains("Login again"))
.expect("the second view is drawn") as u16;
let column = frame[row as usize]
.find("Login again")
.expect("the row names the view") as u16;
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
};
let _ = app.handle_mouse(click).unwrap();
assert_eq!(
app.active_view_id(),
Some(&rows[1].id),
"the clicked row is the one that applied"
);
assert_eq!(
board_numbers(&app),
vec![1],
"the clicked view's plan is live"
);
assert!(
matches!(app.mode, Mode::Views),
"the click does not close the list"
);
let outside = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 79,
row: 23,
modifiers: KeyModifiers::NONE,
};
let _ = app.handle_mouse(outside).unwrap();
assert_eq!(app.active_view_id(), Some(&rows[1].id));
}
#[test]
fn the_tui_plan_and_result_match_the_shared_service() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta polish", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma login", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
type_text(&mut app, "login");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
save_live_board_as_view(&mut app, "Login");
let id = app.views.as_ref().unwrap().rows[0].id.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let mut service_config = load_config(&tmp);
let store = crate::storage::Store::open(&service_config).unwrap();
let board = service_config.default_config().clone();
let corpus = crate::search::Corpus::build(&store).unwrap();
let resolution = crate::workflow::resolution(&service_config, &board, "default");
let path = tmp.path().join("tix.toml");
let projects: BTreeSet<String> = BTreeSet::new();
let (applied_plan, applied_numbers, service_divergence) = {
let mut service = ViewService::new(&mut service_config, &projects, &path);
service
.mark_active(id.as_str(), Some(ViewSurface::Tui))
.unwrap();
let result = service
.apply(id.as_str(), &board, &corpus, &resolution)
.unwrap();
let live = app.live_plan();
(
result.plan().clone(),
result.issue_numbers().to_vec(),
service.divergence(&live),
)
};
assert_eq!(
app.live_plan(),
applied_plan,
"the TUI installed the service's normalized plan"
);
assert_eq!(
board_numbers(&app),
applied_numbers,
"the TUI's board is the service's result set"
);
assert_eq!(
app.view_divergence(),
Some(service_divergence),
"the TUI's divergence report is the service's"
);
let applied = std::process::Command::new(tix_bin())
.current_dir(tmp.path())
.args(["view", "apply", id.as_str()])
.output()
.unwrap();
assert!(applied.status.success(), "{applied:?}");
let stdout = String::from_utf8(applied.stdout).unwrap();
let cli_numbers: Vec<u32> = stdout
.lines()
.find_map(|line| line.strip_prefix("issues: "))
.expect("the CLI printed its result set")
.split(',')
.filter_map(|n| n.trim().parse::<u32>().ok())
.collect();
assert_eq!(
cli_numbers,
board_numbers(&app),
"CLI and TUI apply the same view to the same issue set: {stdout}"
);
let status = std::process::Command::new(tix_bin())
.current_dir(tmp.path())
.args([
"view",
"status",
id.as_str(),
"--filter",
"login",
"--hide-columns",
"",
"--show-mismatch",
"false",
])
.output()
.unwrap();
assert!(status.status.success(), "{status:?}");
let status_stdout = String::from_utf8(status.stdout).unwrap();
assert!(
status_stdout.contains("in-sync: true"),
"the CLI agrees the live plan matches: {status_stdout}"
);
assert!(app.view_divergence().unwrap().in_sync());
}
#[test]
fn the_filter_modal_marks_the_active_view_and_tracks_live_edits() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta polish", "todo");
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma login", "done");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
type_text(&mut app, "login");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
save_live_board_as_view(&mut app, "Login");
let id = app.views.as_ref().unwrap().rows[0].id.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let _ = app.handle_key(press_char('f')).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
let modal = app.filter_modal.as_ref().unwrap();
assert_eq!(
modal.view_id(),
Some(&id),
"the modal carries the stable id"
);
assert_eq!(
modal.applied_plan().map(|plan| plan.text_query()),
Some(Some("login")),
"and the normalized plan that was applied"
);
assert!(modal.indicator_line().unwrap().contains("in sync"));
assert_eq!(
modal.header_height(),
2 + modal.feedback().unwrap().lines.len() as u16,
"the indicator, a blank line, and one line per feedback detail"
);
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("view Login")),
"the modal shows the indicator: {rows:?}"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let modal = app.filter_modal.as_ref().unwrap();
assert_eq!(modal.focus(), crate::search::FilterField::Status);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
for c in "done".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let modal = app.filter_modal.as_ref().unwrap();
assert_eq!(
modal.active_filter().status.as_deref(),
Some("done"),
"the picker wrote the criterion into the modal's live filter"
);
assert!(
modal.indicator_line().unwrap().contains("diverged"),
"{:?}",
modal.indicator_line()
);
assert_eq!(
modal.view_id(),
Some(&id),
"the marker is still the same id"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(app.active_view_id(), Some(&id));
assert!(
!app.view_divergence().unwrap().in_sync(),
"the board still differs from the saved view"
);
assert_eq!(board_numbers(&app), vec![3], "the edited criterion is live");
}
#[test]
fn refreshing_keeps_the_active_view_and_re_derives_the_indicator() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha login", "todo");
write_issue(&dir, "2026-01-01-b.md", 2, "Beta", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
save_live_board_as_view(&mut app, "All");
let id = app.views.as_ref().unwrap().rows[0].id.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.view_divergence().unwrap().in_sync());
let _ = app.handle_key(press_char('r')).unwrap();
assert_eq!(app.active_view_id(), Some(&id));
assert!(app.view_divergence().unwrap().in_sync());
let _ = app.handle_key(press_char('/')).unwrap();
type_text(&mut app, "polish");
let before = app.view_indicator().unwrap();
assert!(before.contains("diverged"), "{before}");
app.refresh_from_disk(&FsDelta::default()).unwrap();
assert_eq!(app.active_view_id(), Some(&id));
assert_eq!(
app.view_indicator().unwrap(),
before,
"re-derived, not stale"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.view_divergence().unwrap().in_sync());
write_issue(&dir, "2026-01-01-c.md", 3, "Gamma", "todo");
app.refresh_from_disk(&FsDelta {
changed: vec![PathBuf::from("2026-01-01-c.md")],
removed: Vec::new(),
})
.unwrap();
assert!(app.view_divergence().unwrap().in_sync());
assert_eq!(
app.view_indicator().unwrap(),
"view All · in sync",
"the name is the label, the id is the identity"
);
}
#[test]
fn unknown_metadata_and_an_invalid_field_are_diagnosed_not_dropped() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
let config_path = tmp.path().join("tix.toml");
fs::write(
&config_path,
format!(
"issues_dir = \"{}\"\n\n[views.v-legacy]\nid = \"v-legacy\"\nname = \"Legacy\"\n\
x-extra = \"kept\"\n\n[views.v-legacy.plan]\nsort = \"nope\"\n",
dir.display()
),
)
.unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let mut app = App::new(&config).unwrap();
let before = config_bytes(&tmp);
let issues_before = issue_bytes(&dir);
let board_before = board_numbers(&app);
let _ = app.handle_key(press_char('v')).unwrap();
let feedback = app
.views
.as_ref()
.unwrap()
.feedback
.clone()
.expect("feedback for the selection");
assert!(
feedback
.lines
.iter()
.any(|line| line.contains("unknown_metadata") && line.contains("x-extra")),
"unknown metadata is reported, not dropped: {feedback:?}"
);
assert!(
feedback
.lines
.iter()
.any(|line| line.contains("invalid_sort")),
"the unsupported sort is diagnosed: {feedback:?}"
);
assert_eq!(config_bytes(&tmp), before, "listing rewrites nothing");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let note = app.views.as_ref().unwrap().note.clone().unwrap_or_default();
assert!(note.contains("not a supported sort key"), "{note}");
assert_eq!(board_numbers(&app), board_before, "nothing applied");
assert!(app.active_view_id().is_none());
assert_eq!(config_bytes(&tmp), before, "a refusal writes no config");
assert_eq!(
issue_bytes(&dir),
issues_before,
"a refusal touches no issue"
);
assert_eq!(app.status_msg.as_deref(), Some(note.as_str()));
}
#[test]
fn a_refused_create_keeps_the_name_and_reports_why() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let modal = app.views.as_ref().unwrap();
assert!(
matches!(modal.prompt, Some(ViewsPrompt::Create(_))),
"a refusal leaves the prompt open so the name can be fixed"
);
let note = modal.note.clone().unwrap_or_default();
assert!(note.contains("needs a name"), "{note}");
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("needs a name")),
"the refusal is on screen: {rows:?}"
);
let text = String::from_utf8(config_bytes(&tmp)).unwrap();
assert!(
!text.contains("[views."),
"a refusal writes nothing: {text}"
);
type_text(&mut app, "Fixed");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let modal = app.views.as_ref().unwrap();
assert!(modal.prompt.is_none());
assert_eq!(modal.rows.len(), 1);
assert_eq!(modal.rows[0].name, "Fixed");
}
#[test]
fn deleting_the_active_view_clears_the_marker() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
save_live_board_as_view(&mut app, "Active");
let id = app.views.as_ref().unwrap().rows[0].id.clone();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press_char('v')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.active_view_id(), Some(&id), "the view is active");
let _ = app.handle_key(press_char('x')).unwrap();
let _ = app.handle_key(press_char('y')).unwrap();
assert_eq!(
app.active_view_id(),
None,
"the marker dies with the definition"
);
let rows = render_rows(&app);
assert!(
rows.iter()
.any(|line| line.contains("active marker cleared")),
"the note reports the cleared marker: {rows:?}"
);
}
fn template_app(extra: &str) -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let issues_dir = format!("{:?}", dir.to_string_lossy());
let text = format!(
"issues_dir = {issues_dir}\n\n\
[kanban.default]\n\
columns = [\"todo\", \"in progress\", \"in review\", \"done\"]\n\
done_column = \"done\"\n\n\
[layout]\n\
move_on_change = false\n\
{extra}"
);
fs::write(tmp.path().join("tix.toml"), text).unwrap();
let config = load_config(&tmp);
assert_eq!(
config.issues_dir, dir,
"the fixture's store is its own temp directory"
);
let app = App::new(&config).unwrap();
(tmp, app)
}
fn choose_stable_template(app: &mut App, nth: usize) {
let _ = app.handle_key(press_char('n')).unwrap();
assert!(matches!(app.mode, Mode::Form), "n opens the New Issue form");
app.form.as_mut().unwrap().focus = FormFocus::Template;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
for _ in 0..=nth {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
#[test]
fn wp05_template_picker_lists_stable_id_name_and_scope() {
let (_tmp, app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\n\
[issue_templates.bug.defaults]\nlabels = [\"bug\"]\n",
);
let mut app = app;
let _ = app.handle_key(press_char('n')).unwrap();
app.form.as_mut().unwrap().focus = FormFocus::Template;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let joined = render_rows(&app).join("\n");
assert!(
joined.contains("Bug report"),
"the display name is listed: {joined}"
);
assert!(joined.contains("bug"), "the stable id is listed: {joined}");
assert!(
joined.contains("[repository]"),
"the explicit scope is visible beside the id: {joined}"
);
}
#[test]
fn wp05_selecting_a_stable_template_seeds_the_existing_form() {
let (_tmp, mut app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\n\
[issue_templates.bug.defaults]\n\
title = \"Templated bug\"\n\
body = \"## Steps\\n\"\n\
labels = [\"bug\", \"urgent\"]\n\
priority = \"high\"\n\n\
[[issue_templates.bug.prompts]]\n\
key = \"status\"\nkind = \"select\"\nrequired = false\n\
default = \"in progress\"\noptions_ref = \"statuses\"\n",
);
choose_stable_template(&mut app, 0);
let form = app.form.as_ref().unwrap();
assert_eq!(
form.template, "bug",
"selection is stable by ID, not display name"
);
assert_eq!(form.template_scope.as_deref(), Some("repository"));
assert_eq!(form.title, "Templated bug");
assert_eq!(form.body, "## Steps\n");
assert_eq!(
form.labels, "bug,urgent",
"list defaults join with the submit path's comma rule"
);
assert_eq!(form.priority, "high");
assert_eq!(
form.status, "in progress",
"a prompt default seeds the existing Status picker row"
);
assert!(
app.store
.config
.default_config()
.columns
.contains(&"in progress".to_string()),
"the seeded status names a real board column, so its picker value is valid"
);
assert_eq!(form.display_value(FormFocus::Template), "bug [repository]");
}
#[test]
fn wp05_template_submit_uses_the_shared_core_and_matches_the_cli() {
let (_tmp, mut app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\n\
[issue_templates.bug.defaults]\n\
title = \"Templated bug\"\n\
body = \"## Steps\\n\"\n\
labels = [\"bug\"]\n\
priority = \"high\"\n",
);
choose_stable_template(&mut app, 0);
assert_eq!(
app.store
.config
.issue_templates
.get("bug")
.unwrap()
.defaults
.priority
.as_deref(),
Some("high"),
"the template block default loaded"
);
assert_eq!(
app.form.as_ref().unwrap().priority,
"high",
"the template default seeded the Priority row"
);
app.form.as_mut().unwrap().focus = FormFocus::Title;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"a successful submit closes the form"
);
assert!(app.form.is_none(), "the form is consumed on success");
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("template `bug`") && m.contains("repository")),
"the created issue's provenance names the id and scope: {:?}",
app.status_msg
);
let tui = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| issue.title == "Templated bug")
.expect("the TUI created the issue");
assert_eq!(tui.labels, vec!["bug".to_string()]);
assert_eq!(tui.priority.to_string(), "high");
assert!(tui.body.contains("## Steps"));
crate::commands::templates::create_from_template(
&app.store.config,
&app.store,
"bug",
None,
&crate::commands::templates::CreateFields::default(),
)
.expect("the CLI template path creates the issue");
let cli = app
.store
.list()
.unwrap()
.into_iter()
.filter(|issue| issue.title == "Templated bug")
.max_by_key(|issue| issue.number)
.expect("the CLI created the second issue");
assert_ne!(cli.number, tui.number, "two distinct issues were created");
assert_eq!(cli.labels, tui.labels, "CLI/TUI parity: labels");
assert_eq!(cli.priority, tui.priority, "CLI/TUI parity: priority");
assert_eq!(cli.body, tui.body, "CLI/TUI parity: body");
assert_eq!(cli.status, tui.status, "CLI/TUI parity: status");
}
#[test]
fn wp05_missing_required_prompt_writes_nothing_and_keeps_the_form() {
let (tmp, mut app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\n\
[issue_templates.bug.defaults]\n\
title = \"Templated bug\"\nlabels = [\"bug\"]\n\n\
[[issue_templates.bug.prompts]]\n\
key = \"estimate\"\nkind = \"text\"\nrequired = true\n",
);
let config_before = fs::read(tmp.path().join("tix.toml")).unwrap();
choose_stable_template(&mut app, 0);
app.form.as_mut().unwrap().focus = FormFocus::Title;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Form),
"a failed resolution keeps the form open, got {:?}",
app.mode
);
let form = app.form.as_ref().unwrap();
assert_eq!(form.template, "bug");
assert_eq!(form.template_scope.as_deref(), Some("repository"));
assert_eq!(form.title, "Templated bug", "the form state is unchanged");
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("did not resolve") && m.contains("estimate")),
"the diagnostic names the missing field: {:?}",
app.status_msg
);
assert!(
collect_md(&tmp.path().join("issues")).is_empty(),
"no partial issue file is written"
);
assert_eq!(
fs::read(tmp.path().join("tix.toml")).unwrap(),
config_before,
"tix.toml is byte-unchanged"
);
}
#[test]
fn wp05_a_scope_mismatch_is_reported_without_a_write() {
let (tmp, mut app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\n\
[issue_templates.bug.defaults]\ntitle = \"Templated bug\"\n",
);
choose_stable_template(&mut app, 0);
app.form.as_mut().unwrap().template_scope = Some("board:default".to_string());
app.form.as_mut().unwrap().focus = FormFocus::Title;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert!(app.form.is_some(), "the form state survives");
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("scoped to")),
"the mismatch is named: {:?}",
app.status_msg
);
assert_eq!(collect_md(&tmp.path().join("issues")).len(), 0);
}
#[test]
fn wp05_unknown_template_metadata_is_not_interpreted() {
let (_tmp, mut app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\
custom_field = \"keep me\"\n\n\
[issue_templates.bug.defaults]\n\
title = \"Templated bug\"\nlabels = [\"bug\"]\n",
);
assert_eq!(
app.store
.config
.issue_templates
.get("bug")
.unwrap()
.extensions
.get("custom_field")
.and_then(|value| value.as_str()),
Some("keep me"),
"the unknown key is preserved by the registry"
);
choose_stable_template(&mut app, 0);
let form = app.form.as_ref().unwrap();
assert_eq!(form.title, "Templated bug");
assert_eq!(form.labels, "bug");
for focus in FormFocus::RING {
assert_ne!(
form.value_of(focus),
"keep me",
"{focus:?} must not read preserved unknown metadata"
);
}
app.form.as_mut().unwrap().focus = FormFocus::Title;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let issue = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| issue.title == "Templated bug")
.expect("the issue was created");
assert!(
!issue.extra_frontmatter.contains_key("custom_field"),
"unknown metadata never becomes an issue field"
);
}
#[test]
fn wp05_no_template_submit_is_unchanged() {
let (_tmp, mut app) = template_app("[issue_templates.bug]\nname = \"Bug report\"\n");
let _ = app.handle_key(press_char('n')).unwrap();
let form = app.form.as_ref().unwrap();
assert_eq!(form.template, "", "no template is selected by default");
assert!(form.template_scope.is_none());
for c in "Plain issue".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let issue = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| issue.title == "Plain issue")
.expect("submitting without a template still works");
assert_eq!(issue.labels.len(), 0);
assert_eq!(issue.body.len(), 0);
}
fn write_series(
dir: &std::path::Path,
name: &str,
number: u32,
id: &str,
anchor: &str,
state: &str,
) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{name}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
series:\n id: {id}\n name: {name}\n state: {state}\n rule:\n kind: daily\n \
anchor: {anchor}\n---\n\nSeries body.\n"
);
fs::write(dir.join(format!("2026-01-01-{id}.md")), contents).unwrap();
}
#[test]
fn a_due_series_raises_the_warning_on_start_and_nothing_else_changes() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
write_series(&dir, "Later", 2, "s-future", "2999-01-01", "active");
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
assert!(
matches!(app.mode, Mode::RecurringWarning),
"start detection raises the warning"
);
let overlay = app.recurring_warning.as_ref().expect("the overlay is open");
assert_eq!(overlay.len(), 1, "only the series that is due: {overlay:?}");
assert_eq!(
overlay.current().map(|w| w.series_id.as_str()),
Some("s-past")
);
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("Due occurrences")),
"the overlay says what it is: {rows:?}"
);
assert!(
rows.iter().any(|line| line.contains("g: generate")),
"and advertises its keys: {rows:?}"
);
}
#[test]
fn closing_the_warning_writes_nothing_and_the_next_refresh_asks_again() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
assert!(matches!(app.mode, Mode::RecurringWarning));
let before = issue_bytes(&dir);
let _ = app.handle_key(press_char('c')).unwrap();
assert!(matches!(app.mode, Mode::Board), "`c` closes the overlay");
assert!(app.recurring_warning.is_none());
assert_eq!(issue_bytes(&dir), before, "closing writes nothing");
let _ = app.handle_key(press_char('r')).unwrap();
assert!(
matches!(app.mode, Mode::RecurringWarning),
"an unanswered warning comes back on the next refresh"
);
}
#[test]
fn a_refresh_leaves_an_open_warning_alone() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
let selected = app.recurring_warning.as_ref().unwrap().selected;
let _ = app.handle_key(press_char('r')).unwrap();
assert!(matches!(app.mode, Mode::RecurringWarning));
assert_eq!(
app.recurring_warning.as_ref().unwrap().selected,
selected,
"a refresh does not move the cursor"
);
}
#[test]
fn generating_from_the_warning_creates_the_occurrence_once() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('g')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("generated #"), "{status:?}");
assert!(
app.recurring_warning.is_none(),
"the answered warning is resolved and the overlay closes"
);
assert_eq!(app.store.list().unwrap().len(), 2, "one occurrence");
let _ = app.handle_key(press_char('r')).unwrap();
assert!(app.recurring_warning.is_none(), "nothing is due any more");
assert_eq!(app.store.list().unwrap().len(), 2);
}
#[test]
fn deferring_from_the_warning_moves_the_candidate_on() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('d')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("defer"), "{status:?}");
let definition = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| issue.number == 1)
.unwrap();
let cursor = definition
.series
.as_ref()
.and_then(|series| series.next_cursor.clone());
assert_eq!(
cursor.as_deref(),
Some("2000-01-01"),
"defer moves the cursor past the deferred date"
);
assert!(
definition
.series
.as_ref()
.is_some_and(|series| series.skipped.is_empty()),
"and does not record it as skipped"
);
assert_eq!(
app.store.list().unwrap().len(),
1,
"deferring creates no issue"
);
}
#[test]
fn the_schedule_page_lists_the_series_and_acts_on_the_selected_one() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
write_series(&dir, "Later", 2, "s-future", "2999-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('c')).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
assert!(matches!(app.mode, Mode::Schedule), "F8 opens the page");
let rows = render_rows(&app);
let drawn = rows.join("\n");
assert!(drawn.contains("F8 Schedule"), "{drawn}");
assert!(drawn.contains("Series (2)"), "{drawn}");
assert!(drawn.contains("Nightly close"), "{drawn}");
assert!(
drawn.contains("[due]"),
"the due series is flagged: {drawn}"
);
assert!(
drawn.contains("g generate"),
"the detail advertises the keys: {drawn}"
);
let selected_id = app
.schedule_detail(0)
.expect("the selected series has a detail")
.lines
.into_iter()
.find(|(key, _)| key == "id")
.map(|(_, value)| value)
.expect("the detail names the id");
let _ = app.handle_key(press_char('p')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("pause"), "{status:?}");
let paused = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| {
issue
.series
.as_ref()
.is_some_and(|series| series.id == selected_id)
})
.expect("the selected series reads back");
assert_eq!(
paused.series.as_ref().and_then(|series| series.state),
Some(crate::recurrence::series::SeriesLifecycle::Paused),
"the selected series is the one that paused: {status:?}"
);
let _ = app.handle_key(press_char('x')).unwrap();
let ended = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| {
issue
.series
.as_ref()
.is_some_and(|series| series.id == selected_id)
})
.unwrap();
assert_eq!(
ended.series.as_ref().and_then(|series| series.state),
Some(crate::recurrence::series::SeriesLifecycle::Ended)
);
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.schedule_state().selected, 1);
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.schedule.is_none(), "the page's state is dropped");
}
#[test]
fn the_schedule_page_with_no_series_says_how_to_make_one() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
let drawn = render_rows(&app).join("\n");
assert!(drawn.contains("Series (0)"), "{drawn}");
assert!(
drawn.contains("No recurring series yet"),
"the empty state says how to fill it: {drawn}"
);
}
#[test]
fn a_series_with_nothing_due_is_told_so_rather_than_guessed_at() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Later", 1, "s-future", "2999-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
let _ = app.handle_key(press_char('g')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("nothing due"),
"the page does not invent a date: {status:?}"
);
assert_eq!(app.store.list().unwrap().len(), 1, "and writes nothing");
}
#[test]
fn the_series_tab_defines_a_first_series_through_the_shared_cli_service() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
let empty = render_rows_in(&app, 160, 44).join("\n");
assert!(empty.contains("No recurring series yet"), "{empty}");
assert!(
empty.contains("`tix recurring create"),
"the CLI command survives in the frame: {empty}"
);
assert!(
empty.contains("--rule daily --anchor <DATE>`"),
"the CLI hint survives in the frame: {empty}"
);
assert!(
empty.contains("or press `n` to"),
"and the TUI offers its own key: {empty}"
);
assert!(empty.contains("create one here."), "{empty}");
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('n')).unwrap();
assert!(
app.schedule_state().series_form.is_some(),
"`n` opens the create form"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"opening the form writes nothing"
);
let form = render_rows_in(&app, 160, 44).join("\n");
assert!(form.contains("New recurring series"), "{form}");
assert!(form.contains("title"), "{form}");
assert!(form.contains("rule"), "{form}");
assert!(form.contains("anchor"), "{form}");
assert!(
form.contains("daily | weekly | monthly"),
"the rule field names the families the form can complete: {form}"
);
assert!(
!form.contains("interval | cron-like"),
"and does not offer a family it would always refuse: {form}"
);
assert!(
form.contains("`interval` and `cron-like` rules need the CLI"),
"the families it cannot complete are named as the CLI's: {form}"
);
assert!(form.contains("YYYY-MM-DD"), "{form}");
for c in "Nightly close".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for c in "daily".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for c in "2026-01-05".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("created series `"), "{status:?}");
assert!(
app.schedule_state().series_form.is_none(),
"a saved form closes"
);
let listed = render_rows_in(&app, 160, 44).join("\n");
assert!(listed.contains("Series (1)"), "{listed}");
assert!(listed.contains("Nightly close"), "{listed}");
let cli = tix_ok(tmp.path(), &["recurring", "list"]);
assert!(cli.contains("Nightly close"), "the CLI lists it: {cli}");
let id = cli
.lines()
.nth(1)
.and_then(|line| line.split('\t').next())
.filter(|id| !id.is_empty() && *id != "id")
.expect("the CLI's listing names the series id");
assert!(
cli.lines()
.any(|line| line.starts_with(id) && line.contains("\tactive\t")),
"and reports it active: {cli}"
);
let shown = tix_ok(tmp.path(), &["recurring", "show", id]);
assert!(shown.contains("rule: daily"), "{shown}");
assert!(shown.contains("anchor: 2026-01-05"), "{shown}");
}
#[test]
fn every_rule_family_the_form_advertises_can_be_created_from_it() {
let hint = crate::ui::schedule::SeriesField::Rule.hint();
let families: Vec<&str> = hint.split(" | ").collect();
assert!(
!families.is_empty(),
"the rule hint names something: {hint}"
);
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
for (index, family) in families.iter().enumerate() {
let title = format!("Family {index} {family}");
let _ = app.handle_key(press_char('n')).unwrap();
assert!(
app.schedule_state().series_form.is_some(),
"`n` opens the form for `{family}`"
);
for c in title.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for c in family.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for c in "2026-01-05".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(
app.schedule_state().series_form.is_none() && status.contains("created series `"),
"the advertised family `{family}` is created from the form, not refused: {status:?}"
);
}
let cli = tix_ok(tmp.path(), &["recurring", "list"]);
for (index, family) in families.iter().enumerate() {
let title = format!("Family {index} {family}");
let line = cli
.lines()
.find(|line| line.contains(&title))
.unwrap_or_else(|| panic!("the CLI lists `{title}`: {cli}"));
let id = line
.split('\t')
.next()
.filter(|id| !id.is_empty() && *id != "id")
.expect("the listing names the series id");
let shown = tix_ok(tmp.path(), &["recurring", "show", id]);
assert!(
shown.contains(&format!("rule: {family}")),
"`{family}` round-trips through the CLI: {shown}"
);
}
}
#[test]
fn the_series_form_refuses_a_bad_field_with_a_reason_and_writes_nothing() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
let cases = [
("", "daily", "2026-01-05", "display name"),
(
"Nightly",
"fortnightly",
"2026-01-05",
"rule kind must be one of",
),
("Nightly", "daily", "yesterday", "not a valid `YYYY-MM-DD`"),
];
for (title, rule, anchor, phrase) in cases {
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('n')).unwrap();
for (index, value) in [title, rule, anchor].iter().enumerate() {
if index > 0 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
for c in value.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let form = app
.schedule_state()
.series_form
.clone()
.expect("a refusal keeps the form open");
let error = form.error.clone().unwrap_or_default();
assert!(
error.contains(phrase),
"`{title}/{rule}/{anchor}` is refused with `{phrase}`: {error}"
);
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("daily | weekly | monthly"), "{drawn}");
assert!(
drawn.contains("`interval` and `cron-like` rules need the CLI"),
"{drawn}"
);
assert_eq!(tree_bytes(tmp.path()), before, "a refusal writes nothing");
assert_eq!(form.value(crate::ui::schedule::SeriesField::Title), title);
assert_eq!(form.value(crate::ui::schedule::SeriesField::Rule), rule);
assert_eq!(form.value(crate::ui::schedule::SeriesField::Anchor), anchor);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.schedule_state().series_form.is_none());
assert_eq!(tree_bytes(tmp.path()), before, "cancel writes nothing");
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("nothing was written")
);
}
}
#[test]
fn the_series_tabs_other_keys_are_unchanged_and_the_form_owns_them_while_open() {
let (tmp, dir) = make_fixture();
write_series(&dir, "A", 1, "s-a", "2000-01-01", "active");
write_series(&dir, "B", 2, "s-b", "2000-01-01", "active");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('c')).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
assert_eq!(app.schedule_state().selected, 0);
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.schedule_state().selected, 1);
let _ = app.handle_key(press_char('k')).unwrap();
assert_eq!(app.schedule_state().selected, 0);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Cycles
);
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Series
);
let _ = app.handle_key(press_char('n')).unwrap();
let _ = app.handle_key(press_char('q')).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
let form = app
.schedule_state()
.series_form
.clone()
.expect("the form stays open");
assert_eq!(
form.value(crate::ui::schedule::SeriesField::Title),
"qj",
"`q` and `j` were typed, not acted on"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.schedule_state().series_form.is_none());
assert!(
matches!(app.mode, Mode::Schedule),
"`esc` cancels the form, not the page"
);
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
}
#[test]
fn the_help_overlay_on_the_series_tab_names_the_create_key() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
let _ = app.handle_key(press_char('?')).unwrap();
assert!(
matches!(app.mode, Mode::Help { .. }),
"`?` opens the overlay"
);
assert_eq!(
app.schedule_help_tab(),
Some(crate::ui::schedule::ScheduleTab::Series),
"the overlay knows which page it was opened from"
);
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(
drawn.contains("create a new series"),
"the overlay advertises the create key: {drawn}"
);
assert!(
!drawn.contains("define a new schedule"),
"the cycle tab's keys are absent: {drawn}"
);
assert!(
!drawn.contains("edit the selected schedule"),
"the cycle tab's keys are absent: {drawn}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Schedule));
}
const WEEKLY_SCHEDULE: &str = "\n[cycle_schedules.\"cs-weekly\"]\n\
name = \"Weekly sprint\"\n\
cadence = { kind = \"weekly\" }\n\
duration = { count = 1, unit = \"weeks\" }\n\
start_date = \"2000-01-01\"\n\
timezone = \"UTC\"\n\
look_ahead = 1\n";
fn config_with(tmp: &TempDir, extra: &str) -> Config {
let path = tmp.path().join("tix.toml");
let mut text = fs::read_to_string(&path).unwrap();
text.push_str(extra);
fs::write(&path, text).unwrap();
load_config(tmp)
}
fn write_generated_cycle(
root: &std::path::Path,
sequence: u32,
start: &str,
due: &str,
state: &str,
) {
crate::cycle_store::CycleStore::new(root)
.save_record(
&crate::cycle_store::CycleRecord {
name: format!("Sprint {sequence}"),
description: None,
start_date: Some(start.to_string()),
due_date: Some(due.to_string()),
schedule_id: Some("cs-weekly".to_string()),
sequence: Some(sequence),
state: Some(state.to_string()),
},
None,
)
.unwrap();
}
fn tree_bytes(root: &std::path::Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
found.push((path.clone(), fs::read(&path).unwrap()));
}
}
}
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
fn stored_cycle_ids(root: &std::path::Path) -> Vec<String> {
let schedule = crate::cycle_schedule::config::CycleScheduleId::new("cs-weekly").unwrap();
let mut ids: Vec<String> = crate::cycle_store::CycleStore::new(root)
.list()
.unwrap()
.into_iter()
.filter_map(|record| record.sequence)
.map(|sequence| {
crate::cycle_schedule::identity::GeneratedCycleId::new(&schedule, sequence).to_string()
})
.collect();
ids.sort();
ids
}
fn open_cycle_tab(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
assert!(matches!(app.mode, Mode::Schedule), "F8 opens the page");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Cycles,
"`Tab` steps to the cycle tab"
);
}
fn type_reason(app: &mut App, text: &str) {
for c in text.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
}
#[test]
fn the_cycle_schedules_tab_ships_and_its_empty_state_is_explicit() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let drawn = render_rows(&app).join("\n");
assert!(drawn.contains("F8 Schedule"), "{drawn}");
assert!(
drawn.contains("[Series]"),
"the Series tab is untouched: {drawn}"
);
assert!(
drawn.contains("[Cycle schedules]"),
"the cycle tab is in the strip: {drawn}"
);
assert!(drawn.contains("Cycle schedules (0)"), "{drawn}");
let wide = render_rows_in(&app, 160, 40).join("\n");
assert!(
wide.contains("No cycle schedules yet"),
"the empty state says what is missing: {wide}"
);
assert!(
wide.contains("Create one with"),
"and how to fill it: {wide}"
);
assert!(
wide.contains("--confirm"),
"and that the CLI's create is confirmed: {wide}"
);
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Series
);
assert!(
render_rows(&app).join("\n").contains("Series (0)"),
"the Series tab renders as before"
);
}
#[test]
fn a_refused_generate_is_visible_on_the_schedule_page_and_does_not_leak_to_the_board() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let _ = app.handle_key(press_char('g')).unwrap();
let refusal = app.status_msg.clone().unwrap_or_default();
assert!(
refusal.contains("No cycle schedule selected"),
"the service explains why `g` cannot act: {refusal:?}"
);
let schedule = render_rows_in(&app, 160, 40).join("\n");
assert!(
schedule.contains(&refusal),
"the refusal is visible on the page that raised it: {schedule}"
);
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
let board = render_rows_in(&app, 160, 40).join("\n");
assert!(
!board.contains(&refusal),
"the schedule refusal is cleared instead of leaking onto the board: {board}"
);
}
#[test]
fn the_cycle_schedules_tab_renders_the_schedule_its_cycles_and_its_snapshots() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let drawn = render_rows_in(&app, 140, 44).join("\n");
assert!(drawn.contains("Cycle schedules (1)"), "{drawn}");
assert!(drawn.contains("Weekly sprint"), "{drawn}");
assert!(drawn.contains("cs-weekly"), "the stable id: {drawn}");
assert!(drawn.contains("weekly"), "the cadence summary: {drawn}");
assert!(drawn.contains("1 weeks"), "the duration: {drawn}");
assert!(drawn.contains("2000-01-01"), "the anchor: {drawn}");
assert!(
drawn.contains("+00:00"),
"the timezone, as `show` prints it: {drawn}"
);
assert!(drawn.contains("generated: 1"), "{drawn}");
assert!(drawn.contains("cs-weekly:000001"), "{drawn}");
assert!(drawn.contains("seq 1"), "{drawn}");
assert!(drawn.contains("Sprint 1"), "{drawn}");
assert!(drawn.contains("2000-01-08"), "{drawn}");
assert!(drawn.contains("ended"), "the evaluated warning: {drawn}");
assert!(drawn.contains("actions close,extend,ignore"), "{drawn}");
assert!(drawn.contains("findings (0)"), "{drawn}");
assert!(drawn.contains("snapshots (0)"), "{drawn}");
assert!(drawn.contains("warnings ("), "{drawn}");
}
#[test]
fn a_refresh_evaluates_cycle_schedules_and_writes_nothing() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "1-alpha.md", 1, "Alpha", "todo");
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
let evaluation = app
.cycle_schedule_evaluation()
.expect("startup evaluates the cycle schedules");
assert_eq!(evaluation.schedules_examined(), 1);
assert!(
evaluation
.warnings()
.iter()
.any(|warning| warning.kind
== crate::cycle_schedule::evaluate::ScheduleWarningKind::Ended),
"the machine-independent ended instance is reported: {:?}",
evaluation.warnings()
);
let warning_count = evaluation.warnings().len();
let schedules_examined = evaluation.schedules_examined();
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('r')).unwrap();
assert_eq!(
tree_bytes(tmp.path()),
before,
"a refresh writes nothing: no record, no snapshot, no cursor move"
);
assert!(!tmp.path().join("docs/cycle_snapshots").exists());
let after = app
.cycle_schedule_evaluation()
.expect("the refresh re-evaluated");
assert_eq!(after.warnings().len(), warning_count);
assert_eq!(after.schedules_examined(), schedules_examined);
let records = crate::cycle_store::CycleStore::new(tmp.path())
.list()
.unwrap();
assert_eq!(
records
.iter()
.find(|record| record.sequence == Some(1))
.and_then(|record| record.state.clone()),
Some("active".to_string()),
"the refresh did not close the instance"
);
}
#[test]
fn a_confirmed_cycle_action_goes_through_the_service_and_reports_its_outcome() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('c')).unwrap();
let prompt = app
.schedule_state()
.prompt
.expect("the reason prompt is open");
assert_eq!(
prompt.action,
crate::cycle_schedule::lifecycle::LifecycleAction::Close
);
assert_eq!(
prompt.cycle.as_ref().map(|cycle| cycle.as_str()),
Some("cs-weekly:000001"),
"the current (non-terminal) instance is the target"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"opening the prompt writes nothing"
);
assert!(
render_rows(&app).join("\n").contains("reason>"),
"the prompt is drawn"
);
type_reason(&mut app, "shipped");
assert_eq!(
app.schedule_state().prompt.as_ref().unwrap().buffer,
"shipped"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("applied"),
"the service's own word, not a private one: {status:?}"
);
assert!(
app.schedule_state().prompt.is_none(),
"the prompt is consumed"
);
let records = crate::cycle_store::CycleStore::new(tmp.path())
.list()
.unwrap();
assert_eq!(
records
.iter()
.find(|record| record.sequence == Some(1))
.and_then(|record| record.state.clone()),
Some("closed".to_string())
);
assert!(
tmp.path()
.join("docs/cycle_snapshots/cs-weekly.000001.md")
.is_file(),
"the close captured the immutable snapshot"
);
let drawn = render_rows_in(&app, 140, 44).join("\n");
assert!(drawn.contains("closed"), "{drawn}");
assert!(drawn.contains("snapshots (1)"), "{drawn}");
assert!(drawn.contains("cs-weekly.000001.md"), "{drawn}");
assert!(!drawn.contains("reason>"), "the prompt is gone: {drawn}");
}
#[test]
fn an_unconfirmed_cycle_action_writes_nothing() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('c')).unwrap();
assert!(app.schedule_state().prompt.is_some());
assert_eq!(
tree_bytes(tmp.path()),
before,
"an unopened request writes nothing"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.schedule_state().prompt.is_none());
assert!(matches!(app.mode, Mode::Schedule), "the page is still open");
assert_eq!(
tree_bytes(tmp.path()),
before,
"a cancelled action writes nothing"
);
assert!(!tmp.path().join("docs/cycle_snapshots").exists());
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("nothing was written"),
"the page says the refusal: {:?}",
app.status_msg
);
let _ = app.handle_key(press_char('c')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
tree_bytes(tmp.path()),
before,
"a reasonless action writes nothing"
);
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("nothing was written")
);
}
#[test]
fn the_cycle_tabs_lifecycle_keys_use_the_shared_service_and_its_own_words() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let _ = app.handle_key(press_char('p')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("applied"), "{status:?}");
assert_eq!(
app.store.config.cycle_schedules["cs-weekly"].state,
crate::cycle_schedule::config::ScheduleState::Paused
);
let _ = app.handle_key(press_char('g')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("rejected"), "{status:?}");
assert!(
status.contains("resume"),
"the refusal says what to do: {status:?}"
);
let _ = app.handle_key(press_char('r')).unwrap();
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("applied"),
"{:?}",
app.status_msg
);
let _ = app.handle_key(press_char('g')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("1 created"), "{status:?}");
assert_eq!(
stored_cycle_ids(tmp.path()),
["cs-weekly:000001", "cs-weekly:000002"]
);
let _ = app.handle_key(press_char('g')).unwrap();
assert_eq!(
stored_cycle_ids(tmp.path()),
["cs-weekly:000001", "cs-weekly:000002", "cs-weekly:000003"]
);
let _ = app.handle_key(press_char('i')).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("no-op"), "{status:?}");
assert!(status.contains("ignore"), "{status:?}");
let due_before = crate::cycle_store::CycleStore::new(tmp.path())
.list()
.unwrap()
.iter()
.find(|record| record.sequence == Some(1))
.and_then(|record| record.due_date.clone());
let _ = app.handle_key(press_char('e')).unwrap();
let prompt = app
.schedule_state()
.prompt
.clone()
.expect("extend asks why");
assert_eq!(
prompt.action,
crate::cycle_schedule::lifecycle::LifecycleAction::Extend
);
assert!(prompt.cycle.is_some());
type_reason(&mut app, "customer asked for one more week");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("applied"),
"{:?}",
app.status_msg
);
let due_after = crate::cycle_store::CycleStore::new(tmp.path())
.list()
.unwrap()
.iter()
.find(|record| record.sequence == Some(1))
.and_then(|record| record.due_date.clone());
assert_ne!(due_before, due_after, "the instance's window moved");
let _ = app.handle_key(press_char('a')).unwrap();
let prompt = app
.schedule_state()
.prompt
.clone()
.expect("adjust-future asks why");
assert_eq!(
prompt.action,
crate::cycle_schedule::lifecycle::LifecycleAction::AdjustFuture
);
assert!(prompt.cycle.is_none(), "the definition is the target");
type_reason(&mut app, "re-derive from the definition");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("no-op") || status.contains("applied"),
"the service's word either way: {status:?}"
);
assert!(status.contains("adjust-future"), "{status:?}");
}
#[test]
fn skipping_from_the_cycle_tab_needs_a_reason_and_never_regenerates() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let _ = app.handle_key(press_char('s')).unwrap();
let prompt = app.schedule_state().prompt.clone().expect("skip asks why");
assert_eq!(
prompt.action,
crate::cycle_schedule::lifecycle::LifecycleAction::Skip
);
type_reason(&mut app, "abandoned");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("applied"), "{status:?}");
let skipped = crate::cycle_store::CycleStore::new(tmp.path())
.list()
.unwrap()
.into_iter()
.find(|record| record.sequence == Some(1))
.and_then(|record| record.state.clone());
assert_eq!(skipped, Some("skipped".to_string()));
let _ = app.handle_key(press_char('g')).unwrap();
assert_eq!(
stored_cycle_ids(tmp.path()),
["cs-weekly:000001", "cs-weekly:000002"]
);
let drawn = render_rows_in(&app, 140, 44).join("\n");
assert!(drawn.contains("skipped"), "{drawn}");
}
#[test]
fn a_dangling_identity_is_rendered_rather_than_rebound() {
let (tmp, _dir) = make_fixture();
crate::cycle_store::CycleStore::new(tmp.path())
.save_record(
&crate::cycle_store::CycleRecord {
name: "Sprint 1".to_string(),
description: None,
start_date: Some("2000-01-01".to_string()),
due_date: Some("2000-01-08".to_string()),
schedule_id: Some("cs-gone".to_string()),
sequence: Some(1),
state: Some("active".to_string()),
},
None,
)
.unwrap();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let drawn = render_rows_in(&app, 140, 44).join("\n");
assert!(
drawn.contains("Unattached findings (1)"),
"the finding belongs to no listed schedule: {drawn}"
);
assert!(
drawn.contains("missing_schedule"),
"the index's own finding kind: {drawn}"
);
assert!(
drawn.contains("cs-gone:000001"),
"and the instance it is about: {drawn}"
);
assert!(
drawn.contains("E-tix-cycle-schedule"),
"rendered through the crate's one diagnostic renderer: {drawn}"
);
assert!(drawn.contains("Cycle schedules (0)"), "{drawn}");
let _ = app.handle_key(press_char('g')).unwrap();
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("No cycle schedule selected"),
"{:?}",
app.status_msg
);
}
#[test]
fn the_series_tab_and_the_board_keys_are_unchanged_by_the_cycle_tab() {
let (tmp, dir) = make_fixture();
write_series(&dir, "Nightly close", 1, "s-past", "2000-01-01", "active");
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('c')).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
let drawn = render_rows(&app).join("\n");
assert!(drawn.contains("Series (1)"), "{drawn}");
assert!(drawn.contains("Nightly close"), "{drawn}");
assert!(
!drawn.contains("Cycle schedules (1)"),
"the cycle rows are on their own tab"
);
let selected_id = app
.schedule_detail(0)
.expect("the series detail")
.lines
.into_iter()
.find(|(key, _)| key == "id")
.map(|(_, value)| value)
.unwrap();
let _ = app.handle_key(press_char('p')).unwrap();
let paused = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| {
issue
.series
.as_ref()
.is_some_and(|series| series.id == selected_id)
})
.unwrap();
assert_eq!(
paused.series.as_ref().and_then(|series| series.state),
Some(crate::recurrence::series::SeriesLifecycle::Paused),
"the Series tab's keys are untouched"
);
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('r')).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"the board's own keys still work"
);
}
fn fill_create_form(app: &mut App, values: &[&str]) {
for (index, value) in values.iter().enumerate() {
if index > 0 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
for c in value.chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
}
}
fn registry_text(root: &std::path::Path) -> String {
fs::read_to_string(root.join("tix.toml")).unwrap()
}
#[test]
fn the_cycle_tab_defines_a_schedule_through_the_shared_registry() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('n')).unwrap();
assert!(
app.schedule_state().form.is_some(),
"`n` opens the create form"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"opening the form writes nothing"
);
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("New cycle schedule"), "{drawn}");
assert!(drawn.contains("cadence"), "{drawn}");
assert!(
drawn.contains("daily | weekly | monthly | interval"),
"{drawn}"
);
fill_create_form(
&mut app,
&[
"cs-sprint",
"Sprint",
"weekly",
"",
"",
"1",
"weeks",
"2026-01-05",
"UTC",
"1",
],
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("created schedule `cs-sprint`"),
"{status:?}"
);
assert!(app.schedule_state().form.is_none(), "a saved form closes");
let text = registry_text(tmp.path());
assert!(
text.contains("[cycle_schedules.cs-sprint]"),
"the registry table: {text}"
);
assert!(text.contains("name = \"Sprint\""), "{text}");
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("Cycle schedules (1)"), "{drawn}");
assert!(drawn.contains("Sprint"), "{drawn}");
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('n')).unwrap();
fill_create_form(
&mut app,
&[
"cs-sprint",
"Again",
"daily",
"",
"",
"1",
"days",
"2026-02-01",
"UTC",
"",
],
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let form = app
.schedule_state()
.form
.clone()
.expect("the form stays open");
let error = form.error.clone().unwrap_or_default();
assert!(error.contains("already exists"), "{error}");
assert!(
error.contains("E-tix-cycle-schedule"),
"the shared diagnostic block: {error}"
);
assert_eq!(
form.value(crate::ui::schedule::ScheduleField::Id),
"cs-sprint"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"a refused create writes nothing"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.schedule_state().form.is_none());
assert_eq!(tree_bytes(tmp.path()), before);
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("nothing was written")
);
}
#[test]
fn a_field_that_is_not_a_number_is_refused_by_name_and_writes_nothing() {
let (tmp, _dir) = make_fixture();
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('n')).unwrap();
fill_create_form(
&mut app,
&[
"cs-x",
"X",
"weekly",
"",
"",
"many",
"weeks",
"2026-01-05",
"UTC",
"",
],
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let form = app
.schedule_state()
.form
.clone()
.expect("the form stays open");
let error = form.error.clone().unwrap_or_default();
assert!(error.contains("`many` is not a whole number"), "{error}");
assert!(error.contains("duration"), "the field is named: {error}");
assert_eq!(tree_bytes(tmp.path()), before, "nothing was written");
}
#[test]
fn the_cycle_tab_edits_the_selected_schedule_and_keeps_its_identity() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let _ = app.handle_key(press_char('E')).unwrap();
let form = app
.schedule_state()
.form
.clone()
.expect("`E` opens the edit form");
assert_eq!(form.editing.as_deref(), Some("cs-weekly"));
assert_eq!(
form.value(crate::ui::schedule::ScheduleField::Name),
"Weekly sprint"
);
assert_eq!(
form.value(crate::ui::schedule::ScheduleField::Cadence),
"weekly"
);
assert_eq!(
form.value(crate::ui::schedule::ScheduleField::Duration),
"1"
);
assert_eq!(
form.value(crate::ui::schedule::ScheduleField::StartDate),
"2000-01-01"
);
assert_eq!(
form.value(crate::ui::schedule::ScheduleField::LookAhead),
"1"
);
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("Edit cycle schedule `cs-weekly`"), "{drawn}");
assert!(drawn.contains("Weekly sprint"), "{drawn}");
let _ = app.handle_key(press(KeyCode::Delete)).unwrap();
for c in "Platform sprint".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("updated schedule `cs-weekly`"),
"{status:?}"
);
let text = registry_text(tmp.path());
assert!(
text.contains("[cycle_schedules.cs-weekly]"),
"the registry table keeps the identity: {text}"
);
assert!(text.contains("name = \"Platform sprint\""), "{text}");
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("Platform sprint"), "{drawn}");
assert!(
drawn.contains("cs-weekly:000001"),
"the instance is untouched: {drawn}"
);
let records = crate::cycle_store::CycleStore::new(tmp.path())
.list()
.unwrap();
assert_eq!(
records
.iter()
.find(|record| record.sequence == Some(1))
.and_then(|record| record.state.clone()),
Some("active".to_string())
);
}
#[test]
fn the_help_overlay_on_the_cycle_tab_names_the_keys_the_tab_acts_on() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
let mut app = App::new(&config).unwrap();
open_cycle_tab(&mut app);
let _ = app.handle_key(press_char('?')).unwrap();
assert!(
matches!(app.mode, Mode::Help { .. }),
"`?` opens the overlay"
);
assert_eq!(
app.schedule_help_tab(),
Some(crate::ui::schedule::ScheduleTab::Cycles),
"the overlay knows which page it was opened from"
);
let drawn = render_rows_in(&app, 160, 44).join("\n");
for expected in [
"generate the schedule's look-ahead",
"define a new schedule",
"edit the selected schedule",
"close the current cycle",
"extend the current cycle",
"re-derive future cycles",
"switch tabs",
] {
assert!(
drawn.contains(expected),
"the overlay advertises `{expected}`: {drawn}"
);
}
for absent in ["end the series", "defer the due date"] {
assert!(
!drawn.contains(absent),
"`{absent}` acts on nothing on this tab: {drawn}"
);
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Schedule));
assert_eq!(
app.schedule_help_tab(),
Some(crate::ui::schedule::ScheduleTab::Cycles),
"the overlay returns to the page, not to a tab it invented"
);
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(
app.schedule_help_tab(),
Some(crate::ui::schedule::ScheduleTab::Series)
);
let _ = app.handle_key(press_char('?')).unwrap();
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("end the series"), "{drawn}");
assert!(drawn.contains("defer the due date"), "{drawn}");
assert!(
!drawn.contains("generate the schedule's look-ahead"),
"the Series rows are its own: {drawn}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Schedule));
}
fn configure_wip(tmp: &TempDir, section: &str) {
let path = tmp.path().join("tix.toml");
let mut text = fs::read_to_string(&path).unwrap();
text.push_str(section);
fs::write(&path, text).unwrap();
}
const BLOCKING_WIP: &str = "\n[kanban.default.wip.units]\nd = 1\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_items = 1\npolicy = \"block\"\n";
#[test]
fn wip_tui_a_blocked_move_prompts_for_a_reason_and_writes_nothing() {
let (tmp, dir) = make_fixture();
configure_wip(&tmp, BLOCKING_WIP);
write_issue(&dir, "1-alpha.md", 1, "Alpha", "todo");
write_issue(&dir, "2-beta.md", 2, "Beta", "in progress");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
let before = issue_bytes(&dir);
let _ = app.handle_key(press_char('>')).unwrap();
assert!(
matches!(app.mode, Mode::WipOverride { .. }),
"the blocking limit raises the override modal: {:?}",
app.mode
);
assert_eq!(
issue_bytes(&dir),
before,
"opening the modal writes nothing — no file, no History"
);
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("WIP override")),
"{rows:?}"
);
assert!(
rows.iter().any(|line| line.contains("override_required")),
"the modal quotes the policy's own decision: {rows:?}"
);
let _ = app
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.wip_override.is_none());
assert_eq!(issue_bytes(&dir), before);
}
#[test]
fn wip_tui_confirming_the_override_moves_and_records_one_event() {
let (tmp, dir) = make_fixture();
configure_wip(&tmp, BLOCKING_WIP);
write_issue(&dir, "1-alpha.md", 1, "Alpha", "todo");
write_issue(&dir, "2-beta.md", 2, "Beta", "in progress");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
let _ = app.handle_key(press_char('>')).unwrap();
assert!(matches!(app.mode, Mode::WipOverride { .. }));
let _ = app
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.mode, Mode::WipOverride { .. }),
"an empty reason keeps the prompt open"
);
assert!(
app.status_msg
.as_deref()
.unwrap_or_default()
.contains("reason")
);
for c in "release week".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
let alpha = fs::read_to_string(dir.join("1-alpha.md")).unwrap();
assert!(alpha.contains("in progress"), "{alpha}");
assert!(alpha.contains("wip override"), "{alpha}");
assert!(alpha.contains("release week"), "{alpha}");
assert!(alpha.contains("max_items 1"), "{alpha}");
assert_eq!(
alpha.matches("wip override").count(),
1,
"exactly one event: {alpha}"
);
}
#[test]
fn wip_tui_a_warn_limit_moves_and_reports() {
let (tmp, dir) = make_fixture();
configure_wip(
&tmp,
"\n[kanban.default.wip.limits.\"in progress\"]\nmax_items = 1\npolicy = \"warn\"\n",
);
write_issue(&dir, "1-alpha.md", 1, "Alpha", "todo");
write_issue(&dir, "2-beta.md", 2, "Beta", "in progress");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
app.board.select_column(0);
let _ = app.handle_key(press_char('>')).unwrap();
assert!(matches!(app.mode, Mode::Board), "a warning does not prompt");
let status = app.status_msg.clone().unwrap_or_default();
assert!(
status.contains("warn") && status.contains("over_limit"),
"{status:?}"
);
let alpha = fs::read_to_string(dir.join("1-alpha.md")).unwrap();
assert!(alpha.contains("in progress"), "{alpha}");
assert!(
!alpha.contains("wip override"),
"a warning is not an override: {alpha}"
);
}
#[test]
fn wip_tui_the_snapshot_carries_the_limit_and_a_plain_board_stays_inert() {
let (tmp, dir) = make_fixture();
configure_wip(&tmp, BLOCKING_WIP);
write_issue(&dir, "1-alpha.md", 1, "Alpha", "todo");
write_issue(&dir, "2-beta.md", 2, "Beta", "in progress");
write_issue(&dir, "3-gamma.md", 3, "Gamma", "in progress");
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
let usage = app
.wip
.column("in progress")
.expect("the configured column has a usage");
assert_eq!(usage.items, 2);
assert_eq!(usage.cell(), "2/1!");
assert_eq!(usage.state(), crate::wip::LimitState::Over);
assert_eq!(
app.wip.column("todo").expect("usage").cell(),
"1",
"a column with no limit carries its plain count"
);
let rows = render_rows(&app);
assert!(
rows.iter().any(|line| line.contains("in progress (")),
"{rows:?}"
);
let (plain_tmp, plain_dir) = make_fixture();
write_issue(&plain_dir, "1-alpha.md", 1, "Alpha", "todo");
write_issue(&plain_dir, "2-beta.md", 2, "Beta", "in progress");
let plain_config = load_config(&plain_tmp);
let plain = App::new(&plain_config).unwrap();
assert!(!plain.wip.is_configured());
assert!(plain.wip.violations().next().is_none());
let plain_rows = render_rows(&plain);
assert!(
plain_rows.iter().all(|line| !line.contains('!')),
"no limit, no violation marker anywhere: {plain_rows:?}"
);
}
fn health_fixture() -> (TempDir, PathBuf) {
let (tmp, dir) = make_fixture();
let write = |name: &str, number: u32, title: &str, extra: &str| {
fs::write(
dir.join(name),
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
{extra}---\n\nBody.\n"
),
)
.unwrap();
};
write("1-parent.md", 1, "Parent", "");
write(
"2-child.md",
2,
"Child",
"parent: 1\nhealth:\n state: at_risk\n source: current\n policy_version: health-v1\n",
);
write(
"3-legacy.md",
3,
"Legacy",
"health:\n state: from_the_future\n",
);
write("4-orphan.md", 4, "Orphan", "parent: 99\n");
(tmp, dir)
}
fn health_app() -> (TempDir, App) {
let (tmp, dir) = health_fixture();
let _ = dir;
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
(tmp, app)
}
fn open_card_for(app: &mut App, number: u32) {
app.board.select_column(0);
let index = app
.board
.column_cards(0)
.iter()
.position(|card| card.number == number)
.unwrap_or_else(|| panic!("issue #{number} is not in the first column"));
app.board.selected = index;
open_detail(app);
}
fn health_snapshot(app: &App) -> String {
let dir = &app.store.issues_dir;
let mut files: Vec<(PathBuf, String)> = fs::read_dir(dir)
.unwrap()
.flatten()
.map(|entry| {
let path = entry.path();
let text = fs::read_to_string(&path).unwrap_or_default();
(path, text)
})
.collect();
files.sort();
files
.into_iter()
.map(|(path, text)| format!("{}\n{text}", path.display()))
.collect::<Vec<_>>()
.join("\n===\n")
}
#[test]
fn health_badge_marks_a_recording_issue_and_leaves_a_legacy_card_alone() {
let (_tmp, app) = health_app();
let rows = render_rows_in(&app, 240, 30);
let text = rows.join("\n");
assert!(
rows.iter().any(|row| row.contains("[at_risk]")),
"the child's card must carry its state: {text}"
);
assert!(
rows.iter().any(|row| row.contains("[unknown→at_risk]")),
"the parent's card must show the derived value beside its own: {text}"
);
let orphan_row = rows
.iter()
.find(|row| row.contains("Orphan"))
.expect("the orphan card");
assert!(
!orphan_row.contains("[at_risk]") && !orphan_row.contains("[unknown"),
"an issue with nothing recorded and nothing contributing carries no badge: {orphan_row}"
);
}
#[test]
fn health_detail_block_shows_the_state_the_rollup_and_the_diagnostics() {
let (_tmp, mut app) = health_app();
open_card_for(&mut app, 1);
assert!(
matches!(app.mode, Mode::Detail),
"Enter on a card opens the detail, got {:?}",
app.mode
);
let rows = render_rows_in(&app, 140, 40);
let text = rows.join("\n");
assert!(
rows.iter().any(|row| row.contains("health:")),
"the pane must draw a health field: {text}"
);
assert!(
rows.iter().any(|row| row.contains("at_risk")),
"the pane must draw the derived value: {text}"
);
assert!(
rows.iter().any(|row| row.contains("health derived:")),
"the pane must name the derived layer: {text}"
);
assert!(
rows.iter().any(|row| row.contains("#2 at_risk")),
"the pane must name the contributor: {text}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
open_card_for(&mut app, 3);
let rows = render_rows_in(&app, 160, 40);
let text = rows.join("\n");
assert!(
rows.iter().any(|row| row.contains("invalid_state")),
"the pane must draw this issue's diagnostics: {text}"
);
assert!(
rows.iter().any(|row| row.contains("remedy:")),
"and say what to do about them: {text}"
);
}
#[test]
fn health_page_shows_the_same_data_the_cli_exposes() {
let (_tmp, mut app) = health_app();
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
assert_eq!(app.mode, Mode::Stats, "F7 opens the Stats page");
let rows = render_rows_in(&app, 160, 40);
let text = rows.join("\n");
assert!(text.contains("Health (health-v1)"), "{text}");
assert!(text.contains("4 issues"), "{text}");
for scope in ["issue:1", "issue:2", "issue:3"] {
assert!(text.contains(scope), "{scope} must be on the page: {text}");
}
assert!(text.contains("at_risk"), "{text}");
assert!(text.contains("missing_child"), "{text}");
assert!(text.contains("invalid_state"), "{text}");
assert!(text.contains("remedy:"), "{text}");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.mode, Mode::Board);
let selected = app.board.selected;
let _ = app.handle_key(press_char('j')).unwrap();
assert_ne!(app.board.selected, selected, "the board cursor still moves");
}
#[test]
fn health_page_has_an_explicit_empty_state() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "1-alpha.md", 1, "Alpha", "todo");
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
let rows = render_rows_in(&app, 160, 40);
let text = rows.join("\n");
assert!(text.contains("No health is recorded"), "{text}");
assert!(text.contains("tix health set"), "{text}");
}
#[test]
fn health_form_records_through_the_shared_service_and_refuses_inertly() {
let (_tmp, mut app) = health_app();
open_card_for(&mut app, 1);
assert!(matches!(app.mode, Mode::Detail));
let action = app.handle_key(press_char('H')).unwrap();
assert!(
matches!(app.mode, Mode::HealthForm),
"H opens the form (mode {:?}, action {action:?}, form {:?})",
app.mode,
app.health_form.is_some()
);
assert!(app.health_form.is_some());
let before = health_snapshot(&app);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::HealthForm),
"a refused submission stays in the form: {:?}",
app.mode
);
assert_eq!(health_snapshot(&app), before, "a refusal writes nothing");
for c in "needs_attention".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.health_form.as_ref().map(|f| f.field_name()),
Some("actor")
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.health_form.as_ref().map(|f| f.field_name()),
Some("rationale")
);
for c in "blocked by upstream".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Detail),
"a valid submission closes the form: {:?}",
app.mode
);
let files: Vec<(PathBuf, String)> = fs::read_dir(&app.store.issues_dir)
.unwrap()
.flatten()
.map(|entry| {
let path = entry.path();
let text = fs::read_to_string(&path).unwrap_or_default();
(path, text)
})
.collect();
let stored = files
.iter()
.map(|(_, text)| text.clone())
.collect::<Vec<_>>()
.join("\n=== \n");
assert!(
stored.contains("state: needs_attention"),
"the state must be written: {stored}"
);
let with_state: Vec<&String> = files
.iter()
.filter(|(_, text)| text.contains("needs_attention"))
.map(|(_, text)| text)
.collect();
assert!(
!with_state.is_empty(),
"a file must carry the state: {stored}"
);
assert!(
with_state
.iter()
.any(|text| text.contains("blocked by upstream")),
"the file carrying the state must carry the rationale too: {with_state:?}"
);
assert!(stored.contains("blocked by upstream"), "{stored}");
let written = with_state[0];
assert_eq!(
written.lines().filter(|line| *line == "health:").count(),
1,
"the key is written once: {written}"
);
let rows = render_rows_in(&app, 140, 40);
let text = rows.join("\n");
assert!(text.contains("needs_attention"), "{text}");
assert!(text.contains("current"), "{text}");
}
#[test]
fn health_form_toggles_an_override_layer_and_records_it_as_one() {
let (_tmp, mut app) = health_app();
open_card_for(&mut app, 1);
let _ = app.handle_key(press_char('H')).unwrap();
assert!(matches!(app.mode, Mode::HealthForm));
for c in "on_track".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
assert_eq!(
app.health_form.as_ref().map(|f| f.field_name()),
Some("layer")
);
assert_eq!(
app.health_form.as_ref().map(|form| form.layer_name()),
Some("current")
);
let _ = app.handle_key(press_char(' ')).unwrap();
assert_eq!(
app.health_form.as_ref().map(|form| form.layer_name()),
Some("override")
);
let _ = app.handle_key(press_char(' ')).unwrap();
assert_eq!(
app.health_form.as_ref().map(|form| form.layer_name()),
Some("current")
);
let _ = app.handle_key(press_char(' ')).unwrap();
assert_eq!(
app.health_form.as_ref().map(|form| form.layer_name()),
Some("override")
);
for _ in 0..3 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
assert_eq!(
app.health_form.as_ref().map(|f| f.field_name()),
Some("rationale")
);
for c in "recovery plan approved".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let stored = fs::read_to_string(app.store.issues_dir.join("1-parent.md")).unwrap();
assert!(stored.contains("source: override"), "{stored}");
assert!(stored.contains("health_overridden"), "{stored}");
let rows = render_rows_in(&app, 140, 40);
let text = rows.join("\n");
assert!(text.contains("health derived:"), "{text}");
assert!(text.contains("#2 at_risk"), "{text}");
}
fn project_config(tmp: &TempDir, projects: &[(&str, &str)]) -> Config {
let mut config = load_config(tmp);
for (id, name) in projects {
config.projects.insert(
(*id).to_string(),
crate::config::ProjectConfiguration {
name: (*name).to_string(),
..Default::default()
},
);
}
config
}
fn write_project_issue(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
projects: &[&str],
) {
let block: String = projects
.iter()
.map(|id| format!("- {id}\n"))
.collect::<Vec<_>>()
.join("");
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nprojects:\n{block}created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
);
fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn t030_the_projects_picker_toggles_two_projects_and_the_form_submits_both() {
let (tmp, _dir) = make_fixture();
let config = project_config(&tmp, &[("p-1", "Platform"), ("p-2", "Infra")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Associated work".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(app.form.as_ref().unwrap().focus, FormFocus::Epics);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(app.form.as_ref().unwrap().focus, FormFocus::Projects);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let overlay = render_rows(&app).join("\n");
assert!(overlay.contains("Projects picker"), "got {overlay:?}");
assert!(
overlay.contains("Platform (p-1)"),
"the options are the service's, with the shared display form: {overlay:?}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.form.as_ref().unwrap().projects, "p-1,p-2");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board), "submit closes the form");
let listed = app.store.list().unwrap();
let created = listed
.iter()
.find(|issue| issue.title == "Associated work")
.expect("the form persisted the issue");
assert_eq!(
created.projects,
Some(vec!["p-1".to_string(), "p-2".to_string()]),
"both toggled projects are associated through the service"
);
}
#[test]
fn t030_toggling_a_preselected_project_off_keeps_the_rest() {
let (tmp, dir) = make_fixture();
write_project_issue(&dir, "2026-01-01-a.md", 1, "Spans two", &["p-1", "p-2"]);
let config = project_config(&tmp, &[("p-1", "Platform"), ("p-2", "Infra")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('C')).unwrap();
assert!(matches!(app.mode, Mode::Form));
let form = app.form.as_ref().expect("the clone opens the form");
assert_eq!(
form.projects, "p-1,p-2",
"the clone pre-selects the source's associations, so nothing is dropped by \
an edit that did not touch them"
);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.form.as_ref().unwrap().projects, "p-2");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let listed = app.store.list().unwrap();
let cloned = listed
.iter()
.find(|issue| issue.number != 1)
.expect("the clone was written");
assert_eq!(cloned.projects, Some(vec!["p-2".to_string()]));
let source = listed
.iter()
.find(|issue| issue.number == 1)
.expect("the source is untouched");
assert_eq!(
source.projects,
Some(vec!["p-1".to_string(), "p-2".to_string()]),
"the change flow must not write through to the source"
);
}
#[test]
fn t030_a_rejected_association_keeps_the_form_open_and_shows_the_service_diagnostic() {
let (tmp, _dir) = make_fixture();
let config = project_config(&tmp, &[("p-1", "Platform")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Rejected".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
app.form.as_mut().expect("the form is open").projects = "p-gone".to_string();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Form),
"a rejected association must not close the form, got {:?}",
app.mode
);
assert_eq!(
app.form.as_ref().map(|f| f.projects.clone()),
Some("p-gone".to_string()),
"and must not clear the field"
);
assert!(
app.store.list().unwrap().is_empty(),
"a refused association creates nothing at all"
);
let msg = app.status_msg.clone().expect("a message must be shown");
let expected = crate::commands::project::resolve_projects(&app.store.config, "p-gone")
.expect_err("the registry does not hold p-gone, so resolution must fail")
.to_string();
assert_eq!(
msg, expected,
"the TUI shows the service's own diagnostic, so it matches the CLI"
);
}
#[test]
fn t030_the_board_shows_the_project_partition_and_the_filter_narrows_to_members() {
let (tmp, dir) = make_fixture();
write_project_issue(&dir, "2026-01-01-a.md", 1, "Platform work", &["p-1"]);
write_project_issue(&dir, "2026-01-01-b.md", 2, "Infra work", &["p-2"]);
write_project_issue(&dir, "2026-01-01-c.md", 3, "Spans both", &["p-1", "p-2"]);
write_project_issue(&dir, "2026-01-01-d.md", 4, "Homeless", &[]);
let config = project_config(&tmp, &[("p-1", "Platform"), ("p-2", "Infra")]);
let mut app = App::new(&config).unwrap();
let total = |app: &App| {
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>()
};
assert_eq!(total(&app), 4);
let _ = app.handle_key(press_char('/')).unwrap();
for c in "group:project sort:project".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let text = render_rows_in(&app, 200, 60).join("\n");
assert!(text.contains("-- project: p-1"), "{text}");
assert!(text.contains("-- project: p-2"), "{text}");
assert!(
text.contains("-- project: (multiple projects)"),
"an issue in two projects gets one bucket, not two cards: {text}"
);
assert!(
text.contains("-- project: (no project)"),
"the no-project bucket is labelled and drawn last: {text}"
);
let _ = app.handle_key(press_char('f')).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
for _ in 0..11 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
for c in "Platform".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
total(&app),
2,
"the members only — and the issue in two projects is one of them"
);
let mut titles: Vec<String> = app
.board
.columns
.iter()
.flat_map(|column| column.cards.iter().map(|card| card.title.clone()))
.collect();
titles.sort();
assert_eq!(
titles,
vec!["Platform work".to_string(), "Spans both".to_string()]
);
let _ = app.handle_key(press_char('a'));
assert!(matches!(app.mode, Mode::Board));
assert_eq!(total(&app), 2, "an applied criterion keeps filtering");
}
#[test]
fn t030_a_stable_template_submit_carries_the_projects() {
let (_tmp, mut app) = template_app(
"[issue_templates.bug]\nname = \"Bug report\"\n\n\
[issue_templates.bug.defaults]\ntitle = \"Templated bug\"\nlabels = [\"bug\"]\n",
);
for (id, name) in [("p-1", "Platform"), ("p-2", "Infra")] {
app.store.config.projects.insert(
id.to_string(),
crate::config::ProjectConfiguration {
name: name.to_string(),
..Default::default()
},
);
}
choose_stable_template(&mut app, 0);
assert_eq!(
app.form.as_ref().unwrap().template_scope.as_deref(),
Some("repository"),
"the fixture chose a stable template, so the submit goes through the resolver"
);
app.form.as_mut().unwrap().focus = FormFocus::Projects;
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.form.as_ref().unwrap().projects, "p-1,p-2");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board), "got {:?}", app.mode);
let created = app
.store
.list()
.unwrap()
.into_iter()
.find(|issue| issue.title == "Templated bug")
.expect("the template route created the issue");
assert_eq!(
created.projects,
Some(vec!["p-1".to_string(), "p-2".to_string()]),
"the stable-template route must carry the Projects row, not discard it"
);
}
#[test]
fn t030_the_f10_portfolio_page_opens_lists_and_closes() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
let config = project_config(&tmp, &[("p-1", "Platform"), ("p-2", "Infra")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(10))).unwrap();
assert!(
matches!(app.mode, Mode::Portfolio),
"F10 opens the Portfolio page, got {:?}",
app.mode
);
assert!(
app.portfolio.is_some(),
"the page's state is `Some` exactly while the mode is"
);
let text = render_rows(&app).join("\n");
assert!(text.contains("F10 Portfolio"), "got {text:?}");
assert!(
text.contains("[Projects]"),
"the Projects tab is the page: {text:?}"
);
assert!(
text.contains("Platform (p-1)"),
"the rows are the service's: {text:?}"
);
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.portfolio_state().selected, 1);
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.portfolio_state().selected, 1, "clamped at the last row");
let _ = app.handle_key(press_char('k')).unwrap();
assert_eq!(app.portfolio_state().selected, 0);
let _ = app.handle_key(press_char('k')).unwrap();
assert_eq!(
app.portfolio_state().selected,
0,
"clamped at the first row"
);
let _ = app.handle_key(press_char('q')).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(
app.portfolio.is_none(),
"the page state is cleared on the way out"
);
}
#[test]
fn t030_the_f10_portfolio_page_draws_its_empty_state() {
let (tmp, _dir) = make_fixture();
let config = project_config(&tmp, &[]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(10))).unwrap();
assert!(matches!(app.mode, Mode::Portfolio));
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.portfolio_state().selected, 0);
let text = render_rows(&app).join("\n");
assert!(text.contains("No projects yet"), "got {text:?}");
assert!(
text.contains("Nothing selected"),
"an empty registry also leaves the detail pane with nothing to describe: {text:?}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert!(app.portfolio.is_none());
}
fn write_issue_with_epics(dir: &std::path::Path, number: u32, title: &str, epics: &[&str]) {
let list = epics
.iter()
.map(|id| format!("\"{id}\""))
.collect::<Vec<_>>()
.join(", ");
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\nepics: [{list}]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
---\n\nBody text."
);
fs::write(dir.join(format!("{number}.md")), contents).unwrap();
}
#[test]
fn t019_c008_the_detail_pane_reads_its_epic_block_through_the_real_app_builder() {
let (tmp, dir) = make_fixture();
let config_path = tmp.path().join("tix.toml");
let mut config = load_config(&tmp);
config.epics.insert(
"e-aaa111".to_string(),
crate::config::EpicConfiguration {
name: "Alpha".to_string(),
..crate::config::EpicConfiguration::default()
},
);
config.save(&config_path).unwrap();
let config = load_config(&tmp);
assert!(
config.epics.contains_key("e-aaa111"),
"precondition: the epic is registered"
);
write_issue_with_epics(&dir, 1, "Member", &["e-aaa111"]);
write_issue_with_epics(&dir, 2, "Dangling", &["e-gone1"]);
let mut app = App::new(&config).unwrap();
app.open_issue_by_filename("1.md").unwrap();
let text = render_rows_in(&app, 120, 60).join("\n");
assert!(text.contains("epics:"), "{text}");
assert!(text.contains("Alpha (e-aaa111)"), "{text}");
assert!(
text.contains("progress: 0/1 done"),
"the pane shows the service's rollup through the real builder: {text}"
);
assert!(text.contains("weighted progress:"), "{text}");
assert!(
text.contains("members open directly from the epic view"),
"the direct-navigation affordance survives the real fill: {text}"
);
app.open_issue_by_filename("2.md").unwrap();
let text = render_rows_in(&app, 120, 60).join("\n");
assert!(
text.contains("e-gone1 (missing reference)"),
"a dangling reference is visible in the pane: {text}"
);
assert!(
text.contains("not in the registry"),
"with the service's own reason: {text}"
);
}
fn epic_config(tmp: &TempDir, epics: &[(&str, &str)]) -> Config {
let mut config = load_config(tmp);
for (id, name) in epics {
config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
..Default::default()
},
);
}
config
}
#[test]
fn t031_the_epic_picker_toggles_two_epics_and_the_form_submits_both() {
let (tmp, _dir) = make_fixture();
let config = epic_config(&tmp, &[("e-aaa111", "Alpha"), ("e-bbb222", "Beta")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Epic work".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(app.form.as_ref().unwrap().focus, FormFocus::Epics);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let overlay = render_rows(&app).join("\n");
assert!(overlay.contains("Epics picker"), "got {overlay:?}");
assert!(
overlay.contains("Alpha (e-aaa111)"),
"the options are the service's, with the shared display form: {overlay:?}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.form.as_ref().unwrap().epics, "e-aaa111,e-bbb222");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board), "submit closes the form");
let listed = app.store.list().unwrap();
let created = listed
.iter()
.find(|issue| issue.title == "Epic work")
.expect("the form persisted the issue");
assert_eq!(
created.epics,
Some(vec!["e-aaa111".to_string(), "e-bbb222".to_string()]),
"both toggled epics are referenced through the service"
);
}
#[test]
fn t031_toggling_a_preselected_epic_off_keeps_the_rest() {
let (tmp, dir) = make_fixture();
write_issue_with_epics(&dir, 1, "Spans two", &["e-aaa111", "e-bbb222"]);
let config = epic_config(&tmp, &[("e-aaa111", "Alpha"), ("e-bbb222", "Beta")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('C')).unwrap();
assert!(matches!(app.mode, Mode::Form));
assert_eq!(
app.form.as_ref().unwrap().epics,
"e-aaa111,e-bbb222",
"the clone pre-selects the source's references"
);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(app.form.as_ref().unwrap().focus, FormFocus::Epics);
let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.form.as_ref().unwrap().epics, "e-bbb222");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let listed = app.store.list().unwrap();
let cloned = listed
.iter()
.find(|issue| issue.number != 1)
.expect("the clone was written");
assert_eq!(cloned.epics, Some(vec!["e-bbb222".to_string()]));
let source = listed
.iter()
.find(|issue| issue.number == 1)
.expect("the source is untouched");
assert_eq!(
source.epics,
Some(vec!["e-aaa111".to_string(), "e-bbb222".to_string()]),
"the change flow must not write through to the source"
);
}
#[test]
fn t031_a_rejected_epic_reference_keeps_the_form_open_and_shows_the_service_diagnostic() {
let (tmp, _dir) = make_fixture();
let config = epic_config(&tmp, &[("e-aaa111", "Alpha")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('n')).unwrap();
for c in "Bad ref".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
app.form.as_mut().unwrap().epics = "e-nope".to_string();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Form),
"the form stays open on a refusal"
);
assert_eq!(
app.form.as_ref().unwrap().epics,
"e-nope",
"the field keeps its value so the user can pick again"
);
let msg = app.status_msg.clone().expect("a message must be shown");
let expected = crate::commands::epic::resolve_epics(&app.store.config, "e-nope")
.expect_err("the registry does not hold e-nope, so resolution must fail")
.to_string();
assert_eq!(
msg, expected,
"the TUI shows the service's own diagnostic, so it matches the CLI"
);
assert!(
app.store
.list()
.unwrap()
.iter()
.all(|issue| issue.title != "Bad ref"),
"a refused reference creates nothing at all"
);
}
#[test]
fn t030_the_board_shows_the_epic_partition_and_the_filter_narrows_to_members() {
let (tmp, dir) = make_fixture();
write_issue_with_epics(&dir, 1, "Alpha work", &["e-aaa111"]);
write_issue_with_epics(&dir, 2, "Beta work", &["e-bbb222"]);
write_issue_with_epics(&dir, 3, "Spans both", &["e-aaa111", "e-bbb222"]);
write_issue_with_epics(&dir, 4, "Homeless", &[]);
let config = epic_config(&tmp, &[("e-aaa111", "Alpha"), ("e-bbb222", "Beta")]);
let mut app = App::new(&config).unwrap();
let total = |app: &App| {
app.board
.columns
.iter()
.map(|c| c.cards.len())
.sum::<usize>()
};
assert_eq!(total(&app), 4);
let _ = app.handle_key(press_char('/')).unwrap();
for c in "group:epic sort:epic".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let text = render_rows_in(&app, 200, 60).join("\n");
assert!(text.contains("-- epic: e-aaa111"), "{text}");
assert!(text.contains("-- epic: e-bbb222"), "{text}");
assert!(
text.contains("-- epic: (multiple epics)"),
"an issue in two epics gets one bucket, not two cards: {text}"
);
assert!(
text.contains("-- epic: (no epic)"),
"the no-epic bucket is labelled and drawn last: {text}"
);
let _ = app.handle_key(press_char('f')).unwrap();
assert!(matches!(app.mode, Mode::FilterModal));
for _ in 0..12 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press_char('a')).unwrap();
assert!(matches!(app.mode, Mode::Board));
assert_eq!(
total(&app),
2,
"the members only — and the issue in two epics is one of them"
);
let mut titles: Vec<String> = app
.board
.columns
.iter()
.flat_map(|column| column.cards.iter().map(|card| card.title.clone()))
.collect();
titles.sort();
assert_eq!(
titles,
vec!["Alpha work".to_string(), "Spans both".to_string()]
);
}
#[test]
fn t032_the_portfolio_epics_tab_opens_the_selected_member_issue_directly() {
let (tmp, dir) = make_fixture();
write_issue_with_epics(&dir, 1, "Alpha work", &["e-aaa111"]);
write_issue_with_epics(&dir, 2, "Beta work", &["e-bbb222"]);
write_issue_with_epics(&dir, 3, "Alpha too", &["e-aaa111"]);
let config = epic_config(&tmp, &[("e-aaa111", "Alpha"), ("e-bbb222", "Beta")]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(10))).unwrap();
assert!(matches!(app.mode, Mode::Portfolio));
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.portfolio_state().tab,
crate::ui::portfolio::PortfolioTab::Epics
);
let text = render_rows_in(&app, 140, 40).join("\n");
assert!(
text.contains("Epics (2)"),
"one row per registered epic: {text}"
);
assert!(text.contains("Alpha (e-aaa111)"), "{text}");
assert!(text.contains("Beta (e-bbb222)"), "{text}");
assert!(
text.contains("progress: 0/2 done"),
"the detail pane shows the service's rollup: {text}"
);
assert!(
text.contains("#1 Alpha work"),
"the member rows are the service's own derived labels: {text}"
);
let _ = app.handle_key(press_char('l')).unwrap();
assert_eq!(app.portfolio_state().member, 1, "l walks the members");
let _ = app.handle_key(press_char('h')).unwrap();
assert_eq!(app.portfolio_state().member, 0);
let text = render_rows_in(&app, 140, 40).join("\n");
assert!(
text.contains("> #1 Alpha work"),
"the cursor marks the member row `Enter` acts on: {text}"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Detail),
"Enter opens the member issue, not an epic pane"
);
assert_eq!(
app.detail.as_ref().map(|d| d.issue.number),
Some(1),
"the member issue the cursor named"
);
assert!(
app.portfolio.is_none(),
"the page state is cleared on the way out"
);
}
#[test]
fn t032_the_portfolio_epics_tab_draws_its_empty_state() {
let (tmp, _dir) = make_fixture();
let config = epic_config(&tmp, &[]);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(10))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.portfolio_state().tab,
crate::ui::portfolio::PortfolioTab::Epics
);
let text = render_rows(&app).join("\n");
assert!(text.contains("No epics yet"), "got {text:?}");
assert!(
text.contains("tix epic"),
"the empty state names how to fill it: {text:?}"
);
}
fn initiative_config(tmp: &TempDir, initiatives: &[(&str, &str, Option<&str>)]) -> Config {
let mut config = load_config(tmp);
for (id, name, date) in initiatives {
config.initiatives.insert(
(*id).to_string(),
crate::config::InitiativeConfiguration {
name: (*name).to_string(),
target_date: date.map(ToString::to_string),
..Default::default()
},
);
}
config
}
fn open_roadmap(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(10))).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.portfolio_state().tab,
crate::ui::portfolio::PortfolioTab::Roadmap,
"Tab cycles the page's tabs onto the Roadmap tab"
);
}
fn select_roadmap_row(app: &mut App, id: &str) {
let index = app
.portfolio_roadmap()
.expect("the Roadmap tab built its projection")
.initiatives
.iter()
.position(|row| row.id == id)
.unwrap_or_else(|| panic!("no roadmap row `{id}`"));
for _ in 0..index {
let _ = app.handle_key(press_char('j')).unwrap();
}
assert_eq!(app.portfolio_state().selected, index);
}
#[test]
fn t022_the_portfolio_roadmap_tab_renders_the_shared_projection() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
let config = initiative_config(
&tmp,
&[
("i-bbb222", "Beta initiative", Some("2026-06-01")),
("i-aaa111", "Alpha initiative", Some("2026-03-01")),
],
);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let projection = app
.portfolio_roadmap()
.expect("the tab built its projection")
.clone();
assert_eq!(projection.initiatives.len(), 2);
assert_eq!(
projection
.initiatives
.iter()
.map(|row| row.id.as_str())
.collect::<Vec<_>>(),
vec!["i-aaa111", "i-bbb222"],
"the CLI's ordering: target date ascending, then stable id"
);
let text = render_rows_in(&app, 320, 30).join("\n");
assert!(text.contains("Roadmap (2)"), "{text}");
assert!(
text.contains("> i-aaa111"),
"the cursor marks the first row: {text}"
);
for row in &projection.initiatives {
let headline = crate::commands::roadmap::roadmap_row_lines(row)[0].replace('\t', " ");
assert!(
text.contains(&headline),
"missing CLI row {headline:?}: {text}"
);
}
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.portfolio_state().selected, 1);
let text = render_rows_in(&app, 320, 30).join("\n");
assert!(text.contains("> i-bbb222"), "{text}");
let _ = app.handle_key(press_char('k')).unwrap();
assert_eq!(app.portfolio_state().selected, 0);
let detail = crate::commands::roadmap::roadmap_row_lines(&projection.initiatives[1])
.into_iter()
.map(|line| line.replace('\t', " "))
.collect::<Vec<_>>();
let _ = app.handle_key(press_char('j')).unwrap();
let text = render_rows_in(&app, 320, 30).join("\n");
for line in detail {
assert!(
text.contains(&line),
"missing CLI detail line {line:?}: {text}"
);
}
}
#[test]
fn t022_the_roadmap_tab_draws_its_explicit_empty_state() {
let (tmp, _dir) = make_fixture();
let config = initiative_config(&tmp, &[]);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(
app.portfolio_state().selected,
0,
"an empty list cannot move"
);
let text = render_rows_in(&app, 320, 30).join("\n");
assert!(text.contains("No initiatives yet"), "got {text:?}");
assert!(
text.contains("tix initiative create"),
"the empty state names how to fill it: {text:?}"
);
assert!(
text.contains("Nothing selected"),
"an empty registry leaves the detail pane with nothing to describe: {text:?}"
);
}
#[test]
fn t023_the_filter_control_normalizes_into_the_shared_dto() {
let (tmp, _dir) = make_fixture();
let mut config = initiative_config(
&tmp,
&[
("i-aaa111", "Alpha", Some("2026-03-01")),
("i-bbb222", "Beta", Some("2026-06-01")),
],
);
config.initiatives.get_mut("i-aaa111").unwrap().owner = Some("alice".to_string());
config.initiatives.get_mut("i-bbb222").unwrap().owner = Some("bob".to_string());
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
assert!(
app.roadmap_filter().is_empty(),
"the page opens on the empty filter"
);
let _ = app.handle_key(press_char('f')).unwrap();
assert!(app.roadmap_overlay().is_some());
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
for ch in "alice".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.roadmap_overlay().is_none(),
"an accepted filter closes the editor"
);
assert_eq!(app.roadmap_filter().owner.as_deref(), Some("alice"));
let projection = app.portfolio_roadmap().expect("projection").clone();
assert_eq!(
projection
.initiatives
.iter()
.map(|row| row.id.as_str())
.collect::<Vec<_>>(),
vec!["i-aaa111"],
"the shared predicate narrowed the set"
);
let text = render_rows_in(&app, 120, 30).join("\n");
assert!(
text.contains("filter: owner=alice"),
"the CLI's own filter line is shown: {text}"
);
}
#[test]
fn t023_an_invalid_filter_is_refused_and_writes_nothing() {
let (tmp, _dir) = make_fixture();
let config = initiative_config(&tmp, &[("i-aaa111", "Alpha", Some("2026-03-01"))]);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let before = fs::read(tmp.path().join("tix.toml")).unwrap();
let _ = app.handle_key(press_char('f')).unwrap();
for ch in "not-a-date".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.roadmap_overlay().is_some(),
"a refusal keeps the editor open"
);
assert!(
app.roadmap_filter().is_empty(),
"the refused filter was not installed"
);
assert!(
app.status_msg
.as_deref()
.is_some_and(|msg| msg.contains("YYYY-MM-DD")),
"the service's own message is reported: {:?}",
app.status_msg
);
assert_eq!(
fs::read(tmp.path().join("tix.toml")).unwrap(),
before,
"a refused filter writes nothing"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(app.roadmap_overlay().is_none());
}
#[test]
fn t024_roadmap_actions_write_through_the_registry_and_keep_stable_ids() {
let (tmp, _dir) = make_fixture();
let config = initiative_config(&tmp, &[("i-aaa111", "Alpha", Some("2026-03-01"))]);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let _ = app.handle_key(press_char('c')).unwrap();
for ch in "Gamma".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.roadmap_overlay().is_none(),
"an accepted create closes the overlay"
);
let gamma = app
.store
.config
.initiatives
.iter()
.find(|(_, record)| record.name == "Gamma")
.map(|(id, _)| id.clone())
.expect("the registry holds the new initiative");
assert!(gamma.starts_with("i-"), "a stable id: {gamma}");
assert!(
load_config(&tmp).initiatives.contains_key(&gamma),
"the create persisted through the registry's boundary"
);
select_roadmap_row(&mut app, "i-aaa111");
let _ = app.handle_key(press_char('e')).unwrap();
for _ in 0.."Alpha".len() {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for ch in "Renamed".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.store.config.initiatives["i-aaa111"].name, "Renamed");
assert!(
app.store.config.initiatives.contains_key("i-aaa111"),
"the stable id survives a rename"
);
assert_eq!(
app.store.config.initiatives.len(),
2,
"no second record was created"
);
select_roadmap_row(&mut app, "i-aaa111");
let _ = app.handle_key(press_char('a')).unwrap();
for ch in "p-1".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.store.config.initiatives["i-aaa111"].projects,
vec!["p-1".to_string()]
);
let after_first = fs::read(tmp.path().join("tix.toml")).unwrap();
let _ = app.handle_key(press_char('a')).unwrap();
for ch in "p-1".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.store.config.initiatives["i-aaa111"].projects,
vec!["p-1".to_string()],
"a repeated association collapses to one entry"
);
assert_eq!(
fs::read(tmp.path().join("tix.toml")).unwrap(),
after_first,
"the idempotent no-op wrote no bytes"
);
let _ = app.handle_key(press_char('x')).unwrap();
for ch in "p-1".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.store.config.initiatives["i-aaa111"].projects.len(), 0);
}
#[test]
fn t025_a_missing_reference_stays_visible_on_the_roadmap_row() {
let (tmp, _dir) = make_fixture();
let mut config = initiative_config(&tmp, &[("i-aaa111", "Alpha", Some("2026-03-01"))]);
config.initiatives.get_mut("i-aaa111").unwrap().projects = vec!["p-gone".to_string()];
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let row = app
.portfolio_roadmap()
.expect("projection")
.initiatives
.first()
.expect("the row stays in the roadmap")
.clone();
assert!(
row.references
.iter()
.any(|reference| !reference.resolution.is_resolved()),
"the dangling reference is recorded as unresolved"
);
let text = render_rows_in(&app, 320, 30).join("\n");
assert!(text.contains("project:p-gone"), "{text}");
assert!(
text.contains("[missing]"),
"the resolution is visible on the contribution: {text}"
);
assert!(
text.contains("! missing_reference"),
"the service's diagnostic is shown: {text}"
);
}
#[test]
fn t025_an_initiative_with_no_references_shows_its_explicit_empty_summary() {
let (tmp, _dir) = make_fixture();
let config = initiative_config(&tmp, &[("i-aaa111", "Alpha", Some("2026-03-01"))]);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let text = render_rows_in(&app, 320, 30).join("\n");
assert!(
text.contains("empty"),
"the summary state is explicit: {text}"
);
assert!(
text.contains("(empty: no source references)"),
"the CLI's explicit empty sentence: {text}"
);
assert!(
!text.contains("progress=0"),
"an empty summary is not a fabricated zero: {text}"
);
}
#[test]
fn t026_the_tab_and_the_cli_expose_the_same_rows() {
let (tmp, dir) = make_fixture();
write_project_issue(&dir, "2026-01-01-a.md", 1, "Alpha work", &["p-1"]);
let mut config = project_config(&tmp, &[("p-1", "Platform")]);
config.initiatives.insert(
"i-aaa111".to_string(),
crate::config::InitiativeConfiguration {
name: "Alpha initiative".to_string(),
target_date: Some("2026-03-01".to_string()),
projects: vec!["p-1".to_string()],
..Default::default()
},
);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let projection = app.portfolio_roadmap().expect("projection").clone();
let expected: Vec<String> =
crate::commands::roadmap::roadmap_row_lines(&projection.initiatives[0])
.into_iter()
.map(|line| line.replace('\t', " "))
.collect();
let text = render_rows_in(&app, 240, 40).join("\n");
for line in &expected {
assert!(
text.contains(line),
"the tab must render the CLI's line {line:?}: {text}"
);
}
assert!(
text.contains("progress=0/1"),
"the owning service's own value, not a TUI figure: {text}"
);
}
#[test]
fn t026_rendering_the_roadmap_tab_mutates_nothing() {
let (tmp, dir) = make_fixture();
write_issue(&dir, "2026-01-01-a.md", 1, "Alpha", "todo");
let mut config = initiative_config(&tmp, &[("i-aaa111", "Alpha", Some("2026-03-01"))]);
config.initiatives.get_mut("i-aaa111").unwrap().projects = vec!["p-gone".to_string()];
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let config_path = tmp.path().join("tix.toml");
let issue_path = dir.join("2026-01-01-a.md");
let before_config = fs::read(&config_path).unwrap();
let before_issue = fs::read(&issue_path).unwrap();
for _ in 0..3 {
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('k')).unwrap();
let _ = render_rows_in(&app, 140, 30);
}
assert_eq!(
fs::read(&config_path).unwrap(),
before_config,
"rendering wrote tix.toml"
);
assert_eq!(
fs::read(&issue_path).unwrap(),
before_issue,
"rendering wrote an issue file"
);
}
#[test]
fn t026_f9_stays_reserved_and_the_roadmap_is_not_a_new_mode() {
let (tmp, _dir) = make_fixture();
let config = initiative_config(&tmp, &[("i-aaa111", "Alpha", None)]);
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
assert_eq!(
app.bindings.action(
crate::keybindings::InputContext::Portfolio,
KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
),
None,
"F9 stays reserved on every page until the Data page exists"
);
assert_eq!(
app.top_mode,
crate::ui::nav::TopMode::Portfolio,
"the Roadmap tab is the F10 page's own mode, not a new one"
);
assert!(app.roadmap_overlay().is_none());
}
#[test]
fn t026_an_applied_filter_clamps_the_roadmap_cursor() {
let (tmp, _dir) = make_fixture();
let mut config = initiative_config(
&tmp,
&[
("i-aaa111", "Alpha", Some("2026-01-01")),
("i-bbb222", "Beta", Some("2026-02-01")),
("i-ccc333", "Gamma", Some("2026-03-01")),
],
);
config.initiatives.get_mut("i-aaa111").unwrap().owner = Some("alice".to_string());
let mut app = App::new(&config).unwrap();
open_roadmap(&mut app);
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.portfolio_state().selected, 2);
let _ = app.handle_key(press_char('f')).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
for ch in "alice".chars() {
let _ = app.handle_key(press_char(ch)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.portfolio_roadmap().unwrap().initiatives.len(), 1);
assert_eq!(
app.portfolio_state().selected,
0,
"the cursor clamps onto the only remaining row"
);
let text = render_rows_in(&app, 260, 30).join("\n");
assert!(text.contains("> i-aaa111"), "the marker is drawn: {text}");
assert!(
!text.contains("Nothing selected"),
"the detail pane describes the row, not a missing one: {text}"
);
let _ = app.handle_key(press_char('f')).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
for _ in 0.."alice".len() {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.portfolio_roadmap().unwrap().initiatives.len(), 3);
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
assert_eq!(app.portfolio_state().selected, 2);
app.store.config.initiatives.shift_remove("i-bbb222");
app.store.config.initiatives.shift_remove("i-ccc333");
let _ = app.handle_key(press_char('r')).unwrap();
assert_eq!(app.portfolio_roadmap().unwrap().initiatives.len(), 1);
assert_eq!(
app.portfolio_state().selected,
0,
"r clamps after the registry shrinks underneath the page"
);
}
const CAPACITY_UNIT: &str = "\n[kanban.default.wip.units]\nd = 1\n";
const CAPACITY_SCOPE: &str = "\n[cycles.\"Cycle 1\"]\nstart_date = \"2026-01-01\"\n\n\
[capacity.cycle-cycle-1]\ncycle = \"Cycle 1\"\ncapacity = 10\nunit = \"d\"\n";
const GENERATED_CAPACITY_SCOPE: &str = "\n[capacity.cycle-cs-000001]\n\
cycle = \"cs-weekly:000001\"\ncapacity = 10\nunit = \"d\"\n";
fn capacity_config(tmp: &TempDir, extra: &str) -> Config {
config_with(tmp, &format!("{CAPACITY_UNIT}{extra}"))
}
fn write_cycle_issue(
dir: &std::path::Path,
number: u32,
status: &str,
cycle: &str,
estimate: Option<&str>,
) {
let estimate = estimate.map_or_else(String::new, |value| format!("estimate: \"{value}\"\n"));
fs::write(
dir.join(format!("2026-01-01-issue-{number}.md")),
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\ncycles: [\"{cycle}\"]\n{estimate}created: \"2026-01-01\"\n\
updated: \"2026-01-01\"\n---\n\nBody text."
),
)
.unwrap();
}
fn open_capacity_tab(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
assert!(matches!(app.mode, Mode::Schedule), "F8 opens the page");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Capacity,
"`Tab` twice steps to the Capacity tab"
);
}
fn switch_to(app: &mut App, tab: crate::ui::schedule::ScheduleTab) {
for _ in 0..3 {
if app.schedule_state().tab == tab {
return;
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
panic!("the page never reached {tab:?}");
}
fn tix_in(dir: &std::path::Path, args: &[&str]) -> (bool, String, String) {
let output = std::process::Command::new(tix_bin())
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|error| panic!("spawn tix {args:?}: {error}"));
(
output.status.success(),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
)
}
fn cli_fields(stdout: &str) -> std::collections::BTreeMap<&str, &str> {
stdout
.lines()
.filter_map(|line| line.split_once(": "))
.collect()
}
fn files_containing(root: &std::path::Path, needle: &str) -> Vec<(PathBuf, String)> {
tree_bytes(root)
.into_iter()
.filter_map(|(path, bytes)| {
let text = String::from_utf8(bytes).ok()?;
text.contains(needle).then_some((path, text))
})
.collect()
}
#[test]
fn wp06_the_capacity_tab_shows_the_cli_values_field_for_field() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
write_cycle_issue(&dir, 2, "done", "Cycle 1", Some("2d"));
let config = capacity_config(&tmp, CAPACITY_SCOPE);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
let rows = app.capacity_rows().to_vec();
assert_eq!(rows.len(), 1, "one configured scope: {rows:?}");
assert_eq!(rows[0].title, "Cycle 1", "the scope's display label");
assert!(
rows[0].subtitle.contains("committed 8d of 10d"),
"the row carries the shared values: {}",
rows[0].subtitle
);
assert!(
rows[0].subtitle.contains("80%"),
"utilization is the DTO's: {}",
rows[0].subtitle
);
assert!(!rows[0].overflowing, "8 of 10 is not over the ceiling");
let (ok, stdout, stderr) = tix_in(
tmp.path(),
&[
"capacity",
"calculate",
"--scope",
"cycle",
"--id",
"Cycle 1",
],
);
assert!(ok, "the CLI calculates: {stderr}");
let cli = cli_fields(&stdout);
let detail = app
.capacity_detail(0)
.expect("the selected scope has a detail");
let tui: std::collections::BTreeMap<&str, &str> = detail
.fields
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let cli_keys: BTreeSet<&str> = cli.keys().copied().collect();
let tui_keys: BTreeSet<&str> = tui.keys().copied().collect();
assert_eq!(
tui_keys, cli_keys,
"the page prints exactly the CLI's fields, no more and no fewer"
);
for (key, value) in &cli {
assert_eq!(
tui.get(key),
Some(value),
"the TUI's `{key}` is the CLI's: TUI {tui:?} vs CLI {cli:?}"
);
}
assert_eq!(
cli.get("committed"),
Some(&"8"),
"the fixture commits 8: {stdout}"
);
assert_eq!(cli.get("completed"), Some(&"2"), "{stdout}");
assert_eq!(cli.get("remaining"), Some(&"6"), "{stdout}");
assert_eq!(cli.get("overflow"), Some(&"0"), "{stdout}");
assert_eq!(cli.get("utilization"), Some(&"80%"), "{stdout}");
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert!(drawn.contains("Capacity (1)"), "{drawn}");
assert!(drawn.contains("Capacity detail"), "{drawn}");
assert!(drawn.contains("committed: 8"), "{drawn}");
assert!(drawn.contains("utilization: 80%"), "{drawn}");
assert!(drawn.contains("unit: d"), "{drawn}");
}
#[test]
fn wp06_the_capacity_tab_has_an_explicit_empty_state() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
assert!(app.capacity_rows().is_empty(), "nothing is configured");
assert!(app.capacity_read(), "and that is a read, not a failure");
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert!(drawn.contains("Capacity (0)"), "{drawn}");
assert!(
drawn.contains("No capacity configured"),
"the empty state says what is missing: {drawn}"
);
assert!(
drawn.contains("tix capacity set"),
"and how to fill it: {drawn}"
);
assert!(!drawn.contains("no committed work"), "{drawn}");
}
#[test]
fn wp06_a_configured_but_uncommitted_scope_says_nothing_is_committed() {
let (tmp, _dir) = make_fixture();
let config = capacity_config(&tmp, CAPACITY_SCOPE);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
assert_eq!(app.capacity_rows().len(), 1);
assert!(
app.capacity_nothing_committed(),
"the only scope has committed nothing"
);
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert!(
drawn.contains("no committed work in any configured scope"),
"{drawn}"
);
let fields: std::collections::BTreeMap<&str, &str> = app
.capacity_detail(0)
.expect("the scope has a detail")
.fields
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
assert_eq!(fields.get("committed"), Some(&"0"), "counted, and zero");
assert_eq!(fields.get("utilization"), Some(&"0%"), "{fields:?}");
assert_eq!(fields.get("state"), Some(&"configured"), "{fields:?}");
}
#[test]
fn wp06_a_scope_that_cannot_be_calculated_is_not_claimed_to_have_committed_nothing() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
let config = capacity_config(
&tmp,
"\n[cycles.\"Cycle 1\"]\nstart_date = \"2026-01-01\"\n\n\
[capacity.cycle-cycle-1]\ncycle = \"Cycle 1\"\ncapacity = 10\nenabled = false\n",
);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
assert_eq!(app.capacity_rows().len(), 1, "the entry is still listed");
assert!(
!app.capacity_nothing_committed(),
"no scope has an answer, so the page cannot claim any of them committed nothing"
);
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert!(
!drawn.contains("no committed work"),
"an unknown value is not the statement that the value is zero: {drawn}"
);
let index = app
.capacity_rows()
.iter()
.position(|row| row.subtitle.contains("unconfigured"))
.expect("the disabled entry is listed as its own unconfigured scope");
let fields: std::collections::BTreeMap<&str, &str> = app
.capacity_detail(index)
.expect("the unconfigured scope still has a detail")
.fields
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
assert_eq!(fields.get("state"), Some(&"unconfigured"), "{fields:?}");
for absent in [
"capacity",
"unit",
"committed",
"completed",
"remaining",
"overflow",
"utilization",
] {
assert_eq!(
fields.get(absent),
Some(&"-"),
"`{absent}` is absent — never `0`: {fields:?}"
);
}
assert!(
app.capacity_rows()[index]
.subtitle
.contains("committed - of - "),
"the row's own cell shows `-` too: {}",
app.capacity_rows()[index].subtitle
);
assert!(drawn.contains("unconfigured"), "{drawn}");
let (ok, stdout, stderr) = tix_in(
tmp.path(),
&[
"capacity",
"calculate",
"--scope",
"cycle",
"--id",
"Cycle 1",
],
);
assert!(ok, "the CLI calculates: {stderr}");
let cli = cli_fields(&stdout);
assert_eq!(cli.get("unit"), Some(&"-"), "{stdout}");
assert_eq!(cli.get("capacity"), Some(&"-"), "{stdout}");
assert_eq!(cli.get("committed"), Some(&"-"), "{stdout}");
assert_eq!(cli.get("utilization"), Some(&"-"), "{stdout}");
}
#[test]
fn wp06_a_failed_capacity_read_is_not_reported_as_nothing_configured() {
let (tmp, _dir) = make_fixture();
let config = capacity_config(&tmp, CAPACITY_SCOPE);
fs::create_dir_all(tmp.path().join("docs/cycles/broken.md")).unwrap();
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
assert!(!app.capacity_read(), "the read failed");
assert!(
app.capacity_rows().is_empty(),
"so there are no rows to draw"
);
assert!(
!app.capacity_nothing_committed(),
"and no claim about what was committed"
);
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert_eq!(
drawn
.matches("Could not read the capacity snapshot")
.count(),
2,
"both panes state the failure: {drawn}"
);
assert!(
!drawn.contains("No capacity configured"),
"a failed read is not the empty state: {drawn}"
);
assert!(
!drawn.contains("Nothing selected"),
"nor an empty selection: {drawn}"
);
}
#[test]
fn wp06_a_missing_unit_is_a_diagnostic_with_a_remedy() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
write_cycle_issue(&dir, 2, "todo", "Cycle 1", Some("3h"));
let config = capacity_config(&tmp, CAPACITY_SCOPE);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
let rows = app.capacity_rows().to_vec();
assert!(rows[0].warned, "the scope carries a finding: {rows:?}");
assert!(
rows[0].subtitle.contains("committed 6d"),
"the countable issue is still counted: {}",
rows[0].subtitle
);
let detail = app.capacity_detail(0).expect("the scope has a detail");
let findings = detail.diagnostics.join("\n");
assert!(findings.contains("missing_conversion"), "{findings}");
assert!(
findings.contains("unit: d"),
"the unit is named: {findings}"
);
assert!(
findings.contains("issues: #2"),
"the uncountable issue is named: {findings}"
);
assert!(
findings.contains("help:"),
"a finding carries its remedy, not only its reason: {findings}"
);
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert!(drawn.contains("missing_conversion"), "{drawn}");
assert!(drawn.contains("help:"), "{drawn}");
let before = fs::read(dir.join("2026-01-01-issue-2.md")).unwrap();
app.open_issue_by_filename("2026-01-01-issue-2.md")
.expect("the issue opens");
assert!(matches!(app.mode, Mode::Detail));
let drawn = render_rows_in(&app, 200, 60).join("\n");
assert!(drawn.contains("capacity:"), "{drawn}");
assert!(drawn.contains("Cycle 1"), "the scope context: {drawn}");
assert!(
drawn.contains("cannot be counted in d"),
"the issue's own contribution is named: {drawn}"
);
assert!(drawn.contains("missing_conversion"), "{drawn}");
assert_eq!(
fs::read(dir.join("2026-01-01-issue-2.md")).unwrap(),
before,
"rendering a detail mutates nothing"
);
}
#[test]
fn wp06_an_overflow_is_a_report_and_writes_nothing() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
let config = capacity_config(
&tmp,
"\n[cycles.\"Cycle 1\"]\nstart_date = \"2026-01-01\"\n\n\
[capacity.cycle-cycle-1]\ncycle = \"Cycle 1\"\ncapacity = 5\nunit = \"d\"\n",
);
let mut app = App::new(&config).unwrap();
let before = tree_bytes(tmp.path());
open_capacity_tab(&mut app);
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('k')).unwrap();
let rows = app.capacity_rows().to_vec();
assert!(rows[0].overflowing, "over the ceiling: {rows:?}");
assert!(
rows[0].subtitle.contains("overflow 1d"),
"the excess is the DTO's: {}",
rows[0].subtitle
);
assert!(
rows[0].subtitle.contains("120%"),
"utilization floors at the committed ratio: {}",
rows[0].subtitle
);
let detail = app.capacity_detail(0).expect("the scope has a detail");
let findings = detail.diagnostics.join("\n");
assert!(
findings.contains("exceeds the configured capacity of 5d by 1d"),
"{findings}"
);
assert!(
findings.contains("issues: #1"),
"the issues are named: {findings}"
);
let drawn = render_rows_in(&app, 200, 40).join("\n");
assert!(drawn.contains("[over]"), "the row is flagged: {drawn}");
assert!(drawn.contains("overflow: 1"), "{drawn}");
let (ok, stdout, stderr) = tix_in(
tmp.path(),
&[
"capacity",
"calculate",
"--scope",
"cycle",
"--id",
"Cycle 1",
"--warnings",
],
);
assert!(ok, "{stderr}");
assert!(
stdout.contains("overflow: 1"),
"the value is always on stdout: {stdout}"
);
assert!(
stderr.contains("exceeds the configured capacity of 5d by 1d"),
"and the report on stderr: {stderr}"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"reading the capacity surfaces wrote nothing"
);
}
#[test]
fn wp06_the_board_cycle_group_header_carries_the_compact_capacity_cell() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
write_cycle_issue(&dir, 2, "done", "Cycle 1", Some("2d"));
write_cycle_issue(&dir, 3, "todo", "Cycle 2", Some("1d"));
let mut config = capacity_config(&tmp, CAPACITY_SCOPE);
{
let default = config.kanban.configurations.get_mut("default").unwrap();
default.columns = vec!["todo".into(), "done".into()];
default.done_column = Some("done".into());
}
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press_char('/')).unwrap();
for c in "group:cycle sort:cycle".chars() {
let _ = app.handle_key(press_char(c)).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Board));
let text = render_rows_in(&app, 240, 40).join("\n");
assert!(
text.contains("-- cycle: Cycle 1 (0/1 done, 0%) · 8/10 d · 80% --"),
"the configured cycle's header carries the shared cell: {text}"
);
assert!(
text.contains("-- cycle: Cycle 1 (1/1 done, 100%) · 8/10 d · 80% --"),
"and the done column's section carries it too: {text}"
);
assert!(
text.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"),
"an unconfigured cycle's header is exactly what it was: {text}"
);
assert!(text.contains("Issue 3"), "{text}");
assert!(text.contains("Issue 2"), "{text}");
}
#[test]
#[allow(clippy::too_many_lines)]
fn wp06_a_closed_snapshot_is_historical_after_a_reopen() {
let (tmp, _dir) = make_fixture();
config_with(&tmp, WEEKLY_SCHEDULE);
write_generated_cycle(tmp.path(), 1, "2000-01-01", "2000-01-08", "active");
let config = capacity_config(&tmp, GENERATED_CAPACITY_SCOPE);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
assert_eq!(app.capacity_rows().len(), 1, "one configured scope");
let label = app
.capacity_detail(0)
.and_then(|detail| detail.snapshot.as_ref())
.map(|snapshot| snapshot.label.clone())
.expect("a cycle scope always carries the snapshot section");
assert!(
label.contains("no close snapshot recorded"),
"an unclosed cycle states it: {label}"
);
switch_to(&mut app, crate::ui::schedule::ScheduleTab::Cycles);
let _ = app.handle_key(press_char('c')).unwrap();
assert!(
app.schedule_state().prompt.is_some(),
"closing asks for a reason"
);
type_reason(&mut app, "iteration complete");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("applied"), "the close applied: {status}");
let snapshot_path = tmp.path().join("docs/cycle_snapshots/cs-weekly.000001.md");
assert!(snapshot_path.exists(), "the close captured a snapshot");
let snapshot_bytes = fs::read(&snapshot_path).unwrap();
switch_to(&mut app, crate::ui::schedule::ScheduleTab::Capacity);
let snapshot = app
.capacity_detail(0)
.and_then(|detail| detail.snapshot.clone())
.expect("a cycle scope always carries the snapshot section");
assert!(
snapshot.label.contains("captured at close"),
"the evidence is labelled historical: {}",
snapshot.label
);
let fields: std::collections::BTreeMap<&str, &str> = snapshot
.fields
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let snapshot_id = fields
.get("snapshot_id")
.copied()
.expect("the identity is shown");
assert!(
snapshot_id.starts_with("cs-weekly:000001@"),
"the identity names the cycle and its close time: {snapshot_id}"
);
let closed_at = fields.get("closed_at").copied().unwrap_or_default();
assert!(
snapshot_id.ends_with(closed_at) && closed_at != "-",
"the identity carries the close time it recorded: {fields:?}"
);
assert_eq!(fields.get("capacity"), Some(&"10"), "{fields:?}");
assert_eq!(fields.get("unit"), Some(&"d"), "{fields:?}");
assert!(
fields.get("closed_by").is_some_and(|value| *value != "-"),
"the actor is shown: {fields:?}"
);
let drawn = render_rows_in(&app, 200, 50).join("\n");
assert!(drawn.contains("closed snapshot —"), "{drawn}");
assert!(drawn.contains("snapshot_id:"), "{drawn}");
switch_to(&mut app, crate::ui::schedule::ScheduleTab::Cycles);
let _ = app.handle_key(press_char('R')).unwrap();
let prompt = app
.schedule_state()
.prompt
.clone()
.expect("reopening asks for a reason");
assert_eq!(
prompt.action,
crate::cycle_schedule::lifecycle::LifecycleAction::Reopen
);
assert_eq!(
prompt.cycle.as_ref().map(|id| id.as_str().to_string()),
Some("cs-weekly:000001".to_string()),
"the closed instance is the one a reopen undoes"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.status_msg
.clone()
.unwrap_or_default()
.contains("records a reason"),
"a blank reason is refused"
);
assert!(
app.schedule_state().prompt.is_none(),
"the refused prompt is closed"
);
let _ = app.handle_key(press_char('R')).unwrap();
type_reason(&mut app, "capacity was wrong");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let status = app.status_msg.clone().unwrap_or_default();
assert!(status.contains("applied"), "the reopen applied: {status}");
let events = files_containing(tmp.path(), "cycle_reopened");
assert_eq!(events.len(), 1, "one record carries the event: {events:?}");
assert!(
events[0].1.contains("capacity was wrong"),
"the reason is recorded: {}",
events[0].1
);
assert!(
events[0].1.matches("cycle_reopened").count() == 1,
"exactly one event: {}",
events[0].1
);
assert_eq!(
fs::read(&snapshot_path).unwrap(),
snapshot_bytes,
"the prior snapshot is not rewritten by the reopen"
);
switch_to(&mut app, crate::ui::schedule::ScheduleTab::Capacity);
let snapshot = app
.capacity_detail(0)
.and_then(|detail| detail.snapshot.clone())
.expect("the snapshot section survives the reopen");
assert!(
snapshot.label.contains("reopened"),
"the evidence is labelled historical after the reopen: {}",
snapshot.label
);
assert_eq!(
snapshot
.fields
.iter()
.find(|(key, _)| key == "snapshot_id")
.map(|(_, value)| value.as_str()),
Some(snapshot_id),
"the identity is the close's, unchanged"
);
}
#[test]
fn wp06_the_capacity_tab_and_the_reopen_route_are_reachable_by_pointer() {
let (tmp, _dir) = make_fixture();
let config = config_with(&tmp, WEEKLY_SCHEDULE);
let mut app = App::new(&config).unwrap();
let _ = app.handle_key(press(KeyCode::F(8))).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Series
);
let frame = render_rows_in(&app, 120, 30);
let (x, y) = label_at(&frame, "[Capacity]");
let _ = app.handle_mouse(mouse_down(x + 1, y)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Capacity,
"the pointer reaches the Capacity tab"
);
let frame = render_rows_in(&app, 120, 30);
let (x, y) = label_at(&frame, "[Series]");
let _ = app.handle_mouse(mouse_down(x + 1, y)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Series
);
let _ = app.handle_mouse(mouse_down(1, 20)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Series
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Cycles
);
let _ = app.handle_key(press_char('R')).unwrap();
assert!(app.schedule_state().prompt.is_some());
let frame = render_rows_in(&app, 120, 30);
let (x, y) = label_at(&frame, "[Capacity]");
let _ = app.handle_mouse(mouse_down(x + 1, y)).unwrap();
assert_eq!(
app.schedule_state().tab,
crate::ui::schedule::ScheduleTab::Cycles,
"the open prompt keeps the pointer from switching tabs"
);
assert!(app.schedule_state().prompt.is_some(), "and stays open");
}
fn label_at(frame: &[String], needle: &str) -> (u16, u16) {
let row = frame
.iter()
.position(|line| line.contains(needle))
.unwrap_or_else(|| panic!("`{needle}` is drawn: {frame:#?}"));
let column = frame[row].find(needle).expect("the needle was found");
(column as u16, row as u16)
}
#[test]
fn wp06_reading_the_capacity_tab_writes_nothing() {
let (tmp, dir) = make_fixture();
write_cycle_issue(&dir, 1, "todo", "Cycle 1", Some("6d"));
write_cycle_issue(&dir, 2, "done", "Cycle 1", Some("2d"));
let config = capacity_config(&tmp, CAPACITY_SCOPE);
let mut app = App::new(&config).unwrap();
let before = tree_bytes(tmp.path());
open_capacity_tab(&mut app);
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('k')).unwrap();
let _ = render_rows_in(&app, 200, 40);
let _ = app.handle_key(press_char('?')).unwrap();
let _ = render_rows_in(&app, 200, 40);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
app.open_issue_by_filename("2026-01-01-issue-1.md")
.expect("the issue opens");
let _ = render_rows_in(&app, 200, 60);
assert_eq!(
tree_bytes(tmp.path()),
before,
"no read path wrote a file, a record or a History entry"
);
}
#[test]
fn wp06_the_help_overlay_on_the_capacity_tab_is_the_tabs_own() {
let (tmp, _dir) = make_fixture();
let config = capacity_config(&tmp, CAPACITY_SCOPE);
let mut app = App::new(&config).unwrap();
open_capacity_tab(&mut app);
let _ = app.handle_key(press_char('?')).unwrap();
assert!(matches!(app.mode, Mode::Help { .. }));
assert_eq!(
app.schedule_help_tab(),
Some(crate::ui::schedule::ScheduleTab::Capacity)
);
let drawn = render_rows_in(&app, 160, 44).join("\n");
assert!(drawn.contains("switch tabs"), "{drawn}");
assert!(drawn.contains("back to the board"), "{drawn}");
for absent in [
"end the series",
"defer the due date",
"generate the schedule's look-ahead",
] {
assert!(
!drawn.contains(absent),
"`{absent}` acts on nothing on this tab: {drawn}"
);
}
}
fn write_issue_with_time(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
extra: &str,
) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{extra}---\n\n\
Body text."
);
fs::write(dir.join(name), contents).unwrap();
}
fn time_entries(entries: &str) -> String {
format!("time_entries:\n{entries}")
}
fn time_fixture() -> (TempDir, PathBuf) {
let (tmp, dir) = make_fixture();
write_issue_with_time(
&dir,
"1-alpha.md",
1,
"Alpha",
"todo",
&format!(
"assignee: \"ada\"\nestimate: \"3 points\"\n{}",
time_entries(
"- id: e-a1\n duration_minutes: 90\n date: \"2026-03-01\"\n author: \"ada\"\n \
note: \"shipped\"\n- id: e-a2\n duration_minutes: 30\n author: \"ada\"\n"
),
),
);
write_issue_with_time(
&dir,
"2-beta.md",
2,
"Beta",
"todo",
&time_entries("- id: e-b1\n author: \"bob\"\n"),
);
write_issue_with_time(&dir, "3-gamma.md", 3, "Gamma", "todo", "");
(tmp, dir)
}
fn time_app() -> (TempDir, App) {
let (tmp, _dir) = time_fixture();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
(tmp, app)
}
fn open_stats_time(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
assert_eq!(app.mode, Mode::Stats, "F7 opens the Stats page");
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Health,
"the page opens on the tab it always showed"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Time,
"Tab switches tabs"
);
}
fn issue_file_text(app: &App, number: u32) -> String {
for path in collect_md(&app.store.issues_dir) {
let text = fs::read_to_string(&path).unwrap_or_default();
if text.contains(&format!("\nnumber: {number}\n")) {
return text;
}
}
panic!("issue #{number} is not on disk");
}
fn parsed_issue(app: &App, number: u32) -> crate::model::Issue {
crate::model::Issue::parse(&issue_file_text(app, number)).expect("the issue parses")
}
fn time_events(issue: &crate::model::Issue) -> Vec<(crate::model::HistoryEventKind, String)> {
use crate::model::HistoryEventKind;
issue
.history
.events()
.into_iter()
.filter(|event| {
matches!(
event.kind,
HistoryEventKind::TimeEntryCreated
| HistoryEventKind::TimeEntryUpdated
| HistoryEventKind::TimeEntryDeleted
)
})
.map(|event| (event.kind, event.detail.clone()))
.collect()
}
fn issues_bytes(app: &App) -> String {
health_snapshot(app)
}
fn show(label: &str, rows: &[String]) {
println!("=== {label} ===\n{}\n=== end {label} ===", rows.join("\n"));
}
#[test]
fn time_detail_pane_renders_the_entries_the_total_and_the_history() {
let (_tmp, mut app) = time_app();
open_card_for(&mut app, 1);
let text = render_rows_in(&app, 160, 60).join("\n");
assert!(text.contains("time:"), "{text}");
assert!(text.contains("state: recorded"), "{text}");
assert!(
text.contains("total: 120"),
"the shared total is 90+30: {text}"
);
assert!(text.contains("entries: 2"), "{text}");
assert!(text.contains("e-a1") && text.contains("e-a2"), "{text}");
assert!(text.contains("2026-03-01"), "{text}");
assert!(text.contains("shipped"), "{text}");
assert!(text.contains("estimate: 3 points"), "{text}");
assert!(text.contains("ada"), "{text}");
let before = issues_bytes(&app);
let _ = render_rows_in(&app, 160, 60);
assert_eq!(
issues_bytes(&app),
before,
"the pane's render writes nothing"
);
}
#[test]
fn time_detail_pane_diagnoses_a_malformed_entry_as_absent_not_as_zero() {
let (_tmp, mut app) = time_app();
open_card_for(&mut app, 2);
let text = render_rows_in(&app, 160, 60).join("\n");
assert!(text.contains("state: unknown"), "{text}");
assert!(
text.contains("total: -"),
"an absent total is a dash, not a zero: {text}"
);
assert!(text.contains("malformed: 1"), "{text}");
assert!(text.contains("invalid_duration"), "{text}");
assert!(text.contains("remedy:"), "{text}");
assert!(text.contains("e-b1"), "{text}");
}
#[test]
fn time_tab_renders_the_shared_totals_and_the_entry_rows() {
let (_tmp, mut app) = time_app();
open_stats_time(&mut app);
let text = render_rows_in(&app, 180, 50).join("\n");
assert!(
text.contains("[Health]") && text.contains("[Time]"),
"{text}"
);
assert!(text.contains("scope: all"), "{text}");
assert!(text.contains("state: recorded"), "{text}");
assert!(text.contains("total_minutes: 120"), "{text}");
assert!(text.contains("2 counted, 1 malformed, 0 undated"), "{text}");
for id in ["e-a1", "e-a2", "e-b1"] {
assert!(text.contains(id), "the row for {id} must be drawn: {text}");
}
assert!(text.contains("#1") && text.contains("#2"), "{text}");
assert!(text.contains("partial"), "{text}");
assert!(text.contains("invalid_duration"), "{text}");
assert!(text.contains("remedy:"), "{text}");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(
app.mode,
Mode::Detail,
"Enter opens the selected row's issue"
);
let pane = render_rows_in(&app, 120, 40).join("\n");
assert!(pane.contains("time:"), "{pane}");
assert!(pane.contains("total: 120"), "{pane}");
}
#[test]
fn time_tab_never_reports_an_unanswerable_scope_as_zero() {
let (_tmp, mut app) = time_app();
open_stats_time(&mut app);
let all = render_rows_in(&app, 180, 50).join("\n");
assert!(all.contains("e-a1"), "{all}");
let _ = app.handle_key(press_char('f')).unwrap();
assert!(
matches!(app.mode, Mode::TimeFilterForm),
"`f` opens the filter"
);
type_text(&mut app, "2");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::Stats, "applying a filter closes the editor");
let text = render_rows_in(&app, 180, 50).join("\n");
assert!(text.contains("scope: issue #2"), "{text}");
assert!(text.contains("state: unknown"), "{text}");
assert!(
text.contains("total_minutes: -"),
"an absent total is a dash, never a zero: {text}"
);
assert!(text.contains("the findings below name each"), "{text}");
assert!(text.contains("finding:"), "{text}");
let _ = app.handle_key(press_char('f')).unwrap();
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
type_text(&mut app, "3");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let rows = render_rows_in(&app, 180, 50);
let text = rows.join("\n");
show("stats time tab — a scope that recorded nothing", &rows);
assert!(text.contains("state: nothing"), "{text}");
assert!(
text.contains("No time is recorded for this scope."),
"{text}"
);
let _ = app.handle_key(press_char('f')).unwrap();
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
type_text(&mut app, "1"); let _ = app.handle_key(press(KeyCode::Tab)).unwrap(); let _ = app.handle_key(press(KeyCode::Tab)).unwrap(); type_text(&mut app, "2026-04-01");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap(); type_text(&mut app, "2026-04-30");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let rows = render_rows_in(&app, 180, 50);
let text = rows.join("\n");
show("stats time tab — an undated-only period scope", &rows);
assert!(
text.contains("scope: issue #1, period 2026-04-01..2026-04-30"),
"{text}"
);
assert!(text.contains("state: unknown"), "{text}");
assert!(text.contains("0 counted, 0 malformed, 1 undated"), "{text}");
assert!(
text.contains("an entry that carries no date is in no period"),
"the undated count must be explained, not just printed: {text}"
);
assert!(
text.contains("#1 holds 1"),
"and the entry the count admits to must be findable: {text}"
);
assert!(text.contains("drop the period bounds with `f`"), "{text}");
assert!(
!text.contains("the findings below name each"),
"a scope with no malformed entry has no finding to point at: {text}"
);
assert!(
!text.contains("finding:"),
"the undated section is not a finding: {text}"
);
}
#[test]
fn time_tab_filters_break_down_and_audit_without_writing() {
let (tmp, mut app) = time_app();
open_stats_time(&mut app);
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('f')).unwrap();
type_text(&mut app, "1");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press_char('s')).unwrap();
let _ = app.handle_key(press_char('H')).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
let text = render_rows_in(&app, 180, 50).join("\n");
assert!(text.contains("scope: issue #1"), "{text}");
assert!(text.contains("breakdown by issue"), "{text}");
assert!(
text.contains("history (audit): 0 deleted, 0 corrected"),
"{text}"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"the whole read path — filter, breakdown, audit — writes nothing"
);
}
#[test]
fn time_tab_adds_an_entry_through_the_shared_service() {
let (tmp, mut app) = time_app();
open_stats_time(&mut app);
let before = tree_bytes(tmp.path());
let before_issue = parsed_issue(&app, 3);
let _ = app.handle_key(press_char('a')).unwrap();
assert!(matches!(app.mode, Mode::TimeForm), "`a` opens the form");
assert_eq!(
app.time_form.as_ref().map(|f| f.field_name()),
Some("issue")
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"opening the form writes nothing"
);
type_text(&mut app, "3");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.time_form.as_ref().map(|f| f.field_name()),
Some("minutes")
);
type_text(&mut app, "45");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
type_text(&mut app, "2026-04-02");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.time_form.as_ref().map(|f| f.field_name()),
Some("author"),
"the author defaults to the configured identity"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(app.time_form.as_ref().map(|f| f.field_name()), Some("note"));
type_text(&mut app, "pairing");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::Stats, "a valid submission closes the form");
let issue = parsed_issue(&app, 3);
assert_eq!(issue.time_entries.len(), 1);
let entry = &issue.time_entries[0];
assert_eq!(entry.minutes(), Some(45));
assert_eq!(entry.date.as_deref(), Some("2026-04-02"));
assert_eq!(entry.note.as_deref(), Some("pairing"));
assert_eq!(entry.author, crate::storage::actor_identity());
assert!(!entry.id.is_empty(), "the service mints a stable id");
let events = time_events(&issue);
assert_eq!(events.len(), 1, "{events:?}");
assert!(matches!(
events[0].0,
crate::model::HistoryEventKind::TimeEntryCreated
));
assert_eq!(issue.status, before_issue.status);
assert_eq!(issue.assignee, before_issue.assignee);
assert_eq!(issue.estimate, before_issue.estimate);
assert_eq!(issue.title, before_issue.title);
let text = render_rows_in(&app, 180, 50).join("\n");
assert!(text.contains("45"), "{text}");
assert!(text.contains("pairing"), "{text}");
}
#[test]
fn time_tab_corrects_and_deletes_and_history_keeps_the_prior_values() {
let (_tmp, mut app) = time_app();
open_stats_time(&mut app);
let before_issue = parsed_issue(&app, 1);
let _ = app.handle_key(press_char('f')).unwrap();
type_text(&mut app, "1");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press_char('j')).unwrap();
let _ = app.handle_key(press_char('e')).unwrap();
assert!(
matches!(app.mode, Mode::TimeForm),
"`e` opens the correction form"
);
assert_eq!(
app.time_form.as_ref().map(|f| f.minutes.clone()),
Some("30".to_string()),
"the form is pre-filled with the recorded value"
);
for _ in 0..2 {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
type_text(&mut app, "40");
for _ in 0..4 {
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
}
assert_eq!(
app.time_form.as_ref().map(|f| f.field_name()),
Some("reason")
);
type_text(&mut app, "mis-timed");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::Stats);
let issue = parsed_issue(&app, 1);
let corrected = issue
.time_entries
.iter()
.find(|entry| entry.id == "e-a2")
.expect("the corrected entry is still current");
assert_eq!(corrected.minutes(), Some(40));
assert_eq!(corrected.date, before_issue.time_entries[1].date);
assert_eq!(corrected.author, before_issue.time_entries[1].author);
let updated: Vec<(crate::model::HistoryEventKind, String)> = time_events(&issue)
.into_iter()
.filter(|(kind, _)| matches!(kind, crate::model::HistoryEventKind::TimeEntryUpdated))
.collect();
assert_eq!(updated.len(), 1, "{updated:?}");
assert!(
updated[0].1.contains("30") && updated[0].1.contains("40"),
"both the prior and the new value are attributable: {:?}",
updated[0].1
);
let _ = app.handle_key(press_char('x')).unwrap();
assert!(
matches!(app.mode, Mode::TimeForm),
"`x` opens the deletion form"
);
assert_eq!(
app.time_form.as_ref().map(|f| f.field_name()),
Some("reason")
);
type_text(&mut app, "duplicate");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.time_form.as_ref().map(|f| f.field_name()),
Some("confirm")
);
let _ = app.handle_key(press_char(' ')).unwrap();
assert!(app.time_form.as_ref().is_some_and(|form| form.confirm));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.mode, Mode::Stats);
let issue = parsed_issue(&app, 1);
assert!(
issue.time_entries.iter().all(|entry| entry.id != "e-a2"),
"the deletion removes the current value"
);
let deleted: Vec<(crate::model::HistoryEventKind, String)> = time_events(&issue)
.into_iter()
.filter(|(kind, _)| matches!(kind, crate::model::HistoryEventKind::TimeEntryDeleted))
.collect();
assert_eq!(deleted.len(), 1, "{deleted:?}");
assert!(deleted[0].1.contains("e-a2"), "{:?}", deleted[0].1);
assert_eq!(issue.status, before_issue.status);
assert_eq!(issue.assignee, before_issue.assignee);
assert_eq!(issue.estimate, before_issue.estimate);
let _ = app.handle_key(press_char('f')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press_char('H')).unwrap();
let text = render_rows_in(&app, 180, 50).join("\n");
assert!(text.contains("history (audit): 1 deleted"), "{text}");
assert!(text.contains("e-a2"), "{text}");
}
#[test]
fn time_form_refusals_keep_the_form_open_and_write_nothing() {
let (tmp, mut app) = time_app();
open_stats_time(&mut app);
let before = tree_bytes(tmp.path());
let _ = app.handle_key(press_char('a')).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::TimeForm),
"a refusal stays in the form"
);
assert!(
app.time_form
.as_ref()
.and_then(|form| form.error.as_ref())
.is_some(),
"and the form says why"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"a refused submission writes nothing"
);
type_text(&mut app, "3");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
type_text(&mut app, "45");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
type_text(&mut app, "2026-13-45");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::TimeForm),
"the date refusal stays in the form"
);
let error = app
.time_form
.as_ref()
.and_then(|form| form.error.clone())
.unwrap_or_default();
assert!(
error.contains("invalid_date"),
"the service's own wording is what the reader sees: {error}"
);
assert_eq!(
tree_bytes(tmp.path()),
before,
"and it writes nothing either"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.mode, Mode::Stats);
assert_eq!(
tree_bytes(tmp.path()),
before,
"leaving the form writes nothing"
);
let _ = app.handle_key(press_char('f')).unwrap();
type_text(&mut app, "1");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press_char('x')).unwrap();
assert!(matches!(app.mode, Mode::TimeForm));
type_text(&mut app, "no longer needed");
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::TimeForm),
"the unconfirmed deletion stays in the form"
);
assert!(
app.time_form
.as_ref()
.and_then(|form| form.error.as_ref())
.is_some_and(|error| error.contains("confirmation")),
"and says what is missing"
);
assert_eq!(tree_bytes(tmp.path()), before, "and writes nothing");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
}
#[test]
fn the_help_overlay_on_each_stats_tab_is_that_tabs_own() {
let (_tmp, mut app) = time_app();
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
let _ = app.handle_key(press_char('?')).unwrap();
assert_eq!(
app.stats_help_tab(),
Some(crate::app::StatsTab::Health),
"the overlay knows which tab it was opened from"
);
let health = render_rows_in(&app, 160, 44);
let health_text = health.join("\n");
show("stats help overlay — Health tab", &health);
assert!(
health_text.contains("open the row's issue"),
"{health_text}"
);
assert!(health_text.contains("close"), "{health_text}");
for absent in [
"add a time entry",
"correct the selected entry",
"delete the selected entry",
"edit the time filter",
"cycle the breakdown",
"show the audit view",
] {
assert!(
!health_text.contains(absent),
"`{absent}` acts on nothing on the Health tab: {health_text}"
);
}
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(app.mode, Mode::Stats);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(app.stats_help_tab(), Some(crate::app::StatsTab::Time));
let _ = app.handle_key(press_char('?')).unwrap();
let time = render_rows_in(&app, 160, 44);
let time_text = time.join("\n");
show("stats help overlay — Time tab", &time);
for present in [
"add a time entry",
"correct the selected entry",
"delete the selected entry",
"edit the time filter",
"cycle the breakdown",
"show the audit view",
] {
assert!(
time_text.contains(present),
"`{present}` must be listed: {time_text}"
);
}
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("the built-in bindings resolve");
let context = crate::keybindings::InputContext::Stats;
for action in bindings.bound_actions(context) {
let described = action.description_in(context);
assert!(
time_text.contains(described),
"`{}` ({described}) fires on the Time tab but its overlay does not list it: {time_text}",
action.as_str()
);
}
}
#[test]
fn the_stats_hint_line_follows_the_active_tab() {
let (_tmp, mut app) = time_app();
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
let hint_line = |app: &App| {
render_rows_in(app, 160, 44)
.into_iter()
.rev()
.find(|row| !row.trim().is_empty())
.unwrap_or_default()
};
let _ = app.handle_key(press_char('?')).unwrap();
let health = hint_line(&app);
show(
"stats hint line — Health tab",
std::slice::from_ref(&health),
);
assert!(
health.contains("Tab next"),
"the tabs are advertised: {health}"
);
assert!(
!health.contains("add a time entry"),
"the Health tab binds no time command, so its hint line must not advertise one: {health}"
);
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press_char('?')).unwrap();
let time = hint_line(&app);
show("stats hint line — Time tab", std::slice::from_ref(&time));
assert!(time.contains("a add a time entry"), "{time}");
assert!(time.contains("x delete the selected"), "{time}");
let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
let _ = app.handle_key(press_char('?')).unwrap();
let health_again = hint_line(&app);
assert!(!health_again.contains("add a time entry"), "{health_again}");
assert!(health_again.contains("Tab next"), "{health_again}");
}
#[test]
fn a_click_on_the_stats_trunk_opens_the_page_and_its_time_tab() {
let (_tmp, mut app) = time_app();
let frame = render_rows_in(&app, 120, 30);
let row = frame
.iter()
.position(|line| line.contains("Stats"))
.expect("the navigation rail lists the Stats page") as u16;
let column = frame[row as usize]
.find("Stats")
.expect("the label is there") as u16;
let action = app
.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
match action {
Action::SwitchMode(mode) => {
assert_eq!(mode, crate::ui::nav::TopMode::Stats, "the Stats trunk");
let _ = app.switch_top_mode(mode).unwrap();
}
other => panic!("the rail row must resolve to its mode switch, got {other:?}"),
}
assert_eq!(app.mode, Mode::Stats, "the clicked rail row opens the page");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(app.stats_tab, crate::app::StatsTab::Time);
let text = render_rows_in(&app, 180, 50).join("\n");
assert!(text.contains("total_minutes: 120"), "{text}");
}
const BURNDOWN_CONFIG: &str = "issues_dir = \"{issues}\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"doing\", \"done\"]\n\n\
[kanban.default.wip.units]\npoints = 1\nhours = 5\n\n\
[cycles.\"Sprint 7\"]\nstart_date = \"2026-01-05\"\ndue_date = \"2026-01-09\"\n\n\
[cycles.\"Sprint 8\"]\nstart_date = \"2026-01-12\"\ndue_date = \"2026-01-16\"\n";
const EMPTY_BURNDOWN_CONFIG: &str = "issues_dir = \"{issues}\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"doing\", \"done\"]\n";
fn burndown_addition(timestamp: &str) -> crate::model::HistoryEvent {
crate::model::HistoryEvent::new(
timestamp,
"alice",
crate::model::HistoryEventKind::ParentSet,
"parent none -> #1",
)
}
fn burndown_completion(timestamp: &str) -> crate::model::HistoryEvent {
crate::model::HistoryEvent::new(
timestamp,
"alice",
crate::model::HistoryEventKind::TransitionAccepted,
"todo -> shipped (unstarted -> completed)",
)
}
fn burndown_issue(
number: u32,
estimate: Option<&str>,
cycles: &[&str],
events: &[crate::model::HistoryEvent],
) -> String {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if let Some(estimate) = estimate {
frontmatter.push_str(&format!("estimate: \"{estimate}\"\n"));
}
if !cycles.is_empty() {
frontmatter.push_str("cycles:\n");
for cycle in cycles {
frontmatter.push_str(&format!(" - {cycle}\n"));
}
}
frontmatter.push_str("---\n");
let mut history = crate::model::History::new();
for event in events {
history.append(event.clone());
}
format!("{frontmatter}{}", history.render())
}
fn burndown_project(raw: &str) -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let issues = tmp.path().join("issues");
fs::create_dir_all(&issues).unwrap();
fs::write(
tmp.path().join("tix.toml"),
raw.replace("{issues}", &issues.to_string_lossy()),
)
.unwrap();
(tmp, issues)
}
fn burndown_app() -> (TempDir, App) {
let (tmp, issues) = burndown_project(BURNDOWN_CONFIG);
fs::write(
issues.join("2026-01-01-issue-1.md"),
burndown_issue(
1,
Some("3points"),
&["Sprint 7"],
&[
burndown_addition("2026-01-05T09:00:00Z"),
burndown_completion("2026-01-07T09:00:00Z"),
],
),
)
.unwrap();
fs::write(
issues.join("2026-01-01-issue-2.md"),
burndown_issue(
2,
Some("2points"),
&["Sprint 7"],
&[
burndown_addition("2026-01-06T09:00:00Z"),
burndown_addition("2026-02-10T09:00:00Z"),
],
),
)
.unwrap();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
(tmp, app)
}
fn burndown_lines(app: &App) -> Vec<String> {
let state = app
.stats_burndown
.as_ref()
.expect("the Burndown tab has been read");
crate::ui::analytics::frame(state)
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn burndown_cells<'a>(lines: &'a [String], date: &str) -> Vec<&'a str> {
let row = lines
.iter()
.find(|line| (line.starts_with("> ") || line.starts_with(" ")) && line.contains(date))
.unwrap_or_else(|| panic!("no row for {date} in:\n{}", lines.join("\n")));
row[2..].split_whitespace().collect()
}
fn open_stats_burndown(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
assert_eq!(app.mode, Mode::Stats, "F7 opens the Stats page");
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Health,
"the page still opens on the tab it always showed"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Time,
"the Time tab is still the second tab"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Burndown,
"and the Burndown tab is the third"
);
}
#[test]
fn the_burndown_tab_renders_the_folds_values_and_its_coverage_markers() {
let (_tmp, mut app) = burndown_app();
open_stats_burndown(&mut app);
let rows = render_rows_in(&app, 160, 44);
let text = rows.join("\n");
show("Stats — Burndown tab", &rows);
assert!(text.contains("scope: cycle"), "{text}");
assert!(text.contains("id: Sprint 7"), "{text}");
assert!(text.contains("period: 2026-01-05..2026-01-09"), "{text}");
assert!(text.contains("snapshot: -"), "{text}");
assert!(text.contains("mode: count"), "{text}");
assert!(text.contains("unit: -"), "{text}");
assert!(text.contains("formula: burndown.count.v1"), "{text}");
assert!(text.contains("days: 5"), "{text}");
assert!(
text.contains("coverage: known=3 unknown=0 coverage_limited=2"),
"the CLI's own tally for this fixture: {text}"
);
assert!(
text.contains("[Burndown]"),
"the strip marks the active tab: {text}"
);
assert!(
text.contains("CHART"),
"the daily table draws its chart column: {text}"
);
assert!(
text.contains('█'),
"and the bar is drawn, not merely labelled: {text}"
);
let lines = burndown_lines(&app);
let added = burndown_cells(&lines, "2026-01-05");
assert_eq!(
added[..7].to_vec(),
["2026-01-05", "known", "1", "1", "0", "1", "0"],
"the day of the addition: {added:?}"
);
let completed = burndown_cells(&lines, "2026-01-07");
assert_eq!(
completed[..7].to_vec(),
["2026-01-07", "known", "1", "2", "1", "0", "1"],
"completions are recorded beside additions, not folded into them: {completed:?}"
);
let unobserved = burndown_cells(&lines, "2026-01-08");
assert_eq!(
unobserved[..7].to_vec(),
["2026-01-08", "coverage_limited", "1", "2", "1", "-", "-"],
"a day with no recorded history carries the coverage marker, and its deltas are \
absent — never a zero: {unobserved:?}"
);
assert!(
!unobserved[5..7].contains(&"0"),
"an unobserved day's additions must not read as a recorded zero: {unobserved:?}"
);
assert!(text.contains("2026-01-08 coverage_limited"), "{text}");
}
#[test]
fn the_burndown_tab_holds_the_same_document_the_cli_composes() {
let (tmp, mut app) = burndown_app();
open_stats_burndown(&mut app);
let config = load_config(&tmp);
let root = Config::project_root(&app.store.issues_dir).expect("the fixture has a tix.toml");
let planner = crate::planning::Planner::from_config(config.clone(), root);
let scope = crate::analytics::events::cycle_scope(&planner, "Sprint 7")
.expect("the fixture declares the cycle");
let document = crate::commands::export::chart_document(
&config,
&app.store,
&scope,
crate::analytics::series::Mode::Count,
None,
None,
)
.expect("the shared adapter folds the fixture");
let held = app
.stats_burndown
.as_ref()
.and_then(|state| state.document.clone())
.expect("the tab has folded");
assert_eq!(held, document, "the tab and the CLI read one document");
let lines = burndown_lines(&app);
let text = lines.join("\n");
assert!(text.contains(&format!("formula: {}", document.formula_version)));
assert!(text.contains(&format!("days: {}", document.series.points.len())));
assert!(text.contains(&format!(
"coverage: known={} unknown={} coverage_limited={}",
document.coverage.known, document.coverage.unknown, document.coverage.coverage_limited
)));
for point in &document.series.points {
let cells = burndown_cells(&lines, &point.date);
let dash =
|value: Option<i64>| value.map_or_else(|| "-".to_string(), |value| value.to_string());
let expected = [
point.coverage.as_str().to_string(),
dash(point.remaining),
dash(point.scope_total),
dash(point.completed),
dash(point.scope_added),
dash(point.completions),
];
assert_eq!(
cells[1..7].to_vec(),
expected.iter().map(String::as_str).collect::<Vec<_>>(),
"the row for {} is the point's own fields",
point.date
);
}
}
#[test]
fn the_burndown_tab_controls_select_the_record_and_write_nothing() {
let (tmp, mut app) = burndown_app();
let config = load_config(&tmp);
open_stats_burndown(&mut app);
let before = tree_bytes(tmp.path());
assert!(burndown_frame_contains(&app, "id: Sprint 7"));
let _ = app.handle_key(press_char(']')).unwrap();
assert!(
burndown_frame_contains(&app, "id: Sprint 8"),
"the next scope"
);
let _ = app.handle_key(press_char(']')).unwrap();
assert!(
burndown_frame_contains(&app, "id: Sprint 7"),
"the scope ring wraps"
);
let _ = app.handle_key(press_char('[')).unwrap();
assert!(burndown_frame_contains(&app, "id: Sprint 8"));
let _ = app.handle_key(press_char('[')).unwrap();
assert!(burndown_frame_contains(&app, "period source: planning"));
let _ = app.handle_key(press_char('p')).unwrap();
let observed = burndown_frame(&app);
assert!(observed.contains("period source: observed"), "{observed}");
assert!(
observed.contains("period: open..open"),
"the observed source releases both bounds rather than inventing them: {observed}"
);
assert!(
observed.contains("covered: 2026-01-05..2026-02-10"),
"and the fold's own span is what the series then covers: {observed}"
);
let _ = app.handle_key(press_char('p')).unwrap();
assert!(burndown_frame_contains(&app, "period source: planning"));
let declared = crate::analytics::units::declared_units(&config);
assert_eq!(
declared.len(),
2,
"the fixture declares two units: {declared:?}"
);
let _ = app.handle_key(press_char('t')).unwrap();
let estimate = burndown_frame(&app);
assert!(estimate.contains("mode: estimate"), "{estimate}");
assert!(
estimate.contains(&format!("unit: {}", declared[0])),
"the first unit the project declares is selected: {estimate}"
);
assert!(
estimate.contains("formula: burndown.estimate.v1"),
"estimate mode folds under its own documented formula: {estimate}"
);
let _ = app.handle_key(press_char('u')).unwrap();
assert!(
burndown_frame_contains(&app, &format!("unit: {}", declared[1])),
"`u` cycles the declared units"
);
let _ = app.handle_key(press_char('u')).unwrap();
assert!(
burndown_frame_contains(&app, &format!("unit: {}", declared[0])),
"and wraps"
);
let _ = app.handle_key(press_char('t')).unwrap();
assert!(burndown_frame_contains(&app, "mode: count"));
assert!(
burndown_frame_contains(&app, "unit: -"),
"a count is unitless, as the CLI's `unit: -` says"
);
let _ = app.handle_key(press_char('v')).unwrap();
let forecast = burndown_frame(&app);
assert!(forecast.contains("projection: projected"), "{forecast}");
assert!(forecast.contains(" as_of: 2026-01-09"), "{forecast}");
assert!(forecast.contains(" end_date: 2026-01-09"), "{forecast}");
assert!(
forecast.contains(" projected_completion: 2026-01-13"),
"the projection's own derived date: {forecast}"
);
assert!(forecast.contains(" meets_end_date: false"), "{forecast}");
let _ = app.handle_key(press_char('d')).unwrap();
assert!(
burndown_frame_contains(&app, " as_of: 2026-01-08"),
"`d` moves the origin one covered day earlier"
);
let _ = app.handle_key(press_char('D')).unwrap();
assert!(burndown_frame_contains(&app, " as_of: 2026-01-09"));
let _ = app.handle_key(press_char('j')).unwrap();
let moved = burndown_lines(&app);
assert!(
moved.iter().any(|line| line.starts_with("> 2026-01-06")),
"the cursor marks the row it is on: {}",
moved.join("\n")
);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(
tree_bytes(tmp.path()),
before,
"opening the page, switching tabs and driving every control writes nothing"
);
}
fn burndown_frame_contains(app: &App, needle: &str) -> bool {
burndown_lines(app).iter().any(|line| line.contains(needle))
}
fn burndown_frame(app: &App) -> String {
render_rows_in(app, 160, 44).join("\n")
}
#[test]
fn the_burndown_tab_names_a_closed_snapshots_provenance() {
let (tmp, mut app) = closed_snapshot_app();
open_stats_burndown(&mut app);
let empty = burndown_frame(&app);
show(
"Stats — Burndown tab, a scope with no planning dates",
&render_rows_in(&app, 160, 44),
);
assert!(empty.contains("id: Default"), "{empty}");
assert!(
empty.contains("The fold answered no day for this scope."),
"{empty}"
);
assert!(
empty.contains("invalid_period") || empty.contains("missing_history"),
"the finding that says why leads the empty state: {empty}"
);
assert!(
!empty.contains("closed_snapshot"),
"an unbound scope is not described as bound: {empty}"
);
let _ = app.handle_key(press_char(']')).unwrap();
let text = burndown_frame(&app);
show(
"Stats — Burndown tab, a closed-snapshot scope",
&render_rows_in(&app, 160, 44),
);
assert!(
text.contains("snapshot: cs-weekly:000001@2026-01-12T09:00:00Z"),
"the closed snapshot's stable id is named: {text}"
);
assert!(
text.contains("period: 2026-01-05..2026-01-11"),
"and the period is the instance's own: {text}"
);
assert!(
burndown_frame_contains(&app, "closed_snapshot"),
"the finding names the binding in the shared vocabulary"
);
let normalized = text
.replace(['│', '─', '┌', '┐', '└', '┘'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
normalized.contains(
"the period the fold applied is the caller's `2026-01-05..2026-01-11`, \
and events recorded after it are not contributions"
),
"the complete reason is readable: {normalized}"
);
assert!(
normalized.contains(
"remedy: pass the snapshot's own recorded period — its \
`start_date`/`due_date`, or the day of its `closed_at` — so the exclusion \
ends where the cycle closed"
),
"the complete remedy is readable: {normalized}"
);
let _ = tmp;
}
fn closed_snapshot_app() -> (TempDir, App) {
use crate::cycle_schedule::config::CycleScheduleId;
use crate::cycle_schedule::identity::GeneratedCycleId;
use crate::cycle_schedule::snapshot::{CycleSnapshot, CycleSnapshotStore};
use crate::cycle_store::{CycleRecord, CycleStore};
let (tmp, issues) = burndown_project(EMPTY_BURNDOWN_CONFIG);
let root = tmp.path().to_path_buf();
let schedule = CycleScheduleId::new("cs-weekly").unwrap();
let cycle_id = GeneratedCycleId::new(&schedule, 1);
let name = "Weekly sprint 2026-01-05 000001".to_string();
CycleStore::new(&root)
.save_record(
&CycleRecord {
name: name.clone(),
description: None,
start_date: Some("2026-01-05".to_string()),
due_date: Some("2026-01-11".to_string()),
schedule_id: Some("cs-weekly".to_string()),
sequence: Some(1),
state: Some("closed".to_string()),
},
None,
)
.unwrap();
CycleSnapshotStore::new(&root)
.capture(&CycleSnapshot {
cycle_id,
schedule_id: schedule,
sequence: 1,
start_date: "2026-01-05".to_string(),
due_date: "2026-01-11".to_string(),
closed_at: "2026-01-12T09:00:00Z".to_string(),
closed_by: "alice".to_string(),
state_summary: "closed".to_string(),
measurements: std::collections::BTreeMap::new(),
extensions: std::collections::BTreeMap::new(),
})
.unwrap();
fs::write(
issues.join("2026-01-01-issue-1.md"),
burndown_issue(
1,
None,
&[&name],
&[
burndown_addition("2026-01-05T09:00:00Z"),
burndown_completion("2026-01-08T09:00:00Z"),
],
),
)
.unwrap();
let config = load_config(&tmp);
let app = App::new(&config).unwrap();
(tmp, app)
}
#[test]
fn the_burndown_tab_states_an_unanswerable_forecast_instead_of_inventing_one() {
let (_tmp, mut app) = burndown_app();
open_stats_burndown(&mut app);
let _ = app.handle_key(press_char('v')).unwrap();
assert!(burndown_frame_contains(&app, "projection: projected"));
for _ in 0..4 {
let _ = app.handle_key(press_char('d')).unwrap();
}
let text = burndown_frame(&app);
assert!(text.contains(" as_of: 2026-01-05"), "{text}");
assert!(text.contains("projection: insufficient_data"), "{text}");
assert!(
burndown_frame_contains(&app, "no elapsed day to observe a completion rate over"),
"the reason travels with the status"
);
assert!(
burndown_frame_contains(
&app,
"remedy: ask for an origin later than the series' first covered day"
),
"and so does the remedy"
);
assert!(
text.contains(" projected_completion: -"),
"no completion date is invented: {text}"
);
assert!(
text.contains(" meets_end_date: -"),
"and no decision about the planning end is invented either: {text}"
);
}
#[test]
fn the_burndown_tab_with_no_scope_says_so_rather_than_drawing_nothing() {
let (tmp, _issues) = burndown_project(EMPTY_BURNDOWN_CONFIG);
let config = load_config(&tmp);
let mut app = App::new(&config).unwrap();
open_stats_burndown(&mut app);
let text = burndown_frame(&app);
show(
"Stats — Burndown tab, no scope recorded",
&render_rows_in(&app, 160, 44),
);
assert!(
text.contains("No cycle or milestone is recorded."),
"the empty state is a sentence: {text}"
);
assert!(
text.contains("tix cycle add"),
"and it names the way out: {text}"
);
assert!(
!text.contains("scope: cycle") && !text.contains("id: Sprint"),
"there is no scope line to draw: {text}"
);
}
#[test]
fn the_help_and_the_hint_on_the_burndown_tab_name_only_its_own_keys() {
let (_tmp, mut app) = burndown_app();
open_stats_burndown(&mut app);
let page = burndown_frame(&app);
assert!(
page.contains("[ ] scope"),
"the hint names the scope control: {page}"
);
assert!(page.contains("p period"), "{page}");
assert!(page.contains("t mode"), "{page}");
assert!(page.contains("u unit"), "{page}");
assert!(page.contains("v forecast"), "{page}");
assert!(page.contains("d/D origin"), "{page}");
assert!(
!page.contains("a add") && !page.contains("x delete"),
"the Time tab's keys act on nothing here and must not be advertised: {page}"
);
let _ = app.handle_key(press_char('?')).unwrap();
assert_eq!(
app.stats_help_tab(),
Some(crate::app::StatsTab::Burndown),
"the overlay knows which tab it was opened from"
);
let rows = render_rows_in(&app, 160, 44);
let text = rows.join("\n");
show("Stats — Burndown tab, help overlay", &rows);
for present in [
"previous / next scope",
"period source (planning / observed)",
"mode (count / estimate)",
"next estimate unit",
"ask for the forecast",
"forecast origin (earlier / later)",
] {
assert!(text.contains(present), "`{present}` must be listed: {text}");
}
for absent in [
"add a time entry",
"correct the selected entry",
"delete the selected entry",
"edit the time filter",
"cycle the breakdown",
"show the audit view",
"open the row's issue",
] {
assert!(
!text.contains(absent),
"`{absent}` acts on nothing on the Burndown tab: {text}"
);
}
let footer = rows
.into_iter()
.rev()
.find(|row| !row.trim().is_empty())
.unwrap_or_default();
assert!(
footer.contains("Tab next"),
"the tabs are advertised: {footer}"
);
assert!(
!footer.contains("add a time entry"),
"and the Time tab's commands are not: {footer}"
);
}
#[test]
fn the_burndown_tab_tabs_off_in_both_directions_and_wraps() {
let (_tmp, mut app) = burndown_app();
open_stats_burndown(&mut app);
assert_eq!(app.stats_tab, crate::app::StatsTab::Burndown);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Health,
"`Tab` steps off Burndown and wraps to the first tab"
);
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Burndown,
"`BackTab` wraps back to Burndown"
);
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Time,
"`BackTab` continues to the middle tab"
);
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(
app.stats_tab,
crate::app::StatsTab::Burndown,
"`Tab` returns in the forward direction"
);
}
#[test]
fn the_burndown_tab_adds_no_mode_no_context_and_no_key_mapping() {
assert_eq!(
crate::ui::nav::TopMode::ALL.len(),
11,
"no new TopMode joined the shell"
);
assert_eq!(
crate::app::StatsTab::ALL,
[
crate::app::StatsTab::Health,
crate::app::StatsTab::Time,
crate::app::StatsTab::Burndown,
],
"the tab strip is the page's own, with the Burndown tab third"
);
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("the built-in bindings resolve");
let context = crate::keybindings::InputContext::Stats;
let actions = bindings.bound_actions(context);
let mut bound: Vec<&str> = actions.iter().map(|action| action.as_str()).collect();
bound.sort_unstable();
assert_eq!(
bound,
[
"close",
"help",
"move_down",
"move_up",
"next",
"previous",
"submit",
"time_add",
"time_breakdown",
"time_delete",
"time_edit",
"time_filter",
"time_history",
],
"the Burndown tab's controls are raw keys, so no key-map row was added"
);
for key in ['[', ']', 'p', 't', 'u', 'v', 'd', 'D'] {
assert!(
bindings.action(context, press_char(key)).is_none(),
"`{key}` must not be bound in the Stats context"
);
}
}
#[test]
fn a_click_reaches_the_burndown_tab_through_the_pages_own_path() {
let (tmp, mut app) = burndown_app();
let frame = render_rows_in(&app, 120, 30);
let row = frame
.iter()
.position(|line| line.contains("Stats"))
.expect("the navigation rail lists the Stats page") as u16;
let column = frame[row as usize]
.find("Stats")
.expect("the label is there") as u16;
let before = tree_bytes(tmp.path());
let action = app
.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
match action {
Action::SwitchMode(mode) => {
assert_eq!(mode, crate::ui::nav::TopMode::Stats, "the Stats trunk");
let _ = app.switch_top_mode(mode).unwrap();
}
other => panic!("the rail row must resolve to its mode switch, got {other:?}"),
}
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(app.stats_tab, crate::app::StatsTab::Burndown);
let text = burndown_frame(&app);
assert!(text.contains("formula: burndown.count.v1"), "{text}");
assert_eq!(
tree_bytes(tmp.path()),
before,
"a mouse-open page and a keyboard tab step write nothing"
);
}