//! 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}"); } }