#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn the_retired_key_warning_stays_actionable_at_every_terminal_width() {
let (_tmp, mut app) = make_app();
app.note_disclosure(
"remove `links:` by hand to silence \u{2014} \
alice/2026-09-14-add-the-ability-to-hide-and-show-kanban-columns.md \
(retired by C-001; never read as typed data, no migration)"
.to_string(),
);
for width in [80u16, 100, 120] {
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: {rendered:?}"));
assert!(
status.contains("remove `links:` by hand to silence"),
"width {width}: the action must be visible, got {status:?}"
);
assert!(
status.contains('\u{2026}'),
"width {width}: a cut is marked rather than silent, got {status:?}"
);
}
}
#[test]
fn accepting_the_edit_warning_declares_the_key_and_the_file_lints_clean() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue_with_custom_key(&dir, "2026-01-01-a.md", "build_id");
app.after_editor("2026-01-01-a.md");
assert!(matches!(app.mode, Mode::EditRepair { .. }));
let action = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert_eq!(action, Action::None);
assert!(
matches!(app.mode, Mode::Board),
"accepting closes the warning: {:?}",
app.mode
);
assert_eq!(
app.status_msg.as_deref(),
Some("declared `build_id` in [custom_attributes] in tix.toml")
);
let reloaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
assert_eq!(
reloaded.custom_attributes.keys,
vec!["build_id".to_string()]
);
let findings =
crate::commands::lint::collect_file(&app.store.config, &app.store, "2026-01-01-a.md")
.unwrap();
assert!(
findings.iter().all(|f| f.kind != "unknown_key"),
"the declared key must stop warning: {findings:?}"
);
}
#[test]
fn a_finding_that_is_not_an_unknown_key_has_nothing_to_declare() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
let path = dir.join("2026-01-01-a.md");
let text = std::fs::read_to_string(&path)
.unwrap()
.replace("priority: \"medium\"", "priority: \"strange\"");
std::fs::write(&path, text).unwrap();
app.after_editor("2026-01-01-a.md");
match &app.mode {
Mode::EditRepair { key, .. } => assert!(
key.is_none(),
"only an undeclared frontmatter key is declarable, got {key:?}"
),
other => panic!("expected the edit warning, got {other:?}"),
}
let action = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert_eq!(action, Action::None);
assert!(matches!(app.mode, Mode::Board));
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("nothing to declare")),
"got {:?}",
app.status_msg
);
let reloaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
assert!(
reloaded.custom_attributes.keys.is_empty(),
"a finding that is not an unknown key must not write a declaration"
);
}
#[test]
fn the_edit_warning_offers_to_declare_only_when_there_is_a_key() {
let (_tmp, mut app) = make_app();
app.mode = repair_mode(
"unknown_key: unknown top-level frontmatter key `build_id`",
Some("build_id"),
);
let chord = app
.bindings
.label(InputContext::EditRepair, TuiAction::Confirm);
for (w, h) in [(100u16, 30u16), (80, 24)] {
let text = cells_to_text(&draw_board(&app, w, h), w, h);
assert!(
text.contains(&chord),
"the offer names the effective chord `{chord}` at {w}x{h}:\n{text}"
);
assert!(
text.contains("declare `build_id`") && text.contains("[custom_attributes]"),
"the warning must offer the fix it can apply, at {w}x{h}:\n{text}"
);
}
app.mode = repair_mode(
"priority: valid options: '' (unset), low, medium, high",
None,
);
let text = cells_to_text(&draw_board(&app, 100, 30), 100, 30);
assert!(
!text.contains("declare"),
"a finding with no key must not offer a declaration:\n{text}"
);
assert!(
text.contains("re-edit") && text.contains("ignore"),
"the other keys stay exactly as they were:\n{text}"
);
}
#[test]
fn help_overlay_lists_mouse_bindings() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
let cells = draw_board(&app, 80, 24);
let text: String = cells.concat();
assert!(text.contains("click"), "got {text:?}");
assert!(text.contains("double-click"), "got {text:?}");
assert!(text.contains("right-click"), "got {text:?}");
assert!(text.contains("drag"), "got {text:?}");
}
#[test]
fn custom_board_binding_replaces_default() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir,
..test_config()
};
config.save(&tmp.path().join("tix.toml")).unwrap();
fs::write(
tmp.path().join(crate::keybindings::KEYBINDINGS_FILE),
"[board]\nnew_issue = [\"ctrl+n\"]\n",
)
.unwrap();
let mut app = App::new(&config).unwrap();
app.handle_key(press(KeyCode::Char('n'))).unwrap();
assert!(matches!(app.mode, Mode::Board));
app.handle_key(press_with_modifiers(
KeyCode::Char('n'),
crossterm::event::KeyModifiers::CONTROL,
))
.unwrap();
assert!(matches!(app.mode, Mode::Form));
}
#[test]
fn config_opened_after_milestones_is_not_milestone_mode() {
let (_tmp, mut app) = make_shell_app();
app.handle_key(press(KeyCode::F(5))).unwrap();
assert!(app.milestone_mode, "F5 is the Milestones mode");
app.handle_key(press(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Board));
app.handle_key(press(KeyCode::Char('s'))).unwrap();
assert!(matches!(app.mode, Mode::Settings));
assert!(!app.milestone_mode, "`s` opens Config, not Milestones");
assert_eq!(app.top_mode, TopMode::Config);
assert_eq!(
app.settings.as_ref().map(|s| s.section),
Some(ConfigSection::Dashboards)
);
}
#[test]
fn config_types_and_attributes_round_trip() {
let (tmp, mut app) = make_app();
let path = tmp.path().join("tix.toml");
app.open_settings(None);
app.config_set_section(ConfigSection::Types);
app.settings.as_mut().unwrap().focus(ConfigLevel::Index);
app.handle_key(press(KeyCode::Char('n'))).unwrap();
type_text(&mut app, "epic");
app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.store.config.custom_types().iter().any(|t| t == "epic"),
"the type is added in memory"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
reloaded.custom_types().iter().any(|t| t == "epic"),
"the type is persisted to tix.toml"
);
let index = app
.store
.config
.custom_types()
.iter()
.position(|t| t == "epic")
.unwrap();
app.settings.as_mut().unwrap().selected = index;
app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert!(!app.store.config.custom_types().iter().any(|t| t == "epic"));
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(!reloaded.custom_types().iter().any(|t| t == "epic"));
app.config_set_section(ConfigSection::CustomAttributes);
app.settings.as_mut().unwrap().focus(ConfigLevel::Index);
assert!(
app.store.config.attributes().iter().any(|a| a == "type"),
"the built-in attributes are listed when none are declared: {:?}",
app.store.config.attributes()
);
app.handle_key(press(KeyCode::Char('n'))).unwrap();
type_text(&mut app, "build_id");
app.handle_key(press(KeyCode::Enter)).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
reloaded
.custom_attributes
.keys
.iter()
.any(|k| k == "build_id"),
"the attribute round-trips: {:?}",
reloaded.custom_attributes.keys
);
let index = app
.store
.config
.attributes()
.iter()
.position(|a| a == "build_id")
.unwrap();
app.settings.as_mut().unwrap().selected = index;
app.handle_key(press(KeyCode::Char('x'))).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
!reloaded
.custom_attributes
.keys
.iter()
.any(|k| k == "build_id"),
"the attribute is removed: {:?}",
reloaded.custom_attributes.keys
);
}
#[test]
fn config_mode_shows_the_three_sections() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let cells = draw_board(&app, 100, 30);
let text = cells_to_text(&cells, 100, 30);
for label in ["Sections", "Dashboards", "Cycles", "Milestones"] {
assert!(text.contains(label), "Config mode shows `{label}`:\n{text}");
}
assert!(
text.contains("Dashboards — "),
"the dashboards index is the active section:\n{text}"
);
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Dashboards
);
}
#[test]
fn config_board_edit_persists_to_the_board_file() {
let (tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let col = app.active_board_config().columns[0].clone();
assert!(
!app.active_board_config().hidden_columns.contains(&col),
"the column starts visible"
);
app.settings.as_mut().unwrap().selected = 0;
let _ = app.handle_key(press(KeyCode::Char(' '))).unwrap();
let raw = fs::read_to_string(tmp.path().join("docs/boards/default.md")).unwrap();
assert!(
raw.contains("hidden_columns:") && raw.contains(&col),
"the board file records the hidden column:\n{raw}"
);
let toml = fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
assert!(
!toml.contains("[kanban"),
"a board edit must not write a second copy into tix.toml:\n{toml}"
);
}
#[test]
fn config_cycle_edit_persists_to_the_cycle_file() {
let (tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles
);
let _ = app.handle_key(press(KeyCode::Right)).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
assert!(matches!(app.mode, Mode::PlanningForm));
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
for _ in 0.."kickoff".len() {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "renamed".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::Settings),
"a form opened from Config returns to Config"
);
assert_eq!(app.top_mode, TopMode::Config);
let raw = fs::read_to_string(tmp.path().join("docs/cycles/sprint-7.md")).unwrap();
assert!(
raw.contains("renamed"),
"the cycle file carries the edit:\n{raw}"
);
}
#[test]
fn config_non_record_setting_persists_to_tix_toml() {
let (tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let editor_row = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
let _ = app.handle_key(press(KeyCode::Right)).unwrap();
let _ = app.handle_key(press(KeyCode::Right)).unwrap();
app.settings.as_mut().unwrap().detail_selected = editor_row;
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(
app.settings.as_ref().unwrap().prompt,
Some(SettingsPrompt::SetEditor(_))
),
"Enter on the editor row opens the prompt"
);
for c in "nvim".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.store.config.editor.as_deref(), Some("nvim"));
let toml = fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
assert!(
toml.contains(r#"editor = "nvim""#),
"a non-record setting persists to tix.toml:\n{toml}"
);
assert!(!toml.contains("[kanban"), "{toml}");
}
#[test]
fn config_rename_cycle_cascades_to_referencing_issues() {
let (tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Right)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
for _ in 0.."Sprint 7".len() {
let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
}
for c in "Sprint 8".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let root = tmp.path();
assert!(
!root.join("docs/cycles/sprint-7.md").exists(),
"the old cycle file is gone"
);
assert!(root.join("docs/cycles/sprint-8.md").exists());
let issue = fs::read_to_string(root.join("issues/alice/2026-01-01-a.md")).unwrap();
assert!(
issue.contains("Sprint 8") && !issue.contains("Sprint 7"),
"the referencing issue was rewritten:\n{issue}"
);
}
#[test]
fn config_delete_reports_the_reference_count() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Right)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert_eq!(
app.planning.as_ref().unwrap().prompt,
Some(PlanningPrompt::ConfirmDelete {
name: "Sprint 7".into(),
referencing: 1,
}),
"the confirmation names the one referencing issue"
);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(app.planning.as_ref().unwrap().prompt.is_none());
assert!(
!app.store.config.cycles.contains_key("Sprint 7"),
"the cycle is gone"
);
}
#[test]
fn config_delete_board_reports_zero_references() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "ops".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().prompt,
Some(SettingsPrompt::ConfirmDeleteBoard {
name: "ops".into(),
referencing: 0,
}),
"nothing in issue frontmatter names a board"
);
}
#[test]
fn config_add_second_board_creates_the_store_directory() {
let (tmp, mut app) = make_app();
let root = tmp.path();
assert!(
!root.join("docs/boards").exists(),
"the fixture starts without a board store"
);
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "ops".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
root.join("docs/boards/ops.md").exists(),
"adding a board past the seeded default creates docs/boards/"
);
assert!(root.join("docs/boards/default.md").exists());
let reloaded = Config::load_with_warning(&root.join("tix.toml"), |_| {}).unwrap();
let names: Vec<&str> = reloaded.configs().map(|(name, _)| name).collect();
assert!(
names.contains(&"ops") && names.contains(&"default"),
"{names:?}"
);
}
#[test]
fn config_add_second_cycle_creates_the_store_directory() {
let (tmp, mut app) = make_app();
let root = tmp.path();
assert!(!root.join("docs/cycles").exists());
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
for c in "Sprint 9".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
root.join("docs/cycles/sprint-9.md").exists(),
"adding a cycle past the implicit Default creates docs/cycles/"
);
}
#[test]
fn config_milestone_section_reuses_the_planning_surface() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Milestones
);
assert_eq!(
app.planning.as_ref().unwrap().segment,
PlanningSegment::Milestones
);
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert_eq!(
app.planning.as_ref().unwrap().prompt,
Some(PlanningPrompt::ConfirmDelete {
name: "GA".into(),
referencing: 1,
})
);
}
#[test]
fn config_starts_with_the_section_rail_focused() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Sections);
}
#[test]
fn config_h_and_l_move_the_level_and_clamp() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Detail);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().level,
ConfigLevel::Detail,
"the detail end clamps"
);
let _ = app.handle_key(press(KeyCode::Left)).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let _ = app.handle_key(press(KeyCode::Left)).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Sections);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Sections);
}
#[test]
fn config_h_at_the_rail_closes_the_mode() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
app.nav_display = NavDisplay::Off;
let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
assert!(
matches!(app.mode, Mode::Board),
"h at the rail backs out of the mode"
);
assert!(app.settings.is_none());
}
#[test]
fn config_j_k_change_the_section_while_the_rail_is_focused() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles
);
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Sections);
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Milestones
);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles
);
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Dashboards
);
}
#[test]
fn config_changing_section_from_the_rail_resets_the_cursors() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
app.settings.as_mut().unwrap().selected = 2;
app.settings.as_mut().unwrap().detail_selected = 1;
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles
);
assert_eq!(
app.settings.as_ref().unwrap().selected,
0,
"the per-section index cursor resets on a change"
);
assert_eq!(
app.settings.as_ref().unwrap().detail_selected,
0,
"the detail cursor resets on a change"
);
}
#[test]
fn config_j_k_move_the_index_row_only_when_the_index_is_focused() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles,
"j on the rail switches section"
);
assert_eq!(app.planning.as_ref().unwrap().selected, 0);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.planning.as_ref().unwrap().selected, 1);
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles,
"an index row move never switches section"
);
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(app.planning.as_ref().unwrap().selected, 0);
}
#[test]
fn config_j_k_move_the_detail_row_only_when_the_detail_is_focused() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let row_count = board_rows_len(app.active_board_config());
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Detail);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 1);
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Dashboards,
"a detail row move never switches section"
);
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 0);
let _ = app.handle_key(press(KeyCode::Up)).unwrap();
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 0);
for _ in 0..row_count + 3 {
let _ = app.handle_key(press(KeyCode::Down)).unwrap();
}
assert_eq!(
app.settings.as_ref().unwrap().detail_selected,
row_count - 1
);
}
#[test]
fn config_j_k_move_the_planning_body_row_when_the_body_is_focused() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.planning.as_ref().unwrap().selected, 1);
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles,
"moving a row does not switch section"
);
let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
assert_eq!(app.planning.as_ref().unwrap().selected, 0);
}
#[test]
fn config_enter_from_the_rail_steps_into_the_index() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Dashboards,
"stepping into the index does not switch section"
);
}
#[test]
fn config_enter_in_the_body_activates_the_row() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let board_name = app.active_board_name().to_string();
let col = app.active_board_config().columns[0].clone();
assert!(!app.active_board_config().hidden_columns.contains(&col));
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
app.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap()
.hidden_columns
.contains(&col),
"Enter in the detail runs the row's activate action"
);
}
#[test]
fn config_enter_in_the_planning_body_edits_the_selected_item() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
assert!(
matches!(app.mode, Mode::PlanningForm),
"Enter edits the selected cycle from the body"
);
}
#[test]
fn config_detail_row_click_focuses_the_detail() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let board_name = app.active_board_name().to_string();
let col = app.active_board_config().columns[1].clone();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = draw_board(&app, 100, 30);
assert_eq!(
app.settings.as_ref().unwrap().level,
ConfigLevel::Detail,
"the detail level is active"
);
let row = {
let regions = app.frame_hit_regions.borrow();
regions
.iter()
.find(|r| r.action == Action::ConfigDetailRow(1))
.map(|r| r.rect)
.expect("the second detail row recorded a click region")
};
let action = app.handle_mouse(down_left(row.x + 1, row.y)).unwrap();
assert_eq!(action, Action::ConfigDetailRow(1));
app.apply_config_click(Action::ConfigDetailRow(1));
let _ = app.settings_toggle_selected();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Detail);
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 1);
assert!(
app.store
.config
.kanban
.configurations
.get(&board_name)
.unwrap()
.hidden_columns
.contains(&col),
"the click activates the row it landed on"
);
}
#[test]
fn config_section_entries_are_hoverable() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = draw_board(&app, 100, 30);
let rail = {
let regions = app.frame_hit_regions.borrow();
regions
.iter()
.find(|r| r.action == Action::ConfigSection(ConfigSection::Milestones))
.map(|r| r.rect)
.expect("the Milestones rail entry recorded a click region")
};
let action = app.handle_mouse(moved_to(rail.x + 1, rail.y)).unwrap();
assert_eq!(action, Action::None, "hover never acts");
assert!(app.is_hovered(&Action::ConfigSection(ConfigSection::Milestones)));
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Dashboards,
"hover never changes the section"
);
app.handle_mouse(moved_to(rail.x + rail.width + 20, rail.y))
.unwrap();
assert!(!app.is_hovered(&Action::ConfigSection(ConfigSection::Milestones)));
}
#[test]
fn config_sections_are_mouse_navigable() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = draw_board(&app, 100, 30);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let rail = {
let regions = app.frame_hit_regions.borrow();
regions
.iter()
.find(|r| r.action == Action::ConfigSection(ConfigSection::Cycles))
.map(|r| r.rect)
.expect("the Cycles rail entry recorded a click region")
};
let action = app.handle_mouse(down_left(rail.x + 1, rail.y)).unwrap();
assert_eq!(action, Action::ConfigSection(ConfigSection::Cycles));
app.apply_config_click(action);
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles
);
assert_eq!(
app.settings.as_ref().unwrap().level,
ConfigLevel::Sections,
"a rail click focuses the rail"
);
let _ = draw_board(&app, 100, 30);
let row = {
let regions = app.frame_hit_regions.borrow();
regions
.iter()
.find(|r| r.action == Action::ConfigPlanningRow(1))
.map(|r| r.rect)
.expect("the cycle row recorded a click region")
};
let action = app.handle_mouse(down_left(row.x + 1, row.y)).unwrap();
assert_eq!(action, Action::ConfigPlanningRow(1));
app.apply_config_click(action);
assert_eq!(app.planning.as_ref().unwrap().selected, 1);
assert_eq!(
app.settings.as_ref().unwrap().level,
ConfigLevel::Index,
"a config-row click focuses the index"
);
app.config_set_section(ConfigSection::Dashboards);
let _ = draw_board(&app, 100, 30);
let board_row = {
let regions = app.frame_hit_regions.borrow();
regions
.iter()
.find(|r| r.action == Action::SettingsRow(0))
.map(|r| r.rect)
.expect("the first dashboards row recorded a click region")
};
let action = app
.handle_mouse(down_left(board_row.x + 1, board_row.y))
.unwrap();
assert_eq!(action, Action::SettingsRow(0));
}
#[test]
fn config_focused_level_is_rendered_distinctly() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let borders = |app: &App| {
let backend = ratatui::backend::TestBackend::new(100, 30);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| crate::app::render(app, f, f.area()))
.unwrap();
let content = app.frame_size.get();
let body = crate::ui::settings::config_body_rect(content);
let buffer = terminal.backend().buffer();
(
buffer[(content.x, content.y)].fg,
buffer[(body.x, body.y)].fg,
)
};
let (rail, body) = borders(&app);
assert_eq!(rail, Color::Yellow, "the focused rail is accented");
assert_eq!(body, Color::DarkGray, "the unfocused index is dimmed");
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let (rail, body) = borders(&app);
assert_eq!(rail, Color::DarkGray, "the unfocused rail is dimmed");
assert_eq!(body, Color::Yellow, "the focused index is accented");
}
#[test]
fn config_index_lists_every_board_and_drill_selects_it() {
let (_tmp, mut app) = make_app_with_config_stores();
app.store
.config
.kanban
.configurations
.insert("ops".into(), crate::config::starter_board());
app.reload_tabs();
open_config(&mut app);
let rows = crate::ui::settings::dashboard_index_rows(&app.store.config);
assert_eq!(rows.len(), 2, "the index lists every board: {rows:?}");
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().selected, 1);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Detail);
assert_eq!(
app.active_board_name(),
rows[1].0,
"drilling into a board makes it the active one"
);
}
#[test]
fn config_detail_hides_the_section_rail() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let text = cells_to_text(&draw_board(&app, 100, 30), 100, 30);
assert!(text.contains("Sections"), "the rail is drawn at the index");
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let text = cells_to_text(&draw_board(&app, 100, 30), 100, 30);
assert!(
!text.contains("Sections"),
"the rail is hidden at the detail:\n{text}"
);
assert!(
text.contains("Wrap titles:"),
"the board rows fill the content area:\n{text}"
);
}
#[test]
fn config_move_column_reorders_and_persists() {
let (tmp, mut app) = make_app_with_config_stores();
let board_name = app.active_board_name().to_string();
let mut expected = app.active_board_config().columns.clone();
assert!(expected.len() >= 2, "the starter board has columns");
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('J'))).unwrap();
expected.swap(0, 1);
assert_eq!(
app.active_board_config().columns,
expected,
"the first column moved one position later"
);
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 1);
assert_eq!(
app.board.columns[0].name, expected[0],
"the board renders the new order"
);
let reloaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
assert_eq!(
reloaded
.kanban
.configurations
.get(&board_name)
.unwrap()
.columns,
expected
);
}
#[test]
fn config_move_column_at_the_ends_is_a_no_op() {
let (tmp, mut app) = make_app_with_config_stores();
let before = app.active_board_config().columns.clone();
let file = tmp.path().join("docs/boards/default.md");
let raw_before = fs::read_to_string(&file).unwrap();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('K'))).unwrap();
assert_eq!(app.active_board_config().columns, before);
assert_eq!(app.settings.as_ref().unwrap().detail_selected, 0);
for _ in 0..before.len() - 1 {
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Char('J'))).unwrap();
assert_eq!(app.active_board_config().columns, before);
assert_eq!(
fs::read_to_string(&file).unwrap(),
raw_before,
"an end-of-list move writes nothing"
);
}
#[test]
fn config_drill_into_a_cycle_opens_the_editor_and_left_returns() {
let (_tmp, mut app) = make_app_with_config_stores();
open_config(&mut app);
let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
let text = cells_to_text(&draw_board(&app, 100, 30), 100, 30);
assert!(
text.contains("Default") && text.contains("Sprint 7"),
"the cycles index lists every cycle:\n{text}"
);
let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
assert!(
matches!(app.mode, Mode::PlanningForm),
"l drills into the record's editor"
);
let _ = app.handle_key(press(KeyCode::Left)).unwrap();
assert!(
matches!(app.mode, Mode::Settings),
"the editor returns to Config"
);
assert!(app.planning_form.is_none());
assert_eq!(
app.settings.as_ref().unwrap().section,
ConfigSection::Cycles
);
assert_eq!(app.settings.as_ref().unwrap().level, ConfigLevel::Index);
}