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::>(); 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::>(); 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!["a","bc","d"]); assert_eq!(runs.iter().map(|run| run.bold).collect::>(),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![("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![("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![("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::>(); 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 = 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" ); }