1//! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied
2//! one by one publish, in one transaction, and must round-trip the model it was given.
3
4#[path = "support/metadata.rs"]
5mod metadata;
6#[path = "support/ops.rs"]
7mod ops;
8
9use onestore::{
10 ExGuid, OutlineEdit, RevisionIndex, Store, TextAttribute,
11 document::{Document, Element, Kind, Revision},
12 op::PageOp,
13 page::{Page, PageObject, ParagraphContent, text::new_id},
14};
15use std::collections::BTreeSet;
16
17/// `source` after `ops` on the page in `space`.
18fn edited(source: &[u8], space: ExGuid, ops: Vec<PageOp>) -> Vec<u8> {
19 ops::page_edited(source, space, ops).unwrap()
20}
21
22/// A plain paragraph of `text` inserted in `container` before `before`, at `level`.
23fn insert(container: ExGuid, before: Option<ExGuid>, text: &str, level: u32) -> PageOp {
24 let mut paragraph = ops::paragraph(text);
25 paragraph.level = level;
26 PageOp::Insert {
27 container,
28 before,
29 paragraphs: vec![paragraph],
30 }
31}
32
33const AUTHOR: &str = "Author";
34const PARAGRAPHS: &[u8] =
35 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one");
36const OUTLINES: &[u8] =
37 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
38const TREES: &[u8] =
39 include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
40
41fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
42 let store = Store::parse(bytes).unwrap();
43 let index = RevisionIndex::parse(&store).unwrap();
44 let document = Document::parse(&index).unwrap();
45 let mut found = None;
46 for (space, _) in document.pages().unwrap() {
47 let page = Page::from_space(&document, space).unwrap();
48 if page.title == title {
49 assert!(found.is_none(), "duplicate title {title}");
50 found = Some((space, page));
51 }
52 }
53 found.unwrap_or_else(|| panic!("no page titled {title}"))
54}
55
56fn page_in(bytes: &[u8], space: ExGuid) -> Page {
57 let store = Store::parse(bytes).unwrap();
58 let index = RevisionIndex::parse(&store).unwrap();
59 let document = Document::parse(&index).unwrap();
60 Page::from_space(&document, space).unwrap()
61}
62
63fn page_id(bytes: &[u8], space: ExGuid) -> ExGuid {
64 let store = Store::parse(bytes).unwrap();
65 let index = RevisionIndex::parse(&store).unwrap();
66 let document = Document::parse(&index).unwrap();
67 document.pages_in(space).unwrap()[0]
68}
69
70/// The body outlines in stacking order (not the title's outlines).
71fn body(page: &Page) -> Vec<&onestore::page::Outline> {
72 page.objects
73 .iter()
74 .filter_map(|object| match object {
75 PageObject::Outline(outline) => Some(outline),
76 _ => None,
77 })
78 .collect()
79}
80
81fn text_ids(outline: &onestore::page::Outline) -> Vec<(ExGuid, ExGuid, String)> {
82 outline
83 .paragraphs
84 .iter()
85 .filter_map(|p| p.text().map(|t| (p.id, t.id, t.text.text().to_owned())))
86 .collect()
87}
88
89fn author_name<'a>(revision: &'a Revision<'_>, id: Option<ExGuid>) -> Option<&'a str> {
90 match &revision.nodes.get(&id?)?.kind {
91 Kind::Author { name } => name.as_deref(),
92 _ => None,
93 }
94}
95
96fn close(a: Option<u32>, b: Option<u32>) -> bool {
97 match (a, b) {
98 (Some(a), Some(b)) => a.abs_diff(b) <= 5,
99 (None, None) => true,
100 _ => false,
101 }
102}
103
104fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid) {
105 let a: &Element = &revision_a.nodes[&id];
106 let b: &Element = &revision_b.nodes[&id];
107 assert_eq!(a.jcid, b.jcid, "{id}");
108 assert_eq!(a.children, b.children, "children of {id}");
109 assert_eq!(a.content, b.content, "content of {id}");
110 assert_eq!(a.structure, b.structure, "structure of {id}");
111 assert_eq!(a.spaces, b.spaces, "spaces of {id}");
112 assert_eq!(a.child_level, b.child_level, "child level of {id}");
113 assert_eq!(a.layout, b.layout, "layout of {id}");
114 assert_eq!(a.format, b.format, "format of {id}");
115 assert_eq!(a.tags, b.tags, "tags of {id}");
116 assert_eq!(a.media_ids, b.media_ids, "media of {id}");
117 assert!(
118 close(a.created, b.created),
119 "created {id}: {:?} {:?}",
120 a.created,
121 b.created
122 );
123 assert_eq!(
124 author_name(revision_a, a.original_author),
125 author_name(revision_b, b.original_author),
126 "original author of {id}"
127 );
128 // A reorder can be attributed to either sibling, so attribution may differ by this edit's author.
129 let latest_a = author_name(revision_a, a.latest_author);
130 let latest_b = author_name(revision_b, b.latest_author);
131 assert!(
132 latest_a == latest_b || latest_a == Some(AUTHOR) || latest_b == Some(AUTHOR),
133 "latest author of {id}: {latest_a:?} {latest_b:?}"
134 );
135 assert_eq!(a.extra, b.extra, "retained fields of {id}");
136 match (&a.kind, &b.kind) {
137 (
138 Kind::RichText {
139 text: ta,
140 boilerplate: ba,
141 paragraph_style: pa,
142 ..
143 },
144 Kind::RichText {
145 text: tb,
146 boilerplate: bb,
147 paragraph_style: pb,
148 ..
149 },
150 ) => {
151 assert_eq!(ta, tb, "text of {id}");
152 assert_eq!((ba, pa), (bb, pb), "text style of {id}");
153 let runs_a = revision_a.text_runs(id).unwrap();
154 let runs_b = revision_b.text_runs(id).unwrap();
155 assert_eq!(runs_a.len(), runs_b.len(), "run count of {id}");
156 for (ra, rb) in runs_a.iter().zip(&runs_b) {
157 assert_eq!(
158 (ra.text, &ra.format, ra.link),
159 (rb.text, &rb.format, rb.link),
160 "runs of {id}"
161 );
162 }
163 }
164 (ka, kb) => assert_eq!(ka, kb, "kind of {id}"),
165 }
166}
167
168/// Every active revision the ops produced one by one is reproduced by the page lowered whole,
169/// ignoring writer-allocated immutable styles and author records. Paragraph modification times are
170/// not compared: a reorder can be attributed to either sibling, and the model cannot say which.
171fn equivalent(reference: &[u8], written: &[u8]) {
172 let store_a = Store::parse(reference).unwrap();
173 let store_b = Store::parse(written).unwrap();
174 let index_a = RevisionIndex::parse(&store_a).unwrap();
175 let index_b = RevisionIndex::parse(&store_b).unwrap();
176 index_b.validate_current().unwrap();
177 let document_a = Document::parse(&index_a).unwrap();
178 let document_b = Document::parse(&index_b).unwrap();
179 assert_eq!(
180 document_a.spaces.keys().collect::<Vec<_>>(),
181 document_b.spaces.keys().collect::<Vec<_>>()
182 );
183 for sid in document_a.spaces.keys() {
184 let a = document_a.active(*sid).unwrap();
185 let b = document_b.active(*sid).unwrap();
186 assert_eq!(a.roots, b.roots, "roots of {sid}");
187 let roots: Vec<ExGuid> = a.roots.values().copied().collect();
188 let reachable = |revision: &Revision<'_>| -> BTreeSet<ExGuid> {
189 revision
190 .parents(&roots)
191 .unwrap()
192 .into_keys()
193 .chain(roots.iter().copied())
194 .filter(|id| !matches!(revision.nodes[id].jcid, 0x12004d | 0x120001))
195 .collect()
196 };
197 let ids_a = reachable(a);
198 let ids_b = reachable(b);
199 assert_eq!(ids_a, ids_b, "reachable objects of {sid}");
200 for id in ids_a {
201 same_element(a, b, id);
202 }
203 }
204}
205
206/// Both lowerings use the last op's FILETIME so revision metadata compares exactly.
207fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec<u8> {
208 let after = page_in(reference, space);
209 let store = Store::parse(reference).unwrap();
210 let index = RevisionIndex::parse(&store).unwrap();
211 let revision = index.resolve_active(space).unwrap();
212 let source_store = Store::parse(source).unwrap();
213 let source_index = RevisionIndex::parse(&source_store).unwrap();
214 let original = source_index.resolve_active(space).unwrap();
215 let at = metadata::check(&original, &revision, AUTHOR);
216 let arena = onestore::Arena::default();
217 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
218 let before = section.page(space).unwrap();
219 let ops = onestore::op::lower_page(&before, &after)
220 .unwrap()
221 .into_iter()
222 .map(|op| onestore::op::Op::Page { space, op })
223 .collect();
224 section
225 .apply(AUTHOR, &onestore::op::Edit { at, ops })
226 .unwrap();
227 section.seal().unwrap().unwrap();
228 let written = section.image();
229 assert_eq!(page_in(&written, space), after, "model round trip");
230 equivalent(reference, &written);
231 let transactions = |bytes: &[u8]| Store::parse(bytes).unwrap().header.transaction_count;
232 assert_eq!(
233 transactions(&written),
234 transactions(source) + 1,
235 "one transaction"
236 );
237 written
238}
239
240#[test]
241fn text_replacement_matches_replace_text() {
242 let (space, page) = page_by_title(PARAGRAPHS, "Split middle");
243 let (_, text, content) = text_ids(body(&page)[0])[0].clone();
244 let end = u32::try_from(content.encode_utf16().count()).unwrap();
245 for (range, replacement) in [
246 (0..0, "馃 "),
247 (1..end.min(3), "é"),
248 (end..end, " end"),
249 (0..end, ""),
250 ] {
251 let op = PageOp::Text {
252 text,
253 range,
254 with: replacement.into(),
255 };
256 oracle(PARAGRAPHS, space, &edited(PARAGRAPHS, space, vec![op]));
257 }
258}
259
260#[test]
261fn formatting_matches_format_text() {
262 let (space, page) = page_by_title(PARAGRAPHS, "Split style boundary");
263 let (_, text, content) = text_ids(body(&page)[0])[0].clone();
264 let end = u32::try_from(content.encode_utf16().count()).unwrap();
265 let reference = ops::page_op(
266 PARAGRAPHS,
267 space,
268 PageOp::Format {
269 text,
270 range: 1..end.min(4),
271 set: vec![
272 TextAttribute::Bold(true),
273 TextAttribute::Color(Some([255, 0, 0])),
274 TextAttribute::FontSize(14.0),
275 ],
276 clear: Vec::new(),
277 },
278 )
279 .unwrap();
280 let reference = ops::page_op(
281 reference.as_bytes(),
282 space,
283 PageOp::Format {
284 text,
285 range: 0..1,
286 set: vec![
287 TextAttribute::Italic(true),
288 TextAttribute::Font("Consolas".into()),
289 ],
290 clear: Vec::new(),
291 },
292 )
293 .unwrap();
294 oracle(PARAGRAPHS, space, reference.as_bytes());
295}
296
297#[test]
298fn paragraph_insertions_match_insertion() {
299 let (space, page) = page_by_title(OUTLINES, "Move subtree down");
300 let outline = body(&page)[0];
301 let paragraphs = text_ids(outline);
302 let parent = outline
303 .paragraphs
304 .iter()
305 .find(|p| p.parent.is_none())
306 .unwrap()
307 .id;
308 for op in [
309 insert(outline.id, None, "Appended 馃 é", 1),
310 insert(outline.id, Some(paragraphs[0].0), "First", 1),
311 insert(parent, None, "Nested child", 2),
312 insert(outline.id, None, "", 1),
313 ] {
314 oracle(OUTLINES, space, &edited(OUTLINES, space, vec![op]));
315 }
316}
317
318#[test]
319fn cell_insertion_and_deletion_match() {
320 let (space, page) = page_by_title(TREES, "Delete cell subtree");
321 let outline = body(&page)[0];
322 let table = outline
323 .paragraphs
324 .iter()
325 .find_map(|p| match &p.content {
326 ParagraphContent::Table(table) => Some(table),
327 _ => None,
328 })
329 .unwrap();
330 let cell = &table.rows[0].cells[0];
331 let reference = edited(TREES, space, vec![insert(cell.id, None, "Cell text", 1)]);
332 oracle(TREES, space, &reference);
333 let victim = cell
334 .paragraphs
335 .iter()
336 .find(|p| p.parent.is_none())
337 .unwrap()
338 .id;
339 let reference = edited(&reference, space, vec![PageOp::Delete { object: victim }]);
340 oracle(TREES, space, &reference);
341}
342
343#[test]
344fn outline_insertion_matches_insertion() {
345 let (space, _) = page_by_title(OUTLINES, "Move leaf down");
346 let (add, ..) = ops::new_outline(144.0, 200.0, "New outline 鏉变含");
347 oracle(OUTLINES, space, &edited(OUTLINES, space, vec![add]));
348}
349
350#[test]
351fn splits_and_joins_match_their_writers() {
352 for title in ["Split middle", "Split start", "Split end", "Split parent"] {
353 let (space, page) = page_by_title(PARAGRAPHS, title);
354 let paragraphs = text_ids(body(&page)[0]);
355 let (_, text, content) = paragraphs[0].clone();
356 let units = u32::try_from(content.encode_utf16().count()).unwrap();
357 let offset = match title {
358 "Split start" => 0,
359 "Split end" => units,
360 _ => units / 2,
361 };
362 let offset = (0..=offset)
363 .rev()
364 .find(|at| {
365 content
366 .encode_utf16()
367 .nth(at.wrapping_sub(1) as usize)
368 .is_none_or(|u| !(0xd800..=0xdbff).contains(&u))
369 })
370 .unwrap();
371 let lists = body(&page)[0].paragraphs[0].lists.len();
372 let split = PageOp::Split {
373 text,
374 at: offset,
375 paragraph: new_id().unwrap(),
376 right: new_id().unwrap(),
377 lists: (0..lists).map(|_| new_id().unwrap()).collect(),
378 };
379 oracle(PARAGRAPHS, space, &edited(PARAGRAPHS, space, vec![split]));
380 }
381 for title in ["Split middle", "Split empty"] {
382 let (space, page) = page_by_title(PARAGRAPHS, title);
383 let paragraphs = text_ids(body(&page)[0]);
384 let join = PageOp::Join {
385 left: paragraphs[0].1,
386 right: paragraphs[1].1,
387 };
388 oracle(PARAGRAPHS, space, &edited(PARAGRAPHS, space, vec![join]));
389 }
390}
391
392#[test]
393fn subtree_moves_and_deletions_match_tree_edits() {
394 for (title, build) in [
395 ("Move leaf down", 0usize),
396 ("Move subtree down", 1),
397 ("Delete leaf", 2),
398 ("Delete subtree", 3),
399 ("Move outline", 4),
400 ("Delete outline", 5),
401 ] {
402 let (space, page) = page_by_title(OUTLINES, title);
403 let outlines = body(&page);
404 let outline = outlines[0];
405 let top: Vec<ExGuid> = outline
406 .paragraphs
407 .iter()
408 .filter(|p| p.parent.is_none())
409 .map(|p| p.id)
410 .collect();
411 let op = match build {
412 0 | 1 => PageOp::Move {
413 object: top[0],
414 parent: Some(outline.id),
415 before: top.get(2).copied(),
416 },
417 2 | 3 => PageOp::Delete { object: top[0] },
418 4 => PageOp::Move {
419 object: outline.id,
420 parent: None,
421 before: None,
422 },
423 _ => PageOp::Delete { object: outline.id },
424 };
425 oracle(OUTLINES, space, &edited(OUTLINES, space, vec![op]));
426 if outlines.len() > 1 && build == 0 {
427 let across = PageOp::Move {
428 object: top[1],
429 parent: Some(outlines[1].id),
430 before: None,
431 };
432 oracle(OUTLINES, space, &edited(OUTLINES, space, vec![across]));
433 }
434 }
435}
436
437#[test]
438fn outline_layout_and_collapse_match_outline_edits() {
439 let (space, page) = page_by_title(OUTLINES, "Collapse subtree");
440 let outline = body(&page)[0];
441 let parent = outline
442 .paragraphs
443 .iter()
444 .find(|p| !outline.paragraphs.iter().all(|q| q.parent != Some(p.id)))
445 .unwrap()
446 .id;
447 for (object, edit) in [
448 (outline.id, OutlineEdit::Position { x: 90.0, y: 250.5 }),
449 (
450 outline.id,
451 OutlineEdit::Width {
452 points: 300.0,
453 user_set: true,
454 },
455 ),
456 (
457 outline.id,
458 OutlineEdit::Width {
459 points: 200.0,
460 user_set: false,
461 },
462 ),
463 (parent, OutlineEdit::Collapsed(true)),
464 ] {
465 let reference = ops::page_op(OUTLINES, space, PageOp::Outline { object, edit }).unwrap();
466 oracle(OUTLINES, space, reference.as_bytes());
467 }
468}
469
470#[test]
471fn composed_edits_publish_one_transaction() {
472 let (space, page) = page_by_title(OUTLINES, "Move subtree up");
473 let outline = body(&page)[0];
474 let paragraphs = text_ids(outline);
475 let mut reference = edited(
476 OUTLINES,
477 space,
478 vec![PageOp::Text {
479 text: paragraphs[0].1,
480 range: 0..0,
481 with: "Lead ".into(),
482 }],
483 );
484 let insertion = insert(outline.id, Some(paragraphs[1].0), "Inserted", 1);
485 let PageOp::Insert {
486 paragraphs: inserted,
487 ..
488 } = &insertion
489 else {
490 unreachable!()
491 };
492 let inserted = inserted[0].text().unwrap().id;
493 reference = edited(&reference, space, vec![insertion]);
494 reference = edited(
495 &reference,
496 space,
497 vec![PageOp::Format {
498 text: inserted,
499 range: 0..3,
500 set: vec![TextAttribute::Bold(true)],
501 clear: Vec::new(),
502 }],
503 );
504 let last = paragraphs.last().unwrap().0;
505 reference = edited(&reference, space, vec![PageOp::Delete { object: last }]);
506 reference = edited(
507 &reference,
508 space,
509 vec![PageOp::Outline {
510 object: outline.id,
511 edit: OutlineEdit::Position { x: 60.0, y: 60.0 },
512 }],
513 );
514 let written = oracle(OUTLINES, space, &reference);
515 assert_eq!(
516 Store::parse(&reference).unwrap().header.transaction_count,
517 Store::parse(OUTLINES).unwrap().header.transaction_count + 5
518 );
519 assert!(written.len() < reference.len());
520}
521
522#[test]
523fn model_edits_round_trip_and_leave_other_objects_untouched() {
524 let (space, mut after) = page_by_title(TREES, "Move numbered subtree down");
525 let before = page_in(TREES, space);
526 let page = page_id(TREES, space);
527 let mut new_paragraph = None;
528 for object in &mut after.objects {
529 let PageObject::Outline(outline) = object else {
530 continue;
531 };
532 if outline.title || new_paragraph.is_some() {
533 continue;
534 }
535 let first = outline
536 .paragraphs
537 .iter()
538 .position(|p| p.text().is_some())
539 .unwrap();
540 let text = outline.paragraphs[first].text_mut().unwrap();
541 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
542 text.text
543 .apply(onestore::page::text::Edit {
544 range: end..end,
545 replacement: onestore::page::Paragraph::new(
546 " appended 馃".into(),
547 text.text.format_at(end).unwrap().clone(),
548 ),
549 })
550 .unwrap();
551 let mut fresh = outline.paragraphs[first].clone();
552 fresh.id = new_id().unwrap();
553 fresh.parent = None;
554 fresh.level = 1;
555 fresh.lists.clear();
556 fresh.tags.clear();
557 fresh.style = None;
558 fresh.collapsed = false;
559 let fresh_text = fresh.text_mut().unwrap();
560 fresh_text.id = new_id().unwrap();
561 fresh_text.tags.clear();
562 fresh_text.text = onestore::page::Paragraph::new(
563 "Brand new".into(),
564 fresh_text.text.format_at(0).unwrap().clone(),
565 );
566 new_paragraph = Some(fresh.id);
567 outline.paragraphs.push(fresh);
568 outline.layout.x = Some(outline.layout.x.unwrap_or(0.0) + 18.0);
569 }
570 let mut outline = onestore::page::Outline {
571 id: new_id().unwrap(),
572 title: false,
573 min_width: None,
574 layout: onestore::document::Layout {
575 x: Some(300.0),
576 y: Some(400.0),
577 ..Default::default()
578 },
579 indents: Vec::new(),
580 paragraphs: Vec::new(),
581 unsupported: Vec::new(),
582 };
583 let template = body(&before)[0]
584 .paragraphs
585 .iter()
586 .find(|p| p.text().is_some())
587 .unwrap()
588 .clone();
589 let mut paragraph = template.clone();
590 paragraph.id = new_id().unwrap();
591 paragraph.parent = None;
592 paragraph.level = 1;
593 paragraph.lists.clear();
594 paragraph.tags.clear();
595 paragraph.style = None;
596 paragraph.collapsed = false;
597 let text = paragraph.text_mut().unwrap();
598 text.id = new_id().unwrap();
599 text.tags.clear();
600 text.text = onestore::page::Paragraph::new(
601 "Outline text".into(),
602 text.text.format_at(0).unwrap().clone(),
603 );
604 outline.paragraphs.push(paragraph);
605 after.objects.push(PageObject::Outline(outline));
606 let started = ops::now();
607 let written = ops::saved(TREES, space, &after).unwrap();
608 let finished = ops::now();
609 let stored = page_in(written.as_slice(), space);
610 let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> {
611 body(page)
612 .iter()
613 .map(|o| {
614 (
615 o.id,
616 o.paragraphs
617 .iter()
618 .filter_map(|p| p.text().map(|t| (p.id, t.text.text().to_owned())))
619 .collect(),
620 )
621 })
622 .collect()
623 };
624 assert_eq!(normalize(&stored), normalize(&after));
625 assert_eq!(
626 body(&stored)
627 .iter()
628 .map(|o| (o.layout.x, o.layout.y))
629 .collect::<Vec<_>>(),
630 body(&after)
631 .iter()
632 .map(|o| (o.layout.x, o.layout.y))
633 .collect::<Vec<_>>()
634 );
635 let store = Store::parse(TREES).unwrap();
636 let index = RevisionIndex::parse(&store).unwrap();
637 let raw_before = index.resolve_active(space).unwrap();
638 let store = Store::parse(written.as_slice()).unwrap();
639 let index = RevisionIndex::parse(&store).unwrap();
640 let raw_after = index.resolve_active(space).unwrap();
641 let at = metadata::check(&raw_before, &raw_after, AUTHOR);
642 assert!((started..=finished).contains(&at));
643 let touched: BTreeSet<ExGuid> = body(&after)
644 .iter()
645 .flat_map(|o| {
646 std::iter::once(o.id).chain(
647 o.paragraphs
648 .iter()
649 .flat_map(|p| [p.id, p.text().map(|t| t.id).unwrap_or(p.id)]),
650 )
651 })
652 .chain([page])
653 .collect();
654 let mut untouched = 0;
655 for (id, object) in &raw_before.objects {
656 if touched.contains(id)
657 || Some(id) == raw_before.roots.get(&4)
658 || matches!(object.jcid, 0x12004d | 0x120001)
659 {
660 continue;
661 }
662 let after = &raw_after.objects[id];
663 assert_eq!((object.jcid, object.data), (after.jcid, after.data), "{id}");
664 untouched += 1;
665 }
666 assert!(untouched > 5, "{untouched}");
667}
668
669#[test]
670fn unsupported_model_edits_are_rejected_before_writing() {
671 let (space, page) = page_by_title(TREES, "Delete cell subtree");
672 let mut widened = page_in(TREES, space);
673 for object in &mut widened.objects {
674 if let PageObject::Outline(outline) = object {
675 for paragraph in &mut outline.paragraphs {
676 if let ParagraphContent::Table(table) = &mut paragraph.content {
677 table.columns[0].width += 10.0;
678 }
679 }
680 }
681 }
682 assert!(ops::saved(TREES, space, &widened).is_ok());
683 let mut relisted = page_in(TREES, space);
684 let outline = body(&page)[0];
685 for object in &mut relisted.objects {
686 if let PageObject::Outline(o) = object
687 && o.id == outline.id
688 {
689 o.paragraphs[0].lists.push(new_id().unwrap());
690 }
691 }
692 assert!(ops::saved(TREES, space, &relisted).is_err());
693 // A page's date is an op of its own now.
694 let mut redated = page_in(TREES, space);
695 redated.created = Some(1);
696 let written = ops::saved(TREES, space, &redated).unwrap();
697 assert_eq!(page_in(&written, space).created, Some(1));
698 let text = page
699 .objects
700 .iter()
701 .find_map(|object| match object {
702 PageObject::Title(title) => title
703 .outlines
704 .iter()
705 .flat_map(|outline| &outline.paragraphs)
706 .find_map(|paragraph| paragraph.text().map(|text| text.id)),
707 _ => None,
708 })
709 .unwrap();
710 let op = onestore::op::Op::Page {
711 space,
712 op: PageOp::Text {
713 text,
714 range: 0..0,
715 with: "x".into(),
716 },
717 };
718 assert!(ops::transaction(TREES, "a\0b", vec![op]).is_err());
719}
720
721/// Unset run values stay unset; only the language, which MS-ONE requires on every run, comes
722/// from the insertion.
723#[test]
724fn a_new_paragraph_with_unspecified_formatting_rereads_with_only_the_required_language() {
725 let (space, mut page) = page_by_title(OUTLINES, "Move leaf down");
726 let text = onestore::page::TextObject {
727 id: new_id().unwrap(),
728 date_field: None,
729 text: onestore::page::Paragraph::new("Plain 鏉变含".into(), Default::default()),
730 tags: Vec::new(),
731 };
732 let mut paragraph = body(&page)[0].paragraphs[0].clone();
733 paragraph.id = new_id().unwrap();
734 paragraph.parent = None;
735 paragraph.level = 1;
736 paragraph.lists.clear();
737 paragraph.tags.clear();
738 paragraph.style = None;
739 paragraph.collapsed = false;
740 paragraph.content = onestore::page::ParagraphContent::Text(text.clone());
741 let outline = onestore::page::Outline {
742 id: new_id().unwrap(),
743 title: false,
744 min_width: None,
745 layout: onestore::document::Layout {
746 x: Some(300.0),
747 y: Some(500.0),
748 ..Default::default()
749 },
750 indents: Vec::new(),
751 paragraphs: vec![paragraph],
752 unsupported: Vec::new(),
753 };
754 page.objects.insert(0, PageObject::Outline(outline));
755 let written = ops::saved(OUTLINES, space, &page).unwrap();
756 let stored = page_in(written.as_slice(), space);
757 let read_back = body(&stored)
758 .iter()
759 .flat_map(|outline| outline.paragraphs.iter())
760 .find_map(|p| p.text().filter(|t| t.id == text.id))
761 .unwrap();
762 assert_eq!(read_back.text.text(), "Plain 鏉变含");
763 let format = read_back.text.format_at(0).unwrap();
764 assert!(format.language.is_some());
765 assert_eq!(
766 format,
767 &onestore::document::Format {
768 language: format.language,
769 ..Default::default()
770 }
771 );
772 assert_eq!(
773 ops::saved(written.as_slice(), space, &stored)
774 .unwrap()
775 .as_slice(),
776 written.as_slice()
777 );
778}
779
780#[test]
781fn an_unchanged_model_publishes_nothing() {
782 let (space, page) = page_by_title(OUTLINES, "Resize outline");
783 let prepared = ops::saved(OUTLINES, space, &page).unwrap();
784 assert_eq!(prepared.as_slice(), OUTLINES);
785}
786
787const JOIN_PAGE: &str = "Bold 馃 italic e\u{301} color 鏉变含";
788
789fn native_candidate_edits() -> Vec<(&'static str, &'static str)> {
790 vec![
791 (
792 "Move leaf down",
793 "Anchor moved last and replaced with Unicode text, bold range on Trailing sibling",
794 ),
795 (
796 "Move subtree down",
797 "Target subtree moved last with a new nested child, italic blue 14 pt range on Anchor",
798 ),
799 (
800 "Delete leaf",
801 "Anchor deleted, paragraph appended, new outline with two paragraphs at (300, 400)",
802 ),
803 ("Delete subtree", "Anchor split in the middle"),
804 (
805 "Collapse subtree",
806 "Target collapsed, outline moved to (90, 250.5) with a fixed 300 pt width",
807 ),
808 (
809 "Move outline",
810 "first body outline moved after the second, first paragraph of each extended",
811 ),
812 (
813 "Delete outline",
814 "second body outline deleted, underline, highlight and Consolas on a range of Anchor",
815 ),
816 (
817 JOIN_PAGE,
818 "the two paragraphs joined, strike and subscript on the first three units",
819 ),
820 ]
821}
822
823fn fresh(template: &onestore::page::PageParagraph, text: &str) -> onestore::page::PageParagraph {
824 let mut paragraph = template.clone();
825 paragraph.id = new_id().unwrap();
826 paragraph.parent = None;
827 paragraph.level = 1;
828 paragraph.lists.clear();
829 paragraph.tags.clear();
830 paragraph.style = None;
831 paragraph.collapsed = false;
832 let format = template.text().unwrap().text.format_at(0).unwrap().clone();
833 paragraph.content = ParagraphContent::Text(onestore::page::TextObject {
834 id: new_id().unwrap(),
835 date_field: None,
836 text: onestore::page::Paragraph::new(text.into(), format),
837 tags: Vec::new(),
838 });
839 paragraph
840}
841
842fn restyle(
843 text: &mut onestore::page::TextObject,
844 range: std::ops::Range<u32>,
845 change: impl Fn(&mut onestore::document::Format),
846) {
847 let slice = text.text.slice(range.clone()).unwrap();
848 let mut runs = Vec::new();
849 let mut from = 0;
850 for span in slice.spans() {
851 let mut format = span.format.clone();
852 change(&mut format);
853 runs.push((slice.text()[from..span.end].to_owned(), format));
854 from = span.end;
855 }
856 text.text
857 .apply(onestore::page::text::Edit {
858 range,
859 replacement: onestore::page::Paragraph::from_runs(runs),
860 })
861 .unwrap();
862}
863
864fn set_text(text: &mut onestore::page::TextObject, replacement: &str) {
865 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
866 let format = text.text.format_at(0).unwrap().clone();
867 text.text
868 .apply(onestore::page::text::Edit {
869 range: 0..end,
870 replacement: onestore::page::Paragraph::new(replacement.into(), format),
871 })
872 .unwrap();
873}
874
875/// Removes a top-level subtree and returns it in order.
876fn take_subtree(
877 outline: &mut onestore::page::Outline,
878 index: usize,
879) -> Vec<onestore::page::PageParagraph> {
880 let mut ids = BTreeSet::from([outline.paragraphs[index].id]);
881 loop {
882 let before = ids.len();
883 let more: Vec<ExGuid> = outline
884 .paragraphs
885 .iter()
886 .filter(|p| p.parent.is_some_and(|q| ids.contains(&q)))
887 .map(|p| p.id)
888 .collect();
889 ids.extend(more);
890 if ids.len() == before {
891 break;
892 }
893 }
894 let (subtree, rest): (Vec<_>, Vec<_>) = outline
895 .paragraphs
896 .drain(..)
897 .partition(|p| ids.contains(&p.id));
898 outline.paragraphs = rest;
899 subtree
900}
901
902fn expectation(page: &Page) -> serde_json::Value {
903 let outlines: Vec<serde_json::Value> = body(page)
904 .iter()
905 .map(|outline| {
906 let index: std::collections::BTreeMap<ExGuid, usize> =
907 outline.paragraphs.iter().enumerate().map(|(i, p)| (p.id, i)).collect();
908 serde_json::json!({
909 "x": outline.layout.x,
910 "y": outline.layout.y,
911 "max_width": outline.layout.max_width,
912 "width_set_by_user": outline.layout.width_set_by_user,
913 "paragraphs": outline.paragraphs.iter().map(|p| serde_json::json!({
914 "text": p.text().map(|t| t.text.text()),
915 "parent": p.parent.map(|q| index[&q]),
916 "collapsed": p.collapsed,
917 "spans": p.text().map(|t| t.text.spans().iter().map(|s| serde_json::json!({
918 "end": t.text.utf16_offset(s.end).unwrap(),
919 "bold": s.format.bold, "italic": s.format.italic, "underline": s.format.underline,
920 "strike": s.format.strike, "subscript": s.format.subscript,
921 "font": s.format.font, "font_size": s.format.font_size,
922 "color": s.format.color, "highlight": s.format.highlight,
923 })).collect::<Vec<_>>()),
924 })).collect::<Vec<_>>(),
925 })
926 })
927 .collect();
928 serde_json::json!({ "title": page.title, "outlines": outlines })
929}
930
931#[test]
932#[ignore = "exports a page-model candidate notebook for cold native validation"]
933fn export_native_page_model_candidates() {
934 let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_PAGE_MODEL_OUTPUT").unwrap());
935 assert!(output.is_absolute());
936 std::fs::create_dir_all(output.join("candidate/notebook")).unwrap();
937 let mut bytes = OUTLINES.to_vec();
938 let mut cases = Vec::new();
939 for (title, description) in native_candidate_edits() {
940 let (space, _) = page_by_title(&bytes, title);
941 let mut after = page_in(&bytes, space);
942 let outlines: Vec<usize> = after
943 .objects
944 .iter()
945 .enumerate()
946 .filter(|(_, o)| matches!(o, PageObject::Outline(_)))
947 .map(|(i, _)| i)
948 .collect();
949 fn body_outline(page: &mut Page, i: usize) -> &mut onestore::page::Outline {
950 match &mut page.objects[i] {
951 PageObject::Outline(outline) => outline,
952 _ => unreachable!(),
953 }
954 }
955 match title {
956 "Move leaf down" => {
957 let outline = body_outline(&mut after, outlines[0]);
958 let subtree = take_subtree(outline, 0);
959 outline.paragraphs.extend(subtree);
960 let last = outline.paragraphs.len() - 1;
961 set_text(
962 outline.paragraphs[last].text_mut().unwrap(),
963 "Rust 馃 é 鏉变含 砖诇讜诐",
964 );
965 let trailing = outline
966 .paragraphs
967 .iter()
968 .position(|p| {
969 p.text()
970 .is_some_and(|t| t.text.text() == "Trailing sibling")
971 })
972 .unwrap();
973 restyle(
974 outline.paragraphs[trailing].text_mut().unwrap(),
975 0..4,
976 |f| f.bold = Some(true),
977 );
978 }
979 "Move subtree down" => {
980 let outline = body_outline(&mut after, outlines[0]);
981 let parent_index = outline
982 .paragraphs
983 .iter()
984 .position(|p| outline.paragraphs.iter().any(|q| q.parent == Some(p.id)))
985 .unwrap();
986 let parent_id = outline.paragraphs[parent_index].id;
987 let subtree = take_subtree(outline, parent_index);
988 outline.paragraphs.extend(subtree);
989 let mut child = fresh(&outline.paragraphs[0], "Nested by Rust");
990 child.parent = Some(parent_id);
991 child.level = 2;
992 outline.paragraphs.push(child);
993 restyle(outline.paragraphs[0].text_mut().unwrap(), 0..6, |f| {
994 f.italic = Some(true);
995 f.color = Some(0xff0000);
996 f.font_size = Some(14.0);
997 });
998 }
999 "Delete leaf" => {
1000 let template = body(&after)[0].paragraphs[0].clone();
1001 let outline = body_outline(&mut after, outlines[0]);
1002 take_subtree(outline, 0);
1003 outline
1004 .paragraphs
1005 .push(fresh(&template, "Appended by Rust"));
1006 let new_outline = onestore::page::Outline {
1007 id: new_id().unwrap(),
1008 title: false,
1009 min_width: None,
1010 layout: onestore::document::Layout {
1011 x: Some(300.0),
1012 y: Some(400.0),
1013 ..Default::default()
1014 },
1015 indents: Vec::new(),
1016 paragraphs: vec![
1017 fresh(&template, "Rust outline one"),
1018 fresh(&template, "Rust outline two"),
1019 ],
1020 unsupported: Vec::new(),
1021 };
1022 let at = after
1023 .objects
1024 .iter()
1025 .position(|o| matches!(o, PageObject::Title(_)))
1026 .unwrap_or(after.objects.len());
1027 after.objects.insert(at, PageObject::Outline(new_outline));
1028 }
1029 "Delete subtree" => {
1030 let outline = body_outline(&mut after, outlines[0]);
1031 let first = outline.paragraphs[0].clone();
1032 let text = first.text().unwrap();
1033 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
1034 let offset = (end / 2..end)
1035 .find(|at| text.text.byte_offset(*at).is_ok())
1036 .unwrap();
1037 let tail = text.text.slice(offset..end).unwrap();
1038 let mut right = fresh(&first, "");
1039 right.style = first.style;
1040 right.text_mut().unwrap().text = tail;
1041 let left = outline.paragraphs[0].text_mut().unwrap();
1042 left.text = left.text.slice(0..offset).unwrap();
1043 outline.paragraphs.insert(1, right);
1044 }
1045 "Collapse subtree" => {
1046 let outline = body_outline(&mut after, outlines[0]);
1047 let parent = outline
1048 .paragraphs
1049 .iter()
1050 .position(|p| outline.paragraphs.iter().any(|q| q.parent == Some(p.id)))
1051 .unwrap();
1052 outline.paragraphs[parent].collapsed = true;
1053 outline.layout.x = Some(90.0);
1054 outline.layout.y = Some(250.5);
1055 outline.layout.max_width = Some(300.0);
1056 outline.layout.width_set_by_user = Some(true);
1057 }
1058 "Move outline" => {
1059 assert!(outlines.len() >= 2);
1060 let first = after.objects.remove(outlines[0]);
1061 after.objects.insert(outlines[1], first);
1062 for i in [outlines[0], outlines[1]] {
1063 let outline = body_outline(&mut after, i);
1064 let text = outline.paragraphs[0].text_mut().unwrap();
1065 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
1066 let format = text.text.format_at(end).unwrap().clone();
1067 text.text
1068 .apply(onestore::page::text::Edit {
1069 range: end..end,
1070 replacement: onestore::page::Paragraph::new(" (Rust)".into(), format),
1071 })
1072 .unwrap();
1073 }
1074 }
1075 "Delete outline" => {
1076 assert!(outlines.len() >= 2);
1077 after.objects.remove(outlines[1]);
1078 let outline = body_outline(&mut after, outlines[0]);
1079 restyle(outline.paragraphs[0].text_mut().unwrap(), 1..5, |f| {
1080 f.underline = Some(true);
1081 f.highlight = Some(0x00ffff);
1082 f.font = Some("Consolas".into());
1083 });
1084 }
1085 _ => {
1086 let outline = body_outline(&mut after, outlines[0]);
1087 assert_eq!(outline.paragraphs.len(), 2);
1088 let right = outline.paragraphs.remove(1);
1089 outline.paragraphs[0]
1090 .text_mut()
1091 .unwrap()
1092 .text
1093 .append(right.text().unwrap().text.clone())
1094 .unwrap();
1095 restyle(outline.paragraphs[0].text_mut().unwrap(), 0..3, |f| {
1096 f.strike = Some(true);
1097 f.subscript = Some(true);
1098 });
1099 }
1100 }
1101 let edit = ops::saved(&bytes, space, &after).unwrap();
1102 bytes = edit.as_slice().to_vec();
1103 let stored = page_in(&bytes, space);
1104 let index = {
1105 let store = Store::parse(&bytes).unwrap();
1106 let index = RevisionIndex::parse(&store).unwrap();
1107 let document = Document::parse(&index).unwrap();
1108 document
1109 .pages()
1110 .unwrap()
1111 .iter()
1112 .position(|(sid, _)| *sid == space)
1113 .unwrap()
1114 };
1115 cases.push(serde_json::json!({
1116 "title": title,
1117 "index": index,
1118 "description": description,
1119 "space": space.to_string(),
1120 "expected": expectation(&stored),
1121 }));
1122 }
1123 std::fs::write(output.join("candidate/notebook/synthetic.one"), &bytes).unwrap();
1124 std::fs::copy(
1125 concat!(
1126 env!("CARGO_MANIFEST_DIR"),
1127 "/../../corpus/outline-edit/before/notebook/Open Notebook.onetoc2"
1128 ),
1129 output.join("candidate/notebook/Open Notebook.onetoc2"),
1130 )
1131 .unwrap();
1132 std::fs::write(
1133 output.join("candidate/manifest.json"),
1134 serde_json::to_string_pretty(&serde_json::json!({
1135 "source": "corpus/outline-edit/before/notebook/synthetic.one",
1136 "author": "Rust page writer",
1137 "transactions_added": cases.len(),
1138 "cases": cases,
1139 }))
1140 .unwrap(),
1141 )
1142 .unwrap();
1143}