Major clean up and improvements to vimwiki compatibility and documentation.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -47,6 +47,24 @@ fn cycle_state_walks_progression() {
|
||||
assert_eq!(ops::cycle_state("[-]"), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_back_is_exact_inverse() {
|
||||
// `glp` walks the progression in reverse — every step must undo the
|
||||
// matching `cycle_state` step.
|
||||
assert_eq!(ops::cycle_state_back("[ ]"), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state_back("[X]"), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state_back("[O]"), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state_back("[o]"), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state_back("[.]"), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state_back("[-]"), Some("[ ]"));
|
||||
// Composing forward then backward returns to the start for every
|
||||
// in-cycle marker.
|
||||
for s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
||||
let fwd = ops::cycle_state(s).unwrap();
|
||||
assert_eq!(ops::cycle_state_back(fwd), Some(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_state_toggles_dash_marker() {
|
||||
assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! §13.1 §13.3 — `color_dic` → `HtmlRenderer` integration.
|
||||
//! `color_dic` → `HtmlRenderer` integration.
|
||||
//!
|
||||
//! Drives the renderer directly so we don't have to spin up the full
|
||||
//! export pipeline. The end-to-end path (export command → renderer
|
||||
//! with `cfg.color_dic` plumbed through) is integration-tested via the
|
||||
//! Phase 17 export tests.
|
||||
//! export tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Command-coverage suite: at least one behavioural test for every
|
||||
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
|
||||
//! of `doc/nuwiki.txt`).
|
||||
//!
|
||||
//! The user-facing commands funnel through `workspace/executeCommand`
|
||||
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
|
||||
//! the standard LSP requests. The Backend itself owns a live `Client` and
|
||||
//! can't be built in an integration test, so each case drives the *pure*
|
||||
//! building-block the command relies on (the same functions the dispatcher
|
||||
//! wraps). This file is organised to mirror the doc's command groups so a
|
||||
//! reader can confirm every command has coverage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use nuwiki_lsp::nav::find_inline_at;
|
||||
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
|
||||
use nuwiki_lsp::{diary, export};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||
WikiConfig::from_root(PathBuf::from(root))
|
||||
}
|
||||
|
||||
/// Build a workspace index rooted at `root` with the given `name -> source`
|
||||
/// pages, mirroring the helper used by the other command suites.
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(src));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||
let mut idx =
|
||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||
for d in dates {
|
||||
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse("= entry =\n"));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 1: Wiki / navigation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiIndex — open wiki N's index page.
|
||||
#[test]
|
||||
fn nuwiki_index_resolves_index_page() {
|
||||
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
|
||||
let page = idx.page_by_name("index").expect("index page indexed");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
|
||||
// The tab-vs-current split is an editor concern; the resolved target is
|
||||
// identical, and both verbs are advertised as executeCommands.
|
||||
#[test]
|
||||
fn nuwiki_tab_index_advertised_alongside_index() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
|
||||
}
|
||||
|
||||
// :NuwikiUISelect — pick a wiki from the configured list.
|
||||
#[test]
|
||||
fn nuwiki_ui_select_lists_configured_wikis() {
|
||||
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
|
||||
let wikis = build_wikis(&cfgs);
|
||||
assert_eq!(wikis.len(), 2);
|
||||
assert_eq!(wikis[0].config.name, "personal");
|
||||
assert_eq!(wikis[1].config.name, "work");
|
||||
|
||||
// Selection by URI picks the wiki that actually owns the file.
|
||||
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
|
||||
}
|
||||
|
||||
// :NuwikiGoto {page} — open `{page}.wiki` by name.
|
||||
#[test]
|
||||
fn nuwiki_goto_opens_page_by_name() {
|
||||
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
|
||||
let page = idx
|
||||
.page_by_name("Recipes")
|
||||
.expect("page resolvable by name");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
|
||||
assert!(idx.page_by_name("Missing").is_none());
|
||||
}
|
||||
|
||||
// :NuwikiFollowLink — follow the link under the cursor.
|
||||
#[test]
|
||||
fn nuwiki_follow_link_resolves_target_under_cursor() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let doc = parse("see [[About]]\n");
|
||||
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
|
||||
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
|
||||
let InlineNode::WikiLink(w) = node else {
|
||||
panic!("expected a wikilink, got {node:?}");
|
||||
};
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
// :NuwikiBacklinks — every reference to the current page.
|
||||
#[test]
|
||||
fn nuwiki_backlinks_lists_referrers() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", "[[About]]\n"),
|
||||
("Contact", "see [[About]] too\n"),
|
||||
("About", "= A =\n"),
|
||||
],
|
||||
);
|
||||
let back = idx.backlinks_for("About");
|
||||
assert_eq!(back.len(), 2);
|
||||
assert!(idx.backlinks_for("Home").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
|
||||
// The jump itself is editor-side cursor movement; what the server provides
|
||||
// is the ordered set of links on the page.
|
||||
#[test]
|
||||
fn nuwiki_next_prev_link_walk_links_in_document_order() {
|
||||
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
|
||||
let links = ops::collect_wiki_links(&doc);
|
||||
let targets: Vec<&str> = links
|
||||
.iter()
|
||||
.filter_map(|l| l.target.path.as_deref())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["First", "Second", "Third"]);
|
||||
}
|
||||
|
||||
// :NuwikiBaddLink — add the target of the link under the cursor to the
|
||||
// buffer list. The editor :badds whatever URI the link resolves to.
|
||||
#[test]
|
||||
fn nuwiki_badd_link_resolves_target_uri() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
|
||||
);
|
||||
let target = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Notes/Todo".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
|
||||
}
|
||||
|
||||
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
|
||||
#[test]
|
||||
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
|
||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
|
||||
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
|
||||
|
||||
assert_eq!(
|
||||
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
|
||||
Some("[[New Name|caption]]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// :NuwikiDeleteFile — delete the current page and its on-disk file.
|
||||
#[test]
|
||||
fn nuwiki_delete_file_emits_delete_workspace_edit() {
|
||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
||||
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
||||
let we = b.build();
|
||||
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected resource operations");
|
||||
};
|
||||
assert!(matches!(
|
||||
&ops[0],
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
|
||||
));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 2: Diary
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiMakeDiaryNote — open today's entry.
|
||||
#[test]
|
||||
fn nuwiki_make_diary_note_targets_todays_file() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
let uri = diary::uri_for_date(&cfg, &today).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
|
||||
// diary back / forward by one period at the daily cadence.
|
||||
#[test]
|
||||
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||
assert!(diary::uri_for_date(&cfg, &yesterday)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-11.wiki"));
|
||||
assert!(diary::uri_for_date(&cfg, &tomorrow)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-13.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryIndex — open the diary index page.
|
||||
#[test]
|
||||
fn nuwiki_diary_index_resolves_index_uri() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let uri = diary::index_uri(&cfg).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_generate_links_builds_grouped_body() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary");
|
||||
assert!(body.starts_with("= Diary ="));
|
||||
assert!(body.contains("2026-05-12"));
|
||||
assert!(body.contains("2026-05-10"));
|
||||
assert!(body.contains("2026-04-30"));
|
||||
// Newest entry appears before the older one within the same month.
|
||||
let p12 = body.find("2026-05-12").unwrap();
|
||||
let p10 = body.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10, "expected newest-first ordering");
|
||||
}
|
||||
|
||||
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_next_and_prev_day_walk_entries() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
assert_eq!(
|
||||
diary::next_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-15"
|
||||
);
|
||||
assert_eq!(
|
||||
diary::prev_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-10"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 3: Page generation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiTOC — generate / refresh the table of contents.
|
||||
#[test]
|
||||
fn nuwiki_toc_generates_nested_contents() {
|
||||
let src = "= Top =\n== Sub ==\ntext\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced");
|
||||
assert!(edit.changes.is_some() || edit.document_changes.is_some());
|
||||
|
||||
let toc = ops::build_toc_text(
|
||||
&[
|
||||
(1, "Top".into(), "top".into()),
|
||||
(2, "Sub".into(), "sub".into()),
|
||||
],
|
||||
"Contents",
|
||||
);
|
||||
assert!(toc.contains("= Contents ="));
|
||||
assert!(toc.contains("- [[#top|Top]]"));
|
||||
assert!(toc.contains(" - [[#sub|Sub]]"));
|
||||
}
|
||||
|
||||
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
|
||||
#[test]
|
||||
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
|
||||
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
|
||||
let text = ops::build_links_text(&pages, "Links", Some("Home"));
|
||||
assert!(text.contains("= Links ="));
|
||||
assert!(text.contains("- [[About]]"));
|
||||
assert!(text.contains("- [[Notes]]"));
|
||||
assert!(!text.contains("[[Home]]"), "current page excluded");
|
||||
|
||||
let src = "= Existing =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some());
|
||||
}
|
||||
|
||||
// :NuwikiCheckLinks — surface broken links across the workspace.
|
||||
#[test]
|
||||
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let good = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let broken = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Ghost".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(idx.resolve(&good, "Home").is_some());
|
||||
assert!(
|
||||
idx.resolve(&broken, "Home").is_none(),
|
||||
"broken link unresolved"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 4: Tags
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
|
||||
#[test]
|
||||
fn nuwiki_search_tags_finds_occurrences() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "alpha");
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.iter().all(|h| h.name == "alpha"));
|
||||
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
|
||||
#[test]
|
||||
fn nuwiki_generate_tag_links_builds_section() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", ":alpha:\n"),
|
||||
("Work", ":alpha:\n"),
|
||||
("Misc", ":beta:\n"),
|
||||
],
|
||||
);
|
||||
let by_tag = ops::tag_pages_snapshot(&idx);
|
||||
|
||||
let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap();
|
||||
assert!(single.starts_with("= Tag: alpha ="));
|
||||
assert!(single.contains("- [[Home]]"));
|
||||
assert!(single.contains("- [[Work]]"));
|
||||
|
||||
// No-arg variant emits a section per tag.
|
||||
let all = ops::build_tag_links_text(&by_tag, None).unwrap();
|
||||
assert!(all.starts_with("= Tags ="));
|
||||
assert!(all.contains("== alpha =="));
|
||||
assert!(all.contains("== beta =="));
|
||||
|
||||
let src = "= Home =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some());
|
||||
}
|
||||
|
||||
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
|
||||
// reflect the current on-disk tags, dropping stale ones.
|
||||
#[test]
|
||||
fn nuwiki_rebuild_tags_reflects_current_state() {
|
||||
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);
|
||||
|
||||
// Re-index after the page changed on disk.
|
||||
idx.upsert(uri, &parse(":new:\n"));
|
||||
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
|
||||
assert_eq!(idx.tags_for("new").len(), 1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 5: HTML export
|
||||
// =====================================================================
|
||||
|
||||
// :Nuwiki2HTML — render the current page to HTML.
|
||||
#[test]
|
||||
fn nuwiki_2html_renders_page() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("%title My Page\n= One =\nbody text\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"));
|
||||
assert!(html.contains("body text"));
|
||||
}
|
||||
|
||||
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
|
||||
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
|
||||
#[test]
|
||||
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("= Page =\nhi\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("{{content}}".into()),
|
||||
"Page",
|
||||
DiaryDate::today_utc(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("hi"));
|
||||
}
|
||||
|
||||
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
|
||||
// per-page output paths.
|
||||
#[test]
|
||||
fn nuwiki_all2html_maps_output_paths_and_excludes() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
|
||||
assert!(
|
||||
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
|
||||
);
|
||||
|
||||
let patterns = vec!["_drafts/**".to_string()];
|
||||
assert!(export::is_excluded(&patterns, "_drafts/secret"));
|
||||
assert!(!export::is_excluded(&patterns, "Home"));
|
||||
|
||||
// Both incremental and forced (`!`) variants are advertised.
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
|
||||
}
|
||||
|
||||
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
|
||||
#[test]
|
||||
fn nuwiki_rss_writes_feed_file() {
|
||||
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let cfg = WikiConfig::from_root(root.clone());
|
||||
|
||||
let entries = vec![
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
|
||||
},
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
|
||||
},
|
||||
];
|
||||
|
||||
let path = export_ops::write_rss(&cfg, &entries).unwrap();
|
||||
assert!(path.ends_with("rss.xml"));
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.contains("<rss version=\"2.0\">"));
|
||||
// Newest entry listed first.
|
||||
let p12 = xml.find("2026-05-12").unwrap();
|
||||
let p10 = xml.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 6: Other
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
|
||||
// editor-side action (binary download / cargo build); it is deliberately
|
||||
// NOT an LSP executeCommand, so the server must not advertise it.
|
||||
#[test]
|
||||
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
|
||||
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Command-surface cross-check: every documented command that maps to an
|
||||
// executeCommand handler is actually advertised by the server.
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn every_documented_lsp_command_is_advertised() {
|
||||
let expected = [
|
||||
"nuwiki.wiki.openIndex", // :NuwikiIndex
|
||||
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
|
||||
"nuwiki.wiki.listAll", // :NuwikiUISelect
|
||||
"nuwiki.wiki.gotoPage", // :NuwikiGoto
|
||||
"nuwiki.file.delete", // :NuwikiDeleteFile
|
||||
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
|
||||
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
|
||||
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
|
||||
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
|
||||
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
|
||||
"nuwiki.diary.next", // :NuwikiDiaryNextDay
|
||||
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
|
||||
"nuwiki.toc.generate", // :NuwikiTOC
|
||||
"nuwiki.links.generate", // :NuwikiGenerateLinks
|
||||
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
|
||||
"nuwiki.tags.search", // :NuwikiSearchTags
|
||||
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
|
||||
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
|
||||
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
|
||||
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
|
||||
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
|
||||
"nuwiki.export.rss", // :NuwikiRss
|
||||
];
|
||||
for cmd in expected {
|
||||
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Phase 13: pure-function tests for the rename helper + the file-delete
|
||||
//! Pure-function tests for the rename helper + the file-delete
|
||||
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
|
||||
//! hitting disk) is exercised by Phase 14+ integration but covered here
|
||||
//! hitting disk) is exercised by integration tests but covered here
|
||||
//! at the building-block level — same logic, no async client.
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -95,7 +95,7 @@ async fn file_delete_returns_workspace_edit_with_delete_op() {
|
||||
//
|
||||
// Easier: shape the test as an integration test over the public
|
||||
// module surface — call `commands::execute` once we expose a thin
|
||||
// builder. For Phase 13 we test the JSON-shaped contract using the
|
||||
// builder. We test the JSON-shaped contract using the
|
||||
// edits module directly, since that's what `file_delete` returns.
|
||||
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! §13.1 Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||
//! Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||
//! `changeSymbol`, `changeLevel`.
|
||||
|
||||
use nuwiki_core::ast::ListSymbol;
|
||||
@@ -86,10 +86,10 @@ fn remove_done_returns_none_when_no_lists() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_scoped_by_position_to_one_item() {
|
||||
// With `position` set, only items whose line range contains the
|
||||
// cursor line are considered. So a done item on line 1 is removed
|
||||
// by cursor on line 1, but a done item on line 3 is left alone.
|
||||
fn remove_done_scoped_by_position_to_current_list() {
|
||||
// With `position` set, the scope is the contiguous list block the
|
||||
// cursor sits in — every done item in that list is removed, not just
|
||||
// the one under the cursor.
|
||||
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
@@ -98,7 +98,37 @@ fn remove_done_scoped_by_position_to_one_item() {
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the line-0 item should match");
|
||||
assert_eq!(edits.len(), 2, "both done items in the current list match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_leaves_other_lists_untouched() {
|
||||
// Two separate list blocks split by a paragraph. Cursor in the first
|
||||
// list removes only that list's done items; the second list's done
|
||||
// item survives.
|
||||
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_cascades_into_sublists() {
|
||||
// The current-list scope includes nested sublists of the block.
|
||||
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
||||
}
|
||||
|
||||
// ===== renumber =====
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! §13.1 Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
||||
//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
@@ -337,7 +337,7 @@ fn diary_entry_serializes_to_date_string_and_uri() {
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase16_entries() {
|
||||
fn commands_list_includes_diary_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.diary.openToday",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Phase 19: LSP folding-range provider.
|
||||
//! LSP folding-range provider.
|
||||
//!
|
||||
//! Drives `folding::folding_ranges` directly. The handler wiring is
|
||||
//! shallow — it just calls into this function — so we verify the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tests for the pure helpers that the LSP backend uses to translate
|
||||
//! between `nuwiki-core` ASTs and the LSP wire format. The async
|
||||
//! request-handling loop is exercised at integration time in Phase 8+
|
||||
//! (navigation), so this file stays focused on conversion correctness.
|
||||
//! request-handling loop is exercised at integration time by the
|
||||
//! navigation tests, so this file stays focused on conversion correctness.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Phase 17: HTML export commands.
|
||||
//! HTML export commands.
|
||||
//!
|
||||
//! Drives the pure `export::*` helpers directly. The side-effecting
|
||||
//! `export_ops::write_page` / `write_rss` paths get exercised via a
|
||||
@@ -474,7 +474,7 @@ fn write_rss_emits_valid_xml_with_entries() {
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase17_entries() {
|
||||
fn commands_list_includes_export_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.export.currentToHtml",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Phase 15: link-health diagnostics + TOC/links/orphans queries.
|
||||
//! Link-health diagnostics + TOC/links/orphans queries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -630,7 +630,7 @@ fn anchor_matches_unicode_heading() {
|
||||
// ===== COMMANDS completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase15_entries() {
|
||||
fn commands_list_includes_link_health_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.toc.generate",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Phase 18: multi-wiki — interwiki resolution + picker commands.
|
||||
//! Multi-wiki — interwiki resolution + picker commands.
|
||||
//!
|
||||
//! The cross-wiki resolver methods on `Backend` are exercised indirectly
|
||||
//! through `tower_lsp::LspService`'s test harness via a synthetic
|
||||
//! `Backend` (the same pattern Phase 13 used). To keep this test suite
|
||||
//! `Backend`. To keep this test suite
|
||||
//! lightweight we drive the *pure* resolution primitives — the parser
|
||||
//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex`
|
||||
//! lookup-by-name — and verify the multi-wiki config shape end-to-end.
|
||||
@@ -80,7 +80,7 @@ fn multi_wiki_config_loads_two_entries() {
|
||||
|
||||
#[test]
|
||||
fn v1_0_single_root_still_works_alongside_multi_wiki_shape() {
|
||||
// Single-wiki shorthand is the v1.0 form; ensure it still desugars.
|
||||
// Single-wiki shorthand is the legacy form; ensure it still desugars.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
@@ -135,7 +135,7 @@ fn nested_root_picks_longest_prefix_match() {
|
||||
|
||||
// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) =====
|
||||
|
||||
/// Verify that the cross-wiki resolution semantics match the SPEC:
|
||||
/// Verify the cross-wiki resolution semantics:
|
||||
/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not
|
||||
/// the source wiki's. The pure-data check here mirrors what
|
||||
/// `Backend::resolve_target_uri` does.
|
||||
@@ -185,7 +185,7 @@ fn interwiki_resolution_falls_back_when_wiki_missing() {
|
||||
// ===== Wiki commands: COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase18_entries() {
|
||||
fn commands_list_includes_wiki_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.wiki.listAll",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tests for the Phase 8 navigation pieces: WorkspaceIndex queries,
|
||||
//! Tests for the navigation pieces: WorkspaceIndex queries,
|
||||
//! page-name derivation, anchor slugify, position lookup, and link
|
||||
//! resolution. The async LSP handlers are exercised through these
|
||||
//! helpers; end-to-end JSON-RPC drive lives in a follow-up phase.
|
||||
//! helpers; end-to-end JSON-RPC drive lives elsewhere.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user