| 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 | |
| 8 | use canvas::{ |
| 9 | document::TextPosition, |
| 10 | editor::{Alignment, CanvasEditor, FAVORITES, Formatting, NoteTag, Pen, Toggle, Whole}, |
| 11 | layout::TextEngine, |
| 12 | }; |
| 13 | use draw::edit::Movement; |
| 14 | use onestore::{ |
| 15 | Arena, ExGuid, RevisionIndex, Section, Store, |
| 16 | document::{Document, Kind as Node, Layout}, |
| 17 | op::{Edit, Op, PageOp}, |
| 18 | page::{Image, Page}, |
| 19 | }; |
| 20 | use std::{collections::BTreeMap, path::Path}; |
| 21 | |
| 22 | /// A one-pixel PNG standing in for template art's bytes. |
| 23 | const 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"] |
| 32 | mod sweep; |
| 33 | |
| 34 | const 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)] |
| 58 | enum 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 | |
| 147 | const 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. |
| 208 | const 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. |
| 222 | const 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. |
| 229 | const 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. |
| 236 | const 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. |
| 258 | const 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. |
| 284 | const 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 | |
| 296 | const 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. |
| 306 | fn 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. |
| 349 | const 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 | |
| 355 | struct Random(u64); |
| 356 | |
| 357 | impl 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. |
| 367 | fn 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. |
| 380 | fn 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. |
| 396 | fn 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)] |
| 907 | enum 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. |
| 923 | struct 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. |
| 931 | fn 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. |
| 948 | fn 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 | |
| 964 | impl 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 | |
| 1166 | fn 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. |
| 1175 | fn 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)] |
| 1216 | struct Tally { |
| 1217 | outcomes: BTreeMap<(Kind, Outcome), usize>, |
| 1218 | examples: BTreeMap<String, String>, |
| 1219 | } |
| 1220 | |
| 1221 | fn 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] |
| 1296 | fn 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 | } |