//! Checkbox state transitions and the `checkbox_edit` rewriter that //! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`, //! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`. use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; 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 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("[-]")); 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, true) .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, true) .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, true) .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, true); 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, true); 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, true) .expect("edit on nested item"); let te = one_text_edit(edit); assert_eq!(te.new_text, "[X]"); assert_eq!(te.range.start.line, 1); } // ===== 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, true).unwrap(); assert!(doc_changes_for(edit.clone()).is_none()); assert!(edit.changes.is_some()); } // ===== parent propagation (vimwiki listsyms_propagate) ===== /// Collect the (line, new_text) pairs for every TextEdit in the result, /// sorted by line. Lets propagation tests assert the parent edit /// without caring about edit ordering. fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> { let changes = edit.changes.expect("expected changes map"); let (_, edits) = changes.into_iter().next().expect("at least one uri"); let mut pairs: Vec<(u32, String)> = edits .into_iter() .map(|e| (e.range.start.line, e.new_text)) .collect(); pairs.sort_by_key(|(line, _)| *line); pairs } #[test] fn propagate_one_of_four_done_marks_parent_quarter() { // Toggle the first child of a 4-child parent. Parent should land at // [.] (Quarter, 25%). let src = "\ - [ ] root - [ ] sub1 - [ ] sub2 - [ ] sub3 - [ ] sub4 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]); } #[test] fn propagate_two_of_four_done_marks_parent_half() { // sub1 is already done; toggling sub2 → done leaves rate=50% → [o]. let src = "\ - [ ] root - [X] sub1 - [ ] sub2 - [ ] sub3 - [ ] sub4 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]); } #[test] fn propagate_three_of_four_done_marks_parent_three_quarters() { let src = "\ - [ ] root - [X] sub1 - [X] sub2 - [ ] sub3 - [ ] sub4 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 3, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]); } #[test] fn propagate_all_done_marks_parent_done() { let src = "\ - [ ] root - [X] sub1 - [X] sub2 - [X] sub3 - [ ] sub4 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 4, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]); } #[test] fn propagate_untoggle_drops_parent_back() { // Untoggling the only completed child should put parent back to [ ]. let src = "\ - [.] root - [X] sub1 - [ ] sub2 - [ ] sub3 - [ ] sub4 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]); } #[test] fn propagate_disabled_only_emits_leaf_edit() { let src = "\ - [ ] root - [ ] sub1 - [ ] sub2 - [ ] sub3 - [ ] sub4 "; let doc = parse(src); let edit = ops::checkbox_edit( src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, false, ) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(1, "[X]".into())]); } #[test] fn propagate_skips_parent_without_checkbox() { // Parent has no `[…]`, so propagation has nothing to update. let src = "\ - root - [ ] sub1 - [ ] sub2 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(1, "[X]".into())]); } #[test] fn propagate_walks_multiple_levels() { // Toggle the deepest leaf: grandparent has 1 child (parent), parent // has 2 children. Sub1 done → parent half → grandparent half. let src = "\ - [ ] grand - [ ] parent - [ ] sub1 - [ ] sub2 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 8, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!( pairs, vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())] ); } #[test] fn propagate_all_rejected_marks_parent_rejected() { // Every counted child is rejected → parent becomes [-]. let src = "\ - [ ] root - [-] sub1 - [ ] sub2 "; let doc = parse(src); // Reject sub2 so both children are rejected. let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::reject_state, true) .expect("reject edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]); } #[test] fn propagate_rejected_sibling_counts_as_done_for_average() { // sub1 rejected, sub2 toggled to done → both "complete" → parent [X]. let src = "\ - [ ] root - [-] sub1 - [ ] sub2 "; let doc = parse(src); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::toggle_state, true) .expect("toggle edit"); let pairs = edits_by_line(edit); assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]); }