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