#[allow(dead_code)]
#[path = "../src/tui/panel.rs"]
mod panel;
use panel::FilterTask;
use std::collections::HashSet;
#[test]
fn context_ancestor_tags_skip_context_and_archive_metadata() {
for archived in [false, true] {
let prefix = if archived { "[archived] " } else { "" };
for tree in [
format!("1 New {prefix}[context] [界 面] [bug] Literal"),
format!(
"1 New {prefix}\n [context] \n [界 面] [bug] Literal"
),
] {
let mut rows = panel::rows(&tree, 90);
panel::set_dirty_markers(
&mut rows,
&[FilterTask {
id: 1,
parent_id: None,
status: "new",
description: "Literal",
}],
&HashSet::from([1]),
true,
);
panel::set_tag_ranges(
&mut rows,
&[panel::TagTask {
id: 1,
tags: &["界 面".into(), "bug".into()],
archived,
context_only: true,
}],
);
let colored: String = rows
.iter()
.filter_map(|row| row.tag_range.map(|(start, end)| &row.text[start..end]))
.collect();
assert_eq!(colored, "[界 面] [bug]", "{tree}");
assert!(rows.iter().all(|row| row.dirty));
}
}
}
#[test]
fn tag_ranges_follow_real_unicode_labels_and_skip_bracket_descriptions() {
let tasks: Vec<_> = (1..=4)
.map(|id| FilterTask {
id,
parent_id: None,
status: "new",
description: "",
})
.collect();
let mut rows = panel::rows(
concat!(
"1 New [界 面] \n",
" [bug] [fake]\n",
" Description\n",
"2 New [fake]\n",
"3 New [archived] [bug] [fake]\n",
"4 New [archived] Literal",
),
72,
);
panel::set_dirty_markers(&mut rows, &tasks, &HashSet::new(), true);
let labels = ["界 面".into(), "bug".into()];
let bug = ["bug".into()];
let archived_label = ["archived".into()];
panel::set_tag_ranges(
&mut rows,
&[
panel::TagTask {
id: 1,
tags: &labels,
archived: false,
context_only: false,
},
panel::TagTask {
id: 2,
tags: &[],
archived: false,
context_only: false,
},
panel::TagTask {
id: 3,
tags: &bug,
archived: true,
context_only: false,
},
panel::TagTask {
id: 4,
tags: &archived_label,
archived: false,
context_only: false,
},
],
);
let actual: Vec<_> = rows
.iter()
.map(|row| row.tag_range.map(|(start, end)| &row.text[start..end]))
.collect();
assert_eq!(
actual,
[
Some("[界 面] "),
Some("[bug]"),
None,
None,
Some("[bug]"),
Some("[archived]")
]
);
panel::set_tag_ranges(&mut rows, &[]);
assert!(rows.iter().all(|row| row.tag_range.is_none()));
}
#[test]
fn tag_ranges_stop_before_preview_ellipsis() {
for suffix in ["012", "..."] {
let tree = format!(
"1 New [abcdefghi\n jklmnopqrs\n tuvwxyz{suffix}\n 345] Desc"
);
let mut rows = panel::rows(&tree, 30);
panel::set_dirty_markers(
&mut rows,
&[FilterTask {
id: 1,
parent_id: None,
status: "new",
description: "Desc",
}],
&HashSet::new(),
true,
);
panel::set_tag_ranges(
&mut rows,
&[panel::TagTask {
id: 1,
tags: &[format!("abcdefghijklmnopqrstuvwxyz{suffix}345")],
archived: false,
context_only: false,
}],
);
assert_eq!(rows.len(), 3);
let (start, end) = rows[2].tag_range.unwrap();
assert_eq!(&rows[2].text[start..end], "tuvwxyz");
assert_eq!(&rows[2].text[end..], "...");
}
}
struct FilteredTasks {
included_ids: HashSet<i64>,
}
fn shared_filter(tasks: &[FilterTask<'_>], query: &str, show_completed: bool) -> FilteredTasks {
let dir = tempfile::TempDir::new().unwrap();
let run = |args: &[&str]| {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_qqq"))
.current_dir(dir.path())
.env("HOME", dir.path())
.env_remove("QQQ_SESSION")
.env_remove("CODEX_THREAD_ID")
.env_remove("CODEX_SESSION_ID")
.env_remove("HERDR_ENV")
.env_remove("HERDR_PANE_ID")
.arg("--json")
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice::<serde_json::Value>(&out.stdout).unwrap()
};
run(&["init"]);
for task in tasks {
let parent = task.parent_id.map(|id| id.to_string());
let mut args = vec!["add", task.description];
if let Some(parent) = &parent {
args.extend(["--parent", parent]);
}
assert_eq!(run(&args)["id"], task.id);
}
let conn = rusqlite::Connection::open(dir.path().join(".qqq/qqq.db")).unwrap();
for task in tasks {
conn.execute(
"UPDATE tasks SET status=?1,claim_key=?3 WHERE id=?2",
rusqlite::params![
task.status,
task.id,
(task.status == "in_progress").then(|| format!("fixture-{}", task.id))
],
)
.unwrap();
}
let mut args = vec!["list", "--query", query];
if !show_completed {
args.extend(["--max-completed", "0"]);
}
let matched = run(&args);
FilteredTasks {
included_ids: matched
.as_array()
.unwrap()
.iter()
.map(|task| task["id"].as_i64().unwrap())
.collect(),
}
}
fn tasks() -> Vec<FilterTask<'static>> {
vec![
FilterTask {
status: "new",
id: 1,
parent_id: None,
description: "Parent",
},
FilterTask {
status: "new",
id: 2,
parent_id: Some(1),
description: "Child\nHidden CaSe match",
},
FilterTask {
status: "new",
id: 3,
parent_id: Some(2),
description: "Grandchild",
},
FilterTask {
status: "new",
id: 4,
parent_id: None,
description: "Other root",
},
FilterTask {
status: "new",
id: 5,
parent_id: Some(4),
description: "Match sibling",
},
]
}
#[test]
fn filter_matches_full_description_case_insensitively_and_keeps_ancestors() {
let filtered = shared_filter(&tasks(), "cAsE MaTcH", true);
assert_eq!(filtered.included_ids, HashSet::from([1, 2]));
assert!(filtered.included_ids.contains(&1));
assert!(!filtered.included_ids.contains(&3));
assert!(!filtered.included_ids.contains(&4));
}
#[test]
fn filter_empty_query_restores_all_and_no_match_is_empty() {
assert_eq!(
shared_filter(&tasks(), "", true).included_ids,
HashSet::from([1, 2, 3, 4, 5])
);
assert!(
shared_filter(&tasks(), "absent", true)
.included_ids
.is_empty()
);
}
#[test]
fn completed_visibility_excludes_ancestors_without_hiding_unfinished_children() {
let mut tasks = tasks();
tasks[0].status = "completed";
tasks[2].status = "completed";
tasks[3].status = "error";
tasks[4].status = "in_progress";
assert_eq!(
shared_filter(&tasks, "", false).included_ids,
HashSet::from([2, 4, 5])
);
assert_eq!(
shared_filter(&tasks, "match", false).included_ids,
HashSet::from([2, 4, 5])
);
assert_eq!(
shared_filter(&tasks, "case", false).included_ids,
HashSet::from([2])
);
assert!(
shared_filter(&tasks, "parent", false)
.included_ids
.is_empty()
);
assert_eq!(
shared_filter(&tasks, "case", true).included_ids,
HashSet::from([1, 2])
);
let filtered = shared_filter(&tasks, "", false);
let ids: Vec<_> = tasks
.iter()
.filter(|task| filtered.included_ids.contains(&task.id))
.map(|task| task.id)
.collect();
assert_eq!(panel::adjacent_visible_id(&ids, None, true), Some(5));
assert_eq!(panel::adjacent_visible_id(&ids, Some(5), true), Some(4));
assert_eq!(panel::adjacent_visible_id(&ids, Some(4), true), Some(2));
assert_eq!(panel::adjacent_visible_id(&ids, Some(2), true), None);
}
#[test]
fn filtered_navigation_skips_hidden_ids_and_reaches_new_draft() {
let filtered = shared_filter(&tasks(), "match", true);
let ids: Vec<_> = tasks()
.iter()
.filter(|task| filtered.included_ids.contains(&task.id))
.map(|task| task.id)
.collect();
assert_eq!(ids, vec![1, 2, 4, 5]);
assert_eq!(panel::adjacent_visible_id(&ids, None, true), Some(5));
assert_eq!(panel::adjacent_visible_id(&ids, Some(5), true), Some(4));
assert_eq!(panel::adjacent_visible_id(&ids, Some(3), true), Some(5));
assert_eq!(panel::adjacent_visible_id(&ids, Some(3), false), Some(1));
assert_eq!(panel::adjacent_visible_id(&ids, Some(2), false), Some(4));
assert_eq!(panel::adjacent_visible_id(&ids, Some(5), false), None);
assert_eq!(panel::adjacent_visible_id(&ids, None, false), None);
}
#[test]
fn navigation_follows_tree_order_instead_of_numeric_ids() {
let ids = [1, 4, 2, 3];
assert_eq!(panel::adjacent_visible_id(&ids, None, true), Some(3));
assert_eq!(panel::adjacent_visible_id(&ids, Some(3), true), Some(2));
assert_eq!(panel::adjacent_visible_id(&ids, Some(2), true), Some(4));
assert_eq!(panel::adjacent_visible_id(&ids, Some(4), false), Some(2));
assert_eq!(panel::adjacent_visible_id(&ids, Some(1), true), None);
assert_eq!(panel::adjacent_visible_id(&ids, Some(3), false), None);
}
#[test]
fn visible_navigation_uses_displayed_rows_once_per_task() {
let rows = panel::rows(
"ID STATUS TASK\n1 New Parent\n4 New Child\n Wrapped detail\n2 New Other\n3 New Last",
80,
);
assert_eq!(panel::visible_ids(&rows), vec![1, 4, 2, 3]);
}
#[test]
fn dirty_description_offsets_use_hierarchy_with_literal_symbols_and_large_ids() {
let tasks = [
FilterTask {
status: "new",
id: 1,
parent_id: None,
description: "└── Literal root",
},
FilterTask {
status: "new",
id: 2,
parent_id: Some(1),
description: "│ Literal child λ",
},
FilterTask {
status: "new",
id: 1_234_567,
parent_id: Some(2),
description: "Deep",
},
FilterTask {
status: "new",
id: 4,
parent_id: Some(99),
description: "",
},
];
let tree = "1 New └── Literal root\n Root continuation\n2 Completed └── │ Literal child λ\n1234567 In progress └── Deep\n4 New ";
let mut rows = panel::rows(tree, 70);
panel::set_dirty_markers(
&mut rows,
&tasks,
&HashSet::from([1, 2, 1_234_567, 4]),
true,
);
assert_eq!(
&rows[0].text[rows[0].description_start.unwrap()..],
"└── Literal root"
);
assert!(rows[1].description_start.is_none());
assert_eq!(
&rows[2].text[rows[2].description_start.unwrap()..],
"│ Literal child λ"
);
assert_eq!(&rows[3].text[rows[3].description_start.unwrap()..], "Deep");
assert_eq!(rows[4].description_start, Some(rows[4].text.len()));
assert!(rows.iter().all(|row| row.dirty));
let text: Vec<_> = rows.iter().map(|row| row.text.clone()).collect();
panel::set_dirty_markers(&mut rows, &tasks, &HashSet::new(), true);
assert!(rows.iter().all(|row| !row.dirty));
assert_eq!(
rows.iter().map(|row| row.text.clone()).collect::<Vec<_>>(),
text
);
}