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"]
6mod ops;
7
8use notebook::{EditStatus, Recovery, Replica};
9use onestore::op::{Op, SectionOp};
10use 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};
18use std::collections::BTreeMap;
19
20#[path = "support/server.rs"]
21mod server;
22use server::*;
23#[path = "support/model_ops.rs"]
24mod model_ops;
25
26const SOURCE: &[u8] =
27 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
28
29fn 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
49fn 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.
67fn 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]
116fn 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]
168fn 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]
194fn 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]
218fn 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]
246fn 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]
295fn 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]
370fn 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]
400fn 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]
426fn 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]
545fn 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.
637fn 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]
700fn 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}