236 lines
7.6 KiB
Rust
236 lines
7.6 KiB
Rust
|
|
//! Backfill tests for items missed in Phases 12–13:
|
|||
|
|
//! - `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}");
|
|||
|
|
}
|
|||
|
|
}
|