refactor(tests): group test files by feature, drop phase/cluster naming
CI / cargo fmt --check (push) Successful in 21s
CI / cargo clippy (push) Successful in 1m14s
CI / cargo test (push) Successful in 1m21s
CI / editor keymaps (push) Failing after 1m31s

Restructures the integration test layout so each file is named after
what it covers, not the implementation phase it landed in.

nuwiki-core (renames only):
  vimwiki_lexer            → lexer
  vimwiki_parser           → parser
  vimwiki_tags             → tags
  vimwiki_table_alignment  → table_alignment
  diary_period             → diary
  list_continuation        → lists
  transclusion_attrs       → transclusion
  + table colspan/rowspan tests moved here from parity_cluster_1

nuwiki-lsp (renames + merges + one split):
  cluster_a_list_rewriters       → commands_lists
  cluster_b_table_rewriters      → commands_tables
  cluster_c_link_helpers +
    phase19_followlink_creates   → commands_links
  phase13_rename_commands        → commands_files
  phase17_colorize               → commands_colorize
  phase17_html_export            → html_export
  phase15_link_health            → link_health
  phase18_multi_wiki             → multi_wiki
  phase19_folding                → folding
  nav                            → navigation
  lsp_helpers                    → helpers
  phase16_diary +
    diary_frequency              → diary
  phase11_plumbing +
    parity_cluster_1 (config)    → index_and_config
  tags_index_and_lsp +
    phase17_backfill             → commands_tags
  phase14_edit_commands          → split into
    commands_checkboxes,
    commands_headings,
    commands_tasks

Net: 30 → 28 integration-test files. 456 → 455 tests (the one
removed test was a dummy `paragraph_render_is_unchanged` whose only
purpose was to keep a `ParagraphNode` import alive in
parity_cluster_1; the import is exercised elsewhere now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:30:55 +00:00
parent 5b6f789c8d
commit f4e086f981
30 changed files with 763 additions and 778 deletions
@@ -1,62 +0,0 @@
//! §13.1 Cluster C — link helpers: `paste_wikilink`, `paste_url`.
//!
//! Exercises the synthesised page-name logic and the URL form that
//! the server returns to the editor. The actual `TextEdit` shape is
//! covered indirectly: the unit tests assert that the command exists
//! in `COMMANDS` and that the path-derivation produces the expected
//! string for both root-level and subdirectory pages.
use std::path::PathBuf;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::page_name_from_uri;
use tower_lsp::lsp_types::Url;
#[test]
fn commands_list_advertises_link_helpers() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in ["nuwiki.link.pasteWikilink", "nuwiki.link.pasteUrl"] {
assert!(names.contains(&name), "missing: {name}");
}
}
#[test]
fn page_name_for_root_level_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
}
#[test]
fn page_name_for_subdir_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
assert_eq!(
page_name_from_uri(&uri, Some(&cfg.root)),
"diary/2026-05-11"
);
}
#[test]
fn paste_url_format_matches_export_output_path_for() {
// The URL the `pasteUrl` handler emits is `<name>.html`, where
// `<name>` walks path segments. Mirror that derivation and assert
// the shape we'd insert at cursor.
let name = "diary/2026-05-11";
let mut url = String::new();
for (i, seg) in name.split('/').enumerate() {
if i > 0 {
url.push('/');
}
url.push_str(seg);
}
url.push_str(".html");
assert_eq!(url, "diary/2026-05-11.html");
}
#[test]
fn paste_wikilink_shape_for_root_page() {
// `[[<name>]]` is what the handler inserts at the cursor.
let name = "Notes";
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
}
@@ -1,5 +1,6 @@
//! Phase 14 edit-command tests. Drive `commands::ops::*` directly with
//! parsed ASTs so we don't need a live Backend / Client.
//! Checkbox state transitions and the `checkbox_edit` rewriter that
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
@@ -116,124 +117,6 @@ fn checkbox_edit_finds_item_in_sublist() {
assert_eq!(te.range.start.line, 1);
}
// ===== heading_change_level =====
#[test]
fn add_heading_level_promotes_h2_to_h3() {
let src = "== Sub ==\n";
let doc = parse(src);
let edit =
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "=== Sub ===");
}
#[test]
fn add_heading_level_caps_at_6() {
let src = "====== Deep ======\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
// No-op because level 6 is the cap; clamp matched current level.
assert!(edit.is_none());
}
#[test]
fn remove_heading_level_demotes() {
let src = "=== Mid ===\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
.expect("remove_level edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "== Mid ==");
}
#[test]
fn remove_heading_level_at_h1_is_noop() {
let src = "= Top =\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
assert!(edit.is_none());
}
#[test]
fn heading_change_returns_none_when_cursor_not_on_heading() {
let src = "paragraph\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
assert!(edit.is_none());
}
#[test]
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
// The lexer's heading span starts at the first `=`, not the line's
// start, so the leading whitespace that marks a "centered" heading
// lives outside the edit range — preserved automatically. The
// rewriter's output is just the `=...=` portion.
let src = " = Title =\n";
let doc = parse(src);
let h = match &doc.children[0] {
nuwiki_core::ast::BlockNode::Heading(h) => h,
_ => panic!("expected heading"),
};
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
assert_eq!(new, "== Title ==");
}
// ===== next_task =====
#[test]
fn next_task_returns_first_unfinished() {
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
// Should be line 1 (the first [ ]).
assert_eq!(loc.range.start.line, 1);
}
#[test]
fn next_task_wraps_around_when_no_task_after_cursor() {
// Only task is on line 0; cursor on line 2 → wraps back.
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
assert_eq!(loc.range.start.line, 0);
}
#[test]
fn next_task_returns_none_when_no_open_tasks() {
let src = "- [X] a\n- [X] b\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
assert!(loc.is_none());
}
#[test]
fn next_task_only_counts_items_with_checkboxes() {
// Plain items without a `[…]` are ignored — they're not tasks.
let src = "- plain\n- [ ] real task\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
assert_eq!(loc.range.start.line, 1);
}
// ===== smoke: COMMANDS list matches registered handlers =====
#[test]
fn commands_list_is_complete() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.file.delete",
"nuwiki.list.toggleCheckbox",
"nuwiki.list.cycleCheckbox",
"nuwiki.list.rejectCheckbox",
"nuwiki.list.nextTask",
"nuwiki.heading.addLevel",
"nuwiki.heading.removeLevel",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
// ===== sanity: workspace edit shape =====
#[test]
@@ -0,0 +1,83 @@
//! Heading level rewriter — `nuwiki.heading.addLevel` /
//! `nuwiki.heading.removeLevel`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn dummy_uri() -> Url {
Url::parse("file:///tmp/note.wiki").unwrap()
}
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
let changes = edit.changes.expect("expected changes map");
let (_, edits) = changes.into_iter().next().expect("at least one uri");
assert_eq!(edits.len(), 1, "expected exactly one text edit");
edits.into_iter().next().unwrap()
}
#[test]
fn add_heading_level_promotes_h2_to_h3() {
let src = "== Sub ==\n";
let doc = parse(src);
let edit =
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "=== Sub ===");
}
#[test]
fn add_heading_level_caps_at_6() {
let src = "====== Deep ======\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
// No-op because level 6 is the cap; clamp matched current level.
assert!(edit.is_none());
}
#[test]
fn remove_heading_level_demotes() {
let src = "=== Mid ===\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
.expect("remove_level edit");
let te = one_text_edit(edit);
assert_eq!(te.new_text, "== Mid ==");
}
#[test]
fn remove_heading_level_at_h1_is_noop() {
let src = "= Top =\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
assert!(edit.is_none());
}
#[test]
fn heading_change_returns_none_when_cursor_not_on_heading() {
let src = "paragraph\n";
let doc = parse(src);
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
assert!(edit.is_none());
}
#[test]
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
// The lexer's heading span starts at the first `=`, not the line's
// start, so the leading whitespace that marks a "centered" heading
// lives outside the edit range — preserved automatically. The
// rewriter's output is just the `=...=` portion.
let src = " = Title =\n";
let doc = parse(src);
let h = match &doc.children[0] {
nuwiki_core::ast::BlockNode::Heading(h) => h,
_ => panic!("expected heading"),
};
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
assert_eq!(new, "== Title ==");
}
+122
View File
@@ -0,0 +1,122 @@
//! Link-related LSP commands.
//!
//! Covers:
//! - `nuwiki.link.pasteWikilink` / `nuwiki.link.pasteUrl` — page-name
//! synthesis from a URI and the textual shape the handler inserts
//! at the cursor.
//! - Wikilink resolution, including the regression where `<CR>` on a
//! wikilink to a not-yet-created page must still resolve to a URI
//! so the editor can open (and on save create) the future page.
//! The resolver itself is private; we exercise the synthesised-path
//! semantics via the public config helpers + index lookup.
use std::path::PathBuf;
use nuwiki_core::ast::LinkKind;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::{page_name_from_uri, WorkspaceIndex};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
// ===== paste helpers =====
#[test]
fn commands_list_advertises_link_helpers() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in ["nuwiki.link.pasteWikilink", "nuwiki.link.pasteUrl"] {
assert!(names.contains(&name), "missing: {name}");
}
}
#[test]
fn page_name_for_root_level_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
}
#[test]
fn page_name_for_subdir_page() {
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
assert_eq!(
page_name_from_uri(&uri, Some(&cfg.root)),
"diary/2026-05-11"
);
}
#[test]
fn paste_url_format_matches_export_output_path_for() {
// The URL the `pasteUrl` handler emits is `<name>.html`, where
// `<name>` walks path segments. Mirror that derivation and assert
// the shape we'd insert at cursor.
let name = "diary/2026-05-11";
let mut url = String::new();
for (i, seg) in name.split('/').enumerate() {
if i > 0 {
url.push('/');
}
url.push_str(seg);
}
url.push_str(".html");
assert_eq!(url, "diary/2026-05-11.html");
}
#[test]
fn paste_wikilink_shape_for_root_page() {
// `[[<name>]]` is what the handler inserts at the cursor.
let name = "Notes";
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
}
// ===== resolve follow-link to non-existent page =====
#[test]
fn link_to_indexed_page_resolves_via_index() {
let root = "/tmp/follow-link-1";
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
idx.upsert(target.clone(), &parse("= notes =\n"));
let resolved = idx.pages_by_name.get("Notes").cloned();
assert_eq!(resolved, Some(target));
}
#[test]
fn missing_page_is_not_in_index() {
let root = "/tmp/follow-link-2";
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
// Empty index — "Missing" isn't there, so the fallback synthesise
// path is the only way to follow `[[Missing]]`.
assert!(!idx.pages_by_name.contains_key("Missing"));
}
#[test]
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
assert_eq!(cfg.file_extension, ".wiki");
let target_path = cfg.root.join("Missing.wiki");
assert!(Url::from_file_path(&target_path).is_ok());
}
#[test]
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
// Synthesis must walk path segments, not just concatenate.
let doc = parse("[[notes/Daily]]\n");
let inline = match &doc.children[0] {
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
_ => panic!("expected paragraph"),
};
let target = match inline {
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
_ => panic!("expected wikilink"),
};
assert_eq!(target.kind, LinkKind::Wiki);
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
}
@@ -1,10 +1,10 @@
//! 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.
//! Tag-related LSP surface:
//! - `WorkspaceIndex` per-page tag lists + reverse `tags_by_name` map,
//! stale-tag replacement on re-upsert, and tag cleanup on `remove`.
//! - Tag commands: `nuwiki.tags.search`, `nuwiki.tags.generateLinks`,
//! `nuwiki.tags.rebuild` — `tag_hits` / `tag_pages_snapshot` /
//! `build_tag_links_text` / `tag_links_edit` driven directly so we
//! don't need a live handler.
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -29,7 +29,77 @@ fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
idx
}
// ===== tag_hits =====
// ===== Workspace index: per-page tags + reverse map =====
#[test]
fn upsert_populates_per_page_tags() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":alpha:beta:\n= Home =\n"));
let page = idx.page(&uri).expect("indexed");
assert_eq!(page.tags.len(), 2);
let names: Vec<_> = page.tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"alpha"));
assert!(names.contains(&"beta"));
}
#[test]
fn tags_by_name_aggregates_across_pages() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
idx.upsert(home.clone(), &parse(":shared:\n"));
idx.upsert(about.clone(), &parse(":shared:other:\n"));
let shared = idx.tags_for("shared");
assert_eq!(shared.len(), 2);
let uris: Vec<&Url> = shared.iter().map(|o| &o.uri).collect();
assert!(uris.contains(&&home));
assert!(uris.contains(&&about));
assert_eq!(idx.tags_for("other").len(), 1);
assert!(idx.tags_for("nonexistent").is_empty());
}
#[test]
fn re_upsert_replaces_stale_tags() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":old:\n"));
assert_eq!(idx.tags_for("old").len(), 1);
idx.upsert(uri.clone(), &parse(":new:\n"));
assert!(idx.tags_for("old").is_empty());
assert_eq!(idx.tags_for("new").len(), 1);
}
#[test]
fn remove_drops_tags_and_reverse_entries() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":a:b:\n"));
idx.remove(&uri);
assert!(idx.page(&uri).is_none());
assert!(idx.tags_for("a").is_empty());
assert!(idx.tags_for("b").is_empty());
}
#[test]
fn heading_scope_tag_indexed_but_not_in_metadata() {
// Tag at the third line below a heading is `Heading`-scoped; the
// metadata.tags accumulator only collects file-scope tags.
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
let doc = parse("para\n\n\n= H =\n:scoped:\n");
idx.upsert(uri.clone(), &doc);
assert_eq!(idx.tags_for("scoped").len(), 1);
assert!(doc.metadata.tags.is_empty());
}
// ===== `nuwiki.tags.search` — tag_hits =====
#[test]
fn tag_hits_empty_query_returns_every_tag() {
@@ -98,7 +168,7 @@ fn tag_hits_serializes_to_expected_json_shape() {
);
}
// ===== tag_pages_snapshot =====
// ===== `nuwiki.tags.rebuild` — tag_pages_snapshot =====
#[test]
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
@@ -127,7 +197,7 @@ fn tag_pages_snapshot_sorts_pages_alphabetically() {
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
}
// ===== build_tag_links_text =====
// ===== `nuwiki.tags.generateLinks` — build_tag_links_text =====
#[test]
fn build_tag_links_for_single_tag() {
@@ -166,7 +236,7 @@ fn build_tag_links_full_index_returns_none_when_no_tags() {
assert!(ops::build_tag_links_text(&snap, None).is_none());
}
// ===== tag_links_edit =====
// ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) =====
#[test]
fn tag_links_edit_inserts_when_section_missing() {
@@ -220,7 +290,7 @@ fn tag_links_edit_returns_none_for_unknown_tag() {
assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none());
}
// ===== COMMANDS list contains new entries =====
// ===== smoke: COMMANDS list advertises tag commands =====
#[test]
fn commands_list_includes_tag_commands() {
+71
View File
@@ -0,0 +1,71 @@
//! Task navigation (`nuwiki.list.nextTask`) plus a smoke test that the
//! `COMMANDS` registry advertises every list/heading/file handler the
//! editor surface relies on.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn dummy_uri() -> Url {
Url::parse("file:///tmp/note.wiki").unwrap()
}
// ===== next_task =====
#[test]
fn next_task_returns_first_unfinished() {
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
// Should be line 1 (the first [ ]).
assert_eq!(loc.range.start.line, 1);
}
#[test]
fn next_task_wraps_around_when_no_task_after_cursor() {
// Only task is on line 0; cursor on line 2 → wraps back.
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
assert_eq!(loc.range.start.line, 0);
}
#[test]
fn next_task_returns_none_when_no_open_tasks() {
let src = "- [X] a\n- [X] b\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
assert!(loc.is_none());
}
#[test]
fn next_task_only_counts_items_with_checkboxes() {
// Plain items without a `[…]` are ignored — they're not tasks.
let src = "- plain\n- [ ] real task\n";
let doc = parse(src);
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
assert_eq!(loc.range.start.line, 1);
}
// ===== smoke: COMMANDS list matches registered handlers =====
#[test]
fn commands_list_is_complete() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.file.delete",
"nuwiki.list.toggleCheckbox",
"nuwiki.list.cycleCheckbox",
"nuwiki.list.rejectCheckbox",
"nuwiki.list.nextTask",
"nuwiki.heading.addLevel",
"nuwiki.heading.removeLevel",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
@@ -1,14 +1,18 @@
//! Phase 16: diary date primitives + path conventions + nav helpers +
//! IndexedPage diary classification.
//! Diary feature tests:
//! - Date primitives, path conventions, nav helpers, IndexedPage diary
//! classification.
//! - Frequency wiring (daily/weekly/monthly/yearly): per-frequency
//! path/URI shape, index recognition, and `next_period` / `prev_period`
//! navigation across mixed-frequency entries.
use std::path::PathBuf;
use nuwiki_core::date::DiaryDate;
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::diary;
use nuwiki_lsp::index::WorkspaceIndex;
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
@@ -376,3 +380,227 @@ fn config_from_json_accepts_custom_diary_paths() {
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
assert_eq!(cfg.wikis[0].diary_index, "index");
}
// ===== Frequency wiring (daily / weekly / monthly / yearly) =====
fn cfg(root: &str, frequency: &str) -> WikiConfig {
let mut c = WikiConfig::from_root(PathBuf::from(root));
c.diary_rel_path = "diary".into();
c.diary_frequency = frequency.into();
c
}
#[test]
fn frequency_helper_picks_up_config_value() {
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
}
#[test]
fn diary_path_for_period_picks_right_stem_per_frequency() {
let c = cfg("/wiki", "daily");
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
let wk = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let mo = DiaryPeriod::Month {
year: 2026,
month: 5,
};
let yr = DiaryPeriod::Year { year: 2026 };
assert_eq!(
c.diary_path_for_period(&day),
PathBuf::from("/wiki/diary/2026-05-12.wiki")
);
assert_eq!(
c.diary_path_for_period(&wk),
PathBuf::from("/wiki/diary/2026-W19.wiki")
);
assert_eq!(
c.diary_path_for_period(&mo),
PathBuf::from("/wiki/diary/2026-05.wiki")
);
assert_eq!(
c.diary_path_for_period(&yr),
PathBuf::from("/wiki/diary/2026.wiki")
);
}
#[test]
fn uri_for_period_produces_file_url_per_frequency() {
let c = cfg("/wiki", "weekly");
let wk = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let u = diary::uri_for_period(&c, &wk).unwrap();
assert!(
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
"got: {u}"
);
}
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
let root = PathBuf::from("/wiki");
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
for stem in entries {
let path = format!("/wiki/diary/{stem}.wiki");
let uri = Url::from_file_path(&path).unwrap();
let ast = VimwikiSyntax::new().parse("= entry =\n");
idx.upsert(uri, &ast);
}
idx
}
#[test]
fn index_recognises_all_four_frequency_stems() {
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
for (stem, expected) in [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
] {
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
let page = idx.page(&uri).expect(stem);
assert_eq!(
page.diary_period,
Some(expected),
"stem {stem} should index as {expected:?}"
);
}
}
#[test]
fn index_only_sets_diary_date_for_daily_entries() {
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
assert!(idx.page(&daily).unwrap().diary_date.is_some());
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
}
#[test]
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
// Non-diary path with a date-looking stem — should NOT count.
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
let root = PathBuf::from("/wiki");
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
}
#[test]
fn next_prev_period_navigate_at_the_same_frequency() {
// Mix entries at different frequencies. Navigation should stay
// within the same flavour as the pivot.
let idx = make_index_with(&[
"2026-W18",
"2026-W19",
"2026-W20", // weekly
"2026-04",
"2026-05",
"2026-06", // monthly
"2026-05-10",
"2026-05-12", // daily
]);
let pivot_w = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
assert_eq!(
next_w.period,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 20
}
);
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
assert_eq!(
prev_w.period,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 18
}
);
let pivot_m = DiaryPeriod::Month {
year: 2026,
month: 5,
};
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
assert_eq!(
next_m.period,
DiaryPeriod::Month {
year: 2026,
month: 6
}
);
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
assert_eq!(
prev_m.period,
DiaryPeriod::Month {
year: 2026,
month: 4
}
);
}
#[test]
fn next_prev_period_returns_none_past_the_edge() {
let idx = make_index_with(&["2026-W19"]);
let only = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
assert!(diary::next_period(&idx, &only).is_none());
assert!(diary::prev_period(&idx, &only).is_none());
}
#[test]
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
let root = PathBuf::from("/wiki");
for (stem, expected) in [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
] {
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
assert_eq!(got, Some(expected), "stem {stem}");
}
}
-241
View File
@@ -1,241 +0,0 @@
//! Cluster 4 — diary frequency wiring at the WikiConfig + index layer.
//!
//! These tests exercise the path/URI shape for each frequency and the
//! index's ability to recognise non-daily diary entries. The
//! integration through `executeCommand` is exercised in the LSP-level
//! tests via the Neovim keymap harness; here we stay at the pure-Rust
//! layer so failures land with a clean stack.
use std::path::PathBuf;
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::diary;
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
use tower_lsp::lsp_types::Url;
fn cfg(root: &str, frequency: &str) -> WikiConfig {
let mut c = WikiConfig::from_root(PathBuf::from(root));
c.diary_rel_path = "diary".into();
c.diary_frequency = frequency.into();
c
}
#[test]
fn frequency_helper_picks_up_config_value() {
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
}
#[test]
fn diary_path_for_period_picks_right_stem_per_frequency() {
let c = cfg("/wiki", "daily");
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
let wk = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let mo = DiaryPeriod::Month {
year: 2026,
month: 5,
};
let yr = DiaryPeriod::Year { year: 2026 };
assert_eq!(
c.diary_path_for_period(&day),
PathBuf::from("/wiki/diary/2026-05-12.wiki")
);
assert_eq!(
c.diary_path_for_period(&wk),
PathBuf::from("/wiki/diary/2026-W19.wiki")
);
assert_eq!(
c.diary_path_for_period(&mo),
PathBuf::from("/wiki/diary/2026-05.wiki")
);
assert_eq!(
c.diary_path_for_period(&yr),
PathBuf::from("/wiki/diary/2026.wiki")
);
}
#[test]
fn uri_for_period_produces_file_url_per_frequency() {
let c = cfg("/wiki", "weekly");
let wk = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let u = diary::uri_for_period(&c, &wk).unwrap();
assert!(
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
"got: {u}"
);
}
// ===== Index recognition =====
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
let root = PathBuf::from("/wiki");
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
for stem in entries {
let path = format!("/wiki/diary/{stem}.wiki");
let uri = Url::from_file_path(&path).unwrap();
let ast = VimwikiSyntax::new().parse("= entry =\n");
idx.upsert(uri, &ast);
}
idx
}
#[test]
fn index_recognises_all_four_frequency_stems() {
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
for (stem, expected) in [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
] {
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
let page = idx.page(&uri).expect(stem);
assert_eq!(
page.diary_period,
Some(expected),
"stem {stem} should index as {expected:?}"
);
}
}
#[test]
fn index_only_sets_diary_date_for_daily_entries() {
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
assert!(idx.page(&daily).unwrap().diary_date.is_some());
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
}
#[test]
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
// Non-diary path with a date-looking stem — should NOT count.
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
let root = PathBuf::from("/wiki");
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
}
#[test]
fn next_prev_period_navigate_at_the_same_frequency() {
// Mix entries at different frequencies. Navigation should stay
// within the same flavour as the pivot.
let idx = make_index_with(&[
"2026-W18",
"2026-W19",
"2026-W20", // weekly
"2026-04",
"2026-05",
"2026-06", // monthly
"2026-05-10",
"2026-05-12", // daily
]);
let pivot_w = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
assert_eq!(
next_w.period,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 20
}
);
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
assert_eq!(
prev_w.period,
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 18
}
);
let pivot_m = DiaryPeriod::Month {
year: 2026,
month: 5,
};
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
assert_eq!(
next_m.period,
DiaryPeriod::Month {
year: 2026,
month: 6
}
);
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
assert_eq!(
prev_m.period,
DiaryPeriod::Month {
year: 2026,
month: 4
}
);
}
#[test]
fn next_prev_period_returns_none_past_the_edge() {
let idx = make_index_with(&["2026-W19"]);
let only = DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
};
assert!(diary::next_period(&idx, &only).is_none());
assert!(diary::prev_period(&idx, &only).is_none());
}
#[test]
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
let root = PathBuf::from("/wiki");
for (stem, expected) in [
(
"2026-05-12",
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
),
(
"2026-W19",
DiaryPeriod::Week {
iso_year: 2026,
iso_week: 19,
},
),
(
"2026-05",
DiaryPeriod::Month {
year: 2026,
month: 5,
},
),
("2026", DiaryPeriod::Year { year: 2026 }),
] {
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
assert_eq!(got, Some(expected), "stem {stem}");
}
}
@@ -1,8 +1,11 @@
//! Phase 11 plumbing tests: WorkspaceEditBuilder, Config desugar, Wiki
//! resolution, collect_diagnostics composition. The async closed-doc
//! loader (`Backend::read_or_open`) is exercised via integration of the
//! pure pieces — the full handler path is exercised once Phase 13 wires
//! it into `workspace/rename`.
//! LSP-side workspace plumbing:
//! - `WorkspaceEditBuilder` (changes-map vs document-changes promotion)
//! and `TextEdit` UTF-8/UTF-16 conversion.
//! - `Config` desugar from `initializationOptions` JSON (per-wiki keys,
//! legacy single-wiki shape).
//! - `Wiki` resolution: building wikis from raw config and matching
//! URIs to the longest-prefix wiki root.
//! - `collect_diagnostics` composition (parser errors + link-health).
use std::path::PathBuf;
@@ -256,3 +259,72 @@ fn collect_diagnostics_link_health_emits_nothing_without_index() {
let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true);
assert!(diags.is_empty());
}
// ===== Per-wiki config defaults + JSON parsing =====
#[test]
fn defaults_carry_vimwiki_per_wiki_keys() {
let cfg = WikiConfig::empty();
assert_eq!(cfg.index, "index");
assert_eq!(cfg.diary_frequency, "daily");
assert_eq!(cfg.diary_start_week_day, "monday");
assert_eq!(cfg.diary_caption_level, 1);
assert_eq!(cfg.diary_sort, "desc");
assert_eq!(cfg.diary_header, "Diary");
assert_eq!(cfg.maxhi, 1);
assert_eq!(cfg.listsyms, " .oOX");
assert!(cfg.listsyms_propagate);
assert_eq!(cfg.list_margin, -1);
assert_eq!(cfg.links_space_char, " ");
assert!(cfg.nested_syntaxes.is_empty());
assert!(!cfg.auto_toc);
}
#[test]
fn raw_wiki_parses_every_new_key() {
let cfg = config_from_json(serde_json::json!({
"wikis": [{
"root": "/tmp/w",
"index": "home",
"diary_frequency": "weekly",
"diary_start_week_day": "sunday",
"diary_caption_level": 2,
"diary_sort": "asc",
"diary_header": "Journal",
"maxhi": 6,
"listsyms": "abcde",
"listsyms_propagate": false,
"list_margin": 2,
"links_space_char": "_",
"nested_syntaxes": { "python": "python", "ruby": "ruby" },
"auto_toc": true,
}],
}));
let w = &cfg.wikis[0];
assert_eq!(w.index, "home");
assert_eq!(w.diary_frequency, "weekly");
assert_eq!(w.diary_start_week_day, "sunday");
assert_eq!(w.diary_caption_level, 2);
assert_eq!(w.diary_sort, "asc");
assert_eq!(w.diary_header, "Journal");
assert_eq!(w.maxhi, 6);
assert_eq!(w.listsyms, "abcde");
assert!(!w.listsyms_propagate);
assert_eq!(w.list_margin, 2);
assert_eq!(w.links_space_char, "_");
assert_eq!(
w.nested_syntaxes.get("python").map(String::as_str),
Some("python")
);
assert!(w.auto_toc);
}
#[test]
fn legacy_single_wiki_shape_picks_up_defaults() {
let cfg = config_from_json(serde_json::json!({
"wiki_root": "/tmp/legacy",
}));
assert_eq!(cfg.wikis.len(), 1);
assert_eq!(cfg.wikis[0].index, "index");
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
}
-186
View File
@@ -1,186 +0,0 @@
//! Cluster 1 of the parity-audit punch list: per-wiki config keys
//! (vimwiki globals shipped through `initializationOptions`) and the
//! HTML renderer's table-span attributes.
use nuwiki_core::ast::{
BlockNode, DocumentNode, InlineNode, ParagraphNode, Span, TableCellNode, TableNode,
TableRowNode, TextNode,
};
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_lsp::config::{config_from_json, WikiConfig};
// ===== Per-wiki config keys =====
#[test]
fn defaults_carry_vimwiki_per_wiki_keys() {
let cfg = WikiConfig::empty();
assert_eq!(cfg.index, "index");
assert_eq!(cfg.diary_frequency, "daily");
assert_eq!(cfg.diary_start_week_day, "monday");
assert_eq!(cfg.diary_caption_level, 1);
assert_eq!(cfg.diary_sort, "desc");
assert_eq!(cfg.diary_header, "Diary");
assert_eq!(cfg.maxhi, 1);
assert_eq!(cfg.listsyms, " .oOX");
assert!(cfg.listsyms_propagate);
assert_eq!(cfg.list_margin, -1);
assert_eq!(cfg.links_space_char, " ");
assert!(cfg.nested_syntaxes.is_empty());
assert!(!cfg.auto_toc);
}
#[test]
fn raw_wiki_parses_every_new_key() {
let cfg = config_from_json(serde_json::json!({
"wikis": [{
"root": "/tmp/w",
"index": "home",
"diary_frequency": "weekly",
"diary_start_week_day": "sunday",
"diary_caption_level": 2,
"diary_sort": "asc",
"diary_header": "Journal",
"maxhi": 6,
"listsyms": "abcde",
"listsyms_propagate": false,
"list_margin": 2,
"links_space_char": "_",
"nested_syntaxes": { "python": "python", "ruby": "ruby" },
"auto_toc": true,
}],
}));
let w = &cfg.wikis[0];
assert_eq!(w.index, "home");
assert_eq!(w.diary_frequency, "weekly");
assert_eq!(w.diary_start_week_day, "sunday");
assert_eq!(w.diary_caption_level, 2);
assert_eq!(w.diary_sort, "asc");
assert_eq!(w.diary_header, "Journal");
assert_eq!(w.maxhi, 6);
assert_eq!(w.listsyms, "abcde");
assert!(!w.listsyms_propagate);
assert_eq!(w.list_margin, 2);
assert_eq!(w.links_space_char, "_");
assert_eq!(
w.nested_syntaxes.get("python").map(String::as_str),
Some("python")
);
assert!(w.auto_toc);
}
#[test]
fn legacy_single_wiki_shape_picks_up_defaults() {
let cfg = config_from_json(serde_json::json!({
"wiki_root": "/tmp/legacy",
}));
assert_eq!(cfg.wikis.len(), 1);
assert_eq!(cfg.wikis[0].index, "index");
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
}
// ===== Table colspan/rowspan =====
fn cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
let s = Span::default();
TableCellNode {
span: s,
children: vec![InlineNode::Text(TextNode {
span: s,
content: text.into(),
})],
col_span,
row_span,
}
}
fn table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
let span = Span::default();
let rows: Vec<TableRowNode> = rows
.into_iter()
.enumerate()
.map(|(i, cells)| TableRowNode {
span,
cells,
is_header: has_header && i == 0,
})
.collect();
DocumentNode {
span,
metadata: Default::default(),
children: vec![BlockNode::Table(TableNode {
span,
rows,
has_header,
alignments: Vec::new(),
})],
}
}
#[test]
fn colspan_marker_folds_into_lead_cell() {
// | a | b | c |
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
let doc = table(
vec![
vec![
cell("a", false, false),
cell("b", false, false),
cell("c", false, false),
],
vec![
cell("d", false, false),
cell(">", true, false),
cell("e", false, false),
],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
assert!(!out.contains(r#"class="col-span""#));
}
#[test]
fn rowspan_marker_folds_into_lead_cell_above() {
let doc = table(
vec![
vec![cell("a", false, false), cell("b", false, false)],
vec![cell("c", false, false), cell("\\/", false, true)],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
}
#[test]
fn no_spans_emits_no_attrs() {
let doc = table(
vec![
vec![cell("a", false, false), cell("b", false, false)],
vec![cell("c", false, false), cell("d", false, false)],
],
false,
);
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(!out.contains("colspan="));
assert!(!out.contains("rowspan="));
}
// Make sure ParagraphNode is exercised so the import stays live.
#[test]
fn paragraph_render_is_unchanged() {
let p = DocumentNode {
span: Span::default(),
metadata: Default::default(),
children: vec![BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![InlineNode::Text(TextNode {
span: Span::default(),
content: "hi".into(),
})],
})],
};
let out = HtmlRenderer::new().render_to_string(&p).unwrap();
assert!(out.contains("hi"));
}
@@ -1,63 +0,0 @@
//! Regression: `<CR>` on a wikilink to a page that doesn't exist yet
//! must still resolve to a URI so the editor opens (and on save
//! creates) the future page. The fix lives in `Backend::resolve_target_uri`
//! but the resolver is private; we exercise the synthesised-path
//! semantics via the public config helpers + index lookup.
use std::path::PathBuf;
use nuwiki_core::ast::LinkKind;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
#[test]
fn link_to_indexed_page_resolves_via_index() {
let root = "/tmp/follow-link-1";
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
idx.upsert(target.clone(), &parse("= notes =\n"));
let resolved = idx.pages_by_name.get("Notes").cloned();
assert_eq!(resolved, Some(target));
}
#[test]
fn missing_page_is_not_in_index() {
let root = "/tmp/follow-link-2";
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
// Empty index — "Missing" isn't there, so the fallback synthesise
// path is the only way to follow `[[Missing]]`.
assert!(!idx.pages_by_name.contains_key("Missing"));
}
#[test]
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
assert_eq!(cfg.file_extension, ".wiki");
let target_path = cfg.root.join("Missing.wiki");
assert!(Url::from_file_path(&target_path).is_ok());
}
#[test]
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
// Synthesis must walk path segments, not just concatenate.
let doc = parse("[[notes/Daily]]\n");
let inline = match &doc.children[0] {
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
_ => panic!("expected paragraph"),
};
let target = match inline {
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
_ => panic!("expected wikilink"),
};
assert_eq!(target.kind, LinkKind::Wiki);
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
}
@@ -1,84 +0,0 @@
//! Phase 12 LSP-side tag tests:
//! - WorkspaceIndex maintains per-page tag lists and a reverse map.
//! - `tags_for` returns occurrences across pages.
//! - `upsert` replaces stale tags on re-parse.
//! - `remove` drops the page's tags + cleans `tags_by_name`.
use std::path::PathBuf;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
#[test]
fn upsert_populates_per_page_tags() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":alpha:beta:\n= Home =\n"));
let page = idx.page(&uri).expect("indexed");
assert_eq!(page.tags.len(), 2);
let names: Vec<_> = page.tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"alpha"));
assert!(names.contains(&"beta"));
}
#[test]
fn tags_by_name_aggregates_across_pages() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
idx.upsert(home.clone(), &parse(":shared:\n"));
idx.upsert(about.clone(), &parse(":shared:other:\n"));
let shared = idx.tags_for("shared");
assert_eq!(shared.len(), 2);
let uris: Vec<&Url> = shared.iter().map(|o| &o.uri).collect();
assert!(uris.contains(&&home));
assert!(uris.contains(&&about));
assert_eq!(idx.tags_for("other").len(), 1);
assert!(idx.tags_for("nonexistent").is_empty());
}
#[test]
fn re_upsert_replaces_stale_tags() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":old:\n"));
assert_eq!(idx.tags_for("old").len(), 1);
idx.upsert(uri.clone(), &parse(":new:\n"));
assert!(idx.tags_for("old").is_empty());
assert_eq!(idx.tags_for("new").len(), 1);
}
#[test]
fn remove_drops_tags_and_reverse_entries() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":a:b:\n"));
idx.remove(&uri);
assert!(idx.page(&uri).is_none());
assert!(idx.tags_for("a").is_empty());
assert!(idx.tags_for("b").is_empty());
}
#[test]
fn heading_scope_tag_indexed_but_not_in_metadata() {
// Tag at the third line below a heading is `Heading`-scoped; the
// metadata.tags accumulator only collects file-scope tags.
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
let doc = parse("para\n\n\n= H =\n:scoped:\n");
idx.upsert(uri.clone(), &doc);
assert_eq!(idx.tags_for("scoped").len(), 1);
assert!(doc.metadata.tags.is_empty());
}