247 lines
7.8 KiB
Rust
247 lines
7.8 KiB
Rust
|
|
//! Phase 14 edit-command tests. Drive `commands::ops::*` directly with
|
||
|
|
//! parsed ASTs so we don't need a live Backend / Client.
|
||
|
|
|
||
|
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||
|
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||
|
|
use nuwiki_lsp::commands::ops;
|
||
|
|
use tower_lsp::lsp_types::{DocumentChanges, 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()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<DocumentChanges> {
|
||
|
|
edit.document_changes
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== state transitions =====
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn toggle_state_round_trip() {
|
||
|
|
assert_eq!(ops::toggle_state("[ ]"), Some("[X]"));
|
||
|
|
assert_eq!(ops::toggle_state("[X]"), Some("[ ]"));
|
||
|
|
assert_eq!(ops::toggle_state("[.]"), Some("[X]"));
|
||
|
|
assert_eq!(ops::toggle_state("[-]"), Some("[ ]"));
|
||
|
|
assert_eq!(ops::toggle_state("[?]"), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn cycle_state_walks_progression() {
|
||
|
|
assert_eq!(ops::cycle_state("[ ]"), Some("[.]"));
|
||
|
|
assert_eq!(ops::cycle_state("[.]"), Some("[o]"));
|
||
|
|
assert_eq!(ops::cycle_state("[o]"), Some("[O]"));
|
||
|
|
assert_eq!(ops::cycle_state("[O]"), Some("[X]"));
|
||
|
|
assert_eq!(ops::cycle_state("[X]"), Some("[ ]"));
|
||
|
|
assert_eq!(ops::cycle_state("[-]"), Some("[ ]"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn reject_state_toggles_dash_marker() {
|
||
|
|
assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
|
||
|
|
assert_eq!(ops::reject_state("[X]"), Some("[-]"));
|
||
|
|
assert_eq!(ops::reject_state("[-]"), Some("[ ]"));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== checkbox_edit =====
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn toggle_empty_checkbox_to_done() {
|
||
|
|
let src = "- [ ] task\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state)
|
||
|
|
.expect("toggle edit");
|
||
|
|
let te = one_text_edit(edit);
|
||
|
|
assert_eq!(te.new_text, "[X]");
|
||
|
|
assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]"
|
||
|
|
assert_eq!(te.range.end.character, 5);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn cycle_advances_partial_states() {
|
||
|
|
let src = "- [.] half\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state)
|
||
|
|
.expect("cycle edit");
|
||
|
|
let te = one_text_edit(edit);
|
||
|
|
assert_eq!(te.new_text, "[o]");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn reject_replaces_empty_with_dash() {
|
||
|
|
let src = "* [ ] thing\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state)
|
||
|
|
.expect("reject edit");
|
||
|
|
let te = one_text_edit(edit);
|
||
|
|
assert_eq!(te.new_text, "[-]");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn checkbox_edit_returns_none_on_plain_list_item() {
|
||
|
|
// No `[…]` at all → no edit.
|
||
|
|
let src = "- plain item\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state);
|
||
|
|
assert!(edit.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn checkbox_edit_returns_none_off_list() {
|
||
|
|
let src = "just a paragraph\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state);
|
||
|
|
assert!(edit.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn checkbox_edit_finds_item_in_sublist() {
|
||
|
|
let src = "- top\n - [ ] nested\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
// Cursor on line 1, the nested item.
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state)
|
||
|
|
.expect("edit on nested item");
|
||
|
|
let te = one_text_edit(edit);
|
||
|
|
assert_eq!(te.new_text, "[X]");
|
||
|
|
assert_eq!(te.range.start.line, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== heading_change_level =====
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn add_heading_level_promotes_h2_to_h3() {
|
||
|
|
let src = "== Sub ==\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit =
|
||
|
|
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
|
||
|
|
let te = one_text_edit(edit);
|
||
|
|
assert_eq!(te.new_text, "=== Sub ===");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn add_heading_level_caps_at_6() {
|
||
|
|
let src = "====== Deep ======\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
||
|
|
// No-op because level 6 is the cap; clamp matched current level.
|
||
|
|
assert!(edit.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn remove_heading_level_demotes() {
|
||
|
|
let src = "=== Mid ===\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
|
||
|
|
.expect("remove_level edit");
|
||
|
|
let te = one_text_edit(edit);
|
||
|
|
assert_eq!(te.new_text, "== Mid ==");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn remove_heading_level_at_h1_is_noop() {
|
||
|
|
let src = "= Top =\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
|
||
|
|
assert!(edit.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn heading_change_returns_none_when_cursor_not_on_heading() {
|
||
|
|
let src = "paragraph\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
||
|
|
assert!(edit.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
|
||
|
|
// The lexer's heading span starts at the first `=`, not the line's
|
||
|
|
// start, so the leading whitespace that marks a "centered" heading
|
||
|
|
// lives outside the edit range — preserved automatically. The
|
||
|
|
// rewriter's output is just the `=...=` portion.
|
||
|
|
let src = " = Title =\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let h = match &doc.children[0] {
|
||
|
|
nuwiki_core::ast::BlockNode::Heading(h) => h,
|
||
|
|
_ => panic!("expected heading"),
|
||
|
|
};
|
||
|
|
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
|
||
|
|
assert_eq!(new, "== Title ==");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== next_task =====
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn next_task_returns_first_unfinished() {
|
||
|
|
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
|
||
|
|
// Should be line 1 (the first [ ]).
|
||
|
|
assert_eq!(loc.range.start.line, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn next_task_wraps_around_when_no_task_after_cursor() {
|
||
|
|
// Only task is on line 0; cursor on line 2 → wraps back.
|
||
|
|
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
|
||
|
|
assert_eq!(loc.range.start.line, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn next_task_returns_none_when_no_open_tasks() {
|
||
|
|
let src = "- [X] a\n- [X] b\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
|
||
|
|
assert!(loc.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn next_task_only_counts_items_with_checkboxes() {
|
||
|
|
// Plain items without a `[…]` are ignored — they're not tasks.
|
||
|
|
let src = "- plain\n- [ ] real task\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
|
||
|
|
assert_eq!(loc.range.start.line, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== smoke: COMMANDS list matches registered handlers =====
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn commands_list_is_complete() {
|
||
|
|
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||
|
|
for name in [
|
||
|
|
"nuwiki.file.delete",
|
||
|
|
"nuwiki.list.toggleCheckbox",
|
||
|
|
"nuwiki.list.cycleCheckbox",
|
||
|
|
"nuwiki.list.rejectCheckbox",
|
||
|
|
"nuwiki.list.nextTask",
|
||
|
|
"nuwiki.heading.addLevel",
|
||
|
|
"nuwiki.heading.removeLevel",
|
||
|
|
] {
|
||
|
|
assert!(names.contains(&name), "missing: {name}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== sanity: workspace edit shape =====
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
||
|
|
let src = "- [ ] task\n";
|
||
|
|
let doc = parse(src);
|
||
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state).unwrap();
|
||
|
|
assert!(doc_changes_for(edit.clone()).is_none());
|
||
|
|
assert!(edit.changes.is_some());
|
||
|
|
}
|