1//! Drives random page-model mutations through `op::lower_page` and a `Section` and checks
2//! the contract: the page the section holds is what the model oracle predicts from the ops,
3//! the sealed image reads back as the edited model, and objects outside the edit keep their
4//! bytes.
5
6use onestore::{
7 Arena, ExGuid, RevisionIndex, Section, Store,
8 document::{Document, Format, Layout},
9 op::{self, Op},
10 page::{
11 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
12 text::{Edit, new_id},
13 },
14};
15use std::{collections::BTreeSet, sync::LazyLock};
16
17#[path = "current.rs"]
18pub(crate) mod current;
19
20static SOURCES: LazyLock<[Vec<u8>; 4]> = LazyLock::new(|| {
21 [
22 onestore::create_section("model.one", "Original 🦀 é 東京", "Author").unwrap(),
23 include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one").to_vec(),
24 include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one").to_vec(),
25 include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one")
26 .to_vec(),
27 ]
28});
29
30struct Bytes<'a> {
31 input: &'a [u8],
32 at: usize,
33}
34
35impl Bytes<'_> {
36 fn next(&mut self) -> Option<u8> {
37 let byte = *self.input.get(self.at)?;
38 self.at += 1;
39 Some(byte)
40 }
41
42 fn pick(&mut self, count: usize) -> Option<usize> {
43 if count == 0 {
44 return None;
45 }
46 Some(usize::from(self.next().unwrap_or(0)) % count)
47 }
48
49 fn text(&mut self) -> String {
50 let length = usize::from(self.next().unwrap_or(0)) % 12;
51 let mut text = String::new();
52 for _ in 0..length {
53 text.push(match self.next().unwrap_or(b' ') % 12 {
54 0 => '🦀',
55 1 => 'é',
56 2 => '\u{301}',
57 3 => '東',
58 4 => ' ',
59 5 => '\u{05e9}',
60 6 => '\u{000b}',
61 n => (b'a' + n) as char,
62 });
63 }
64 text
65 }
66}
67
68fn model(bytes: &[u8], space: ExGuid) -> Option<Page> {
69 let store = Store::parse(bytes).ok()?;
70 let index = RevisionIndex::parse(&store).ok()?;
71 let document = Document::parse(&index).ok()?;
72 Page::from_space(&document, space).ok()
73}
74
75/// What the writer promises to reproduce exactly; other fields are writer- or reader-owned.
76fn projection(page: &Page) -> String {
77 let mut out = String::new();
78 for object in &page.objects {
79 match object {
80 PageObject::Outline(outline) => {
81 // A new outline without a width takes the writer's 468 pt default.
82 out.push_str(&format!(
83 "outline {} {:?} {:?} {:?}\n",
84 outline.id,
85 outline.layout.x,
86 outline.layout.y,
87 outline.layout.max_width.unwrap_or(468.0)
88 ));
89 for paragraph in &outline.paragraphs {
90 out.push_str(&format!(
91 " {} parent {:?} collapsed {}",
92 paragraph.id, paragraph.parent, paragraph.collapsed
93 ));
94 match &paragraph.content {
95 ParagraphContent::Text(text) => {
96 out.push_str(&format!(" text {} {:?}", text.id, text.text.text()));
97 // Runs that differ only in attributes the writer leaves as stored,
98 // such as the language tag, project as one span.
99 let mut runs: Vec<(usize, String)> = Vec::new();
100 for span in text.text.spans() {
101 let f = &span.format;
102 let attributes = format!(
103 "{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}",
104 f.bold.unwrap_or(false),
105 f.italic.unwrap_or(false),
106 f.underline.unwrap_or(false),
107 f.strike.unwrap_or(false),
108 f.superscript.unwrap_or(false),
109 f.subscript.unwrap_or(false),
110 f.font,
111 f.font_size,
112 f.color,
113 f.highlight
114 );
115 match runs.last_mut() {
116 Some((end, previous)) if *previous == attributes => {
117 *end = span.end
118 }
119 _ => runs.push((span.end, attributes)),
120 }
121 }
122 for (end, attributes) in runs {
123 out.push_str(&format!(" [{end} {attributes}]"));
124 }
125 }
126 ParagraphContent::Table(table) => {
127 out.push_str(&format!(" table {}", table.id))
128 }
129 ParagraphContent::Image(image) => {
130 out.push_str(&format!(" image {}", image.id))
131 }
132 ParagraphContent::Attachment(attachment) => {
133 out.push_str(&format!(" attachment {}", attachment.id))
134 }
135 ParagraphContent::Ink(ink) => out.push_str(&format!(" ink {}", ink.id)),
136 ParagraphContent::Unsupported(u) => {
137 out.push_str(&format!(" unsupported {}", u.id))
138 }
139 }
140 out.push('\n');
141 }
142 }
143 PageObject::Title(title) => out.push_str(&format!("title {}\n", title.id)),
144 PageObject::Image(image) => out.push_str(&format!("image {}\n", image.id)),
145 PageObject::Attachment(file) => out.push_str(&format!("file {}\n", file.id)),
146 PageObject::Ink(ink) => out.push_str(&format!("ink {}\n", ink.id)),
147 PageObject::Unsupported(u) => out.push_str(&format!("unsupported {}\n", u.id)),
148 }
149 }
150 out
151}
152
153fn text_paragraphs(outline: &mut Outline) -> Vec<usize> {
154 outline
155 .paragraphs
156 .iter()
157 .enumerate()
158 .filter(|(_, p)| p.text().is_some())
159 .map(|(i, _)| i)
160 .collect()
161}
162
163fn fresh_paragraph(template: &PageParagraph, text: String) -> PageParagraph {
164 let mut paragraph = template.clone();
165 paragraph.id = new_id().unwrap();
166 paragraph.parent = None;
167 paragraph.level = 1;
168 paragraph.lists.clear();
169 paragraph.tags.clear();
170 paragraph.style = None;
171 paragraph.collapsed = false;
172 let format = template
173 .text()
174 .map(|t| t.text.format_at(0).unwrap().clone())
175 .unwrap_or_default();
176 paragraph.content = ParagraphContent::Text(onestore::page::TextObject {
177 id: new_id().unwrap(),
178 date_field: None,
179 text: Paragraph::new(text, format),
180 tags: Vec::new(),
181 });
182 paragraph
183}
184
185/// Removes a paragraph and every descendant, returning the removed identities.
186fn remove_subtree(outline: &mut Outline, index: usize) -> BTreeSet<ExGuid> {
187 let mut removed = BTreeSet::from([outline.paragraphs[index].id]);
188 loop {
189 let before = removed.len();
190 for paragraph in &outline.paragraphs {
191 if paragraph.parent.is_some_and(|p| removed.contains(&p)) {
192 removed.insert(paragraph.id);
193 }
194 }
195 if removed.len() == before {
196 break;
197 }
198 }
199 outline.paragraphs.retain(|p| !removed.contains(&p.id));
200 removed
201}
202
203fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {
204 let steps = usize::from(bytes.next().unwrap_or(0)) % 6 + 1;
205 for _ in 0..steps {
206 let Some(kind) = bytes.next() else { return };
207 let outlines: Vec<usize> = page
208 .objects
209 .iter()
210 .enumerate()
211 .filter(|(_, o)| matches!(o, PageObject::Outline(_)))
212 .map(|(i, _)| i)
213 .collect();
214 match kind % 11 {
215 10 => {
216 let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
217 PageObject::Outline(outline) => outline,
218 _ => unreachable!(),
219 }) else {
220 continue;
221 };
222 let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1);
223 let Some(text) = outline.paragraphs.get_mut(at).and_then(|p| p.text_mut()) else {
224 continue;
225 };
226 if text.tags.is_empty() {
227 let definition = page
228 .definitions
229 .iter()
230 .find(|(_, d)| {
231 matches!(&d.kind, onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Fuzz task")
232 })
233 .map(|(id, _)| *id)
234 .unwrap_or_else(|| {
235 let id = new_id().unwrap();
236 page.definitions.insert(
237 id,
238 onestore::page::Definition {
239 kind: onestore::document::Kind::TagDefinition {
240 label: Some("Fuzz task".into()),
241 action_type: Some(0),
242 shape: Some(3),
243 color: None,
244 highlight: None,
245 },
246 format: Default::default(),
247 },
248 );
249 id
250 });
251 let outline = outlines.first().map(|i| match &mut page.objects[*i] {
252 PageObject::Outline(outline) => outline,
253 _ => unreachable!(),
254 });
255 let text = outline.unwrap().paragraphs[at].text_mut().unwrap();
256 text.tags.push(onestore::document::Tag {
257 definition: Some(definition),
258 action_type: None,
259 shape: None,
260 property_status: None,
261 status: u16::from(bytes.next().unwrap_or(0) % 2),
262 created: Some(1_262_401_445),
263 completed: None,
264 start: None,
265 due: None,
266 task_id: None,
267 extra_set: 0,
268 });
269 } else {
270 text.tags.clear();
271 }
272 }
273 8 => {
274 let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
275 PageObject::Outline(outline) => outline,
276 _ => unreachable!(),
277 }) else {
278 continue;
279 };
280 let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1);
281 let Some(paragraph) = outline.paragraphs.get_mut(at) else {
282 continue;
283 };
284 if paragraph.text().is_none() {
285 continue;
286 }
287 if paragraph.lists.is_empty() {
288 let id = new_id().unwrap();
289 page.definitions.insert(
290 id,
291 onestore::page::Definition {
292 kind: onestore::document::Kind::List {
293 font: Some("Courier New".into()),
294 format: Some("\u{25cb}".into()),
295 restart: None,
296 bullet: Some(4),
297 },
298 format: onestore::document::Format {
299 font_size: Some(11.0),
300 color: Some(0xff000000),
301 ..Default::default()
302 },
303 },
304 );
305 paragraph.lists = vec![id];
306 } else {
307 paragraph.lists.clear();
308 }
309 }
310 0 | 1 => {
311 let Some(o) = bytes.pick(outlines.len()) else {
312 continue;
313 };
314 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
315 continue;
316 };
317 let texts = text_paragraphs(outline);
318 let Some(p) = bytes.pick(texts.len()) else {
319 continue;
320 };
321 let text = outline.paragraphs[texts[p]].text_mut().unwrap();
322 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
323 let start = bytes.next().map_or(0, |b| u32::from(b) % (end + 1));
324 let stop = bytes
325 .next()
326 .map_or(end, |b| start + u32::from(b) % (end - start + 1));
327 let format = text.text.format_at(start).cloned().unwrap_or_default();
328 let replacement = bytes.text();
329 let _ = text.text.apply(Edit {
330 range: start..stop,
331 replacement: Paragraph::new(replacement, format),
332 });
333 }
334 2 => {
335 let Some(o) = bytes.pick(outlines.len()) else {
336 continue;
337 };
338 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
339 continue;
340 };
341 let texts = text_paragraphs(outline);
342 let Some(p) = bytes.pick(texts.len()) else {
343 continue;
344 };
345 let text = outline.paragraphs[texts[p]].text_mut().unwrap();
346 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
347 let start = bytes.next().map_or(0, |b| u32::from(b) % (end + 1));
348 let stop = bytes
349 .next()
350 .map_or(end, |b| start + u32::from(b) % (end - start + 1));
351 let Ok(mut slice) = text.text.slice(start..stop) else {
352 continue;
353 };
354 let attribute = bytes.next().unwrap_or(0) % 8;
355 let runs: Vec<(String, Format)> = {
356 let mut runs = Vec::new();
357 let mut from = 0;
358 for span in slice.spans() {
359 let mut format = span.format.clone();
360 match attribute {
361 0 => format.bold = Some(!format.bold.unwrap_or(false)),
362 1 => format.italic = Some(!format.italic.unwrap_or(false)),
363 2 => format.underline = Some(!format.underline.unwrap_or(false)),
364 3 => format.strike = Some(!format.strike.unwrap_or(false)),
365 4 => {
366 format.font_size = Some(if format.font_size == Some(14.0) {
367 11.0
368 } else {
369 14.0
370 })
371 }
372 5 => {
373 format.color = Some(if format.color == Some(0x00ff) {
374 0xff000000
375 } else {
376 0x00ff
377 })
378 }
379 6 => format.highlight = Some(0x00ffff),
380 _ => format.font = Some("Consolas".into()),
381 }
382 runs.push((slice.text()[from..span.end].to_owned(), format));
383 from = span.end;
384 }
385 runs
386 };
387 slice = Paragraph::from_runs(runs);
388 let _ = text.text.apply(Edit {
389 range: start..stop,
390 replacement: slice,
391 });
392 }
393 3 => {
394 let Some(o) = bytes.pick(outlines.len()) else {
395 continue;
396 };
397 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
398 continue;
399 };
400 let Some(template) = outline
401 .paragraphs
402 .iter()
403 .find(|p| p.text().is_some())
404 .cloned()
405 else {
406 continue;
407 };
408 let mut fresh = fresh_paragraph(&template, bytes.text());
409 // Only subtree boundaries keep the pre-order flattening valid.
410 let boundaries: Vec<usize> = (0..=outline.paragraphs.len())
411 .filter(|i| {
412 outline
413 .paragraphs
414 .get(*i)
415 .is_none_or(|p| p.parent.is_none())
416 })
417 .collect();
418 let at = boundaries[bytes.pick(boundaries.len()).unwrap_or(0)];
419 if bytes.next().unwrap_or(0).is_multiple_of(3)
420 && let Some(parent) = at.checked_sub(1).map(|i| &outline.paragraphs[i])
421 {
422 fresh.parent = Some(parent.id);
423 fresh.level = parent.level + 1;
424 }
425 outline.paragraphs.insert(at, fresh);
426 }
427 4 => {
428 let Some(o) = bytes.pick(outlines.len()) else {
429 continue;
430 };
431 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
432 continue;
433 };
434 let Some(index) = bytes.pick(outline.paragraphs.len()) else {
435 continue;
436 };
437 remove_subtree(outline, index);
438 }
439 5 => {
440 let Some(o) = bytes.pick(outlines.len()) else {
441 continue;
442 };
443 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
444 continue;
445 };
446 let top: Vec<usize> = outline
447 .paragraphs
448 .iter()
449 .enumerate()
450 .filter(|(_, p)| p.parent.is_none())
451 .map(|(i, _)| i)
452 .collect();
453 let (Some(from), Some(to)) = (bytes.pick(top.len()), bytes.pick(top.len())) else {
454 continue;
455 };
456 let moving = outline.paragraphs[top[from]].clone();
457 let mut subtree = remove_subtree(outline, top[from]);
458 subtree.remove(&moving.id);
459 let mut descendants: Vec<PageParagraph> = Vec::new();
460 let mut rest = Vec::new();
461 for paragraph in outline.paragraphs.drain(..) {
462 if subtree.contains(&paragraph.id) {
463 descendants.push(paragraph);
464 } else {
465 rest.push(paragraph);
466 }
467 }
468 outline.paragraphs = rest;
469 let anchor = outline
470 .paragraphs
471 .iter()
472 .enumerate()
473 .filter(|(_, p)| p.parent.is_none())
474 .nth(to.min(top.len().saturating_sub(1)))
475 .map(|(i, _)| i)
476 .unwrap_or(outline.paragraphs.len());
477 let mut block = vec![moving];
478 block.extend(descendants);
479 for (offset, paragraph) in block.into_iter().enumerate() {
480 outline.paragraphs.insert(anchor + offset, paragraph);
481 }
482 }
483 6 => {
484 let Some(o) = bytes.pick(outlines.len()) else {
485 continue;
486 };
487 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
488 continue;
489 };
490 let Some(index) = bytes.pick(outline.paragraphs.len()) else {
491 continue;
492 };
493 outline.paragraphs[index].collapsed ^= true;
494 }
495 7 => {
496 let Some(o) = bytes.pick(outlines.len()) else {
497 continue;
498 };
499 let PageObject::Outline(outline) = &mut page.objects[outlines[o]] else {
500 continue;
501 };
502 if outline.title {
503 continue;
504 }
505 match bytes.next().unwrap_or(0) % 3 {
506 0 => {
507 outline.layout.x = Some(f32::from(bytes.next().unwrap_or(0)) * 1.5);
508 outline.layout.y = Some(f32::from(bytes.next().unwrap_or(0)) * 2.25);
509 }
510 1 => {
511 outline.layout.max_width =
512 Some(36.0 + f32::from(bytes.next().unwrap_or(0)) * 2.0);
513 outline.layout.width_set_by_user = Some(true);
514 }
515 _ => {
516 outline.layout.max_width =
517 Some(36.0 + f32::from(bytes.next().unwrap_or(0)) * 2.0);
518 outline.layout.width_set_by_user = None;
519 }
520 }
521 }
522 _ => {
523 let Some(template) = page.objects.iter().find_map(|o| match o {
524 PageObject::Outline(outline) => outline
525 .paragraphs
526 .iter()
527 .find(|p| p.text().is_some())
528 .cloned(),
529 _ => None,
530 }) else {
531 continue;
532 };
533 let outline = Outline {
534 id: new_id().unwrap(),
535 title: false,
536 min_width: None,
537 layout: Layout {
538 x: Some(f32::from(bytes.next().unwrap_or(0)) * 1.5),
539 y: Some(f32::from(bytes.next().unwrap_or(0)) * 2.25),
540 ..Default::default()
541 },
542 indents: Vec::new(),
543 paragraphs: vec![fresh_paragraph(&template, bytes.text())],
544 unsupported: Vec::new(),
545 };
546 // Titles live in the page's structure list and always follow its children.
547 let children = page
548 .objects
549 .iter()
550 .position(|o| matches!(o, PageObject::Title(_)))
551 .unwrap_or(page.objects.len());
552 let at = bytes.pick(children + 1).unwrap_or(children);
553 page.objects.insert(at, PageObject::Outline(outline));
554 }
555 }
556 }
557}
558
559pub fn run(input: &[u8]) {
560 let mut bytes = Bytes { input, at: 0 };
561 let source = &SOURCES[usize::from(bytes.next().unwrap_or(0)) % SOURCES.len()];
562 let store = Store::parse(source).unwrap();
563 let index = RevisionIndex::parse(&store).unwrap();
564 let document = Document::parse(&index).unwrap();
565 let pages = document.pages().unwrap();
566 let Some(page_index) = bytes.pick(pages.len()) else {
567 return;
568 };
569 let (space, page_object) = pages[page_index];
570 let Some(mut after) = model(source, space) else {
571 return;
572 };
573 let before = after.clone();
574 mutate(&mut after, &mut bytes);
575 let Ok(ops) = onestore::op::lower_page(&before, &after) else {
576 return;
577 };
578 let arena = Arena::default();
579 let mut section = Section::open(&arena, source.clone()).unwrap();
580 let edit = op::Edit {
581 at: 134_000_000_000_000_000,
582 ops: ops
583 .iter()
584 .map(|op| Op::Page {
585 space,
586 op: op.clone(),
587 })
588 .collect(),
589 };
590 if section.apply("Fuzz author", &edit).is_err() {
591 assert_eq!(
592 section.page(space).unwrap(),
593 before,
594 "a refused edit changes nothing"
595 );
596 return;
597 }
598 let mut predicted = before.clone();
599 for op in &ops {
600 onestore::op::predict(&mut predicted, op).unwrap();
601 }
602 assert_eq!(
603 projection(&section.page(space).unwrap()),
604 projection(&predicted),
605 "the model oracle"
606 );
607 if section.seal().unwrap().is_none() {
608 assert_eq!(
609 projection(&before),
610 projection(&after),
611 "an unchanged model publishes nothing"
612 );
613 return;
614 }
615 let written = &section.image();
616 current::current(written);
617 let stored = model(written, space).expect("the written page reads back");
618 let (stored, expected) = (projection(&stored), projection(&after));
619 if stored != expected {
620 let differences: Vec<String> = stored
621 .lines()
622 .zip(expected.lines())
623 .filter(|(a, b)| a != b)
624 .map(|(a, b)| format!("stored: {a}\nexpected: {b}"))
625 .collect();
626 panic!(
627 "model round trip ({} vs {} lines):\n{}",
628 stored.lines().count(),
629 expected.lines().count(),
630 differences.join("\n")
631 );
632 }
633 let raw_before = index.resolve_active(space).unwrap();
634 let store = Store::parse(written).unwrap();
635 let index = RevisionIndex::parse(&store).unwrap();
636 let raw_after = index.resolve_active(space).unwrap();
637 let mut modeled = BTreeSet::from([page_object]);
638 for object in &after.objects {
639 modeled.insert(object.id());
640 let outlines: Vec<&Outline> = match object {
641 PageObject::Outline(outline) => vec![outline],
642 PageObject::Title(title) => title.outlines.iter().collect(),
643 _ => Vec::new(),
644 };
645 for outline in outlines {
646 modeled.insert(outline.id);
647 for paragraph in &outline.paragraphs {
648 modeled.insert(paragraph.id);
649 if let Some(text) = paragraph.text() {
650 modeled.insert(text.id);
651 }
652 }
653 }
654 }
655 // Revision roots carry derived metadata such as the automatic navigation title, and
656 // outline groups (0x60019) are containers the model flattens but the writers normalize.
657 let roots: BTreeSet<ExGuid> = raw_before.roots.values().copied().collect();
658 for (id, object) in &raw_before.objects {
659 if modeled.contains(id)
660 || roots.contains(id)
661 || matches!(object.jcid, 0x12004d | 0x120001 | 0x60019)
662 {
663 continue;
664 }
665 let Some(after) = raw_after.objects.get(id) else {
666 panic!("{id} disappeared from the active revision");
667 };
668 assert_eq!(
669 (object.jcid, object.data),
670 (after.jcid, after.data),
671 "{id} changed outside the model"
672 );
673 }
674}