1use onestore::{
2 ExGuid, OutlineEdit as Edit, RevisionIndex, Store,
3 document::{Document, Kind},
4 op::PageOp,
5};
6use std::collections::BTreeSet;
7
8#[path = "support/current.rs"]
9mod current;
10#[path = "support/disk.rs"]
11mod disk;
12#[path = "support/ops.rs"]
13mod ops;
14#[path = "support/sweep.rs"]
15mod sweep;
16
17const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
18
19fn cases(document: &Document<'_>) -> Vec<(String, ExGuid, ExGuid, Edit)> {
20 let mut cases = Vec::new();
21 for (sid, page) in document.pages().unwrap() {
22 let space = &document.spaces[&sid];
23 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
24 let Kind::Metadata {
25 title: Some(name), ..
26 } = &view.nodes[&view.roots[&2]].kind
27 else {
28 panic!()
29 };
30 let edit = match name.as_str() {
31 "Move outline" => Edit::Position { x: 180.0, y: 216.0 },
32 "Resize outline" => Edit::Width {
33 points: 144.0,
34 user_set: true,
35 },
36 "Automatic outline size" => Edit::Width {
37 points: 360.0,
38 user_set: false,
39 },
40 "Collapse subtree" => Edit::Collapsed(true),
41 "Expand subtree" => Edit::Collapsed(false),
42 _ => continue,
43 };
44 let mut object = *view.nodes[&page]
45 .children
46 .iter()
47 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
48 .unwrap();
49 if matches!(edit, Edit::Collapsed(_)) {
50 object = *view.nodes[&object].children.iter().find(|id| {
51 view.nodes[id].content.first().is_some_and(|text| matches!(
52 &view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")
53 ))
54 }).unwrap();
55 }
56 cases.push((name.clone(), sid, object, edit));
57 }
58 assert_eq!(cases.len(), 5);
59 cases
60}
61
62#[test]
63fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
64 let store = Store::parse(SOURCE).unwrap();
65 let index = RevisionIndex::parse(&store).unwrap();
66 let document = Document::parse(&index).unwrap();
67 for (name, sid, object, edit) in cases(&document) {
68 let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap();
69 assert_eq!(edit, restored);
70 let prepared = ops::page_op(
71 SOURCE,
72 sid,
73 PageOp::Outline {
74 object,
75 edit: restored,
76 },
77 )
78 .unwrap();
79 let after_store = Store::parse(prepared.as_bytes()).unwrap();
80 assert!(after_store.checksum_mismatches.is_empty());
81 let after_index = RevisionIndex::parse(&after_store).unwrap();
82 after_index.validate_current().unwrap();
83 let after_document = Document::parse(&after_index).unwrap();
84 let space = &document.spaces[&sid];
85 let before = &space.revisions[&space.contexts[&ExGuid::default()]];
86 let space = &after_document.spaces[&sid];
87 let after = &space.revisions[&space.contexts[&ExGuid::default()]];
88 let mut changed = BTreeSet::from([before.roots[&2]]);
89 let mut pending = vec![object];
90 while let Some(id) = pending.pop() {
91 if !changed.insert(id) {
92 continue;
93 }
94 pending.extend(before.nodes.iter().filter_map(|(parent, node)| {
95 node.children
96 .iter()
97 .chain(&node.content)
98 .chain(&node.structure)
99 .any(|child| *child == id)
100 .then_some(*parent)
101 }));
102 }
103 assert_eq!(
104 before.nodes.keys().collect::<Vec<_>>(),
105 after.nodes.keys().collect::<Vec<_>>()
106 );
107 for (id, node) in &before.nodes {
108 let mut expected = serde_json::to_value(node).unwrap();
109 let actual = serde_json::to_value(&after.nodes[id]).unwrap();
110 if changed.contains(id) {
111 expected["modified"] = actual["modified"].clone();
112 }
113 if *id == object {
114 match edit {
115 Edit::Position { x, y } => {
116 expected["layout"]["x"] = x.into();
117 expected["layout"]["y"] = y.into();
118 }
119 Edit::Width { points, user_set } => {
120 expected["layout"]["max_width"] = points.into();
121 expected["layout"]["width_set_by_user"] = user_set.into();
122 let fields = expected["extra"][0].as_array_mut().unwrap();
123 fields.retain(|field| field["id"].as_u64() != Some(0x14001cdb));
124 }
125 Edit::Collapsed(value) => {
126 expected["kind"]["collapse_state"] = u8::from(value).into()
127 }
128 }
129 }
130 let mut actual = actual;
131 // Property order is not semantic; every property and nested value still compares exactly.
132 expected["extra"][0]
133 .as_array_mut()
134 .unwrap()
135 .sort_by_key(|field| field["id"].as_u64().unwrap());
136 actual["extra"][0]
137 .as_array_mut()
138 .unwrap()
139 .sort_by_key(|field| field["id"].as_u64().unwrap());
140 assert_eq!(actual, expected, "{name}: {id}");
141 }
142 for (space_id, old_space) in &index.spaces {
143 for revision in old_space.revisions.keys() {
144 let old = index.resolve(*space_id, *revision).unwrap();
145 let retained = after_index.resolve(*space_id, *revision).unwrap();
146 assert_eq!(old.roots, retained.roots);
147 for (id, value) in old.objects {
148 assert_eq!(value.data, retained.objects[&id].data);
149 }
150 }
151 if *space_id != sid {
152 assert_eq!(old_space.labels, after_index.spaces[space_id].labels);
153 }
154 }
155 let again =
156 ops::page_op(prepared.as_bytes(), sid, PageOp::Outline { object, edit }).unwrap();
157 assert_eq!(again.as_bytes(), prepared.as_bytes());
158 }
159}
160
161#[test]
162fn invalid_geometry_and_non_outline_targets_are_rejected() {
163 let store = Store::parse(SOURCE).unwrap();
164 let index = RevisionIndex::parse(&store).unwrap();
165 let document = Document::parse(&index).unwrap();
166 for (_, sid, object, _) in cases(&document) {
167 for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
168 for edit in [
169 Edit::Position { x: value, y: 0.0 },
170 Edit::Position { x: 0.0, y: value },
171 Edit::Width {
172 points: value,
173 user_set: true,
174 },
175 ] {
176 assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object, edit }).is_err());
177 }
178 }
179 for points in [-1.0, 0.0, 35.999] {
180 assert!(
181 ops::page_op(
182 SOURCE,
183 sid,
184 PageOp::Outline {
185 object,
186 edit: Edit::Width {
187 points,
188 user_set: false
189 }
190 }
191 )
192 .is_err()
193 );
194 }
195 let space = &document.spaces[&sid];
196 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
197 for (id, node) in &view.nodes {
198 if !matches!(node.kind, Kind::Outline { .. }) {
199 assert!(
200 ops::page_op(
201 SOURCE,
202 sid,
203 PageOp::Outline {
204 object: *id,
205 edit: Edit::Position { x: 72.0, y: 72.0 }
206 }
207 )
208 .is_err()
209 );
210 }
211 if matches!(node.kind, Kind::Title) {
212 let mut descendants = node.children.clone();
213 while let Some(child) = descendants.pop() {
214 descendants.extend(&view.nodes[&child].children);
215 for edit in [
216 Edit::Position { x: 72.0, y: 72.0 },
217 Edit::Width {
218 points: 144.0,
219 user_set: true,
220 },
221 Edit::Collapsed(true),
222 ] {
223 assert!(
224 ops::page_op(
225 SOURCE,
226 sid,
227 PageOp::Outline {
228 object: child,
229 edit
230 }
231 )
232 .is_err()
233 );
234 }
235 }
236 }
237 if !matches!(node.kind, Kind::Paragraph { .. }) {
238 assert!(
239 ops::page_op(
240 SOURCE,
241 sid,
242 PageOp::Outline {
243 object: *id,
244 edit: Edit::Collapsed(true)
245 }
246 )
247 .is_err()
248 );
249 }
250 }
251 assert!(
252 ops::page_op(
253 SOURCE,
254 ExGuid::default(),
255 PageOp::Outline {
256 object,
257 edit: Edit::Collapsed(true)
258 }
259 )
260 .is_err()
261 );
262 assert!(
263 ops::page_op(
264 SOURCE,
265 sid,
266 PageOp::Outline {
267 object: ExGuid::default(),
268 edit: Edit::Collapsed(true)
269 }
270 )
271 .is_err()
272 );
273 }
274}
275
276#[test]
277fn resizing_a_native_reserved_width_preserves_content() {
278 let source = include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one");
279 let store = Store::parse(source).unwrap();
280 let index = RevisionIndex::parse(&store).unwrap();
281 let document = Document::parse(&index).unwrap();
282 let (_, sid, object, _) = cases(&document)
283 .into_iter()
284 .find(|(name, ..)| name == "Move outline")
285 .unwrap();
286 let space = &document.spaces[&sid];
287 let before = &space.revisions[&space.contexts[&ExGuid::default()]];
288 assert!(before.nodes[&object].layout.reserved_width.is_some());
289 let edit = ops::page_op(
290 source,
291 sid,
292 PageOp::Outline {
293 object,
294 edit: Edit::Width {
295 points: 144.0,
296 user_set: true,
297 },
298 },
299 )
300 .unwrap();
301 let after_store = Store::parse(edit.as_bytes()).unwrap();
302 let after_index = RevisionIndex::parse(&after_store).unwrap();
303 let after_document = Document::parse(&after_index).unwrap();
304 let space = &after_document.spaces[&sid];
305 let after = &space.revisions[&space.contexts[&ExGuid::default()]];
306 assert_eq!(after.nodes[&object].layout.reserved_width, None);
307 assert_eq!(after.nodes[&object].layout.max_width, Some(144.0));
308 for (id, node) in &before.nodes {
309 assert_eq!(node.children, after.nodes[id].children);
310 assert_eq!(node.content, after.nodes[id].content);
311 if matches!(node.kind, Kind::RichText { .. }) {
312 assert_eq!(
313 serde_json::to_value(node).unwrap(),
314 serde_json::to_value(&after.nodes[id]).unwrap()
315 );
316 }
317 }
318 if let Some(output) = std::env::var_os("ONESTORE_RESERVED_WIDTH_OUTPUT") {
319 let output = std::path::PathBuf::from(output);
320 assert!(output.is_absolute());
321 std::fs::create_dir(&output).unwrap();
322 std::fs::write(output.join("synthetic.one"), edit.as_bytes()).unwrap();
323 }
324}
325
326#[test]
327fn outline_movement_updates_both_automatic_title_fields_without_changing_content() {
328 let source = onestore::create_section("titles.one", "First", "Author").unwrap();
329 let store = Store::parse(&source).unwrap();
330 let index = RevisionIndex::parse(&store).unwrap();
331 let document = Document::parse(&index).unwrap();
332 let (sid, page) = document.pages().unwrap()[0];
333 let (add, insertion, _) = ops::new_outline(144.0, 144.0, "Second šŸ¦‹ é");
334 let source = ops::page_op(&source, sid, add).unwrap().image;
335 let store = Store::parse(&source).unwrap();
336 let index = RevisionIndex::parse(&store).unwrap();
337 let document = Document::parse(&index).unwrap();
338 let space = &document.spaces[&sid];
339 let rid = space.contexts[&ExGuid::default()];
340 let before = &space.revisions[&rid];
341 for (x, y, expected) in [
342 (144.0, 0.0, "Second šŸ¦‹ é"),
343 (0.0, 36.0, "Second šŸ¦‹ é"),
344 (144.0, 36.0, "First"),
345 (0.0, 144.0, "First"),
346 ] {
347 let edit = ops::page_op(
348 &source,
349 sid,
350 PageOp::Outline {
351 object: insertion,
352 edit: Edit::Position { x, y },
353 },
354 )
355 .unwrap();
356 let saved = Store::parse(edit.as_bytes()).unwrap();
357 let saved_index = RevisionIndex::parse(&saved).unwrap();
358 saved_index.validate_current().unwrap();
359 let saved_document = Document::parse(&saved_index).unwrap();
360 let saved_space = &saved_document.spaces[&sid];
361 let after = &saved_space.revisions[&saved_space.contexts[&ExGuid::default()]];
362 let metadata = before.roots[&2];
363 assert!(
364 matches!(&after.nodes[&metadata].kind, Kind::Metadata { title: Some(title), .. } if title == expected)
365 );
366 assert!(
367 matches!(&after.nodes[&page].kind, Kind::Page { alternate_title: Some(title), .. } if title == expected)
368 );
369 assert_eq!(
370 before.nodes.keys().collect::<Vec<_>>(),
371 after.nodes.keys().collect::<Vec<_>>()
372 );
373 for (oid, node) in &before.nodes {
374 if ![page, metadata, insertion].contains(oid) {
375 assert_eq!(
376 serde_json::to_value(node).unwrap(),
377 serde_json::to_value(&after.nodes[oid]).unwrap()
378 );
379 }
380 }
381 assert_eq!(
382 format!("{:?}", index.resolve(sid, rid).unwrap()),
383 format!("{:?}", saved_index.resolve(sid, rid).unwrap())
384 );
385 assert_eq!(
386 ops::page_op(
387 edit.as_bytes(),
388 sid,
389 PageOp::Outline {
390 object: insertion,
391 edit: Edit::Position { x, y }
392 }
393 )
394 .unwrap()
395 .as_bytes(),
396 edit.as_bytes()
397 );
398 }
399}
400
401#[test]
402fn native_automatic_and_explicit_titles_follow_outline_movement() {
403 let source = include_bytes!(
404 "../../../corpus/outline-edit/automatic-title/before/notebook/TitleControl.one"
405 );
406 let store = Store::parse(source).unwrap();
407 let index = RevisionIndex::parse(&store).unwrap();
408 let document = Document::parse(&index).unwrap();
409 let mut candidate = source.to_vec();
410 let pages = document.pages().unwrap();
411 assert_eq!(pages.len(), 3);
412 for (sid, page) in pages {
413 let space = &document.spaces[&sid];
414 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
415 let Kind::Metadata {
416 title: Some(title), ..
417 } = &view.nodes[&view.roots[&2]].kind
418 else {
419 panic!()
420 };
421 let outline = *view.nodes[&page].children.iter().find(|oid| {
422 view.nodes[oid].children.iter().any(|paragraph| view.nodes[paragraph].content.iter().any(|text| {
423 matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Second"))
424 }))
425 }).unwrap();
426 let change = Edit::Position {
427 x: 36.0,
428 y: if title == "Horizontal first" {
429 108.0
430 } else {
431 72.0
432 },
433 };
434 candidate = ops::page_op(
435 &candidate,
436 sid,
437 PageOp::Outline {
438 object: outline,
439 edit: change,
440 },
441 )
442 .unwrap()
443 .as_bytes()
444 .to_vec();
445 let store = Store::parse(&candidate).unwrap();
446 let saved = RevisionIndex::parse(&store).unwrap();
447 saved.validate_current().unwrap();
448 let document = Document::parse(&saved).unwrap();
449 let space = &document.spaces[&sid];
450 let after = &space.revisions[&space.contexts[&ExGuid::default()]];
451 let expected = if title == "Explicit title" {
452 "Explicit title"
453 } else {
454 "Second šŸ¦‹ é"
455 };
456 assert!(
457 matches!(&after.nodes[&after.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == expected)
458 );
459 for (oid, node) in &view.nodes {
460 assert_eq!(node.children, after.nodes[oid].children);
461 assert_eq!(node.content, after.nodes[oid].content);
462 if matches!(node.kind, Kind::RichText { .. }) {
463 assert_eq!(
464 serde_json::to_value(node).unwrap(),
465 serde_json::to_value(&after.nodes[oid]).unwrap()
466 );
467 }
468 }
469 }
470 if let Some(output) = std::env::var_os("ONESTORE_OUTLINE_TITLE_OUTPUT") {
471 let output = std::path::PathBuf::from(output);
472 assert!(output.is_absolute());
473 std::fs::create_dir(&output).unwrap();
474 std::fs::write(output.join("TitleControl.one"), candidate).unwrap();
475 }
476}
477
478#[test]
479fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() {
480 let source = onestore::create_section("layout.one", "Before šŸ¦€ after", "Author").unwrap();
481 let store = Store::parse(&source).unwrap();
482 let index = RevisionIndex::parse(&store).unwrap();
483 let document = Document::parse(&index).unwrap();
484 let (sid, page) = document.pages().unwrap()[0];
485 let space = &document.spaces[&sid];
486 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
487 let outline = *view.nodes[&page]
488 .children
489 .iter()
490 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
491 .unwrap();
492 let paragraph = view.nodes[&outline].children[0];
493 let (add, _, _) = ops::new_outline(144.0, 36.0, "Second");
494 let source = ops::page_op(&source, sid, add).unwrap().image;
495 for (object, operation) in [
496 (outline, Edit::Position { x: 216.0, y: 72.0 }),
497 (
498 outline,
499 Edit::Width {
500 points: 144.0,
501 user_set: true,
502 },
503 ),
504 (paragraph, Edit::Collapsed(true)),
505 ] {
506 let edit = ops::page_op(
507 &source,
508 sid,
509 PageOp::Outline {
510 object,
511 edit: operation,
512 },
513 )
514 .unwrap();
515 let before = current::current(&source);
516 let after = current::current(edit.as_bytes());
517 for write_limit in [17, 4096] {
518 let disk = |fail_at| disk::Disk {
519 visible: source.clone(),
520 durable: source.clone(),
521 operation: 0,
522 fail_at,
523 write_limit,
524 random: 1927,
525 };
526 let mut success = disk(None);
527 edit.commit(&mut success).unwrap();
528 assert_eq!(success.durable, edit.as_bytes());
529 for at in 1..=success.operation {
530 let mut interrupted = disk(Some(at));
531 let failure = edit.commit(&mut interrupted).unwrap_err();
532 let actual = current::current(&interrupted.durable);
533 assert!(
534 actual == before || actual == after,
535 "{operation:?} operation {at}"
536 );
537 if failure.state == onestore::CommitState::NotCommitted {
538 assert_eq!(actual, before);
539 }
540 }
541 }
542 }
543}
544
545#[test]
546fn repeated_geometry_changes_and_expansion_match_an_independent_model() {
547 let original = onestore::create_section("layout.one", "Before šŸ¦€ é\rafter", "Author").unwrap();
548 let store = Store::parse(&original).unwrap();
549 let index = RevisionIndex::parse(&store).unwrap();
550 let document = Document::parse(&index).unwrap();
551 let (sid, page) = document.pages().unwrap()[0];
552 let space = &document.spaces[&sid];
553 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
554 let outline = *view.nodes[&page]
555 .children
556 .iter()
557 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
558 .unwrap();
559 let paragraph = view.nodes[&outline].children[0];
560 let text = view.nodes[&paragraph].content[0];
561 let (add, _, _) = ops::new_outline(144.0, 36.0, "Second");
562 let original = ops::page_op(&original, sid, add).unwrap().image;
563 for seed in sweep::seeds(1..17, 16) {
564 let mut rng = seed;
565 let mut source = original.clone();
566 let mut layout = serde_json::to_value(&view.nodes[&outline].layout).unwrap();
567 let mut collapsed = None;
568 for step in 0..48 {
569 rng ^= rng << 13;
570 rng ^= rng >> 7;
571 rng ^= rng << 17;
572 let (object, edit) = match step % 3 {
573 0 => {
574 let x = ((rng % 400) as f32 - 100.0) * 18.0;
575 let y = ((rng % 217) as f32 - 60.0) * 18.0;
576 layout["x"] = x.into();
577 layout["y"] = y.into();
578 (outline, Edit::Position { x, y })
579 }
580 1 => {
581 let points = (2 + rng % 86) as f32 * 18.0;
582 layout["max_width"] = points.into();
583 layout["width_set_by_user"] = (rng & 1 != 0).into();
584 (
585 outline,
586 Edit::Width {
587 points,
588 user_set: rng & 1 != 0,
589 },
590 )
591 }
592 _ => {
593 collapsed = Some(u8::from(rng & 1 != 0));
594 (paragraph, Edit::Collapsed(rng & 1 != 0))
595 }
596 };
597 source = ops::page_op(&source, sid, PageOp::Outline { object, edit })
598 .unwrap()
599 .as_bytes()
600 .to_vec();
601 let store = Store::parse(&source).unwrap();
602 let index = RevisionIndex::parse(&store).unwrap();
603 index.validate_current().unwrap();
604 let document = Document::parse(&index).unwrap();
605 let space = &document.spaces[&sid];
606 let current = &space.revisions[&space.contexts[&ExGuid::default()]];
607 let x = layout["x"].as_f64().unwrap();
608 let y = layout["y"].as_f64().unwrap();
609 let title = if y < 36.0 || (y == 36.0 && x <= 144.0) {
610 "Before šŸ¦€ é"
611 } else {
612 "Second"
613 };
614 assert!(
615 matches!(&current.nodes[&current.roots[&2]].kind, Kind::Metadata { title: Some(actual), .. } if actual == title)
616 );
617 assert!(
618 matches!(&current.nodes[&page].kind, Kind::Page { alternate_title: Some(actual), .. } if actual == title)
619 );
620 assert_eq!(
621 serde_json::to_value(&current.nodes[&outline].layout).unwrap(),
622 layout
623 );
624 let Kind::Paragraph { collapse_state, .. } = &current.nodes[&paragraph].kind else {
625 panic!()
626 };
627 assert_eq!(*collapse_state, collapsed);
628 assert_eq!(
629 current.nodes[&outline].children,
630 view.nodes[&outline].children
631 );
632 assert_eq!(
633 serde_json::to_value(&current.nodes[&text]).unwrap(),
634 serde_json::to_value(&view.nodes[&text]).unwrap()
635 );
636 }
637 }
638}
639
640#[test]
641#[ignore = "exports public-API outline candidates for cold native validation"]
642fn export_native_outline_candidates() {
643 let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_OUTLINE_OUTPUT").unwrap());
644 assert!(output.is_absolute());
645 std::fs::create_dir(&output).unwrap();
646 let store = Store::parse(SOURCE).unwrap();
647 let index = RevisionIndex::parse(&store).unwrap();
648 let document = Document::parse(&index).unwrap();
649 let mut bytes = SOURCE.to_vec();
650 for (_, sid, object, edit) in cases(&document) {
651 bytes = ops::page_op(&bytes, sid, PageOp::Outline { object, edit })
652 .unwrap()
653 .as_bytes()
654 .to_vec();
655 }
656 std::fs::write(output.join("synthetic.one"), bytes).unwrap();
657}