use std::collections::BTreeMap;
use cuj::app::{App, Opts, parse_ref};
use cuj::client::ClientConfig;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use cui::cmdline::{self, ExCommand};
use cui::keymap::{Command, KEYMAP, lookup};
use cui::query::FindSpec;
use cui::snapshot::Snapshot;
use cui::state::{self, AppState, Focus, ListSource, Mode};
fn client_for(tmp: &tempfile::TempDir) -> ClientConfig {
ClientConfig {
vault: tmp.path().join("vault"),
active_profile: "default".into(),
editor: None,
extract: BTreeMap::new(),
spaces: BTreeMap::new(),
path: tmp.path().join("config.toml"),
}
}
fn opts() -> Opts {
Opts::default()
}
fn seeded() -> (tempfile::TempDir, cuj::App) {
let tmp = tempfile::tempdir().unwrap();
let client = client_for(&tmp);
App::init(client.clone(), None).unwrap();
let app = App::open(client.clone()).unwrap();
app.create(&opts(), "capture the ..urgent idea ::work::alpha")
.unwrap();
app.create(&opts(), "quieter thought about gardens --1")
.unwrap();
app.create(&opts(), "??water the plants ..garden").unwrap();
app.label(
&opts(),
&[parse_ref("--2").unwrap()],
&["..overlaid".into()],
)
.unwrap();
App::init(client, Some("work")).unwrap();
let mut wopts = opts();
wopts.profile = Some("work".into());
app.create(&wopts, "work note pointing at --default--2")
.unwrap();
cuj::ext::taxopsis_fresh(app.root(), &app.vault, false, false).unwrap();
cuj::ext::logopsis_fresh(app.root(), &app.vault, false, false).unwrap();
(tmp, app)
}
fn seeded_state() -> (tempfile::TempDir, AppState) {
let (tmp, app) = seeded();
(tmp, AppState::new(app).unwrap())
}
#[test]
fn snapshot_rows_sorted_and_owned() {
let (_tmp, app) = seeded();
let snap = Snapshot::build(&app).unwrap();
let idents: Vec<(&str, i64)> = snap
.rows
.iter()
.map(|r| (r.profile.as_str(), r.id))
.collect();
assert_eq!(
idents,
[("default", 1), ("default", 2), ("default", 3), ("work", 1)]
);
assert_eq!(
snap.rows[0].summary,
"capture the ..urgent idea ::work::alpha"
);
assert!(snap.rows[1].effective.tags.contains("overlaid"));
assert_eq!(
snap.profiles,
vec![("default".to_string(), 3), ("work".to_string(), 1)]
);
assert_eq!(snap.by_ident[&("work".to_string(), 1)], 3);
}
#[test]
fn snapshot_summary_truncated_and_deletions_drop() {
let (_tmp, app) = seeded();
let long = "x".repeat(100);
let ident = app.create(&opts(), &long).unwrap();
let snap = Snapshot::build(&app).unwrap();
let row = &snap.rows[snap.by_ident[&("default".to_string(), ident.id)]];
assert_eq!(row.summary.chars().count(), 73);
assert!(row.summary.ends_with('…'));
app.rm(&opts(), &parse_ref(&format!("--{}", ident.id)).unwrap())
.unwrap();
let snap = Snapshot::build(&app).unwrap();
assert!(
!snap
.by_ident
.contains_key(&("default".to_string(), ident.id))
);
}
#[test]
fn keymap_bars_never_drift() {
let (_tmp, mut st) = seeded_state();
for b in KEYMAP.iter().filter(|b| b.bar.is_some()) {
st.focus = b.when.focus.unwrap_or(Focus::List);
st.source = if b.when.results_only {
ListSource::Find {
spec: "x".into(),
entries: Vec::new(),
}
} else {
ListSource::All
};
let key = KeyEvent::new(b.chord.code, b.chord.mods);
assert_eq!(
lookup(&st, &key).as_ref(),
Some(&b.cmd),
"bar item {:?} does not dispatch to its own command",
b.key_label
);
}
}
#[test]
fn keymap_no_chord_collisions() {
for (i, a) in KEYMAP.iter().enumerate() {
for b in &KEYMAP[i + 1..] {
if a.chord.code != b.chord.code || a.chord.mods != b.chord.mods {
continue;
}
let focus_overlap = match (a.when.focus, b.when.focus) {
(Some(x), Some(y)) => x == y,
_ => true,
};
if focus_overlap && a.when.results_only == b.when.results_only {
assert_eq!(
a.cmd, b.cmd,
"chord {:?} bound to two different commands",
a.chord.code
);
}
}
}
}
#[test]
fn keymap_focus_scoping() {
let (_tmp, mut st) = seeded_state();
st.focus = Focus::List;
assert_eq!(
lookup(&st, &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)),
None,
"h is view-only"
);
st.focus = Focus::View;
assert_eq!(
lookup(&st, &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)),
Some(Command::Back)
);
assert_eq!(
lookup(&st, &KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)),
None,
"f is list-only"
);
}
#[test]
fn parse_ex_commands() {
assert_eq!(cmdline::parse("q").unwrap(), ExCommand::Quit);
assert_eq!(cmdline::parse("quit").unwrap(), ExCommand::Quit);
assert!(cmdline::parse("quit now").is_err());
assert_eq!(cmdline::parse("help").unwrap(), ExCommand::Help);
assert_eq!(cmdline::parse("reload").unwrap(), ExCommand::Reload);
assert_eq!(
cmdline::parse("profile work").unwrap(),
ExCommand::Profile("work".into())
);
assert!(cmdline::parse("profile").is_err());
assert_eq!(
cmdline::parse("search two words").unwrap(),
ExCommand::Search("two words".into())
);
assert!(cmdline::parse("search").is_err());
assert_eq!(
cmdline::parse("goto --work--3").unwrap(),
ExCommand::Goto(parse_ref("--work--3").unwrap())
);
assert!(cmdline::parse("goto nope").is_err());
assert_eq!(
cmdline::parse("backlinks").unwrap(),
ExCommand::Backlinks(None)
);
assert_eq!(
cmdline::parse("backlinks --2").unwrap(),
ExCommand::Backlinks(Some(parse_ref("--2").unwrap()))
);
assert!(cmdline::parse("frobnicate").is_err());
}
#[test]
fn parse_find_spec() {
let spec = match cmdline::parse("find ..a ::b --todo --since 2026-01-01").unwrap() {
ExCommand::Find(s) => s,
other => panic!("expected Find, got {other:?}"),
};
assert_eq!(spec.tags, ["a"]);
assert_eq!(spec.categories, ["b"]);
assert!(spec.todo);
assert_eq!(spec.since.as_deref(), Some("2026-01-01"));
assert_eq!(spec.raw, "..a ::b --todo --since 2026-01-01");
let spec = match cmdline::parse(r#"find --filter jot/index/tags = "a""#).unwrap() {
ExCommand::Find(s) => s,
other => panic!("expected Find, got {other:?}"),
};
assert_eq!(spec.filter.as_deref(), Some(r#"jot/index/tags = "a""#));
assert!(cmdline::parse("find").is_err(), "find needs a filter");
assert!(cmdline::parse("find bare").is_err(), "bare tokens rejected");
assert!(
cmdline::parse("find --since").is_err(),
"--since needs a date"
);
}
#[test]
fn reducer_navigation_and_focus() {
let (_tmp, mut st) = seeded_state();
assert_eq!(st.profile.as_deref(), Some("default"));
assert_eq!(st.visible.len(), 3);
assert_eq!(st.list.selected(), Some(0));
state::apply(&mut st, Command::MoveDown);
assert_eq!(st.list.selected(), Some(1));
state::apply(&mut st, Command::MoveBottom);
assert_eq!(st.list.selected(), Some(2));
state::apply(&mut st, Command::MoveDown);
assert_eq!(st.list.selected(), Some(2), "clamped at end");
state::apply(&mut st, Command::MoveTop);
assert_eq!(st.list.selected(), Some(0));
state::apply(&mut st, Command::Open);
assert_eq!(st.focus, Focus::View);
state::apply(&mut st, Command::MoveDown);
assert_eq!(st.view_scroll, 1, "j scrolls the view");
assert_eq!(st.list.selected(), Some(0), "list selection unchanged");
state::apply(&mut st, Command::Back);
assert_eq!(st.focus, Focus::List);
}
#[test]
fn reducer_profile_cycle() {
let (_tmp, mut st) = seeded_state();
state::apply(&mut st, Command::NextProfile);
assert_eq!(st.profile.as_deref(), Some("work"));
assert_eq!(st.visible.len(), 1);
state::apply(&mut st, Command::NextProfile);
assert_eq!(st.profile, None, "then all profiles");
assert_eq!(st.visible.len(), 4);
state::apply(&mut st, Command::NextProfile);
assert_eq!(st.profile.as_deref(), Some("default"));
state::apply(&mut st, Command::Ex(ExCommand::Profile("work".into())));
assert_eq!(st.profile.as_deref(), Some("work"));
state::apply(&mut st, Command::Ex(ExCommand::Profile("nope".into())));
assert_eq!(
st.profile.as_deref(),
Some("work"),
"unknown profile refused"
);
assert!(st.message.is_some());
}
#[test]
fn reducer_search_find_backlinks_goto() {
let (_tmp, mut st) = seeded_state();
state::apply(&mut st, Command::Ex(ExCommand::Search("gardens".into())));
assert!(matches!(st.source, ListSource::Search { .. }));
assert_eq!(st.visible.len(), 1);
assert_eq!(st.selected_row().unwrap().id, 2);
state::apply(&mut st, Command::Back);
assert!(st.source.is_all());
assert_eq!(st.visible.len(), 3);
assert_eq!(st.selected_row().unwrap().id, 2, "selection preserved");
let spec = FindSpec {
todo: true,
raw: "--todo".into(),
..FindSpec::default()
};
state::apply(&mut st, Command::Ex(ExCommand::Find(spec)));
assert_eq!(st.visible.len(), 1);
assert_eq!(st.selected_row().unwrap().id, 3);
state::apply(
&mut st,
Command::Ex(ExCommand::Goto(parse_ref("--1").unwrap())),
);
assert!(st.source.is_all());
assert_eq!(st.selected_row().unwrap().id, 1);
assert_eq!(st.focus, Focus::View);
state::apply(&mut st, Command::Backlinks);
assert!(matches!(st.source, ListSource::Backlinks { .. }));
assert_eq!(st.visible.len(), 1);
assert_eq!(st.selected_row().unwrap().id, 2);
state::apply(&mut st, Command::Ex(ExCommand::Profile("all".into())));
state::apply(
&mut st,
Command::Ex(ExCommand::Backlinks(Some(
parse_ref("--default--2").unwrap(),
))),
);
let profiles: Vec<&str> = st
.visible
.iter()
.map(|&i| st.snapshot.rows[i].profile.as_str())
.collect();
assert!(profiles.contains(&"work"), "{profiles:?}");
}
#[test]
fn reducer_goto_widens_scope_and_bare_ref_needs_profile() {
let (_tmp, mut st) = seeded_state();
state::apply(
&mut st,
Command::Ex(ExCommand::Goto(parse_ref("--work--1").unwrap())),
);
assert_eq!(st.profile.as_deref(), Some("work"), "scope follows goto");
assert_eq!(st.selected_row().unwrap().profile, "work");
state::apply(&mut st, Command::Ex(ExCommand::Profile("all".into())));
state::apply(
&mut st,
Command::Ex(ExCommand::Goto(parse_ref("--1").unwrap())),
);
let (level, msg) = st.message.clone().unwrap();
assert_eq!(level, cui::state::Level::Error);
assert!(msg.contains("ambiguous"), "{msg}");
}
#[test]
fn reducer_reload_preserves_selection() {
let (_tmp, mut st) = seeded_state();
state::apply(&mut st, Command::MoveDown);
let entry = st.selected_row().unwrap().entry;
state::apply(&mut st, Command::Reload);
assert_eq!(st.selected_row().unwrap().entry, entry);
}
#[test]
fn content_loads_lazily() {
let (_tmp, mut st) = seeded_state();
assert!(st.content_cache.is_empty());
st.ensure_content();
let entry = st.selected_row().unwrap().entry;
assert!(
st.content_of(entry)
.unwrap()
.raw
.starts_with("capture the ..urgent")
);
}
#[test]
fn draw_smoke_all_modes_and_sizes() {
let (_tmp, mut st) = seeded_state();
for (w, h) in [(80, 24), (20, 5), (5, 2)] {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
for mode in [Mode::Normal, Mode::Command, Mode::Help] {
st.mode = mode;
st.ensure_content();
terminal.draw(|f| cui::ui::draw(f, &mut st)).unwrap();
}
}
st.mode = Mode::Normal;
state::apply(&mut st, Command::Ex(ExCommand::Search("gardens".into())));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|f| cui::ui::draw(f, &mut st)).unwrap();
st.visible.clear();
st.list.select(None);
terminal.draw(|f| cui::ui::draw(f, &mut st)).unwrap();
}
#[test]
fn highlight_styles_constructs() {
use ratatui::style::{Color, Style};
let cfg = cuj::facts::ProfileConfig::new("default");
let text = "capture the ..urgent idea ::work::alpha --1\n\
??water the plants ..garden\n\
see ==https://example.org and !!intro\n";
let r = cui::highlight::render(text, &cfg);
assert_eq!(r.lines.len(), 3);
assert_eq!(r.raw, text);
let style_of = |needle: &str| -> Style {
r.lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains(needle))
.unwrap_or_else(|| panic!("no span containing {needle:?}"))
.style
};
assert_eq!(style_of("..urgent").fg, Some(Color::Yellow));
assert_eq!(style_of("::work::alpha").fg, Some(Color::Green));
assert_eq!(style_of("--1").fg, Some(Color::Cyan));
assert_eq!(style_of("??water the plants").fg, Some(Color::LightRed));
assert_eq!(style_of("..garden").fg, Some(Color::Yellow));
assert_eq!(style_of("https://example.org").fg, Some(Color::Blue));
assert_eq!(style_of("!!intro").fg, Some(Color::Magenta));
assert_eq!(style_of("capture the ").fg, None);
}
#[test]
fn highlight_survives_multibyte_and_plain_text() {
let cfg = cuj::facts::ProfileConfig::new("default");
let text = "héllo wörld — naïve …dots ..tag\nsecond ligne\n";
let r = cui::highlight::render(text, &cfg);
assert_eq!(r.lines.len(), 2);
let flat: String = r.lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(flat, "héllo wörld — naïve …dots ..tag");
let empty = cui::highlight::render("", &cfg);
assert_eq!(empty.lines.len(), 1);
}
#[test]
fn search_hybrid_via_zetetes() {
let (_tmp, app) = seeded();
cuj::ext::semopsis_fresh(app.root(), &app.vault, false, false).unwrap();
zetetes::Chain::open(app.root())
.unwrap()
.advance(&zetetes::AdvanceInputs {
semopsis: Some(zetetes::PinInput::Current),
logopsis: Some(zetetes::PinInput::Current),
..Default::default()
})
.unwrap();
let mut st = AppState::new(app).unwrap();
state::apply(&mut st, Command::Ex(ExCommand::Search("gardens".into())));
match &st.source {
ListSource::Search { engine, .. } => {
assert_eq!(*engine, cui::query::SearchEngine::Hybrid);
}
other => panic!("expected Search source, got {:?}", other.is_all()),
}
assert!(
st.visible
.iter()
.any(|&i| st.snapshot.rows[i].profile == "default" && st.snapshot.rows[i].id == 2),
"hybrid results include the gardens jot"
);
}
#[test]
fn search_falls_back_to_bm25_without_zetetes() {
let (_tmp, mut st) = seeded_state();
state::apply(&mut st, Command::Ex(ExCommand::Search("gardens".into())));
match &st.source {
ListSource::Search { engine, .. } => {
assert_eq!(*engine, cui::query::SearchEngine::Bm25);
}
_ => panic!("expected Search source"),
}
let (_, msg) = st.message.clone().unwrap();
assert!(msg.contains("zetetes"), "{msg}");
}
#[test]
fn cmdline_history_recall() {
let (_tmp, mut st) = seeded_state();
let type_line = |st: &mut AppState, line: &str| {
state::apply(st, Command::StartCommand(""));
for c in line.chars() {
cmdline::handle_key(st, &KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
cmdline::handle_key(st, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
};
type_line(&mut st, "find ..urgent");
type_line(&mut st, "search gardens");
state::apply(&mut st, Command::StartCommand(""));
cmdline::handle_key(&mut st, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(st.cmdline.buffer, "search gardens");
cmdline::handle_key(&mut st, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(st.cmdline.buffer, "find ..urgent");
cmdline::handle_key(&mut st, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
cmdline::handle_key(&mut st, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(st.cmdline.buffer, "");
}
#[test]
fn more_results_doubles_search_k() {
let (_tmp, mut st) = seeded_state();
state::apply(&mut st, Command::Ex(ExCommand::Search("gardens".into())));
let k0 = match &st.source {
ListSource::Search { k, .. } => *k,
_ => panic!("expected search source"),
};
state::apply(&mut st, Command::MoreResults);
match &st.source {
ListSource::Search { k, .. } => assert_eq!(*k, k0 * 2),
_ => panic!("expected search source"),
}
state::apply(&mut st, Command::Back);
state::apply(&mut st, Command::MoreResults);
assert!(st.message.as_ref().unwrap().1.contains("search"));
}
#[test]
fn expand_toggle_transcludes() {
let (_tmp, mut st) = seeded_state();
st.app
.create(&Opts::default(), "digest of --3:1-1 above")
.unwrap();
state::apply(&mut st, Command::Reload);
state::apply(&mut st, Command::Ex(ExCommand::Goto(parse_ref("--4").unwrap())));
st.ensure_content();
let entry = st.selected_row().unwrap().entry;
let plain: String = st
.content_of(entry)
.unwrap()
.lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(!plain.contains("water the plants"));
state::apply(&mut st, Command::ToggleExpand);
st.ensure_content();
let expanded: String = st
.content_of(entry)
.unwrap()
.lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(expanded.contains("water the plants"), "{expanded}");
}
#[test]
fn goto_suffix_scrolls() {
let (_tmp, mut st) = seeded_state();
st.app
.create(
&Opts::default(),
"line one\nline two\n!!mark line three\nline four",
)
.unwrap();
state::apply(&mut st, Command::Reload);
state::apply(
&mut st,
Command::Ex(ExCommand::Goto(parse_ref("--4:4").unwrap())),
);
assert_eq!(st.view_scroll, 3, "line suffix scrolls to its line");
state::apply(
&mut st,
Command::Ex(ExCommand::Goto(parse_ref("--4#mark").unwrap())),
);
assert_eq!(st.view_scroll, 2, "bookmark suffix scrolls to its line");
}