1//! Paragraph tree edits expressed as page-model saves: moves, deletions, their
2//! reconciliation against native remote images, and emptied table cells.
3
4use super::*;
5use model_schedule::move_subtree;
6use onestore::page::{Paragraph, ParagraphContent, TableCell};
7
8/// Saves an edited model of `space`, reaching content that outline helpers cannot.
9fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> {
10 model_ops::save_page(cache, space, edit).unwrap()
11}
12
13#[test]
14fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() {
15 let runs = sweep::seeds(0..24, 1);
16 let mut random = 1940 + runs.start;
17 for _ in runs {
18 let input: Vec<_> = (0..256)
19 .map(|_| {
20 random ^= random << 13;
21 random ^= random >> 7;
22 random ^= random << 17;
23 random as u8
24 })
25 .collect();
26 model_schedule::run(&input);
27 }
28}
29
30/// The one-outline fixture with three further sibling paragraphs.
31fn fixture() -> (Vec<u8>, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) {
32 let (source, space, outline, text) = super::fixture();
33 let mut page = page_of(&source, space);
34 let mut anchor = text;
35 for at in 1..4 {
36 anchor = model_ops::insert_after(&mut page, anchor, &format!("Sibling {at}")).1;
37 }
38 let source = ops::saved(&source, space, &page)
39 .unwrap()
40 .as_slice()
41 .to_vec();
42 let stored = page_of(&source, space);
43 let siblings = &outline_of(&stored, outline).paragraphs;
44 assert_eq!(siblings.len(), 4);
45 let ids = std::array::from_fn(|at| siblings[at].id);
46 let texts = std::array::from_fn(|at| siblings[at].text().unwrap().id);
47 (source, space, outline, ids, texts)
48}
49
50/// Paragraph identities of an outline in model order.
51fn order(bytes: &[u8], space: ExGuid, outline: ExGuid) -> Vec<ExGuid> {
52 outline_of(&page_of(bytes, space), outline)
53 .paragraphs
54 .iter()
55 .map(|paragraph| paragraph.id)
56 .collect()
57}
58
59/// Adds a plain paragraph as the last child of `parent`.
60fn insert_child(page: &mut Page, parent: ExGuid, value: &str) {
61 for outline in outlines_mut(page) {
62 let Some(at) = outline.paragraphs.iter().position(|p| p.id == parent) else {
63 continue;
64 };
65 let mut child = model_ops::fresh_paragraph(&outline.paragraphs[at], value);
66 child.parent = Some(parent);
67 child.level = outline.paragraphs[at].level + 1;
68 let level = outline.paragraphs[at].level;
69 let mut end = at + 1;
70 while end < outline.paragraphs.len() && outline.paragraphs[end].level > level {
71 end += 1;
72 }
73 outline.paragraphs.insert(end, child);
74 return;
75 }
76 panic!("the parent paragraph is on the page");
77}
78
79#[test]
80fn move_preserves_remote_content_and_dependent_edits_through_reopen() {
81 let (source, space, outline, paragraphs, texts) = fixture();
82 let directory = tempfile::tempdir().unwrap();
83 let path = directory.path().join("cache.sqlite");
84 let cache = Replica::create(&path, &source).unwrap();
85 let id = save(&cache, texts[0], |page| {
86 move_subtree(page, paragraphs[0], None, None);
87 replace_text(page, texts[0], 0..0, "Local ");
88 })
89 .unwrap()
90 .unwrap();
91 let local = snapshot(&cache);
92 let queue = cache.pending().unwrap();
93 drop(cache);
94 let cache = Replica::open(&path).unwrap();
95 assert_eq!(cache.pending().unwrap(), queue);
96 assert_eq!(pages(&snapshot(&cache)), pages(&local));
97 let mut server = Server::new(&remote_with(&source, space, |page| {
98 insert_child(page, paragraphs[0], "New remote child");
99 restyle(page, texts[1], 0..7, |format| format.bold = Some(true));
100 }));
101 published(&cache, &mut server, id);
102 let durable = page_of(&server.durable, space);
103 let published = outline_of(&durable, outline);
104 assert_eq!(
105 published
106 .paragraphs
107 .iter()
108 .map(|p| p.id)
109 .collect::<Vec<_>>()[..4],
110 [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]]
111 );
112 let child = &published.paragraphs[4];
113 assert_eq!(child.parent, Some(paragraphs[0]));
114 assert_eq!(child.text().unwrap().text.text(), "New remote child");
115 assert_eq!(text_of(&durable, texts[0]), "Local Original 🦀 é");
116 let sibling = &paragraph_with(&durable, texts[1])
117 .unwrap()
118 .text()
119 .unwrap()
120 .text;
121 assert_eq!(sibling.spans()[0].format.bold, Some(true));
122 assert!(cache.pending().unwrap().is_empty());
123 assert_eq!(server.publications, 1);
124}
125
126#[test]
127fn native_empty_child_list_normalization_merges_with_a_local_deletion() {
128 let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one");
129 let native = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one");
130 let intent: serde_json::Value = serde_json::from_str(include_str!(
131 "../../../../corpus/outline-edit/empty-children/intent.json"
132 ))
133 .unwrap();
134 let space: ExGuid = intent[0].as_str().unwrap().parse().unwrap();
135 let object: ExGuid = intent[1]["object"].as_str().unwrap().parse().unwrap();
136 let page = page_of(source, space);
137 let outline = body_outlines(&page)
138 .into_iter()
139 .find(|outline| outline.paragraphs.iter().any(|p| p.id == object))
140 .unwrap();
141 let deleted = outline
142 .paragraphs
143 .iter()
144 .find(|p| p.id == object)
145 .unwrap()
146 .text()
147 .unwrap()
148 .id;
149 let left = outline.paragraphs[0].text().unwrap().id;
150 let outline = outline.id;
151 let concurrent: Vec<String> = body_outlines(&page_of(native, space))
152 .into_iter()
153 .flat_map(|outline| outline.paragraphs.iter())
154 .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned()))
155 .collect();
156 let directory = tempfile::tempdir().unwrap();
157 let path = directory.path().join("cache.sqlite");
158 let cache = Replica::create(&path, source).unwrap();
159 let change = |page: &mut Page| {
160 delete_paragraph(page, deleted);
161 replace_text(page, left, 0..0, "Local ");
162 };
163 let id = save(&cache, left, change).unwrap().unwrap();
164 drop(cache);
165 let cache = Replica::open(&path).unwrap();
166 let mut server = Server::new(native);
167 assert!(matches!(
168 cache.sync_once(&mut server).unwrap().edit,
169 Some((published, EditStatus::Published { .. })) if published == id
170 ));
171 assert_eq!(server.publications, 1);
172 assert!(cache.pending().unwrap().is_empty());
173 let durable = page_of(&server.durable, space);
174 assert!(!order(&server.durable, space, outline).contains(&object));
175 assert!(text_of(&durable, left).starts_with("Local "));
176 let published: Vec<String> = body_outlines(&durable)
177 .into_iter()
178 .flat_map(|outline| outline.paragraphs.iter())
179 .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned()))
180 .collect();
181 for text in concurrent {
182 assert!(
183 text == "🐈" || published.iter().any(|value| value.ends_with(&text)),
184 "the merge keeps concurrent remote content: {text}"
185 );
186 }
187}
188
189/// Deleting what the remote changed keeps the remote's content, and the local version on a
190/// conflict page; the edits that do not depend on the deletion merge.
191#[test]
192fn deletion_of_remote_content_keeps_a_conflict_page_and_merges_later_work() {
193 let (source, space, outline, paragraphs, texts) = fixture();
194 let mut page = page_of(&source, space);
195 insert_child(&mut page, paragraphs[0], "Descendant");
196 let source = ops::saved(&source, space, &page)
197 .unwrap()
198 .as_slice()
199 .to_vec();
200 let child = outline_of(&page_of(&source, space), outline).paragraphs[1]
201 .text()
202 .unwrap()
203 .id;
204 let remotes: [Change<Page>; 4] = [
205 Box::new(move |page| replace_text(page, texts[0], 0..0, "Remote ")),
206 Box::new(move |page| replace_text(page, child, 0..0, "Remote ")),
207 Box::new(move |page| {
208 restyle(page, child, 0..10, |format| format.italic = Some(true));
209 }),
210 Box::new(move |page| insert_child(page, paragraphs[0], "New child")),
211 ];
212 for remote in remotes {
213 let directory = tempfile::tempdir().unwrap();
214 let path = directory.path().join("cache.sqlite");
215 let cache = Replica::create(&path, &source).unwrap();
216 let change = |page: &mut Page| {
217 delete_paragraph(page, texts[0]);
218 replace_text(page, texts[1], 0..0, "Local ");
219 };
220 let id = save(&cache, texts[1], change).unwrap().unwrap();
221 let mut server = Server::new(&remote_with(&source, space, remote));
222 assert!(conflicted(&cache, &mut server, space));
223 assert_eq!(
224 text_of(&page_of(&server.durable, space), texts[1]),
225 "Local Sibling 1"
226 );
227 drop(cache);
228 let cache = Replica::open(&path).unwrap();
229 published(&cache, &mut server, id);
230 // The writer deletes the paragraph again, remote changes and all.
231 let again = save(&cache, texts[1], |page| delete_paragraph(page, texts[0]))
232 .unwrap()
233 .unwrap();
234 published(&cache, &mut server, again);
235 assert_eq!(
236 order(&server.durable, space, outline),
237 paragraphs[1..].to_vec()
238 );
239 assert_eq!(
240 text_of(&page_of(&server.durable, space), texts[1]),
241 "Local Sibling 1"
242 );
243 }
244}
245
246#[test]
247fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {
248 let (source, space, outline, paragraphs, texts) = fixture();
249 // The published order of the fixture's paragraphs, or a conflict page.
250 let cases: [(Change<Page>, Option<&[usize]>); 5] = [
251 (
252 Box::new(move |page| {
253 model_ops::insert_after(page, texts[0], "New sibling");
254 }),
255 Some(&[1, 2, 3, 0]),
256 ),
257 (
258 Box::new(move |page| delete_paragraph(page, texts[1])),
259 Some(&[2, 3, 0]),
260 ),
261 // Both moved a paragraph last; the local move replays after the remote one.
262 (
263 Box::new(move |page| move_subtree(page, paragraphs[1], None, None)),
264 Some(&[2, 3, 1, 0]),
265 ),
266 (
267 Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)),
268 None,
269 ),
270 (Box::new(move |page| delete_paragraph(page, texts[0])), None),
271 ];
272 for (remote, expected) in cases {
273 let directory = tempfile::tempdir().unwrap();
274 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
275 let id = save(&cache, texts[0], |page| {
276 move_subtree(page, paragraphs[0], None, None)
277 })
278 .unwrap()
279 .unwrap();
280 let mut server = Server::new(&remote_with(&source, space, remote));
281 let conflict = conflicted(&cache, &mut server, space);
282 assert!(matches!(
283 cache.status(id).unwrap(),
284 Some(EditStatus::Published { .. })
285 ));
286 let Some(expected) = expected else {
287 assert!(conflict);
288 continue;
289 };
290 assert!(!conflict);
291 let published: Vec<ExGuid> = order(&server.durable, space, outline)
292 .into_iter()
293 .filter(|id| paragraphs.contains(id))
294 .collect();
295 let expected: Vec<ExGuid> = expected.iter().map(|at| paragraphs[*at]).collect();
296 assert_eq!(published, expected);
297 assert_eq!(
298 text_of(&page_of(&server.durable, space), texts[2]),
299 "Sibling 2"
300 );
301 }
302}
303
304#[test]
305fn independently_satisfied_move_confirms_without_republication() {
306 let (source, space, _, paragraphs, texts) = fixture();
307 let directory = tempfile::tempdir().unwrap();
308 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
309 let id = save(&cache, texts[0], |page| {
310 move_subtree(page, paragraphs[0], None, None)
311 })
312 .unwrap()
313 .unwrap();
314 let mut server = Server::new(&remote_with(&source, space, |page| {
315 move_subtree(page, paragraphs[0], None, None);
316 }));
317 published(&cache, &mut server, id);
318 assert_eq!((server.publications, server.confirmations), (0, 1));
319}
320
321fn cells(page: &Page) -> Vec<&TableCell> {
322 body_outlines(page)
323 .into_iter()
324 .flat_map(|outline| outline.paragraphs.iter())
325 .filter_map(|paragraph| match &paragraph.content {
326 ParagraphContent::Table(table) => Some(table),
327 _ => None,
328 })
329 .flat_map(|table| table.rows.iter())
330 .flat_map(|row| row.cells.iter())
331 .collect()
332}
333
334fn cell_of(page: &Page, id: ExGuid) -> &TableCell {
335 cells(page)
336 .into_iter()
337 .find(|cell| cell.id == id)
338 .expect("the cell is on the page")
339}
340
341fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell {
342 outlines_mut(page)
343 .into_iter()
344 .flat_map(|outline| outline.paragraphs.iter_mut())
345 .filter_map(|paragraph| match &mut paragraph.content {
346 ParagraphContent::Table(table) => Some(table),
347 _ => None,
348 })
349 .flat_map(|table| table.rows.iter_mut())
350 .flat_map(|row| row.cells.iter_mut())
351 .find(|cell| cell.id == id)
352 .expect("the cell is on the page")
353}
354
355/// Moves a cell's sole paragraph to the end of another cell, or deletes it, leaving the
356/// cell a new empty paragraph, as an edit that empties a cell must.
357fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option<ExGuid>) {
358 let emptied = cell_mut(page, cell);
359 let mut replacement = model_ops::fresh_paragraph(&emptied.paragraphs[0], "");
360 replacement.level = emptied.paragraphs[0].level;
361 emptied.paragraphs.insert(0, replacement);
362 let moved = cell_mut(page, cell).paragraphs.remove(1);
363 let Some(destination) = destination else {
364 return;
365 };
366 let destination = cell_mut(page, destination);
367 let mut moved = moved;
368 moved.level = destination.paragraphs[0].level;
369 moved.parent = None;
370 destination.paragraphs.push(moved);
371}
372
373#[test]
374fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay() {
375 let source =
376 include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
377 let store = Store::parse(source).unwrap();
378 let index = RevisionIndex::parse(&store).unwrap();
379 let document = Document::parse(&index).unwrap();
380 let (space, page) = document
381 .pages()
382 .unwrap()
383 .into_iter()
384 .find_map(|(space, _)| {
385 let page = Page::from_space(&document, space).unwrap();
386 (page.title == "Delete sole cell paragraph").then_some((space, page))
387 })
388 .unwrap();
389 let other = document
390 .pages()
391 .unwrap()
392 .into_iter()
393 .find(|(other, _)| *other != space)
394 .unwrap()
395 .0;
396 let text = |cell: &TableCell| cell.paragraphs[0].text().unwrap().id;
397 let cell = cells(&page)
398 .into_iter()
399 .find(|cell| {
400 cell.paragraphs[0]
401 .text()
402 .unwrap()
403 .text
404 .text()
405 .starts_with("Target ")
406 })
407 .unwrap();
408 let (cell, target) = (cell.id, text(cell));
409 let neighbour = cells(&page)
410 .into_iter()
411 .find(|other| other.id != cell)
412 .unwrap();
413 let (neighbour, neighbour_text) = (neighbour.id, text(neighbour));
414 for move_out in [false, true] {
415 for competing in [false, true] {
416 let directory = tempfile::tempdir().unwrap();
417 let path = directory.path().join("cell.sqlite");
418 let cache = Replica::create(&path, source).unwrap();
419 let id = save_page(&cache, space, |page| {
420 empty_cell(page, cell, move_out.then_some(neighbour));
421 })
422 .unwrap();
423 let local = snapshot(&cache);
424 let queue = cache.pending().unwrap();
425 {
426 let page = page_of(&local, space);
427 let paragraphs = &cell_of(&page, cell).paragraphs;
428 assert_eq!(paragraphs.len(), 1);
429 assert_ne!(paragraphs[0].text().unwrap().id, target);
430 assert!(paragraphs[0].text().unwrap().text.text().is_empty());
431 }
432 drop(cache);
433 let cache = Replica::open(&path).unwrap();
434 assert_eq!(cache.pending().unwrap(), queue);
435 assert_eq!(pages(&snapshot(&cache)), pages(&local));
436 let mut server = Server::new(&if competing {
437 remote_with(source, space, |page| {
438 let target = cell_mut(page, cell);
439 let mut sibling =
440 model_ops::fresh_paragraph(&target.paragraphs[0], "Remote sibling");
441 sibling.level = target.paragraphs[0].level;
442 target.paragraphs.push(sibling);
443 })
444 } else {
445 remote_with(source, other, |page| {
446 let text = body_outlines(page)[0].paragraphs[0].text().unwrap().id;
447 replace_text(page, text, 0..0, "Remote ");
448 })
449 });
450 if competing {
451 // The remote's new paragraph and the replacement both stay; the target goes.
452 published(&cache, &mut server, id);
453 let durable = page_of(&server.durable, space);
454 let texts: Vec<_> = cell_of(&durable, cell)
455 .paragraphs
456 .iter()
457 .map(|paragraph| paragraph.text().unwrap())
458 .collect();
459 assert_eq!(texts.len(), 2);
460 assert!(texts.iter().all(|text| text.id != target));
461 assert!(
462 texts
463 .iter()
464 .any(|text| text.text.text() == "Remote sibling")
465 );
466 continue;
467 }
468 published(&cache, &mut server, id);
469 let replacement = {
470 let durable = page_of(&server.durable, space);
471 let paragraphs = &cell_of(&durable, cell).paragraphs;
472 assert_eq!(paragraphs.len(), 1);
473 assert_ne!(paragraphs[0].text().unwrap().id, target);
474 paragraphs[0].text().unwrap().id
475 };
476 let edit = save_page(&cache, space, |page| {
477 let cell = cell_mut(page, cell);
478 let text = cell.paragraphs[0].text_mut().unwrap();
479 text.text = Paragraph::new(
480 "Local replacement".into(),
481 text.text.format_at(0).unwrap().clone(),
482 );
483 })
484 .unwrap();
485 drop(cache);
486 let cache = Replica::open(&path).unwrap();
487 published(&cache, &mut server, edit);
488 let durable = page_of(&server.durable, space);
489 let paragraphs = &cell_of(&durable, cell).paragraphs;
490 assert_eq!(paragraphs.len(), 1);
491 assert_eq!(paragraphs[0].text().unwrap().id, replacement);
492 assert_eq!(
493 paragraphs[0].text().unwrap().text.text(),
494 "Local replacement"
495 );
496 let published = cell_of(&durable, neighbour);
497 assert_eq!(
498 published.paragraphs.len(),
499 1 + usize::from(move_out),
500 "the moved paragraph joins its destination"
501 );
502 assert_eq!(published.paragraphs[0].text().unwrap().id, neighbour_text);
503 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") {
504 let output = std::path::PathBuf::from(output)
505 .join(if move_out { "cell-move" } else { "cell-delete" })
506 .join("candidate");
507 std::fs::create_dir_all(&output).unwrap();
508 std::fs::write(output.join("synthetic.one"), &server.durable).unwrap();
509 }
510 }
511 }
512}
513
514/// The move or deletion each native fixture page is reconciled against.
515fn native_change(
516 name: &str,
517 outline: ExGuid,
518 target: (ExGuid, ExGuid),
519 anchor: Option<ExGuid>,
520) -> Change<Page> {
521 let name = name.to_owned();
522 Box::new(move |page| {
523 let present = paragraph_with(page, target.1).is_some();
524 match name.as_str() {
525 "Move leaf down" | "Move subtree down" if present => {
526 move_subtree(page, target.0, None, None);
527 }
528 "Move subtree up" if present => {
529 move_subtree(page, target.0, None, anchor);
530 }
531 "Delete outline" | "Move outline" | "Resize outline" | "Automatic outline size" => {
532 page.objects
533 .retain(|object| !matches!(object, PageObject::Outline(o) if o.id == outline));
534 }
535 _ if present => {
536 delete_paragraph(page, target.1);
537 // An outline cannot survive without a paragraph.
538 page.objects.retain(
539 |object| !matches!(object, PageObject::Outline(o) if o.paragraphs.is_empty()),
540 );
541 }
542 _ => {}
543 }
544 })
545}
546
547#[test]
548fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() {
549 let mut server = Server::new(NATIVE);
550 let mut records = Vec::new();
551 for (name, space, outline, target, dependent) in native_pages(BEFORE) {
552 let page = page_of(BEFORE, space);
553 let paragraphs = &outline_of(&page, outline).paragraphs;
554 let target = (
555 paragraphs
556 .iter()
557 .find(|p| p.text().is_some_and(|text| text.id == target))
558 .unwrap()
559 .id,
560 target,
561 );
562 let anchor = paragraphs
563 .iter()
564 .find(|p| {
565 p.text()
566 .is_some_and(|text| text.text.text().starts_with("Anchor"))
567 })
568 .map(|paragraph| paragraph.id);
569 let change = native_change(&name, outline, target, anchor);
570 let save_both = |page: &mut Page| {
571 change(page);
572 replace_text(page, dependent, 0..0, "Offline ");
573 };
574 let directory = tempfile::tempdir().unwrap();
575 let path = directory.path().join("native.sqlite");
576 let cache = Replica::create(&path, BEFORE).unwrap();
577 let id = save(&cache, dependent, save_both).unwrap().unwrap();
578 let merged = !conflicted(&cache, &mut server, space);
579 assert!(matches!(
580 cache.status(id).unwrap(),
581 Some(EditStatus::Published { .. })
582 ));
583 if !merged {
584 // The dependent text merged; the writer makes the change again.
585 drop(cache);
586 let cache = Replica::open(&path).unwrap();
587 // Unless the remote made the same change.
588 if let Some(again) = save(&cache, dependent, &change).unwrap() {
589 published(&cache, &mut server, again);
590 }
591 }
592 let durable = page_of(&server.durable, space);
593 assert!(
594 text_of(&durable, dependent).starts_with("Offline "),
595 "{name} keeps its dependent text"
596 );
597 let mut reapplied = durable.clone();
598 change(&mut reapplied);
599 assert_eq!(reapplied, durable, "{name} keeps its reconciled change");
600 records.push((name, if merged { "merged" } else { "reviewed" }));
601 }
602 let reviewed = records
603 .iter()
604 .filter(|(_, result)| *result == "reviewed")
605 .count();
606 assert_eq!((records.len(), reviewed), (14, 7), "{records:?}");
607 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") {
608 let output = std::path::PathBuf::from(output);
609 std::fs::create_dir_all(output.join("candidate")).unwrap();
610 std::fs::write(output.join("candidate/synthetic.one"), &server.durable).unwrap();
611 std::fs::write(
612 output.join("cases.json"),
613 serde_json::to_vec_pretty(&records).unwrap(),
614 )
615 .unwrap();
616 }
617}