fix(lists): propagate checkbox state to ancestor list items on toggle
Toggling/cycling/rejecting a nested checkbox now recomputes each ancestor's progress marker (vimwiki's listsyms_propagate). Mirrors vimwiki's averaging — rejected items count as 100% unless every child is rejected, in which case the parent itself becomes rejected. Walks up while each ancestor has its own checkbox. Also tightens find_checkbox_span to scan only the marker line, since a parent item's span covers its nested sublist and was matching child markers as if they belonged to the parent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,8 +231,12 @@ fn list_checkbox(
|
|||||||
};
|
};
|
||||||
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
||||||
let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
|
let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
|
||||||
|
let propagate = backend
|
||||||
|
.wiki_for_uri(&p.uri)
|
||||||
|
.map(|w| w.config.listsyms_propagate)
|
||||||
|
.unwrap_or(true);
|
||||||
Ok(ops::checkbox_edit(
|
Ok(ops::checkbox_edit(
|
||||||
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate,
|
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, propagate,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,6 +1220,7 @@ pub mod ops {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn checkbox_edit(
|
pub fn checkbox_edit(
|
||||||
text: &str,
|
text: &str,
|
||||||
ast: &DocumentNode,
|
ast: &DocumentNode,
|
||||||
@@ -1224,9 +1229,11 @@ pub mod ops {
|
|||||||
col: u32,
|
col: u32,
|
||||||
utf8: bool,
|
utf8: bool,
|
||||||
mutate: fn(&str) -> Option<&'static str>,
|
mutate: fn(&str) -> Option<&'static str>,
|
||||||
|
propagate: bool,
|
||||||
) -> Option<WorkspaceEdit> {
|
) -> Option<WorkspaceEdit> {
|
||||||
let item = find_list_item_at(ast, line, col)?;
|
let path = find_path_to_item(ast, line, col)?;
|
||||||
let cb_span = find_checkbox_span(text, item.span)?;
|
let leaf = *path.last()?;
|
||||||
|
let cb_span = find_checkbox_span(text, leaf.span)?;
|
||||||
let cur = &text[cb_span.start.offset..cb_span.end.offset];
|
let cur = &text[cb_span.start.offset..cb_span.end.offset];
|
||||||
let new = mutate(cur)?;
|
let new = mutate(cur)?;
|
||||||
let mut b = WorkspaceEditBuilder::new();
|
let mut b = WorkspaceEditBuilder::new();
|
||||||
@@ -1234,9 +1241,132 @@ pub mod ops {
|
|||||||
uri.clone(),
|
uri.clone(),
|
||||||
text_edit_replace(cb_span, new.to_string(), text, utf8),
|
text_edit_replace(cb_span, new.to_string(), text, utf8),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if propagate && path.len() > 1 {
|
||||||
|
// Vimwiki's listsyms_propagate: walk up the chain, recomputing
|
||||||
|
// each ancestor's marker from the average rate of its immediate
|
||||||
|
// children (using the modified child's *new* state). Stops at
|
||||||
|
// the first ancestor that has no checkbox.
|
||||||
|
let mut child_node: &ListItemNode = leaf;
|
||||||
|
let mut child_marker: &str = new;
|
||||||
|
for parent in path.iter().rev().skip(1) {
|
||||||
|
let parent_cb_span = match find_checkbox_span(text, parent.span) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
let parent_cur = &text[parent_cb_span.start.offset..parent_cb_span.end.offset];
|
||||||
|
let new_parent = match compute_parent_marker(parent, child_node, child_marker) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
if new_parent == parent_cur {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
b.edit(
|
||||||
|
uri.clone(),
|
||||||
|
text_edit_replace(parent_cb_span, new_parent.to_string(), text, utf8),
|
||||||
|
);
|
||||||
|
child_node = parent;
|
||||||
|
child_marker = new_parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some(b.build())
|
Some(b.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a marker string like `"[X]"` to its progress rate. `-1`
|
||||||
|
/// signals rejected (handled specially when averaging). Returns
|
||||||
|
/// `None` for unrecognised markers.
|
||||||
|
fn marker_to_rate(marker: &str) -> Option<i32> {
|
||||||
|
match marker {
|
||||||
|
"[ ]" => Some(0),
|
||||||
|
"[.]" => Some(25),
|
||||||
|
"[o]" => Some(50),
|
||||||
|
"[O]" => Some(75),
|
||||||
|
"[X]" => Some(100),
|
||||||
|
"[-]" => Some(-1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkbox_state_to_rate(state: CheckboxState) -> i32 {
|
||||||
|
match state {
|
||||||
|
CheckboxState::Empty => 0,
|
||||||
|
CheckboxState::Quarter => 25,
|
||||||
|
CheckboxState::Half => 50,
|
||||||
|
CheckboxState::ThreeQuarters => 75,
|
||||||
|
CheckboxState::Done => 100,
|
||||||
|
CheckboxState::Rejected => -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of `marker_to_rate`. `rate` is 0..=100 or `-1` (rejected).
|
||||||
|
/// Mirrors vimwiki's `s:rate_to_state` for the standard 5-symbol
|
||||||
|
/// `' .oOX'` palette.
|
||||||
|
fn rate_to_marker(rate: i32) -> &'static str {
|
||||||
|
if rate == -1 {
|
||||||
|
return "[-]";
|
||||||
|
}
|
||||||
|
if rate <= 0 {
|
||||||
|
return "[ ]";
|
||||||
|
}
|
||||||
|
if rate >= 100 {
|
||||||
|
return "[X]";
|
||||||
|
}
|
||||||
|
// n=5 symbols, so n-2=3. ceil(rate/100 * 3) ∈ {1,2,3}
|
||||||
|
let idx = ((rate as f64) / 100.0 * 3.0).ceil() as i32;
|
||||||
|
match idx {
|
||||||
|
1 => "[.]",
|
||||||
|
2 => "[o]",
|
||||||
|
_ => "[O]",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the new marker for `parent` given that `modified_child`
|
||||||
|
/// (one of its immediate sublist items) just became
|
||||||
|
/// `modified_marker`. Returns `None` if the parent has no children
|
||||||
|
/// with checkboxes — there's nothing to average from.
|
||||||
|
///
|
||||||
|
/// Mirrors vimwiki's `s:update_state` averaging: rejected children
|
||||||
|
/// count as 100% for the average, but if *every* child is rejected
|
||||||
|
/// the parent itself becomes rejected.
|
||||||
|
fn compute_parent_marker(
|
||||||
|
parent: &ListItemNode,
|
||||||
|
modified_child: &ListItemNode,
|
||||||
|
modified_marker: &str,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
let sub = parent.sublist.as_ref()?;
|
||||||
|
let mut sum: i32 = 0;
|
||||||
|
let mut count_with_cb: i32 = 0;
|
||||||
|
let mut count_rejected: i32 = 0;
|
||||||
|
for item in &sub.items {
|
||||||
|
let rate = if std::ptr::eq(item, modified_child) {
|
||||||
|
marker_to_rate(modified_marker)?
|
||||||
|
} else {
|
||||||
|
match item.checkbox {
|
||||||
|
Some(state) => checkbox_state_to_rate(state),
|
||||||
|
None => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
count_with_cb += 1;
|
||||||
|
if rate == -1 {
|
||||||
|
count_rejected += 1;
|
||||||
|
sum += 100;
|
||||||
|
} else {
|
||||||
|
sum += rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count_with_cb == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let new_rate = if count_rejected == count_with_cb {
|
||||||
|
-1
|
||||||
|
} else {
|
||||||
|
sum / count_with_cb
|
||||||
|
};
|
||||||
|
Some(rate_to_marker(new_rate))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn next_task(
|
pub fn next_task(
|
||||||
ast: &DocumentNode,
|
ast: &DocumentNode,
|
||||||
uri: &Url,
|
uri: &Url,
|
||||||
@@ -1374,6 +1504,9 @@ pub mod ops {
|
|||||||
|
|
||||||
/// Find the `[X]` (or other checkbox) substring within a list item's
|
/// Find the `[X]` (or other checkbox) substring within a list item's
|
||||||
/// source span. Returns a span covering exactly the 3-byte run.
|
/// source span. Returns a span covering exactly the 3-byte run.
|
||||||
|
/// Searches only the marker line — a parent item's span covers its
|
||||||
|
/// nested sublist too, and we don't want a child's checkbox to look
|
||||||
|
/// like the parent's.
|
||||||
pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
|
pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
|
||||||
let start = item_span.start.offset;
|
let start = item_span.start.offset;
|
||||||
let end = item_span.end.offset.min(text.len());
|
let end = item_span.end.offset.min(text.len());
|
||||||
@@ -1382,6 +1515,8 @@ pub mod ops {
|
|||||||
}
|
}
|
||||||
let segment = &text[start..end];
|
let segment = &text[start..end];
|
||||||
let bytes = segment.as_bytes();
|
let bytes = segment.as_bytes();
|
||||||
|
let line_end = bytes.iter().position(|&b| b == b'\n').unwrap_or(bytes.len());
|
||||||
|
let bytes = &bytes[..line_end];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i + 2 < bytes.len() {
|
while i + 2 < bytes.len() {
|
||||||
if bytes[i] == b'['
|
if bytes[i] == b'['
|
||||||
@@ -1444,6 +1579,50 @@ pub mod ops {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the chain of list items from outermost ancestor to the
|
||||||
|
/// deepest item containing `line`. Used by checkbox propagation so
|
||||||
|
/// it can update each ancestor in turn.
|
||||||
|
pub fn find_path_to_item(
|
||||||
|
ast: &DocumentNode,
|
||||||
|
line: u32,
|
||||||
|
col: u32,
|
||||||
|
) -> Option<Vec<&ListItemNode>> {
|
||||||
|
for block in &ast.children {
|
||||||
|
if let Some(path) = path_in_block(block, line, col) {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_in_block(block: &BlockNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
|
||||||
|
match block {
|
||||||
|
BlockNode::List(l) => l.items.iter().find_map(|it| path_in_item(it, line, col)),
|
||||||
|
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||||
|
children.iter().find_map(|c| path_in_block(c, line, col))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_in_item(item: &ListItemNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
|
||||||
|
if let Some(sub) = &item.sublist {
|
||||||
|
for sub_it in &sub.items {
|
||||||
|
if let Some(mut deeper) = path_in_item(sub_it, line, col) {
|
||||||
|
let mut path = Vec::with_capacity(deeper.len() + 1);
|
||||||
|
path.push(item);
|
||||||
|
path.append(&mut deeper);
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.span.start.line..=item.span.end.line).contains(&line) {
|
||||||
|
return Some(vec![item]);
|
||||||
|
}
|
||||||
|
let _ = col;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Phase 15: TOC + Links generation, workspace queries =====
|
// ===== Phase 15: TOC + Links generation, workspace queries =====
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ fn reject_state_toggles_dash_marker() {
|
|||||||
fn toggle_empty_checkbox_to_done() {
|
fn toggle_empty_checkbox_to_done() {
|
||||||
let src = "- [ ] task\n";
|
let src = "- [ ] task\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state)
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true)
|
||||||
.expect("toggle edit");
|
.expect("toggle edit");
|
||||||
let te = one_text_edit(edit);
|
let te = one_text_edit(edit);
|
||||||
assert_eq!(te.new_text, "[X]");
|
assert_eq!(te.new_text, "[X]");
|
||||||
@@ -72,7 +72,7 @@ fn toggle_empty_checkbox_to_done() {
|
|||||||
fn cycle_advances_partial_states() {
|
fn cycle_advances_partial_states() {
|
||||||
let src = "- [.] half\n";
|
let src = "- [.] half\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state)
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state, true)
|
||||||
.expect("cycle edit");
|
.expect("cycle edit");
|
||||||
let te = one_text_edit(edit);
|
let te = one_text_edit(edit);
|
||||||
assert_eq!(te.new_text, "[o]");
|
assert_eq!(te.new_text, "[o]");
|
||||||
@@ -82,7 +82,7 @@ fn cycle_advances_partial_states() {
|
|||||||
fn reject_replaces_empty_with_dash() {
|
fn reject_replaces_empty_with_dash() {
|
||||||
let src = "* [ ] thing\n";
|
let src = "* [ ] thing\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state)
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state, true)
|
||||||
.expect("reject edit");
|
.expect("reject edit");
|
||||||
let te = one_text_edit(edit);
|
let te = one_text_edit(edit);
|
||||||
assert_eq!(te.new_text, "[-]");
|
assert_eq!(te.new_text, "[-]");
|
||||||
@@ -93,7 +93,7 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
|
|||||||
// No `[…]` at all → no edit.
|
// No `[…]` at all → no edit.
|
||||||
let src = "- plain item\n";
|
let src = "- plain item\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state);
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
|
||||||
assert!(edit.is_none());
|
assert!(edit.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
|
|||||||
fn checkbox_edit_returns_none_off_list() {
|
fn checkbox_edit_returns_none_off_list() {
|
||||||
let src = "just a paragraph\n";
|
let src = "just a paragraph\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state);
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
|
||||||
assert!(edit.is_none());
|
assert!(edit.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ fn checkbox_edit_finds_item_in_sublist() {
|
|||||||
let src = "- top\n - [ ] nested\n";
|
let src = "- top\n - [ ] nested\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
// Cursor on line 1, the nested item.
|
// Cursor on line 1, the nested item.
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state)
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state, true)
|
||||||
.expect("edit on nested item");
|
.expect("edit on nested item");
|
||||||
let te = one_text_edit(edit);
|
let te = one_text_edit(edit);
|
||||||
assert_eq!(te.new_text, "[X]");
|
assert_eq!(te.new_text, "[X]");
|
||||||
@@ -123,7 +123,189 @@ fn checkbox_edit_finds_item_in_sublist() {
|
|||||||
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
||||||
let src = "- [ ] task\n";
|
let src = "- [ ] task\n";
|
||||||
let doc = parse(src);
|
let doc = parse(src);
|
||||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state).unwrap();
|
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!(doc_changes_for(edit.clone()).is_none());
|
||||||
assert!(edit.changes.is_some());
|
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())]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user