nigig-org/crates/apps/doc/doc-engine/tests/materialize.rs
arena-agent 54e1148b39
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
doc-engine / engine (push) Successful in 16s
doc-engine / consumer (push) Successful in 3m58s
feat(doc): round-trip tabular clipboard payloads with RFC-4180 quoting
Cells holding tabs, newlines, CRs, or quotes re-distributed across the
grid on a copy/paste cycle — the documented caveat of every tabular
clipboard milestone. This closes it on both sides:

- table_grid_tsv quotes such fields on the way out (wrapped in double
  quotes, inner quotes doubled) via a new quote_tabular_field helper;
  plain and empty fields stay raw, so payloads stay byte-compatible
  with spreadsheets and plain text editors. The cell-range payload and
  the block-span document payload share the one builder, so both
  inherit the quoting at once.
- paste_table_payload replaces its split('\n')/split('\t') walk with
  split_tabular_payload, an RFC-4180-style tokenizer: quotes open only
  at field start (mid-field quotes are literal), doubled quotes read
  as one, tabs/newlines/CRs inside quotes are literal field text,
  CRLF rows outside quotes keep their tolerance, an unterminated
  quote reads to the end as best effort, and a single trailing
  newline adds no phantom row (a deliberate empty row survives).
- Caret parking, no-op skipping, empty-field clears, and the one-undo
  grouped write semantics of the raw paste milestone are unchanged;
  the caret offset in a multi-line value counts the newline too.

Tests: unit coverage for the writer and the tokenizer (every quoting
rule plus the quote/split round-trip property), runtime coverage of
copy quoting, paste restoring embedded tab/newline values verbatim
with one-undo and redo, and an end-to-end copy-cut-paste cycle; a
doc-engine materialize test pins special-character cell text
surviving peer sync and undo/redo verbatim. README caveats updated
and the milestone documented.
2026-08-02 09:54:40 +00:00

1206 lines
65 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"
);
}