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