use docs_native_crdt::Editor; use serde_json::{json, Value}; fn paragraph(editor: &mut Editor, text: &str) -> (String, String) { editor.command(&json!({"op":"insert_element", "at":0, "type":"paragraph"})).unwrap(); let block = editor.projection()["children"][0]["id"].as_str().unwrap().to_owned(); editor.command(&json!({"op":"insert_text", "node":block, "at":0, "text":text})).unwrap(); let node = editor.projection()["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); (block, node) } fn plain(projection: &Value) -> String { if let Some(runs) = projection["runs"].as_array() { return runs.iter().map(|r| r["insert"].as_str().unwrap()).collect(); } projection["children"].as_array().map(|items| items.iter().map(plain).collect()).unwrap_or_default() } #[test] fn utf16_edits_preserve_identity_and_marks() { let mut editor = Editor::new("fixture", 1); let (block, node) = paragraph(&mut editor, "A😀B"); editor.command(&json!({"op":"format", "node":node, "at":1, "length":2, "marks":{"strong":{}}})).unwrap(); editor.command(&json!({"op":"replace_text", "node":node, "at":3, "length":1, "text":"C"})).unwrap(); let p = editor.projection(); assert_eq!(plain(&p), "A😀C"); assert_eq!(p["children"][0]["id"], block); assert_eq!(p["children"][0]["children"][0]["id"], node); assert_eq!(p["children"][0]["children"][0]["runs"][1]["marks"]["strong"], json!({})); assert!(editor.command(&json!({"op":"replace_text", "node":node, "at":2, "length":0, "text":"broken"})).is_err()); assert_eq!(plain(&editor.projection()), "A😀C"); } #[test] fn concurrent_insertions_and_own_undo_preserve_peer() { let mut a = Editor::new("fixture", 2); let (_, node) = paragraph(&mut a, "Start"); let mut b = Editor::new("fixture", 3); b.apply(&a.snapshot()).unwrap(); let local = a.command(&json!({"op":"replace_text", "node":node, "at":5, "length":0, "text":" alice"})).unwrap(); let peer = b.command(&json!({"op":"replace_text", "node":node, "at":0, "length":0, "text":"bob "})).unwrap(); a.apply(&peer).unwrap(); b.apply(&local).unwrap(); assert_eq!(plain(&a.projection()), plain(&b.projection())); let undo = a.command(&json!({"op":"undo"})).unwrap(); b.apply(&undo).unwrap(); assert_eq!(plain(&a.projection()), "bob Start"); assert_eq!(plain(&a.projection()), plain(&b.projection())); } #[test] fn concatenated_log_applies_every_update() { let mut a = Editor::new("fixture", 4); let (_, node) = paragraph(&mut a, "one"); let mut log = a.snapshot(); log.extend(a.command(&json!({"op":"replace_text", "node":node, "at":3, "length":0, "text":" two"})).unwrap()); let b = Editor::new("fixture", 5); b.apply(&log).unwrap(); assert_eq!(plain(&b.projection()), "one two"); } #[test] fn reads_existing_yjs_fragment_without_rewriting() { let editor = Editor::new("fixture", 6); editor.apply(include_bytes!("../../../fixtures/vectors/fragment/formatted.update.bin")).unwrap(); let before = editor.projection(); let other = Editor::new("fixture", 7); other.apply(&editor.snapshot()).unwrap(); assert_eq!(before["children"], other.projection()["children"]); assert!(!plain(&before).is_empty()); } #[test] fn paragraph_split_keeps_prefix_identity_and_suffix_marks() { let mut editor = Editor::new("fixture", 9); let (block, node) = paragraph(&mut editor, "A😀B"); editor.command(&json!({"op":"format", "node":node, "at":1, "length":3, "marks":{"em":{}}})).unwrap(); editor.command(&json!({"op":"split", "node":node, "at":1})).unwrap(); let p = editor.projection(); assert_eq!(p["children"][0]["id"], block); assert_eq!(plain(&p["children"][0]), "A"); assert_eq!(plain(&p["children"][1]), "😀B"); assert_eq!(p["children"][1]["children"][0]["runs"][0]["marks"]["em"], json!({})); } #[test] fn changing_block_preserves_existing_inline_formatting() { let mut editor = Editor::new("fixture", 10); let (block, node) = paragraph(&mut editor, "Heading"); editor.command(&json!({"op":"format", "node":node, "at":0, "length":7, "marks":{"strong":{},"font_serif":{}}})).unwrap(); editor.command(&json!({"op":"block", "node":block, "type":"heading", "level":2})).unwrap(); let p = editor.projection(); assert_eq!(p["children"][0]["type"], "heading"); assert_eq!(p["children"][0]["attrs"]["level"], "2"); assert_eq!(p["children"][0]["children"][0]["runs"][0]["marks"]["font_serif"], json!({})); } #[test] fn distinct_display_generations_preserve_peer_and_authoritative_undo() { let mut authority = Editor::new("generation", 100); let (_, node) = paragraph(&mut authority, "abc"); let mut displayed = Editor::new("generation", 101); displayed.apply(&authority.snapshot()).unwrap(); let mut peer = Editor::new("generation", 102); peer.apply(&authority.snapshot()).unwrap(); let remote = peer.command(&json!({"op":"replace_text","node":node,"at":0,"length":0,"text":"P"})).unwrap(); authority.apply(&remote).unwrap(); // A callback still refers to the displayed pre-peer range, deleting b. let local = displayed.command(&json!({"op":"replace_text","node":node,"at":1,"length":1,"text":"X"})).unwrap(); authority.apply_local(&local).unwrap(); assert_eq!(plain(&authority.projection()), "PaXc"); let undo = authority.command(&json!({"op":"undo"})).unwrap(); assert_eq!(plain(&authority.projection()), "Pabc"); let mut next = Editor::new("generation", 103); next.apply(&authority.snapshot()).unwrap(); let typed = next.command(&json!({"op":"replace_text","node":node,"at":4,"length":0,"text":"!"})).unwrap(); authority.apply_local(&typed).unwrap(); peer.apply(&local).unwrap(); peer.apply(&undo).unwrap(); peer.apply(&typed).unwrap(); assert_eq!(plain(&authority.projection()), "Pabc!"); assert_eq!(plain(&peer.projection()), "Pabc!"); } #[test] fn optimistic_newline_typeahead_resolves_new_paragraph_in_source() { let mut source = Editor::new("typeahead", 201); paragraph(&mut source, "A😀B"); source.command(&json!({"op":"display_edit","at":3,"length":0,"text":"\n"})).unwrap(); source.command(&json!({"op":"display_edit","at":4,"length":0,"text":"typed"})).unwrap(); let projection = source.projection(); assert_eq!(plain(&projection["children"][0]), "A😀"); assert_eq!(plain(&projection["children"][1]), "typedB"); source.command(&json!({"op":"display_edit","at":9,"length":0,"text":"\nsecond\nthird"})).unwrap(); let projection = source.projection(); assert_eq!(projection["children"].as_array().unwrap().len(), 4); assert_eq!(plain(&projection["children"][2]), "second"); assert_eq!(plain(&projection["children"][3]), "thirdB"); } #[test] fn cross_paragraph_replacement_keeps_prefix_id_and_formatted_suffix() { let mut editor = Editor::new("join", 301); let (prefix, _) = paragraph(&mut editor, "first"); editor.command(&json!({"op":"display_edit","at":5,"length":0,"text":"\nsecond\nthird"})).unwrap(); let tail = editor.projection()["children"][2]["children"][0]["id"].as_str().unwrap().to_owned(); editor.command(&json!({"op":"format","node":tail,"at":0,"length":5,"marks":{"em":{}}})).unwrap(); editor.command(&json!({"op":"display_edit","at":2,"length":13,"text":"X"})).unwrap(); let projected = editor.projection(); assert_eq!(projected["children"].as_array().unwrap().len(), 1); assert_eq!(projected["children"][0]["id"], prefix); assert_eq!(plain(&projected), "fiXird"); assert_eq!(projected["children"][0]["children"][1]["runs"][0]["marks"]["em"], json!({})); } #[test] fn selection_anchor_tracks_peer_insertion_in_utf16() { let mut displayed = Editor::new("selection", 401); let (_, node) = paragraph(&mut displayed, "A😀B"); let anchor = displayed.anchor(3).unwrap(); let mut authority = Editor::new("selection", 402); authority.apply(&displayed.snapshot()).unwrap(); authority.command(&json!({"op":"replace_text","node":node,"at":0,"length":0,"text":"peer "})).unwrap(); assert_eq!(authority.resolve(&anchor).unwrap(), 8); } #[test] fn rich_paste_preserves_native_marks_and_existing_suffix() { let mut editor = Editor::new("paste", 501); let (prefix, _) = paragraph(&mut editor,"AB"); editor.command(&json!({"op":"display_paste","at":1,"length":0,"blocks":[ {"type":"paragraph","children":[{"type":"text","runs":[{"insert":"bold","marks":{"strong":{}}}]}]}, {"type":"paragraph","children":[{"type":"text","runs":[{"insert":"link","marks":{"link":{"href":"https://example.com"}}}]}]} ]})).unwrap(); let p = editor.projection(); assert_eq!(p["children"][0]["id"],prefix); assert_eq!(plain(&p["children"][0]),"Abold"); assert_eq!(plain(&p["children"][1]),"linkB"); assert_eq!(p["children"][0]["children"][1]["runs"][0]["marks"]["strong"],json!({})); assert_eq!(p["children"][1]["children"][0]["runs"][0]["marks"]["link"]["href"],"https://example.com"); } #[test] fn list_item_sink_and_lift_keep_text_and_marks() { let mut editor = Editor::new("list", 601); let (block, _) = paragraph(&mut editor,"one"); editor.command(&json!({"op":"block","node":block,"type":"bullet_list"})).unwrap(); editor.command(&json!({"op":"display_edit","at":3,"length":0,"text":"\ntwo"})).unwrap(); let second = editor.projection()["children"][0]["children"][1]["children"][0]["id"].as_str().unwrap().to_owned(); editor.command(&json!({"op":"list_indent","node":second,"delta":1})).unwrap(); let p = editor.projection(); assert_eq!(p["children"][0]["children"].as_array().unwrap().len(),1); assert_eq!(plain(&p["children"][0]["children"][0]["children"][1]),"two"); let nested = p["children"][0]["children"][0]["children"][1]["children"][0]["children"][0]["id"].as_str().unwrap(); editor.command(&json!({"op":"list_indent","node":nested,"delta":-1})).unwrap(); assert_eq!(editor.projection()["children"][0]["children"].as_array().unwrap().len(),2); assert_eq!(plain(&editor.projection()),"onetwo"); } #[test] fn select_all_replaces_multiple_native_text_nodes_and_terminal_separator() { let mut editor = Editor::new("select-all", 701); let (block, _) = paragraph(&mut editor,"one"); editor.command(&json!({"op":"insert_text","node":block,"at":1,"text":"two"})).unwrap(); editor.command(&json!({"op":"display_edit","at":0,"length":7,"text":"replaced"})).unwrap(); assert_eq!(plain(&editor.projection()),"replaced"); assert_eq!(editor.projection()["children"][0]["id"],block); } #[test] fn hard_break_is_inline_and_code_enter_does_not_split_block() { let mut editor = Editor::new("break", 801); let (block, _) = paragraph(&mut editor,"AB"); editor.command(&json!({"op":"display_edit","at":1,"length":0,"text":"\u{2028}next"})).unwrap(); let p = editor.projection(); assert_eq!(p["children"].as_array().unwrap().len(),1); assert_eq!(p["children"][0]["children"][1]["type"],"hard_break"); assert_eq!(plain(&p),"AnextB"); editor.command(&json!({"op":"block","node":block,"type":"code_block"})).unwrap(); editor.command(&json!({"op":"display_edit","at":0,"length":0,"text":"code\n"})).unwrap(); assert_eq!(editor.projection()["children"].as_array().unwrap().len(),1); assert!(plain(&editor.projection()).starts_with("code\nA")); } #[test] fn toggling_existing_list_changes_type_and_lifts_without_nesting() { let mut editor = Editor::new("toggle", 901); let (block,_) = paragraph(&mut editor,"item"); editor.command(&json!({"op":"block","node":block,"type":"bullet_list"})).unwrap(); let block = editor.projection()["children"][0]["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); editor.command(&json!({"op":"block","node":block,"type":"ordered_list"})).unwrap(); assert_eq!(editor.projection()["children"][0]["type"],"ordered_list"); let block = editor.projection()["children"][0]["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); editor.command(&json!({"op":"block","node":block,"type":"ordered_list"})).unwrap(); assert_eq!(editor.projection()["children"][0]["type"],"paragraph"); assert_eq!(plain(&editor.projection()),"item"); } #[test] fn deleting_and_replacing_inline_images_preserves_surrounding_text() { let mut editor = Editor::new("image", 1001); let (block,_) = paragraph(&mut editor,""); editor.command(&json!({"op":"insert_element","node":block,"at":0,"type":"image","attrs":{"src":"https://example.com/a.png","alt":"A"}})).unwrap(); editor.command(&json!({"op":"insert_text","node":block,"at":1,"text":"after"})).unwrap(); editor.command(&json!({"op":"display_edit","at":0,"length":1,"text":"before "})).unwrap(); assert_eq!(plain(&editor.projection()),"before after"); assert!(!editor.projection().to_string().contains("https://example.com/a.png")); } #[test] fn rich_paste_at_empty_block_keeps_heading_and_list_structure() { let mut editor = Editor::new("paste-structure", 1101); paragraph(&mut editor, ""); editor.command(&json!({"op":"display_paste","at":0,"length":0,"blocks":[ {"type":"heading","attrs":{"level":"4"},"children":[{"type":"text","runs":[{"insert":"Heading","marks":{}}]}]}, {"type":"bullet_list","children":[{"type":"list_item","children":[{"type":"paragraph","children":[{"type":"text","runs":[{"insert":"Item","marks":{}}]}]}]}]} ]})).unwrap(); let projected = editor.projection(); assert_eq!(projected["children"][0]["type"], "heading"); assert_eq!(projected["children"][0]["attrs"]["level"], "4"); assert_eq!(projected["children"][1]["type"], "bullet_list"); assert_eq!(projected["children"].as_array().unwrap().len(), 2); editor.command(&json!({"op":"display_edit","at":12,"length":0,"text":"!"})).unwrap(); assert_eq!(plain(&editor.projection()), "HeadingItem!"); } #[test] fn rich_paste_rule_retains_suffix_as_separate_paragraph() { let mut editor = Editor::new("paste-rule", 1102); paragraph(&mut editor, "AB"); editor.command(&json!({"op":"display_paste","at":1,"length":0,"blocks":[{"type":"horizontal_rule"}]})).unwrap(); let p = editor.projection(); assert_eq!(p["children"].as_array().unwrap().len(),3); assert_eq!(plain(&p["children"][0]), "A"); assert_eq!(p["children"][1]["type"], "horizontal_rule"); assert_eq!(plain(&p["children"][2]), "B"); editor.command(&json!({"op":"display_edit","at":4,"length":0,"text":"!"})).unwrap(); assert_eq!(plain(&editor.projection()), "A!B"); } #[test] fn input_rules_transform_and_backspace_restore_typed_prefix() { for (prefix, kind, level) in [("-", "bullet_list", 0), ("7.", "ordered_list", 0), ("###", "heading", 3), (">", "blockquote", 0)] { let mut editor = Editor::new("input-rule", 1201); paragraph(&mut editor, prefix); editor.command(&json!({"op":"display_autoformat","at":0,"length":prefix.len(),"kind":kind,"level":level})).unwrap(); assert_eq!(editor.projection()["children"][0]["type"], kind); assert_eq!(plain(&editor.projection()), ""); editor.command(&json!({"op":"display_revert_rule","at":0,"kind":kind,"prior_type":"paragraph","prior_attrs":{},"text":format!("{prefix} ")})).unwrap(); assert_eq!(editor.projection()["children"][0]["type"], "paragraph"); assert_eq!(plain(&editor.projection()), format!("{prefix} ")); } } #[test] fn merged_pending_updates_preserve_dependencies_deletes_and_duplicate_delivery() { use yrs::updates::decoder::{Decode, Decoder, DecoderV1}; use yrs::Update; let mut writer = Editor::new("batch", 201); let (_, node) = paragraph(&mut writer, "original"); let initial = writer.snapshot(); let mut bytes = vec![253]; bytes.extend(writer.command(&json!({"op":"replace_text","node":node,"at":8,"length":0,"text":" added"})).unwrap()); bytes.extend(writer.command(&json!({"op":"replace_text","node":node,"at":0,"length":3,"text":"new"})).unwrap()); bytes.extend(writer.command(&json!({"op":"format","node":node,"at":0,"length":3,"marks":{"strong":{}}})).unwrap()); let merged = docs_native_crdt::dispatch(&bytes).unwrap(); // A POST must contain exactly one Yjs v1 update, not concatenated messages. let mut decoder = DecoderV1::from(merged.as_slice()); Update::decode(&mut decoder).unwrap(); assert!(decoder.read_to_end().unwrap().is_empty()); let receiver = Editor::new("batch", 202); receiver.apply(&initial).unwrap(); receiver.apply(&merged).unwrap(); receiver.apply(&merged).unwrap(); assert_eq!(receiver.projection()["children"], writer.projection()["children"]); assert!(docs_native_crdt::dispatch(&[253, 255]).is_err()); }