refactor(tests): group test files by feature, drop phase/cluster naming
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:
@@ -2,7 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`.
|
//! 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::render::{HtmlRenderer, Renderer};
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
@@ -12,6 +15,42 @@ fn render(src: &str) -> String {
|
|||||||
HtmlRenderer::new().render_to_string(&doc).unwrap()
|
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<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(),
|
||||||
|
})],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Block elements =====
|
// ===== Block elements =====
|
||||||
|
|
||||||
#[test]
|
#[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#"<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 = 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#"<td rowspan="2">b</td>"#), "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="));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]]");
|
|
||||||
}
|
|
||||||
+3
-120
@@ -1,5 +1,6 @@
|
|||||||
//! Phase 14 edit-command tests. Drive `commands::ops::*` directly with
|
//! Checkbox state transitions and the `checkbox_edit` rewriter that
|
||||||
//! parsed ASTs so we don't need a live Backend / Client.
|
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
|
||||||
|
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
|
||||||
|
|
||||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
@@ -116,124 +117,6 @@ fn checkbox_edit_finds_item_in_sublist() {
|
|||||||
assert_eq!(te.range.start.line, 1);
|
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 =====
|
// ===== sanity: workspace edit shape =====
|
||||||
|
|
||||||
#[test]
|
#[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 ==");
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
+82
-12
@@ -1,10 +1,10 @@
|
|||||||
//! Backfill tests for items missed in Phases 12–13:
|
//! Tag-related LSP surface:
|
||||||
//! - `nuwiki.tags.search`, `nuwiki.tags.generateLinks`, `nuwiki.tags.rebuild`
|
//! - `WorkspaceIndex` per-page tag lists + reverse `tags_by_name` map,
|
||||||
//! - `workspace/fileOperations` capability advertisement
|
//! stale-tag replacement on re-upsert, and tag cleanup on `remove`.
|
||||||
//!
|
//! - Tag commands: `nuwiki.tags.search`, `nuwiki.tags.generateLinks`,
|
||||||
//! These tests drive the pure ops directly; the live-handler paths
|
//! `nuwiki.tags.rebuild` — `tag_hits` / `tag_pages_snapshot` /
|
||||||
//! (rebuild walking the filesystem, did_rename re-reading from disk) are
|
//! `build_tag_links_text` / `tag_links_edit` driven directly so we
|
||||||
//! covered by the existing Phase 11 closed-doc loader tests.
|
//! don't need a live handler.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -29,7 +29,77 @@ fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
|||||||
idx
|
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]
|
#[test]
|
||||||
fn tag_hits_empty_query_returns_every_tag() {
|
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]
|
#[test]
|
||||||
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
|
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"]);
|
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== build_tag_links_text =====
|
// ===== `nuwiki.tags.generateLinks` — build_tag_links_text =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_tag_links_for_single_tag() {
|
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());
|
assert!(ops::build_tag_links_text(&snap, None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== tag_links_edit =====
|
// ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tag_links_edit_inserts_when_section_missing() {
|
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());
|
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]
|
#[test]
|
||||||
fn commands_list_includes_tag_commands() {
|
fn commands_list_includes_tag_commands() {
|
||||||
@@ -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 +
|
//! Diary feature tests:
|
||||||
//! IndexedPage diary classification.
|
//! - 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 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::vimwiki::VimwikiSyntax;
|
||||||
use nuwiki_core::syntax::SyntaxPlugin;
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
use nuwiki_lsp::config::WikiConfig;
|
use nuwiki_lsp::config::WikiConfig;
|
||||||
use nuwiki_lsp::diary;
|
use nuwiki_lsp::diary;
|
||||||
use nuwiki_lsp::index::WorkspaceIndex;
|
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
|
||||||
use tower_lsp::lsp_types::Url;
|
use tower_lsp::lsp_types::Url;
|
||||||
|
|
||||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
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_rel_path, "journal");
|
||||||
assert_eq!(cfg.wikis[0].diary_index, "index");
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+77
-5
@@ -1,8 +1,11 @@
|
|||||||
//! Phase 11 plumbing tests: WorkspaceEditBuilder, Config desugar, Wiki
|
//! LSP-side workspace plumbing:
|
||||||
//! resolution, collect_diagnostics composition. The async closed-doc
|
//! - `WorkspaceEditBuilder` (changes-map vs document-changes promotion)
|
||||||
//! loader (`Backend::read_or_open`) is exercised via integration of the
|
//! and `TextEdit` UTF-8/UTF-16 conversion.
|
||||||
//! pure pieces — the full handler path is exercised once Phase 13 wires
|
//! - `Config` desugar from `initializationOptions` JSON (per-wiki keys,
|
||||||
//! it into `workspace/rename`.
|
//! 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;
|
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);
|
let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true);
|
||||||
assert!(diags.is_empty());
|
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");
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user