diff --git a/crates/nuwiki-core/tests/diary_period.rs b/crates/nuwiki-core/tests/diary.rs similarity index 100% rename from crates/nuwiki-core/tests/diary_period.rs rename to crates/nuwiki-core/tests/diary.rs diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs index b5d6f5e..24d23d1 100644 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -2,7 +2,10 @@ //! //! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`. -use nuwiki_core::ast::LinkTarget; +use nuwiki_core::ast::{ + BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode, + TextNode, +}; use nuwiki_core::render::{HtmlRenderer, Renderer}; use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; @@ -12,6 +15,42 @@ fn render(src: &str) -> String { HtmlRenderer::new().render_to_string(&doc).unwrap() } +fn span_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 span_table(rows: Vec>, has_header: bool) -> DocumentNode { + let span = Span::default(); + let rows: Vec = 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(), + })], + } +} + // ===== Block elements ===== #[test] @@ -290,3 +329,56 @@ fn x() {} ); } } + +// ===== Table colspan / rowspan ===== + +#[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 = span_table( + vec![ + vec![ + span_cell("a", false, false), + span_cell("b", false, false), + span_cell("c", false, false), + ], + vec![ + span_cell("d", false, false), + span_cell(">", true, false), + span_cell("e", false, false), + ], + ], + false, + ); + let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); + assert!(out.contains(r#"d"#), "got: {out}"); + assert!(!out.contains(r#"class="col-span""#)); +} + +#[test] +fn rowspan_marker_folds_into_lead_cell_above() { + let doc = span_table( + vec![ + vec![span_cell("a", false, false), span_cell("b", false, false)], + vec![span_cell("c", false, false), span_cell("\\/", false, true)], + ], + false, + ); + let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); + assert!(out.contains(r#"b"#), "got: {out}"); +} + +#[test] +fn no_spans_emits_no_table_attrs() { + let doc = span_table( + vec![ + vec![span_cell("a", false, false), span_cell("b", false, false)], + vec![span_cell("c", false, false), span_cell("d", false, false)], + ], + false, + ); + let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); + assert!(!out.contains("colspan=")); + assert!(!out.contains("rowspan=")); +} diff --git a/crates/nuwiki-core/tests/vimwiki_lexer.rs b/crates/nuwiki-core/tests/lexer.rs similarity index 100% rename from crates/nuwiki-core/tests/vimwiki_lexer.rs rename to crates/nuwiki-core/tests/lexer.rs diff --git a/crates/nuwiki-core/tests/list_continuation.rs b/crates/nuwiki-core/tests/lists.rs similarity index 100% rename from crates/nuwiki-core/tests/list_continuation.rs rename to crates/nuwiki-core/tests/lists.rs diff --git a/crates/nuwiki-core/tests/vimwiki_parser.rs b/crates/nuwiki-core/tests/parser.rs similarity index 100% rename from crates/nuwiki-core/tests/vimwiki_parser.rs rename to crates/nuwiki-core/tests/parser.rs diff --git a/crates/nuwiki-core/tests/vimwiki_table_alignment.rs b/crates/nuwiki-core/tests/table_alignment.rs similarity index 100% rename from crates/nuwiki-core/tests/vimwiki_table_alignment.rs rename to crates/nuwiki-core/tests/table_alignment.rs diff --git a/crates/nuwiki-core/tests/vimwiki_tags.rs b/crates/nuwiki-core/tests/tags.rs similarity index 100% rename from crates/nuwiki-core/tests/vimwiki_tags.rs rename to crates/nuwiki-core/tests/tags.rs diff --git a/crates/nuwiki-core/tests/transclusion_attrs.rs b/crates/nuwiki-core/tests/transclusion.rs similarity index 100% rename from crates/nuwiki-core/tests/transclusion_attrs.rs rename to crates/nuwiki-core/tests/transclusion.rs diff --git a/crates/nuwiki-lsp/tests/cluster_c_link_helpers.rs b/crates/nuwiki-lsp/tests/cluster_c_link_helpers.rs deleted file mode 100644 index b6b39f9..0000000 --- a/crates/nuwiki-lsp/tests/cluster_c_link_helpers.rs +++ /dev/null @@ -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 `.html`, where - // `` 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() { - // `[[]]` is what the handler inserts at the cursor. - let name = "Notes"; - assert_eq!(format!("[[{name}]]"), "[[Notes]]"); -} diff --git a/crates/nuwiki-lsp/tests/phase14_edit_commands.rs b/crates/nuwiki-lsp/tests/commands_checkboxes.rs similarity index 50% rename from crates/nuwiki-lsp/tests/phase14_edit_commands.rs rename to crates/nuwiki-lsp/tests/commands_checkboxes.rs index fe562cb..0c9bff2 100644 --- a/crates/nuwiki-lsp/tests/phase14_edit_commands.rs +++ b/crates/nuwiki-lsp/tests/commands_checkboxes.rs @@ -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] diff --git a/crates/nuwiki-lsp/tests/phase17_colorize.rs b/crates/nuwiki-lsp/tests/commands_colorize.rs similarity index 100% rename from crates/nuwiki-lsp/tests/phase17_colorize.rs rename to crates/nuwiki-lsp/tests/commands_colorize.rs diff --git a/crates/nuwiki-lsp/tests/phase13_rename_commands.rs b/crates/nuwiki-lsp/tests/commands_files.rs similarity index 100% rename from crates/nuwiki-lsp/tests/phase13_rename_commands.rs rename to crates/nuwiki-lsp/tests/commands_files.rs diff --git a/crates/nuwiki-lsp/tests/commands_headings.rs b/crates/nuwiki-lsp/tests/commands_headings.rs new file mode 100644 index 0000000..712a00b --- /dev/null +++ b/crates/nuwiki-lsp/tests/commands_headings.rs @@ -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 =="); +} diff --git a/crates/nuwiki-lsp/tests/commands_links.rs b/crates/nuwiki-lsp/tests/commands_links.rs new file mode 100644 index 0000000..e879141 --- /dev/null +++ b/crates/nuwiki-lsp/tests/commands_links.rs @@ -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 `` 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 `.html`, where + // `` 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() { + // `[[]]` 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 /.. + 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")); +} diff --git a/crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs b/crates/nuwiki-lsp/tests/commands_lists.rs similarity index 100% rename from crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs rename to crates/nuwiki-lsp/tests/commands_lists.rs diff --git a/crates/nuwiki-lsp/tests/cluster_b_table_rewriters.rs b/crates/nuwiki-lsp/tests/commands_tables.rs similarity index 100% rename from crates/nuwiki-lsp/tests/cluster_b_table_rewriters.rs rename to crates/nuwiki-lsp/tests/commands_tables.rs diff --git a/crates/nuwiki-lsp/tests/phase17_backfill.rs b/crates/nuwiki-lsp/tests/commands_tags.rs similarity index 68% rename from crates/nuwiki-lsp/tests/phase17_backfill.rs rename to crates/nuwiki-lsp/tests/commands_tags.rs index 4b91871..64c2bbe 100644 --- a/crates/nuwiki-lsp/tests/phase17_backfill.rs +++ b/crates/nuwiki-lsp/tests/commands_tags.rs @@ -1,10 +1,10 @@ -//! 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. +//! 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() { diff --git a/crates/nuwiki-lsp/tests/commands_tasks.rs b/crates/nuwiki-lsp/tests/commands_tasks.rs new file mode 100644 index 0000000..896f6b2 --- /dev/null +++ b/crates/nuwiki-lsp/tests/commands_tasks.rs @@ -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}"); + } +} diff --git a/crates/nuwiki-lsp/tests/phase16_diary.rs b/crates/nuwiki-lsp/tests/diary.rs similarity index 62% rename from crates/nuwiki-lsp/tests/phase16_diary.rs rename to crates/nuwiki-lsp/tests/diary.rs index 31c1b71..b76888d 100644 --- a/crates/nuwiki-lsp/tests/phase16_diary.rs +++ b/crates/nuwiki-lsp/tests/diary.rs @@ -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}"); + } +} diff --git a/crates/nuwiki-lsp/tests/diary_frequency.rs b/crates/nuwiki-lsp/tests/diary_frequency.rs deleted file mode 100644 index 258f595..0000000 --- a/crates/nuwiki-lsp/tests/diary_frequency.rs +++ /dev/null @@ -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}"); - } -} diff --git a/crates/nuwiki-lsp/tests/phase19_folding.rs b/crates/nuwiki-lsp/tests/folding.rs similarity index 100% rename from crates/nuwiki-lsp/tests/phase19_folding.rs rename to crates/nuwiki-lsp/tests/folding.rs diff --git a/crates/nuwiki-lsp/tests/lsp_helpers.rs b/crates/nuwiki-lsp/tests/helpers.rs similarity index 100% rename from crates/nuwiki-lsp/tests/lsp_helpers.rs rename to crates/nuwiki-lsp/tests/helpers.rs diff --git a/crates/nuwiki-lsp/tests/phase17_html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs similarity index 100% rename from crates/nuwiki-lsp/tests/phase17_html_export.rs rename to crates/nuwiki-lsp/tests/html_export.rs diff --git a/crates/nuwiki-lsp/tests/phase11_plumbing.rs b/crates/nuwiki-lsp/tests/index_and_config.rs similarity index 74% rename from crates/nuwiki-lsp/tests/phase11_plumbing.rs rename to crates/nuwiki-lsp/tests/index_and_config.rs index 1baca73..4a787a7 100644 --- a/crates/nuwiki-lsp/tests/phase11_plumbing.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -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"); +} diff --git a/crates/nuwiki-lsp/tests/phase15_link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs similarity index 100% rename from crates/nuwiki-lsp/tests/phase15_link_health.rs rename to crates/nuwiki-lsp/tests/link_health.rs diff --git a/crates/nuwiki-lsp/tests/phase18_multi_wiki.rs b/crates/nuwiki-lsp/tests/multi_wiki.rs similarity index 100% rename from crates/nuwiki-lsp/tests/phase18_multi_wiki.rs rename to crates/nuwiki-lsp/tests/multi_wiki.rs diff --git a/crates/nuwiki-lsp/tests/nav.rs b/crates/nuwiki-lsp/tests/navigation.rs similarity index 100% rename from crates/nuwiki-lsp/tests/nav.rs rename to crates/nuwiki-lsp/tests/navigation.rs diff --git a/crates/nuwiki-lsp/tests/parity_cluster_1.rs b/crates/nuwiki-lsp/tests/parity_cluster_1.rs deleted file mode 100644 index 9d68ae3..0000000 --- a/crates/nuwiki-lsp/tests/parity_cluster_1.rs +++ /dev/null @@ -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>, has_header: bool) -> DocumentNode { - let span = Span::default(); - let rows: Vec = 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#"d"#), "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#"b"#), "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")); -} diff --git a/crates/nuwiki-lsp/tests/phase19_followlink_creates.rs b/crates/nuwiki-lsp/tests/phase19_followlink_creates.rs deleted file mode 100644 index 6e6ee3e..0000000 --- a/crates/nuwiki-lsp/tests/phase19_followlink_creates.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Regression: `` 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 /.. - 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")); -} diff --git a/crates/nuwiki-lsp/tests/tags_index_and_lsp.rs b/crates/nuwiki-lsp/tests/tags_index_and_lsp.rs deleted file mode 100644 index 5017a74..0000000 --- a/crates/nuwiki-lsp/tests/tags_index_and_lsp.rs +++ /dev/null @@ -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()); -}