| 1 | use docs_native_crdt::Editor; |
| 2 | use serde_json::{json, Value}; |
| 3 | |
| 4 | fn paragraph(editor: &mut Editor, text: &str) -> (String, String) { |
| 5 | editor.command(&json!({"op":"insert_element", "at":0, "type":"paragraph"})).unwrap(); |
| 6 | let block = editor.projection()["children"][0]["id"].as_str().unwrap().to_owned(); |
| 7 | editor.command(&json!({"op":"insert_text", "node":block, "at":0, "text":text})).unwrap(); |
| 8 | let node = editor.projection()["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); |
| 9 | (block, node) |
| 10 | } |
| 11 | fn plain(projection: &Value) -> String { |
| 12 | if let Some(runs) = projection["runs"].as_array() { |
| 13 | return runs.iter().map(|r| r["insert"].as_str().unwrap()).collect(); |
| 14 | } |
| 15 | projection["children"].as_array().map(|items| items.iter().map(plain).collect()).unwrap_or_default() |
| 16 | } |
| 17 | #[test] |
| 18 | fn utf16_edits_preserve_identity_and_marks() { |
| 19 | let mut editor = Editor::new("fixture", 1); |
| 20 | let (block, node) = paragraph(&mut editor, "A๐B"); |
| 21 | editor.command(&json!({"op":"format", "node":node, "at":1, "length":2, "marks":{"strong":{}}})).unwrap(); |
| 22 | editor.command(&json!({"op":"replace_text", "node":node, "at":3, "length":1, "text":"C"})).unwrap(); |
| 23 | let p = editor.projection(); |
| 24 | assert_eq!(plain(&p), "A๐C"); |
| 25 | assert_eq!(p["children"][0]["id"], block); |
| 26 | assert_eq!(p["children"][0]["children"][0]["id"], node); |
| 27 | assert_eq!(p["children"][0]["children"][0]["runs"][1]["marks"]["strong"], json!({})); |
| 28 | assert!(editor.command(&json!({"op":"replace_text", "node":node, "at":2, "length":0, "text":"broken"})).is_err()); |
| 29 | assert_eq!(plain(&editor.projection()), "A๐C"); |
| 30 | } |
| 31 | #[test] |
| 32 | fn concurrent_insertions_and_own_undo_preserve_peer() { |
| 33 | let mut a = Editor::new("fixture", 2); |
| 34 | let (_, node) = paragraph(&mut a, "Start"); |
| 35 | let mut b = Editor::new("fixture", 3); |
| 36 | b.apply(&a.snapshot()).unwrap(); |
| 37 | let local = a.command(&json!({"op":"replace_text", "node":node, "at":5, "length":0, "text":" alice"})).unwrap(); |
| 38 | let peer = b.command(&json!({"op":"replace_text", "node":node, "at":0, "length":0, "text":"bob "})).unwrap(); |
| 39 | a.apply(&peer).unwrap(); b.apply(&local).unwrap(); |
| 40 | assert_eq!(plain(&a.projection()), plain(&b.projection())); |
| 41 | let undo = a.command(&json!({"op":"undo"})).unwrap(); b.apply(&undo).unwrap(); |
| 42 | assert_eq!(plain(&a.projection()), "bob Start"); |
| 43 | assert_eq!(plain(&a.projection()), plain(&b.projection())); |
| 44 | } |
| 45 | #[test] |
| 46 | fn concatenated_log_applies_every_update() { |
| 47 | let mut a = Editor::new("fixture", 4); |
| 48 | let (_, node) = paragraph(&mut a, "one"); |
| 49 | let mut log = a.snapshot(); |
| 50 | log.extend(a.command(&json!({"op":"replace_text", "node":node, "at":3, "length":0, "text":" two"})).unwrap()); |
| 51 | let b = Editor::new("fixture", 5); |
| 52 | b.apply(&log).unwrap(); |
| 53 | assert_eq!(plain(&b.projection()), "one two"); |
| 54 | } |
| 55 | #[test] |
| 56 | fn reads_existing_yjs_fragment_without_rewriting() { |
| 57 | let editor = Editor::new("fixture", 6); |
| 58 | editor.apply(include_bytes!("../../../fixtures/vectors/fragment/formatted.update.bin")).unwrap(); |
| 59 | let before = editor.projection(); |
| 60 | let other = Editor::new("fixture", 7); |
| 61 | other.apply(&editor.snapshot()).unwrap(); |
| 62 | assert_eq!(before["children"], other.projection()["children"]); |
| 63 | assert!(!plain(&before).is_empty()); |
| 64 | } |
| 65 | #[test] |
| 66 | fn paragraph_split_keeps_prefix_identity_and_suffix_marks() { |
| 67 | let mut editor = Editor::new("fixture", 9); |
| 68 | let (block, node) = paragraph(&mut editor, "A๐B"); |
| 69 | editor.command(&json!({"op":"format", "node":node, "at":1, "length":3, "marks":{"em":{}}})).unwrap(); |
| 70 | editor.command(&json!({"op":"split", "node":node, "at":1})).unwrap(); |
| 71 | let p = editor.projection(); |
| 72 | assert_eq!(p["children"][0]["id"], block); |
| 73 | assert_eq!(plain(&p["children"][0]), "A"); |
| 74 | assert_eq!(plain(&p["children"][1]), "๐B"); |
| 75 | assert_eq!(p["children"][1]["children"][0]["runs"][0]["marks"]["em"], json!({})); |
| 76 | } |
| 77 | #[test] |
| 78 | fn changing_block_preserves_existing_inline_formatting() { |
| 79 | let mut editor = Editor::new("fixture", 10); |
| 80 | let (block, node) = paragraph(&mut editor, "Heading"); |
| 81 | editor.command(&json!({"op":"format", "node":node, "at":0, "length":7, "marks":{"strong":{},"font_serif":{}}})).unwrap(); |
| 82 | editor.command(&json!({"op":"block", "node":block, "type":"heading", "level":2})).unwrap(); |
| 83 | let p = editor.projection(); |
| 84 | assert_eq!(p["children"][0]["type"], "heading"); |
| 85 | assert_eq!(p["children"][0]["attrs"]["level"], "2"); |
| 86 | assert_eq!(p["children"][0]["children"][0]["runs"][0]["marks"]["font_serif"], json!({})); |
| 87 | } |
| 88 | |
| 89 | #[test] |
| 90 | fn distinct_display_generations_preserve_peer_and_authoritative_undo() { |
| 91 | let mut authority = Editor::new("generation", 100); |
| 92 | let (_, node) = paragraph(&mut authority, "abc"); |
| 93 | let mut displayed = Editor::new("generation", 101); |
| 94 | displayed.apply(&authority.snapshot()).unwrap(); |
| 95 | let mut peer = Editor::new("generation", 102); |
| 96 | peer.apply(&authority.snapshot()).unwrap(); |
| 97 | let remote = peer.command(&json!({"op":"replace_text","node":node,"at":0,"length":0,"text":"P"})).unwrap(); |
| 98 | authority.apply(&remote).unwrap(); |
| 99 | // A callback still refers to the displayed pre-peer range, deleting b. |
| 100 | let local = displayed.command(&json!({"op":"replace_text","node":node,"at":1,"length":1,"text":"X"})).unwrap(); |
| 101 | authority.apply_local(&local).unwrap(); |
| 102 | assert_eq!(plain(&authority.projection()), "PaXc"); |
| 103 | let undo = authority.command(&json!({"op":"undo"})).unwrap(); |
| 104 | assert_eq!(plain(&authority.projection()), "Pabc"); |
| 105 | let mut next = Editor::new("generation", 103); |
| 106 | next.apply(&authority.snapshot()).unwrap(); |
| 107 | let typed = next.command(&json!({"op":"replace_text","node":node,"at":4,"length":0,"text":"!"})).unwrap(); |
| 108 | authority.apply_local(&typed).unwrap(); |
| 109 | peer.apply(&local).unwrap(); peer.apply(&undo).unwrap(); peer.apply(&typed).unwrap(); |
| 110 | assert_eq!(plain(&authority.projection()), "Pabc!"); |
| 111 | assert_eq!(plain(&peer.projection()), "Pabc!"); |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn optimistic_newline_typeahead_resolves_new_paragraph_in_source() { |
| 116 | let mut source = Editor::new("typeahead", 201); |
| 117 | paragraph(&mut source, "A๐B"); |
| 118 | source.command(&json!({"op":"display_edit","at":3,"length":0,"text":"\n"})).unwrap(); |
| 119 | source.command(&json!({"op":"display_edit","at":4,"length":0,"text":"typed"})).unwrap(); |
| 120 | let projection = source.projection(); |
| 121 | assert_eq!(plain(&projection["children"][0]), "A๐"); |
| 122 | assert_eq!(plain(&projection["children"][1]), "typedB"); |
| 123 | source.command(&json!({"op":"display_edit","at":9,"length":0,"text":"\nsecond\nthird"})).unwrap(); |
| 124 | let projection = source.projection(); |
| 125 | assert_eq!(projection["children"].as_array().unwrap().len(), 4); |
| 126 | assert_eq!(plain(&projection["children"][2]), "second"); |
| 127 | assert_eq!(plain(&projection["children"][3]), "thirdB"); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn cross_paragraph_replacement_keeps_prefix_id_and_formatted_suffix() { |
| 132 | let mut editor = Editor::new("join", 301); |
| 133 | let (prefix, _) = paragraph(&mut editor, "first"); |
| 134 | editor.command(&json!({"op":"display_edit","at":5,"length":0,"text":"\nsecond\nthird"})).unwrap(); |
| 135 | let tail = editor.projection()["children"][2]["children"][0]["id"].as_str().unwrap().to_owned(); |
| 136 | editor.command(&json!({"op":"format","node":tail,"at":0,"length":5,"marks":{"em":{}}})).unwrap(); |
| 137 | editor.command(&json!({"op":"display_edit","at":2,"length":13,"text":"X"})).unwrap(); |
| 138 | let projected = editor.projection(); |
| 139 | assert_eq!(projected["children"].as_array().unwrap().len(), 1); |
| 140 | assert_eq!(projected["children"][0]["id"], prefix); |
| 141 | assert_eq!(plain(&projected), "fiXird"); |
| 142 | assert_eq!(projected["children"][0]["children"][1]["runs"][0]["marks"]["em"], json!({})); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn selection_anchor_tracks_peer_insertion_in_utf16() { |
| 147 | let mut displayed = Editor::new("selection", 401); |
| 148 | let (_, node) = paragraph(&mut displayed, "A๐B"); |
| 149 | let anchor = displayed.anchor(3).unwrap(); |
| 150 | let mut authority = Editor::new("selection", 402); |
| 151 | authority.apply(&displayed.snapshot()).unwrap(); |
| 152 | authority.command(&json!({"op":"replace_text","node":node,"at":0,"length":0,"text":"peer "})).unwrap(); |
| 153 | assert_eq!(authority.resolve(&anchor).unwrap(), 8); |
| 154 | } |
| 155 | |
| 156 | #[test] |
| 157 | fn rich_paste_preserves_native_marks_and_existing_suffix() { |
| 158 | let mut editor = Editor::new("paste", 501); |
| 159 | let (prefix, _) = paragraph(&mut editor,"AB"); |
| 160 | editor.command(&json!({"op":"display_paste","at":1,"length":0,"blocks":[ |
| 161 | {"type":"paragraph","children":[{"type":"text","runs":[{"insert":"bold","marks":{"strong":{}}}]}]}, |
| 162 | {"type":"paragraph","children":[{"type":"text","runs":[{"insert":"link","marks":{"link":{"href":"https://example.com"}}}]}]} |
| 163 | ]})).unwrap(); |
| 164 | let p = editor.projection(); |
| 165 | assert_eq!(p["children"][0]["id"],prefix); |
| 166 | assert_eq!(plain(&p["children"][0]),"Abold"); |
| 167 | assert_eq!(plain(&p["children"][1]),"linkB"); |
| 168 | assert_eq!(p["children"][0]["children"][1]["runs"][0]["marks"]["strong"],json!({})); |
| 169 | assert_eq!(p["children"][1]["children"][0]["runs"][0]["marks"]["link"]["href"],"https://example.com"); |
| 170 | } |
| 171 | #[test] |
| 172 | fn list_item_sink_and_lift_keep_text_and_marks() { |
| 173 | let mut editor = Editor::new("list", 601); |
| 174 | let (block, _) = paragraph(&mut editor,"one"); |
| 175 | editor.command(&json!({"op":"block","node":block,"type":"bullet_list"})).unwrap(); |
| 176 | editor.command(&json!({"op":"display_edit","at":3,"length":0,"text":"\ntwo"})).unwrap(); |
| 177 | let second = editor.projection()["children"][0]["children"][1]["children"][0]["id"].as_str().unwrap().to_owned(); |
| 178 | editor.command(&json!({"op":"list_indent","node":second,"delta":1})).unwrap(); |
| 179 | let p = editor.projection(); |
| 180 | assert_eq!(p["children"][0]["children"].as_array().unwrap().len(),1); |
| 181 | assert_eq!(plain(&p["children"][0]["children"][0]["children"][1]),"two"); |
| 182 | let nested = p["children"][0]["children"][0]["children"][1]["children"][0]["children"][0]["id"].as_str().unwrap(); |
| 183 | editor.command(&json!({"op":"list_indent","node":nested,"delta":-1})).unwrap(); |
| 184 | assert_eq!(editor.projection()["children"][0]["children"].as_array().unwrap().len(),2); |
| 185 | assert_eq!(plain(&editor.projection()),"onetwo"); |
| 186 | } |
| 187 | |
| 188 | #[test] |
| 189 | fn select_all_replaces_multiple_native_text_nodes_and_terminal_separator() { |
| 190 | let mut editor = Editor::new("select-all", 701); |
| 191 | let (block, _) = paragraph(&mut editor,"one"); |
| 192 | editor.command(&json!({"op":"insert_text","node":block,"at":1,"text":"two"})).unwrap(); |
| 193 | editor.command(&json!({"op":"display_edit","at":0,"length":7,"text":"replaced"})).unwrap(); |
| 194 | assert_eq!(plain(&editor.projection()),"replaced"); |
| 195 | assert_eq!(editor.projection()["children"][0]["id"],block); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn hard_break_is_inline_and_code_enter_does_not_split_block() { |
| 200 | let mut editor = Editor::new("break", 801); |
| 201 | let (block, _) = paragraph(&mut editor,"AB"); |
| 202 | editor.command(&json!({"op":"display_edit","at":1,"length":0,"text":"\u{2028}next"})).unwrap(); |
| 203 | let p = editor.projection(); |
| 204 | assert_eq!(p["children"].as_array().unwrap().len(),1); |
| 205 | assert_eq!(p["children"][0]["children"][1]["type"],"hard_break"); |
| 206 | assert_eq!(plain(&p),"AnextB"); |
| 207 | editor.command(&json!({"op":"block","node":block,"type":"code_block"})).unwrap(); |
| 208 | editor.command(&json!({"op":"display_edit","at":0,"length":0,"text":"code\n"})).unwrap(); |
| 209 | assert_eq!(editor.projection()["children"].as_array().unwrap().len(),1); |
| 210 | assert!(plain(&editor.projection()).starts_with("code\nA")); |
| 211 | } |
| 212 | #[test] |
| 213 | fn toggling_existing_list_changes_type_and_lifts_without_nesting() { |
| 214 | let mut editor = Editor::new("toggle", 901); |
| 215 | let (block,_) = paragraph(&mut editor,"item"); |
| 216 | editor.command(&json!({"op":"block","node":block,"type":"bullet_list"})).unwrap(); |
| 217 | let block = editor.projection()["children"][0]["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); |
| 218 | editor.command(&json!({"op":"block","node":block,"type":"ordered_list"})).unwrap(); |
| 219 | assert_eq!(editor.projection()["children"][0]["type"],"ordered_list"); |
| 220 | let block = editor.projection()["children"][0]["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); |
| 221 | editor.command(&json!({"op":"block","node":block,"type":"ordered_list"})).unwrap(); |
| 222 | assert_eq!(editor.projection()["children"][0]["type"],"paragraph"); |
| 223 | assert_eq!(plain(&editor.projection()),"item"); |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn deleting_and_replacing_inline_images_preserves_surrounding_text() { |
| 228 | let mut editor = Editor::new("image", 1001); |
| 229 | let (block,_) = paragraph(&mut editor,""); |
| 230 | editor.command(&json!({"op":"insert_element","node":block,"at":0,"type":"image","attrs":{"src":"https://example.com/a.png","alt":"A"}})).unwrap(); |
| 231 | editor.command(&json!({"op":"insert_text","node":block,"at":1,"text":"after"})).unwrap(); |
| 232 | editor.command(&json!({"op":"display_edit","at":0,"length":1,"text":"before "})).unwrap(); |
| 233 | assert_eq!(plain(&editor.projection()),"before after"); |
| 234 | assert!(!editor.projection().to_string().contains("https://example.com/a.png")); |
| 235 | } |
| 236 | |
| 237 | #[test] |
| 238 | fn rich_paste_at_empty_block_keeps_heading_and_list_structure() { |
| 239 | let mut editor = Editor::new("paste-structure", 1101); |
| 240 | paragraph(&mut editor, ""); |
| 241 | editor.command(&json!({"op":"display_paste","at":0,"length":0,"blocks":[ |
| 242 | {"type":"heading","attrs":{"level":"4"},"children":[{"type":"text","runs":[{"insert":"Heading","marks":{}}]}]}, |
| 243 | {"type":"bullet_list","children":[{"type":"list_item","children":[{"type":"paragraph","children":[{"type":"text","runs":[{"insert":"Item","marks":{}}]}]}]}]} |
| 244 | ]})).unwrap(); |
| 245 | let projected = editor.projection(); |
| 246 | assert_eq!(projected["children"][0]["type"], "heading"); |
| 247 | assert_eq!(projected["children"][0]["attrs"]["level"], "4"); |
| 248 | assert_eq!(projected["children"][1]["type"], "bullet_list"); |
| 249 | assert_eq!(projected["children"].as_array().unwrap().len(), 2); |
| 250 | editor.command(&json!({"op":"display_edit","at":12,"length":0,"text":"!"})).unwrap(); |
| 251 | assert_eq!(plain(&editor.projection()), "HeadingItem!"); |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn rich_paste_rule_retains_suffix_as_separate_paragraph() { |
| 256 | let mut editor = Editor::new("paste-rule", 1102); |
| 257 | paragraph(&mut editor, "AB"); |
| 258 | editor.command(&json!({"op":"display_paste","at":1,"length":0,"blocks":[{"type":"horizontal_rule"}]})).unwrap(); |
| 259 | let p = editor.projection(); |
| 260 | assert_eq!(p["children"].as_array().unwrap().len(),3); |
| 261 | assert_eq!(plain(&p["children"][0]), "A"); |
| 262 | assert_eq!(p["children"][1]["type"], "horizontal_rule"); |
| 263 | assert_eq!(plain(&p["children"][2]), "B"); |
| 264 | editor.command(&json!({"op":"display_edit","at":4,"length":0,"text":"!"})).unwrap(); |
| 265 | assert_eq!(plain(&editor.projection()), "A!B"); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn input_rules_transform_and_backspace_restore_typed_prefix() { |
| 270 | for (prefix, kind, level) in [("-", "bullet_list", 0), ("7.", "ordered_list", 0), ("###", "heading", 3), (">", "blockquote", 0)] { |
| 271 | let mut editor = Editor::new("input-rule", 1201); |
| 272 | paragraph(&mut editor, prefix); |
| 273 | editor.command(&json!({"op":"display_autoformat","at":0,"length":prefix.len(),"kind":kind,"level":level})).unwrap(); |
| 274 | assert_eq!(editor.projection()["children"][0]["type"], kind); |
| 275 | assert_eq!(plain(&editor.projection()), ""); |
| 276 | editor.command(&json!({"op":"display_revert_rule","at":0,"kind":kind,"prior_type":"paragraph","prior_attrs":{},"text":format!("{prefix} ")})).unwrap(); |
| 277 | assert_eq!(editor.projection()["children"][0]["type"], "paragraph"); |
| 278 | assert_eq!(plain(&editor.projection()), format!("{prefix} ")); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | #[test] |
| 283 | fn merged_pending_updates_preserve_dependencies_deletes_and_duplicate_delivery() { |
| 284 | use yrs::updates::decoder::{Decode, Decoder, DecoderV1}; |
| 285 | use yrs::Update; |
| 286 | let mut writer = Editor::new("batch", 201); |
| 287 | let (_, node) = paragraph(&mut writer, "original"); |
| 288 | let initial = writer.snapshot(); |
| 289 | let mut bytes = vec![253]; |
| 290 | bytes.extend(writer.command(&json!({"op":"replace_text","node":node,"at":8,"length":0,"text":" added"})).unwrap()); |
| 291 | bytes.extend(writer.command(&json!({"op":"replace_text","node":node,"at":0,"length":3,"text":"new"})).unwrap()); |
| 292 | bytes.extend(writer.command(&json!({"op":"format","node":node,"at":0,"length":3,"marks":{"strong":{}}})).unwrap()); |
| 293 | let merged = docs_native_crdt::dispatch(&bytes).unwrap(); |
| 294 | // A POST must contain exactly one Yjs v1 update, not concatenated messages. |
| 295 | let mut decoder = DecoderV1::from(merged.as_slice()); |
| 296 | Update::decode(&mut decoder).unwrap(); |
| 297 | assert!(decoder.read_to_end().unwrap().is_empty()); |
| 298 | let receiver = Editor::new("batch", 202); |
| 299 | receiver.apply(&initial).unwrap(); |
| 300 | receiver.apply(&merged).unwrap(); |
| 301 | receiver.apply(&merged).unwrap(); |
| 302 | assert_eq!(receiver.projection()["children"], writer.projection()["children"]); |
| 303 | assert!(docs_native_crdt::dispatch(&[253, 255]).is_err()); |
| 304 | } |
| 305 | |