1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind, Revision},
4 op::{Op, OpError, PageOp},
5 page::text::new_id,
6};
7use serde_json::Value;
8
9#[path = "support/checkpoint.rs"]
10mod checkpoint;
11#[path = "support/current.rs"]
12mod current;
13#[path = "support/disk.rs"]
14mod disk;
15#[path = "support/metadata.rs"]
16mod metadata;
17#[path = "support/ops.rs"]
18mod ops;
19
20/// Enter at `at` in `text`, with fresh identities for the new paragraph, its text and a
21/// copy of each list node of the paragraph: the op, the new paragraph and its text.
22fn split(source: &[u8], space: ExGuid, text: ExGuid, at: u32) -> (Op, ExGuid, ExGuid) {
23 let store = Store::parse(source).unwrap();
24 let index = RevisionIndex::parse(&store).unwrap();
25 let document = Document::parse(&index).unwrap();
26 let lists = document.spaces[&space]
27 .active()
28 .unwrap()
29 .nodes
30 .values()
31 .find_map(|node| match &node.kind {
32 Kind::Paragraph { lists, .. } if node.content == [text] => Some(lists.len()),
33 _ => None,
34 })
35 .unwrap_or(0);
36 let (paragraph, right) = (new_id().unwrap(), new_id().unwrap());
37 let op = PageOp::Split {
38 text,
39 at,
40 paragraph,
41 right,
42 lists: (0..lists).map(|_| new_id().unwrap()).collect(),
43 };
44 (Op::Page { space, op }, paragraph, right)
45}
46
47/// `source` after joining the paragraph of text `right` to the one of text `left`.
48fn join(source: &[u8], space: ExGuid, left: ExGuid, right: ExGuid) -> Result<Vec<u8>, OpError> {
49 ops::edited(
50 source,
51 vec![Op::Page {
52 space,
53 op: PageOp::Join { left, right },
54 }],
55 )
56}
57
58const SOURCE: &[u8] =
59 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one");
60
61fn characters(view: &Revision<'_>, id: ExGuid) -> Vec<(char, Value)> {
62 view.text_runs(id)
63 .unwrap()
64 .into_iter()
65 .flat_map(|run| {
66 let format = serde_json::to_value(run.format).unwrap();
67 run.text.chars().map(move |c| (c, format.clone()))
68 })
69 .collect()
70}
71
72const JOIN_FIXTURES: [(&[u8], &[u8], bool); 3] = [
73 (
74 include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one").as_slice(),
75 include_bytes!("../../../corpus/paragraph-edit/joined/notebook/synthetic.one").as_slice(),
76 true,
77 ),
78 (
79 include_bytes!("../../../corpus/paragraph-edit/join-edges/before/notebook/synthetic.one")
80 .as_slice(),
81 include_bytes!("../../../corpus/paragraph-edit/join-edges/joined/notebook/synthetic.one")
82 .as_slice(),
83 false,
84 ),
85 (
86 include_bytes!("../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one")
87 .as_slice(),
88 include_bytes!("../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one")
89 .as_slice(),
90 false,
91 ),
92];
93
94fn join_targets(document: &Document<'_>, split_cases: bool) -> Vec<(String, ExGuid, ExGuid)> {
95 let mut cases = Vec::new();
96 if split_cases {
97 let manifest: Value =
98 serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json"))
99 .unwrap();
100 for case in manifest["cases"].as_array().unwrap() {
101 cases.push((
102 case["case"].as_str().unwrap().to_owned(),
103 serde_json::from_value::<ExGuid>(case["original_text"].clone()).unwrap(),
104 serde_json::from_value::<ExGuid>(case["new_text"].clone()).unwrap(),
105 ));
106 }
107 } else {
108 for (sid, page) in document.pages().unwrap() {
109 let s = &document.spaces[&sid];
110 let view = &s.revisions[&s.contexts[&ExGuid::default()]];
111 let Kind::Metadata {
112 title: Some(name), ..
113 } = &view.nodes[&view.roots[&2]].kind
114 else {
115 panic!()
116 };
117 if !name.starts_with("Join ") {
118 continue;
119 }
120 let outline = view.nodes[&page]
121 .children
122 .iter()
123 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
124 .unwrap();
125 let mut left = view.nodes[outline].children[0];
126 while let Some(child) = view.nodes[&left].children.last() {
127 left = *child;
128 }
129 let right = view.nodes[outline].children[1];
130 cases.push((
131 name.clone(),
132 view.nodes[&left].content[0],
133 view.nodes[&right].content[0],
134 ));
135 }
136 }
137 assert_eq!(cases.len(), document.pages().unwrap().len() - 1);
138 cases
139}
140
141#[test]
142fn joins_match_native_graphs_tags_and_inherited_character_styles() {
143 for (source, native, split_cases) in JOIN_FIXTURES {
144 let store = Store::parse(source).unwrap();
145 let index = RevisionIndex::parse(&store).unwrap();
146 let document = Document::parse(&index).unwrap();
147 let native_store = Store::parse(native).unwrap();
148 let native_index = RevisionIndex::parse(&native_store).unwrap();
149 let native_document = Document::parse(&native_index).unwrap();
150 let cases = join_targets(&document, split_cases);
151 for (name, left, right) in cases {
152 let (sid, space) = document
153 .spaces
154 .iter()
155 .find(|(_, space)| {
156 space.revisions[&space.contexts[&ExGuid::default()]]
157 .nodes
158 .contains_key(&left)
159 })
160 .unwrap();
161 let edited =
162 join(source, *sid, left, right).unwrap_or_else(|error| panic!("{name}: {error}"));
163 let current_store = Store::parse(&edited).unwrap();
164 assert_eq!(
165 current_store.header.transaction_count,
166 store.header.transaction_count + 1
167 );
168 let current_index = RevisionIndex::parse(&current_store).unwrap();
169 current_index.validate_current().unwrap();
170 let current_document = Document::parse(&current_index).unwrap();
171 let current = &current_document.spaces[sid];
172 let current = &current.revisions[&current.contexts[&ExGuid::default()]];
173 let expected = &native_document.spaces[sid];
174 let expected = &expected.revisions[&expected.contexts[&ExGuid::default()]];
175 let (_, page) = document
176 .pages()
177 .unwrap()
178 .into_iter()
179 .find(|(id, _)| id == sid)
180 .unwrap();
181 let before = &space.revisions[&space.contexts[&ExGuid::default()]];
182 let left_paragraph = *before
183 .nodes
184 .iter()
185 .find(|(_, node)| node.content == [left])
186 .unwrap()
187 .0;
188 let right_paragraph = *before
189 .nodes
190 .iter()
191 .find(|(_, node)| node.content == [right])
192 .unwrap()
193 .0;
194 let right_parent = *before
195 .nodes
196 .iter()
197 .find(|(_, node)| node.children.contains(&right_paragraph))
198 .unwrap()
199 .0;
200 let survivor = if characters(before, left).is_empty() {
201 right
202 } else {
203 left
204 };
205 let mut changed = std::collections::BTreeSet::from([survivor, before.roots[&2]]);
206 let mut ancestors = vec![left_paragraph, right_parent];
207 while let Some(id) = ancestors.pop() {
208 if !changed.insert(id) || id == page {
209 continue;
210 }
211 ancestors.extend(before.nodes.iter().filter_map(|(parent, node)| {
212 node.children
213 .iter()
214 .chain(&node.content)
215 .chain(&node.structure)
216 .any(|child| *child == id)
217 .then_some(*parent)
218 }));
219 }
220 let old_raw = index
221 .resolve(*sid, space.contexts[&ExGuid::default()])
222 .unwrap();
223 let current_rid = current_document.spaces[sid].contexts[&ExGuid::default()];
224 let new_raw = current_index.resolve(*sid, current_rid).unwrap();
225 let modified = metadata::check(&old_raw, &new_raw, "Author");
226 let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]);
227 changed.extend(old_raw.roots.get(&4));
228 for id in &changed {
229 if current
230 .nodes
231 .get(id)
232 .is_some_and(|node| matches!(node.kind, Kind::Paragraph { .. }))
233 {
234 assert_eq!(metadata::author(&new_raw.objects[id]), author);
235 assert_eq!(
236 current.nodes[id].modified.map(u64::from),
237 Some(modified / 10_000_000 - 11_960_006_400)
238 );
239 }
240 }
241 for (id, object) in old_raw.objects {
242 if !changed.contains(&id) {
243 assert_eq!(
244 object.data, new_raw.objects[&id].data,
245 "{name} untouched {id}"
246 );
247 }
248 }
249 let mut pending: Vec<_> = expected.nodes[&page]
250 .children
251 .iter()
252 .filter(|id| matches!(expected.nodes[id].kind, Kind::Outline { .. }))
253 .copied()
254 .collect();
255 while let Some(id) = pending.pop() {
256 let node = &expected.nodes[&id];
257 assert_eq!(
258 current.nodes[&id].children, node.children,
259 "{name} children {id}"
260 );
261 assert_eq!(
262 current.nodes[&id].content, node.content,
263 "{name} content {id}"
264 );
265 assert_eq!(
266 current.nodes[&id].child_level, node.child_level,
267 "{name} indentation {id}"
268 );
269 pending.extend(
270 node.children
271 .iter()
272 .chain(&node.content)
273 .chain(&node.structure)
274 .copied(),
275 );
276 if matches!(node.kind, Kind::RichText { .. }) {
277 assert_eq!(
278 serde_json::to_value(&current.nodes[&id].tags).unwrap(),
279 serde_json::to_value(&node.tags).unwrap(),
280 "{name} tags {id}"
281 );
282 let normalize = |view, id| {
283 characters(view, id)
284 .into_iter()
285 .map(|(c, mut style)| {
286 let fields = style.as_object_mut().unwrap();
287 for key in
288 ["alignment", "space_before", "space_after", "line_spacing"]
289 {
290 fields.remove(key);
291 }
292 for key in [
293 "bold",
294 "italic",
295 "underline",
296 "strike",
297 "superscript",
298 "subscript",
299 "hidden",
300 "hyperlink",
301 "hyperlink_label",
302 "math",
303 "embedded_object",
304 "rtl",
305 ] {
306 if fields[key].is_null() {
307 fields.insert(key.into(), false.into());
308 }
309 }
310 for key in ["color", "highlight"] {
311 if fields[key].is_null() {
312 fields.insert(key.into(), 0xff000000_u32.into());
313 }
314 }
315 (c, style)
316 })
317 .collect::<Vec<_>>()
318 };
319 assert_eq!(
320 normalize(current, id),
321 normalize(expected, id),
322 "{name} styles {id}"
323 );
324 }
325 }
326 for (sid, old) in &index.spaces {
327 for revision in old.revisions.keys() {
328 let old = index.resolve(*sid, *revision).unwrap();
329 let retained = current_index.resolve(*sid, *revision).unwrap();
330 assert_eq!(old.roots, retained.roots);
331 for (id, object) in old.objects {
332 assert_eq!(object.data, retained.objects[&id].data);
333 }
334 }
335 }
336 assert!(join(&edited, *sid, left, right).is_err());
337 assert_eq!(
338 space.contexts.len(),
339 current_document.spaces[sid].contexts.len()
340 );
341 }
342 }
343}
344
345#[test]
346fn splits_partition_native_paragraphs_at_every_scalar_boundary() {
347 let manifest: Value =
348 serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap();
349 let store = Store::parse(SOURCE).unwrap();
350 let index = RevisionIndex::parse(&store).unwrap();
351 let document = Document::parse(&index).unwrap();
352 let native_store = Store::parse(JOIN_FIXTURES[0].0).unwrap();
353 let native_index = RevisionIndex::parse(&native_store).unwrap();
354 let native_document = Document::parse(&native_index).unwrap();
355 for case in manifest["cases"].as_array().unwrap() {
356 let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap();
357 let paragraph: ExGuid = serde_json::from_value(case["original_paragraph"].clone()).unwrap();
358 let (sid, space) = document
359 .spaces
360 .iter()
361 .find(|(_, space)| {
362 space.revisions[&space.contexts[&ExGuid::default()]]
363 .nodes
364 .contains_key(&text)
365 })
366 .unwrap();
367 let before = &space.revisions[&space.contexts[&ExGuid::default()]];
368 let (parent, parent_node) = before
369 .nodes
370 .iter()
371 .find(|(_, node)| node.children.contains(&paragraph))
372 .unwrap();
373 let expected = characters(before, text);
374 let offsets: Vec<u32> = std::iter::once(0)
375 .chain(expected.iter().scan(0, |offset, (c, _)| {
376 *offset += u32::try_from(c.len_utf16()).unwrap();
377 Some(*offset)
378 }))
379 .collect();
380 for (position, offset) in offsets.iter().enumerate() {
381 let (op, new_paragraph, new_text) = split(SOURCE, *sid, text, *offset);
382 let edited = ops::edited(SOURCE, vec![op.clone()]);
383 // OneNote 2010 splits before or after a hyperlink and ignores Enter inside one.
384 let link = |at: usize| {
385 expected
386 .get(at)
387 .is_some_and(|(_, style)| style["hyperlink"] == true)
388 };
389 if position > 0
390 && link(position - 1)
391 && link(position)
392 && expected[position].0 != '\u{fddf}'
393 {
394 assert!(edited.is_err(), "{} at {offset}", case["case"]);
395 continue;
396 }
397 let edited =
398 edited.unwrap_or_else(|error| panic!("{} at {offset}: {error}", case["case"]));
399 let current_store = Store::parse(&edited).unwrap();
400 assert_eq!(
401 current_store.header.transaction_count,
402 store.header.transaction_count + 1
403 );
404 let current_index = RevisionIndex::parse(&current_store).unwrap();
405 current_index.validate_current().unwrap();
406 let current_document = Document::parse(&current_index).unwrap();
407 let current_space = &current_document.spaces[sid];
408 let after = &current_space.revisions[&current_space.contexts[&ExGuid::default()]];
409 let mut changed = std::collections::BTreeSet::from([text, before.roots[&2]]);
410 let mut pending = vec![paragraph];
411 while let Some(id) = pending.pop() {
412 if !changed.insert(id) {
413 continue;
414 }
415 pending.extend(before.nodes.iter().filter_map(|(parent, node)| {
416 node.children
417 .iter()
418 .chain(&node.content)
419 .chain(&node.structure)
420 .any(|child| *child == id)
421 .then_some(*parent)
422 }));
423 }
424 let old_raw = index
425 .resolve(*sid, space.contexts[&ExGuid::default()])
426 .unwrap();
427 let new_raw = current_index
428 .resolve(*sid, current_space.contexts[&ExGuid::default()])
429 .unwrap();
430 let modified = metadata::check(&old_raw, &new_raw, "Author");
431 let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]);
432 changed.extend(old_raw.roots.get(&4));
433 for id in [paragraph, new_paragraph] {
434 assert_eq!(metadata::author(&new_raw.objects[&id]), author);
435 assert_eq!(
436 after.nodes[&id].modified.map(u64::from),
437 Some(modified / 10_000_000 - 11_960_006_400)
438 );
439 }
440 for (id, object) in old_raw.objects {
441 if !changed.contains(&id) {
442 assert_eq!(object.data, new_raw.objects[&id].data, "untouched {id}");
443 }
444 }
445 assert_eq!(
446 characters(after, text),
447 expected[..position],
448 "{} prefix at {offset}",
449 case["case"]
450 );
451 assert_eq!(
452 characters(after, new_text),
453 expected[position..],
454 "{} suffix at {offset}",
455 case["case"]
456 );
457 if case["offset_utf16"] == *offset {
458 let native = &native_document.spaces[sid];
459 let native = &native.revisions[&native.contexts[&ExGuid::default()]];
460 let native_text = serde_json::from_value(case["new_text"].clone()).unwrap();
461 assert_eq!(
462 characters(after, text),
463 characters(native, text),
464 "{}",
465 case["case"]
466 );
467 assert_eq!(
468 characters(after, new_text),
469 characters(native, native_text),
470 "{}",
471 case["case"]
472 );
473 }
474 let mut children = parent_node.children.clone();
475 children.insert(
476 children.iter().position(|id| *id == paragraph).unwrap() + 1,
477 new_paragraph,
478 );
479 assert_eq!(after.nodes[parent].children, children);
480 assert_eq!(after.nodes[&paragraph].content, [text]);
481 assert!(after.nodes[&paragraph].children.is_empty());
482 assert_eq!(after.nodes[&new_paragraph].content, [new_text]);
483 assert_eq!(
484 after.nodes[&new_paragraph].children,
485 before.nodes[&paragraph].children
486 );
487 assert_eq!(
488 serde_json::to_value(&after.nodes[&text].tags).unwrap(),
489 serde_json::to_value(&before.nodes[&text].tags).unwrap()
490 );
491 assert!(after.nodes[&new_text].tags.is_empty());
492 let Kind::Paragraph {
493 lists: old_lists, ..
494 } = &before.nodes[&paragraph].kind
495 else {
496 panic!()
497 };
498 let Kind::Paragraph { lists, .. } = &after.nodes[&new_paragraph].kind else {
499 panic!()
500 };
501 assert_eq!(old_lists.len(), lists.len());
502 for (old, new) in old_lists.iter().zip(lists) {
503 assert_ne!(old, new);
504 let mut old = serde_json::to_value(&before.nodes[old]).unwrap();
505 old["kind"]["restart"] = Value::Null;
506 assert_eq!(serde_json::to_value(&after.nodes[new]).unwrap(), old);
507 }
508 for (space_id, old_space) in &index.spaces {
509 for revision in old_space.revisions.keys() {
510 let old = index.resolve(*space_id, *revision).unwrap();
511 let retained = current_index.resolve(*space_id, *revision).unwrap();
512 assert_eq!(old.roots, retained.roots);
513 for (id, object) in old.objects {
514 assert_eq!(object.data, retained.objects[&id].data);
515 }
516 }
517 }
518 assert!(ops::edited(&edited, vec![op]).is_err());
519 }
520 for offset in 0..=*offsets.last().unwrap() + 1 {
521 if offsets.contains(&offset) {
522 continue;
523 }
524 assert!(ops::edited(SOURCE, vec![split(SOURCE, *sid, text, offset).0]).is_err());
525 }
526 }
527}
528
529#[test]
530fn invalid_split_identities_and_title_targets_are_rejected() {
531 let manifest: Value =
532 serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap();
533 let text: ExGuid =
534 serde_json::from_value(manifest["cases"][0]["original_text"].clone()).unwrap();
535 let store = Store::parse(SOURCE).unwrap();
536 let index = RevisionIndex::parse(&store).unwrap();
537 let document = Document::parse(&index).unwrap();
538 let sid = *document
539 .spaces
540 .iter()
541 .find(|(_, space)| {
542 space.revisions[&space.contexts[&ExGuid::default()]]
543 .nodes
544 .contains_key(&text)
545 })
546 .unwrap()
547 .0;
548 let (op, ..) = split(SOURCE, sid, text, 1);
549 assert!(ops::transaction(SOURCE, "a\0b", vec![op.clone()]).is_err());
550 let Op::Page {
551 op:
552 PageOp::Split {
553 paragraph,
554 right,
555 lists,
556 ..
557 },
558 ..
559 } = op
560 else {
561 unreachable!()
562 };
563 for (text, at, paragraph) in [
564 (ExGuid::default(), 1, paragraph),
565 (text, u32::MAX, paragraph),
566 (text, 1, ExGuid::default()),
567 (text, 1, text),
568 ] {
569 let op = PageOp::Split {
570 text,
571 at,
572 paragraph,
573 right,
574 lists: lists.clone(),
575 };
576 assert!(ops::page_edited(SOURCE, sid, vec![op]).is_err());
577 }
578 let mut titles = 0;
579 for (sid, _) in document.pages().unwrap() {
580 let space = &document.spaces[&sid];
581 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
582 let mut pending: Vec<_> = view
583 .nodes
584 .iter()
585 .filter_map(|(id, n)| matches!(n.kind, Kind::Title).then_some(*id))
586 .collect();
587 let mut seen = std::collections::BTreeSet::new();
588 while let Some(id) = pending.pop() {
589 if !seen.insert(id) {
590 continue;
591 }
592 let node = &view.nodes[&id];
593 pending.extend(node.children.iter().chain(&node.content).copied());
594 if matches!(node.kind, Kind::RichText { .. }) {
595 assert!(ops::edited(SOURCE, vec![split(SOURCE, sid, id, 0).0]).is_err());
596 titles += 1;
597 }
598 }
599 }
600 assert!(titles >= 14);
601}
602
603#[test]
604#[ignore = "exports paragraph splits for independent native validation"]
605fn export_native_paragraph_splits() {
606 use std::{fs, path::PathBuf};
607 let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_OUTPUT").unwrap());
608 assert!(output.is_absolute());
609 fs::create_dir(&output).unwrap();
610 let manifest: Value =
611 serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap();
612 let mut source = SOURCE.to_vec();
613 let mut written = Vec::new();
614 for case in manifest["cases"].as_array().unwrap() {
615 let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap();
616 let store = Store::parse(&source).unwrap();
617 let index = RevisionIndex::parse(&store).unwrap();
618 let document = Document::parse(&index).unwrap();
619 let sid = *document
620 .spaces
621 .iter()
622 .find(|(_, space)| {
623 space.revisions[&space.contexts[&ExGuid::default()]]
624 .nodes
625 .contains_key(&text)
626 })
627 .unwrap()
628 .0;
629 let at = serde_json::from_value(case["offset_utf16"].clone()).unwrap();
630 let (op, new_paragraph, new_text) = split(&source, sid, text, at);
631 let transaction = ops::transaction(&source, "Rust split author", vec![op.clone()])
632 .unwrap()
633 .unwrap();
634 written.push(serde_json::json!({"case": case["case"], "op": op,
635 "new_paragraph": new_paragraph, "new_text": new_text}));
636 transaction.apply(&mut source).unwrap();
637 }
638 let candidate = output.join("candidate");
639 fs::create_dir(&candidate).unwrap();
640 fs::write(candidate.join("synthetic.one"), source).unwrap();
641 fs::write(
642 output.join("manifest.json"),
643 serde_json::to_vec_pretty(&serde_json::json!({"cases": written})).unwrap(),
644 )
645 .unwrap();
646}
647
648#[test]
649fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() {
650 let original = onestore::create_section("split.one", "Original", "Author").unwrap();
651 let store = Store::parse(&original).unwrap();
652 let index = RevisionIndex::parse(&store).unwrap();
653 let document = Document::parse(&index).unwrap();
654 let (sid, page) = document.pages().unwrap()[0];
655 let space = &document.spaces[&sid];
656 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
657 let outline = view.nodes[&page]
658 .children
659 .iter()
660 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
661 .unwrap();
662 let paragraph = view.nodes[outline].children[0];
663 let mut child = ops::paragraph("a🦀b");
664 child.level = 2;
665 let text = child.text().unwrap().id;
666 let inserted = ops::page_edited(
667 &original,
668 sid,
669 vec![
670 PageOp::Insert {
671 container: paragraph,
672 before: None,
673 paragraphs: vec![child],
674 },
675 PageOp::Format {
676 text,
677 range: 1..3,
678 set: vec![onestore::TextAttribute::Bold(true)],
679 clear: Vec::new(),
680 },
681 ],
682 )
683 .unwrap();
684 let source = inserted.as_slice();
685 let checkpoint = checkpoint::pending(source, sid, text);
686 for source in [source, &checkpoint] {
687 let (op, ..) = split(source, sid, text, 1);
688 let edit = ops::transaction(source, "Author", vec![op])
689 .unwrap()
690 .unwrap();
691 let mut written = source.to_vec();
692 edit.apply(&mut written).unwrap();
693 let before = current::current(source);
694 let after = current::current(&written);
695 for write_limit in [17, 4096] {
696 let disk = |fail_at| disk::Disk {
697 visible: source.to_vec(),
698 durable: source.to_vec(),
699 operation: 0,
700 fail_at,
701 write_limit,
702 random: 347,
703 };
704 let mut successful = disk(None);
705 edit.commit(&mut successful).unwrap();
706 assert_eq!(successful.durable, written);
707 for at in 1..=successful.operation {
708 let mut interrupted = disk(Some(at));
709 let error = edit.commit(&mut interrupted).unwrap_err();
710 let recovered = current::current(&interrupted.durable);
711 assert!(
712 recovered == before || recovered == after,
713 "interruption {at}"
714 );
715 match error.state {
716 onestore::CommitState::NotCommitted => assert_eq!(recovered, before),
717 onestore::CommitState::Committed => assert_eq!(recovered, after),
718 onestore::CommitState::Unknown => {}
719 }
720 }
721 }
722 }
723}
724
725#[test]
726#[ignore = "exports paragraph joins for independent native validation"]
727fn export_native_paragraph_joins() {
728 use std::{fs, path::PathBuf};
729 let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_JOIN_OUTPUT").unwrap());
730 assert!(output.is_absolute());
731 fs::create_dir(&output).unwrap();
732 for (number, (source, _, split_cases)) in JOIN_FIXTURES.into_iter().enumerate() {
733 let store = Store::parse(source).unwrap();
734 let index = RevisionIndex::parse(&store).unwrap();
735 let document = Document::parse(&index).unwrap();
736 let cases = join_targets(&document, split_cases);
737 let mut source = source.to_vec();
738 let mut manifest = Vec::new();
739 for (name, left, right) in cases {
740 let sid = *document
741 .spaces
742 .iter()
743 .find(|(_, space)| {
744 space.revisions[&space.contexts[&ExGuid::default()]]
745 .nodes
746 .contains_key(&left)
747 })
748 .unwrap()
749 .0;
750 let op = Op::Page {
751 space: sid,
752 op: PageOp::Join { left, right },
753 };
754 let transaction = ops::transaction(&source, "Rust join author", vec![op.clone()])
755 .unwrap()
756 .unwrap();
757 transaction.apply(&mut source).unwrap();
758 manifest.push(serde_json::json!({"case":name,"space":sid,"op":op}));
759 }
760 let folder = output.join(["split", "inheritance", "tags"][number]);
761 fs::create_dir_all(folder.join("candidate")).unwrap();
762 fs::write(folder.join("candidate/synthetic.one"), source).unwrap();
763 fs::write(
764 folder.join("manifest.json"),
765 serde_json::to_vec_pretty(&manifest).unwrap(),
766 )
767 .unwrap();
768 }
769}
770
771#[test]
772fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() {
773 let source = onestore::create_section("join.one", "Left", "Author").unwrap();
774 let store = Store::parse(&source).unwrap();
775 let index = RevisionIndex::parse(&store).unwrap();
776 let document = Document::parse(&index).unwrap();
777 let (sid, page) = document.pages().unwrap()[0];
778 let space = &document.spaces[&sid];
779 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
780 let outline = *view.nodes[&page]
781 .children
782 .iter()
783 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
784 .unwrap();
785 let left = view.nodes[&view.nodes[&outline].children[0]].content[0];
786 let right = ops::paragraph("Right 🦀");
787 let right_text = right.text().unwrap().id;
788 let mut child = ops::paragraph("Retained child");
789 child.parent = Some(right.id);
790 child.level = 2;
791 let original = ops::page_edited(
792 &source,
793 sid,
794 vec![
795 PageOp::Insert {
796 container: outline,
797 before: None,
798 paragraphs: vec![right, child],
799 },
800 PageOp::Format {
801 text: right_text,
802 range: 0..5,
803 set: vec![onestore::TextAttribute::Bold(true)],
804 clear: Vec::new(),
805 },
806 ],
807 )
808 .unwrap();
809 let original = original.as_slice();
810 let empty = ops::page_edited(
811 original,
812 sid,
813 vec![PageOp::Text {
814 text: left,
815 range: 0..4,
816 with: String::new(),
817 }],
818 )
819 .unwrap();
820 let checkpoint = checkpoint::pending(original, sid, left);
821 for source in [original, &empty, &checkpoint] {
822 let op = Op::Page {
823 space: sid,
824 op: PageOp::Join {
825 left,
826 right: right_text,
827 },
828 };
829 let edit = ops::transaction(source, "Join author", vec![op])
830 .unwrap()
831 .unwrap();
832 let mut written = source.to_vec();
833 edit.apply(&mut written).unwrap();
834 let before = current::current(source);
835 let after = current::current(&written);
836 for write_limit in [17, 4096] {
837 let disk = |fail_at| disk::Disk {
838 visible: source.to_vec(),
839 durable: source.to_vec(),
840 operation: 0,
841 fail_at,
842 write_limit,
843 random: 917,
844 };
845 let mut successful = disk(None);
846 edit.commit(&mut successful).unwrap();
847 assert_eq!(successful.durable, written);
848 for at in 1..=successful.operation {
849 let mut interrupted = disk(Some(at));
850 let error = edit.commit(&mut interrupted).unwrap_err();
851 let recovered = current::current(&interrupted.durable);
852 assert!(
853 recovered == before || recovered == after,
854 "interruption {at}"
855 );
856 match error.state {
857 onestore::CommitState::NotCommitted => assert_eq!(recovered, before),
858 onestore::CommitState::Committed => assert_eq!(recovered, after),
859 onestore::CommitState::Unknown => {}
860 }
861 }
862 }
863 }
864}
865
866#[test]
867fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() {
868 let (source, _, _) = JOIN_FIXTURES[0];
869 let store = Store::parse(source).unwrap();
870 let index = RevisionIndex::parse(&store).unwrap();
871 let document = Document::parse(&index).unwrap();
872 let cases = join_targets(&document, true);
873 let (_, left, right) = &cases[0];
874 let (sid, space) = document
875 .spaces
876 .iter()
877 .find(|(_, space)| {
878 space.revisions[&space.contexts[&ExGuid::default()]]
879 .nodes
880 .contains_key(left)
881 })
882 .unwrap();
883 for (a, b) in [
884 (*left, *left),
885 (ExGuid::default(), *right),
886 (*right, *left),
887 (*left, cases[1].2),
888 ] {
889 assert!(join(source, *sid, a, b).is_err());
890 }
891 let op = Op::Page {
892 space: *sid,
893 op: PageOp::Join {
894 left: *left,
895 right: *right,
896 },
897 };
898 assert!(ops::transaction(source, "a\0b", vec![op]).is_err());
899 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
900 let paragraph = *view
901 .nodes
902 .iter()
903 .find(|(_, node)| node.content == [*left])
904 .unwrap()
905 .0;
906 let parent = view
907 .nodes
908 .values()
909 .find(|node| node.children.contains(&paragraph))
910 .unwrap();
911 let last = view.nodes[parent.children.last().unwrap()].content[0];
912 assert!(join(source, *sid, *left, last).is_err());
913 let title = view
914 .nodes
915 .iter()
916 .find_map(|(_, node)| matches!(node.kind, Kind::Title).then_some(node))
917 .unwrap();
918 let mut pending = title.children.clone();
919 let mut rejected = 0;
920 while let Some(id) = pending.pop() {
921 let node = &view.nodes[&id];
922 pending.extend(node.children.iter().chain(&node.content).copied());
923 if matches!(node.kind, Kind::RichText { .. }) {
924 assert!(join(source, *sid, id, *right).is_err());
925 rejected += 1;
926 }
927 }
928 assert!(rejected > 0);
929}