1//! The ops the editor records for each edit, applied to the section, store what the model
2//! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's
3//! page or what it lowered whole stores: every editor operation alone and in random sequences with undo and redo, on every page of the corpus sections
4//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural
5//! probe section, which `structural_roundtrip` sweeps, takes minutes). The smoke slice takes
6//! the first pages of each section.
7
8use canvas::{
9 document::TextPosition,
10 editor::{Alignment, CanvasEditor, FAVORITES, Formatting, NoteTag, Pen, Toggle, Whole},
11 layout::TextEngine,
12};
13use draw::edit::Movement;
14use onestore::{
15 Arena, ExGuid, RevisionIndex, Section, Store,
16 document::{Document, Kind as Node, Layout},
17 op::{Edit, Op, PageOp},
18 page::{Image, Page},
19};
20use std::{collections::BTreeMap, path::Path};
21
22/// A one-pixel PNG standing in for template art's bytes.
23const ART: [u8; 67] = [
24 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
25 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
26 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
27 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
28 0x42, 0x60, 0x82,
29];
30
31#[path = "../../onestore/tests/support/sweep.rs"]
32mod sweep;
33
34const SECTIONS: [&str; 20] = [
35 "corpus/outline-edit/before/notebook/synthetic.one",
36 "corpus/paragraph-edit/before/notebook/synthetic.one",
37 "corpus/outline-edit/tree/before/notebook/synthetic.one",
38 "corpus/m6/native-features-01/notebook/Features.one",
39 "corpus/table-edit/nested/cold/notebook/synthetic.one",
40 "corpus/m6/native-table-controls-01/notebook/synthetic.one",
41 "corpus/list-edit/cold/notebook/lists.one",
42 "corpus/tag-edit/cold/notebook/tags.one",
43 "corpus/picture-edit/native-page-level/notebook/pictures.one",
44 "corpus/attachment-edit/plain/cold/notebook/files.one",
45 "corpus/ink-edit/drawing/cold/notebook/ink.one",
46 "corpus/ink-tools/native-ui/notebook/ink.one",
47 "corpus/math-edit/native-editor/notebook/links.one",
48 "corpus/link-edit/native-links/notebook/links.one",
49 "corpus/link-edit/native-typed/notebook/links.one",
50 "corpus/math-edit/native-editor-3/notebook/links.one",
51 "corpus/math-edit/native-enter/notebook/links.one",
52 "corpus/paragraph-format/cold/notebook/synthetic.one",
53 "corpus/canvas/baseline-anchors.one",
54 "corpus/styles/onenote/Styles.one",
55];
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
58enum Kind {
59 Type,
60 TypeEnd,
61 Compose,
62 ComposeCancel,
63 DeleteBackward,
64 DeleteForward,
65 DeleteRange,
66 DeleteWord,
67 EnterStart,
68 EnterMiddle,
69 EnterEnd,
70 SoftEnter,
71 Backspace,
72 Delete,
73 Tab,
74 ShiftTab,
75 TabMiddle,
76 Paste,
77 PasteLine,
78 Bold,
79 Italic,
80 Font,
81 FontSize,
82 Color,
83 Highlight,
84 Clear,
85 Center,
86 Bullets,
87 Numbering,
88 Tag,
89 Check,
90 ClickCheck,
91 MoveOutline,
92 ResizeOutline,
93 CreateOutline,
94 EmptyOutline,
95 ArrowPastEnd,
96 PlaceImage,
97 RemoveImage,
98 Date,
99 Link,
100 Unlink,
101 TypedUrl,
102 Equation,
103 Linear,
104 Professional,
105 PageDelete,
106 PageType,
107 PageMove,
108 PageBold,
109 InsertSpace,
110 RemoveSpace,
111 InsertSpaceRight,
112 Paper,
113 Art,
114 Attach,
115 /// Record Audio at a random caret, a note taken meanwhile, then Stop.
116 Record,
117 /// A click on blank page, then Attach File: a file on the page.
118 AttachPage,
119 MoveFile,
120 RemoveFile,
121 /// Typing, backspaces and a word backspace at one caret: one undo step.
122 TypeRun,
123 /// More typing where the caret is, joining the run before it.
124 TypeOn,
125 /// A pen stroke, a highlighter stroke or a shape: a drawing of its own.
126 Pen,
127 Marker,
128 Shape,
129 /// The stroke eraser touching a drawing's first point.
130 Erase,
131 /// Lassoed drawings moved or deleted.
132 LassoMove,
133 LassoDelete,
134 /// A style from the Styles gallery on the caret's paragraph or a selection.
135 Style,
136 /// The stored page's styles restyled to a theme, as a theme reaches a page, and the
137 /// editor refreshed from what is stored.
138 Theme,
139 /// A two-by-two table at the caret with a line typed into its first cell, widening it.
140 Table,
141 /// The first table's first column dragged 30 points wider, locking it.
142 ColumnResize,
143 Undo,
144 Redo,
145}
146
147const KINDS: [Kind; 57] = [
148 Kind::Type,
149 Kind::TypeEnd,
150 Kind::Compose,
151 Kind::ComposeCancel,
152 Kind::DeleteBackward,
153 Kind::DeleteForward,
154 Kind::DeleteRange,
155 Kind::DeleteWord,
156 Kind::EnterStart,
157 Kind::EnterMiddle,
158 Kind::EnterEnd,
159 Kind::SoftEnter,
160 Kind::Backspace,
161 Kind::Delete,
162 Kind::Tab,
163 Kind::ShiftTab,
164 Kind::TabMiddle,
165 Kind::Paste,
166 Kind::PasteLine,
167 Kind::Bold,
168 Kind::Italic,
169 Kind::Font,
170 Kind::FontSize,
171 Kind::Color,
172 Kind::Highlight,
173 Kind::Clear,
174 Kind::Center,
175 Kind::Bullets,
176 Kind::Numbering,
177 Kind::Tag,
178 Kind::Check,
179 Kind::ClickCheck,
180 Kind::MoveOutline,
181 Kind::ResizeOutline,
182 Kind::CreateOutline,
183 Kind::EmptyOutline,
184 Kind::ArrowPastEnd,
185 Kind::PlaceImage,
186 Kind::RemoveImage,
187 Kind::Date,
188 Kind::Link,
189 Kind::Unlink,
190 Kind::TypedUrl,
191 Kind::Equation,
192 Kind::Linear,
193 Kind::Professional,
194 Kind::PageDelete,
195 Kind::PageType,
196 Kind::PageMove,
197 Kind::PageBold,
198 Kind::InsertSpace,
199 Kind::RemoveSpace,
200 Kind::InsertSpaceRight,
201 Kind::Paper,
202 Kind::Art,
203 Kind::Undo,
204 Kind::Redo,
205];
206
207/// Typing runs, in plans of their own so the random edits' sequences stay as they were.
208const TYPING_PLANS: [&[Kind]; 3] = [
209 &[Kind::TypeRun, Kind::Undo, Kind::Redo],
210 &[
211 Kind::TypeRun,
212 Kind::TypeOn,
213 Kind::Undo,
214 Kind::Redo,
215 Kind::Undo,
216 ],
217 &[Kind::PageType, Kind::TypeOn, Kind::Undo, Kind::Redo],
218];
219
220/// Files attached at a random caret, in plans of their own so the random edits' sequences
221/// stay as they were.
222const ATTACH_PLANS: [&[Kind]; 2] = [
223 &[Kind::Attach, Kind::Undo, Kind::Redo],
224 &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo],
225];
226
227/// Recordings at a random caret, in plans of their own so the random edits' sequences stay
228/// as they were.
229const RECORD_PLANS: [&[Kind]; 2] = [
230 &[Kind::Record, Kind::Undo, Kind::Redo],
231 &[Kind::Record, Kind::PageDelete, Kind::Undo],
232];
233
234/// Files attached on blank page, moved, spaced and removed, in plans of their own so the
235/// random edits' sequences stay as they were.
236const FLOATING_PLANS: [&[Kind]; 4] = [
237 &[Kind::AttachPage, Kind::Undo, Kind::Redo],
238 &[
239 Kind::AttachPage,
240 Kind::MoveFile,
241 Kind::Undo,
242 Kind::Redo,
243 Kind::RemoveFile,
244 Kind::Undo,
245 ],
246 &[Kind::AttachPage, Kind::InsertSpace, Kind::Undo, Kind::Redo],
247 &[
248 Kind::AttachPage,
249 Kind::AttachPage,
250 Kind::MoveFile,
251 Kind::Undo,
252 Kind::Undo,
253 ],
254];
255
256/// The Draw tab's tools, in plans of their own so the random edits' sequences stay as they
257/// were.
258const INK_PLANS: [&[Kind]; 6] = [
259 &[Kind::Pen, Kind::Undo, Kind::Redo],
260 &[
261 Kind::Marker,
262 Kind::Shape,
263 Kind::Undo,
264 Kind::Undo,
265 Kind::Redo,
266 ],
267 &[Kind::Pen, Kind::Pen, Kind::Erase, Kind::Undo, Kind::Redo],
268 &[
269 Kind::Shape,
270 Kind::LassoMove,
271 Kind::Undo,
272 Kind::Redo,
273 Kind::LassoDelete,
274 Kind::Undo,
275 ],
276 &[Kind::Erase, Kind::Undo, Kind::Redo],
277 &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo],
278];
279
280/// Gallery styles and themes, in plans of their own so the random edits' sequences stay as
281/// they were.
282/// Tables whose columns fit as they are typed in and dragged, in plans of their own so the
283/// random edits' sequences stay as they were.
284const TABLE_PLANS: [&[Kind]; 2] = [
285 &[Kind::Table, Kind::Undo, Kind::Redo, Kind::TypeOn],
286 &[
287 Kind::Table,
288 Kind::ColumnResize,
289 Kind::TypeOn,
290 Kind::Undo,
291 Kind::Undo,
292 Kind::Redo,
293 ],
294];
295
296const STYLE_PLANS: [&[Kind]; 5] = [
297 &[Kind::Style, Kind::Undo, Kind::Redo],
298 &[Kind::Style, Kind::EnterEnd, Kind::Type, Kind::Undo],
299 &[Kind::Theme, Kind::Style, Kind::TypeEnd, Kind::Undo],
300 &[Kind::Style, Kind::Theme, Kind::EnterEnd, Kind::Type],
301 &[Kind::Theme, Kind::Theme, Kind::Style, Kind::Redo],
302];
303
304/// A theme's styles for the sweep: OneNote's names in another look, headings followed by
305/// Normal; `alternate` picks one of two, so a second restyle changes the page again.
306fn theme(alternate: bool) -> BTreeMap<String, onestore::page::Definition> {
307 use onestore::document::Format;
308 [
309 "h1",
310 "h2",
311 "h3",
312 "PageTitle",
313 "cite",
314 "blockquote",
315 "code",
316 "p",
317 ]
318 .into_iter()
319 .enumerate()
320 .map(|(at, name)| {
321 let definition = onestore::page::Definition {
322 kind: Node::Style {
323 name: Some(name.into()),
324 next: name.starts_with('h').then(|| "p".into()),
325 },
326 format: Format {
327 bold: Some(name.starts_with('h')),
328 italic: Some(name == "blockquote"),
329 underline: Some(false),
330 strike: Some(false),
331 superscript: Some(false),
332 subscript: Some(false),
333 font: Some(if alternate { "Georgia" } else { "Arial" }.into()),
334 font_size: Some(10.0 + at as f32),
335 color: Some(if alternate { 0x0022_3344 } else { 0xff00_0000 }),
336 highlight: Some(0xff00_0000),
337 space_before: Some(if alternate { 6.0 } else { 0.0 }),
338 space_after: Some(0.0),
339 line_spacing: Some(0.0),
340 ..Format::default()
341 },
342 };
343 (name.to_owned(), definition)
344 })
345 .collect()
346}
347
348/// Page colour, rule lines and art undone and redone across each other.
349const PAPER_PLANS: [&[Kind]; 3] = [
350 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],
351 &[Kind::Art, Kind::Paper, Kind::Art, Kind::Undo, Kind::Undo],
352 &[Kind::Paper, Kind::Art, Kind::Undo, Kind::Paper, Kind::Redo],
353];
354
355struct Random(u64);
356
357impl Random {
358 fn below(&mut self, bound: usize) -> usize {
359 self.0 ^= self.0 << 13;
360 self.0 ^= self.0 >> 7;
361 self.0 ^= self.0 << 17;
362 (self.0 % bound.max(1) as u64) as usize
363 }
364}
365
366/// A random caret in the active outline: the paragraph and its UTF-16 length.
367fn caret(editor: &CanvasEditor, random: &mut Random) -> (usize, u32) {
368 let count = editor.active_outline().document().paragraphs().count();
369 let paragraph = random.below(count);
370 let text = editor
371 .active_outline()
372 .document()
373 .paragraphs()
374 .nth(paragraph)
375 .unwrap();
376 (paragraph, text.text().encode_utf16().count() as u32)
377}
378
379/// A scalar boundary of the paragraph near its middle, or a random one.
380fn inside(editor: &CanvasEditor, paragraph: usize, random: &mut Random) -> u32 {
381 let text = editor
382 .active_outline()
383 .document()
384 .paragraphs()
385 .nth(paragraph)
386 .unwrap();
387 let chars: Vec<usize> = text.text().char_indices().map(|(at, _)| at).collect();
388 let at = chars
389 .get(random.below(chars.len() + 1))
390 .copied()
391 .unwrap_or(text.text().len());
392 text.utf16_offset(at).unwrap()
393}
394
395/// Performs `kind` somewhere on the page; false when the editor declines it.
396fn perform(
397 editor: &mut CanvasEditor,
398 engine: &mut TextEngine,
399 kind: Kind,
400 random: &mut Random,
401) -> bool {
402 let outlines: Vec<ExGuid> = editor.outlines().iter().map(|outline| outline.id).collect();
403 if !matches!(
404 kind,
405 Kind::Undo
406 | Kind::Redo
407 | Kind::TypeOn
408 | Kind::ColumnResize
409 | Kind::PlaceImage
410 | Kind::RemoveImage
411 | Kind::CreateOutline
412 | Kind::Date
413 | Kind::Paper
414 | Kind::Art
415 | Kind::Pen
416 | Kind::Marker
417 | Kind::Shape
418 | Kind::Erase
419 | Kind::LassoMove
420 | Kind::LassoDelete
421 ) {
422 if outlines.is_empty() {
423 return false;
424 }
425 let outline = outlines[random.below(outlines.len())];
426 if editor.focus_outline(outline).is_err() {
427 return false;
428 }
429 }
430 let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
431 let (paragraph, length) = if outlines.is_empty() {
432 (0, 0)
433 } else {
434 caret(editor, random)
435 };
436 let middle = if outlines.is_empty() {
437 0
438 } else {
439 inside(editor, paragraph, random)
440 };
441 let place = |editor: &mut CanvasEditor, offset| editor.select(at(paragraph, offset)).is_ok();
442 let range = |editor: &mut CanvasEditor, random: &mut Random| {
443 let (last, _) = caret(editor, random);
444 let (first, last) = (paragraph.min(last), paragraph.max(last));
445 let start = inside(editor, first, random);
446 let end = inside(editor, last, random);
447 let (start, end) = if first == last {
448 (start.min(end), start.max(end))
449 } else {
450 (start, end)
451 };
452 editor
453 .select(
454 [
455 TextPosition {
456 paragraph: first,
457 offset: start,
458 },
459 TextPosition {
460 paragraph: last,
461 offset: end,
462 },
463 ]
464 .into(),
465 )
466 .is_ok()
467 };
468 let format =
469 |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| {
470 range(editor, random) && editor.format(engine, command).is_ok()
471 };
472 // Select All until it holds every body outline.
473 let page = |editor: &mut CanvasEditor| {
474 for _ in 0..16 {
475 if editor.whole() == Some(Whole::Page) {
476 return true;
477 }
478 if editor.widen_selection().is_err() {
479 return false;
480 }
481 }
482 false
483 };
484 match kind {
485 Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(),
486 Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(),
487 Kind::Compose => {
488 place(editor, middle)
489 && editor.compose(engine, "k".into(), 1..1).is_ok()
490 && editor.compose(engine, "ka".into(), 2..2).is_ok()
491 && editor.commit_text(engine, "か".into()).is_ok()
492 }
493 Kind::ComposeCancel => {
494 place(editor, middle)
495 && editor.compose(engine, "k".into(), 1..1).is_ok()
496 && editor.cancel_composition(engine).is_ok()
497 }
498 Kind::DeleteBackward => place(editor, middle) && editor.delete(engine, true).is_ok(),
499 Kind::DeleteForward => place(editor, middle) && editor.delete(engine, false).is_ok(),
500 Kind::DeleteRange => range(editor, random) && editor.delete(engine, true).is_ok(),
501 Kind::DeleteWord => {
502 place(editor, middle) && editor.delete_to(engine, Movement::WordLeft).is_ok()
503 }
504 Kind::EnterStart => place(editor, 0) && editor.enter(engine, false).is_ok(),
505 Kind::EnterMiddle => place(editor, middle) && editor.enter(engine, false).is_ok(),
506 Kind::EnterEnd => place(editor, length) && editor.enter(engine, false).is_ok(),
507 Kind::SoftEnter => place(editor, middle) && editor.enter(engine, true).is_ok(),
508 Kind::Backspace => place(editor, 0) && editor.delete(engine, true).is_ok(),
509 Kind::Delete => place(editor, length) && editor.delete(engine, false).is_ok(),
510 Kind::Tab => place(editor, 0) && editor.tab(engine, false).is_ok(),
511 Kind::ShiftTab => place(editor, 0) && editor.tab(engine, true).is_ok(),
512 Kind::TabMiddle => place(editor, middle) && editor.tab(engine, false).is_ok(),
513 Kind::Paste => {
514 range(editor, random) && editor.paste(engine, "Line one\r\nLine two", 1033).is_ok()
515 }
516 Kind::PasteLine => place(editor, middle) && editor.paste(engine, "pasted", 1033).is_ok(),
517 Kind::Bold => format(editor, engine, random, Formatting::Toggle(Toggle::Bold)),
518 Kind::Italic => format(editor, engine, random, Formatting::Toggle(Toggle::Italic)),
519 Kind::Font => format(editor, engine, random, Formatting::Font("Georgia".into())),
520 Kind::FontSize => format(editor, engine, random, Formatting::FontSize(14.0)),
521 Kind::Color => format(editor, engine, random, Formatting::Color(Some(0x0000_00ff))),
522 Kind::Highlight => format(
523 editor,
524 engine,
525 random,
526 Formatting::Highlight(Some(0x0000_ffff)),
527 ),
528 Kind::Clear => format(editor, engine, random, Formatting::Clear),
529 Kind::Center => format(editor, engine, random, Formatting::Align(Alignment::Center)),
530 Kind::Bullets => format(editor, engine, random, Formatting::Bullets),
531 Kind::Numbering => format(editor, engine, random, Formatting::Numbering),
532 Kind::Tag => {
533 let place = random.below(9);
534 let tag = NoteTag::defaults().swap_remove(place);
535 format(editor, engine, random, Formatting::Tag(tag, place as u16))
536 }
537 Kind::Check => format(editor, engine, random, Formatting::Check),
538 Kind::ClickCheck => {
539 let outline = editor.active_outline();
540 let id = outline.document().text_nodes().nth(paragraph).unwrap().id;
541 let outline = outline.id;
542 editor.click_check(engine, outline, id).is_ok()
543 }
544 Kind::MoveOutline => {
545 let outline = editor.active_outline();
546 let [x, y] = outline.origin();
547 let id = outline.id;
548 editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok()
549 }
550 Kind::Table => {
551 place(editor, middle)
552 && editor.insert_table(engine, 2, 2).is_ok()
553 && editor
554 .insert(engine, "A line long enough to widen its column")
555 .is_ok()
556 }
557 Kind::ColumnResize => {
558 let table = editor
559 .active_outline()
560 .document()
561 .nodes()
562 .iter()
563 .find_map(|node| match &node.content {
564 onestore::page::ParagraphContent::Table(table) => {
565 Some((table.id, table.columns[0].width))
566 }
567 _ => None,
568 });
569 table.is_some_and(|(id, width)| {
570 editor.resize_column(engine, id, 0, width + 30.0).is_ok()
571 })
572 }
573 Kind::ResizeOutline => {
574 let width = editor.active_outline().wrap_width();
575 editor.resize(engine, width + 72.0).is_ok()
576 }
577 Kind::CreateOutline => {
578 editor
579 .create_outline(
580 engine,
581 [400.0, 600.0 + 20.0 * random.below(10) as f32],
582 300.0,
583 )
584 .is_ok()
585 && editor.insert(engine, "Fresh").is_ok()
586 }
587 Kind::EmptyOutline => editor.select_all().is_ok() && editor.delete(engine, true).is_ok(),
588 Kind::ArrowPastEnd => {
589 editor
590 .move_selection(engine, Movement::DocumentEnd, false)
591 .is_ok()
592 && editor.move_selection(engine, Movement::Down, false).is_ok()
593 && editor.move_selection(engine, Movement::Down, false).is_ok()
594 && editor.insert(engine, "Below").is_ok()
595 }
596 Kind::PlaceImage | Kind::RemoveImage => {
597 let images: Vec<ExGuid> = editor
598 .object_layouts()
599 .map(|(id, _)| id)
600 .filter(|id| editor.image_placement(*id).is_some())
601 .collect();
602 if images.is_empty() {
603 return false;
604 }
605 let id = images[random.below(images.len())];
606 if kind == Kind::RemoveImage {
607 return editor.remove_image(engine, id).is_ok();
608 }
609 let (origin, size) = editor.image_placement(id).unwrap();
610 editor
611 .place_image(
612 engine,
613 id,
614 [origin[0] + 18.0, origin[1] + 9.0],
615 [size[0] * 1.5, size[1] * 1.5],
616 )
617 .is_ok()
618 }
619 Kind::Date => {
620 let Some(date) = editor.date() else {
621 return false;
622 };
623 let timestamp = date.timestamp() + 864_000_000_000 * (1 + random.below(30) as u64);
624 editor
625 .change_date(
626 engine,
627 timestamp,
628 ["Friday, July 04, 2025".into(), "9:45 AM".into()],
629 )
630 .unwrap_or(false)
631 }
632 Kind::Link => {
633 range(editor, random)
634 && editor
635 .set_link(engine, "linked", "https://example.invalid/ops")
636 .is_ok()
637 }
638 Kind::Unlink => place(editor, middle) && editor.remove_link(engine).unwrap_or(false),
639 Kind::TypedUrl => {
640 place(editor, middle)
641 && editor.insert(engine, " www.example.invalid").is_ok()
642 && editor.insert(engine, " ").is_ok()
643 }
644 Kind::Equation => {
645 place(editor, middle)
646 && editor.insert_equation(engine).is_ok()
647 && editor.insert(engine, "x^2").is_ok()
648 && editor.insert(engine, " ").is_ok()
649 }
650 Kind::Linear => place(editor, middle) && editor.linear_equation(engine).unwrap_or(false),
651 Kind::Professional => {
652 place(editor, middle) && editor.build_equation(engine).unwrap_or(false)
653 }
654 Kind::PageDelete => page(editor) && editor.delete(engine, false).is_ok(),
655 Kind::PageType => page(editor) && editor.insert(engine, "Z").is_ok(),
656 Kind::PageMove => page(editor) && editor.move_page([36.0, 18.0]).is_ok(),
657 Kind::PageBold => {
658 page(editor)
659 && editor
660 .format(engine, Formatting::Toggle(Toggle::Bold))
661 .is_ok()
662 }
663 Kind::InsertSpace | Kind::RemoveSpace => {
664 // A line at a paragraph's top parts the outline there, or moves all of it.
665 let outline = editor.active_outline();
666 let paragraphs = &outline.shaped().paragraphs;
667 let line = outline.origin()[1]
668 + paragraphs[random.below(paragraphs.len())].origin[1]
669 + [0.0, 0.5][random.below(2)];
670 let delta = if kind == Kind::InsertSpace {
671 36.0
672 } else {
673 -200.0
674 };
675 editor.insert_space(engine, 1, line, delta).unwrap_or(false)
676 }
677 Kind::InsertSpaceRight => {
678 let line = editor.active_outline().origin()[0] - random.below(40) as f32;
679 editor.insert_space(engine, 0, line, 45.0).unwrap_or(false)
680 }
681 // Lemon paper with college rules, or none.
682 Kind::Paper => {
683 let (color, lines) = match editor.page_color() {
684 None => (Some(0x00dd_fdfd), Some(canvas::template::RULE_LINES[1].1)),
685 Some(_) => (None, None),
686 };
687 editor.set_paper(color, lines, None)
688 }
689 // Template art in two pieces, as the Page Color menu gives it, or none after art.
690 Kind::Art => {
691 let piece = |x: f32| Image {
692 id: onestore::page::text::new_id().unwrap(),
693 layout: Layout {
694 x: Some(x),
695 y: Some(-3.6),
696 max_width: Some(174.5),
697 max_height: Some(640.0),
698 width_set_by_user: Some(true),
699 ..Default::default()
700 },
701 size: Some([174.5, 640.0]),
702 bytes: Some(std::sync::Arc::from(ART.as_slice())),
703 display: None,
704 alt: None,
705 background: true,
706 printout: None,
707 tags: Vec::new(),
708 link: None,
709 text: None,
710 };
711 let shown = editor.page().unwrap().objects.iter().any(|object| {
712 matches!(object, onestore::page::PageObject::Image(image) if image.background)
713 });
714 let art = match shown {
715 true => Vec::new(),
716 false => vec![piece(-27.0), piece(400.0)],
717 };
718 editor.set_paper(editor.page_color(), editor.rule_lines(), Some(art))
719 }
720 Kind::Record => {
721 place(editor, middle)
722 && editor
723 .start_recording(engine, "Audio recording started")
724 .is_ok_and(|id| {
725 let file = onestore::page::Attachment {
726 id: onestore::page::text::new_id().unwrap(),
727 filename: "Recorded.wav".into(),
728 source_path: None,
729 size: Some([24.0, 24.0]),
730 layout: Default::default(),
731 bytes: Some(std::sync::Arc::from(b"RIFF".as_slice())),
732 preview: Some(std::sync::Arc::from(ART.as_slice())),
733 recording: Some(onestore::page::Recording {
734 id,
735 kind: 1,
736 duration_ms: Some(1000),
737 }),
738 tags: Vec::new(),
739 };
740 editor.insert(engine, "noted").is_ok()
741 && editor.finish_recording(engine, file).is_ok()
742 })
743 }
744 Kind::Attach => {
745 let file = onestore::page::Attachment {
746 id: onestore::page::text::new_id().unwrap(),
747 filename: "notes.txt".into(),
748 source_path: None,
749 size: Some([24.0, 24.0]),
750 layout: Default::default(),
751 bytes: Some(std::sync::Arc::from(b"notes".as_slice())),
752 preview: Some(std::sync::Arc::from(ART.as_slice())),
753 recording: None,
754 tags: Vec::new(),
755 };
756 place(editor, middle) && editor.insert_attachment(engine, file).is_ok()
757 }
758 Kind::AttachPage => {
759 let file = onestore::page::Attachment {
760 id: onestore::page::text::new_id().unwrap(),
761 filename: "float.txt".into(),
762 source_path: None,
763 size: Some([24.0, 24.0]),
764 layout: Default::default(),
765 bytes: Some(std::sync::Arc::from(b"float".as_slice())),
766 preview: Some(std::sync::Arc::from(ART.as_slice())),
767 recording: None,
768 tags: Vec::new(),
769 };
770 let x = 36.0 + 18.0 * random.below(20) as f32;
771 editor.place_caret(engine, [x, 1440.0], 240.0).is_ok()
772 && editor.insert_attachment(engine, file).is_ok()
773 }
774 Kind::MoveFile | Kind::RemoveFile => {
775 let Some(file) = editor
776 .page()
777 .unwrap()
778 .objects
779 .into_iter()
780 .find_map(|object| match object {
781 onestore::page::PageObject::Attachment(file) => Some(file),
782 _ => None,
783 })
784 else {
785 return false;
786 };
787 if kind == Kind::RemoveFile {
788 return editor.remove_image(engine, file.id).is_ok();
789 }
790 let (origin, size) = editor.image_placement(file.id).unwrap();
791 let delta = [18.0, -36.0].map(|step| step * (1 + random.below(3)) as f32);
792 editor
793 .place_image(
794 engine,
795 file.id,
796 [origin[0] + delta[0], origin[1] + delta[1]],
797 size,
798 )
799 .is_ok()
800 }
801 Kind::TypeRun => {
802 place(editor, middle)
803 && editor.insert(engine, "ab").is_ok()
804 && editor.delete(engine, true).is_ok()
805 && editor.insert(engine, " cd").is_ok()
806 && editor.delete_to(engine, Movement::WordLeft).is_ok()
807 && editor.commit_text(engine, "ef".into()).is_ok()
808 }
809 Kind::TypeOn => editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok(),
810 Kind::Pen | Kind::Marker => {
811 let pen = if kind == Kind::Pen {
812 Pen::new(35.0, Some(0x7a9a1f))
813 } else {
814 FAVORITES[5]
815 };
816 let [x, y] = [
817 60.0 + 18.0 * random.below(20) as f32,
818 400.0 + 18.0 * random.below(10) as f32,
819 ];
820 let points: Vec<[f32; 2]> = (0..12)
821 .map(|step| [x + 6.0 * step as f32, y + 9.0 * (step as f32 / 2.0).sin()])
822 .collect();
823 // Every other stroke a pressure pen's.
824 let pressure: Vec<f32> = if random.below(2) == 0 {
825 (0..12).map(|step| step as f32 / 11.0).collect()
826 } else {
827 Vec::new()
828 };
829 let ink = onestore::page::Ink {
830 id: onestore::page::text::new_id().unwrap(),
831 layout: Default::default(),
832 strokes: vec![pen.stroke(&points, &pressure).unwrap()],
833 groups: Vec::new(),
834 shape: None,
835 };
836 editor.draw(ink).is_ok()
837 }
838 Kind::Shape => {
839 use onestore::page::ink::ShapeKind;
840 let kinds = [
841 ShapeKind::Line,
842 ShapeKind::Arrow,
843 ShapeKind::Rectangle,
844 ShapeKind::Ellipse,
845 ];
846 let from = [
847 36.0 + 18.0 * random.below(20) as f32,
848 14.4 + 18.0 * random.below(30) as f32,
849 ];
850 let to = [
851 from[0] + 18.0 * (1 + random.below(6)) as f32,
852 from[1] + 18.0 * (1 + random.below(4)) as f32,
853 ];
854 let pen = Pen::new(50.0, Some(0x7a9a1f)).stroke(&[], &[]).unwrap();
855 let ink = onestore::page::Ink::drawn(kinds[random.below(4)], from, to, &pen).unwrap();
856 editor.draw(ink).is_ok()
857 }
858 Kind::Erase | Kind::LassoMove | Kind::LassoDelete => {
859 let drawings: Vec<onestore::page::Ink> = editor
860 .page()
861 .unwrap()
862 .objects
863 .into_iter()
864 .filter_map(|object| match object {
865 onestore::page::PageObject::Ink(ink) => Some(ink),
866 _ => None,
867 })
868 .collect();
869 if drawings.is_empty() {
870 return false;
871 }
872 let ink = &drawings[random.below(drawings.len())];
873 match kind {
874 Kind::Erase => {
875 let Some([x, y]) = ink.strokes.first().map(|stroke| stroke.points[0]) else {
876 return false;
877 };
878 let [dx, dy] = [ink.layout.x, ink.layout.y].map(|v| v.unwrap_or(0.0));
879 let point = [x + dx, y + dy];
880 editor.erase(point, point, 1.0, false).unwrap_or(false)
881 }
882 Kind::LassoMove => {
883 let delta = [18.0, -9.0].map(|step| step * (1 + random.below(3)) as f32);
884 editor.move_ink(&[ink.id], delta).is_ok()
885 }
886 _ => editor.delete_ink(&[ink.id]).is_ok(),
887 }
888 }
889 Kind::Style => {
890 let sheet = theme(random.below(2) == 0);
891 let names = ["h1", "h2", "blockquote", "code", "p"];
892 let style = sheet[names[random.below(names.len())]].clone();
893 (if random.below(2) == 0 {
894 place(editor, middle)
895 } else {
896 range(editor, random)
897 }) && editor.format(engine, Formatting::Style(style)).is_ok()
898 }
899 Kind::Theme => unreachable!("The sweep restyles what is stored"),
900 Kind::Undo => editor.undo(engine).unwrap_or(false),
901 Kind::Redo => editor.redo(engine).unwrap_or(false),
902 }
903}
904
905/// How a step's ops compared with the editor's page.
906#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
907enum Outcome {
908 /// The ops store the editor's page, or what that page lowered whole stores.
909 Same,
910 /// Both refuse the edit.
911 Refused,
912 /// The ops store; the page lowered whole cannot reach the editor's page from what is
913 /// stored.
914 OpsOnly,
915 /// The paths part where the section already stored the page differently from the
916 /// editor's model, as the writers normalize some values, or where text keeps an insertion
917 /// style the page model cannot show.
918 Normalized,
919 Differs(String),
920}
921
922/// The section the editor's ops are applied to, sealed after each step.
923struct Stored<'a> {
924 section: Section<'a>,
925 space: ExGuid,
926 at: u64,
927}
928
929/// The text objects of the page in `space` whose empty final run keeps an insertion style,
930/// which inserting at their end takes and the page model cannot show.
931fn hidden_styles(image: &[u8], space: ExGuid) -> Vec<ExGuid> {
932 let store = Store::parse(image).unwrap();
933 let index = RevisionIndex::parse(&store).unwrap();
934 let document = Document::parse(&index).unwrap();
935 let space = &document.spaces[&space];
936 space.revisions[&space.contexts[&ExGuid::default()]]
937 .nodes
938 .iter()
939 .filter(|(_, node)| {
940 matches!(&node.kind, Node::RichText { text, runs, .. }
941 if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end))
942 })
943 .map(|(id, _)| *id)
944 .collect()
945}
946
947/// A page as JSON without tags' property-set indices, which the writer assigns.
948fn comparable(page: &Page) -> serde_json::Value {
949 fn strip(value: &mut serde_json::Value) {
950 match value {
951 serde_json::Value::Object(map) => {
952 map.remove("extra_set");
953 map.values_mut().for_each(strip);
954 }
955 serde_json::Value::Array(items) => items.iter_mut().for_each(strip),
956 _ => {}
957 }
958 }
959 let mut value = serde_json::to_value(page).unwrap();
960 strip(&mut value);
961 value
962}
963
964impl Stored<'_> {
965 /// Restyles the stored page to a theme, as opening a themed page does, checks it against
966 /// the model and the sealed image, and refreshes the editor from it, which then shows it.
967 fn theme(
968 &mut self,
969 editor: &mut CanvasEditor,
970 engine: &mut TextEngine,
971 alternate: bool,
972 ) -> Option<Outcome> {
973 self.at += 10_000_000;
974 let before = self.section.page(self.space).unwrap();
975 let sheet = theme(alternate);
976 let ops = onestore::op::restyle(&before, &sheet).unwrap();
977 if ops.is_empty() {
978 return None;
979 }
980 editor.styles = sheet;
981 let edit = Edit {
982 at: self.at,
983 ops: ops
984 .iter()
985 .map(|op| Op::Page {
986 space: self.space,
987 op: op.clone(),
988 })
989 .collect(),
990 };
991 if let Err(error) = self.section.apply("Sweep", &edit) {
992 return Some(Outcome::Differs(format!("a theme is refused: {error}")));
993 }
994 let stored = self.section.page(self.space).unwrap();
995 let mut predicted = before;
996 for op in &ops {
997 onestore::op::predict(&mut predicted, op).unwrap();
998 }
999 if comparable(&predicted) != comparable(&stored) {
1000 return Some(Outcome::Differs(format!(
1001 "the model predicts a theme otherwise: {}",
1002 difference(&stored, &predicted)
1003 )));
1004 }
1005 self.section.seal().unwrap();
1006 let arena = Arena::default();
1007 let reread = Section::open(&arena, self.section.image())
1008 .unwrap()
1009 .page(self.space)
1010 .unwrap();
1011 if reread != stored {
1012 return Some(Outcome::Differs("a theme reads back otherwise".into()));
1013 }
1014 editor.refresh(stored.clone(), engine).unwrap();
1015 Some(if reads_as(&stored, &editor.page().unwrap()) {
1016 Outcome::Same
1017 } else {
1018 Outcome::Differs(format!(
1019 "the refreshed editor shows otherwise: {}",
1020 difference(&stored, &editor.page().unwrap())
1021 ))
1022 })
1023 }
1024
1025 /// Stores the editor's step as its ops, checks the stored page against what the model
1026 /// oracle predicts from them and against the sealed image reread, and, unless it is the
1027 /// editor's page, compares with that page lowered whole onto the section as it was
1028 /// before the step.
1029 fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome {
1030 self.at += 10_000_000;
1031 let image = self.section.image();
1032 let before = self.section.page(self.space).unwrap();
1033 let normalized = !reads_as(&before, model);
1034 let ops = editor.take_ops().map_err(|error| error.to_string());
1035 let applied = ops.clone().and_then(|ops| {
1036 if ops.is_empty() {
1037 return Ok(());
1038 }
1039 let edit = Edit {
1040 at: self.at,
1041 ops: ops
1042 .into_iter()
1043 .map(|op| Op::Page {
1044 space: self.space,
1045 op,
1046 })
1047 .collect(),
1048 };
1049 self.section
1050 .apply("Sweep", &edit)
1051 .map_err(|error| error.to_string())
1052 });
1053 let stored = self.section.page(self.space).unwrap();
1054 let edited = editor.page().unwrap();
1055 let outcome = self.classify(image, &before, &stored, &edited, &ops, applied, normalized);
1056 if !matches!(outcome, Outcome::Same | Outcome::Refused)
1057 && std::env::var_os("OPS_SWEEP_DEBUG").is_some()
1058 {
1059 eprintln!("DIFFERS {outcome:?}\n ops:");
1060 for op in ops.iter().flatten() {
1061 let op = format!("{op:?}");
1062 eprintln!(" {}", &op[..op.len().min(600)]);
1063 }
1064 }
1065 self.section.seal().unwrap();
1066 let arena = Arena::default();
1067 let reread = Section::open(&arena, self.section.image())
1068 .unwrap()
1069 .page(self.space)
1070 .unwrap();
1071 if reread != stored {
1072 return Outcome::Differs(format!(
1073 "reads back otherwise: {}",
1074 difference(&stored, &reread)
1075 ));
1076 }
1077 outcome
1078 }
1079
1080 #[allow(clippy::too_many_arguments)]
1081 fn classify(
1082 &self,
1083 image: Vec<u8>,
1084 before: &Page,
1085 stored: &Page,
1086 edited: &Page,
1087 ops: &Result<Vec<PageOp>, String>,
1088 applied: Result<(), String>,
1089 normalized: bool,
1090 ) -> Outcome {
1091 if let Err(error) = &applied
1092 && stored != before
1093 {
1094 return Outcome::Differs(format!("a refused edit changed the page: {error}"));
1095 }
1096 if let (Ok(()), Ok(ops)) = (&applied, ops) {
1097 let mut predicted = before.clone();
1098 if let Err(error) = ops
1099 .iter()
1100 .try_for_each(|op| onestore::op::predict(&mut predicted, op))
1101 {
1102 return Outcome::Differs(format!(
1103 "the model refuses what the section stores: {error}"
1104 ));
1105 }
1106 if comparable(&predicted) != comparable(stored) {
1107 let hidden = hidden_styles(&image, self.space);
1108 if ops.iter().any(|op| {
1109 matches!(op, PageOp::Text { text, .. } | PageOp::Link { text, .. } | PageOp::Split { text, .. }
1110 if hidden.contains(text))
1111 }) {
1112 return Outcome::Normalized;
1113 }
1114 return Outcome::Differs(format!(
1115 "the model predicts otherwise: {}",
1116 difference(stored, &predicted)
1117 ));
1118 }
1119 if reads_as(stored, edited) {
1120 return Outcome::Same;
1121 }
1122 }
1123 let arena = Arena::default();
1124 let mut whole = Section::open(&arena, image).unwrap();
1125 let lowered = onestore::op::lower_page(before, edited)
1126 .map_err(|error| error.to_string())
1127 .and_then(|ops| {
1128 let edit = Edit {
1129 at: self.at,
1130 ops: ops
1131 .into_iter()
1132 .map(|op| Op::Page {
1133 space: self.space,
1134 op,
1135 })
1136 .collect(),
1137 };
1138 whole
1139 .apply("Sweep", &edit)
1140 .map_err(|error| error.to_string())
1141 });
1142 match (applied, lowered) {
1143 (Err(error), Err(whole)) => {
1144 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
1145 eprintln!("BOTH {error} | {whole}");
1146 }
1147 Outcome::Refused
1148 }
1149 (Ok(()), Ok(())) => {
1150 let whole = whole.page(self.space).unwrap();
1151 if *stored == whole {
1152 Outcome::Same
1153 } else if normalized {
1154 Outcome::Normalized
1155 } else {
1156 Outcome::Differs(difference(stored, &whole))
1157 }
1158 }
1159 (Err(_), Ok(())) if normalized => Outcome::Normalized,
1160 (Err(error), Ok(())) => Outcome::Differs(format!("ops refused: {error}")),
1161 (Ok(()), Err(_)) => Outcome::OpsOnly,
1162 }
1163 }
1164}
1165
1166fn reads_as(stored: &Page, model: &Page) -> bool {
1167 Page {
1168 title: model.title.clone(),
1169 ..stored.clone()
1170 } == *model
1171}
1172
1173/// The first place two pages part: an object missing from one, then objects by identity,
1174/// then their order.
1175fn difference(a: &Page, b: &Page) -> String {
1176 let objects = |page: &Page| -> BTreeMap<ExGuid, String> {
1177 page.objects
1178 .iter()
1179 .map(|object| (object.id(), format!("{object:?}")))
1180 .collect()
1181 };
1182 let (x, y) = (objects(a), objects(b));
1183 if let Some(id) = x
1184 .keys()
1185 .chain(y.keys())
1186 .find(|id| x.contains_key(id) != y.contains_key(id))
1187 {
1188 return format!("object {id} on one side only");
1189 }
1190 for (id, x) in &x {
1191 let y = &y[id];
1192 if x != y {
1193 let at = x.bytes().zip(y.bytes()).take_while(|(p, q)| p == q).count();
1194 let from = at.saturating_sub(160);
1195 return format!(
1196 "object {id} differs\n one: …{}\n other: …{}",
1197 &x[from..(at + 160).min(x.len())],
1198 &y[from..(at + 160).min(y.len())]
1199 );
1200 }
1201 }
1202 if a.objects
1203 .iter()
1204 .map(|o| o.id())
1205 .ne(b.objects.iter().map(|o| o.id()))
1206 {
1207 return "object order".into();
1208 }
1209 if a.definitions != b.definitions {
1210 return "definitions".into();
1211 }
1212 "page fields".into()
1213}
1214
1215#[derive(Default)]
1216struct Tally {
1217 outcomes: BTreeMap<(Kind, Outcome), usize>,
1218 examples: BTreeMap<String, String>,
1219}
1220
1221fn sweep(
1222 path: &Path,
1223 random: &mut Random,
1224 engine: &mut TextEngine,
1225 tally: &mut Tally,
1226 pages: usize,
1227) {
1228 let image = std::fs::read(path).unwrap();
1229 let arena = Arena::default();
1230 let mut section = Section::open(&arena, image.clone()).unwrap();
1231 for (space, title, _) in section.pages().unwrap().into_iter().take(pages) {
1232 let Ok(page) = section.page(space) else {
1233 continue;
1234 };
1235 if CanvasEditor::from_page(page.clone(), engine).is_err() {
1236 continue;
1237 }
1238 let plans = KINDS
1239 .iter()
1240 .map(|kind| vec![*kind])
1241 .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo]))
1242 .chain((0..4).map(|_| {
1243 (0..3 + random.below(8))
1244 .map(|_| KINDS[random.below(KINDS.len())])
1245 .collect()
1246 }))
1247 .chain(PAPER_PLANS.map(<[Kind]>::to_vec))
1248 .chain(INK_PLANS.map(<[Kind]>::to_vec))
1249 .chain(ATTACH_PLANS.map(<[Kind]>::to_vec))
1250 .chain(RECORD_PLANS.map(<[Kind]>::to_vec))
1251 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))
1252 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))
1253 .chain(STYLE_PLANS.map(<[Kind]>::to_vec))
1254 .chain(TABLE_PLANS.map(<[Kind]>::to_vec))
1255 .collect::<Vec<Vec<Kind>>>();
1256 for plan in plans {
1257 let arena = Arena::default();
1258 let mut stored = Stored {
1259 section: Section::open(&arena, image.clone()).unwrap(),
1260 space,
1261 at: 133_000_000_000_000_000,
1262 };
1263 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
1264 for kind in &plan {
1265 let model = editor.page().unwrap();
1266 let outcome = if *kind == Kind::Theme {
1267 match stored.theme(&mut editor, engine, random.below(2) == 0) {
1268 Some(outcome) => outcome,
1269 None => continue,
1270 }
1271 } else {
1272 if !perform(&mut editor, engine, *kind, random) {
1273 continue;
1274 }
1275 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
1276 eprintln!("STEP {kind:?} of {plan:?} on {title:?}");
1277 }
1278 stored.step(&mut editor, &model)
1279 };
1280 *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1;
1281 if let Outcome::Differs(reason) = &outcome {
1282 tally
1283 .examples
1284 .entry(reason.clone())
1285 .or_insert_with(|| format!("{plan:?} on {title:?} in {}", path.display()));
1286 }
1287 if outcome != Outcome::Same {
1288 break;
1289 }
1290 }
1291 }
1292 }
1293}
1294
1295#[test]
1296fn editor_ops_store_the_editor_s_page() {
1297 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1298 let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect();
1299 if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") {
1300 paths.extend(std::env::split_paths(&extra));
1301 }
1302 let full = sweep::full();
1303 let pages = if full.is_some() { usize::MAX } else { 2 };
1304 let mut random = Random(1 + full.unwrap_or(0));
1305 let mut engine = TextEngine::default();
1306 let mut tally = Tally::default();
1307 for path in &paths {
1308 let start = std::time::Instant::now();
1309 sweep(path, &mut random, &mut engine, &mut tally, pages);
1310 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
1311 eprintln!("TIME {:?} {}", start.elapsed(), path.display());
1312 }
1313 }
1314 let mut report = String::new();
1315 let mut per_kind: BTreeMap<Kind, [usize; 5]> = BTreeMap::new();
1316 for ((kind, outcome), count) in &tally.outcomes {
1317 let slot = match outcome {
1318 Outcome::Same => 0,
1319 Outcome::Refused => 1,
1320 Outcome::OpsOnly => 2,
1321 Outcome::Normalized => 3,
1322 Outcome::Differs(_) => 4,
1323 };
1324 per_kind.entry(*kind).or_default()[slot] += count;
1325 }
1326 let mut totals = [0; 5];
1327 for (kind, counts) in &per_kind {
1328 let [same, refused, ops_only, normalized, differs] = counts;
1329 report += &format!(
1330 "{kind:?}: {same} same, {refused} refused, {ops_only} by ops only, {normalized} after normalization, {differs} differ\n"
1331 );
1332 for (total, count) in totals.iter_mut().zip(counts) {
1333 *total += count;
1334 }
1335 }
1336 report +=
1337 &format!("all: {totals:?} (same, refused, by ops only, after normalization, differ)\n");
1338 for ((kind, outcome), count) in &tally.outcomes {
1339 if let Outcome::Differs(reason) = outcome {
1340 report += &format!(
1341 "{count} x {kind:?} {reason}\n e.g. {}\n",
1342 tally.examples.get(reason).map_or("", String::as_str)
1343 );
1344 }
1345 }
1346 println!("{report}");
1347 let differs = totals[4];
1348 assert_eq!(differs, 0, "{report}");
1349 // Undoing stores what the edit it undoes stored, so storage never refuses it.
1350 for kind in [Kind::Undo, Kind::Redo] {
1351 assert_eq!(
1352 per_kind.get(&kind).map_or(0, |counts| counts[1]),
1353 0,
1354 "{report}"
1355 );
1356 }
1357}