| 1 | use onestore::{ |
| 2 | ExGuid, RevisionIndex, Store, |
| 3 | document::{Document, Kind, Revision}, |
| 4 | op::{Op, OpError, PageOp}, |
| 5 | page::text::new_id, |
| 6 | }; |
| 7 | use serde_json::Value; |
| 8 | |
| 9 | #[path = "support/checkpoint.rs"] |
| 10 | mod checkpoint; |
| 11 | #[path = "support/current.rs"] |
| 12 | mod current; |
| 13 | #[path = "support/disk.rs"] |
| 14 | mod disk; |
| 15 | #[path = "support/ops.rs"] |
| 16 | mod ops; |
| 17 | |
| 18 | /// Enter at `at` in `text`, with fresh identities for the new paragraph, its text and a |
| 19 | /// copy of each list node of the paragraph: the op, the new paragraph and its text. |
| 20 | fn split(source: &[u8], space: ExGuid, text: ExGuid, at: u32) -> (Op, ExGuid, ExGuid) { |
| 21 | let store = Store::parse(source).unwrap(); |
| 22 | let index = RevisionIndex::parse(&store).unwrap(); |
| 23 | let document = Document::parse(&index).unwrap(); |
| 24 | let lists = document.spaces[&space] |
| 25 | .active() |
| 26 | .unwrap() |
| 27 | .nodes |
| 28 | .values() |
| 29 | .find_map(|node| match &node.kind { |
| 30 | Kind::Paragraph { lists, .. } if node.content == [text] => Some(lists.len()), |
| 31 | _ => None, |
| 32 | }) |
| 33 | .unwrap_or(0); |
| 34 | let (paragraph, right) = (new_id().unwrap(), new_id().unwrap()); |
| 35 | let op = PageOp::Split { |
| 36 | text, |
| 37 | at, |
| 38 | paragraph, |
| 39 | right, |
| 40 | lists: (0..lists).map(|_| new_id().unwrap()).collect(), |
| 41 | }; |
| 42 | (Op::Page { space, op }, paragraph, right) |
| 43 | } |
| 44 | |
| 45 | /// `source` after joining the paragraph of text `right` to the one of text `left`. |
| 46 | fn join(source: &[u8], space: ExGuid, left: ExGuid, right: ExGuid) -> Result<Vec<u8>, OpError> { |
| 47 | ops::edited( |
| 48 | source, |
| 49 | vec![Op::Page { |
| 50 | space, |
| 51 | op: PageOp::Join { left, right }, |
| 52 | }], |
| 53 | ) |
| 54 | } |
| 55 | |
| 56 | const SOURCE: &[u8] = |
| 57 | include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); |
| 58 | |
| 59 | fn characters(view: &Revision<'_>, id: ExGuid) -> Vec<(char, Value)> { |
| 60 | view.text_runs(id) |
| 61 | .unwrap() |
| 62 | .into_iter() |
| 63 | .flat_map(|run| { |
| 64 | let format = serde_json::to_value(run.format).unwrap(); |
| 65 | run.text.chars().map(move |c| (c, format.clone())) |
| 66 | }) |
| 67 | .collect() |
| 68 | } |
| 69 | |
| 70 | const JOIN_FIXTURES: [(&[u8], &[u8], bool); 3] = [ |
| 71 | ( |
| 72 | include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one").as_slice(), |
| 73 | include_bytes!("../../../corpus/paragraph-edit/joined/notebook/synthetic.one").as_slice(), |
| 74 | true, |
| 75 | ), |
| 76 | ( |
| 77 | include_bytes!("../../../corpus/paragraph-edit/join-edges/before/notebook/synthetic.one") |
| 78 | .as_slice(), |
| 79 | include_bytes!("../../../corpus/paragraph-edit/join-edges/joined/notebook/synthetic.one") |
| 80 | .as_slice(), |
| 81 | false, |
| 82 | ), |
| 83 | ( |
| 84 | include_bytes!("../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one") |
| 85 | .as_slice(), |
| 86 | include_bytes!("../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one") |
| 87 | .as_slice(), |
| 88 | false, |
| 89 | ), |
| 90 | ]; |
| 91 | |
| 92 | fn join_targets(document: &Document<'_>, split_cases: bool) -> Vec<(String, ExGuid, ExGuid)> { |
| 93 | let mut cases = Vec::new(); |
| 94 | if split_cases { |
| 95 | let manifest: Value = |
| 96 | serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")) |
| 97 | .unwrap(); |
| 98 | for case in manifest["cases"].as_array().unwrap() { |
| 99 | cases.push(( |
| 100 | case["case"].as_str().unwrap().to_owned(), |
| 101 | serde_json::from_value::<ExGuid>(case["original_text"].clone()).unwrap(), |
| 102 | serde_json::from_value::<ExGuid>(case["new_text"].clone()).unwrap(), |
| 103 | )); |
| 104 | } |
| 105 | } else { |
| 106 | for (sid, page) in document.pages().unwrap() { |
| 107 | let s = &document.spaces[&sid]; |
| 108 | let view = &s.revisions[&s.contexts[&ExGuid::default()]]; |
| 109 | let Kind::Metadata { |
| 110 | title: Some(name), .. |
| 111 | } = &view.nodes[&view.roots[&2]].kind |
| 112 | else { |
| 113 | panic!() |
| 114 | }; |
| 115 | if !name.starts_with("Join ") { |
| 116 | continue; |
| 117 | } |
| 118 | let outline = view.nodes[&page] |
| 119 | .children |
| 120 | .iter() |
| 121 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) |
| 122 | .unwrap(); |
| 123 | let mut left = view.nodes[outline].children[0]; |
| 124 | while let Some(child) = view.nodes[&left].children.last() { |
| 125 | left = *child; |
| 126 | } |
| 127 | let right = view.nodes[outline].children[1]; |
| 128 | cases.push(( |
| 129 | name.clone(), |
| 130 | view.nodes[&left].content[0], |
| 131 | view.nodes[&right].content[0], |
| 132 | )); |
| 133 | } |
| 134 | } |
| 135 | assert_eq!(cases.len(), document.pages().unwrap().len() - 1); |
| 136 | cases |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn joins_match_native_graphs_tags_and_inherited_character_styles() { |
| 141 | for (source, native, split_cases) in JOIN_FIXTURES { |
| 142 | let store = Store::parse(source).unwrap(); |
| 143 | let index = RevisionIndex::parse(&store).unwrap(); |
| 144 | let document = Document::parse(&index).unwrap(); |
| 145 | let native_store = Store::parse(native).unwrap(); |
| 146 | let native_index = RevisionIndex::parse(&native_store).unwrap(); |
| 147 | let native_document = Document::parse(&native_index).unwrap(); |
| 148 | let cases = join_targets(&document, split_cases); |
| 149 | for (name, left, right) in cases { |
| 150 | let (sid, space) = document |
| 151 | .spaces |
| 152 | .iter() |
| 153 | .find(|(_, space)| { |
| 154 | space.revisions[&space.contexts[&ExGuid::default()]] |
| 155 | .nodes |
| 156 | .contains_key(&left) |
| 157 | }) |
| 158 | .unwrap(); |
| 159 | let edited = |
| 160 | join(source, *sid, left, right).unwrap_or_else(|error| panic!("{name}: {error}")); |
| 161 | let current_store = Store::parse(&edited).unwrap(); |
| 162 | assert_eq!( |
| 163 | current_store.header.transaction_count, |
| 164 | store.header.transaction_count + 1 |
| 165 | ); |
| 166 | let current_index = RevisionIndex::parse(&current_store).unwrap(); |
| 167 | current_index.validate_current().unwrap(); |
| 168 | let current_document = Document::parse(&current_index).unwrap(); |
| 169 | let current = &current_document.spaces[sid]; |
| 170 | let current = &current.revisions[&current.contexts[&ExGuid::default()]]; |
| 171 | let expected = &native_document.spaces[sid]; |
| 172 | let expected = &expected.revisions[&expected.contexts[&ExGuid::default()]]; |
| 173 | let (_, page) = document |
| 174 | .pages() |
| 175 | .unwrap() |
| 176 | .into_iter() |
| 177 | .find(|(id, _)| id == sid) |
| 178 | .unwrap(); |
| 179 | let before = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 180 | let left_paragraph = *before |
| 181 | .nodes |
| 182 | .iter() |
| 183 | .find(|(_, node)| node.content == [left]) |
| 184 | .unwrap() |
| 185 | .0; |
| 186 | let right_paragraph = *before |
| 187 | .nodes |
| 188 | .iter() |
| 189 | .find(|(_, node)| node.content == [right]) |
| 190 | .unwrap() |
| 191 | .0; |
| 192 | let right_parent = *before |
| 193 | .nodes |
| 194 | .iter() |
| 195 | .find(|(_, node)| node.children.contains(&right_paragraph)) |
| 196 | .unwrap() |
| 197 | .0; |
| 198 | let survivor = if characters(before, left).is_empty() { |
| 199 | right |
| 200 | } else { |
| 201 | left |
| 202 | }; |
| 203 | let mut changed = std::collections::BTreeSet::from([survivor, before.roots[&2]]); |
| 204 | let mut ancestors = vec![left_paragraph, right_parent]; |
| 205 | while let Some(id) = ancestors.pop() { |
| 206 | if !changed.insert(id) || id == page { |
| 207 | continue; |
| 208 | } |
| 209 | ancestors.extend(before.nodes.iter().filter_map(|(parent, node)| { |
| 210 | node.children |
| 211 | .iter() |
| 212 | .chain(&node.content) |
| 213 | .chain(&node.structure) |
| 214 | .any(|child| *child == id) |
| 215 | .then_some(*parent) |
| 216 | })); |
| 217 | } |
| 218 | let old_raw = index |
| 219 | .resolve(*sid, space.contexts[&ExGuid::default()]) |
| 220 | .unwrap(); |
| 221 | let current_rid = current_document.spaces[sid].contexts[&ExGuid::default()]; |
| 222 | let new_raw = current_index.resolve(*sid, current_rid).unwrap(); |
| 223 | for (id, object) in old_raw.objects { |
| 224 | if !changed.contains(&id) { |
| 225 | assert_eq!( |
| 226 | object.data, new_raw.objects[&id].data, |
| 227 | "{name} untouched {id}" |
| 228 | ); |
| 229 | } |
| 230 | } |
| 231 | let mut pending: Vec<_> = expected.nodes[&page] |
| 232 | .children |
| 233 | .iter() |
| 234 | .filter(|id| matches!(expected.nodes[id].kind, Kind::Outline { .. })) |
| 235 | .copied() |
| 236 | .collect(); |
| 237 | while let Some(id) = pending.pop() { |
| 238 | let node = &expected.nodes[&id]; |
| 239 | assert_eq!( |
| 240 | current.nodes[&id].children, node.children, |
| 241 | "{name} children {id}" |
| 242 | ); |
| 243 | assert_eq!( |
| 244 | current.nodes[&id].content, node.content, |
| 245 | "{name} content {id}" |
| 246 | ); |
| 247 | assert_eq!( |
| 248 | current.nodes[&id].child_level, node.child_level, |
| 249 | "{name} indentation {id}" |
| 250 | ); |
| 251 | pending.extend( |
| 252 | node.children |
| 253 | .iter() |
| 254 | .chain(&node.content) |
| 255 | .chain(&node.structure) |
| 256 | .copied(), |
| 257 | ); |
| 258 | if matches!(node.kind, Kind::RichText { .. }) { |
| 259 | assert_eq!( |
| 260 | serde_json::to_value(&current.nodes[&id].tags).unwrap(), |
| 261 | serde_json::to_value(&node.tags).unwrap(), |
| 262 | "{name} tags {id}" |
| 263 | ); |
| 264 | let normalize = |view, id| { |
| 265 | characters(view, id) |
| 266 | .into_iter() |
| 267 | .map(|(c, mut style)| { |
| 268 | let fields = style.as_object_mut().unwrap(); |
| 269 | for key in |
| 270 | ["alignment", "space_before", "space_after", "line_spacing"] |
| 271 | { |
| 272 | fields.remove(key); |
| 273 | } |
| 274 | for key in [ |
| 275 | "bold", |
| 276 | "italic", |
| 277 | "underline", |
| 278 | "strike", |
| 279 | "superscript", |
| 280 | "subscript", |
| 281 | "hidden", |
| 282 | "hyperlink", |
| 283 | "hyperlink_label", |
| 284 | "math", |
| 285 | "embedded_object", |
| 286 | "rtl", |
| 287 | ] { |
| 288 | if fields[key].is_null() { |
| 289 | fields.insert(key.into(), false.into()); |
| 290 | } |
| 291 | } |
| 292 | for key in ["color", "highlight"] { |
| 293 | if fields[key].is_null() { |
| 294 | fields.insert(key.into(), 0xff000000_u32.into()); |
| 295 | } |
| 296 | } |
| 297 | (c, style) |
| 298 | }) |
| 299 | .collect::<Vec<_>>() |
| 300 | }; |
| 301 | assert_eq!( |
| 302 | normalize(current, id), |
| 303 | normalize(expected, id), |
| 304 | "{name} styles {id}" |
| 305 | ); |
| 306 | } |
| 307 | } |
| 308 | for (sid, old) in &index.spaces { |
| 309 | for revision in old.revisions.keys() { |
| 310 | let old = index.resolve(*sid, *revision).unwrap(); |
| 311 | let retained = current_index.resolve(*sid, *revision).unwrap(); |
| 312 | assert_eq!(old.roots, retained.roots); |
| 313 | for (id, object) in old.objects { |
| 314 | assert_eq!(object.data, retained.objects[&id].data); |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | assert!(join(&edited, *sid, left, right).is_err()); |
| 319 | assert_eq!( |
| 320 | space.contexts.len(), |
| 321 | current_document.spaces[sid].contexts.len() |
| 322 | ); |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | #[test] |
| 328 | fn splits_partition_native_paragraphs_at_every_scalar_boundary() { |
| 329 | let manifest: Value = |
| 330 | serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap(); |
| 331 | let store = Store::parse(SOURCE).unwrap(); |
| 332 | let index = RevisionIndex::parse(&store).unwrap(); |
| 333 | let document = Document::parse(&index).unwrap(); |
| 334 | let native_store = Store::parse(JOIN_FIXTURES[0].0).unwrap(); |
| 335 | let native_index = RevisionIndex::parse(&native_store).unwrap(); |
| 336 | let native_document = Document::parse(&native_index).unwrap(); |
| 337 | for case in manifest["cases"].as_array().unwrap() { |
| 338 | let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); |
| 339 | let paragraph: ExGuid = serde_json::from_value(case["original_paragraph"].clone()).unwrap(); |
| 340 | let (sid, space) = document |
| 341 | .spaces |
| 342 | .iter() |
| 343 | .find(|(_, space)| { |
| 344 | space.revisions[&space.contexts[&ExGuid::default()]] |
| 345 | .nodes |
| 346 | .contains_key(&text) |
| 347 | }) |
| 348 | .unwrap(); |
| 349 | let before = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 350 | let (parent, parent_node) = before |
| 351 | .nodes |
| 352 | .iter() |
| 353 | .find(|(_, node)| node.children.contains(&paragraph)) |
| 354 | .unwrap(); |
| 355 | let expected = characters(before, text); |
| 356 | let offsets: Vec<u32> = std::iter::once(0) |
| 357 | .chain(expected.iter().scan(0, |offset, (c, _)| { |
| 358 | *offset += u32::try_from(c.len_utf16()).unwrap(); |
| 359 | Some(*offset) |
| 360 | })) |
| 361 | .collect(); |
| 362 | for (position, offset) in offsets.iter().enumerate() { |
| 363 | let (op, new_paragraph, new_text) = split(SOURCE, *sid, text, *offset); |
| 364 | let edited = ops::edited(SOURCE, vec![op.clone()]); |
| 365 | // OneNote 2010 splits before or after a hyperlink and ignores Enter inside one. |
| 366 | let link = |at: usize| { |
| 367 | expected |
| 368 | .get(at) |
| 369 | .is_some_and(|(_, style)| style["hyperlink"] == true) |
| 370 | }; |
| 371 | if position > 0 |
| 372 | && link(position - 1) |
| 373 | && link(position) |
| 374 | && expected[position].0 != '\u{fddf}' |
| 375 | { |
| 376 | assert!(edited.is_err(), "{} at {offset}", case["case"]); |
| 377 | continue; |
| 378 | } |
| 379 | let edited = |
| 380 | edited.unwrap_or_else(|error| panic!("{} at {offset}: {error}", case["case"])); |
| 381 | let current_store = Store::parse(&edited).unwrap(); |
| 382 | assert_eq!( |
| 383 | current_store.header.transaction_count, |
| 384 | store.header.transaction_count + 1 |
| 385 | ); |
| 386 | let current_index = RevisionIndex::parse(&current_store).unwrap(); |
| 387 | current_index.validate_current().unwrap(); |
| 388 | let current_document = Document::parse(&current_index).unwrap(); |
| 389 | let current_space = &current_document.spaces[sid]; |
| 390 | let after = &current_space.revisions[&current_space.contexts[&ExGuid::default()]]; |
| 391 | let mut changed = std::collections::BTreeSet::from([text, before.roots[&2]]); |
| 392 | let mut pending = vec![paragraph]; |
| 393 | while let Some(id) = pending.pop() { |
| 394 | if !changed.insert(id) { |
| 395 | continue; |
| 396 | } |
| 397 | pending.extend(before.nodes.iter().filter_map(|(parent, node)| { |
| 398 | node.children |
| 399 | .iter() |
| 400 | .chain(&node.content) |
| 401 | .chain(&node.structure) |
| 402 | .any(|child| *child == id) |
| 403 | .then_some(*parent) |
| 404 | })); |
| 405 | } |
| 406 | let old_raw = index |
| 407 | .resolve(*sid, space.contexts[&ExGuid::default()]) |
| 408 | .unwrap(); |
| 409 | let new_raw = current_index |
| 410 | .resolve(*sid, current_space.contexts[&ExGuid::default()]) |
| 411 | .unwrap(); |
| 412 | for (id, object) in old_raw.objects { |
| 413 | if !changed.contains(&id) { |
| 414 | assert_eq!(object.data, new_raw.objects[&id].data, "untouched {id}"); |
| 415 | } |
| 416 | } |
| 417 | assert_eq!( |
| 418 | characters(after, text), |
| 419 | expected[..position], |
| 420 | "{} prefix at {offset}", |
| 421 | case["case"] |
| 422 | ); |
| 423 | assert_eq!( |
| 424 | characters(after, new_text), |
| 425 | expected[position..], |
| 426 | "{} suffix at {offset}", |
| 427 | case["case"] |
| 428 | ); |
| 429 | if case["offset_utf16"] == *offset { |
| 430 | let native = &native_document.spaces[sid]; |
| 431 | let native = &native.revisions[&native.contexts[&ExGuid::default()]]; |
| 432 | let native_text = serde_json::from_value(case["new_text"].clone()).unwrap(); |
| 433 | assert_eq!( |
| 434 | characters(after, text), |
| 435 | characters(native, text), |
| 436 | "{}", |
| 437 | case["case"] |
| 438 | ); |
| 439 | assert_eq!( |
| 440 | characters(after, new_text), |
| 441 | characters(native, native_text), |
| 442 | "{}", |
| 443 | case["case"] |
| 444 | ); |
| 445 | } |
| 446 | let mut children = parent_node.children.clone(); |
| 447 | children.insert( |
| 448 | children.iter().position(|id| *id == paragraph).unwrap() + 1, |
| 449 | new_paragraph, |
| 450 | ); |
| 451 | assert_eq!(after.nodes[parent].children, children); |
| 452 | assert_eq!(after.nodes[&paragraph].content, [text]); |
| 453 | assert!(after.nodes[&paragraph].children.is_empty()); |
| 454 | assert_eq!(after.nodes[&new_paragraph].content, [new_text]); |
| 455 | assert_eq!( |
| 456 | after.nodes[&new_paragraph].children, |
| 457 | before.nodes[&paragraph].children |
| 458 | ); |
| 459 | assert_eq!( |
| 460 | serde_json::to_value(&after.nodes[&text].tags).unwrap(), |
| 461 | serde_json::to_value(&before.nodes[&text].tags).unwrap() |
| 462 | ); |
| 463 | assert!(after.nodes[&new_text].tags.is_empty()); |
| 464 | let Kind::Paragraph { |
| 465 | lists: old_lists, .. |
| 466 | } = &before.nodes[&paragraph].kind |
| 467 | else { |
| 468 | panic!() |
| 469 | }; |
| 470 | let Kind::Paragraph { lists, .. } = &after.nodes[&new_paragraph].kind else { |
| 471 | panic!() |
| 472 | }; |
| 473 | assert_eq!(old_lists.len(), lists.len()); |
| 474 | for (old, new) in old_lists.iter().zip(lists) { |
| 475 | assert_ne!(old, new); |
| 476 | let mut old = serde_json::to_value(&before.nodes[old]).unwrap(); |
| 477 | old["kind"]["restart"] = Value::Null; |
| 478 | assert_eq!(serde_json::to_value(&after.nodes[new]).unwrap(), old); |
| 479 | } |
| 480 | for (space_id, old_space) in &index.spaces { |
| 481 | for revision in old_space.revisions.keys() { |
| 482 | let old = index.resolve(*space_id, *revision).unwrap(); |
| 483 | let retained = current_index.resolve(*space_id, *revision).unwrap(); |
| 484 | assert_eq!(old.roots, retained.roots); |
| 485 | for (id, object) in old.objects { |
| 486 | assert_eq!(object.data, retained.objects[&id].data); |
| 487 | } |
| 488 | } |
| 489 | } |
| 490 | assert!(ops::edited(&edited, vec![op]).is_err()); |
| 491 | } |
| 492 | for offset in 0..=*offsets.last().unwrap() + 1 { |
| 493 | if offsets.contains(&offset) { |
| 494 | continue; |
| 495 | } |
| 496 | assert!(ops::edited(SOURCE, vec![split(SOURCE, *sid, text, offset).0]).is_err()); |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | #[test] |
| 502 | fn invalid_split_identities_and_title_targets_are_rejected() { |
| 503 | let manifest: Value = |
| 504 | serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap(); |
| 505 | let text: ExGuid = |
| 506 | serde_json::from_value(manifest["cases"][0]["original_text"].clone()).unwrap(); |
| 507 | let store = Store::parse(SOURCE).unwrap(); |
| 508 | let index = RevisionIndex::parse(&store).unwrap(); |
| 509 | let document = Document::parse(&index).unwrap(); |
| 510 | let sid = *document |
| 511 | .spaces |
| 512 | .iter() |
| 513 | .find(|(_, space)| { |
| 514 | space.revisions[&space.contexts[&ExGuid::default()]] |
| 515 | .nodes |
| 516 | .contains_key(&text) |
| 517 | }) |
| 518 | .unwrap() |
| 519 | .0; |
| 520 | let (op, ..) = split(SOURCE, sid, text, 1); |
| 521 | assert!(ops::transaction(SOURCE, "a\0b", vec![op.clone()]).is_err()); |
| 522 | let Op::Page { |
| 523 | op: |
| 524 | PageOp::Split { |
| 525 | paragraph, |
| 526 | right, |
| 527 | lists, |
| 528 | .. |
| 529 | }, |
| 530 | .. |
| 531 | } = op |
| 532 | else { |
| 533 | unreachable!() |
| 534 | }; |
| 535 | for (text, at, paragraph) in [ |
| 536 | (ExGuid::default(), 1, paragraph), |
| 537 | (text, u32::MAX, paragraph), |
| 538 | (text, 1, ExGuid::default()), |
| 539 | (text, 1, text), |
| 540 | ] { |
| 541 | let op = PageOp::Split { |
| 542 | text, |
| 543 | at, |
| 544 | paragraph, |
| 545 | right, |
| 546 | lists: lists.clone(), |
| 547 | }; |
| 548 | assert!(ops::page_edited(SOURCE, sid, vec![op]).is_err()); |
| 549 | } |
| 550 | let mut titles = 0; |
| 551 | for (sid, _) in document.pages().unwrap() { |
| 552 | let space = &document.spaces[&sid]; |
| 553 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 554 | let mut pending: Vec<_> = view |
| 555 | .nodes |
| 556 | .iter() |
| 557 | .filter_map(|(id, n)| matches!(n.kind, Kind::Title).then_some(*id)) |
| 558 | .collect(); |
| 559 | let mut seen = std::collections::BTreeSet::new(); |
| 560 | while let Some(id) = pending.pop() { |
| 561 | if !seen.insert(id) { |
| 562 | continue; |
| 563 | } |
| 564 | let node = &view.nodes[&id]; |
| 565 | pending.extend(node.children.iter().chain(&node.content).copied()); |
| 566 | if matches!(node.kind, Kind::RichText { .. }) { |
| 567 | assert!(ops::edited(SOURCE, vec![split(SOURCE, sid, id, 0).0]).is_err()); |
| 568 | titles += 1; |
| 569 | } |
| 570 | } |
| 571 | } |
| 572 | assert!(titles >= 14); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | #[ignore = "exports paragraph splits for independent native validation"] |
| 577 | fn export_native_paragraph_splits() { |
| 578 | use std::{fs, path::PathBuf}; |
| 579 | let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_OUTPUT").unwrap()); |
| 580 | assert!(output.is_absolute()); |
| 581 | fs::create_dir(&output).unwrap(); |
| 582 | let manifest: Value = |
| 583 | serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap(); |
| 584 | let mut source = SOURCE.to_vec(); |
| 585 | let mut written = Vec::new(); |
| 586 | for case in manifest["cases"].as_array().unwrap() { |
| 587 | let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); |
| 588 | let store = Store::parse(&source).unwrap(); |
| 589 | let index = RevisionIndex::parse(&store).unwrap(); |
| 590 | let document = Document::parse(&index).unwrap(); |
| 591 | let sid = *document |
| 592 | .spaces |
| 593 | .iter() |
| 594 | .find(|(_, space)| { |
| 595 | space.revisions[&space.contexts[&ExGuid::default()]] |
| 596 | .nodes |
| 597 | .contains_key(&text) |
| 598 | }) |
| 599 | .unwrap() |
| 600 | .0; |
| 601 | let at = serde_json::from_value(case["offset_utf16"].clone()).unwrap(); |
| 602 | let (op, new_paragraph, new_text) = split(&source, sid, text, at); |
| 603 | let transaction = ops::transaction(&source, "Rust split author", vec![op.clone()]) |
| 604 | .unwrap() |
| 605 | .unwrap(); |
| 606 | written.push(serde_json::json!({"case": case["case"], "op": op, |
| 607 | "new_paragraph": new_paragraph, "new_text": new_text})); |
| 608 | transaction.apply(&mut source).unwrap(); |
| 609 | } |
| 610 | let candidate = output.join("candidate"); |
| 611 | fs::create_dir(&candidate).unwrap(); |
| 612 | fs::write(candidate.join("synthetic.one"), source).unwrap(); |
| 613 | fs::write( |
| 614 | output.join("manifest.json"), |
| 615 | serde_json::to_vec_pretty(&serde_json::json!({"cases": written})).unwrap(), |
| 616 | ) |
| 617 | .unwrap(); |
| 618 | } |
| 619 | |
| 620 | #[test] |
| 621 | fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() { |
| 622 | let original = onestore::create_section("split.one", "Original", "Author").unwrap(); |
| 623 | let store = Store::parse(&original).unwrap(); |
| 624 | let index = RevisionIndex::parse(&store).unwrap(); |
| 625 | let document = Document::parse(&index).unwrap(); |
| 626 | let (sid, page) = document.pages().unwrap()[0]; |
| 627 | let space = &document.spaces[&sid]; |
| 628 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 629 | let outline = view.nodes[&page] |
| 630 | .children |
| 631 | .iter() |
| 632 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) |
| 633 | .unwrap(); |
| 634 | let paragraph = view.nodes[outline].children[0]; |
| 635 | let mut child = ops::paragraph("a🦀b"); |
| 636 | child.level = 2; |
| 637 | let text = child.text().unwrap().id; |
| 638 | let inserted = ops::page_edited( |
| 639 | &original, |
| 640 | sid, |
| 641 | vec![ |
| 642 | PageOp::Insert { |
| 643 | container: paragraph, |
| 644 | before: None, |
| 645 | paragraphs: vec![child], |
| 646 | }, |
| 647 | PageOp::Format { |
| 648 | text, |
| 649 | range: 1..3, |
| 650 | set: vec![onestore::TextAttribute::Bold(true)], |
| 651 | clear: Vec::new(), |
| 652 | }, |
| 653 | ], |
| 654 | ) |
| 655 | .unwrap(); |
| 656 | let source = inserted.as_slice(); |
| 657 | let checkpoint = checkpoint::pending(source, sid, text); |
| 658 | for source in [source, &checkpoint] { |
| 659 | let (op, ..) = split(source, sid, text, 1); |
| 660 | let edit = ops::transaction(source, "Author", vec![op]) |
| 661 | .unwrap() |
| 662 | .unwrap(); |
| 663 | let mut written = source.to_vec(); |
| 664 | edit.apply(&mut written).unwrap(); |
| 665 | let before = current::current(source); |
| 666 | let after = current::current(&written); |
| 667 | for write_limit in [17, 4096] { |
| 668 | let disk = |fail_at| disk::Disk { |
| 669 | visible: source.to_vec(), |
| 670 | durable: source.to_vec(), |
| 671 | operation: 0, |
| 672 | fail_at, |
| 673 | write_limit, |
| 674 | random: 347, |
| 675 | }; |
| 676 | let mut successful = disk(None); |
| 677 | edit.commit(&mut successful).unwrap(); |
| 678 | assert_eq!(successful.durable, written); |
| 679 | for at in 1..=successful.operation { |
| 680 | let mut interrupted = disk(Some(at)); |
| 681 | let error = edit.commit(&mut interrupted).unwrap_err(); |
| 682 | let recovered = current::current(&interrupted.durable); |
| 683 | assert!( |
| 684 | recovered == before || recovered == after, |
| 685 | "interruption {at}" |
| 686 | ); |
| 687 | match error.state { |
| 688 | onestore::CommitState::NotCommitted => assert_eq!(recovered, before), |
| 689 | onestore::CommitState::Committed => assert_eq!(recovered, after), |
| 690 | onestore::CommitState::Unknown => {} |
| 691 | } |
| 692 | } |
| 693 | } |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | #[test] |
| 698 | #[ignore = "exports paragraph joins for independent native validation"] |
| 699 | fn export_native_paragraph_joins() { |
| 700 | use std::{fs, path::PathBuf}; |
| 701 | let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_JOIN_OUTPUT").unwrap()); |
| 702 | assert!(output.is_absolute()); |
| 703 | fs::create_dir(&output).unwrap(); |
| 704 | for (number, (source, _, split_cases)) in JOIN_FIXTURES.into_iter().enumerate() { |
| 705 | let store = Store::parse(source).unwrap(); |
| 706 | let index = RevisionIndex::parse(&store).unwrap(); |
| 707 | let document = Document::parse(&index).unwrap(); |
| 708 | let cases = join_targets(&document, split_cases); |
| 709 | let mut source = source.to_vec(); |
| 710 | let mut manifest = Vec::new(); |
| 711 | for (name, left, right) in cases { |
| 712 | let sid = *document |
| 713 | .spaces |
| 714 | .iter() |
| 715 | .find(|(_, space)| { |
| 716 | space.revisions[&space.contexts[&ExGuid::default()]] |
| 717 | .nodes |
| 718 | .contains_key(&left) |
| 719 | }) |
| 720 | .unwrap() |
| 721 | .0; |
| 722 | let op = Op::Page { |
| 723 | space: sid, |
| 724 | op: PageOp::Join { left, right }, |
| 725 | }; |
| 726 | let transaction = ops::transaction(&source, "Rust join author", vec![op.clone()]) |
| 727 | .unwrap() |
| 728 | .unwrap(); |
| 729 | transaction.apply(&mut source).unwrap(); |
| 730 | manifest.push(serde_json::json!({"case":name,"space":sid,"op":op})); |
| 731 | } |
| 732 | let folder = output.join(["split", "inheritance", "tags"][number]); |
| 733 | fs::create_dir_all(folder.join("candidate")).unwrap(); |
| 734 | fs::write(folder.join("candidate/synthetic.one"), source).unwrap(); |
| 735 | fs::write( |
| 736 | folder.join("manifest.json"), |
| 737 | serde_json::to_vec_pretty(&manifest).unwrap(), |
| 738 | ) |
| 739 | .unwrap(); |
| 740 | } |
| 741 | } |
| 742 | |
| 743 | #[test] |
| 744 | fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() { |
| 745 | let source = onestore::create_section("join.one", "Left", "Author").unwrap(); |
| 746 | let store = Store::parse(&source).unwrap(); |
| 747 | let index = RevisionIndex::parse(&store).unwrap(); |
| 748 | let document = Document::parse(&index).unwrap(); |
| 749 | let (sid, page) = document.pages().unwrap()[0]; |
| 750 | let space = &document.spaces[&sid]; |
| 751 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 752 | let outline = *view.nodes[&page] |
| 753 | .children |
| 754 | .iter() |
| 755 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) |
| 756 | .unwrap(); |
| 757 | let left = view.nodes[&view.nodes[&outline].children[0]].content[0]; |
| 758 | let right = ops::paragraph("Right 🦀"); |
| 759 | let right_text = right.text().unwrap().id; |
| 760 | let mut child = ops::paragraph("Retained child"); |
| 761 | child.parent = Some(right.id); |
| 762 | child.level = 2; |
| 763 | let original = ops::page_edited( |
| 764 | &source, |
| 765 | sid, |
| 766 | vec![ |
| 767 | PageOp::Insert { |
| 768 | container: outline, |
| 769 | before: None, |
| 770 | paragraphs: vec![right, child], |
| 771 | }, |
| 772 | PageOp::Format { |
| 773 | text: right_text, |
| 774 | range: 0..5, |
| 775 | set: vec![onestore::TextAttribute::Bold(true)], |
| 776 | clear: Vec::new(), |
| 777 | }, |
| 778 | ], |
| 779 | ) |
| 780 | .unwrap(); |
| 781 | let original = original.as_slice(); |
| 782 | let empty = ops::page_edited( |
| 783 | original, |
| 784 | sid, |
| 785 | vec![PageOp::Text { |
| 786 | text: left, |
| 787 | range: 0..4, |
| 788 | with: String::new(), |
| 789 | }], |
| 790 | ) |
| 791 | .unwrap(); |
| 792 | let checkpoint = checkpoint::pending(original, sid, left); |
| 793 | for source in [original, &empty, &checkpoint] { |
| 794 | let op = Op::Page { |
| 795 | space: sid, |
| 796 | op: PageOp::Join { |
| 797 | left, |
| 798 | right: right_text, |
| 799 | }, |
| 800 | }; |
| 801 | let edit = ops::transaction(source, "Join author", vec![op]) |
| 802 | .unwrap() |
| 803 | .unwrap(); |
| 804 | let mut written = source.to_vec(); |
| 805 | edit.apply(&mut written).unwrap(); |
| 806 | let before = current::current(source); |
| 807 | let after = current::current(&written); |
| 808 | for write_limit in [17, 4096] { |
| 809 | let disk = |fail_at| disk::Disk { |
| 810 | visible: source.to_vec(), |
| 811 | durable: source.to_vec(), |
| 812 | operation: 0, |
| 813 | fail_at, |
| 814 | write_limit, |
| 815 | random: 917, |
| 816 | }; |
| 817 | let mut successful = disk(None); |
| 818 | edit.commit(&mut successful).unwrap(); |
| 819 | assert_eq!(successful.durable, written); |
| 820 | for at in 1..=successful.operation { |
| 821 | let mut interrupted = disk(Some(at)); |
| 822 | let error = edit.commit(&mut interrupted).unwrap_err(); |
| 823 | let recovered = current::current(&interrupted.durable); |
| 824 | assert!( |
| 825 | recovered == before || recovered == after, |
| 826 | "interruption {at}" |
| 827 | ); |
| 828 | match error.state { |
| 829 | onestore::CommitState::NotCommitted => assert_eq!(recovered, before), |
| 830 | onestore::CommitState::Committed => assert_eq!(recovered, after), |
| 831 | onestore::CommitState::Unknown => {} |
| 832 | } |
| 833 | } |
| 834 | } |
| 835 | } |
| 836 | } |
| 837 | |
| 838 | #[test] |
| 839 | fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() { |
| 840 | let (source, _, _) = JOIN_FIXTURES[0]; |
| 841 | let store = Store::parse(source).unwrap(); |
| 842 | let index = RevisionIndex::parse(&store).unwrap(); |
| 843 | let document = Document::parse(&index).unwrap(); |
| 844 | let cases = join_targets(&document, true); |
| 845 | let (_, left, right) = &cases[0]; |
| 846 | let (sid, space) = document |
| 847 | .spaces |
| 848 | .iter() |
| 849 | .find(|(_, space)| { |
| 850 | space.revisions[&space.contexts[&ExGuid::default()]] |
| 851 | .nodes |
| 852 | .contains_key(left) |
| 853 | }) |
| 854 | .unwrap(); |
| 855 | for (a, b) in [ |
| 856 | (*left, *left), |
| 857 | (ExGuid::default(), *right), |
| 858 | (*right, *left), |
| 859 | (*left, cases[1].2), |
| 860 | ] { |
| 861 | assert!(join(source, *sid, a, b).is_err()); |
| 862 | } |
| 863 | let op = Op::Page { |
| 864 | space: *sid, |
| 865 | op: PageOp::Join { |
| 866 | left: *left, |
| 867 | right: *right, |
| 868 | }, |
| 869 | }; |
| 870 | assert!(ops::transaction(source, "a\0b", vec![op]).is_err()); |
| 871 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 872 | let paragraph = *view |
| 873 | .nodes |
| 874 | .iter() |
| 875 | .find(|(_, node)| node.content == [*left]) |
| 876 | .unwrap() |
| 877 | .0; |
| 878 | let parent = view |
| 879 | .nodes |
| 880 | .values() |
| 881 | .find(|node| node.children.contains(&paragraph)) |
| 882 | .unwrap(); |
| 883 | let last = view.nodes[parent.children.last().unwrap()].content[0]; |
| 884 | assert!(join(source, *sid, *left, last).is_err()); |
| 885 | let title = view |
| 886 | .nodes |
| 887 | .iter() |
| 888 | .find_map(|(_, node)| matches!(node.kind, Kind::Title).then_some(node)) |
| 889 | .unwrap(); |
| 890 | let mut pending = title.children.clone(); |
| 891 | let mut rejected = 0; |
| 892 | while let Some(id) = pending.pop() { |
| 893 | let node = &view.nodes[&id]; |
| 894 | pending.extend(node.children.iter().chain(&node.content).copied()); |
| 895 | if matches!(node.kind, Kind::RichText { .. }) { |
| 896 | assert!(join(source, *sid, id, *right).is_err()); |
| 897 | rejected += 1; |
| 898 | } |
| 899 | } |
| 900 | assert!(rejected > 0); |
| 901 | } |