1//! Outline layout through page-model saves: reconciliation against the remote page,
2//! review of competing layout, twelve offline writers, and the native outline fixtures.
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::{Outline, Page, PageObject, PageParagraph},
12};
13use serde_json::json;
14
15#[path = "support/server.rs"]
16mod server;
17use server::{Fault, Server, conflicts, pages, snapshot};
18#[path = "support/model_ops.rs"]
19mod model_ops;
20use model_ops::{
21 delete_paragraph, insert_outline, outlines_mut, page_of, paragraph_with, replace_text, restyle,
22 save,
23};
24#[path = "support/model_schedule.rs"]
25mod model_schedule;
26#[path = "../../onestore/tests/support/sweep.rs"]
27mod sweep;
28#[path = "sync/tree.rs"]
29mod tree;
30
31const BEFORE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
32const NATIVE: &[u8] = include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one");
33
34/// One model change, boxed so alternative cases can be listed together.
35pub(crate) type Change<T> = Box<dyn Fn(&mut T)>;
36
37/// A one-outline page with its space, outline and first paragraph's text identities.
38pub(crate) fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid) {
39 let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap();
40 let store = Store::parse(&source).unwrap();
41 let index = RevisionIndex::parse(&store).unwrap();
42 let document = Document::parse(&index).unwrap();
43 let space = document.pages().unwrap()[0].0;
44 let page = Page::from_space(&document, space).unwrap();
45 let outline = body_outlines(&page)[0];
46 let ids = (outline.id, outline.paragraphs[0].text().unwrap().id);
47 (source, space, ids.0, ids.1)
48}
49
50pub(crate) fn body_outlines(page: &Page) -> Vec<&Outline> {
51 page.objects
52 .iter()
53 .filter_map(|object| match object {
54 PageObject::Outline(outline) => Some(outline),
55 _ => None,
56 })
57 .collect()
58}
59
60pub(crate) fn outline_of(page: &Page, id: ExGuid) -> &Outline {
61 body_outlines(page)
62 .into_iter()
63 .find(|outline| outline.id == id)
64 .expect("the outline is on the page")
65}
66
67pub(crate) fn outline_mut(page: &mut Page, id: ExGuid) -> &mut Outline {
68 outlines_mut(page)
69 .into_iter()
70 .find(|outline| outline.id == id)
71 .expect("the outline is on the page")
72}
73
74pub(crate) fn paragraph_mut(page: &mut Page, text: ExGuid) -> &mut PageParagraph {
75 outlines_mut(page)
76 .into_iter()
77 .flat_map(|outline| outline.paragraphs.iter_mut())
78 .find(|paragraph| paragraph.text().is_some_and(|value| value.id == text))
79 .expect("the paragraph is on the page")
80}
81
82/// The text identity of the stored paragraph carrying exactly `value`.
83pub(crate) fn text_with(page: &Page, value: &str) -> ExGuid {
84 body_outlines(page)
85 .into_iter()
86 .flat_map(|outline| outline.paragraphs.iter())
87 .find_map(|paragraph| {
88 let text = paragraph.text()?;
89 (text.text.text() == value).then_some(text.id)
90 })
91 .expect("a paragraph carries the text")
92}
93
94pub(crate) fn text_of(page: &Page, text: ExGuid) -> String {
95 paragraph_with(page, text)
96 .and_then(PageParagraph::text)
97 .expect("the paragraph is on the page")
98 .text
99 .text()
100 .to_owned()
101}
102
103/// A remote image holding `change`, published as another writer would.
104pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut Page)) -> Vec<u8> {
105 let mut page = page_of(source, space);
106 change(&mut page);
107 ops::saved(source, space, &page)
108 .unwrap()
109 .as_slice()
110 .to_vec()
111}
112
113/// Runs one synchronization step, which publishes; true when the remote's version of
114/// `space` stayed and the local one became a conflict page under it.
115pub(crate) fn conflicted(cache: &Replica, server: &mut Server, space: ExGuid) -> bool {
116 let count = |image: &[u8]| {
117 conflicts(image)
118 .into_iter()
119 .filter(|(page, _)| *page == space)
120 .map(|(_, pages)| pages.len())
121 .sum::<usize>()
122 };
123 let before = count(&server.durable);
124 assert!(matches!(
125 cache.sync_once(server).unwrap().edit,
126 Some((_, EditStatus::Published { .. }))
127 ));
128 count(&server.durable) > before
129}
130
131/// Publishes the batch holding `id`, unless an earlier step already did.
132pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) {
133 if !matches!(
134 cache.status(id).unwrap(),
135 Some(EditStatus::Published { .. })
136 ) {
137 assert!(matches!(
138 cache.sync_once(server).unwrap().edit,
139 Some((_, EditStatus::Published { .. }))
140 ));
141 }
142 assert!(matches!(
143 cache.status(id).unwrap(),
144 Some(EditStatus::Published { .. })
145 ));
146}
147
148fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) {
149 move |outline| {
150 outline.layout.x = Some(x);
151 outline.layout.y = Some(y);
152 }
153}
154
155fn resized(points: f32, user_set: bool) -> impl Fn(&mut Outline) {
156 move |outline| {
157 outline.layout.max_width = Some(points);
158 outline.layout.width_set_by_user = Some(user_set);
159 }
160}
161
162#[test]
163fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page() {
164 let (source, space, outline, text_id) = fixture();
165 let changes: [Change<Page>; 3] = [
166 Box::new(move |page| moved(180.0, 216.0)(outline_mut(page, outline))),
167 Box::new(move |page| resized(144.0, true)(outline_mut(page, outline))),
168 Box::new(move |page| paragraph_mut(page, text_id).collapsed = true),
169 ];
170 for change in changes {
171 let directory = tempfile::tempdir().unwrap();
172 let path = directory.path().join("cache.sqlite");
173 let cache = Replica::create(&path, &source).unwrap();
174 let id = save(&cache, text_id, &change).unwrap().unwrap();
175 let expected = page_of(&snapshot(&cache), space);
176 let pending = cache.pending().unwrap();
177 let local = snapshot(&cache);
178 drop(cache);
179 let cache = Replica::open(&path).unwrap();
180 assert_eq!(cache.pending().unwrap(), pending);
181 assert_eq!(pages(&snapshot(&cache)), pages(&local));
182 let mut server = Server::new(&remote_with(&source, space, |page| {
183 restyle(page, text_id, 0..8, |format| format.bold = Some(true));
184 }));
185 published(&cache, &mut server, id);
186 let durable = page_of(&server.durable, space);
187 assert_eq!(
188 outline_of(&durable, outline).layout,
189 outline_of(&expected, outline).layout
190 );
191 assert_eq!(
192 paragraph_with(&durable, text_id).unwrap().collapsed,
193 paragraph_with(&expected, text_id).unwrap().collapsed
194 );
195 assert!(cache.pending().unwrap().is_empty());
196
197 let dependent = save(&cache, text_id, |page| {
198 replace_text(page, text_id, 0..0, "Local ")
199 })
200 .unwrap()
201 .unwrap();
202 drop(cache);
203 let cache = Replica::open(&path).unwrap();
204 published(&cache, &mut server, dependent);
205 let durable = page_of(&server.durable, space);
206 assert_eq!(text_of(&durable, text_id), "Local Original 🦀 é");
207 let text = &paragraph_with(&durable, text_id)
208 .unwrap()
209 .text()
210 .unwrap()
211 .text;
212 assert_eq!(text.spans()[0].format.bold, Some(true));
213 assert_eq!(
214 outline_of(&durable, outline).layout,
215 outline_of(&expected, outline).layout
216 );
217 assert_eq!(server.publications, 2);
218 drop(cache);
219 let cache = Replica::open(&path).unwrap();
220 for id in [id, dependent] {
221 assert!(matches!(
222 cache.status(id).unwrap(),
223 Some(EditStatus::Published { .. })
224 ));
225 }
226 }
227}
228
229#[test]
230fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_title() {
231 let (source, space, outline, _) = fixture();
232 let mut page = page_of(&source, space);
233 insert_outline(&mut page, 144.0, 36.0, "Second");
234 let source = ops::saved(&source, space, &page)
235 .unwrap()
236 .as_slice()
237 .to_vec();
238 let second = text_with(&page_of(&source, space), "Second");
239 let directory = tempfile::tempdir().unwrap();
240 let path = directory.path().join("titles.sqlite");
241 let cache = Replica::create(&path, &source).unwrap();
242 let id = save(&cache, second, |page| {
243 moved(216.0, 72.0)(outline_mut(page, outline));
244 })
245 .unwrap()
246 .unwrap();
247 drop(cache);
248 let mut server = Server::new(&remote_with(&source, space, |page| {
249 replace_text(page, second, 0..6, "Remote second 🐈");
250 }));
251 server.fault = Fault::UnknownAfter;
252 let cache = Replica::open(&path).unwrap();
253 assert!(cache.sync_once(&mut server).is_err());
254 assert!(matches!(
255 cache.status(id).unwrap(),
256 Some(EditStatus::AwaitingConfirmation { .. })
257 ));
258 assert_eq!(page_of(&server.durable, space).title, "Original 🦀 é");
259 assert_eq!(page_of(&server.visible, space).title, "Remote second 🐈");
260 drop(cache);
261 let cache = Replica::open(&path).unwrap();
262 published(&cache, &mut server, id);
263 assert_eq!(page_of(&server.durable, space).title, "Remote second 🐈");
264 assert!(cache.pending().unwrap().is_empty());
265 assert_eq!((server.publications, server.confirmations), (1, 1));
266}
267
268/// Competing layout keeps the remote's on the page and the local one on its conflict page.
269#[test]
270fn competing_layout_keeps_the_local_one_on_a_conflict_page() {
271 let (source, space, outline, text_id) = fixture();
272 let cases: [(Change<Outline>, Change<Outline>); 2] = [
273 (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))),
274 (
275 Box::new(resized(144.0, true)),
276 Box::new(resized(216.0, false)),
277 ),
278 ];
279 for (local_change, remote_change) in cases {
280 let directory = tempfile::tempdir().unwrap();
281 let path = directory.path().join("cache.sqlite");
282 let cache = Replica::create(&path, &source).unwrap();
283 save(&cache, text_id, |page| {
284 local_change(outline_mut(page, outline))
285 })
286 .unwrap()
287 .unwrap();
288 let local = page_of(&snapshot(&cache), space);
289 let remote = remote_with(&source, space, |page| {
290 remote_change(outline_mut(page, outline));
291 });
292 let mut server = Server::new(&remote);
293 assert!(conflicted(&cache, &mut server, space));
294 assert_eq!(server.publications, 1);
295 let durable = page_of(&server.durable, space);
296 assert_eq!(
297 outline_of(&durable, outline).layout,
298 outline_of(&page_of(&remote, space), outline).layout
299 );
300 let arena = onestore::Arena::default();
301 let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap();
302 let conflict = section.conflicts().unwrap()[0].1[0].space;
303 let kept = section.page(conflict).unwrap();
304 assert_eq!(
305 body_outlines(&kept)[0].layout,
306 outline_of(&local, outline).layout
307 );
308 let dependent = save(&cache, text_id, |page| {
309 replace_text(page, text_id, 0..0, "Local ")
310 })
311 .unwrap()
312 .unwrap();
313 drop(cache);
314 let cache = Replica::open(&path).unwrap();
315 published(&cache, &mut server, dependent);
316 assert_eq!(
317 text_of(&page_of(&server.durable, space), text_id),
318 "Local Original 🦀 é"
319 );
320 }
321}
322
323#[test]
324fn a_local_move_and_a_remote_resize_of_one_outline_converge() {
325 let (source, space, outline, text_id) = fixture();
326 let directory = tempfile::tempdir().unwrap();
327 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
328 let id = save(&cache, text_id, |page| {
329 moved(180.0, 216.0)(outline_mut(page, outline))
330 })
331 .unwrap()
332 .unwrap();
333 let mut server = Server::new(&remote_with(&source, space, |page| {
334 resized(252.0, true)(outline_mut(page, outline));
335 }));
336 published(&cache, &mut server, id);
337 let layout = outline_of(&page_of(&server.durable, space), outline)
338 .layout
339 .clone();
340 assert_eq!((layout.x, layout.y), (Some(180.0), Some(216.0)));
341 assert_eq!(
342 (layout.max_width, layout.width_set_by_user),
343 (Some(252.0), Some(true))
344 );
345}
346
347#[test]
348fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() {
349 let (source, space, outline, text_id) = fixture();
350 let directory = tempfile::tempdir().unwrap();
351 let mut server = Server::new(&source);
352 let mut reviewed = 0;
353 for round in 0..4 {
354 for actor in 0..12 {
355 let path = directory.path().join(format!("{actor}.sqlite"));
356 let cache = if round == 0 {
357 Replica::create(&path, &source).unwrap()
358 } else {
359 Replica::open(&path).unwrap()
360 };
361 let change = |page: &mut Page| {
362 let layout = &mut outline_mut(page, outline).layout;
363 layout.x = Some((actor + 3) as f32 * 36.0);
364 layout.y = Some((round + 3) as f32 * 36.0);
365 layout.max_width = Some((actor + round + 3) as f32 * 36.0);
366 layout.width_set_by_user = Some(true);
367 paragraph_mut(page, text_id).collapsed = (actor + round) % 2 != 0;
368 };
369 let id = save(&cache, text_id, change).unwrap().unwrap();
370 let expected = page_of(&snapshot(&cache), space);
371 drop(cache);
372 let cache = Replica::open(&path).unwrap();
373 if conflicted(&cache, &mut server, space) {
374 // The writer makes the change again over the remote's version.
375 let again = save(&cache, text_id, change).unwrap().unwrap();
376 reviewed += 1;
377 published(&cache, &mut server, again);
378 }
379 assert!(matches!(
380 cache.status(id).unwrap(),
381 Some(EditStatus::Published { .. })
382 ));
383 assert!(cache.pending().unwrap().is_empty());
384 let durable = page_of(&server.durable, space);
385 assert_eq!(
386 outline_of(&durable, outline).layout,
387 outline_of(&expected, outline).layout
388 );
389 assert_eq!(
390 paragraph_with(&durable, text_id).unwrap().collapsed,
391 (actor + round) % 2 != 0
392 );
393 assert_eq!(text_of(&durable, text_id), "Original 🦀 é");
394 }
395 }
396 assert_eq!(reviewed, 47);
397}
398
399#[test]
400fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() {
401 let (source, space, outline, text_id) = fixture();
402 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
403 let directory = tempfile::tempdir().unwrap();
404 let path = directory.path().join("cache.sqlite");
405 let cache = Replica::create(&path, &source).unwrap();
406 let id = save(&cache, text_id, |page| {
407 resized(144.0, true)(outline_mut(page, outline))
408 })
409 .unwrap()
410 .unwrap();
411 let local = snapshot(&cache);
412 let mut server = Server::new(&source);
413 server.fault = fault;
414 assert!(cache.sync_once(&mut server).is_err());
415 let status = cache.status(id).unwrap().unwrap();
416 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
417 drop(cache);
418 let cache = Replica::open(&path).unwrap();
419 if matches!(fault, Fault::UnknownAfter) {
420 published(&cache, &mut server, id);
421 let layout = outline_of(&page_of(&server.durable, space), outline)
422 .layout
423 .clone();
424 assert_eq!(layout.max_width, Some(144.0));
425 assert_eq!((server.publications, server.confirmations), (1, 1));
426 continue;
427 }
428 assert_eq!(
429 cache.sync_once(&mut server).unwrap().edit,
430 Some((id, status.clone()))
431 );
432 server.visible = remote_with(&source, space, |page| {
433 resized(216.0, true)(outline_mut(page, outline));
434 });
435 assert_eq!(
436 cache.sync_once(&mut server).unwrap().edit,
437 Some((id, status.clone()))
438 );
439 assert_eq!(pages(&snapshot(&cache)), pages(&local));
440 server.visible = remote_with(&source, space, |page| {
441 resized(144.0, true)(outline_mut(page, outline));
442 });
443 published(&cache, &mut server, id);
444 assert_eq!((server.publications, server.confirmations), (1, 1));
445 }
446}
447
448/// The layout or collapse change the native fixture page is reconciled against.
449fn native_case(name: &str, outline: ExGuid, target: ExGuid) -> Change<Page> {
450 let layout: Option<Change<Outline>> = match name {
451 "Move outline" => Some(Box::new(moved(252.0, 288.0))),
452 "Resize outline" | "Automatic outline size" | "Delete outline" => {
453 Some(Box::new(resized(252.0, true)))
454 }
455 _ => None,
456 };
457 let collapsed = name != "Expand subtree";
458 Box::new(move |page| match &layout {
459 Some(change) => {
460 if body_outlines(page).iter().any(|o| o.id == outline) {
461 change(outline_mut(page, outline));
462 }
463 }
464 None => {
465 if paragraph_with(page, target).is_some() {
466 paragraph_mut(page, target).collapsed = collapsed;
467 }
468 }
469 })
470}
471
472/// Native fixture pages with two body outlines: the page, its first outline, the
473/// "Target " paragraph's text and an independent paragraph's text in the second outline.
474pub(crate) fn native_pages(source: &[u8]) -> Vec<(String, ExGuid, ExGuid, ExGuid, ExGuid)> {
475 let store = Store::parse(source).unwrap();
476 let index = RevisionIndex::parse(&store).unwrap();
477 let document = Document::parse(&index).unwrap();
478 let mut cases = Vec::new();
479 for (space, _) in document.pages().unwrap() {
480 let page = Page::from_space(&document, space).unwrap();
481 let outlines = body_outlines(&page);
482 if outlines.len() != 2 || page.title == "Unicode rich text" {
483 continue;
484 }
485 let target = outlines[0]
486 .paragraphs
487 .iter()
488 .find(|paragraph| {
489 paragraph
490 .text()
491 .is_some_and(|text| text.text.text().starts_with("Target "))
492 })
493 .expect("each fixture page marks its target paragraph");
494 cases.push((
495 page.title.clone(),
496 space,
497 outlines[0].id,
498 target.text().unwrap().id,
499 outlines[1].paragraphs[0].text().unwrap().id,
500 ));
501 }
502 cases
503}
504
505#[test]
506fn native_moves_deletions_and_layout_changes_merge_or_keep_a_conflict_page() {
507 let mut server = Server::new(NATIVE);
508 let mut records = Vec::new();
509 for (name, space, outline, target, dependent) in native_pages(BEFORE) {
510 let change = native_case(&name, outline, target);
511 let save_both = |page: &mut Page| {
512 change(page);
513 replace_text(page, dependent, 0..0, "Local ");
514 };
515 let directory = tempfile::tempdir().unwrap();
516 let path = directory.path().join("cache.sqlite");
517 let cache = Replica::create(&path, BEFORE).unwrap();
518 let id = save(&cache, dependent, save_both).unwrap().unwrap();
519 let merged = !conflicted(&cache, &mut server, space);
520 assert!(matches!(
521 cache.status(id).unwrap(),
522 Some(EditStatus::Published { .. })
523 ));
524 if !merged {
525 // The dependent text merged; the writer makes the change again.
526 drop(cache);
527 let cache = Replica::open(&path).unwrap();
528 // Unless the remote made the same change.
529 if let Some(again) = save(&cache, dependent, &change).unwrap() {
530 published(&cache, &mut server, again);
531 }
532 }
533 let durable = page_of(&server.durable, space);
534 assert!(
535 text_of(&durable, dependent).starts_with("Local "),
536 "{name} keeps its dependent text"
537 );
538 let mut reapplied = durable.clone();
539 change(&mut reapplied);
540 assert_eq!(reapplied, durable, "{name} keeps its reconciled change");
541 let (object, change) = match name.as_str() {
542 "Move outline" => (outline, json!({"Position": {"x": 252.0, "y": 288.0}})),
543 "Resize outline" | "Automatic outline size" | "Delete outline" => (
544 outline,
545 json!({"Width": {"points": 252.0, "user_set": true}}),
546 ),
547 _ => (
548 paragraph_with(&page_of(BEFORE, space), target).unwrap().id,
549 json!({"Collapsed": name != "Expand subtree"}),
550 ),
551 };
552 records.push(json!({
553 "name": name,
554 "outcome": if merged { "merged" } else { "reviewed" },
555 "object": object.to_string(),
556 "change": change,
557 "dependent_text": dependent.to_string(),
558 }));
559 }
560 let reviewed = records
561 .iter()
562 .filter(|record| record["outcome"] == "reviewed")
563 .count();
564 assert_eq!((records.len(), reviewed), (14, 7), "{records:?}");
565 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") {
566 let output = std::path::PathBuf::from(output);
567 assert!(output.is_absolute());
568 std::fs::create_dir_all(output.join("candidate")).unwrap();
569 std::fs::write(output.join("candidate/synthetic.one"), &server.durable).unwrap();
570 std::fs::write(
571 output.join("cases.json"),
572 serde_json::to_vec_pretty(&records).unwrap(),
573 )
574 .unwrap();
575 }
576}
577
578#[test]
579fn a_new_native_wrap_reservation_keeps_the_local_width_on_a_conflict_page() {
580 let (_, space, outline, _, text_id) = native_pages(BEFORE)
581 .into_iter()
582 .find(|(name, ..)| name == "Move outline")
583 .unwrap();
584 let stored = outline_of(&page_of(BEFORE, space), outline).layout.clone();
585 let remote = outline_of(&page_of(NATIVE, space), outline).layout.clone();
586 assert_eq!(
587 (stored.max_width, stored.width_set_by_user),
588 (remote.max_width, remote.width_set_by_user)
589 );
590 assert!(remote.reserved_width.is_some());
591 let directory = tempfile::tempdir().unwrap();
592 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
593 let id = save(&cache, text_id, |page| {
594 resized(144.0, true)(outline_mut(page, outline))
595 })
596 .unwrap()
597 .unwrap();
598 let mut server = Server::new(NATIVE);
599 assert!(conflicted(&cache, &mut server, space));
600 assert!(matches!(
601 cache.status(id).unwrap(),
602 Some(EditStatus::Published { .. })
603 ));
604 let again = save(&cache, text_id, |page| {
605 resized(144.0, true)(outline_mut(page, outline))
606 })
607 .unwrap()
608 .unwrap();
609 published(&cache, &mut server, again);
610 let layout = outline_of(&page_of(&server.durable, space), outline)
611 .layout
612 .clone();
613 assert_eq!((layout.x, layout.y), (remote.x, remote.y));
614 assert_eq!(layout.max_width, Some(144.0));
615 assert!(layout.reserved_width.is_none());
616}
617
618/// `source` with a file on its page, and the file's identity.
619fn with_file(source: &[u8], space: ExGuid) -> (Vec<u8>, ExGuid) {
620 let id = onestore::page::text::new_id().unwrap();
621 let file = onestore::page::Attachment {
622 id,
623 filename: "float.txt".into(),
624 source_path: None,
625 size: Some([24.0, 24.0]),
626 layout: onestore::document::Layout {
627 x: Some(342.0),
628 y: Some(284.4),
629 max_width: Some(54.0),
630 max_height: Some(63.0),
631 width_set_by_user: Some(false),
632 reserved_width: None,
633 },
634 bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])),
635 preview: None,
636 recording: None,
637 tags: Vec::new(),
638 };
639 let image = remote_with(source, space, |page| {
640 page.objects.push(PageObject::Attachment(file))
641 });
642 (image, id)
643}
644
645fn file_mut(page: &mut Page, id: ExGuid) -> &mut onestore::page::Attachment {
646 page.objects
647 .iter_mut()
648 .find_map(|object| match object {
649 PageObject::Attachment(file) if file.id == id => Some(file),
650 _ => None,
651 })
652 .unwrap()
653}
654
655/// A file on the page merges as an outline's position does: a local move and a remote rename
656/// both land, and competing moves keep the local one on a conflict page.
657#[test]
658fn files_on_the_page_move_through_sync_and_competing_moves_conflict() {
659 let (source, space, _, text_id) = fixture();
660 let (source, file) = with_file(&source, space);
661 let place = |x, y| {
662 move |page: &mut Page| {
663 let layout = &mut file_mut(page, file).layout;
664 [layout.x, layout.y] = [Some(x), Some(y)];
665 }
666 };
667 let directory = tempfile::tempdir().unwrap();
668 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
669 let id = save(&cache, text_id, place(126.0, 410.4)).unwrap().unwrap();
670 let mut server = Server::new(&remote_with(&source, space, |page| {
671 file_mut(page, file).filename = "renamed.txt".into();
672 }));
673 published(&cache, &mut server, id);
674 let mut durable = page_of(&server.durable, space);
675 let merged = file_mut(&mut durable, file);
676 assert_eq!(
677 (merged.layout.x, merged.layout.y, merged.filename.as_str()),
678 (Some(126.0), Some(410.4), "renamed.txt")
679 );
680 assert_eq!(merged.bytes.as_deref(), Some(&b"Floating bytes"[..]));
681
682 let directory = tempfile::tempdir().unwrap();
683 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
684 save(&cache, text_id, place(126.0, 410.4)).unwrap().unwrap();
685 let remote = remote_with(&source, space, place(36.0, 50.4));
686 let mut server = Server::new(&remote);
687 assert!(conflicted(&cache, &mut server, space));
688 let mut durable = page_of(&server.durable, space);
689 assert_eq!(file_mut(&mut durable, file).layout.x, Some(36.0));
690 let arena = onestore::Arena::default();
691 let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap();
692 let conflict = section.conflicts().unwrap()[0].1[0].space;
693 let kept = section.page(conflict).unwrap();
694 let copies: Vec<_> = kept
695 .objects
696 .iter()
697 .filter_map(|object| match object {
698 PageObject::Attachment(file) => Some(file),
699 _ => None,
700 })
701 .collect();
702 let [copy] = copies.as_slice() else {
703 panic!("{kept:?}")
704 };
705 assert_eq!((copy.layout.x, copy.layout.y), (Some(126.0), Some(410.4)));
706 assert_eq!(copy.bytes.as_deref(), Some(&b"Floating bytes"[..]));
707}