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