1//! Page-model saves: one intent per page until it is attempted, reconciliation against the
2//! remote page, reviewed conflicts, and independence between pages.
3
4#[path = "../../onestore/tests/support/ops.rs"]
5mod ops;
6
7use notebook::{EditStatus, Replica};
8use onestore::{ExGuid, RevisionIndex, Store, document::Document};
9
10#[path = "support/server.rs"]
11mod server;
12use server::*;
13#[path = "support/model_ops.rs"]
14mod model_ops;
15
16use onestore::page::{Page, PageObject};
17
18const OUTLINES: &[u8] =
19 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
20
21fn page_of(bytes: &[u8], space: ExGuid) -> Page {
22 let store = Store::parse(bytes).unwrap();
23 let index = RevisionIndex::parse(&store).unwrap();
24 let document = Document::parse(&index).unwrap();
25 Page::from_space(&document, space).unwrap()
26}
27
28fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
29 let store = Store::parse(bytes).unwrap();
30 let index = RevisionIndex::parse(&store).unwrap();
31 let document = Document::parse(&index).unwrap();
32 document
33 .pages()
34 .unwrap()
35 .into_iter()
36 .find_map(|(sid, _)| {
37 let page = Page::from_space(&document, sid).unwrap();
38 (page.title == title).then_some((sid, page))
39 })
40 .unwrap()
41}
42
43/// Appends `suffix` to the first body paragraph of the first body outline.
44fn append(page: &mut Page, suffix: &str) {
45 let outline = page
46 .objects
47 .iter_mut()
48 .find_map(|object| match object {
49 PageObject::Outline(outline) => Some(outline),
50 _ => None,
51 })
52 .unwrap();
53 let text = outline.paragraphs[0].text_mut().unwrap();
54 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
55 let format = text.text.format_at(end).unwrap().clone();
56 text.text
57 .apply(onestore::page::text::Edit {
58 range: end..end,
59 replacement: onestore::page::Paragraph::new(suffix.into(), format),
60 })
61 .unwrap();
62}
63
64fn first_text(page: &Page) -> String {
65 page.objects
66 .iter()
67 .find_map(|object| match object {
68 PageObject::Outline(outline) => {
69 Some(outline.paragraphs[0].text().unwrap().text.text().to_owned())
70 }
71 _ => None,
72 })
73 .unwrap()
74}
75
76#[test]
77fn a_saved_page_model_publishes_and_reads_back() {
78 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
79 let directory = tempfile::tempdir().unwrap();
80 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
81 append(&mut after, " saved 🦀");
82 let id = model_ops::save_as(&cache, space, &after, "Model author")
83 .unwrap()
84 .unwrap();
85 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
86 assert_eq!(page_of(&snapshot(&cache), space), after);
87 let pending = cache.pending().unwrap();
88 assert_eq!(pending.len(), 1);
89 assert_eq!(pending[0].author, "Model author");
90 assert!(
91 pending[0]
92 .edit
93 .ops
94 .iter()
95 .all(|op| matches!(op, onestore::op::Op::Page { space: s, .. } if *s == space))
96 );
97 let mut server = Server::new(OUTLINES);
98 assert!(
99 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
100 );
101 assert_eq!(page_of(&server.durable, space), after);
102 assert_eq!(server.publications, 1);
103 assert!(cache.pending().unwrap().is_empty());
104 assert_eq!(
105 model_ops::save_as(&cache, space, &after, "Model author").unwrap(),
106 None
107 );
108}
109
110#[test]
111fn saves_before_an_attempt_publish_together() {
112 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
113 let directory = tempfile::tempdir().unwrap();
114 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
115 append(&mut after, " one");
116 let id = model_ops::save_as(&cache, space, &after, "Model author")
117 .unwrap()
118 .unwrap();
119 let mut later = page_of(&snapshot(&cache), space);
120 append(&mut later, " two");
121 let second = model_ops::save_as(&cache, space, &later, "Model author")
122 .unwrap()
123 .unwrap();
124 assert!(second > id);
125 assert_eq!(cache.pending().unwrap().len(), 2);
126 let mut server = Server::new(OUTLINES);
127 assert!(
128 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == second)
129 );
130 assert!(matches!(
131 cache.status(id).unwrap(),
132 Some(EditStatus::Published { .. })
133 ));
134 assert_eq!(server.publications, 1);
135 assert_eq!(
136 first_text(&page_of(&server.durable, space)),
137 first_text(&later)
138 );
139 let mut third = page_of(&snapshot(&cache), space);
140 append(&mut third, " three");
141 let next = model_ops::save_as(&cache, space, &third, "Model author")
142 .unwrap()
143 .unwrap();
144 assert!(next > second);
145}
146
147#[test]
148fn an_attempted_save_is_never_rewritten() {
149 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
150 let directory = tempfile::tempdir().unwrap();
151 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
152 append(&mut after, " uncertain");
153 let id = model_ops::save_as(&cache, space, &after, "Model author")
154 .unwrap()
155 .unwrap();
156 let mut server = Server::new(OUTLINES);
157 server.fault = Fault::UnknownAfter;
158 assert!(cache.sync_once(&mut server).is_err());
159 assert!(matches!(
160 cache.status(id).unwrap(),
161 Some(EditStatus::AwaitingConfirmation { .. })
162 ));
163 let mut more = page_of(&snapshot(&cache), space);
164 append(&mut more, " more");
165 let next = model_ops::save_as(&cache, space, &more, "Model author")
166 .unwrap()
167 .unwrap();
168 assert!(next > id);
169 let pending = cache.pending().unwrap();
170 assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]);
171 assert_eq!(page_of(&snapshot(&cache), space), more);
172 assert!(matches!(
173 cache.status(id).unwrap(),
174 Some(EditStatus::AwaitingConfirmation { .. })
175 ));
176 assert_eq!(cache.status(next).unwrap(), Some(EditStatus::Pending));
177}
178
179/// A remote change to the saved text keeps the remote's page and the local one as its
180/// conflict page; the writer's change made again publishes over the remote's.
181#[test]
182fn a_remote_change_to_the_saved_page_keeps_a_conflict_page() {
183 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
184 let directory = tempfile::tempdir().unwrap();
185 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
186 append(&mut after, " local");
187 let id = model_ops::save_as(&cache, space, &after, "Model author")
188 .unwrap()
189 .unwrap();
190 let mut remote_page = page_of(OUTLINES, space);
191 append(&mut remote_page, " remote");
192 let remote = ops::saved(OUTLINES, space, &remote_page).unwrap();
193 let mut server = Server::new(remote.as_slice());
194 assert!(matches!(
195 cache.sync_once(&mut server).unwrap().edit,
196 Some((_, EditStatus::Published { .. }))
197 ));
198 assert!(matches!(
199 cache.status(id).unwrap(),
200 Some(EditStatus::Published { .. })
201 ));
202 assert_eq!(server.publications, 1);
203 let published = page_of(&server.durable, space);
204 assert_eq!(first_text(&published), first_text(&remote_page));
205 assert_eq!(
206 conflicts(&server.durable)[0].1[0].0,
207 "Model author",
208 "the conflict page names whose version it is"
209 );
210 let text = published
211 .objects
212 .iter()
213 .find_map(|object| match object {
214 PageObject::Outline(outline) => outline.paragraphs[0].text().map(|text| text.id),
215 _ => None,
216 })
217 .unwrap();
218 let again = model_ops::save(&cache, text, |page| append(page, " local"))
219 .unwrap()
220 .unwrap();
221 assert!(
222 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == again)
223 );
224 assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local"));
225}
226
227#[test]
228fn remote_changes_to_other_pages_do_not_conflict() {
229 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
230 let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf");
231 let directory = tempfile::tempdir().unwrap();
232 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
233 append(&mut after, " local");
234 let id = model_ops::save_as(&cache, space, &after, "Model author")
235 .unwrap()
236 .unwrap();
237 append(&mut other_page, " elsewhere");
238 let remote = ops::saved(OUTLINES, other, &other_page).unwrap();
239 let mut server = Server::new(remote.as_slice());
240 assert!(
241 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
242 );
243 assert_eq!(page_of(&server.durable, space), after);
244 assert_eq!(page_of(&server.durable, other), other_page);
245 assert_eq!(page_of(&snapshot(&cache), other), other_page);
246}
247
248#[test]
249fn pending_saves_survive_reopening_the_cache() {
250 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
251 let directory = tempfile::tempdir().unwrap();
252 let path = directory.path().join("cache.sqlite");
253 let cache = Replica::create(&path, OUTLINES).unwrap();
254 append(&mut after, " durable");
255 let id = model_ops::save_as(&cache, space, &after, "Model author")
256 .unwrap()
257 .unwrap();
258 let pending = cache.pending().unwrap();
259 drop(cache);
260 let reopened = Replica::open(&path).unwrap();
261 assert_eq!(reopened.pending().unwrap(), pending);
262 assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending));
263 assert_eq!(page_of(&snapshot(&reopened), space), after);
264}
265
266/// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`.
267fn edit_paragraph(page: &mut Page, index: usize, range: std::ops::Range<u32>, replacement: &str) {
268 let outline = page
269 .objects
270 .iter_mut()
271 .find_map(|object| match object {
272 PageObject::Outline(outline) => Some(outline),
273 _ => None,
274 })
275 .unwrap();
276 let text = outline.paragraphs[index].text_mut().unwrap();
277 let format = text.text.format_at(range.start).unwrap().clone();
278 text.text
279 .apply(onestore::page::text::Edit {
280 range,
281 replacement: onestore::page::Paragraph::new(replacement.into(), format),
282 })
283 .unwrap();
284}
285
286fn texts(page: &Page) -> Vec<String> {
287 page.objects
288 .iter()
289 .find_map(|object| match object {
290 PageObject::Outline(outline) => Some(
291 outline
292 .paragraphs
293 .iter()
294 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
295 .collect(),
296 ),
297 _ => None,
298 })
299 .unwrap()
300}
301
302fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
303 let mut page = page_of(OUTLINES, space);
304 change(&mut page);
305 Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice())
306}
307
308#[test]
309fn concurrent_edits_to_different_paragraphs_merge() {
310 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
311 let directory = tempfile::tempdir().unwrap();
312 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
313 edit_paragraph(&mut after, 0, 0..0, "Local ");
314 let id = model_ops::save_as(&cache, space, &after, "Model author")
315 .unwrap()
316 .unwrap();
317 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));
318 assert!(
319 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
320 );
321 let published = texts(&page_of(&server.durable, space));
322 assert!(published[0].starts_with("Local Anchor"), "{published:?}");
323 assert!(published[2].starts_with("Remote Trailing"), "{published:?}");
324 assert_eq!(
325 cache
326 .status(id)
327 .unwrap()
328 .map(|s| matches!(s, EditStatus::Published { .. })),
329 Some(true)
330 );
331}
332
333#[test]
334fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
335 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
336 let directory = tempfile::tempdir().unwrap();
337 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
338 append(&mut after, " local");
339 let id = model_ops::save_as(&cache, space, &after, "Model author")
340 .unwrap()
341 .unwrap();
342 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
343 assert!(
344 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
345 );
346 assert_eq!(
347 texts(&page_of(&server.durable, space))[0],
348 "Remote Anchor local"
349 );
350
351 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();
352 let mut after = page_of(OUTLINES, space);
353 edit_paragraph(&mut after, 0, 0..6, "Local");
354 let id = model_ops::save_as(&cache, space, &after, "Model author")
355 .unwrap()
356 .unwrap();
357 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));
358 assert!(matches!(
359 cache.sync_once(&mut server).unwrap().edit,
360 Some((_, EditStatus::Published { .. }))
361 ));
362 assert!(matches!(
363 cache.status(id).unwrap(),
364 Some(EditStatus::Published { .. })
365 ));
366 assert!(conflicted(&server.durable, space));
367 assert_eq!(server.publications, 1);
368}
369
370#[test]
371fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
372 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
373 let directory = tempfile::tempdir().unwrap();
374 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
375 let template = texts(&after);
376 assert_eq!(template.len(), 3);
377 {
378 let outline = after
379 .objects
380 .iter_mut()
381 .find_map(|object| match object {
382 PageObject::Outline(outline) => Some(outline),
383 _ => None,
384 })
385 .unwrap();
386 let mut fresh = outline.paragraphs[0].clone();
387 fresh.id = onestore::page::text::new_id().unwrap();
388 fresh.style = None;
389 let text = fresh.text_mut().unwrap();
390 text.id = onestore::page::text::new_id().unwrap();
391 text.text = onestore::page::Paragraph::new(
392 "Inserted locally".into(),
393 text.text.format_at(0).unwrap().clone(),
394 );
395 outline.paragraphs.insert(1, fresh);
396 }
397 let id = model_ops::save_as(&cache, space, &after, "Model author")
398 .unwrap()
399 .unwrap();
400 let mut server = remote_with(space, |page| {
401 let outline = page
402 .objects
403 .iter_mut()
404 .find_map(|object| match object {
405 PageObject::Outline(outline) => Some(outline),
406 _ => None,
407 })
408 .unwrap();
409 outline.paragraphs.pop();
410 });
411 assert!(
412 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
413 );
414 assert_eq!(
415 texts(&page_of(&server.durable, space)),
416 vec![
417 template[0].clone(),
418 "Inserted locally".to_owned(),
419 template[1].clone()
420 ]
421 );
422}
423
424#[test]
425fn a_remote_deletion_of_the_edited_paragraph_keeps_a_conflict_page() {
426 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
427 let directory = tempfile::tempdir().unwrap();
428 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
429 edit_paragraph(&mut after, 2, 0..0, "Local ");
430 let id = model_ops::save_as(&cache, space, &after, "Model author")
431 .unwrap()
432 .unwrap();
433 let mut server = remote_with(space, |page| {
434 let outline = page
435 .objects
436 .iter_mut()
437 .find_map(|object| match object {
438 PageObject::Outline(outline) => Some(outline),
439 _ => None,
440 })
441 .unwrap();
442 outline.paragraphs.pop();
443 });
444 assert!(matches!(
445 cache.sync_once(&mut server).unwrap().edit,
446 Some((_, EditStatus::Published { .. }))
447 ));
448 assert!(matches!(
449 cache.status(id).unwrap(),
450 Some(EditStatus::Published { .. })
451 ));
452 assert!(conflicted(&server.durable, space));
453}
454
455#[test]
456fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
457 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
458 let directory = tempfile::tempdir().unwrap();
459 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
460 let outline_id = {
461 let PageObject::Outline(outline) = after
462 .objects
463 .iter_mut()
464 .find(|o| matches!(o, PageObject::Outline(_)))
465 .unwrap()
466 else {
467 unreachable!()
468 };
469 outline.layout.x = Some(200.0);
470 outline.layout.y = Some(300.0);
471 outline.id
472 };
473 let id = model_ops::save_as(&cache, space, &after, "Model author")
474 .unwrap()
475 .unwrap();
476 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
477 assert!(
478 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
479 );
480 let published = page_of(&server.durable, space);
481 let PageObject::Outline(outline) = published
482 .objects
483 .iter()
484 .find(|o| o.id() == outline_id)
485 .unwrap()
486 else {
487 unreachable!()
488 };
489 assert_eq!(
490 (outline.layout.x, outline.layout.y),
491 (Some(200.0), Some(300.0))
492 );
493 assert!(
494 outline.paragraphs[0]
495 .text()
496 .unwrap()
497 .text
498 .text()
499 .starts_with("Remote ")
500 );
501
502 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();
503 let id = model_ops::save_as(&cache, space, &after, "Model author")
504 .unwrap()
505 .unwrap();
506 let mut server = remote_with(space, |page| {
507 let PageObject::Outline(outline) = page
508 .objects
509 .iter_mut()
510 .find(|o| matches!(o, PageObject::Outline(_)))
511 .unwrap()
512 else {
513 unreachable!()
514 };
515 outline.layout.x = Some(50.0);
516 });
517 assert!(matches!(
518 cache.sync_once(&mut server).unwrap().edit,
519 Some((_, EditStatus::Published { .. }))
520 ));
521 assert!(matches!(
522 cache.status(id).unwrap(),
523 Some(EditStatus::Published { .. })
524 ));
525 assert!(conflicted(&server.durable, space));
526}
527
528#[test]
529fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() {
530 let (space, before) = page_titled(OUTLINES, "Move leaf down");
531 let directory = tempfile::tempdir().unwrap();
532 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
533 let mut after = before.clone();
534 let bullet = onestore::page::text::new_id().unwrap();
535 after.definitions.insert(
536 bullet,
537 onestore::page::Definition {
538 kind: onestore::document::Kind::List {
539 font: Some("Courier New".into()),
540 format: Some("\u{25cb}".into()),
541 restart: None,
542 bullet: Some(4),
543 },
544 format: onestore::document::Format {
545 font_size: Some(11.0),
546 color: Some(0xff000000),
547 ..Default::default()
548 },
549 },
550 );
551 let outline = after
552 .objects
553 .iter_mut()
554 .find_map(|object| match object {
555 PageObject::Outline(outline) if !outline.title => Some(outline),
556 _ => None,
557 })
558 .unwrap();
559 outline.paragraphs[0].lists = vec![bullet];
560 let id = model_ops::save_as(&cache, space, &after, "Model author")
561 .unwrap()
562 .unwrap();
563 let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote "));
564 assert!(
565 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
566 );
567 let published = page_of(&server.durable, space);
568 let outline = published
569 .objects
570 .iter()
571 .find_map(|object| match object {
572 PageObject::Outline(outline) if !outline.title => Some(outline),
573 _ => None,
574 })
575 .unwrap();
576 assert_eq!(outline.paragraphs[0].lists, vec![bullet]);
577 assert!(matches!(
578 published.definitions[&bullet].kind,
579 onestore::document::Kind::List {
580 bullet: Some(4),
581 ..
582 }
583 ));
584 assert!(texts(&published)[1].starts_with("Remote "));
585}