#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
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 = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, number: u32, title: &str, status: &str) -> Issue {
let mut issue = Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: Status::new(status.to_string()),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".to_string()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".to_string(),
type_: None,
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "body".to_string(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
issue
}
#[test]
fn columns_are_case_insensitive_and_ordered() {
let parsed = parse_columns("PrIoRiTy,TITLE,num").unwrap();
assert_eq!(
parsed,
vec![ListColumn::Priority, ListColumn::Title, ListColumn::Num]
);
}
#[test]
fn invalid_columns_are_rejected() {
for value in ["", "title,", "title,title", "unknown"] {
assert!(parse_columns(value).is_err(), "{value:?} should fail");
}
}
#[test]
fn selected_order_and_empty_output() {
let _store = make_store().1;
let columns = parse_columns("status,title").unwrap();
let store = make_store().1;
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let output = render_table(&[], &columns, None, &resolution, 40);
let header = output.lines().next().unwrap();
assert!(header.starts_with("STATUS TITLE"));
assert_eq!(UnicodeWidthStr::width(header), 40);
assert_eq!(output.lines().count(), 1);
}
#[test]
fn title_receives_space_and_secondary_cells_wrap() {
let store = make_store().1;
let issue = seed(&store, 1, "A long title that wraps", "in progress");
let mut issue = issue;
issue.reviewer = Some("reviewer with a long name".to_string());
let columns = parse_columns("num,title,status,priority,reviewer").unwrap();
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let output = render_table(&[issue], &columns, None, &resolution, 45);
assert!(output.lines().count() > 2);
assert!(
output
.lines()
.all(|line| UnicodeWidthStr::width(line) == 45)
);
}
#[test]
fn query_without_filter_returns_every_issue() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "First", "todo");
seed(&store, 2, "Second", "done");
assert_eq!(query(&store, None, None, false).unwrap().len(), 2);
}
#[test]
fn with_archived_shows_the_row_marked_and_composes_with_the_filter() {
let (_tmp, store) = make_store();
seed(&store, 1, "Keep", "todo");
seed(&store, 2, "Old", "todo");
store.archive("2").unwrap();
let visible = query(&store, None, None, false).unwrap();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].number, 1);
let all = query(&store, None, None, true).unwrap();
assert_eq!(all.len(), 2);
assert!(all.iter().any(|issue| issue.number == 2 && issue.archived));
let archived_only = query(&store, None, Some("archived:true"), true).unwrap();
assert_eq!(numbers(&archived_only), vec![2]);
let active_only = query(&store, None, Some("archived:false"), true).unwrap();
assert_eq!(numbers(&active_only), vec![1]);
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let table = render_default_table(&all, None, &resolution, 0);
assert!(table.contains("ARCHIVED"), "marker column missing: {table}");
}
#[test]
fn progress_renders_the_count_and_rollup_for_a_parent_and_a_dash_for_a_leaf() {
let store = make_store().1;
let parent = seed(&store, 1, "Epic", "todo");
let mut child = seed(&store, 2, "Child", "done");
child.parent = Some(crate::id::IssueRef::Number(1));
fs::write(
store.issues_dir.join(&child.filename),
child.to_file_contents(),
)
.unwrap();
let leaf = seed(&store, 3, "Leaf", "todo");
let done_column =
crate::workflow::completion_column_or_default(store.config.default_config(), &store.config);
let cells = hierarchy_cells(&store, &done_column).unwrap();
assert_eq!(
cells.get(&1).map(String::as_str),
Some("1 1/1"),
"a parent renders its completed/total rollup"
);
assert_eq!(cells.get(&3), None, "a leaf has no hierarchy cell");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = parse_columns("num,progress").unwrap();
let output = render_table_with(
&[parent, leaf],
&columns,
None,
&resolution,
40,
None,
Some(&cells),
None,
None,
None,
None,
None,
None,
);
let parent_row = output
.lines()
.find(|l| l.trim_start().starts_with('1'))
.unwrap();
let leaf_row = output
.lines()
.find(|l| l.trim_start().starts_with('3'))
.unwrap();
assert!(
parent_row.contains("1/1"),
"parent row shows the rollup: {output:?}"
);
assert!(
leaf_row.trim_end().ends_with('-'),
"a leaf renders `-`, not `0`: {output:?}"
);
}
#[test]
fn a_progress_column_requested_by_name_renders_data_rather_than_a_placeholder() {
let by_name = parse_columns("num,progress").unwrap();
assert!(
wants_progress_data(false, &by_name),
"a progress column named in --columns fetches the data"
);
let plain = parse_columns("num,title").unwrap();
assert!(
wants_progress_data(true, &plain),
"--show progress fetches the data"
);
assert!(
!wants_progress_data(false, &plain),
"neither spelling: no hierarchy fetch"
);
}
fn numbers(issues: &[Issue]) -> Vec<u32> {
issues.iter().map(|issue| issue.number).collect()
}
fn store_with_projects(projects: &[(&str, &str)]) -> (TempDir, Store) {
let (tmp, mut store) = make_store();
for (id, name) in projects {
store.config.projects.insert(
(*id).to_string(),
crate::config::ProjectConfiguration {
name: (*name).to_string(),
..crate::config::ProjectConfiguration::default()
},
);
}
(tmp, store)
}
fn rewrite(store: &Store, mut issue: Issue, projects: &[&str]) {
issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
write_back(store, &issue);
}
fn write_back(store: &Store, issue: &Issue) {
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
}
#[test]
fn t018_the_project_column_shows_both_projects_and_a_dash_when_none() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform"), ("p-b", "Docs")]);
rewrite(&store, seed(&store, 1, "Shared", "todo"), &["p-a", "p-b"]);
seed(&store, 2, "None", "todo");
let issues = query(&store, None, None, false).unwrap();
let cells = project_cells(&store, &issues).unwrap();
let cell = cells.get(&1).expect("issue #1 belongs to two projects");
assert!(cell.contains("Platform (p-a)"), "{cell}");
assert!(cell.contains("Docs (p-b)"), "{cell}");
assert!(
cell.contains("done, overdue 0, blocked 0, unassigned 0"),
"the health context is rendered: {cell}"
);
assert_eq!(cells.get(&2), None, "an unassociated issue has no cell");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = parse_columns("num,projects").unwrap();
let table = render_table_with(
&issues,
&columns,
None,
&resolution,
0,
None,
None,
None,
None,
Some(&cells),
None,
None,
None,
);
assert!(table.contains("PROJECTS"), "{table}");
assert!(
table
.lines()
.any(|l| l.trim_start().starts_with('2') && l.trim_end().ends_with('-')),
"an unassociated issue renders `-`: {table}"
);
}
#[test]
fn t018_a_missing_reference_is_visible_in_the_project_cell() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform")]);
rewrite(
&store,
seed(&store, 1, "Dangling", "todo"),
&["p-a", "p-gone"],
);
let issues = query(&store, None, None, false).unwrap();
let cells = project_cells(&store, &issues).unwrap();
let cell = cells.get(&1).unwrap();
assert!(
cell.contains("p-gone (missing reference)"),
"a dangling association is named: {cell}"
);
assert!(cell.contains("Platform (p-a)"), "{cell}");
}
#[test]
fn t018_the_project_cells_are_deterministic() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform"), ("p-b", "Docs")]);
rewrite(&store, seed(&store, 1, "Shared", "todo"), &["p-b", "p-a"]);
rewrite(&store, seed(&store, 2, "One", "todo"), &["p-a"]);
let issues = query(&store, None, None, false).unwrap();
let first = project_cells(&store, &issues).unwrap();
assert_eq!(
first.get(&1).map(String::as_str),
Some(
"Docs (p-b) 0/1 done, overdue 0, blocked 0, unassigned 0; Platform (p-a) 0/2 done, overdue 0, blocked 0, unassigned 0"
),
"associations keep the issue's own order, projects in declaration order"
);
for _ in 0..3 {
assert_eq!(
project_cells(&store, &issues).unwrap(),
first,
"repeated reads are identical"
);
}
}
#[test]
fn t018_the_show_progress_column_set_is_pinned() {
let (_tmp, store) = make_store();
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let headers = |columns: &[ListColumn]| -> Vec<&'static str> {
columns.iter().map(|column| column.header()).collect()
};
let by_name = parse_columns("num,progress").unwrap();
assert_eq!(
headers(&by_name),
["NUM", "CHILDREN"],
"`--columns progress` is the CHILDREN data only"
);
let mut via_show = default_columns(&[]);
append_show_columns(&mut via_show, &["progress"]);
assert_eq!(
headers(&via_show),
["NUM", "ID", "TITLE", "CHILDREN", "PROJECTS", "EPICS"],
"`--show progress` is CHILDREN then PROJECTS then EPICS (WP04 T018), \
appended to the compact default set (#1711)"
);
let issue = seed(&store, 1, "Plain", "todo");
let mut project_free = default_columns(&[]);
append_show_columns(&mut project_free, &["progress"]);
let table = render_table_with(
&[issue],
&project_free,
None,
&resolution,
0,
None,
None,
None,
None,
None,
None,
None,
None,
);
let row = table.lines().nth(1).unwrap();
assert!(table.contains("PROJECTS"), "{table}");
assert!(
row.trim_end().ends_with('-'),
"a project-free row renders `-` in PROJECTS: {table}"
);
}
#[test]
fn t018_a_filtered_listing_reports_the_same_project_figures_as_tix_show() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform")]);
let mut done = seed(&store, 1, "Done", "done");
done.projects = Some(vec!["p-a".into()]);
write_back(&store, &done);
rewrite(&store, seed(&store, 2, "Open", "todo"), &["p-a"]);
let mut unassigned = seed(&store, 3, "Unassigned", "todo");
unassigned.assignee = None;
unassigned.projects = Some(vec!["p-a".into()]);
write_back(&store, &unassigned);
let filtered = query(&store, Some("todo"), None, false).unwrap();
assert_eq!(
numbers(&filtered),
vec![2, 3],
"precondition: only the open rows are listed"
);
let cells = project_cells(&store, &filtered).unwrap();
let figure = "1/3 done, overdue 0, blocked 0, unassigned 1";
let cell = cells.get(&2).expect("a listed row in the project");
assert!(
cell.contains(figure),
"the filtered listing reports the **full** project: {cell}"
);
let loaded = crate::commands::view::get(&store, "2").unwrap();
let block = crate::commands::view::projects_block(&store, &loaded).unwrap();
assert!(block.contains(figure), "`tix show` must agree: {block}");
}
fn store_with_epics(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
let (tmp, mut store) = make_store();
for (id, name, weight, parent) in epics {
store.config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
weight: *weight,
parent: parent.map(str::to_string),
..crate::config::EpicConfiguration::default()
},
);
}
(tmp, store)
}
fn set_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
write_back(store, &issue);
issue
}
#[test]
fn t018_the_epic_column_shows_every_reference_and_a_dash_when_none() {
let (_tmp, store) = store_with_epics(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
set_epics(
&store,
seed(&store, 1, "Shared", "todo"),
&["e-aaa111", "e-bbb222"],
);
seed(&store, 2, "None", "todo");
let issues = query(&store, None, None, false).unwrap();
let cells = crate::commands::view::epic_cells(&store, &issues).unwrap();
let cell = cells.get(&1).expect("#1 references two epics");
assert!(cell.contains("Alpha (e-aaa111)"), "{cell}");
assert!(cell.contains("Beta (e-bbb222)"), "{cell}");
assert!(
cell.contains("Alpha (e-aaa111) 0/1 done"),
"the figures are read from the service: {cell}"
);
assert_eq!(cells.get(&2), None, "an unreferenced issue has no cell");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = parse_columns("num,epics").unwrap();
let table = render_table_with(
&issues,
&columns,
None,
&resolution,
0,
None,
None,
None,
None,
None,
Some(&cells),
None,
None,
);
assert!(table.contains("EPICS"), "{table}");
assert!(
table
.lines()
.any(|l| l.trim_start().starts_with('2') && l.trim_end().ends_with('-')),
"an unreferenced issue renders `-`: {table}"
);
}
#[test]
fn t018_a_missing_epic_reference_is_visible_in_the_cell() {
let (_tmp, store) = store_with_epics(&[("e-aaa111", "Alpha", None, None)]);
set_epics(
&store,
seed(&store, 1, "Dangling", "todo"),
&["e-aaa111", "e-gone1"],
);
let issues = query(&store, None, None, false).unwrap();
let cells = crate::commands::view::epic_cells(&store, &issues).unwrap();
let cell = cells.get(&1).unwrap();
assert!(
cell.contains("e-gone1 (missing reference)"),
"a dangling reference is named: {cell}"
);
assert!(cell.contains("Alpha (e-aaa111)"), "{cell}");
}
#[test]
fn t018_the_epic_cells_are_deterministic() {
let (_tmp, store) = store_with_epics(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
set_epics(
&store,
seed(&store, 1, "Shared", "todo"),
&["e-bbb222", "e-aaa111"],
);
let issues = query(&store, None, None, false).unwrap();
let first = crate::commands::view::epic_cells(&store, &issues).unwrap();
let cell = first.get(&1).unwrap();
assert!(
cell.starts_with("Beta (e-bbb222)"),
"the issue's own reference order is kept: {cell}"
);
assert!(cell.ends_with("blocked 0"), "{cell}");
for _ in 0..3 {
assert_eq!(
crate::commands::view::epic_cells(&store, &issues).unwrap(),
first,
"repeated reads are identical"
);
}
}
#[test]
fn t018_epic_is_a_recognised_column_spelling() {
assert_eq!(
parse_columns("epics").unwrap(),
vec![ListColumn::Epics],
"the plural is the column name"
);
assert_eq!(
parse_columns("epic").unwrap(),
vec![ListColumn::Epics],
"the singular parses to the same column"
);
}
#[test]
fn the_canonical_id_is_a_column_and_a_default_one() {
assert_eq!(parse_columns("id").unwrap(), vec![ListColumn::Id]);
assert_eq!(
parse_columns("hash").unwrap(),
vec![ListColumn::Id],
"the word the request used reaches the same column"
);
assert_eq!(
default_columns(&[]),
vec![ListColumn::Num, ListColumn::Id, ListColumn::Title],
"the identity pair and the title, nothing else"
);
}
#[test]
fn truncate_cell_fits_the_width_and_marks_what_it_dropped() {
assert_eq!(truncate_cell("short", 10), "short");
assert_eq!(truncate_cell("exactly-10", 10), "exactly-10");
assert_eq!(truncate_cell("anything", 0), "", "no width, no cell");
let cut = truncate_cell("a very long title indeed", 8);
assert!(cut.ends_with('…'), "a cut is visible: {cut:?}");
assert!(
display_width(&cut) <= 8,
"and fits its column: {cut:?} is {} wide",
display_width(&cut)
);
assert!(
!cut.trim_end().ends_with(' '),
"a cut that landed after a space does not print `word …`: {cut:?}"
);
let wide = truncate_cell("日本語のタイトル", 4);
assert!(display_width(&wide) <= 4, "{wide:?}");
}
#[test]
fn the_compact_table_is_one_line_per_issue_at_the_given_width() {
let store = make_store().1;
let issue = seed(
&store,
1,
"A title long enough that it cannot possibly fit in one row",
"todo",
);
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = default_columns(std::slice::from_ref(&issue));
let (header, rows) = table_rows(
std::slice::from_ref(&issue),
&columns,
&resolution,
None,
None,
None,
None,
None,
None,
);
let rendered = render_table_from(
std::slice::from_ref(&issue),
&columns,
None,
&resolution,
40,
&header,
&rows,
None,
None,
true,
);
assert_eq!(
rendered.lines().count(),
2,
"a header and one row: {rendered}"
);
for line in rendered.lines() {
assert_eq!(UnicodeWidthStr::width(line), 40, "{line:?}");
}
}
#[test]
fn the_compact_table_keeps_the_whole_title_when_no_width_is_known() {
let store = make_store().1;
let title = "A title long enough that no forty column cap could ever hold it";
let issue = seed(&store, 1, title, "todo");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = default_columns(std::slice::from_ref(&issue));
let (header, rows) = table_rows(
std::slice::from_ref(&issue),
&columns,
&resolution,
None,
None,
None,
None,
None,
None,
);
let rendered = render_table_from(
std::slice::from_ref(&issue),
&columns,
None,
&resolution,
0,
&header,
&rows,
None,
None,
true,
);
assert!(rendered.contains(title), "{rendered}");
assert_eq!(rendered.lines().count(), 2, "{rendered}");
}
#[test]
fn the_entry_column_skips_the_triage_inbox() {
let starter = crate::config::Config::starter();
assert_eq!(entry_column(&starter).as_deref(), Some("todo"));
let bare = crate::config::Config::default();
assert_eq!(bare.triage, None);
assert_eq!(entry_column(&bare).as_deref(), Some("todo"));
let mut inbox_only = crate::config::Config::starter();
inbox_only.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["triage".to_string()],
..crate::config::BoardConfiguration::default()
},
);
assert_eq!(entry_column(&inbox_only).as_deref(), Some("triage"));
}
#[test]
fn the_default_scope_is_dropped_by_any_selection_of_your_own() {
assert!(!ListSelection::default().names_a_selection());
for selection in [
ListSelection {
all: true,
..ListSelection::default()
},
ListSelection {
status: Some("doing"),
..ListSelection::default()
},
ListSelection {
filter: Some("label:bug"),
..ListSelection::default()
},
ListSelection {
project: Some("p-1"),
..ListSelection::default()
},
ListSelection {
epic: Some("e-1"),
..ListSelection::default()
},
] {
assert!(
selection.names_a_selection(),
"a named selection replaces the scope: {selection:?}"
);
}
}
#[test]
fn is_mine_matches_the_stored_identity_and_its_handles() {
let me = crate::assignee::AuthorIdentity {
name: "Herbert Maschke".to_string(),
email: Some("thyseus@pm.me".to_string()),
};
let assigned = |value: Option<&str>| {
let mut issue = seed(&make_store().1, 1, "T", "todo");
issue.assignee = value.map(str::to_string);
issue
};
for stored in [
"Herbert Maschke <thyseus@pm.me>",
"Herbert Maschke",
"@Herbert Maschke",
"thyseus@pm.me",
"thyseus",
] {
assert!(is_mine(&assigned(Some(stored)), &me), "{stored:?}");
}
for stored in [None, Some("alice"), Some("alice@example.com")] {
assert!(!is_mine(&assigned(stored), &me), "{stored:?}");
}
}
#[test]
fn ready_orders_by_priority_then_age_then_number() {
let store = make_store().1;
let with = |number: u32, priority: &str, created: &str| {
let mut issue = seed(&store, number, "T", "todo");
issue.priority = priority.parse().unwrap();
issue.created = created.to_string();
issue
};
let mut issues = vec![
with(1, "low", "2026-01-01"),
with(2, "", "2026-01-01"),
with(3, "high", "2026-02-01"),
with(4, "high", "2026-01-01"),
with(5, "someday", "2026-01-01"),
with(6, "medium", "2026-01-01"),
];
sort_ready(&mut issues);
assert_eq!(
issues.iter().map(|issue| issue.number).collect::<Vec<_>>(),
vec![4, 3, 6, 1, 2, 5],
"high (oldest first), medium, low, unset, then the unknown word"
);
}
#[test]
fn records_carry_the_displayed_columns_in_every_format() {
let header = vec!["NUM".to_string(), "TITLE".to_string()];
let rows = vec![
vec!["1".to_string(), "Alpha & <Beta>".to_string()],
vec!["2".to_string(), String::new()],
];
let toml = render_records(&header, &rows, ListFormat::Toml).unwrap();
assert!(toml.contains("[[issues]]"), "{toml}");
assert!(toml.contains(r#"title = "Alpha & <Beta>""#), "{toml}");
assert_eq!(
toml.matches("[[issues]]").count(),
2,
"one record per issue: {toml}"
);
let yaml = render_records(&header, &rows, ListFormat::Yaml).unwrap();
assert!(yaml.starts_with("issues:\n"), "{yaml}");
assert!(
yaml.contains(" title: ''"),
"an empty cell is present and empty: {yaml}"
);
let xml = render_records(&header, &rows, ListFormat::Xml).unwrap();
assert!(xml.contains("<num>1</num>"), "{xml}");
assert!(
xml.contains("<title>Alpha & <Beta></title>"),
"values are escaped, so the document parses: {xml}"
);
assert!(ListFormat::parse("YAML").is_ok(), "case-insensitive");
for value in ["json", "csv", ""] {
let error = ListFormat::parse(value).unwrap_err().to_string();
assert!(error.contains("unknown list format"), "{value}: {error}");
}
}