1//! Paragraph splits and joins expressed as page-model differences: reconciliation against
2//! native OneNote captures, dependent edits, uncertain attempts and competing clients.
3
4#[path = "../../onestore/tests/support/ops.rs"]
5mod ops;
6
7use notebook::{EditStatus, Replica};
8use onestore::{
9 ExGuid, RevisionIndex, Store,
10 document::Document,
11 page::{Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id},
12};
13use std::path::Path;
14
15#[path = "support/server.rs"]
16mod server;
17use server::{Fault, Server, conflicts, pages, snapshot, typed};
18#[path = "support/model_ops.rs"]
19mod model_ops;
20use model_ops::{AUTHOR, outlines_mut, page_of, replace_text};
21
22const BEFORE: &[u8] =
23 include_bytes!("../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one");
24const KEYBOARD: &[u8] =
25 include_bytes!("../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one");
26const REPLACED: &[u8] =
27 include_bytes!("../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one");
28const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/paragraph-edit");
29
30fn at(list: &[PageParagraph], text: ExGuid) -> Option<usize> {
31 list.iter()
32 .position(|p| p.text().is_some_and(|t| t.id == text))
33}
34
35/// Applies `edit` to the paragraph list holding `text`, descending into table cells.
36fn in_list<T>(
37 list: &mut Vec<PageParagraph>,
38 text: ExGuid,
39 edit: &mut impl FnMut(&mut Vec<PageParagraph>, usize) -> T,
40) -> Option<T> {
41 if let Some(index) = at(list, text) {
42 return Some(edit(list, index));
43 }
44 for paragraph in list {
45 if let ParagraphContent::Table(table) = &mut paragraph.content {
46 for row in &mut table.rows {
47 for cell in &mut row.cells {
48 if let Some(value) = in_list(&mut cell.paragraphs, text, edit) {
49 return Some(value);
50 }
51 }
52 }
53 }
54 }
55 None
56}
57
58fn locate_in<T>(
59 page: &mut Page,
60 text: ExGuid,
61 mut edit: impl FnMut(&mut Vec<PageParagraph>, usize) -> T,
62) -> T {
63 for outline in outlines_mut(page) {
64 if let Some(value) = in_list(&mut outline.paragraphs, text, &mut edit) {
65 return value;
66 }
67 }
68 panic!("the edited paragraph is on the page");
69}
70
71/// Splits the paragraph carrying `text` at a UTF-16 offset: the text tail and the children
72/// move to a new following paragraph, as pressing Enter mid-paragraph does.
73fn split_paragraph(page: &mut Page, text: ExGuid, offset: u32) {
74 locate_in(page, text, |list, index| {
75 // One identity per split, numbered as OneNote numbers the paragraph it creates.
76 // A GUID of the split's own: `new_id` shares its GUID across identities.
77 let ExGuid { mut guid, n } = new_id().unwrap();
78 guid[14] ^= n as u8;
79 guid[15] ^= 0xff;
80 let mut right = list[index].clone();
81 right.id = ExGuid { guid, n: 1 };
82 right.tags.clear();
83 let source = list[index].text().unwrap().text.clone();
84 let end = source.utf16_offset(source.text().len()).unwrap();
85 list[index].text_mut().unwrap().text = source.slice(0..offset).unwrap();
86 right.content = ParagraphContent::Text(TextObject {
87 id: ExGuid { guid, n: 2 },
88 date_field: None,
89 text: source.slice(offset..end).unwrap(),
90 tags: Vec::new(),
91 });
92 let (left, tail) = (list[index].id, right.id);
93 for paragraph in &mut list[index + 1..] {
94 if paragraph.parent == Some(left) {
95 paragraph.parent = Some(tail);
96 }
97 }
98 list.insert(index + 1, right);
99 })
100}
101
102/// Joins the paragraph carrying `right` into the one carrying `left`; an empty left text
103/// object is replaced by a non-empty right one, which is the identity OneNote keeps. The
104/// right paragraph's children keep their depth, under the left paragraph or the nearest of
105/// its ancestors above them.
106fn join_paragraphs(page: &mut Page, left: ExGuid, right: ExGuid) {
107 locate_in(page, left, |list, first| {
108 let second = at(list, right).expect("the joined paragraphs share a container");
109 let (parent, orphaned, removed) = (
110 list[first].id,
111 list[second].id,
112 list[second].text().unwrap().clone(),
113 );
114 let adopts = !removed.text.text().is_empty();
115 let target = list[first].text_mut().unwrap();
116 let mut joined = target.text.clone();
117 joined.append(removed.text).unwrap();
118 if adopts && target.text.text().is_empty() {
119 target.id = removed.id;
120 }
121 target.text = joined;
122 list.remove(second);
123 let nesting: std::collections::BTreeMap<_, _> =
124 list.iter().map(|p| (p.id, (p.level, p.parent))).collect();
125 for paragraph in list {
126 if paragraph.parent == Some(orphaned) {
127 let mut adopter = Some(parent);
128 while let Some(id) = adopter
129 && nesting[&id].0 >= paragraph.level
130 {
131 adopter = nesting[&id].1;
132 }
133 paragraph.parent = adopter;
134 }
135 }
136 });
137}
138
139/// The paragraphs of a page's single body outline.
140fn body(page: &Page) -> &[PageParagraph] {
141 page.objects
142 .iter()
143 .find_map(|object| match object {
144 PageObject::Outline(outline) => Some(&outline.paragraphs[..]),
145 _ => None,
146 })
147 .unwrap()
148}
149
150fn texts(page: &Page) -> Vec<String> {
151 body(page)
152 .iter()
153 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
154 .collect()
155}
156
157/// The page space and model holding a text object anywhere on a page, table cells included.
158fn holder(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) {
159 let store = Store::parse(bytes).unwrap();
160 let index = RevisionIndex::parse(&store).unwrap();
161 let document = Document::parse(&index).unwrap();
162 document
163 .pages()
164 .unwrap()
165 .into_iter()
166 .find_map(|(space, _)| {
167 let mut page = Page::from_space(&document, space).ok()?;
168 let found = outlines_mut(&mut page)
169 .into_iter()
170 .any(|outline| in_list(&mut outline.paragraphs, text, &mut |_, _| ()).is_some());
171 found.then_some((space, page))
172 })
173 .expect("text object belongs to an active page")
174}
175
176fn save_page(
177 cache: &Replica,
178 text: ExGuid,
179 edit: impl FnOnce(&mut Page),
180) -> Result<Option<u64>, notebook::Error> {
181 let source = snapshot(cache);
182 let (space, mut page) = holder(&source, text);
183 edit(&mut page);
184 model_ops::save_as(cache, space, &page, AUTHOR)
185}
186
187/// Makes `edit` again on the page as the remote's version left it, as a writer does after
188/// a conflict page kept the first try.
189fn redo_page(cache: &Replica, text: ExGuid, edit: impl FnOnce(&mut Page)) {
190 save_page(cache, text, edit).unwrap().unwrap();
191}
192
193/// The conflict pages `image` lists under page `space`.
194fn conflict_pages(image: &[u8], space: ExGuid) -> usize {
195 conflicts(image)
196 .into_iter()
197 .filter(|(page, _)| *page == space)
198 .map(|(_, pages)| pages.len())
199 .sum()
200}
201
202/// The styled text of an object anywhere on a page.
203fn content(page: &Page, text: ExGuid) -> onestore::page::Paragraph {
204 locate_in(&mut page.clone(), text, |list, index| {
205 list[index].text().unwrap().text.clone()
206 })
207}
208
209/// Rewrites one character of a text object, the dependent edit the native lane checks.
210fn dependent_edit(page: &mut Page, text: ExGuid) {
211 let content = content(page, text);
212 match content.text().find('c') {
213 Some(byte) => {
214 let start = content.utf16_offset(byte).unwrap();
215 replace_text(page, text, start..start + 1, "C");
216 }
217 None => replace_text(page, text, 1..2, "I"),
218 }
219}
220
221/// The reconciliation fixture's split and join controls, in page order.
222fn controls() -> Vec<(ExGuid, String, bool)> {
223 let store = Store::parse(BEFORE).unwrap();
224 let index = RevisionIndex::parse(&store).unwrap();
225 let document = Document::parse(&index).unwrap();
226 document
227 .pages()
228 .unwrap()
229 .into_iter()
230 .filter_map(|(space, _)| {
231 let title = Page::from_space(&document, space).unwrap().title;
232 let split = title.starts_with("Split ");
233 (split || title.starts_with("Join ")).then_some((space, title, split))
234 })
235 .collect()
236}
237
238/// Publishes every control's split or join against the native keyboard capture, reviewing
239/// whatever conflicts, then a dependent edit of the resulting paragraph. Returns the
240/// reconciled remote image and one manifest entry per control.
241fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
242 let mut server = Server::new(KEYBOARD);
243 let mut recorded = Vec::new();
244 let mut automatic = 0;
245 for (space, name, split) in controls() {
246 let original = page_of(BEFORE, space);
247 let left = body(&original)[0].text().unwrap().id;
248 let right = (!split).then(|| body(&original)[1].text().unwrap().id);
249 let apply = |page: &mut Page| match right {
250 Some(right) => join_paragraphs(page, left, right),
251 None => split_paragraph(page, left, 2),
252 };
253 let directory = tempfile::tempdir().unwrap();
254 let path = directory.path().join("reconciliation.sqlite");
255 let mut cache = Replica::create(&path, BEFORE).unwrap();
256 let mut after = original.clone();
257 apply(&mut after);
258 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
259 .unwrap()
260 .unwrap();
261 let pending = cache.pending().unwrap();
262 drop(cache);
263 cache = Replica::open(&path).unwrap();
264 assert_eq!(cache.pending().unwrap(), pending);
265 let kept = conflict_pages(&server.visible, space);
266 let (_, status) = cache.sync_once(&mut server).unwrap().edit.unwrap();
267 assert!(matches!(
268 cache.status(id).unwrap(),
269 Some(EditStatus::Published { .. })
270 ));
271 let reviewed = conflict_pages(&server.visible, space) > kept;
272 let receipt = if reviewed {
273 redo_page(&cache, left, apply);
274 cache.sync_once(&mut server).unwrap().edit.unwrap().1
275 } else {
276 automatic += 1;
277 status
278 };
279 let EditStatus::Published { revision } = receipt else {
280 panic!("{name}: {receipt:?}")
281 };
282 let published = page_of(&server.visible, space);
283 let index = body(&published)
284 .iter()
285 .position(|p| {
286 p.text()
287 .is_some_and(|t| t.id == left || Some(t.id) == right)
288 })
289 .unwrap();
290 let intent = match right {
291 Some(right) => serde_json::json!({"author": AUTHOR, "left": left, "right": right}),
292 None => {
293 // The native lane derives both new identities from the guid recorded here.
294 let tail = &body(&published)[index + 1];
295 let guid = tail.id.guid;
296 assert_eq!(tail.id, ExGuid { guid, n: 1 }, "{name}");
297 assert_eq!(tail.text().unwrap().id, ExGuid { guid, n: 2 }, "{name}");
298 serde_json::json!({"author": AUTHOR, "offset": 2, "text": left, "guid": guid})
299 }
300 };
301 let target = body(&published)[if split { index + 1 } else { index }]
302 .text()
303 .unwrap()
304 .id;
305 drop(cache);
306 cache = Replica::open(&path).unwrap();
307 let dependent = save_page(&cache, target, |page| dependent_edit(page, target))
308 .unwrap()
309 .unwrap();
310 let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap();
311 assert_eq!(actual, dependent);
312 let EditStatus::Published {
313 revision: dependent_revision,
314 } = status
315 else {
316 panic!("{name} dependent: {status:?}")
317 };
318 assert!(cache.pending().unwrap().is_empty());
319 assert_eq!(snapshot(&cache), server.durable);
320 recorded.push(
321 serde_json::json!({"case": name, "space": space, "intent": intent,
322 "outcome": if reviewed { "reviewed" } else { "automatic" },
323 "receipt": revision, "dependent_receipt": dependent_revision}),
324 );
325 }
326 assert_eq!((recorded.len(), automatic), (16, 8));
327 // Each edit and its dependent, and each conflict page before its edit made again.
328 assert_eq!(server.publications, 32 + 16 - automatic);
329 (server.durable, recorded)
330}
331
332#[test]
333fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_keep_a_conflict_page() {
334 let (durable, cases) = reconcile_controls();
335 let expected = [
336 ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]),
337 ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]),
338 ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]),
339 // A split names copies of its paragraph's list nodes; the remote's new list has none.
340 ("Split remote list", "reviewed", vec!["ab", "🦀Cd"]),
341 ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]),
342 ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]),
343 ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]),
344 ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]),
345 ("Join left boundary", "automatic", vec!["ab🦀CdXRight"]),
346 ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]),
347 ("Join remote child", "automatic", vec!["ab🦀CdRight"]),
348 ("Join remote list", "reviewed", vec!["ab🦀CdRight"]),
349 ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]),
350 ("Join remote format", "reviewed", vec!["ab🦀CdRight"]),
351 ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]),
352 ("Join empty adoption", "automatic", vec!["XIight"]),
353 ];
354 for (case, (name, outcome, prefix)) in cases.iter().zip(expected) {
355 assert_eq!(case["case"], name);
356 assert_eq!(case["outcome"], outcome, "{name}");
357 let space: ExGuid = serde_json::from_value(case["space"].clone()).unwrap();
358 let published = texts(&page_of(&durable, space));
359 assert_eq!(&published[..prefix.len()], prefix, "{name}");
360 assert!(
361 published.iter().any(|text| text.contains("sibling")),
362 "{name}: {published:?}"
363 );
364 }
365}
366
367#[test]
368fn a_native_text_object_replacement_keeps_a_conflict_page_for_every_offline_split_and_join() {
369 let mut server = Server::new(REPLACED);
370 let mut count = 0;
371 for (space, name, split) in controls() {
372 let original = page_of(BEFORE, space);
373 let left = body(&original)[0].text().unwrap().id;
374 let directory = tempfile::tempdir().unwrap();
375 let path = directory.path().join("replaced.sqlite");
376 let cache = Replica::create(&path, BEFORE).unwrap();
377 let mut after = original.clone();
378 if split {
379 split_paragraph(&mut after, left, 2);
380 } else {
381 join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id);
382 }
383 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
384 .unwrap()
385 .unwrap();
386 let remote = page_of(&server.visible, space);
387 assert!(
388 matches!(
389 cache.sync_once(&mut server).unwrap().edit,
390 Some((_, EditStatus::Published { .. }))
391 ),
392 "{name}"
393 );
394 assert!(
395 matches!(
396 cache.status(id).unwrap(),
397 Some(EditStatus::Published { .. })
398 ),
399 "{name}"
400 );
401 assert_eq!(page_of(&server.visible, space), remote, "{name}");
402 assert_eq!(conflict_pages(&server.visible, space), 1, "{name}");
403 count += 1;
404 }
405 assert_eq!(server.publications, count);
406}
407
408/// Replays a recorded native split or join intent as a page-model difference.
409fn replay(page: &mut Page, intent: &serde_json::Value, split: bool) {
410 if split {
411 split_paragraph(
412 page,
413 serde_json::from_value(intent["text"].clone()).unwrap(),
414 u32::try_from(intent["offset"].as_u64().unwrap()).unwrap(),
415 );
416 } else {
417 join_paragraphs(
418 page,
419 serde_json::from_value(intent["left"].clone()).unwrap(),
420 serde_json::from_value(intent["right"].clone()).unwrap(),
421 );
422 }
423}
424
425/// The text object a recorded intent starts from.
426fn origin(intent: &serde_json::Value, split: bool) -> ExGuid {
427 serde_json::from_value(intent[if split { "text" } else { "left" }].clone()).unwrap()
428}
429
430/// Replays every recorded native split and join intent as a page-model save, against a
431/// remote that prefixed the edited text wherever a prefix is distinguishable.
432fn offline_paragraphs(output: Option<&Path>) {
433 let root = Path::new(CORPUS);
434 for (name, source, recorded, split) in [
435 (
436 "splits",
437 "before/notebook/synthetic.one",
438 "rust-split/manifest.json",
439 true,
440 ),
441 (
442 "joins",
443 "split/notebook/synthetic.one",
444 "rust-join/split/manifest.json",
445 false,
446 ),
447 (
448 "inheritance",
449 "join-edges/before/notebook/synthetic.one",
450 "rust-join/inheritance/manifest.json",
451 false,
452 ),
453 (
454 "tags",
455 "join-tags/before/notebook/synthetic.one",
456 "rust-join/tags/manifest.json",
457 false,
458 ),
459 ] {
460 let source = std::fs::read(root.join(source)).unwrap();
461 let directory = tempfile::tempdir().unwrap();
462 let folder = output.map_or_else(|| directory.path().to_owned(), |at| at.join(name));
463 std::fs::create_dir_all(&folder).unwrap();
464 let path = folder.join("cache.sqlite");
465 let mut cache = Replica::create(&path, &source).unwrap();
466 let mut server = Server::new(&source);
467 let manifest: serde_json::Value =
468 serde_json::from_slice(&std::fs::read(root.join(recorded)).unwrap()).unwrap();
469 let cases = if split { &manifest["cases"] } else { &manifest };
470 let mut entries = Vec::new();
471 let mut refused: Vec<String> = Vec::new();
472 let mut reviewed: Vec<String> = Vec::new();
473 for case in cases
474 .as_array()
475 .unwrap()
476 .iter()
477 .filter(|case| case.get("intent").is_some())
478 {
479 let intent = &case["intent"];
480 let left = origin(intent, split);
481 let (space, page) = holder(&snapshot(&cache), left);
482 let offset = if split {
483 intent["offset"].as_u64().unwrap()
484 } else {
485 1
486 };
487 let prefixed = offset > 0 && !content(&page, left).text().is_empty();
488 if prefixed {
489 server.visible = typed(&server.visible, space, left, 0..0, "\u{2602}");
490 server.durable.clone_from(&server.visible);
491 }
492 let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else {
493 // Joining runs that differ in language or spacing is not a page-model edit.
494 refused.push(case["case"].as_str().unwrap().to_owned());
495 continue;
496 };
497 let id = queued.unwrap();
498 let mut entry = serde_json::json!({"case": case["case"], "id": id, "space": space,
499 "intent": intent, "remote_prefix": prefixed});
500 drop(cache);
501 cache = Replica::open(&path).unwrap();
502 let case = entry["case"].as_str().unwrap().to_owned();
503 let intent = entry["intent"].clone();
504 let mut uncertain = id % 2 == 0;
505 let mut kept = conflict_pages(&server.visible, space);
506 let mut id = id;
507 let revision = loop {
508 if uncertain {
509 server.fault = Fault::UnknownAfter;
510 }
511 match cache.sync_once(&mut server) {
512 Err(_) => {
513 assert!(matches!(
514 cache.status(id).unwrap(),
515 Some(EditStatus::AwaitingConfirmation { .. })
516 ));
517 uncertain = false;
518 drop(cache);
519 cache = Replica::open(&path).unwrap();
520 }
521 Ok(notebook::Synced {
522 edit: Some((_, EditStatus::Published { revision })),
523 ..
524 }) if conflict_pages(&server.visible, space) == kept => {
525 assert!(matches!(
526 cache.status(id).unwrap(),
527 Some(EditStatus::Published { .. })
528 ));
529 break revision;
530 }
531 Ok(notebook::Synced {
532 edit: Some((_, EditStatus::Published { .. })),
533 ..
534 }) => {
535 kept += 1;
536 server.fault = Fault::None;
537 reviewed.push(case.clone());
538 // The split made again sits after the remote's one-unit prefix.
539 let mut placed = intent.clone();
540 if split && entry["remote_prefix"] == true {
541 placed["offset"] = (intent["offset"].as_u64().unwrap() + 1).into();
542 }
543 redo_page(&cache, origin(&intent, split), |page| {
544 replay(page, &placed, split)
545 });
546 id = cache.pending().unwrap().last().unwrap().id;
547 }
548 other => panic!("{name} {case}: {other:?}"),
549 }
550 };
551 entry["revision"] = serde_json::to_value(revision).unwrap();
552 entries.push(entry);
553 }
554 let unrepresentable = ["Join inherited styles".to_owned()];
555 let expected: (usize, &[String], &[String]) = match name {
556 "splits" | "joins" => (12, &[], &[]),
557 "inheritance" => (4, &[], &unrepresentable),
558 _ => (3, &[], &[]),
559 };
560 assert_eq!(
561 (entries.len(), reviewed.as_slice(), refused.as_slice()),
562 expected,
563 "{name}"
564 );
565 assert_eq!(server.publications, entries.len());
566 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
567 assert_eq!(snapshot(&cache), server.durable);
568 if output.is_some() {
569 cache
570 .export_recovery(folder.join("recovery.sqlite"))
571 .unwrap();
572 std::fs::create_dir(folder.join("candidate")).unwrap();
573 std::fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap();
574 std::fs::write(
575 folder.join("manifest.json"),
576 serde_json::to_vec_pretty(&entries).unwrap(),
577 )
578 .unwrap();
579 }
580 }
581}
582
583/// A control page of the reconciliation fixture, by title.
584fn control(title: &str) -> (ExGuid, Page) {
585 let (space, ..) = controls()
586 .into_iter()
587 .find(|(_, name, _)| name == title)
588 .unwrap();
589 (space, page_of(BEFORE, space))
590}
591
592/// A remote holding the control page with `change` applied by a native author.
593fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
594 let mut page = page_of(BEFORE, space);
595 change(&mut page);
596 Server::new(ops::saved(BEFORE, space, &page).unwrap().as_slice())
597}
598
599#[test]
600fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {
601 let (space, page) = control("Join remote prefix");
602 let left = body(&page)[0].text().unwrap().id;
603 let directory = tempfile::tempdir().unwrap();
604 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
605 let mut server = Server::new(BEFORE);
606 save_page(&cache, left, |page| split_paragraph(page, left, 2))
607 .unwrap()
608 .unwrap();
609 assert!(matches!(
610 cache.sync_once(&mut server).unwrap().edit,
611 Some((_, EditStatus::Published { .. }))
612 ));
613 let published = page_of(&server.durable, space);
614 assert_eq!(
615 texts(&published),
616 ["ab", "\u{1f980}cd", "Right", "Preserved sibling"]
617 );
618 let right = body(&published)[1].text().unwrap().id;
619 save_page(&cache, left, |page| join_paragraphs(page, left, right))
620 .unwrap()
621 .unwrap();
622 assert!(matches!(
623 cache.sync_once(&mut server).unwrap().edit,
624 Some((_, EditStatus::Published { .. }))
625 ));
626 assert_eq!(
627 texts(&page_of(&server.durable, space)),
628 ["ab\u{1f980}cd", "Right", "Preserved sibling"]
629 );
630 assert_eq!(server.publications, 2);
631 assert_eq!(snapshot(&cache), server.durable);
632}
633
634#[test]
635fn a_split_merges_with_a_remote_edit_to_another_paragraph() {
636 let (space, page) = control("Join remote prefix");
637 let (left, other) = (
638 body(&page)[0].text().unwrap().id,
639 body(&page)[1].text().unwrap().id,
640 );
641 let directory = tempfile::tempdir().unwrap();
642 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
643 let id = save_page(&cache, left, |page| split_paragraph(page, left, 2))
644 .unwrap()
645 .unwrap();
646 let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote "));
647 assert!(
648 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
649 );
650 assert_eq!(
651 texts(&page_of(&server.durable, space)),
652 ["ab", "\u{1f980}cd", "Remote Right", "Preserved sibling"]
653 );
654}
655
656#[test]
657fn a_join_keeps_a_conflict_page_when_the_remote_changed_the_paragraph_it_removes() {
658 let (space, page) = control("Join remote prefix");
659 let (left, right) = (
660 body(&page)[0].text().unwrap().id,
661 body(&page)[1].text().unwrap().id,
662 );
663 let directory = tempfile::tempdir().unwrap();
664 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
665 let id = save_page(&cache, left, |page| join_paragraphs(page, left, right))
666 .unwrap()
667 .unwrap();
668 let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote "));
669 assert!(matches!(
670 cache.sync_once(&mut server).unwrap().edit,
671 Some((_, EditStatus::Published { .. }))
672 ));
673 assert!(matches!(
674 cache.status(id).unwrap(),
675 Some(EditStatus::Published { .. })
676 ));
677 assert_eq!(conflict_pages(&server.durable, space), 1);
678 redo_page(&cache, left, |page| join_paragraphs(page, left, right));
679 assert!(matches!(
680 cache.sync_once(&mut server).unwrap().edit,
681 Some((_, EditStatus::Published { .. }))
682 ));
683 assert_eq!(
684 texts(&page_of(&server.durable, space))[0],
685 "ab\u{1f980}cdRemote Right"
686 );
687}
688
689#[test]
690fn edits_dependent_on_a_published_split_survive_reopen() {
691 let (space, page) = control("Join remote prefix");
692 let (left, other) = (
693 body(&page)[0].text().unwrap().id,
694 body(&page)[1].text().unwrap().id,
695 );
696 let directory = tempfile::tempdir().unwrap();
697 let path = directory.path().join("dependent.sqlite");
698 let mut cache = Replica::create(&path, BEFORE).unwrap();
699 let mut server = Server::new(BEFORE);
700 save_page(&cache, left, |page| split_paragraph(page, left, 2))
701 .unwrap()
702 .unwrap();
703 cache.sync_once(&mut server).unwrap().edit.unwrap();
704 let tail = body(&page_of(&snapshot(&cache), space))[1]
705 .text()
706 .unwrap()
707 .id;
708 let dependent = save_page(&cache, tail, |page| {
709 replace_text(page, tail, 0..0, "Local ")
710 })
711 .unwrap()
712 .unwrap();
713 let local = snapshot(&cache);
714 let pending = cache.pending().unwrap();
715 drop(cache);
716 cache = Replica::open(&path).unwrap();
717 assert_eq!(pages(&snapshot(&cache)), pages(&local));
718 assert_eq!(cache.pending().unwrap(), pending);
719 let mut remote = page_of(&server.durable, space);
720 replace_text(&mut remote, other, 0..0, "Remote ");
721 server.visible = ops::saved(&server.durable, space, &remote)
722 .unwrap()
723 .as_slice()
724 .to_vec();
725 server.durable.clone_from(&server.visible);
726 assert!(
727 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == dependent)
728 );
729 assert_eq!(
730 texts(&page_of(&server.durable, space)),
731 [
732 "ab",
733 "Local \u{1f980}cd",
734 "Remote Right",
735 "Preserved sibling"
736 ]
737 );
738}
739
740#[test]
741fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
742 for join in [false, true] {
743 for fault in [
744 Fault::Before,
745 Fault::UnknownBefore,
746 Fault::UnknownAfter,
747 Fault::Committed,
748 ] {
749 let (space, page) = control("Join remote prefix");
750 let (left, right) = (
751 body(&page)[0].text().unwrap().id,
752 body(&page)[1].text().unwrap().id,
753 );
754 let directory = tempfile::tempdir().unwrap();
755 let path = directory.path().join("uncertain.sqlite");
756 let cache = Replica::create(&path, BEFORE).unwrap();
757 let id = save_page(&cache, left, |page| {
758 if join {
759 join_paragraphs(page, left, right);
760 } else {
761 split_paragraph(page, left, 2);
762 }
763 })
764 .unwrap()
765 .unwrap();
766 let local = snapshot(&cache);
767 let pending = cache.pending().unwrap();
768 let mut server = Server::new(BEFORE);
769 server.fault = fault;
770 assert!(cache.sync_once(&mut server).is_err());
771 let state = cache.status(id).unwrap().unwrap();
772 drop(cache);
773 let cache = Replica::open(&path).unwrap();
774 assert_eq!(cache.status(id).unwrap(), Some(state.clone()));
775 match fault {
776 Fault::Before => {
777 assert_eq!(state, EditStatus::Pending);
778 assert_eq!(cache.pending().unwrap(), pending);
779 assert!(matches!(
780 cache.sync_once(&mut server).unwrap().edit.unwrap().1,
781 EditStatus::Published { .. }
782 ));
783 assert_eq!(server.publications, 2);
784 }
785 Fault::UnknownBefore => {
786 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
787 assert_eq!(cache.pending().unwrap(), pending);
788 assert_eq!(pages(&snapshot(&cache)), pages(&local));
789 for _ in 0..3 {
790 assert_eq!(
791 cache.sync_once(&mut server).unwrap().edit,
792 Some((id, state.clone()))
793 );
794 }
795 assert_eq!(server.publications, 1);
796 assert_eq!(server.durable, BEFORE);
797 continue;
798 }
799 Fault::UnknownAfter => {
800 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
801 assert_eq!(pages(&snapshot(&cache)), pages(&local));
802 assert!(matches!(
803 cache.sync_once(&mut server).unwrap().edit.unwrap().1,
804 EditStatus::Published { .. }
805 ));
806 assert_eq!(server.publications, 1);
807 }
808 _ => {
809 assert!(matches!(state, EditStatus::Published { .. }));
810 assert_eq!(server.publications, 1);
811 }
812 }
813 assert!(cache.pending().unwrap().is_empty());
814 let expected: &[&str] = if join {
815 &["ab\u{1f980}cdRight", "Preserved sibling"]
816 } else {
817 &["ab", "\u{1f980}cd", "Right", "Preserved sibling"]
818 };
819 assert_eq!(texts(&page_of(&server.durable, space)), expected);
820 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
821 assert_eq!(snapshot(&cache), server.durable);
822 }
823 }
824}
825
826#[test]
827fn a_competing_join_leaves_the_other_client_its_split_on_a_conflict_page() {
828 let (space, page) = control("Join remote prefix");
829 let (left, right) = (
830 body(&page)[0].text().unwrap().id,
831 body(&page)[1].text().unwrap().id,
832 );
833 let directory = tempfile::tempdir().unwrap();
834 let first = Replica::create(directory.path().join("first.sqlite"), BEFORE).unwrap();
835 let path = directory.path().join("second.sqlite");
836 let second = Replica::create(&path, BEFORE).unwrap();
837 save_page(&first, left, |page| join_paragraphs(page, left, right))
838 .unwrap()
839 .unwrap();
840 let id = save_page(&second, right, |page| split_paragraph(page, right, 2))
841 .unwrap()
842 .unwrap();
843 let mut server = Server::new(BEFORE);
844 assert!(matches!(
845 first.sync_once(&mut server).unwrap().edit.unwrap().1,
846 EditStatus::Published { .. }
847 ));
848 assert!(matches!(
849 second.sync_once(&mut server).unwrap().edit,
850 Some((_, EditStatus::Published { .. }))
851 ));
852 assert_eq!(server.publications, 2);
853 assert_eq!(conflict_pages(&server.durable, space), 1);
854 drop(second);
855 let second = Replica::open(&path).unwrap();
856 assert!(matches!(
857 second.status(id).unwrap(),
858 Some(EditStatus::Published { .. })
859 ));
860 redo_page(&second, left, |page| {
861 split_paragraph(page, left, 2);
862 });
863 assert!(matches!(
864 second.sync_once(&mut server).unwrap().edit.unwrap().1,
865 EditStatus::Published { .. }
866 ));
867 assert_eq!(
868 texts(&page_of(&server.durable, space)),
869 ["ab", "\u{1f980}cdRight", "Preserved sibling"]
870 );
871}
872
873#[test]
874fn recorded_native_splits_and_joins_replay_over_a_remote_prefix() {
875 offline_paragraphs(None);
876}
877
878#[test]
879#[ignore = "exports native-controlled reconciliation for cold OneNote validation"]
880fn export_native_reconciliation() {
881 let output = std::path::PathBuf::from(
882 std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(),
883 );
884 assert!(output.is_absolute());
885 std::fs::create_dir(&output).unwrap();
886 let (bytes, cases) = reconcile_controls();
887 std::fs::create_dir(output.join("candidate")).unwrap();
888 std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap();
889 std::fs::write(
890 output.join("manifest.json"),
891 serde_json::to_vec_pretty(&cases).unwrap(),
892 )
893 .unwrap();
894}
895
896#[test]
897#[ignore = "exports reconciled paragraph edits for independent native validation"]
898fn export_native_offline_paragraphs() {
899 let output =
900 std::path::PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap());
901 assert!(output.is_absolute());
902 std::fs::create_dir(&output).unwrap();
903 offline_paragraphs(Some(&output));
904}