| 1 | //! Page batch reconciliation: atomic batches with dependent body saves, competing moves and |
| 2 | //! indentation (a page the remote moved keeps the remote's placement), uncertain batches, and |
| 3 | //! native page movement fixtures. |
| 4 | |
| 5 | #[path = "../../onestore/tests/support/ops.rs"] |
| 6 | mod ops; |
| 7 | |
| 8 | use notebook::{EditStatus, Recovery, Replica}; |
| 9 | use onestore::op::{Op, SectionOp}; |
| 10 | use onestore::{ |
| 11 | ExGuid, PageEdit, PagePosition, RevisionIndex, Store, |
| 12 | document::{Document, Format, Kind, Layout}, |
| 13 | page::{ |
| 14 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, |
| 15 | text::new_id, |
| 16 | }, |
| 17 | }; |
| 18 | use std::collections::BTreeMap; |
| 19 | |
| 20 | #[path = "support/server.rs"] |
| 21 | mod server; |
| 22 | use server::*; |
| 23 | #[path = "support/model_ops.rs"] |
| 24 | mod model_ops; |
| 25 | |
| 26 | const SOURCE: &[u8] = |
| 27 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); |
| 28 | |
| 29 | fn order(source: &[u8]) -> Vec<(ExGuid, u32)> { |
| 30 | let store = Store::parse(source).unwrap(); |
| 31 | let index = RevisionIndex::parse(&store).unwrap(); |
| 32 | index.validate_current().unwrap(); |
| 33 | let document = Document::parse(&index).unwrap(); |
| 34 | document |
| 35 | .pages() |
| 36 | .unwrap() |
| 37 | .iter() |
| 38 | .map(|(sid, _)| { |
| 39 | let space = &document.spaces[sid]; |
| 40 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 41 | let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { |
| 42 | panic!() |
| 43 | }; |
| 44 | (*sid, level.unwrap_or(1)) |
| 45 | }) |
| 46 | .collect() |
| 47 | } |
| 48 | |
| 49 | fn texts(source: &[u8]) -> BTreeMap<(ExGuid, ExGuid), String> { |
| 50 | let store = Store::parse(source).unwrap(); |
| 51 | let index = RevisionIndex::parse(&store).unwrap(); |
| 52 | let document = Document::parse(&index).unwrap(); |
| 53 | let mut texts = BTreeMap::new(); |
| 54 | for (sid, space) in &document.spaces { |
| 55 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 56 | for (oid, node) in &view.nodes { |
| 57 | if let Kind::RichText { text, .. } = &node.kind { |
| 58 | texts.insert((*sid, *oid), text.clone()); |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | texts |
| 63 | } |
| 64 | |
| 65 | /// Adds a body outline holding one plain paragraph, returning its text identity. A page the |
| 66 | /// section just created has no body text for `model_ops::insert_outline` to copy formatting from. |
| 67 | fn body_outline(page: &mut Page, text: &str) -> ExGuid { |
| 68 | // The writer creates outline text with the store's default style; the model must state it. |
| 69 | let format = Format { |
| 70 | font: Some("Calibri".to_owned()), |
| 71 | font_size: Some(11.0), |
| 72 | language: Some(0x409), |
| 73 | ..Default::default() |
| 74 | }; |
| 75 | let content = TextObject { |
| 76 | id: new_id().unwrap(), |
| 77 | date_field: None, |
| 78 | text: Paragraph::new(text.into(), format), |
| 79 | tags: Vec::new(), |
| 80 | }; |
| 81 | let id = content.id; |
| 82 | let outline = Outline { |
| 83 | id: new_id().unwrap(), |
| 84 | title: false, |
| 85 | min_width: None, |
| 86 | layout: Layout { |
| 87 | x: Some(36.0), |
| 88 | y: Some(36.0), |
| 89 | ..Default::default() |
| 90 | }, |
| 91 | indents: Vec::new(), |
| 92 | paragraphs: vec![PageParagraph { |
| 93 | id: new_id().unwrap(), |
| 94 | parent: None, |
| 95 | level: 1, |
| 96 | style: None, |
| 97 | format: Default::default(), |
| 98 | content: ParagraphContent::Text(content), |
| 99 | lists: Vec::new(), |
| 100 | tags: Vec::new(), |
| 101 | media: Default::default(), |
| 102 | collapsed: false, |
| 103 | }], |
| 104 | unsupported: Vec::new(), |
| 105 | }; |
| 106 | let at = page |
| 107 | .objects |
| 108 | .iter() |
| 109 | .position(|object| matches!(object, PageObject::Title(_))) |
| 110 | .unwrap_or(page.objects.len()); |
| 111 | page.objects.insert(at, PageObject::Outline(outline)); |
| 112 | id |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn atomic_page_batches_survive_reopen_with_dependent_text() { |
| 117 | let directory = tempfile::tempdir().unwrap(); |
| 118 | let path = directory.path().join("pages.sqlite"); |
| 119 | let cache = Replica::create(&path, SOURCE).unwrap(); |
| 120 | let before = order(SOURCE); |
| 121 | let edits: Vec<_> = before[3..6] |
| 122 | .iter() |
| 123 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) |
| 124 | .collect(); |
| 125 | let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 126 | let mut expected_text = texts(SOURCE); |
| 127 | let (&(sid, oid), original) = expected_text |
| 128 | .iter() |
| 129 | .find(|(_, text)| text.starts_with("Body parent")) |
| 130 | .unwrap(); |
| 131 | let changed = format!("Offline {original}"); |
| 132 | expected_text.insert((sid, oid), changed); |
| 133 | model_ops::save(&cache, oid, |page| { |
| 134 | model_ops::replace_text(page, oid, 0..0, "Offline ") |
| 135 | }) |
| 136 | .unwrap() |
| 137 | .unwrap(); |
| 138 | let queue = cache.pending().unwrap(); |
| 139 | assert!( |
| 140 | matches!(&queue[0].edit.ops[..], [Op::Section(SectionOp::Pages(batch))] if *batch == edits) |
| 141 | ); |
| 142 | let local = snapshot(&cache); |
| 143 | drop(cache); |
| 144 | let cache = Replica::open(&path).unwrap(); |
| 145 | assert_eq!(cache.pending().unwrap(), queue); |
| 146 | assert_eq!(server::pages(&snapshot(&cache)), server::pages(&local)); |
| 147 | let mut server = Server::new(SOURCE); |
| 148 | assert!(matches!( |
| 149 | cache.sync_once(&mut server).unwrap().edit, |
| 150 | Some((_, EditStatus::Published { .. })) |
| 151 | )); |
| 152 | let expected = [ |
| 153 | before[..3].to_vec(), |
| 154 | before[6..].to_vec(), |
| 155 | before[3..6].to_vec(), |
| 156 | ] |
| 157 | .concat(); |
| 158 | assert_eq!(order(&server.durable), expected); |
| 159 | assert_eq!(texts(&server.durable), expected_text); |
| 160 | assert!(matches!( |
| 161 | cache.status(id).unwrap(), |
| 162 | Some(EditStatus::Published { .. }) |
| 163 | )); |
| 164 | assert_eq!(server.publications, 1); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn indenting_a_page_the_remote_moved_keeps_the_remote_placement() { |
| 169 | let directory = tempfile::tempdir().unwrap(); |
| 170 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 171 | let before = order(SOURCE); |
| 172 | let sid = before[4].0; |
| 173 | let id = section_op( |
| 174 | &cache, |
| 175 | SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()), |
| 176 | ); |
| 177 | let mut server = Server::new(SOURCE); |
| 178 | ops::section_op( |
| 179 | SOURCE, |
| 180 | SectionOp::Pages([PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()].to_vec()), |
| 181 | ) |
| 182 | .unwrap() |
| 183 | .commit(&mut server) |
| 184 | .unwrap(); |
| 185 | let expected = order(&server.durable); |
| 186 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 187 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 188 | assert_eq!(order(&server.durable), expected); |
| 189 | } |
| 190 | |
| 191 | /// A page both sides moved stays where the remote put it, as OneNote 2010 keeps it |
| 192 | /// (corpus/conflict-page/native-pages). |
| 193 | #[test] |
| 194 | fn competing_page_moves_keep_the_remote_placement() { |
| 195 | let directory = tempfile::tempdir().unwrap(); |
| 196 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 197 | let pages = order(SOURCE); |
| 198 | let sid = pages[6].0; |
| 199 | let id = section_op( |
| 200 | &cache, |
| 201 | SectionOp::Pages(vec![PageEdit::move_to(sid, None, 1).unwrap()]), |
| 202 | ); |
| 203 | let mut server = Server::new(SOURCE); |
| 204 | ops::section_op( |
| 205 | SOURCE, |
| 206 | SectionOp::Pages([PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()].to_vec()), |
| 207 | ) |
| 208 | .unwrap() |
| 209 | .commit(&mut server) |
| 210 | .unwrap(); |
| 211 | let expected = order(&server.visible); |
| 212 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 213 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 214 | assert_eq!(order(&server.durable), expected); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn a_competing_level_keeps_the_remote_level_and_the_rest_of_the_batch() { |
| 219 | let directory = tempfile::tempdir().unwrap(); |
| 220 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 221 | let pages = order(SOURCE); |
| 222 | let edits = vec![ |
| 223 | PageEdit::set_level(pages[4].0, 3).unwrap(), |
| 224 | PageEdit::set_level(pages[5].0, 2).unwrap(), |
| 225 | ]; |
| 226 | let id = section_op(&cache, SectionOp::Pages(edits.clone())); |
| 227 | let mut server = Server::new(SOURCE); |
| 228 | ops::section_op( |
| 229 | SOURCE, |
| 230 | SectionOp::Pages([PageEdit::set_level(pages[5].0, 1).unwrap()].to_vec()), |
| 231 | ) |
| 232 | .unwrap() |
| 233 | .commit(&mut server) |
| 234 | .unwrap(); |
| 235 | let expected = ops::section_op( |
| 236 | &server.durable.clone(), |
| 237 | SectionOp::Pages(edits[..1].to_vec()), |
| 238 | ) |
| 239 | .unwrap(); |
| 240 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 241 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 242 | assert_eq!(order(&server.durable), order(expected.as_bytes())); |
| 243 | } |
| 244 | |
| 245 | #[test] |
| 246 | fn uncertain_page_batches_survive_recovery_and_do_not_replay() { |
| 247 | for fault in [ |
| 248 | Fault::UnknownBefore, |
| 249 | Fault::UnknownAfter, |
| 250 | Fault::PanicBefore, |
| 251 | Fault::PanicAfter, |
| 252 | ] { |
| 253 | let directory = tempfile::tempdir().unwrap(); |
| 254 | let path = directory.path().join("pages.sqlite"); |
| 255 | let mut cache = Replica::create(&path, SOURCE).unwrap(); |
| 256 | let pages = order(SOURCE); |
| 257 | let edits = [ |
| 258 | PageEdit::set_level(pages[4].0, 3).unwrap(), |
| 259 | PageEdit::set_level(pages[5].0, 2).unwrap(), |
| 260 | ]; |
| 261 | let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 262 | let local = snapshot(&cache); |
| 263 | let queue = cache.pending().unwrap(); |
| 264 | let mut server = Server::new(SOURCE); |
| 265 | server.fault = fault; |
| 266 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| 267 | cache.sync_once(&mut server) |
| 268 | })); |
| 269 | drop(cache); |
| 270 | cache = Replica::open(&path).unwrap(); |
| 271 | let attempted = cache.status(id).unwrap().unwrap(); |
| 272 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); |
| 273 | let archive = directory.path().join("recovery.sqlite"); |
| 274 | cache.export_recovery(&archive).unwrap(); |
| 275 | let recovery = Recovery::open(&archive).unwrap(); |
| 276 | assert_eq!(recovery.pending().unwrap(), queue); |
| 277 | assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone())); |
| 278 | assert_eq!( |
| 279 | server::pages(&recovery.snapshot().unwrap()), |
| 280 | server::pages(&local) |
| 281 | ); |
| 282 | let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 283 | assert_eq!(server.publications, 1); |
| 284 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { |
| 285 | assert!(matches!(result.1, EditStatus::Published { .. })); |
| 286 | assert_eq!(server.confirmations, 1); |
| 287 | } else { |
| 288 | assert_eq!(result, (id, attempted.clone())); |
| 289 | assert_eq!(server.confirmations, 0); |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | #[test] |
| 295 | fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { |
| 296 | let before = order(SOURCE); |
| 297 | let sid = before[4].0; |
| 298 | let store = Store::parse(SOURCE).unwrap(); |
| 299 | let index = RevisionIndex::parse(&store).unwrap(); |
| 300 | let root = index.root; |
| 301 | let doc = Document::parse(&index).unwrap(); |
| 302 | let space = &doc.spaces[&root]; |
| 303 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 304 | let copy = *view.nodes.iter().find(|(_, node)| |
| 305 | matches!(&node.kind, Kind::Metadata { title: Some(title), .. } if title == "child")) |
| 306 | .unwrap().0; |
| 307 | // The section's copy of that page's metadata says level 3, stored where it lies. |
| 308 | let revision = index.resolve_active(root).unwrap(); |
| 309 | let onestore::ObjectData::Properties(data) = revision.objects[&copy].data else { |
| 310 | panic!() |
| 311 | }; |
| 312 | let properties = onestore::PropertySets::parse(data).unwrap(); |
| 313 | let onestore::Value::Bytes(level) = properties.sets[0] |
| 314 | .iter() |
| 315 | .find(|property| property.id == 0x14001dff) |
| 316 | .unwrap() |
| 317 | .value |
| 318 | else { |
| 319 | panic!() |
| 320 | }; |
| 321 | let at = level.as_ptr().addr() - SOURCE.as_ptr().addr(); |
| 322 | let mut source = SOURCE.to_vec(); |
| 323 | source[at..at + 4].copy_from_slice(&3_u32.to_le_bytes()); |
| 324 | let directory = tempfile::tempdir().unwrap(); |
| 325 | let path = directory.path().join("pages.sqlite"); |
| 326 | let cache = Replica::create(&path, &source).unwrap(); |
| 327 | let id = section_op( |
| 328 | &cache, |
| 329 | SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()), |
| 330 | ); |
| 331 | let mut server = Server::new(&source); |
| 332 | server.fault = Fault::UnknownAfter; |
| 333 | assert!(cache.sync_once(&mut server).is_err()); |
| 334 | let attempted = cache.status(id).unwrap().unwrap(); |
| 335 | drop(cache); |
| 336 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 337 | let encoded: String = db |
| 338 | .query_row("SELECT revisions FROM batches WHERE attempted=1", [], |r| { |
| 339 | r.get(0) |
| 340 | }) |
| 341 | .unwrap(); |
| 342 | let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); |
| 343 | assert_eq!(proofs.len(), 2); |
| 344 | let store = Store::parse(&source).unwrap(); |
| 345 | let index = RevisionIndex::parse(&store).unwrap(); |
| 346 | assert_eq!( |
| 347 | proofs[&root], |
| 348 | index.spaces[&root].labels[&(ExGuid::default(), 1)] |
| 349 | ); |
| 350 | assert!(!index.spaces[&sid].revisions.contains_key(&proofs[&sid])); |
| 351 | drop(db); |
| 352 | let cache = Replica::open(&path).unwrap(); |
| 353 | let complete = server.visible.clone(); |
| 354 | server.visible = source; |
| 355 | assert_eq!( |
| 356 | cache.sync_once(&mut server).unwrap().edit, |
| 357 | Some((id, attempted.clone())) |
| 358 | ); |
| 359 | assert_eq!(server.confirmations, 0); |
| 360 | server.visible = complete; |
| 361 | assert!(matches!( |
| 362 | cache.sync_once(&mut server).unwrap().edit, |
| 363 | Some((_, EditStatus::Published { .. })) |
| 364 | )); |
| 365 | assert_eq!(server.publications, 1); |
| 366 | assert_eq!(server.confirmations, 1); |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn an_explicit_anchor_yields_to_a_remote_move() { |
| 371 | let directory = tempfile::tempdir().unwrap(); |
| 372 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 373 | let pages = order(SOURCE); |
| 374 | let id = section_op( |
| 375 | &cache, |
| 376 | SectionOp::Pages(vec![ |
| 377 | PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(), |
| 378 | PageEdit::set_level(pages[4].0, 3).unwrap(), |
| 379 | ]), |
| 380 | ); |
| 381 | let mut server = Server::new(SOURCE); |
| 382 | ops::section_op( |
| 383 | SOURCE, |
| 384 | SectionOp::Pages([PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()].to_vec()), |
| 385 | ) |
| 386 | .unwrap() |
| 387 | .commit(&mut server) |
| 388 | .unwrap(); |
| 389 | let expected = ops::section_op( |
| 390 | &server.durable.clone(), |
| 391 | SectionOp::Pages(vec![PageEdit::set_level(pages[4].0, 3).unwrap()]), |
| 392 | ) |
| 393 | .unwrap(); |
| 394 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 395 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 396 | assert_eq!(order(&server.durable), order(expected.as_bytes())); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn convergent_page_indentation_requires_confirmation_without_republishing() { |
| 401 | let directory = tempfile::tempdir().unwrap(); |
| 402 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 403 | let sid = order(SOURCE)[4].0; |
| 404 | let id = section_op( |
| 405 | &cache, |
| 406 | SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()), |
| 407 | ); |
| 408 | let mut server = Server::new(SOURCE); |
| 409 | ops::section_op( |
| 410 | SOURCE, |
| 411 | SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()), |
| 412 | ) |
| 413 | .unwrap() |
| 414 | .commit(&mut server) |
| 415 | .unwrap(); |
| 416 | server.fault = Fault::Confirm; |
| 417 | assert!(cache.sync_once(&mut server).is_err()); |
| 418 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 419 | assert_eq!(server.publications, 0); |
| 420 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 421 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 422 | assert_eq!(server.publications, 0); |
| 423 | assert_eq!(server.confirmations, 2); |
| 424 | } |
| 425 | #[test] |
| 426 | fn native_page_changes_reconcile_with_atomic_offline_batches() { |
| 427 | let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 428 | .join("../../corpus/page-lifecycle/movement"); |
| 429 | let provenance: serde_json::Value = |
| 430 | serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap(); |
| 431 | let phases = provenance["cold"].as_object().unwrap(); |
| 432 | assert_eq!(phases.len(), 9); |
| 433 | let original = order(SOURCE); |
| 434 | let original_text = texts(SOURCE); |
| 435 | let (&(text_space, text_object), body) = original_text |
| 436 | .iter() |
| 437 | .find(|(_, text)| text.starts_with("Body parent")) |
| 438 | .unwrap(); |
| 439 | let mut expected_text = original_text.clone(); |
| 440 | expected_text.insert((text_space, text_object), format!("Offline {body}")); |
| 441 | let mut counts = [0; 2]; |
| 442 | for mode in ["indent", "group"] { |
| 443 | for phase in phases.keys() { |
| 444 | let directory = tempfile::tempdir().unwrap(); |
| 445 | let path = directory.path().join("pages.sqlite"); |
| 446 | let cache = Replica::create(&path, SOURCE).unwrap(); |
| 447 | let edits = if mode == "indent" { |
| 448 | vec![PageEdit::set_level(original[4].0, 3).unwrap()] |
| 449 | } else { |
| 450 | original[3..6] |
| 451 | .iter() |
| 452 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) |
| 453 | .collect() |
| 454 | }; |
| 455 | let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 456 | let text_id = model_ops::save(&cache, text_object, |page| { |
| 457 | model_ops::replace_text(page, text_object, 0..0, "Offline ") |
| 458 | }) |
| 459 | .unwrap() |
| 460 | .unwrap(); |
| 461 | let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap(); |
| 462 | let mut server = Server::new(&native); |
| 463 | // The page list takes the edits of the pages OneNote left in their series, at |
| 464 | // their level and after the same page. |
| 465 | let both: Vec<ExGuid> = order(&native) |
| 466 | .into_iter() |
| 467 | .map(|(sid, _)| sid) |
| 468 | .filter(|sid| original.iter().any(|(s, _)| s == sid)) |
| 469 | .collect(); |
| 470 | let placement = |image: &[u8]| -> BTreeMap<ExGuid, (ExGuid, Option<ExGuid>, u32)> { |
| 471 | let arena = onestore::Arena::default(); |
| 472 | let series = onestore::Section::open(&arena, image.to_vec()) |
| 473 | .unwrap() |
| 474 | .series() |
| 475 | .unwrap(); |
| 476 | let shared: Vec<_> = order(image) |
| 477 | .into_iter() |
| 478 | .filter(|(sid, _)| both.contains(sid)) |
| 479 | .collect(); |
| 480 | (0..shared.len()) |
| 481 | .map(|at| { |
| 482 | let (sid, level) = shared[at]; |
| 483 | ( |
| 484 | sid, |
| 485 | (series[&sid], at.checked_sub(1).map(|b| shared[b].0), level), |
| 486 | ) |
| 487 | }) |
| 488 | .collect() |
| 489 | }; |
| 490 | let (before, after) = (placement(SOURCE), placement(&native)); |
| 491 | let reviewed: Vec<PageEdit> = edits |
| 492 | .iter() |
| 493 | .filter(|edit| after.get(&edit.space()) == before.get(&edit.space())) |
| 494 | .cloned() |
| 495 | .collect(); |
| 496 | let trimmed = reviewed != edits; |
| 497 | assert!( |
| 498 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == text_id), |
| 499 | "{mode}:{phase}" |
| 500 | ); |
| 501 | counts[usize::from(trimmed)] += 1; |
| 502 | drop(cache); |
| 503 | let cache = Replica::open(&path).unwrap(); |
| 504 | assert!(cache.pending().unwrap().is_empty()); |
| 505 | let mut expected = order(&native); |
| 506 | for edit in &reviewed { |
| 507 | let at = expected.iter().position(|p| p.0 == edit.space()).unwrap(); |
| 508 | expected[at].1 = edit.level(); |
| 509 | if let PagePosition::Before(before) = edit.position() { |
| 510 | let page = expected.remove(at); |
| 511 | let at = before.map_or(expected.len(), |sid| { |
| 512 | expected.iter().position(|p| p.0 == sid).unwrap() |
| 513 | }); |
| 514 | expected.insert(at, page); |
| 515 | } |
| 516 | } |
| 517 | assert_eq!(order(&server.durable), expected, "{mode}:{phase}"); |
| 518 | assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}"); |
| 519 | assert_eq!(server.publications, 1); |
| 520 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") { |
| 521 | let output = std::path::Path::new(&output); |
| 522 | assert!(output.is_absolute()); |
| 523 | let path = output.join(format!("{mode}-{phase}")); |
| 524 | std::fs::create_dir_all(&path).unwrap(); |
| 525 | std::fs::write(path.join("Lifecycle.one"), &server.durable).unwrap(); |
| 526 | std::fs::write( |
| 527 | path.join("manifest.json"), |
| 528 | serde_json::to_vec_pretty( |
| 529 | &serde_json::json!({"mode": mode, "phase": phase, "original": edits, |
| 530 | "reviewed": reviewed, "conflict": trimmed, |
| 531 | "publications": server.publications, "confirmations": server.confirmations, |
| 532 | "page_receipt": format!("{:?}", cache.status(id).unwrap()), |
| 533 | "text_receipt": format!("{:?}", cache.status(text_id).unwrap())}), |
| 534 | ) |
| 535 | .unwrap(), |
| 536 | ) |
| 537 | .unwrap(); |
| 538 | } |
| 539 | } |
| 540 | } |
| 541 | assert_eq!(counts.iter().sum::<usize>(), 18); |
| 542 | } |
| 543 | |
| 544 | #[test] |
| 545 | fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { |
| 546 | let mut source = onestore::create_section("pages.one", "Sentinel", "Author").unwrap(); |
| 547 | let mut created = Vec::new(); |
| 548 | for _ in 0..36 { |
| 549 | let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); |
| 550 | source = ops::section_op(&source, SectionOp::Create(page.clone())) |
| 551 | .unwrap() |
| 552 | .as_bytes() |
| 553 | .to_vec(); |
| 554 | created.push(page); |
| 555 | } |
| 556 | let mut expected = vec![order(&source)[0]]; |
| 557 | let mut expected_text = texts(&source); |
| 558 | let directory = tempfile::tempdir().unwrap(); |
| 559 | let mut queues = Vec::new(); |
| 560 | for (actor, group) in created.chunks_exact(3).enumerate() { |
| 561 | let path = directory.path().join(format!("{actor}.sqlite")); |
| 562 | let cache = Replica::create(&path, &source).unwrap(); |
| 563 | let edits = [ |
| 564 | PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(), |
| 565 | PageEdit::set_level(group[2].space(), 2).unwrap(), |
| 566 | ]; |
| 567 | section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 568 | let body = format!("Actor {actor} 🦀 e\u{301}"); |
| 569 | let local = snapshot(&cache); |
| 570 | let mut model = model_ops::page_of(&local, group[1].space()); |
| 571 | let text = body_outline(&mut model, &body); |
| 572 | model_ops::save_as(&cache, group[1].space(), &model, "Author") |
| 573 | .unwrap() |
| 574 | .unwrap(); |
| 575 | expected.extend([ |
| 576 | (group[1].space(), 1), |
| 577 | (group[0].space(), 1), |
| 578 | (group[2].space(), 2), |
| 579 | ]); |
| 580 | expected_text.insert((group[1].space(), text), body); |
| 581 | queues.push((path, cache.pending().unwrap())); |
| 582 | } |
| 583 | let mut server = Server::new(&source); |
| 584 | for (path, queue) in &queues { |
| 585 | let cache = Replica::open(path).unwrap(); |
| 586 | assert_eq!(cache.pending().unwrap(), *queue); |
| 587 | assert!(matches!( |
| 588 | cache.sync_once(&mut server).unwrap().edit, |
| 589 | Some((_, EditStatus::Published { .. })) |
| 590 | )); |
| 591 | assert!(cache.pending().unwrap().is_empty()); |
| 592 | } |
| 593 | assert_eq!(server.publications, 12); |
| 594 | assert_eq!(order(&server.durable), expected); |
| 595 | assert_eq!(texts(&server.durable), expected_text); |
| 596 | for (path, queue) in &queues { |
| 597 | let cache = Replica::open(path).unwrap(); |
| 598 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 599 | assert_eq!(order(&snapshot(&cache)), expected); |
| 600 | for edit in queue { |
| 601 | assert!(matches!( |
| 602 | cache.status(edit.id).unwrap(), |
| 603 | Some(EditStatus::Published { .. }) |
| 604 | )); |
| 605 | } |
| 606 | } |
| 607 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_CLIENT_OUTPUT") { |
| 608 | let output = std::path::Path::new(&output); |
| 609 | assert!(output.is_absolute()); |
| 610 | std::fs::create_dir_all(output).unwrap(); |
| 611 | std::fs::write(output.join("pages.one"), &server.durable).unwrap(); |
| 612 | let operations: Vec<_> = queues |
| 613 | .iter() |
| 614 | .map(|(_, queue)| { |
| 615 | queue |
| 616 | .iter() |
| 617 | .map(|edit| serde_json::json!({"id": edit.id, "edit": edit.edit})) |
| 618 | .collect::<Vec<_>>() |
| 619 | }) |
| 620 | .collect(); |
| 621 | std::fs::write( |
| 622 | output.join("manifest.json"), |
| 623 | serde_json::to_vec_pretty( |
| 624 | &serde_json::json!({"operations": operations, "publications": server.publications, |
| 625 | "expected_order": expected, "pages": 37}), |
| 626 | ) |
| 627 | .unwrap(), |
| 628 | ) |
| 629 | .unwrap(); |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | /// Two OneNote 2010 clients' page merges (`corpus/conflict-page/native-*`), replayed: client |
| 634 | /// A, offline, `moves` pages (each last, or before another) and edits `Target`'s body while |
| 635 | /// client B publishes `b-published`. The pages the merge lists after the first, and |
| 636 | /// `Target`'s space and texts. |
| 637 | fn native_page_merge( |
| 638 | capture: &str, |
| 639 | moves: &[(&str, Option<&str>)], |
| 640 | ) -> (Vec<String>, ExGuid, Vec<String>) { |
| 641 | let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 642 | .join("../../corpus/conflict-page") |
| 643 | .join(capture); |
| 644 | let read = |
| 645 | |phase: &str| std::fs::read(root.join(phase).join("notebook/synthetic.one")).unwrap(); |
| 646 | let pages = |image: &[u8]| { |
| 647 | let arena = onestore::Arena::default(); |
| 648 | onestore::Section::open(&arena, image.to_vec()) |
| 649 | .unwrap() |
| 650 | .pages() |
| 651 | .unwrap() |
| 652 | }; |
| 653 | let initial = read("initial"); |
| 654 | let listed = pages(&initial); |
| 655 | let space = |title: &str| listed.iter().find(|page| page.1 == title).unwrap().0; |
| 656 | let directory = tempfile::tempdir().unwrap(); |
| 657 | let cache = Replica::create(directory.path().join("pages.sqlite"), &initial).unwrap(); |
| 658 | for (page, before) in moves { |
| 659 | section_op( |
| 660 | &cache, |
| 661 | SectionOp::Pages(vec![ |
| 662 | PageEdit::move_to(space(page), before.map(space), 1).unwrap(), |
| 663 | ]), |
| 664 | ); |
| 665 | } |
| 666 | let body = texts(&initial) |
| 667 | .into_iter() |
| 668 | .find(|(_, text)| text == "Body Target.") |
| 669 | .unwrap() |
| 670 | .0 |
| 671 | .1; |
| 672 | model_ops::save(&cache, body, |page| { |
| 673 | model_ops::replace_text(page, body, 0..12, "Body Target edited offline.") |
| 674 | }) |
| 675 | .unwrap() |
| 676 | .unwrap(); |
| 677 | let mut server = Server::new(&read("b-published")); |
| 678 | for _ in 0..moves.len() + 2 { |
| 679 | cache.sync_once(&mut server).unwrap(); |
| 680 | } |
| 681 | assert!(cache.pending().unwrap().is_empty(), "{capture}"); |
| 682 | let merged = pages(&server.durable); |
| 683 | let target = merged.iter().find(|page| page.1 == "Target").unwrap().0; |
| 684 | let texts = texts(&server.durable) |
| 685 | .into_iter() |
| 686 | .filter(|((page, _), _)| *page == target) |
| 687 | .map(|(_, text)| text) |
| 688 | .collect(); |
| 689 | ( |
| 690 | merged[1..].iter().map(|page| page.1.clone()).collect(), |
| 691 | target, |
| 692 | texts, |
| 693 | ) |
| 694 | } |
| 695 | |
| 696 | /// Each side keeps the pages it gave a series of their own, the remote's where both did; |
| 697 | /// a page the remote deleted comes back as a new page where the queue had it. OneNote's |
| 698 | /// COM moves re-series the pages a page jumps over, so its moves replay as moves of those. |
| 699 | #[test] |
| 700 | fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() { |
| 701 | for (capture, moves) in [ |
| 702 | ( |
| 703 | "native-pages", |
| 704 | &[ |
| 705 | ("One", Some("Target")), |
| 706 | ("Three", Some("Target")), |
| 707 | ("Two", None), |
| 708 | ][..], |
| 709 | ), |
| 710 | ( |
| 711 | "native-restore", |
| 712 | &[ |
| 713 | ("One", None), |
| 714 | ("Two", None), |
| 715 | ("Target", None), |
| 716 | ("Three", None), |
| 717 | ][..], |
| 718 | ), |
| 719 | ("native-restore", &[("Four", Some("One"))][..]), |
| 720 | ] { |
| 721 | let root = |
| 722 | std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/conflict-page"); |
| 723 | let native: serde_json::Value = |
| 724 | serde_json::from_slice(&std::fs::read(root.join(capture).join("merged.json")).unwrap()) |
| 725 | .unwrap(); |
| 726 | let (order, target, texts) = native_page_merge(capture, moves); |
| 727 | let native: Vec<String> = serde_json::from_value(native["order"].clone()).unwrap(); |
| 728 | assert_eq!(order, native[1..], "{capture}"); |
| 729 | assert!( |
| 730 | texts.contains(&"Body Target edited offline.".to_owned()), |
| 731 | "{capture}" |
| 732 | ); |
| 733 | let arena = onestore::Arena::default(); |
| 734 | let initial = |
| 735 | std::fs::read(root.join(capture).join("initial/notebook/synthetic.one")).unwrap(); |
| 736 | let listed = onestore::Section::open(&arena, initial) |
| 737 | .unwrap() |
| 738 | .pages() |
| 739 | .unwrap(); |
| 740 | assert!( |
| 741 | listed.iter().all(|page| page.0 != target), |
| 742 | "{capture}: a new page" |
| 743 | ); |
| 744 | } |
| 745 | } |