nigig-org/crates/apps/doc/doc-engine/tests/materialize.rs
arena-agent 1269811b44 test(doc-engine): cover the branches the first coverage report named
A first instrumented run (99.00% -> this branch set is what got it
there) showed the gaps precisely; these tests close the reachable ones:

- insert_text_at_offset / delete_text_at_offset: mid-block splices,
  prepend at 0, append at end, backward/forward deletion and every
  out-of-range guard (both fns were 0% covered).
- set_block_alignment materialization, including the CRDT-tolerance arm
  for a block whose op has not arrived.
- set_table_cells: multi-cell batch undoes and redoes as ONE group;
  empty write lists are rejected.
- CrdtDocument::to_json/from_json wire round trip (the format every
  workspace save rides on) was never exercised end to end.
- toggle_text_style_at_offsets rejects unknown fields and missing
  blocks; a style patch on an EMPTY block is retained rather than
  dropped.

Writing them inverted two expectations and the tests pin the actual --
and correct -- CRDT semantics instead: inserts/cell writes addressed to
anchors that have not arrived yet are ACCEPTED into the op log (they
must be, to merge when the anchor lands) while conjuring nothing into
the rendered document. 96 integration tests pass; clippy stays at -D
warnings clean.
2026-08-17 04:33:08 +00:00

1394 lines
73 KiB
Rust

use doc_engine::controller::DocumentController;
use doc_engine::crdt::{CrdtDocument, OpId, Operation};
#[test]
fn text_materializes_into_its_block() {
let mut doc=CrdtDocument::default(); let block=OpId{actor:"a".into(),counter:1}; let text=OpId{actor:"a".into(),counter:2};
doc.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertText{id:text,block:block.clone(),after:None,text:"Hello".into()});
let projection=doc.materialize(); assert_eq!(projection.blocks[0].text,"Hello");
}
#[test]
fn text_tombstone_is_hidden_in_projection() {
let mut doc=CrdtDocument::default();
let block=OpId{actor:"a".into(),counter:1}; let first=OpId{actor:"a".into(),counter:2}; let second=OpId{actor:"a".into(),counter:3};
doc.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertText{id:first.clone(),block:block.clone(),after:None,text:"A".into()});
doc.apply(Operation::InsertText{id:second.clone(),block:block.clone(),after:Some(first.clone()),text:"B".into()});
doc.apply(Operation::DeleteText{id:OpId{actor:"b".into(),counter:1},block:block.clone(),targets:vec![first]});
assert_eq!(doc.materialize().blocks[0].text,"B");
}
#[test]
fn concurrent_sibling_inserts_have_deterministic_order() {
let mut doc=CrdtDocument::default();
let block=OpId{actor:"root".into(),counter:1}; let anchor=OpId{actor:"root".into(),counter:2};
doc.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertText{id:anchor.clone(),block:block.clone(),after:None,text:"A".into()});
doc.apply(Operation::InsertText{id:OpId{actor:"z".into(),counter:1},block:block.clone(),after:Some(anchor.clone()),text:"Z".into()});
doc.apply(Operation::InsertText{id:OpId{actor:"b".into(),counter:1},block:block.clone(),after:Some(anchor),text:"B".into()});
assert_eq!(doc.materialize().blocks[0].text,"ABZ");
}
#[test]
fn block_operations_materialize_in_anchor_order() {
let mut doc=CrdtDocument::default();
let root=OpId{actor:"a".into(),counter:1}; let second=OpId{actor:"a".into(),counter:2}; let concurrent=OpId{actor:"b".into(),counter:1};
doc.apply(Operation::InsertBlock{id:root.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertBlock{id:second.clone(),after:Some(root.clone()),kind:"quote".into()});
doc.apply(Operation::InsertBlock{id:concurrent,after:Some(root),kind:"code".into()});
let blocks=doc.materialize().blocks;
assert_eq!(blocks.len(),3);
assert_eq!(blocks[0].kind,"paragraph");
assert_eq!(blocks[1].kind,"quote");
assert_eq!(blocks[2].kind,"code");
}
#[test]
fn table_operations_materialize_rows_columns_and_cells() {
let mut doc=CrdtDocument::default(); let table=OpId{actor:"a".into(),counter:1}; let row=OpId{actor:"a".into(),counter:2}; let col=OpId{actor:"a".into(),counter:3};
doc.apply(Operation::InsertTableRow{id:row.clone(),table:table.clone(),after:None});
doc.apply(Operation::InsertTableColumn{id:col.clone(),table:table.clone(),after:None});
doc.apply(Operation::SetTableCell{id:OpId{actor:"a".into(),counter:4},table:table.clone(),row:row.clone(),column:col.clone(),text:"cell".into()});
let projection=doc.materialize(); let table=projection.tables.get("a:1").unwrap();
assert_eq!(table.rows.len(),1); assert_eq!(table.columns.len(),1); assert_eq!(table.cells.get(&(String::from("a:2"),String::from("a:3"))),Some(&String::from("cell")));
}
#[test]
fn deleted_table_row_is_not_projected() {
let mut doc=CrdtDocument::default(); let table=OpId{actor:"a".into(),counter:1}; let row=OpId{actor:"a".into(),counter:2};
doc.apply(Operation::InsertTableRow{id:row.clone(),table:table.clone(),after:None});
doc.apply(Operation::DeleteTableRow{id:OpId{actor:"b".into(),counter:1},table, target:row});
assert!(doc.materialize().tables.get("a:1").map(|t| t.rows.is_empty()).unwrap_or(true));
}
#[test]
fn concurrent_cell_updates_use_deterministic_last_writer() {
let mut doc=CrdtDocument::default(); let table=OpId{actor:"a".into(),counter:1}; let row=OpId{actor:"a".into(),counter:2}; let col=OpId{actor:"a".into(),counter:3};
doc.apply(Operation::SetTableCell{id:OpId{actor:"b".into(),counter:1},table:table.clone(),row:row.clone(),column:col.clone(),text:"B".into()});
doc.apply(Operation::SetTableCell{id:OpId{actor:"z".into(),counter:1},table:table.clone(),row:row.clone(),column:col.clone(),text:"Z".into()});
let projected=doc.materialize();
assert_eq!(projected.tables["a:1"].cells[&(String::from("a:2"),String::from("a:3"))],"Z");
}
#[test]
fn advanced_nodes_materialize_and_tombstone() {
let mut doc=CrdtDocument::default(); let id=OpId{actor:"a".into(),counter:1};
doc.apply(Operation::InsertNode{id:id.clone(),parent:None,after:None,kind:"canvas".into(),state_json:"{}".into()});
assert_eq!(doc.materialize().nodes.len(),1);
doc.apply(Operation::DeleteNode{id:OpId{actor:"b".into(),counter:1},target:id});
assert!(doc.materialize().nodes.is_empty());
}
#[test]
fn controller_refreshes_projection_after_operation() {
use doc_engine::controller::DocumentController;
let mut controller=DocumentController::default(); let block=OpId{actor:"a".into(),counter:1};
assert!(controller.apply(Operation::InsertBlock{id:block,after:None,kind:"paragraph".into()}));
assert_eq!(controller.projection.blocks.len(),1);
}
#[test]
fn document_allocates_monotonic_actor_operation_ids() {
let mut doc=CrdtDocument::default();
let a=doc.next_id("alice"); let b=doc.next_id("alice");
assert_eq!(a.counter,1); assert_eq!(b.counter,2);
}
#[test]
fn controller_factory_methods_build_materialized_paragraph() {
use doc_engine::controller::DocumentController;
let mut controller=DocumentController::default();
let block=controller.insert_block("a",None,"paragraph").unwrap();
controller.insert_text("a",block.clone(),None,"Hello");
assert_eq!(controller.projection.blocks[0].text,"Hello");
}
#[test]
fn controller_table_factories_materialize_cells() {
use doc_engine::controller::DocumentController;
let mut controller=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1};
let row=controller.insert_table_row("a",table.clone(),None).unwrap();
let col=controller.insert_table_column("a",table.clone(),None).unwrap();
assert!(controller.set_table_cell("a",table.clone(),row.clone(),col.clone(),"value"));
assert_eq!(controller.projection.tables["t:1"].cells[&(format!("{}:{}",row.actor,row.counter),format!("{}:{}",col.actor,col.counter))],"value");
}
#[test]
fn controller_node_factories_materialize_and_delete() {
use doc_engine::controller::DocumentController;
let mut controller=DocumentController::default(); let id=controller.insert_node("a",None,None,"canvas","{}").unwrap();
assert_eq!(controller.projection.nodes.len(),1);
assert!(controller.delete_node("a",id));
assert!(controller.projection.nodes.is_empty());
}
#[test]
fn version_vector_sync_returns_missing_operations() {
use doc_engine::crdt::VersionVector;
let mut doc=CrdtDocument::default(); let id=doc.next_id("a"); doc.apply(Operation::InsertBlock{id:id.clone(),after:None,kind:"paragraph".into()});
let peer=VersionVector::default(); assert_eq!(doc.operations_since(&peer).len(),1);
let mut peer=VersionVector::default(); peer.observe("a".into(),1); assert!(doc.operations_since(&peer).is_empty());
}
#[test]
fn controllers_exchange_missing_operations() {
use doc_engine::controller::DocumentController;
let mut a=DocumentController::default(); let mut b=DocumentController::default();
a.insert_block("a",None,"paragraph");
let ops=a.export_operations_since(&b.document.versions);
assert_eq!(b.import_operations(ops),1);
assert_eq!(b.projection.blocks.len(),1);
}
#[test]
fn peers_converge_after_concurrent_text_edits() {
use doc_engine::controller::DocumentController;
let mut a=DocumentController::default(); let mut b=DocumentController::default();
let block=a.insert_block("root",None,"paragraph").unwrap();
b.import_operations(a.export_operations_since(&b.document.versions));
a.insert_text("a",block.clone(),None,"A");
b.insert_text("b",block.clone(),None,"B");
let for_b=a.export_operations_since(&b.document.versions);
let for_a=b.export_operations_since(&a.document.versions);
b.import_operations(for_b); a.import_operations(for_a);
assert_eq!(a.projection.blocks[0].text,b.projection.blocks[0].text);
}
#[test]
fn peers_converge_after_concurrent_table_row_inserts() {
use doc_engine::controller::DocumentController;
let mut a=DocumentController::default(); let mut b=DocumentController::default();
let table=OpId{actor:"table".into(),counter:1};
a.insert_table_row("a",table.clone(),None);
b.insert_table_row("b",table.clone(),None);
let to_b=a.export_operations_since(&b.document.versions);
let to_a=b.export_operations_since(&a.document.versions);
b.import_operations(to_b); a.import_operations(to_a);
assert_eq!(a.projection.tables["table:1"].rows,b.projection.tables["table:1"].rows);
assert_eq!(a.projection.tables["table:1"].rows.len(),2);
}
#[test]
fn replaying_operation_log_rebuilds_projection() {
let mut source=CrdtDocument::default();
let block=source.next_id("a"); source.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
let text=source.next_id("a"); source.apply(Operation::InsertText{id:text,block:block.clone(),after:None,text:"Replay".into()});
let log=source.operations.values().cloned().collect::<Vec<_>>();
let mut replay=CrdtDocument::default(); for op in log { replay.apply(op); }
assert_eq!(replay.materialize().blocks[0].text,"Replay");
}
#[test]
fn advanced_node_parent_relationship_is_projected() {
let mut doc=CrdtDocument::default();
let parent=OpId{actor:"a".into(),counter:1}; let child=OpId{actor:"a".into(),counter:2};
doc.apply(Operation::InsertNode{id:parent.clone(),parent:None,after:None,kind:"container".into(),state_json:"{}".into()});
doc.apply(Operation::InsertNode{id:child.clone(),parent:Some(parent.clone()),after:None,kind:"canvas".into(),state_json:"{}".into()});
let projection=doc.materialize();
let child=projection.nodes.iter().find(|node| node.id=="a:2").unwrap();
assert_eq!(child.parent.as_deref(),Some("a:1"));
}
#[test]
fn block_tombstone_converges_across_peers() {
use doc_engine::controller::DocumentController;
let mut a=DocumentController::default(); let mut b=DocumentController::default();
let block=a.insert_block("a",None,"paragraph").unwrap();
b.import_operations(a.export_operations_since(&b.document.versions));
let delete=a.document.next_id("a"); a.apply(Operation::DeleteBlock{id:delete,target:block});
b.import_operations(a.export_operations_since(&b.document.versions));
assert!(a.projection.blocks.is_empty()); assert!(b.projection.blocks.is_empty());
}
#[test]
fn controller_replaces_text_range() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let block=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",block.clone(),None,"abc");
c.replace_text_range("a",block,1,2,"X");
assert_eq!(c.projection.blocks[0].text,"aXc");
}
#[test]
fn cross_block_replace_materializes_merged_block() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let a=c.insert_block("a",None,"paragraph").unwrap(); let b=c.insert_block("a",Some(a.clone()),"paragraph").unwrap();
c.insert_text("a",a.clone(),None,"Hello"); c.insert_text("a",b.clone(),None,"World");
assert!(c.replace_block_range("a",a,2,b,3,"X"));
assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"HeXld");
}
#[test]
fn text_style_operation_projects_run_style() {
let mut doc=CrdtDocument::default(); let block=OpId{actor:"a".into(),counter:1};
doc.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertText{id:OpId{actor:"a".into(),counter:2},block:block.clone(),after:None,text:"x".into()});
doc.apply(Operation::SetTextStyle{id:OpId{actor:"a".into(),counter:3},block,start:None,end:None,patch:doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()}});
assert!(doc.materialize().blocks[0].runs[0].bold);
}
#[test]
fn atom_operations_materialize_character_order() {
use doc_engine::crdt::TextAtomOperation;
let mut doc=CrdtDocument::default(); let block=OpId{actor:"a".into(),counter:1}; let h=OpId{actor:"a".into(),counter:2}; let i=OpId{actor:"a".into(),counter:3};
doc.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertTextAtoms{id:OpId{actor:"a".into(),counter:4},block:block.clone(),atoms:vec![TextAtomOperation{id:h.clone(),after:None,ch:'h'},TextAtomOperation{id:i,after:Some(h),ch:'i'}]});
assert_eq!(doc.materialize().blocks[0].text,"hi");
}
#[test]
fn controller_text_factory_uses_character_atoms() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let block=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",block,None,"xy");
assert_eq!(c.projection.blocks[0].text_atoms.len(),2);
}
#[test]
fn style_range_splits_projected_runs() {
use doc_engine::projection::{apply_style_range, ProjectedTextRun, TextStylePatch};
let atoms=(1..=3).map(|counter| OpId{actor:"a".into(),counter}).collect::<Vec<_>>();
let mut runs=vec![ProjectedTextRun{text:"abc".into(),atoms:atoms.clone(),..Default::default()}];
apply_style_range(&mut runs,Some(&atoms[1]),None,&TextStylePatch{bold:Some(true),..Default::default()});
assert_eq!(runs.len(),2); assert_eq!(runs[0].text,"a"); assert_eq!(runs[1].text,"bc"); assert!(runs[1].bold);
}
#[test]
fn controller_styles_atom_range() {
use doc_engine::{controller::DocumentController, projection::TextStylePatch};
let mut c=DocumentController::default(); let block=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",block.clone(),None,"abc");
assert!(c.set_text_style_at_offsets("a",block,1,3,TextStylePatch{italic:Some(true),..Default::default()}));
assert_eq!(c.projection.blocks[0].runs.len(),2); assert!(c.projection.blocks[0].runs[1].italic);
}
#[test]
fn controller_toggles_style_off_when_all_selected_runs_enabled() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let block=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",block.clone(),None,"ab");
c.toggle_text_style_at_offsets("a",block.clone(),0,2,"bold");
assert!(c.projection.blocks[0].runs[0].bold);
c.toggle_text_style_at_offsets("a",block,0,2,"bold");
assert!(!c.projection.blocks[0].runs[0].bold);
}
#[test]
fn controller_toggles_style_across_blocks() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let a=c.insert_block("a",None,"paragraph").unwrap(); let b=c.insert_block("a",Some(a.clone()),"paragraph").unwrap();
c.insert_text("a",a,None,"one"); c.insert_text("a",b,None,"two");
assert!(c.toggle_style_across_blocks("a",0,1,1,2,"underline"));
assert!(c.projection.blocks[0].runs.iter().any(|run| run.underline));
assert!(c.projection.blocks[1].runs.iter().any(|run| run.underline));
}
#[test]
fn table_block_projects_table_data() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=c.insert_table("a",None).unwrap();
let row=c.insert_table_row("a",table.clone(),None).unwrap(); let col=c.insert_table_column("a",table.clone(),None).unwrap();
c.set_table_cell("a",table, row, col, "x");
assert_eq!(c.projection.blocks[0].kind,"table"); assert_eq!(c.projection.tables.len(),1);
}
#[test]
fn controller_deletes_table_row_and_column() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1}; let row=c.insert_table_row("a",table.clone(),None).unwrap(); let col=c.insert_table_column("a",table.clone(),None).unwrap();
c.delete_table_row("a",table.clone(),row); c.delete_table_column("a",table.clone(),col);
let projected=&c.projection.tables["t:1"]; assert!(projected.rows.is_empty()); assert!(projected.columns.is_empty());
}
#[test]
fn table_merge_materializes_and_split_hides_merge() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1};
let r1=c.insert_table_row("a",table.clone(),None).unwrap(); let r2=c.insert_table_row("a",table.clone(),Some(r1.clone())).unwrap(); let col=c.insert_table_column("a",table.clone(),None).unwrap();
let merge=c.merge_table_cells("a",table.clone(),r1.clone(),col.clone(),r2.clone(),col.clone()).unwrap();
assert_eq!(c.projection.tables["t:1"].merges.len(),1);
c.split_table_cells("a",table,merge);
assert!(c.projection.tables["t:1"].merges.is_empty());
}
#[test]
fn projection_exposes_unified_block_and_node_order() {
let mut doc=CrdtDocument::default();
doc.apply(Operation::InsertBlock{id:OpId{actor:"b".into(),counter:1},after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertNode{id:OpId{actor:"a".into(),counter:1},parent:None,after:None,kind:"canvas".into(),state_json:"{}".into()});
let order=doc.materialize().order;
assert_eq!(order,vec!["a:1".to_string(),"b:1".to_string()]);
}
#[test]
fn unified_order_respects_node_after_anchor() {
let mut doc=CrdtDocument::default(); let block=OpId{actor:"a".into(),counter:1}; let node=OpId{actor:"a".into(),counter:2};
doc.apply(Operation::InsertBlock{id:block.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertNode{id:node.clone(),parent:None,after:Some(block.clone()),kind:"canvas".into(),state_json:"{}".into()});
assert_eq!(doc.materialize().order,vec!["a:1".to_string(),"a:2".to_string()]);
}
#[test]
fn style_operation_projects_font_size_and_color() {
use doc_engine::{controller::DocumentController, projection::TextStylePatch};
let mut c=DocumentController::default(); let block=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",block.clone(),None,"x");
c.set_text_style_at_offsets("a",block,0,1,TextStylePatch{font_size:Some(18.0),color:Some(Some((1.0,0.0,0.0,1.0))),..Default::default()});
let run=&c.projection.blocks[0].runs[0]; assert_eq!(run.font_size,18.0); assert_eq!(run.color,Some((1.0,0.0,0.0,1.0)));
}
#[test]
fn crdt_history_undo_redo_text_atoms() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let block=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",block,None,"x"); assert_eq!(c.projection.blocks[0].text,"x");
assert!(c.undo("a")); assert_eq!(c.projection.blocks[0].text,"");
assert!(c.redo("a")); assert_eq!(c.projection.blocks[0].text,"x");
}
#[test]
fn crdt_history_undo_redo_block_insert() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); c.insert_block("a",None,"paragraph");
assert_eq!(c.projection.blocks.len(),1); assert!(c.undo("a")); assert!(c.projection.blocks.is_empty()); assert!(c.redo("a")); assert_eq!(c.projection.blocks.len(),1);
}
#[test]
fn crdt_history_undo_redo_table_row_insert() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1};
c.insert_table_row("a",table.clone(),None); assert_eq!(c.projection.tables["t:1"].rows.len(),1);
assert!(c.undo("a")); assert!(c.projection.tables["t:1"].rows.is_empty());
assert!(c.redo("a")); assert_eq!(c.projection.tables["t:1"].rows.len(),1);
}
#[test]
fn crdt_history_undo_redo_table_column_insert() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1};
c.insert_table_column("a",table.clone(),None); assert_eq!(c.projection.tables["t:1"].columns.len(),1);
assert!(c.undo("a")); assert!(c.projection.tables["t:1"].columns.is_empty());
assert!(c.redo("a")); assert_eq!(c.projection.tables["t:1"].columns.len(),1);
}
#[test]
fn crdt_history_undo_redo_node_insert() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); c.insert_node("a",None,None,"canvas","{}");
assert_eq!(c.projection.nodes.len(),1); assert!(c.undo("a")); assert!(c.projection.nodes.is_empty()); assert!(c.redo("a")); assert_eq!(c.projection.nodes.len(),1);
}
#[test]
fn crdt_history_undo_redo_table_cell_value() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1}; let row=c.insert_table_row("a",table.clone(),None).unwrap(); let col=c.insert_table_column("a",table.clone(),None).unwrap();
let key=(format!("{}:{}",row.actor,row.counter),format!("{}:{}",col.actor,col.counter));
c.set_table_cell("a",table.clone(),row.clone(),col.clone(),"x"); assert_eq!(c.projection.tables["t:1"].cells[&key],"x");
assert!(c.undo("a")); assert_eq!(c.projection.tables["t:1"].cells[&key],"");
assert!(c.redo("a")); assert_eq!(c.projection.tables["t:1"].cells[&key],"x");
}
#[test]
fn crdt_history_undo_redo_table_merge() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1}; let r1=c.insert_table_row("a",table.clone(),None).unwrap(); let r2=c.insert_table_row("a",table.clone(),Some(r1.clone())).unwrap(); let col=c.insert_table_column("a",table.clone(),None).unwrap();
c.merge_table_cells("a",table.clone(),r1.clone(),col.clone(),r2,col); assert_eq!(c.projection.tables["t:1"].merges.len(),1);
assert!(c.undo("a")); assert!(c.projection.tables["t:1"].merges.is_empty());
assert!(c.redo("a")); assert_eq!(c.projection.tables["t:1"].merges.len(),1);
}
#[test]
fn crdt_history_undo_redo_node_delete() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let node=c.insert_node("a",None,None,"canvas","{}").unwrap(); c.delete_node("a",node);
assert!(c.projection.nodes.is_empty()); assert!(c.undo("a")); assert_eq!(c.projection.nodes.len(),1); assert!(c.redo("a")); assert!(c.projection.nodes.is_empty());
}
#[test]
fn crdt_history_undo_table_row_delete() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let table=OpId{actor:"t".into(),counter:1}; let row=c.insert_table_row("a",table.clone(),None).unwrap(); c.delete_table_row("a",table.clone(),row);
assert!(c.projection.tables["t:1"].rows.is_empty()); assert!(c.undo("a")); assert_eq!(c.projection.tables["t:1"].rows.len(),1);
}
#[test]
fn block_anchored_after_node_materializes_in_chain() {
// Regression: the after-chain was block-only, so a paragraph inserted
// after an advanced node was unreachable and vanished from the projection.
let mut doc=CrdtDocument::default();
let first=OpId{actor:"a".into(),counter:1}; let node=OpId{actor:"a".into(),counter:2}; let second=OpId{actor:"a".into(),counter:3};
doc.apply(Operation::InsertBlock{id:first.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertNode{id:node.clone(),parent:None,after:Some(first.clone()),kind:"canvas".into(),state_json:"{}".into()});
doc.apply(Operation::InsertBlock{id:second.clone(),after:Some(node.clone()),kind:"paragraph".into()});
doc.apply(Operation::InsertText{id:OpId{actor:"a".into(),counter:4},block:second.clone(),after:None,text:"after".into()});
let projection=doc.materialize();
assert_eq!(projection.blocks.len(),2);
assert_eq!(projection.blocks[1].id,"a:3");
assert_eq!(projection.blocks[1].text,"after");
assert_eq!(projection.order,vec!["a:1".to_string(),"a:2".to_string(),"a:3".to_string()]);
}
#[test]
fn mixed_sibling_blocks_and_nodes_have_deterministic_order() {
let mut doc=CrdtDocument::default();
let root=OpId{actor:"a".into(),counter:1}; let node=OpId{actor:"a".into(),counter:2}; let sibling=OpId{actor:"a".into(),counter:3};
doc.apply(Operation::InsertBlock{id:root.clone(),after:None,kind:"paragraph".into()});
doc.apply(Operation::InsertNode{id:node.clone(),parent:None,after:Some(root.clone()),kind:"image".into(),state_json:"".into()});
doc.apply(Operation::InsertBlock{id:sibling.clone(),after:Some(root.clone()),kind:"quote".into()});
let projection=doc.materialize();
// Siblings of one anchor order by OpId ascending regardless of kind.
assert_eq!(projection.order,vec!["a:1".to_string(),"a:2".to_string(),"a:3".to_string()]);
assert_eq!(projection.blocks.len(),2);
assert_eq!(projection.blocks[1].kind,"quote");
}
#[test]
fn split_block_materializes_trailing_half_in_order() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",b.clone(),None,"hello world");
let trailing=c.split_block("a",b.clone(),5).unwrap();
assert_eq!(c.projection.blocks.len(),2);
assert_eq!(c.projection.blocks[0].text,"hello");
assert_eq!(c.projection.blocks[1].text," world");
assert_eq!(c.projection.blocks[1].id,format!("{}:{}",trailing.actor,trailing.counter));
assert_eq!(c.projection.order,vec![format!("{}:{}",b.actor,b.counter),format!("{}:{}",trailing.actor,trailing.counter)]);
}
#[test]
fn split_block_preserves_styles_across_the_cut() {
use doc_engine::{controller::DocumentController, projection::TextStylePatch};
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",b.clone(),None,"aabbcc");
c.set_text_style_at_offsets("a",b.clone(),2,4,TextStylePatch{bold:Some(true),..Default::default()});
c.split_block("a",b,3);
let blocks=&c.projection.blocks;
assert_eq!(blocks[0].runs.len(),2);
assert!(!blocks[0].runs[0].bold); assert_eq!(blocks[0].runs[0].text,"aa");
assert!(blocks[0].runs[1].bold); assert_eq!(blocks[0].runs[1].text,"b");
assert_eq!(blocks[1].runs.len(),2);
assert!(blocks[1].runs[0].bold); assert_eq!(blocks[1].runs[0].text,"b");
assert!(!blocks[1].runs[1].bold); assert_eq!(blocks[1].runs[1].text,"cc");
}
#[test]
fn merge_block_after_folds_successor_text() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let b1=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",b1.clone(),None,"hello"); let b2=c.insert_block("a",Some(b1.clone()),"paragraph").unwrap(); c.insert_text("a",b2,None,"world");
assert!(c.merge_block_after("a",b1));
assert_eq!(c.projection.blocks.len(),1);
assert_eq!(c.projection.blocks[0].text,"helloworld");
assert_eq!(c.projection.order.len(),1);
}
#[test]
fn split_undo_folds_halves_back_and_redo_resplits() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",b.clone(),None,"hello world");
c.split_block("a",b,5);
assert_eq!(c.projection.blocks.len(),2);
assert!(c.undo("a"));
assert_eq!(c.projection.blocks.len(),1);
assert_eq!(c.projection.blocks[0].text,"hello world");
assert!(c.redo("a"));
assert_eq!(c.projection.blocks.len(),2);
assert_eq!(c.projection.blocks[0].text,"hello");
assert_eq!(c.projection.blocks[1].text," world");
}
#[test]
fn merge_undo_resplits_at_the_recorded_offset() {
use doc_engine::controller::DocumentController;
let mut c=DocumentController::default(); let b1=c.insert_block("a",None,"paragraph").unwrap(); c.insert_text("a",b1.clone(),None,"hello"); let b2=c.insert_block("a",Some(b1.clone()),"paragraph").unwrap(); c.insert_text("a",b2,None,"world");
assert!(c.merge_block_after("a",b1));
assert!(c.undo("a"));
let texts:Vec<&str>=c.projection.blocks.iter().map(|block|block.text.as_str()).collect();
assert_eq!(texts,vec!["hello","world"]);
assert!(c.redo("a"));
assert_eq!(c.projection.blocks.len(),1);
assert_eq!(c.projection.blocks[0].text,"helloworld");
}
/// Rows anchor on `after`: inserting below an existing row lands right
/// after it (newest sibling first), regardless of insertion chronology.
#[test]
fn table_rows_follow_after_anchor_chain_with_rga_sibling_order() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r1=c.insert_table_row("a",t.clone(),None).unwrap();
let r2=c.insert_table_row("a",t.clone(),Some(r1.clone())).unwrap();
let mid=c.insert_table_row("a",t.clone(),Some(r1.clone())).unwrap();
let table=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)];
let fmt=|id:&OpId| format!("{}:{}",id.actor,id.counter);
assert_eq!(table.rows,vec![fmt(&r1),fmt(&mid),fmt(&r2)]);
}
#[test]
fn table_columns_follow_after_anchor_chain() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let c1=c.insert_table_column("a",t.clone(),None).unwrap();
let c2=c.insert_table_column("a",t.clone(),Some(c1.clone())).unwrap();
let mid=c.insert_table_column("a",t.clone(),Some(c1.clone())).unwrap();
let table=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)];
let fmt=|id:&OpId| format!("{}:{}",id.actor,id.counter);
assert_eq!(table.columns,vec![fmt(&c1),fmt(&mid),fmt(&c2)]);
}
// == Table cell style runs ==
/// Cells with no style ops still project one default-styled run over the
/// whole text, so renderers never branch on absent styles.
#[test]
fn table_cell_without_styles_projects_one_default_run() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"ab");
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.len(),1); assert_eq!(runs[0].text,"ab"); assert!(!runs[0].bold && !runs[0].italic && !runs[0].underline); assert_eq!(runs[0].font_size,12.0);
}
/// A style patch splits the cell's run at char offsets, keeping the
/// unstyled head and tail as separate runs.
#[test]
fn table_cell_style_splits_runs_at_char_offsets() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"abcd");
assert!(c.set_table_cell_style("a",cell.clone(),1,3,doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()}));
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.iter().map(|run| run.text.as_str()).collect::<Vec<_>>(),vec!["a","bc","d"]);
assert_eq!(runs.iter().map(|run| run.bold).collect::<Vec<_>>(),vec![false,true,false]);
}
/// Offsets clamp against the cell's current text, and a later whole-cell
/// replacement keeps the earlier (now position-bound) style ranges: the
/// authored span does not stretch when the text grows, but shrinking
/// text re-clamps it.
#[test]
fn table_cell_style_clamps_to_current_text_and_survives_replacement() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"ab");
assert!(c.set_table_cell_style("a",cell.clone(),1,99,doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()}));
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.iter().map(|run| (run.text.as_str(), run.bold)).collect::<Vec<_>>(),vec![("a",false),("b",true)]);
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"xyz");
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.iter().map(|run| (run.text.as_str(), run.bold)).collect::<Vec<_>>(),vec![("x",false),("y",true),("z",false)]);
}
/// Two equal adjacent patches merge into one run instead of leaving a
/// style boundary at the join.
#[test]
fn table_cell_adjacent_equal_styles_merge_into_one_run() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"abcd");
let patch=|| doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()};
assert!(c.set_table_cell_style("a",cell.clone(),0,2,patch()));
assert!(c.set_table_cell_style("a",cell.clone(),2,4,patch()));
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.len(),1); assert_eq!(runs[0].text,"abcd"); assert!(runs[0].bold);
}
/// Newer patches win where ranges overlap (bold on, then bold off over a
/// sub-range), while fields they do not name stay untouched.
#[test]
fn table_cell_newer_style_overlaps_win() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"abcd");
assert!(c.set_table_cell_style("a",cell.clone(),0,4,doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()}));
assert!(c.set_table_cell_style("a",cell.clone(),1,3,doc_engine::projection::TextStylePatch{bold:Some(false),..Default::default()}));
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.iter().map(|run| (run.text.as_str(), run.bold)).collect::<Vec<_>>(),vec![("a",true),("bc",false),("d",true)]);
}
/// Toggle enables bold unless every covered run already has it; a second
/// toggle over the fully bold cell disables it again.
#[test]
fn table_cell_toggle_style_follows_full_coverage() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"ab");
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
assert!(c.toggle_table_cell_style("a",cell.clone(),0,2,"bold"));
assert!(c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key].iter().all(|run| run.bold));
assert!(c.toggle_table_cell_style("a",cell.clone(),0,2,"bold"));
assert!(c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key].iter().all(|run| !run.bold));
assert!(!c.toggle_table_cell_style("a",cell.clone(),0,2,"superscript"));
}
/// The controller rejects styles on missing cells and spans that clamp
/// to empty.
#[test]
fn table_cell_style_rejects_missing_cells_and_empty_spans() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
let patch=|| doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()};
assert!(!c.set_table_cell_style("a",cell.clone(),0,1,patch()));
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"ab");
assert!(!c.set_table_cell_style("a",cell.clone(),2,2,patch()));
assert!(!c.set_table_cell_style("a",cell.clone(),5,9,patch()));
}
/// Undo tombstones only the latest style op; an earlier overlapping
/// style keeps its effect, and redo re-applies the tombstoned one.
#[test]
fn table_cell_style_undo_tombstones_only_the_latest_style_op() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"ab");
assert!(c.set_table_cell_style("a",cell.clone(),0,2,doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()}));
assert!(c.set_table_cell_style("a",cell.clone(),0,2,doc_engine::projection::TextStylePatch{underline:Some(true),..Default::default()}));
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
assert!(c.undo("a"));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.len(),1); assert!(runs[0].bold && !runs[0].underline);
assert!(c.redo("a"));
let runs=&c.projection.tables[&format!("{}:{}",t.actor,t.counter)].cell_runs[&key];
assert_eq!(runs.len(),1); assert!(runs[0].bold && runs[0].underline);
}
/// Two replicas applying the same ops in different arrival orders
/// materialize identical cell runs.
#[test]
fn table_cell_styles_converge_across_replica_orderings() {
let mut a=DocumentController::default(); let t=a.insert_table("a",None).unwrap();
let r=a.insert_table_row("a",t.clone(),None).unwrap(); let col=a.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
a.set_table_cell("a",t.clone(),r.clone(),col.clone(),"abcd");
a.set_table_cell_style("a",cell.clone(),0,4,doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()});
a.set_table_cell("a",t.clone(),r.clone(),col.clone(),"XYuv");
let ops=a.export_operations_since(&Default::default());
let mut forward=DocumentController::default(); forward.import_operations(ops.clone());
let mut reversed=DocumentController::default(); let mut rlist=ops; rlist.reverse(); reversed.import_operations(rlist);
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
let tid=format!("{}:{}",t.actor,t.counter);
let runs=|c:&DocumentController| c.projection.tables[&tid].cell_runs[&key].iter().map(|run| (run.text.clone(),run.bold)).collect::<Vec<_>>();
assert_eq!(runs(&forward),runs(&reversed));
assert_eq!(runs(&forward),vec![("XYuv".to_string(),true)]);
}
// == Text deletion undo invariants ==
/// Deleting atoms pushes the symmetric restore: undo brings the atoms
/// back at their anchors and redo re-deletes them — the same guarantee
/// inserts have always had in the other direction.
#[test]
fn delete_text_undo_restores_atoms_and_redo_redeletes() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"abc");
let targets: Vec<OpId> = c.projection.blocks[0].text_atoms[..2].iter().map(|value| { let mut p=value.split(':'); OpId{actor:p.next().unwrap().into(), counter:p.next().unwrap().parse().unwrap()} }).collect();
assert!(c.delete_text("a",b,targets)); assert_eq!(c.projection.blocks[0].text,"c");
assert!(c.undo("a")); assert_eq!(c.projection.blocks[0].text,"abc", "undo restores the deleted atoms");
assert!(c.redo("a")); assert_eq!(c.projection.blocks[0].text,"c", "redo re-deletes them");
}
/// `replace_text_range` with an EMPTY replacement returns no trailing
/// atom (None means "nothing was inserted", not failure) and is still
/// undoable exactly once through the deletion's compensation.
#[test]
fn replace_text_range_with_empty_is_a_clean_one_step_delete() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"abc");
assert!(c.replace_text_range("a",b.clone(),0,2,"").is_none(), "empty replacement yields no trailing atom");
assert_eq!(c.projection.blocks[0].text,"c");
assert!(c.undo("a")); assert_eq!(c.projection.blocks[0].text,"abc");
assert!(c.redo("a")); assert_eq!(c.projection.blocks[0].text,"c");
}
/// Blocks follow the same chronological tombstone rule as text: a
/// delete/undo/redo cycle hides the block again (the pre-chronology
/// set-union bug kept it visible forever afterwards).
#[test]
fn delete_block_undo_redo_cycle_hides_the_block_again() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"x");
let target=c.projection.blocks[0].id.clone(); let mut p=target.split(':'); let tid=OpId{actor:p.next().unwrap().into(),counter:p.next().unwrap().parse().unwrap()};
let op=doc_engine::history::Compensation::DeleteBlock{target:tid.clone()};
let document=&mut c.document; let delete=op.materialize("a",document); let _ = delete;
c.history.push_undo(op);
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),0,"delete via undo hides the block");
assert!(c.redo("a")); assert_eq!(c.projection.blocks.len(),1,"redo restores it");
assert!(!c.redo("a"), "nothing left on the redo stack");
// Full cycle again through a second undo: block hides once more.
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),0);
}
/// Rows obey it too: undo of a row delete restores the row and redo
/// deletes it again.
#[test]
fn delete_table_row_undo_redo_reapplies_the_deletion() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap();
assert!(c.delete_table_row("a",t,r)); assert_eq!(c.projection.tables.values().next().unwrap().rows.len(),0);
assert!(c.undo("a")); assert_eq!(c.projection.tables.values().next().unwrap().rows.len(),1);
assert!(c.redo("a")); assert_eq!(c.projection.tables.values().next().unwrap().rows.len(),0, "redo re-deletes the row");
}
/// A merge split tombstone re-applies across an undo/redo cycle instead
/// of sticking to "restored" after the first restore op exists.
#[test]
fn merge_split_undo_redo_cycle_rehides_the_merge() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
c.merge_table_cells("a",t.clone(),r.clone(),col.clone(),r,col); assert_eq!(c.projection.tables.values().next().unwrap().merges.len(),1);
assert!(c.undo("a")); assert!(c.projection.tables.values().next().unwrap().merges.is_empty(), "undo splits");
assert!(c.redo("a")); assert_eq!(c.projection.tables.values().next().unwrap().merges.len(),1, "redo remerges");
assert!(c.undo("a")); assert!(c.projection.tables.values().next().unwrap().merges.is_empty(), "second undo still splits");
}
/// The deepest cell-style cycle: apply, undo, redo, undo again must
/// re-tombstone the style op instead of sticking to "restored".
#[test]
fn cell_style_clear_restore_cycles_reapply_the_tombstone() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let r=c.insert_table_row("a",t.clone(),None).unwrap(); let col=c.insert_table_column("a",t.clone(),None).unwrap();
let cell=doc_engine::controller::TableCellRef{table:t.clone(),row:r.clone(),column:col.clone()};
c.set_table_cell("a",t.clone(),r.clone(),col.clone(),"ab");
let key=(format!("{}:{}",r.actor,r.counter),format!("{}:{}",col.actor,col.counter));
assert!(c.set_table_cell_style("a",cell,0,2,doc_engine::projection::TextStylePatch{bold:Some(true),..Default::default()}));
let tid=format!("{}:{}",t.actor,t.counter);
let bold=|c:&DocumentController| c.projection.tables[&tid].cell_runs[&key].iter().any(|run| run.bold);
assert!(bold(&c));
assert!(c.undo("a")); assert!(!bold(&c), "undo clears");
assert!(c.redo("a")); assert!(bold(&c), "redo restores");
assert!(c.undo("a")); assert!(!bold(&c), "second undo clears again (chronological tombstones)");
}
/// A cross-block replace range is undoable as ONE step: the compensation
/// tombstones the range op (CancelBlockRange), so every drained block and
/// the spliced text re-materialize from the untouched op log. The double
/// undo cycle re-cancels chronologically instead of sticking live.
#[test]
fn cross_block_replace_range_undoes_as_one_lossless_step() {
let mut c=DocumentController::default();
let a=c.insert_block("a",None,"paragraph").unwrap(); let b=c.insert_block("a",Some(a.clone()),"paragraph").unwrap(); let d=c.insert_block("a",Some(b.clone()),"paragraph").unwrap();
c.insert_text("a",a.clone(),None,"ab"); c.insert_text("a",b.clone(),None,"cd"); c.insert_text("a",d.clone(),None,"ef");
assert!(c.replace_block_range("a",a,1,d,1,""));
assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"af","prefix + suffix merged into the start block");
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),3,"undo restores every drained block");
assert_eq!(c.projection.blocks[0].text,"ab"); assert_eq!(c.projection.blocks[1].text,"cd"); assert_eq!(c.projection.blocks[2].text,"ef");
assert!(c.redo("a")); assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"af","redo re-cuts");
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),3,"second undo cancels again (chronological tombstone)");
assert_eq!(c.projection.blocks[2].text,"ef");
}
/// Cancelling a range never loses text: atoms typed around the range
/// live in the op log untouched by it, so they surface again the moment
/// the range stops overwriting the block text.
#[test]
fn cancelled_block_range_keeps_text_typed_underneath_it() {
let mut c=DocumentController::default();
let a=c.insert_block("a",None,"paragraph").unwrap(); let b=c.insert_block("a",Some(a.clone()),"paragraph").unwrap();
c.insert_text("a",a.clone(),None,"ab"); c.insert_text("a",b.clone(),None,"cd");
c.insert_text("a",a.clone(),Some(OpId{actor:"a".into(),counter:4}),"X");
assert_eq!(c.projection.blocks[0].text,"abX");
assert!(c.replace_block_range("a",a.clone(),1,b.clone(),1,""));
assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"ad","the range truncates the visible prefix to its offset");
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),2);
assert_eq!(c.projection.blocks[0].text,"abX","every atom beneath the cancelled range survives");
assert_eq!(c.projection.blocks[1].text,"cd");
assert!(c.redo("a")); assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"ad");
}
/// Spans that do not resolve against the projection in order (unknown
/// or reversed endpoints) record nothing and push no undo compensation —
/// previously the op landed but materialized to a silent no-op.
#[test]
fn replace_block_range_refuses_unresolved_spans() {
let mut c=DocumentController::default();
let a=c.insert_block("a",None,"paragraph").unwrap(); let b=c.insert_block("a",Some(a.clone()),"paragraph").unwrap();
c.insert_text("a",a.clone(),None,"ab"); c.insert_text("a",b.clone(),None,"cd");
let undo_depth=c.history.undo.len();
assert!(!c.replace_block_range("a",b.clone(),0,a.clone(),1,""),"reversed span refuses");
assert!(!c.replace_block_range("a",a.clone(),0,OpId{actor:"ghost".into(),counter:9},1,""),"unknown end refuses");
assert_eq!(c.history.undo.len(),undo_depth,"no compensation pushed for a no-op range");
assert_eq!(c.projection.blocks.len(),2); assert_eq!(c.projection.blocks[0].text,"ab");
}
/// Parsed id of the atom at `index` in projected block `block` — the
/// anchor form offset-based text ops consume.
fn atom(c:&DocumentController,block:usize,index:usize)->OpId {
let s=c.projection.blocks[block].text_atoms[index].clone(); let mut p=s.split(':'); OpId{actor:p.next().unwrap().into(),counter:p.next().unwrap().parse().unwrap()}
}
/// Pasting multi-line text splits into one block per line, carries the
/// caret block's suffix onto the last line, and un-pastes in a SINGLE
/// undo step through the grouped compensation — a 3-line paste without
/// grouping would take 5 undos to retract.
#[test]
fn multiline_paste_splits_into_blocks_and_undoes_in_one_step() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"heading").unwrap();
c.insert_text("a",b.clone(),None,"abXY");
let anchor=atom(&c,0,1); // caret after 'b'
let insert=c.insert_multiline_text("a",b.clone(),Some(anchor),"l0\nl1\nl2").unwrap();
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["abl0","l1","l2XY"],"line 0 splices at the caret; the suffix carries to the last line");
assert_eq!(c.projection.blocks[1].kind,"heading","middle blocks inherit the caret block kind");
assert_eq!(format!("{}:{}",insert.caret_block.actor,insert.caret_block.counter),c.projection.blocks[2].id);
assert_eq!(insert.caret_atom.map(|a|format!("{}:{}",a.actor,a.counter)),Some(c.projection.blocks[2].text_atoms[1].clone()),"caret parks after the last pasted atom");
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"abXY","ONE undo un-pastes the whole edit");
assert!(c.redo("a")); let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect(); assert_eq!(texts,vec!["abl0","l1","l2XY"],"redo re-pastes whole");
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"abXY","second cycle still un-pastes (chronological)");
}
/// A trailing newline leaves an empty tail block and the caret at its
/// head — the same place Return-then-nothing leaves it.
#[test]
fn multiline_paste_ending_in_a_newline_parks_on_the_empty_tail() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"ab");
let insert=c.insert_multiline_text("a",b.clone(),Some(atom(&c,0,1)),"x\n").unwrap();
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["abx",""]);
assert_eq!(insert.caret_atom,None,"an empty last line has no trailing atom");
assert_eq!(format!("{}:{}",insert.caret_block.actor,insert.caret_block.counter),c.projection.blocks[1].id);
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"ab");
}
/// Empty middle lines become empty blocks, with no stranded
/// compensation entries from their empty text inserts.
#[test]
fn multiline_paste_keeps_empty_lines_as_empty_blocks() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"AB");
c.insert_multiline_text("a",b,Some(atom(&c,0,0)),"a\n\nb");
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["Aa","","bB"]);
assert!(c.undo("a")); assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"AB","empties ride along in the one group");
}
/// The replayed op log converges: a peer importing every op of the
/// paste materializes the identical block sequence.
#[test]
fn multiline_paste_converges_when_replayed_to_a_peer() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"abXY"); c.insert_multiline_text("a",b,Some(atom(&c,0,1)),"l0\nl1\nl2");
let ops=c.export_operations_since(&doc_engine::crdt::VersionVector::default());
let mut peer=DocumentController::default(); peer.import_operations(ops);
let mine:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
let theirs:Vec<&str>=peer.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(mine,theirs); assert_eq!(mine,vec!["abl0","l1","l2XY"]);
}
/// Cells paste as whole-cell writes (SetTableCell), never block splits:
/// the engine refuses a multi-line insert addressed at a table block
/// and records nothing.
#[test]
fn multiline_paste_refuses_table_blocks() {
let mut c=DocumentController::default(); let t=c.insert_table("a",None).unwrap();
let depth=c.history.undo.len();
assert!(c.insert_multiline_text("a",t,None,"a\nb").is_none());
assert_eq!(c.history.undo.len(),depth,"no ops and no undo entries created");
}
/// CRLF payloads lose their carriage returns per line.
#[test]
fn multiline_paste_strips_cr_from_crlf_payloads() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"ab");
c.insert_multiline_text("a",b,Some(atom(&c,0,1)),"x\r\ny");
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["abx","y"]);
}
/// A payload without any line break is exactly the typing path
/// (single undo entry, no group).
#[test]
fn multiline_api_without_a_break_is_the_typing_path() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"ac");
let insert=c.insert_multiline_text("a",b,Some(atom(&c,0,0)),"b").unwrap();
assert_eq!(c.projection.blocks.len(),1); assert_eq!(c.projection.blocks[0].text,"abc");
assert_eq!(insert.caret_atom.map(|a|format!("{}:{}",a.actor,a.counter)),Some(c.projection.blocks[0].text_atoms[1].clone()));
}
/// The caret a selection delete leaves behind is TOMBSTONED; the paste
/// still lands exactly where a single-line paste would land.
#[test]
fn multiline_paste_anchors_after_a_tombstoned_caret() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"abcdef");
let anchor=atom(&c,0,0); // 'a', about to be deleted
c.replace_text_range("a",b.clone(),0,3,"");
assert_eq!(c.projection.blocks[0].text,"def");
c.insert_multiline_text("a",b,Some(anchor),"x\ny");
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["x","ydef"],"anchors after tombstones splice where the selection began");
assert!(c.undo("a")); let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect(); assert_eq!(texts,vec!["def"],"one undo retracts only the paste");
assert!(c.undo("a")); let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect(); assert_eq!(texts,vec!["abcdef"],"the next undo restores the selection delete");
}
/// Regression: atoms typed into a synthetic split child (the block a
/// Return keypress opens) used to land in the op log but evaporate from
/// the projection. The child now seeds its text from child-addressed
/// ops spliced into the transplanted suffix RGA-wise.
#[test]
fn typing_into_a_split_child_materializes() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"hello");
let t=c.split_block("a",b,5).unwrap();
c.insert_text("a",t,None,"X");
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["hello","X"]);
// Sibling order: typing at the head of a child lands BEFORE the suffix.
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"hi there");
let t=c.split_block("a",b,2).unwrap();
c.insert_text("a",t,None,"X");
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["hi","X there"],"a head-of-child insert precedes the transplanted suffix");
}
/// Suffix atoms keep the style they inherited from the parent; text
/// typed into the child takes the block default, grouped by style.
#[test]
fn split_child_weaves_suffix_styles_with_default_styled_new_text() {
use doc_engine::projection::TextStylePatch;
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"aabb");
c.set_text_style_at_offsets("a",b.clone(),2,4,TextStylePatch{bold:Some(true),..Default::default()});
let t=c.split_block("a",b,2).unwrap();
c.insert_text("a",t,None,"x");
let child=&c.projection.blocks[1];
assert_eq!(child.text,"xbb");
assert_eq!(child.runs.len(),2);
assert!(!child.runs[0].bold,"typed atoms are default styled"); assert_eq!(child.runs[0].text,"x");
assert!(child.runs[1].bold,"suffix keeps its inherited style"); assert_eq!(child.runs[1].text,"bb");
}
/// Style ops addressed to a split child replay over its woven runs in
/// log order, exactly like real blocks.
#[test]
fn style_ops_addressed_to_a_split_child_apply() {
use doc_engine::projection::TextStylePatch;
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"abcd");
let t=c.split_block("a",b,2).unwrap();
c.insert_text("a",t.clone(),None,"X");
assert!(c.set_text_style_at_offsets("a",t,1,3,TextStylePatch{italic:Some(true),..Default::default()}));
let child=&c.projection.blocks[1]; assert_eq!(child.text,"Xcd");
assert!(child.runs.iter().any(|run| run.italic && run.text=="cd"),"style anchored to child offsets applies");
}
/// Ordering regression: consecutive splits of one paragraph keep the
/// newest split directly behind the parent (no real descendants), while
/// blocks authored under the parent first stay ahead of the child.
#[test]
fn repeated_splits_still_nest_behind_their_parent() {
let mut c=DocumentController::default(); let b=c.insert_block("a",None,"paragraph").unwrap();
c.insert_text("a",b.clone(),None,"hello world");
c.split_block("a",b.clone(),5);
c.split_block("a",b,2);
let texts:Vec<&str>=c.projection.blocks.iter().map(|b|b.text.as_str()).collect();
assert_eq!(texts,vec!["he","llo"," world"]);
let ids:Vec<&str>=c.projection.blocks.iter().map(|b|b.id.as_str()).collect();
let order:Vec<&str>=c.projection.order.iter().map(|s|s.as_str()).filter(|id| ids.contains(id)).collect();
assert_eq!(ids,order,"projection.order tracks the blocks splice rule");
}
// == Multi-cell writes and cell-text undo/redo symmetry ==
/// Regression: an undo after a redo re-applied the redone cell text —
/// redo pushed the write's own text as its undo compensation (the
/// compensation `inverse()` swaps the verb but keeps the text), while
/// undo's leaf special-case captured live state only in one
/// direction. Both transition boundaries now capture the projected
/// text, so the cycle round-trips.
#[test]
fn cell_text_undo_redo_cycle_round_trips() {
use doc_engine::controller::DocumentController;
let mut c = DocumentController::default();
let table = c.insert_table("a", None).unwrap();
let row = c.insert_table_row("a", table.clone(), None).unwrap();
let col = c.insert_table_column("a", table.clone(), None).unwrap();
let key = (
format!("{}:{}", row.actor, row.counter),
format!("{}:{}", col.actor, col.counter),
);
let table_key = format!("{}:{}", table.actor, table.counter);
c.set_table_cell("a", table.clone(), row.clone(), col.clone(), "x");
c.set_table_cell("a", table.clone(), row.clone(), col.clone(), "y");
assert!(c.undo("a"));
assert_eq!(c.projection.tables[&table_key].cells[&key], "x");
assert!(c.redo("a"));
assert_eq!(c.projection.tables[&table_key].cells[&key], "y");
assert!(c.undo("a"));
assert_eq!(
c.projection.tables[&table_key].cells[&key], "x",
"undo after redo restores the pre-redo text"
);
assert!(c.redo("a"));
assert_eq!(
c.projection.tables[&table_key].cells[&key], "y",
"redo after that undo re-applies the write"
);
assert!(c.undo("a"));
assert_eq!(
c.projection.tables[&table_key].cells[&key], "x",
"the cycle keeps round-tripping"
);
}
/// `set_table_cells` lands N cell writes on the undo stack as ONE
/// step: clearing two cells un-clears in a single undo, and the
/// group members keep round-tripping through redo like leaf edits.
#[test]
fn set_table_cells_is_one_undo_step_and_round_trips() {
use doc_engine::controller::DocumentController;
let mut c = DocumentController::default();
let table = c.insert_table("a", None).unwrap();
let row = c.insert_table_row("a", table.clone(), None).unwrap();
let col0 = c.insert_table_column("a", table.clone(), None).unwrap();
let col1 = c
.insert_table_column("a", table.clone(), Some(col0.clone()))
.unwrap();
let id = |op: &doc_engine::crdt::OpId| format!("{}:{}", op.actor, op.counter);
let table_key = id(&table);
let key0 = (id(&row), id(&col0));
let key1 = (id(&row), id(&col1));
c.set_table_cell("a", table.clone(), row.clone(), col0.clone(), "a");
c.set_table_cell("a", table.clone(), row.clone(), col1.clone(), "bc");
let depth = c.history.undo.len();
assert!(c.set_table_cells(
"a",
table.clone(),
vec![
(row.clone(), col0.clone(), String::new()),
(row.clone(), col1.clone(), String::new())
]
));
assert_eq!(c.projection.tables[&table_key].cells[&key0], "");
assert_eq!(c.projection.tables[&table_key].cells[&key1], "");
assert_eq!(
c.history.undo.len(),
depth + 1,
"both clears land as one undo entry"
);
assert!(c.undo("a"));
assert_eq!(c.projection.tables[&table_key].cells[&key0], "a");
assert_eq!(c.projection.tables[&table_key].cells[&key1], "bc");
assert!(c.redo("a"));
assert_eq!(c.projection.tables[&table_key].cells[&key0], "");
assert_eq!(c.projection.tables[&table_key].cells[&key1], "");
assert!(c.undo("a"));
assert_eq!(
c.projection.tables[&table_key].cells[&key0], "a",
"grouped members round-trip through redo"
);
assert_eq!(c.projection.tables[&table_key].cells[&key1], "bc");
}
/// A one-write call keeps the leaf compensation (behaving exactly
/// like `set_table_cell`), and an empty list records nothing.
#[test]
fn set_table_cells_single_and_empty_keep_leaf_semantics() {
use doc_engine::controller::DocumentController;
let mut c = DocumentController::default();
let table = c.insert_table("a", None).unwrap();
let row = c.insert_table_row("a", table.clone(), None).unwrap();
let col = c.insert_table_column("a", table.clone(), None).unwrap();
let key = (
format!("{}:{}", row.actor, row.counter),
format!("{}:{}", col.actor, col.counter),
);
let table_key = format!("{}:{}", table.actor, table.counter);
assert!(
!c.set_table_cells("a", table.clone(), Vec::new()),
"an empty write list applies nothing"
);
let depth = c.history.undo.len();
assert!(c.set_table_cells(
"a",
table.clone(),
vec![(row.clone(), col.clone(), "v".to_string())]
));
assert!(
matches!(
c.history.undo.last(),
Some(doc_engine::history::Compensation::RestoreTableCell { .. })
),
"a single write stays a leaf"
);
assert_eq!(c.history.undo.len(), depth + 1);
assert!(c.undo("a"));
assert_eq!(c.projection.tables[&table_key].cells[&key], "");
assert!(c.redo("a"));
assert_eq!(c.projection.tables[&table_key].cells[&key], "v");
}
/// A grouped multi-cell write replays identically: a peer importing
/// the op log materializes the same cell values, so a tabular paste
/// converges across replicas like any other cell edit.
#[test]
fn multi_cell_write_converges_when_replayed_to_a_peer() {
use doc_engine::controller::DocumentController;
let mut c = DocumentController::default();
let table = c.insert_table("a", None).unwrap();
let row = c.insert_table_row("a", table.clone(), None).unwrap();
let col0 = c.insert_table_column("a", table.clone(), None).unwrap();
let col1 = c
.insert_table_column("a", table.clone(), Some(col0.clone()))
.unwrap();
c.set_table_cell("a", table.clone(), row.clone(), col0.clone(), "a");
c.set_table_cell("a", table.clone(), row.clone(), col1.clone(), "bc");
let table_key = format!("{}:{}", table.actor, table.counter);
assert!(c.set_table_cells(
"a",
table.clone(),
vec![
(row.clone(), col0.clone(), "1".to_string()),
(row.clone(), col1.clone(), "2".to_string())
]
));
let ops = c.export_operations_since(&doc_engine::crdt::VersionVector::default());
let mut peer = DocumentController::default();
peer.import_operations(ops);
assert_eq!(
c.projection.tables[&table_key].cells,
peer.projection.tables[&table_key].cells
);
let key0 = (
format!("{}:{}", row.actor, row.counter),
format!("{}:{}", col0.actor, col0.counter),
);
let key1 = (
format!("{}:{}", row.actor, row.counter),
format!("{}:{}", col1.actor, col1.counter),
);
assert_eq!(peer.projection.tables[&table_key].cells[&key0], "1");
assert_eq!(peer.projection.tables[&table_key].cells[&key1], "2");
}
/// Cell values carrying tabs, newlines, and quotes materialize verbatim on
/// peers and restore verbatim through undo/redo — the clipboard round-trip
/// relies on the data layer never mangling these characters.
#[test]
fn table_cell_special_character_text_survives_sync_and_undo() {
use doc_engine::controller::DocumentController;
let mut a = DocumentController::default();
let mut b = DocumentController::default();
let table = OpId {
actor: "t".into(),
counter: 1,
};
let row = a.insert_table_row("a", table.clone(), None).unwrap();
let col = a.insert_table_column("a", table.clone(), None).unwrap();
let text = "tab\tnewline\nquote\"end";
a.set_table_cell("a", table.clone(), row.clone(), col.clone(), text);
b.import_operations(a.export_operations_since(&b.document.versions));
let key = (
format!("{}:{}", row.actor, row.counter),
format!("{}:{}", col.actor, col.counter),
);
assert_eq!(
b.projection.tables["t:1"].cells[&key], text,
"peers carry tab/newline/quote cell text verbatim"
);
assert!(a.undo("a"));
assert_eq!(a.projection.tables["t:1"].cells[&key], "");
assert!(a.redo("a"));
assert_eq!(
a.projection.tables["t:1"].cells[&key], text,
"undo/redo restores the text verbatim"
);
}
/// `insert_text_at_offset` resolves its after-anchor from the projected
/// atom list: mid-block splices, prepend at offset 0, append at the end,
/// and a clean None on an unknown block.
#[test]
fn insert_text_at_offset_maps_mid_block_prepend_and_append() {
let mut c = DocumentController::default();
let b = c.insert_block("a", None, "paragraph").unwrap();
c.insert_text("a", b.clone(), None, "hello");
c.insert_text_at_offset("a", b.clone(), 2, "XX");
assert_eq!(c.projection.blocks[0].text, "heXXllo");
c.insert_text_at_offset("a", b.clone(), 0, ">");
assert_eq!(c.projection.blocks[0].text, ">heXXllo");
let len = ">heXXllo".chars().count();
c.insert_text_at_offset("a", b.clone(), len, "<");
assert_eq!(c.projection.blocks[0].text, ">heXXllo<");
// An insert addressed to an unknown block is ACCEPTED (CRDT store
// tolerance: the block op may still be in flight from a peer) but
// materializes nothing visible.
assert!(
c.insert_text_at_offset("a", OpId { actor: "z".into(), counter: 99 }, 1, "q")
.is_some(),
"unknown-block inserts stay in the op log for convergence"
);
assert_eq!(c.projection.blocks.len(), 1, "nothing projects from a block that never arrives");
}
/// `delete_text_at_offset` addresses one atom in each direction and
/// rejects every out-of-range/guard case instead of guessing.
#[test]
fn delete_text_at_offset_deletes_backward_and_forward_with_guards() {
let mut c = DocumentController::default();
let b = c.insert_block("a", None, "paragraph").unwrap();
c.insert_text("a", b.clone(), None, "abc");
assert!(c.delete_text_at_offset("a", b.clone(), 2, true));
assert_eq!(c.projection.blocks[0].text, "ac", "backward at 2 removes 'b'");
assert!(c.delete_text_at_offset("a", b.clone(), 0, false));
assert_eq!(c.projection.blocks[0].text, "c", "forward at 0 removes 'a'");
assert!(!c.delete_text_at_offset("a", b.clone(), 0, true), "nothing before offset 0");
assert!(
!c.delete_text_at_offset("a", b.clone(), 1, false),
"forward past the last atom finds no target"
);
assert!(
!c.delete_text_at_offset("a", OpId { actor: "z".into(), counter: 9 }, 1, true),
"unknown block"
);
}
/// SetBlockAlignment reaches the projection (the materialization arm a
/// plain text run never touches).
#[test]
fn block_alignment_is_materialized() {
let mut c = DocumentController::default();
let b = c.insert_block("a", None, "paragraph").unwrap();
c.insert_text("a", b.clone(), None, "align me");
assert!(c.set_block_alignment("a", b, "center"));
assert_eq!(c.projection.blocks[0].alignment, "center");
// Alignment on an unknown block is accepted into the log (CRDT
// tolerance) but materializes against nothing.
assert!(c.set_block_alignment("a", OpId { actor: "z".into(), counter: 9 }, "right"));
assert_eq!(c.projection.blocks.len(), 1);
}
/// Styling an empty block stores a span over the empty range instead of
/// discarding the op or panicking (the run-assembly arm a populated
/// block never reaches).
#[test]
fn style_patch_on_empty_block_is_retained() {
let mut c = DocumentController::default();
let b = c.insert_block("a", None, "paragraph").unwrap();
assert!(c.set_text_style_at_offsets(
"a",
b.clone(),
0,
0,
doc_engine::projection::TextStylePatch { bold: Some(true), ..Default::default() }
));
assert_eq!(c.projection.blocks[0].text, "");
// Text typed afterwards materializes normally on top of the styled range.
c.insert_text("a", b, None, "x");
assert_eq!(c.projection.blocks[0].text, "x");
}
/// A multi-cell batch is one undoable group: every write comes back
/// together on undo and reapplies together on redo.
#[test]
fn batched_cell_writes_undo_as_one_group() {
let mut c = DocumentController::default();
let t = c.insert_table("a", None).unwrap();
let r = c.insert_table_row("a", t.clone(), None).unwrap();
let c0 = c.insert_table_column("a", t.clone(), None).unwrap();
let c1 = c.insert_table_column("a", t.clone(), Some(c0.clone())).unwrap();
c.set_table_cell("a", t.clone(), r.clone(), c0.clone(), "old0");
assert!(c.set_table_cells(
"a",
t.clone(),
vec![
(r.clone(), c0.clone(), "new0".to_string()),
(r.clone(), c1.clone(), "new1".to_string())
]
));
let tid = format!("{}:{}", t.actor, t.counter);
let k0 = (format!("{}:{}", r.actor, r.counter), format!("{}:{}", c0.actor, c0.counter));
let k1 = (format!("{}:{}", r.actor, r.counter), format!("{}:{}", c1.actor, c1.counter));
assert_eq!(c.projection.tables[&tid].cells[&k0], "new0");
assert_eq!(c.projection.tables[&tid].cells[&k1], "new1");
assert!(c.undo("a"));
assert_eq!(c.projection.tables[&tid].cells[&k0], "old0");
assert_eq!(
c.projection.tables[&tid].cells.get(&k1).map(String::as_str),
Some(""),
"the single group undo restores both cells"
);
assert!(c.redo("a"));
assert_eq!(c.projection.tables[&tid].cells[&k0], "new0");
assert_eq!(c.projection.tables[&tid].cells[&k1], "new1");
}
/// The batch writer refuses an empty write list outright; writes to
/// cells that do not exist yet are accepted into the op log (CRDT store
/// tolerance: the row/column ops may still be in flight from a peer) but
/// project nothing, and the batch reports its real write count.
#[test]
fn batched_cell_writes_reject_empty_and_tolerate_stray_writes() {
let mut c = DocumentController::default();
let t = c.insert_table("a", None).unwrap();
assert!(!c.set_table_cells("a", t.clone(), vec![]));
let ghost = OpId { actor: "z".into(), counter: 9 };
assert!(
c.set_table_cells("a", t.clone(), vec![(ghost.clone(), ghost.clone(), "x".to_string())]),
"stray cell writes are logged, not lost"
);
let tid = format!("{}:{}", t.actor, t.counter);
let ghost_key = (
format!("{}:{}", ghost.actor, ghost.counter),
format!("{}:{}", ghost.actor, ghost.counter),
);
let table = &c.projection.tables[&tid];
assert!(
table.rows.is_empty() && table.columns.is_empty(),
"stray writes never conjure rows or columns"
);
assert_eq!(
table.cells[&ghost_key],
"x",
"the text itself is retained under the stray key, so it still merges if the cell ever arrives"
);
}
/// The document wire round trip (`to_json`/`from_json`) is the save
/// format every workspace rides on; pin that a full document survives it
/// byte-identical in the projection.
#[test]
fn document_wire_round_trip_preserves_projection() {
let mut c = DocumentController::default();
let b = c.insert_block("a", None, "paragraph").unwrap();
c.insert_text("a", b.clone(), None, "wire");
let t = c.insert_table("a", Some(b)).unwrap();
let r = c.insert_table_row("a", t.clone(), None).unwrap();
let col = c.insert_table_column("a", t.clone(), None).unwrap();
c.set_table_cell("a", t.clone(), r.clone(), col.clone(), "cell(x)");
let json = c.document.to_json().expect("serialize");
let doc = CrdtDocument::from_json(&json).expect("deserialize");
let projection = doc.materialize();
assert_eq!(projection.blocks[0].text, "wire");
let tid = format!("{}:{}", t.actor, t.counter);
let key = (format!("{}:{}", r.actor, r.counter), format!("{}:{}", col.actor, col.counter));
assert_eq!(projection.tables[&tid].cells[&key], "cell(x)");
assert_eq!(projection.blocks.len(), c.projection.blocks.len());
}
/// Text-style toggles reject unknown fields (rather than writing a
/// no-field patch) and a block that is not projected at all.
#[test]
fn toggle_text_style_rejects_unknown_field_and_missing_block() {
let mut c = DocumentController::default();
let b = c.insert_block("a", None, "paragraph").unwrap();
c.insert_text("a", b.clone(), None, "ab");
assert!(!c.toggle_text_style_at_offsets("a", b, 0, 2, "superscript"));
assert!(!c.toggle_text_style_at_offsets(
"a",
OpId { actor: "z".into(), counter: 9 },
0,
1,
"bold"
));
}