1//! Formatting and insertion as page-model differences: what reconciles with an independent
2//! remote change, what keeps a conflict page, and what the edit made again publishes.
3
4#[path = "../../onestore/tests/support/ops.rs"]
5mod ops;
6
7use notebook::{EditStatus, Replica};
8use onestore::{
9 ExGuid, RevisionIndex, Store,
10 document::{Document, Format},
11 page::{Page, PageObject},
12};
13
14#[path = "support/server.rs"]
15mod server;
16use server::{Fault, Server, conflicted, pages, snapshot};
17#[path = "support/model_ops.rs"]
18mod model_ops;
19#[path = "../../onestore/tests/support/sweep.rs"]
20mod sweep;
21use model_ops::{
22 AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, save,
23};
24
25const OUTLINES: &[u8] =
26 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
27const PAGE: &str = "Move subtree down";
28
29fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
30 let store = Store::parse(bytes).unwrap();
31 let index = RevisionIndex::parse(&store).unwrap();
32 let document = Document::parse(&index).unwrap();
33 document
34 .pages()
35 .unwrap()
36 .into_iter()
37 .find_map(|(space, _)| {
38 let page = Page::from_space(&document, space).unwrap();
39 (page.title == title).then_some((space, page))
40 })
41 .unwrap()
42}
43
44/// The text objects of a page that carry no hidden field, and so accept range formatting.
45fn plain(page: &Page) -> Vec<ExGuid> {
46 page.objects
47 .iter()
48 .flat_map(|object| match object {
49 PageObject::Outline(outline) => outline.paragraphs.iter().collect(),
50 _ => Vec::new(),
51 })
52 .filter_map(|paragraph| {
53 let text = paragraph.text()?;
54 (!text.text.text().contains('\u{fddf}')).then_some(text.id)
55 })
56 .collect()
57}
58
59fn text_of(page: &Page, text: ExGuid) -> String {
60 paragraph_with(page, text)
61 .unwrap()
62 .text()
63 .unwrap()
64 .text
65 .text()
66 .to_owned()
67}
68
69/// The format of every UTF-16 unit of a text object.
70fn formats(page: &Page, text: ExGuid) -> Vec<Format> {
71 let content = &paragraph_with(page, text).unwrap().text().unwrap().text;
72 let mut out = Vec::new();
73 let mut from = 0;
74 for span in content.spans() {
75 let units = content.text()[from..span.end].encode_utf16().count();
76 out.extend(std::iter::repeat_n(span.format.clone(), units));
77 from = span.end;
78 }
79 out
80}
81
82/// A remote holding the fixture page with `change` applied by a native author.
83fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
84 let mut page = page_of(OUTLINES, space);
85 change(&mut page);
86 Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice())
87}
88
89fn cache(directory: &tempfile::TempDir) -> Replica {
90 Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap()
91}
92
93#[test]
94fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {
95 let (space, page) = page_titled(OUTLINES, PAGE);
96 let ids = plain(&page);
97 let directory = tempfile::tempdir().unwrap();
98 let cache = cache(&directory);
99 let id = save(&cache, ids[0], |page| {
100 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
101 })
102 .unwrap()
103 .unwrap();
104 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
105 assert!(
106 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
107 );
108 let published = page_of(&server.durable, space);
109 assert!(
110 formats(&published, ids[0])
111 .iter()
112 .all(|format| format.bold == Some(true))
113 );
114 assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling");
115 assert_eq!(snapshot(&cache), server.durable);
116}
117
118#[test]
119fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() {
120 let (space, page) = page_titled(OUTLINES, PAGE);
121 let ids = plain(&page);
122 for typed in [false, true] {
123 let directory = tempfile::tempdir().unwrap();
124 let cache = cache(&directory);
125 let id = save(&cache, ids[0], |page| {
126 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
127 })
128 .unwrap()
129 .unwrap();
130 let local = snapshot(&cache);
131 let mut server = remote_with(space, |page| {
132 if typed {
133 replace_text(page, ids[0], 0..0, "Remote ");
134 } else {
135 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));
136 }
137 });
138 let outcome = cache.sync_once(&mut server).unwrap().edit;
139 if typed {
140 // Text typed before the formatted range moves it; the ranges do not meet.
141 assert!(matches!(outcome, Some((n, EditStatus::Published { .. })) if n == id));
142 let published = page_of(&server.durable, space);
143 assert!(text_of(&published, ids[0]).starts_with("Remote Anchor"));
144 let styles = formats(&published, ids[0]);
145 for (offset, format) in styles.iter().enumerate() {
146 assert_eq!(
147 format.bold == Some(true),
148 (7..13).contains(&offset),
149 "{offset}"
150 );
151 }
152 } else {
153 assert!(matches!(outcome, Some((_, EditStatus::Published { .. }))));
154 assert!(conflicted(&server.durable, space));
155 let remote = page_of(&server.durable, space);
156 assert!(
157 formats(&remote, ids[0])
158 .iter()
159 .all(|format| format.bold != Some(true))
160 );
161 assert_eq!(pages(&snapshot(&cache)), pages(&server.durable));
162 assert_ne!(pages(&local), pages(&server.durable));
163 }
164 }
165}
166
167#[test]
168fn competing_formatting_of_one_paragraph_keeps_a_conflict_page_until_made_again() {
169 let (space, page) = page_titled(OUTLINES, PAGE);
170 let ids = plain(&page);
171 let directory = tempfile::tempdir().unwrap();
172 let path = directory.path().join("competing.sqlite");
173 let cache = Replica::create(&path, OUTLINES).unwrap();
174 let id = save(&cache, ids[0], |page| {
175 restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0));
176 })
177 .unwrap()
178 .unwrap();
179 let mut server = remote_with(space, |page| {
180 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));
181 });
182 assert!(matches!(
183 cache.sync_once(&mut server).unwrap().edit,
184 Some((_, EditStatus::Published { .. }))
185 ));
186 assert!(conflicted(&server.durable, space));
187 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
188 drop(cache);
189 let cache = Replica::open(&path).unwrap();
190 assert!(matches!(
191 cache.status(id).unwrap(),
192 Some(EditStatus::Published { .. })
193 ));
194 save(&cache, ids[0], |page| {
195 restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0));
196 })
197 .unwrap();
198 assert!(matches!(
199 cache.sync_once(&mut server).unwrap().edit,
200 Some((_, EditStatus::Published { .. }))
201 ));
202 let published = formats(&page_of(&server.durable, space), ids[0]);
203 assert!(
204 published
205 .iter()
206 .all(|format| format.font_size == Some(14.0))
207 );
208 assert_eq!(
209 published
210 .iter()
211 .filter(|format| format.italic == Some(true))
212 .count(),
213 3
214 );
215}
216
217#[test]
218fn every_visual_attribute_publishes_through_a_page_model_save() {
219 let (space, page) = page_titled(OUTLINES, PAGE);
220 let ids = plain(&page);
221 for (name, apply) in [
222 (
223 "bold",
224 (|f: &mut Format| f.bold = Some(true)) as fn(&mut Format),
225 ),
226 ("italic", |f| f.italic = Some(true)),
227 ("underline", |f| f.underline = Some(true)),
228 ("strike", |f| f.strike = Some(true)),
229 ("superscript", |f| f.superscript = Some(true)),
230 ("subscript", |f| f.subscript = Some(true)),
231 ("font", |f| f.font = Some("Georgia".into())),
232 ("font size", |f| f.font_size = Some(21.0)),
233 ("color", |f| f.color = Some(0x0056_3412)),
234 ("highlight", |f| f.highlight = Some(0x0006_0504)),
235 ] {
236 let directory = tempfile::tempdir().unwrap();
237 let cache = cache(&directory);
238 let id = save(&cache, ids[0], |page| restyle(page, ids[0], 2..6, apply))
239 .unwrap()
240 .unwrap();
241 let mut server = Server::new(OUTLINES);
242 assert!(
243 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id),
244 "{name}"
245 );
246 let mut expected = formats(&page_of(OUTLINES, space), ids[0]);
247 assert_eq!(expected.len(), 6);
248 for format in &mut expected[2..] {
249 apply(format);
250 }
251 assert_eq!(
252 formats(&page_of(&server.durable, space), ids[0]),
253 expected,
254 "{name}"
255 );
256 }
257}
258
259#[test]
260fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone() {
261 let (space, page) = page_titled(OUTLINES, PAGE);
262 let ids = plain(&page);
263 let lengths: Vec<u32> = ids
264 .iter()
265 .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap())
266 .collect();
267 let (mut published, mut conflicts) = (0, 0);
268 let seeds = sweep::seeds(1..65, 16);
269 for seed in seeds.clone() {
270 let mut state = seed;
271 let mut next = || {
272 state = state
273 .wrapping_mul(6364136223846793005)
274 .wrapping_add(1442695040888963407);
275 usize::try_from(state >> 33).unwrap()
276 };
277 let target = next() % ids.len();
278 let start = u32::try_from(next()).unwrap() % lengths[target];
279 let end = start + 1 + u32::try_from(next()).unwrap() % (lengths[target] - start);
280 let attribute: fn(&mut Format) = match seed % 3 {
281 0 => |format| format.bold = Some(true),
282 1 => |format| format.font_size = Some(21.0),
283 _ => |format| format.color = Some(0x0012_3456),
284 };
285 let remote: Vec<usize> = (0..1 + next() % 3).map(|_| next() % ids.len()).collect();
286 let directory = tempfile::tempdir().unwrap();
287 let cache = cache(&directory);
288 let id = save(&cache, ids[target], |page| {
289 restyle(page, ids[target], start..end, attribute);
290 })
291 .unwrap()
292 .unwrap();
293 let local = snapshot(&cache);
294 let mut server = remote_with(space, |page| {
295 for (step, &at) in remote.iter().enumerate() {
296 if step % 2 == 0 {
297 replace_text(page, ids[at], 0..0, "\u{2022}");
298 } else {
299 restyle(page, ids[at], 0..1, |format| format.underline = Some(true));
300 }
301 }
302 });
303 let remote_page = page_of(&server.durable, space);
304 let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap();
305 // Bullets the remote typed before the target's text, and whether it underlined a
306 // character the target already had.
307 let mut bullets = vec![0_u32; ids.len()];
308 let mut underlined = vec![false; ids.len()];
309 for (step, &at) in remote.iter().enumerate() {
310 if step % 2 == 0 {
311 bullets[at] += 1;
312 } else if bullets[at] == 0 {
313 underlined[at] = true;
314 }
315 }
316 if underlined[target] && start == 0 {
317 conflicts += 1;
318 // The step names its batch by the newest edit: the conflict page the rebase queued.
319 assert!(outcome.0 > id, "seed {seed}");
320 assert!(
321 matches!(outcome.1, EditStatus::Published { .. }),
322 "seed {seed}"
323 );
324 assert_eq!(cache.status(id).unwrap(), Some(outcome.1), "seed {seed}");
325 assert!(conflicted(&server.durable, space), "seed {seed}");
326 assert_eq!(page_of(&server.durable, space), remote_page, "seed {seed}");
327 assert_ne!(pages(&snapshot(&cache)), pages(&local), "seed {seed}");
328 continue;
329 }
330 published += 1;
331 assert_eq!(outcome.0, id, "seed {seed}");
332 assert!(
333 matches!(outcome.1, EditStatus::Published { .. }),
334 "seed {seed}: {outcome:?}"
335 );
336 let page = page_of(&server.durable, space);
337 let shift = bullets[target] as usize;
338 let mut expected: Vec<Format> = formats(&remote_page, ids[target]);
339 for format in &mut expected[start as usize + shift..end as usize + shift] {
340 attribute(format);
341 }
342 assert_eq!(formats(&page, ids[target]), expected, "seed {seed}");
343 for &at in &remote {
344 assert!(
345 text_of(&page, ids[at]).starts_with('\u{2022}')
346 || formats(&page, ids[at])[0].underline == Some(true),
347 "seed {seed}"
348 );
349 }
350 assert_eq!(snapshot(&cache), server.durable, "seed {seed}");
351 }
352 assert!(
353 published * 4 >= seeds.end - seeds.start,
354 "{published} published, {conflicts} conflicts"
355 );
356}
357
358#[test]
359fn offline_insertions_survive_reopen_and_carry_their_formatting() {
360 let (space, page) = page_titled(OUTLINES, PAGE);
361 let ids = plain(&page);
362 let directory = tempfile::tempdir().unwrap();
363 let path = directory.path().join("insert.sqlite");
364 let mut cache = Replica::create(&path, OUTLINES).unwrap();
365 let id = save(&cache, ids[0], |page| {
366 insert_outline(page, 72.0, 144.0, "Offline outline");
367 insert_after(page, ids[0], "Offline paragraph 🦀");
368 })
369 .unwrap()
370 .unwrap();
371 let local = snapshot(&cache);
372 let pending = cache.pending().unwrap();
373 drop(cache);
374 cache = Replica::open(&path).unwrap();
375 assert_eq!(pages(&snapshot(&cache)), pages(&local));
376 assert_eq!(cache.pending().unwrap(), pending);
377 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
378 assert!(
379 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
380 );
381 let published = page_of(&server.durable, space);
382 let inserted = plain(&published)
383 .into_iter()
384 .find(|id| text_of(&published, *id) == "Offline paragraph 🦀")
385 .unwrap();
386 assert!(
387 plain(&published)
388 .iter()
389 .any(|id| text_of(&published, *id) == "Offline outline")
390 );
391 assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling");
392 let styled = save(&cache, inserted, |page| {
393 restyle(page, inserted, 8..17, |format| format.bold = Some(true));
394 })
395 .unwrap()
396 .unwrap();
397 assert!(
398 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == styled)
399 );
400 let styles = formats(&page_of(&server.durable, space), inserted);
401 for (offset, format) in styles.iter().enumerate() {
402 assert_eq!(format.bold == Some(true), (8..17).contains(&offset));
403 }
404 assert_eq!(snapshot(&cache), server.durable);
405}
406
407#[test]
408fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {
409 let (space, page) = page_titled(OUTLINES, PAGE);
410 let ids = plain(&page);
411 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
412 let directory = tempfile::tempdir().unwrap();
413 let path = directory.path().join("uncertain.sqlite");
414 let cache = Replica::create(&path, OUTLINES).unwrap();
415 let id = save(&cache, ids[0], |page| {
416 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
417 })
418 .unwrap()
419 .unwrap();
420 let local = snapshot(&cache);
421 let mut server = Server::new(OUTLINES);
422 server.fault = fault;
423 assert!(cache.sync_once(&mut server).is_err());
424 let state = cache.status(id).unwrap().unwrap();
425 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
426 drop(cache);
427 let cache = Replica::open(&path).unwrap();
428 assert_eq!(cache.status(id).unwrap(), Some(state.clone()));
429 if matches!(fault, Fault::UnknownBefore) {
430 for _ in 0..3 {
431 assert_eq!(
432 cache.sync_once(&mut server).unwrap().edit,
433 Some((id, state.clone()))
434 );
435 }
436 assert_eq!(server.publications, 1);
437 assert_eq!(server.durable, OUTLINES);
438 assert_eq!(pages(&snapshot(&cache)), pages(&local));
439 continue;
440 }
441 assert!(matches!(
442 cache.sync_once(&mut server).unwrap().edit,
443 Some((_, EditStatus::Published { .. }))
444 ));
445 assert_eq!(server.publications, 1);
446 assert!(cache.pending().unwrap().is_empty());
447 assert!(
448 formats(&page_of(&server.durable, space), ids[0])
449 .iter()
450 .all(|format| format.bold == Some(true))
451 );
452 }
453}
454
455#[test]
456fn a_format_the_remote_already_carries_is_confirmed_without_republishing() {
457 let (space, page) = page_titled(OUTLINES, PAGE);
458 let ids = plain(&page);
459 let directory = tempfile::tempdir().unwrap();
460 let cache = cache(&directory);
461 let id = save(&cache, ids[0], |page| {
462 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
463 })
464 .unwrap()
465 .unwrap();
466 let mut server = remote_with(space, |page| {
467 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
468 });
469 server.durable = OUTLINES.to_vec();
470 server.fault = Fault::Confirm;
471 assert!(cache.sync_once(&mut server).is_err());
472 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
473 assert_eq!(server.durable, OUTLINES);
474 assert!(
475 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
476 );
477 assert_eq!((server.publications, server.confirmations), (0, 2));
478 assert!(cache.pending().unwrap().is_empty());
479 let published = page_of(&snapshot(&cache), space);
480 assert_eq!(published, page_of(&server.durable, space));
481 assert!(
482 formats(&published, ids[0])
483 .iter()
484 .all(|format| format.bold == Some(true))
485 );
486}
487
488#[test]
489fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
490 let (space, page) = page_titled(OUTLINES, PAGE);
491 let ids = plain(&page);
492 let mut after = page.clone();
493 restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true));
494 let directory = tempfile::tempdir().unwrap();
495 let cache = cache(&directory);
496 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
497 .unwrap()
498 .unwrap();
499 let mut server = Server::new(OUTLINES);
500 server.fault = Fault::UnknownAfter;
501 assert!(cache.sync_once(&mut server).is_err());
502 let attempt = cache.status(id).unwrap();
503 assert!(matches!(
504 attempt,
505 Some(EditStatus::AwaitingConfirmation { .. })
506 ));
507 let partial = remote_with(space, |page| {
508 restyle(page, ids[0], 0..3, |format| format.bold = Some(true));
509 });
510 server.visible.clone_from(&partial.visible);
511 assert_eq!(
512 cache.sync_once(&mut server).unwrap().edit,
513 attempt.map(|state| (id, state))
514 );
515 assert_eq!(server.confirmations, 0);
516 let complete = remote_with(space, |page| {
517 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
518 });
519 server.visible.clone_from(&complete.visible);
520 assert_eq!(page_of(&server.visible, space), after);
521 assert!(
522 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
523 );
524 assert_eq!((server.publications, server.confirmations), (1, 1));
525 assert!(cache.pending().unwrap().is_empty());
526}
527
528#[test]
529fn a_formatted_save_of_an_unchanged_model_queues_nothing() {
530 let (space, page) = page_titled(OUTLINES, PAGE);
531 let directory = tempfile::tempdir().unwrap();
532 let cache = cache(&directory);
533 assert_eq!(
534 model_ops::save_as(&cache, space, &page, AUTHOR).unwrap(),
535 None
536 );
537 assert!(cache.pending().unwrap().is_empty());
538}
539
540#[test]
541fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() {
542 let (space, page) = page_titled(OUTLINES, PAGE);
543 let ids = plain(&page);
544 let attributes: &[fn(&mut Format)] = &[
545 |f| f.alignment = Some(2),
546 |f| f.rtl = Some(true),
547 |f| f.space_before = Some(12.0),
548 |f| f.space_after = Some(6.0),
549 |f| f.line_spacing = Some(18.0),
550 ];
551 for apply in attributes {
552 let directory = tempfile::tempdir().unwrap();
553 let cache = cache(&directory);
554 let id = save(&cache, ids[0], |page| restyle(page, ids[0], 0..6, apply))
555 .unwrap()
556 .unwrap();
557 let expected = formats(&page_of(&snapshot(&cache), space), ids[0]);
558 drop(cache);
559 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();
560 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
561 server.fault = Fault::UnknownAfter;
562 assert!(
563 matches!(cache.sync_once(&mut server), Err(notebook::Error::Remote(error)) if error.state == onestore::CommitState::Unknown)
564 );
565 assert!(matches!(
566 cache.status(id).unwrap(),
567 Some(EditStatus::AwaitingConfirmation { .. })
568 ));
569 drop(cache);
570 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();
571 assert!(
572 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
573 );
574 assert_eq!(server.publications, 1);
575 let page = page_of(&server.durable, space);
576 assert_eq!(formats(&page, ids[0]), expected);
577 assert!(text_of(&page, ids[3]).starts_with("Remote "));
578 }
579}