//! 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 { 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()); }