Files
nuwiki/crates/nuwiki-lsp/tests/phase17_backfill.rs
T

236 lines
7.6 KiB
Rust
Raw Normal View History

//! Backfill tests for items missed in Phases 1213:
//! - `nuwiki.tags.search`, `nuwiki.tags.generateLinks`, `nuwiki.tags.rebuild`
//! - `workspace/fileOperations` capability advertisement
//!
//! These tests drive the pure ops directly; the live-handler paths
//! (rebuild walking the filesystem, did_rename re-reading from disk) are
//! covered by the existing Phase 11 closed-doc loader tests.
use std::collections::BTreeMap;
use std::path::PathBuf;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let path = format!("{root}/{name}.wiki");
let uri = Url::from_file_path(&path).unwrap();
idx.upsert(uri, &parse(src));
}
idx
}
// ===== tag_hits =====
#[test]
fn tag_hits_empty_query_returns_every_tag() {
let idx = build_index(
"/tmp/th1",
&[
("Home", "= Home =\n:work:personal:\n"),
("Notes", "= Notes =\n:work:\n"),
],
);
let hits = ops::tag_hits(&idx, "");
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
assert!(names.contains(&"work"));
assert!(names.contains(&"personal"));
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
assert_eq!(hits.len(), 3);
}
#[test]
fn tag_hits_substring_filter_case_insensitive() {
let idx = build_index(
"/tmp/th2",
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
);
let hits = ops::tag_hits(&idx, "RELEASE");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].name, "release-2026");
}
#[test]
fn tag_hits_orders_by_name_then_page() {
let idx = build_index(
"/tmp/th3",
&[
("Beta", "= Beta =\n:zeta:\n"),
("Alpha", "= Alpha =\n:alpha:\n"),
("Gamma", "= Gamma =\n:alpha:\n"),
],
);
let hits = ops::tag_hits(&idx, "");
let pairs: Vec<(String, String)> = hits
.iter()
.map(|h| (h.name.clone(), h.page.clone()))
.collect();
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
}
#[test]
fn tag_hits_serializes_to_expected_json_shape() {
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
let hits = ops::tag_hits(&idx, "");
let v = serde_json::to_value(&hits).unwrap();
let row = &v[0];
assert_eq!(row["name"], "release");
assert!(row["uri"].is_string());
assert!(row["range"]["start"]["line"].is_number());
assert!(row["page"].is_string());
// scope is one of the three string variants.
let scope = row["scope"].as_str().unwrap();
assert!(
["file", "heading", "standalone"].contains(&scope),
"{scope}"
);
}
// ===== tag_pages_snapshot =====
#[test]
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
// Same page has the tag in both file scope and heading scope —
// dedup so the rendered list doesn't show it twice.
let idx = build_index(
"/tmp/ts1",
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
);
let snap = ops::tag_pages_snapshot(&idx);
assert_eq!(snap.get("release").unwrap().len(), 1);
assert_eq!(snap["release"][0], "Home");
}
#[test]
fn tag_pages_snapshot_sorts_pages_alphabetically() {
let idx = build_index(
"/tmp/ts2",
&[
("Charlie", "= Charlie =\n:topic:\n"),
("Alpha", "= Alpha =\n:topic:\n"),
("Bravo", "= Bravo =\n:topic:\n"),
],
);
let snap = ops::tag_pages_snapshot(&idx);
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
}
// ===== build_tag_links_text =====
#[test]
fn build_tag_links_for_single_tag() {
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
let out = ops::build_tag_links_text(&snap, Some("release")).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Tag: release =");
assert_eq!(lines[1], "- [[Alpha]]");
assert_eq!(lines[2], "- [[Beta]]");
}
#[test]
fn build_tag_links_for_missing_tag_returns_none() {
let snap = BTreeMap::new();
assert!(ops::build_tag_links_text(&snap, Some("ghost")).is_none());
}
#[test]
fn build_tag_links_full_index_groups_by_tag() {
let mut snap = BTreeMap::new();
snap.insert("a".to_string(), vec!["P1".into()]);
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
let out = ops::build_tag_links_text(&snap, None).unwrap();
assert!(out.starts_with("= Tags =\n"));
assert!(out.contains("== a =="));
assert!(out.contains("== b =="));
assert!(out.contains("- [[P1]]"));
assert!(out.contains("- [[P2]]"));
assert!(out.contains("- [[P3]]"));
}
#[test]
fn build_tag_links_full_index_returns_none_when_no_tags() {
let snap = BTreeMap::new();
assert!(ops::build_tag_links_text(&snap, None).is_none());
}
// ===== tag_links_edit =====
#[test]
fn tag_links_edit_inserts_when_section_missing() {
let src = "Some content.\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into()]);
let edit =
ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("got an edit");
let te = &edit.changes.unwrap()[&uri][0];
// Insertion at start.
assert_eq!(te.range.start, te.range.end);
assert!(te.new_text.contains("= Tag: release ="));
assert!(te.new_text.contains("[[Alpha]]"));
}
#[test]
fn tag_links_edit_replaces_existing_section() {
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Fresh".into()]);
let edit = ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 0);
assert!(te.new_text.contains("[[Fresh]]"));
assert!(!te.new_text.contains("Stale"));
}
#[test]
fn tag_links_edit_full_index_replaces_existing_tags_section() {
let src = "= Tags =\n- [[old]]\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("alpha".to_string(), vec!["P".into()]);
let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("== alpha =="));
assert!(!te.new_text.contains("[[old]]"));
}
#[test]
fn tag_links_edit_returns_none_for_unknown_tag() {
let src = "hi\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let snap = BTreeMap::new();
assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none());
}
// ===== COMMANDS list contains new entries =====
#[test]
fn commands_list_includes_tag_commands() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.tags.search",
"nuwiki.tags.generateLinks",
"nuwiki.tags.rebuild",
] {
assert!(names.contains(&name), "missing: {name}");
}
}