#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn the_cut_marker_survives_a_double_width_filename() {
let (_tmp, mut app) = make_app();
app.note_disclosure(
"remove `links:` by hand to silence \u{2014} \
alice/2026-09-14-\u{65e5}\u{672c}\u{8a9e}\u{306e}\u{3068}\u{3066}\u{3082}\u{3044}\u{3044}\u{30d5}\u{30a1}\u{30a4}\u{30eb}\u{540d}_tests.md \
(retired by C-001; never read as typed data, no migration)"
.to_string(),
);
for width in [60u16, 80, 100, 120, 200] {
let cells = render_to_cells(&app, width, 40).unwrap();
let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
let status = rendered
.iter()
.find(|l| l.contains("remove `links:`"))
.unwrap_or_else(|| panic!("width {width}: no status row: {rendered:?}"));
assert!(
status.contains("remove `links:` by hand"),
"width {width}: the action must stay visible, got {status:?}"
);
if width < 200 {
assert!(
status.contains('\u{2026}'),
"width {width}: a cut must be marked, not silent, got {status:?}"
);
}
}
}
#[test]
#[allow(clippy::unicode_not_nfc)] fn a_truncated_status_row_never_exceeds_its_width_in_cells() {
use unicode_width::UnicodeWidthStr;
for msg in [
"remove `links:` by hand to silence \u{2014} plain-ascii-file.md",
"remove `links:` by hand to silence \u{2014} \u{65e5}\u{672c}\u{8a9e}.md",
"short",
"",
] {
for budget in [1usize, 2, 3, 5, 10, 20] {
let text = crate::ui::board::truncate_to_cells(msg, budget);
assert!(
text.width() <= budget,
"budget {budget}, msg {msg:?}: produced {text:?} at width {}",
text.width()
);
if msg.width() > budget {
assert!(
text.ends_with('\u{2026}'),
"budget {budget}, msg {msg:?}: a cut must be marked, got {text:?}"
);
}
}
}
}
#[test]
fn frame_hit_regions_are_empty_until_recorded_and_resolve_to_actions() {
let (_tmp, app) = make_app();
assert!(
app.frame_hit_regions.borrow().is_empty(),
"no regions before a draw"
);
let rect_a = Rect::new(0, 0, 10, 5);
let rect_b = Rect::new(2, 1, 2, 2);
app.record_region(rect_a, Action::OpenEditor("a.md".into()));
app.record_region(rect_b, Action::Quit);
assert_eq!(app.frame_hit_regions.borrow().len(), 2);
assert_eq!(
app.resolve_region(0, 0),
Some(Action::OpenEditor("a.md".into()))
);
assert_eq!(app.resolve_region(3, 2), Some(Action::Quit));
assert_eq!(app.resolve_region(11, 2), None);
assert_eq!(app.resolve_region(3, 6), None);
}
#[test]
fn draw_clears_regions_so_stale_targets_never_fire() {
use ratatui::backend::TestBackend;
let (_tmp, app) = make_app();
app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
terminal.draw(|f| render(&app, f, f.area())).unwrap();
assert!(
!app.frame_hit_regions
.borrow()
.iter()
.any(|region| region.action == Action::Quit),
"a draw clears regions recorded before it"
);
}
#[test]
fn f4_the_loop_drain_moves_a_raised_disclosure_into_the_status_line() {
let (_tmp, mut app) = make_app();
app.status_msg = Some("Moved #1 to in progress".to_string());
crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
assert_eq!(
crate::storage::take_disclosures().len(),
1,
"precondition: a disclosure is queued and nothing has drained it"
);
crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
super::super::drain_disclosures(&mut app);
let status = app.status_msg.as_deref().unwrap_or_default();
assert!(
status.contains("Moved #1 to in progress"),
"the confirmation survives: {status:?}"
);
assert!(
status.contains("retired"),
"and the loop's drain moved the disclosure in: {status:?}"
);
assert!(
crate::storage::take_disclosures().is_empty(),
"the queue is empty, so the next frame does not repeat it"
);
}
#[test]
fn render_once_uses_default_board_configuration() {
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 config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.save(&config_path).unwrap();
let mut config = Config::load_with_warning(&config_path, |_| {}).unwrap();
config.kanban.configurations.clear();
config.kanban.configurations.insert(
"release".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".to_string(), "done".to_string()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::new(),
hidden_columns: vec![],
show_mismatch: false,
wrap_titles: true,
title_lines: 2,
card_order: vec![],
order_by: crate::config::OrderBy::CreatedAtDesc,
wip: crate::wip::WipConfig::default(),
},
);
config.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["backlog".to_string(), "done".to_string()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::new(),
hidden_columns: vec![],
show_mismatch: false,
wrap_titles: true,
title_lines: 2,
card_order: vec![],
order_by: crate::config::OrderBy::CreatedAtDesc,
wip: crate::wip::WipConfig::default(),
},
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "backlog", None);
let rendered = render_once_at(&config, 80, 24).unwrap();
assert!(
rendered.contains("backlog"),
"rendered board should include default board columns, got:\n{rendered}"
);
}
#[test]
fn render_once_tiny_terminal_is_stable() {
let (tmp, _app) = make_app();
let config_path = tmp.path().join("tix.toml");
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(
&tmp.path().join("issues"),
"2026-01-01-a.md",
1,
"Alpha",
"todo",
None,
);
let first = render_once_at(&config, 20, 4).unwrap();
let second = render_once_at(&config, 20, 4).unwrap();
assert_eq!(first, second, "tiny render should be deterministic");
}
#[test]
fn render_once_omits_keyboard_hints() {
let (tmp, _app) = make_app();
let config_path = tmp.path().join("tix.toml");
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(
&tmp.path().join("issues"),
"2026-01-01-a.md",
1,
"Alpha",
"todo",
None,
);
let rendered = render_once_at(&config, 80, 24).unwrap();
for hint in [
"j/k nav",
"n new",
"? help",
"h/l column",
"enter open",
"d delete",
] {
assert!(
!rendered.contains(hint),
"one-shot output must not contain the keyboard hint {hint:?}:\n{rendered}"
);
}
}
#[test]
fn render_once_reserves_bottom_margin_lines() {
let (tmp, _app) = make_app();
let config_path = tmp.path().join("tix.toml");
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
write_issue(
&tmp.path().join("issues"),
"2026-01-01-a.md",
1,
"Alpha",
"todo",
None,
);
let height = 24u16;
let rendered = render_once_at(&config, 80, height).unwrap();
let line_count = rendered.lines().count();
assert!(
line_count <= (height - RENDER_BOTTOM_MARGIN) as usize,
"one-shot render must leave {RENDER_BOTTOM_MARGIN} spare lines, \
got {line_count} lines for a {height}-row terminal:\n{rendered}"
);
assert!(
rendered.lines().any(|l| l.chars().count() == 80),
"one-shot render should still span the full width:\n{rendered}"
);
}
#[test]
fn choosing_a_priority_writes_the_row_and_inline_create_still_works() {
let (_tmp, mut app) = make_app();
app.handle_key(press(KeyCode::F(1))).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); app.handle_key(press(KeyCode::Down)).unwrap();
app.handle_key(press(KeyCode::Enter)).unwrap();
for _ in 0..2 {
app.handle_key(press(KeyCode::Down)).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.form.as_ref().unwrap().priority, "high");
app.handle_key(press(KeyCode::Char('+'))).unwrap();
for c in "blocker".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.form.as_ref().unwrap().priority, "blocker");
}
#[test]
fn the_status_dropdown_refuses_inline_create() {
let (_tmp, mut app) = make_app();
app.handle_key(press(KeyCode::F(1))).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); app.handle_key(press(KeyCode::Enter)).unwrap();
app.handle_key(press(KeyCode::Char('+'))).unwrap();
assert_eq!(
app.form_picker.as_ref().unwrap().state.mode,
PickerMode::Idle,
"Status refuses the inline-create prompt"
);
for c in "legacy".chars() {
app.handle_key(press(KeyCode::Char(c))).unwrap();
}
assert!(
app.form_picker
.as_ref()
.unwrap()
.state
.create_buffer
.is_empty(),
"the swallowed create keys never reach a buffer"
);
}
#[test]
fn the_status_and_priority_dropdowns_render_their_footers() {
let (_tmp, mut app) = make_app();
app.handle_key(press(KeyCode::F(1))).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); app.handle_key(press(KeyCode::Enter)).unwrap();
let text = cells_to_text(&draw_board(&app, 120, 30), 120, 30);
assert!(
text.contains("Status picker"),
"the Status dropdown renders:\n{text}"
);
let footer = text
.lines()
.find(|line| line.contains("close |"))
.expect("the picker footer renders");
assert!(
footer.contains("select"),
"the footer names select: {footer:?}"
);
assert!(
!footer.contains("create"),
"Status must not offer the create key it refuses: {footer:?}"
);
app.handle_key(press(KeyCode::Esc)).unwrap();
app.handle_key(press(KeyCode::Down)).unwrap(); app.handle_key(press(KeyCode::Enter)).unwrap();
let text = cells_to_text(&draw_board(&app, 120, 30), 120, 30);
assert!(
text.contains("Priority picker"),
"the Priority dropdown renders:\n{text}"
);
let footer = text
.lines()
.find(|line| line.contains("close |"))
.expect("the picker footer renders");
assert!(
footer.contains("create"),
"Priority keeps inline create: {footer:?}"
);
}