| 1 | use onestore::{ |
| 2 | ExGuid, RevisionIndex, Store, |
| 3 | document::{Document, Kind}, |
| 4 | }; |
| 5 | use std::fs; |
| 6 | |
| 7 | #[test] |
| 8 | fn documented_cell_shading_survives_native_2010_ignoring_its_display() { |
| 9 | let bytes = |
| 10 | fs::read("../../corpus/m6/cell-shading-control-01/input/notebook/synthetic.one").unwrap(); |
| 11 | let store = Store::parse(&bytes).unwrap(); |
| 12 | let index = RevisionIndex::parse(&store).unwrap(); |
| 13 | let document = Document::parse(&index).unwrap(); |
| 14 | let colors: Vec<_> = document |
| 15 | .spaces |
| 16 | .values() |
| 17 | .flat_map(|s| s.revisions.values()) |
| 18 | .flat_map(|r| r.nodes.values()) |
| 19 | .filter_map(|n| match n.kind { |
| 20 | Kind::Cell { shading, .. } => shading, |
| 21 | _ => None, |
| 22 | }) |
| 23 | .collect(); |
| 24 | assert_eq!(colors, [0x0000ffff]); |
| 25 | } |
| 26 | |
| 27 | #[test] |
| 28 | fn native_saved_collapse_default_preserves_descendants() { |
| 29 | for (path, expected) in [ |
| 30 | ( |
| 31 | "../../corpus/m6/native-features-01/notebook/Features.one", |
| 32 | 1, |
| 33 | ), |
| 34 | ( |
| 35 | "../../corpus/m6/rust-collapse-control-01/input/notebook/Features.one", |
| 36 | 0, |
| 37 | ), |
| 38 | ] { |
| 39 | let bytes = fs::read(path).unwrap(); |
| 40 | let store = Store::parse(&bytes).unwrap(); |
| 41 | let index = RevisionIndex::parse(&store).unwrap(); |
| 42 | let document = Document::parse(&index).unwrap(); |
| 43 | let mut parents = 0; |
| 44 | for revision in document.spaces.values().flat_map(|s| s.revisions.values()) { |
| 45 | for node in revision.nodes.values() { |
| 46 | if let Kind::Paragraph { collapse_state, .. } = node.kind |
| 47 | && node.content.iter().any(|id| { |
| 48 | matches!(&revision.nodes[id].kind, Kind::RichText { text, .. } if text == "Collapsed parent") |
| 49 | }) |
| 50 | { |
| 51 | assert_eq!(collapse_state, Some(expected)); |
| 52 | assert_eq!(node.children.len(), 1); |
| 53 | assert_eq!(revision.nodes[&node.children[0]].children.len(), 1); |
| 54 | parents += 1; |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | assert_eq!(parents, 1); |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | #[test] |
| 63 | fn native_math_preserves_run_data_and_distinguishes_plain_text() { |
| 64 | let bytes = fs::read("../../corpus/m6/native-math-01/notebook/synthetic.one").unwrap(); |
| 65 | let store = Store::parse(&bytes).unwrap(); |
| 66 | let index = RevisionIndex::parse(&store).unwrap(); |
| 67 | let document = Document::parse(&index).unwrap(); |
| 68 | let mut equations = 0; |
| 69 | let mut inline = 0; |
| 70 | for revision in document.spaces.values().flat_map(|s| s.revisions.values()) { |
| 71 | for (id, node) in &revision.nodes { |
| 72 | let Kind::RichText { text, runs, .. } = &node.kind else { |
| 73 | continue; |
| 74 | }; |
| 75 | let resolved = revision.text_runs(*id).unwrap(); |
| 76 | if text.contains('\u{fdd0}') { |
| 77 | equations += 1; |
| 78 | assert!(resolved.iter().all(|r| r.format.math == Some(true))); |
| 79 | let expected = if text.contains('π₯') { |
| 80 | assert_eq!( |
| 81 | text, |
| 82 | "\u{fdd0}π₯\u{fdee}2\u{fdef}+\u{fdd0}π¦\u{fdee}2\u{fdef}=\u{fdd0}π§\u{fdee}2\u{fdef}" |
| 83 | ); |
| 84 | 11 |
| 85 | } else { |
| 86 | assert_eq!(text, "\u{fdd0}π+π\u{fdee}π+π\u{fdef}"); |
| 87 | 3 |
| 88 | }; |
| 89 | assert_eq!(runs.len(), expected); |
| 90 | for (i, run) in runs.iter().enumerate() { |
| 91 | assert_eq!(run.extra_set, Some(i + 1)); |
| 92 | assert!(!node.extra[run.extra_set.unwrap()].is_empty()); |
| 93 | } |
| 94 | } |
| 95 | if text == "Inline πΌ+π½" { |
| 96 | inline += 1; |
| 97 | assert_eq!(resolved.len(), 2); |
| 98 | assert_eq!(resolved[0].text, "Inline "); |
| 99 | assert_eq!(resolved[0].format.math, Some(false)); |
| 100 | assert_eq!(resolved[1].text, "πΌ+π½"); |
| 101 | assert_eq!(resolved[1].format.math, Some(true)); |
| 102 | assert!(runs.iter().all(|r| r.extra_set.is_none())); |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | assert_eq!((equations, inline), (2, 1)); |
| 107 | } |
| 108 | |
| 109 | #[test] |
| 110 | fn native_origin_control_retains_source_coordinate_frame() { |
| 111 | let bytes = |
| 112 | fs::read("../../corpus/m6/native-origin-controls-01/input/notebook/synthetic.one").unwrap(); |
| 113 | let store = Store::parse(&bytes).unwrap(); |
| 114 | let index = RevisionIndex::parse(&store).unwrap(); |
| 115 | let document = Document::parse(&index).unwrap(); |
| 116 | let (revision, page) = document |
| 117 | .spaces |
| 118 | .values() |
| 119 | .flat_map(|space| space.revisions.values()) |
| 120 | .find_map(|revision| { |
| 121 | revision |
| 122 | .nodes |
| 123 | .values() |
| 124 | .find(|node| matches!(node.kind, Kind::Page { .. })) |
| 125 | .map(|page| (revision, page)) |
| 126 | }) |
| 127 | .unwrap(); |
| 128 | let Kind::Page { |
| 129 | margin_origin_x, |
| 130 | margin_origin_y, |
| 131 | rtl, |
| 132 | .. |
| 133 | } = page.kind |
| 134 | else { |
| 135 | unreachable!() |
| 136 | }; |
| 137 | assert_eq!(margin_origin_x, Some(108.0)); |
| 138 | assert_eq!(margin_origin_y, Some(-36.0)); |
| 139 | assert_ne!(rtl, Some(true)); |
| 140 | let outline = &revision.nodes[&page.children[0]]; |
| 141 | assert_eq!(outline.layout.x, Some(36.0)); |
| 142 | assert!((outline.layout.y.unwrap() - 14.4).abs() < 0.002); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn native_rtl_table_retains_visual_column_order() { |
| 147 | let bytes = |
| 148 | fs::read("../../corpus/m6/native-page-direction-03/notebook/synthetic.one").unwrap(); |
| 149 | let store = Store::parse(&bytes).unwrap(); |
| 150 | let index = RevisionIndex::parse(&store).unwrap(); |
| 151 | let document = Document::parse(&index).unwrap(); |
| 152 | let revision = document |
| 153 | .spaces |
| 154 | .values() |
| 155 | .flat_map(|space| space.revisions.values()) |
| 156 | .find(|revision| { |
| 157 | revision.nodes.values().any(|node| { |
| 158 | matches!( |
| 159 | node.kind, |
| 160 | Kind::Page { |
| 161 | rtl: Some(true), |
| 162 | .. |
| 163 | } |
| 164 | ) |
| 165 | }) |
| 166 | }) |
| 167 | .unwrap(); |
| 168 | let table = revision |
| 169 | .nodes |
| 170 | .values() |
| 171 | .find(|node| matches!(node.kind, Kind::Table { .. })) |
| 172 | .unwrap(); |
| 173 | let Kind::Table { widths, locked, .. } = &table.kind else { |
| 174 | unreachable!() |
| 175 | }; |
| 176 | assert_eq!(widths, &[144.0, 96.0]); |
| 177 | assert_eq!(locked, &[true, true]); |
| 178 | let row = &revision.nodes[&table.children[0]]; |
| 179 | let text: Vec<_> = row |
| 180 | .children |
| 181 | .iter() |
| 182 | .map(|id| { |
| 183 | let paragraph = &revision.nodes[&revision.nodes[id].children[0]]; |
| 184 | revision |
| 185 | .text_runs(paragraph.content[0]) |
| 186 | .unwrap() |
| 187 | .iter() |
| 188 | .map(|run| run.text) |
| 189 | .collect::<String>() |
| 190 | }) |
| 191 | .collect(); |
| 192 | assert_eq!(text, ["Right cell", "Left cell"]); |
| 193 | } |
| 194 | |
| 195 | #[test] |
| 196 | fn native_default_template_has_a_named_page_relationship() { |
| 197 | let bytes = |
| 198 | fs::read("../../corpus/m6/native-template-controls-01/notebook/synthetic.one").unwrap(); |
| 199 | let store = Store::parse(&bytes).unwrap(); |
| 200 | let index = RevisionIndex::parse(&store).unwrap(); |
| 201 | let document = Document::parse(&index).unwrap(); |
| 202 | let root = &document.spaces[&document.root]; |
| 203 | let root = &root.revisions[&root.contexts[&ExGuid::default()]]; |
| 204 | let Kind::Section { |
| 205 | default_template: Some(template), |
| 206 | } = root.nodes[&root.roots[&1]].kind |
| 207 | else { |
| 208 | panic!("Missing default template") |
| 209 | }; |
| 210 | let template = &document.spaces[&template]; |
| 211 | let template = &template.revisions[&template.contexts[&ExGuid::default()]]; |
| 212 | let Kind::TemplateMetadata { name } = &template.nodes[&template.roots[&2]].kind else { |
| 213 | panic!("Missing template metadata") |
| 214 | }; |
| 215 | assert_eq!(name.as_deref(), Some("Hearts")); |
| 216 | let manifest = &template.nodes[&template.roots[&1]]; |
| 217 | assert_eq!(manifest.content.len(), 1); |
| 218 | assert!(matches!( |
| 219 | template.nodes[&manifest.content[0]].kind, |
| 220 | Kind::Page { .. } |
| 221 | )); |
| 222 | } |
| 223 | |
| 224 | #[test] |
| 225 | fn native_paragraph_breaks_preserve_every_stored_character() { |
| 226 | let bytes = |
| 227 | fs::read("../../corpus/m6/native-break-controls-02/notebook/synthetic.one").unwrap(); |
| 228 | let store = Store::parse(&bytes).unwrap(); |
| 229 | let index = RevisionIndex::parse(&store).unwrap(); |
| 230 | let document = Document::parse(&index).unwrap(); |
| 231 | let (revision, outline) = document |
| 232 | .spaces |
| 233 | .values() |
| 234 | .flat_map(|s| s.revisions.values()) |
| 235 | .find_map(|revision| { |
| 236 | revision |
| 237 | .nodes |
| 238 | .values() |
| 239 | .find(|node| matches!(node.kind, Kind::Outline { .. }) && node.children.len() == 24) |
| 240 | .map(|node| (revision, node)) |
| 241 | }) |
| 242 | .unwrap(); |
| 243 | let expected = [ |
| 244 | "A", "A\r", "A\r\r", "\rA", "\rA\r", "\r", "\r\r", "A\rB\r", "A\rB\r", "AB", "A\rB\r", |
| 245 | "A\rB\r", |
| 246 | ]; |
| 247 | for (case, paragraph) in outline.children.iter().enumerate() { |
| 248 | let runs = revision |
| 249 | .text_runs(revision.nodes[paragraph].content[0]) |
| 250 | .unwrap(); |
| 251 | let visible: Vec<_> = runs |
| 252 | .iter() |
| 253 | .filter(|r| r.format.hidden != Some(true)) |
| 254 | .collect(); |
| 255 | assert_eq!( |
| 256 | visible.iter().map(|r| r.text).collect::<String>(), |
| 257 | expected[case % 12], |
| 258 | "case {case}" |
| 259 | ); |
| 260 | for run in visible { |
| 261 | for character in run.text.chars() { |
| 262 | assert_eq!( |
| 263 | run.link.is_some(), |
| 264 | case >= 12 && character != '\r', |
| 265 | "case {case}" |
| 266 | ); |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn referenced_history_contexts_require_history_roots_where_stored() { |
| 274 | let bytes = fs::read("../../corpus/m6/native-features-01/notebook/Features.one").unwrap(); |
| 275 | let store = Store::parse(&bytes).unwrap(); |
| 276 | let mut index = RevisionIndex::parse(&store).unwrap(); |
| 277 | let document = Document::parse(&index).unwrap(); |
| 278 | let (sid, context, current) = document |
| 279 | .spaces |
| 280 | .iter() |
| 281 | .find_map(|(sid, space)| { |
| 282 | let current = space.contexts[&ExGuid::default()]; |
| 283 | let revision = &space.revisions[&current]; |
| 284 | match revision.nodes[&revision.roots[&1]].kind { |
| 285 | Kind::Manifest { |
| 286 | history: Some(context), |
| 287 | } => Some((*sid, context, current)), |
| 288 | _ => None, |
| 289 | } |
| 290 | }) |
| 291 | .unwrap(); |
| 292 | let space = &document.spaces[&sid]; |
| 293 | let history = &space.revisions[&space.contexts[&context]]; |
| 294 | assert!(matches!( |
| 295 | history.nodes[&history.roots[&1]].kind, |
| 296 | Kind::VersionHistory |
| 297 | )); |
| 298 | assert!(history.nodes[&history.roots[&1]].children.is_empty()); |
| 299 | index |
| 300 | .spaces |
| 301 | .get_mut(&sid) |
| 302 | .unwrap() |
| 303 | .labels |
| 304 | .insert((context, 1), current); |
| 305 | assert_eq!( |
| 306 | Document::parse(&index).unwrap_err().message, |
| 307 | "History context does not resolve to version history" |
| 308 | ); |
| 309 | // OneNote's Copy Page To of a page version names a history it never writes |
| 310 | // (corpus/page-versions/native/step-03). |
| 311 | index |
| 312 | .spaces |
| 313 | .get_mut(&sid) |
| 314 | .unwrap() |
| 315 | .labels |
| 316 | .remove(&(context, 1)); |
| 317 | let document = Document::parse(&index).unwrap(); |
| 318 | assert!(!document.spaces[&sid].contexts.contains_key(&context)); |
| 319 | } |
| 320 | |
| 321 | #[test] |
| 322 | fn native_hyperlink_boundaries_preserve_whitespace_and_literal_labels() { |
| 323 | let bytes = fs::read("../../corpus/m6/native-link-controls-01/notebook/Links.one").unwrap(); |
| 324 | let store = Store::parse(&bytes).unwrap(); |
| 325 | let index = RevisionIndex::parse(&store).unwrap(); |
| 326 | let document = Document::parse(&index).unwrap(); |
| 327 | let (space, outline) = document |
| 328 | .spaces |
| 329 | .values() |
| 330 | .flat_map(|s| s.revisions.values()) |
| 331 | .find_map(|space| { |
| 332 | space.nodes.values().find_map(|node| { |
| 333 | (matches!(node.kind, Kind::Outline { .. }) && node.children.len() == 10) |
| 334 | .then_some((space, node)) |
| 335 | }) |
| 336 | }) |
| 337 | .unwrap(); |
| 338 | let expected = [ |
| 339 | (" label ", "label "), |
| 340 | ("\tlabel\t", "label\t"), |
| 341 | ("\u{a0}label\u{a0}", "\u{a0}label\u{a0}"), |
| 342 | (" \tlabel \t", "label \t"), |
| 343 | ("label", "label"), |
| 344 | (" ", ""), |
| 345 | ("label", "label"), |
| 346 | (" ]]> end", "]]> end"), |
| 347 | ("<b> & \"quoted\"", "<b> & \"quoted\""), |
| 348 | ("\rlabel\r", "label"), |
| 349 | ]; |
| 350 | for (number, (paragraph, (text, linked))) in outline.children.iter().zip(expected).enumerate() { |
| 351 | let runs = space.text_runs(space.nodes[paragraph].content[0]).unwrap(); |
| 352 | let visible: Vec<_> = runs |
| 353 | .iter() |
| 354 | .filter(|r| r.format.hidden != Some(true)) |
| 355 | .collect(); |
| 356 | assert_eq!(visible.iter().map(|r| r.text).collect::<String>(), text); |
| 357 | assert_eq!( |
| 358 | visible |
| 359 | .iter() |
| 360 | .filter(|r| r.link.is_some()) |
| 361 | .map(|r| r.text) |
| 362 | .collect::<String>(), |
| 363 | linked |
| 364 | ); |
| 365 | let target = format!("https://example.invalid/label/{number}"); |
| 366 | for run in visible { |
| 367 | if let Some(link) = run.link { |
| 368 | assert_eq!(link, target); |
| 369 | } |
| 370 | assert_eq!(run.format.bold, Some(true)); |
| 371 | assert_eq!(run.format.font_size, Some(14.0)); |
| 372 | assert_eq!(run.format.underline, Some(false)); |
| 373 | assert_eq!(run.format.color, Some(0xff000000)); |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | #[test] |
| 379 | fn native_unicode_table_assets_and_coordinates_are_typed() { |
| 380 | let bytes = |
| 381 | fs::read("../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one") |
| 382 | .unwrap(); |
| 383 | let store = Store::parse(&bytes).unwrap(); |
| 384 | let index = RevisionIndex::parse(&store).unwrap(); |
| 385 | let document = Document::parse(&index).unwrap(); |
| 386 | let nodes: Vec<_> = document |
| 387 | .spaces |
| 388 | .values() |
| 389 | .flat_map(|s| s.revisions.values()) |
| 390 | .flat_map(|r| r.nodes.values()) |
| 391 | .collect(); |
| 392 | assert!(nodes.iter().any(|n| matches!(&n.kind, Kind::RichText { text, .. } if text == "Fictitious: café, ζ±δΊ¬, Ω
Ψ±ΨΨ¨Ψ§"))); |
| 393 | let table = nodes |
| 394 | .iter() |
| 395 | .find(|n| matches!(n.kind, Kind::Table { .. })) |
| 396 | .unwrap(); |
| 397 | let Kind::Table { |
| 398 | rows, |
| 399 | columns, |
| 400 | widths, |
| 401 | .. |
| 402 | } = &table.kind |
| 403 | else { |
| 404 | unreachable!() |
| 405 | }; |
| 406 | assert_eq!( |
| 407 | (*rows, *columns, table.children.len(), widths.len()), |
| 408 | (Some(1), Some(2), 1, 2) |
| 409 | ); |
| 410 | assert_eq!( |
| 411 | nodes |
| 412 | .iter() |
| 413 | .filter(|n| matches!(n.kind, Kind::Cell { .. })) |
| 414 | .count(), |
| 415 | 2 |
| 416 | ); |
| 417 | assert_eq!( |
| 418 | nodes |
| 419 | .iter() |
| 420 | .filter(|n| matches!(n.kind, Kind::Attachment { .. })) |
| 421 | .count(), |
| 422 | 1 |
| 423 | ); |
| 424 | assert!(nodes.iter().any(|n| matches!(&n.kind, Kind::File { payload: Some(data), .. } if data.starts_with(b"\x89PNG")))); |
| 425 | assert!(nodes.iter().any(|n| matches!(n.kind, Kind::Outline { .. }) |
| 426 | && n.layout.x == Some(144.0) |
| 427 | && n.layout.y == Some(96.0))); |
| 428 | for node in nodes { |
| 429 | if let Kind::RichText { text, runs, .. } = &node.kind { |
| 430 | assert_eq!(runs.first().unwrap().start, 0); |
| 431 | assert_eq!( |
| 432 | runs.last().unwrap().end as usize, |
| 433 | text.encode_utf16().count() |
| 434 | ); |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | #[test] |
| 440 | fn encrypted_content_is_accounted_without_fabricated_plaintext() { |
| 441 | for path in [ |
| 442 | "../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one", |
| 443 | "../../corpus/native-protected-boundaries/notebook/synthetic.one", |
| 444 | ] { |
| 445 | let bytes = fs::read(path).unwrap(); |
| 446 | let store = Store::parse(&bytes).unwrap(); |
| 447 | let index = RevisionIndex::parse(&store).unwrap(); |
| 448 | let document = Document::parse(&index).unwrap(); |
| 449 | assert!(!document.spaces.is_empty()); |
| 450 | let nodes: Vec<_> = document |
| 451 | .spaces |
| 452 | .values() |
| 453 | .flat_map(|s| s.revisions.values()) |
| 454 | .flat_map(|r| r.nodes.values()) |
| 455 | .collect(); |
| 456 | assert!(!nodes.is_empty()); |
| 457 | for node in nodes { |
| 458 | assert!(matches!(node.kind, Kind::Encrypted { ciphertext } if !ciphertext.is_empty())); |
| 459 | } |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | #[test] |
| 464 | fn rust_unicode_and_native_latin1_use_utf16_run_offsets() { |
| 465 | let text = "Aπe\u{301}ζ±δΊ¬"; |
| 466 | let bytes = onestore::create_section("unicode.one", text, "Fictitious").unwrap(); |
| 467 | let store = Store::parse(&bytes).unwrap(); |
| 468 | let index = RevisionIndex::parse(&store).unwrap(); |
| 469 | let document = Document::parse(&index).unwrap(); |
| 470 | let node = document |
| 471 | .spaces |
| 472 | .values() |
| 473 | .flat_map(|s| s.revisions.values()) |
| 474 | .flat_map(|r| r.nodes.values()) |
| 475 | .find(|n| matches!(n.kind, Kind::RichText { .. })) |
| 476 | .unwrap(); |
| 477 | let Kind::RichText { |
| 478 | text: actual, runs, .. |
| 479 | } = &node.kind |
| 480 | else { |
| 481 | unreachable!() |
| 482 | }; |
| 483 | assert_eq!(actual, text); |
| 484 | assert_eq!(runs[0].end, 7); |
| 485 | let expected = format!( |
| 486 | "Extended bytes: {}", |
| 487 | (128u8..=255) |
| 488 | .map(|b| char::from(b).to_string()) |
| 489 | .collect::<Vec<_>>() |
| 490 | .join(" ") |
| 491 | ); |
| 492 | // OneNote 2010 reads TextExtendedAscii as Latin-1 whatever the run's language |
| 493 | // (corpus/extended-ascii/{en,ru,ja}/cold). |
| 494 | for language in ["en", "ru", "ja"] { |
| 495 | let path = format!("../../corpus/extended-ascii/{language}/candidate/synthetic.one"); |
| 496 | let bytes = fs::read(path).unwrap(); |
| 497 | let store = Store::parse(&bytes).unwrap(); |
| 498 | let index = RevisionIndex::parse(&store).unwrap(); |
| 499 | let document = Document::parse(&index).unwrap(); |
| 500 | assert!( |
| 501 | document |
| 502 | .spaces |
| 503 | .values() |
| 504 | .flat_map(|s| s.revisions.values()) |
| 505 | .flat_map(|r| r.nodes.values()) |
| 506 | .any(|n| matches!(&n.kind, Kind::RichText { text, .. } if text == &expected)) |
| 507 | ); |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | #[test] |
| 512 | fn toc_preserves_section_order_and_filename() { |
| 513 | let bytes = onestore::create_table_of_contents( |
| 514 | "Open Notebook.onetoc2", |
| 515 | &[("z.one", [1; 16]), ("a.one", [2; 16])], |
| 516 | ) |
| 517 | .unwrap(); |
| 518 | let store = Store::parse(&bytes).unwrap(); |
| 519 | let index = RevisionIndex::parse(&store).unwrap(); |
| 520 | let document = Document::parse(&index).unwrap(); |
| 521 | let space = &document.spaces[&document.root]; |
| 522 | let space = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 523 | let root = &space.nodes[&space.roots[&1]]; |
| 524 | let Kind::Toc { entries, .. } = &root.kind else { |
| 525 | panic!("Missing TOC") |
| 526 | }; |
| 527 | let names: Vec<_> = entries |
| 528 | .iter() |
| 529 | .map(|id| match &space.nodes[id].kind { |
| 530 | Kind::Toc { filename, .. } => filename.as_deref().unwrap(), |
| 531 | _ => panic!("Missing TOC entry"), |
| 532 | }) |
| 533 | .collect(); |
| 534 | assert_eq!(names, ["z.one", "a.one"]); |
| 535 | assert_ne!(document.root, ExGuid::default()); |
| 536 | } |
| 537 | |
| 538 | #[test] |
| 539 | fn native_printouts_tasks_and_recording_associations_are_typed() { |
| 540 | let bytes = fs::read("../../corpus/m6/native-features-01/notebook/Features.one").unwrap(); |
| 541 | let store = Store::parse(&bytes).unwrap(); |
| 542 | let index = RevisionIndex::parse(&store).unwrap(); |
| 543 | let document = Document::parse(&index).unwrap(); |
| 544 | let mut roles = Vec::new(); |
| 545 | let mut task = false; |
| 546 | let mut recording = false; |
| 547 | let mut annotation = false; |
| 548 | let mut decimal = false; |
| 549 | let mut inherited_strike = false; |
| 550 | let media = [ |
| 551 | 0x57, 0x8e, 0x41, 0x9a, 0x64, 0x7f, 0xc5, 0x40, 0x87, 0xd0, 0x8a, 0x8f, 0x7c, 0x1e, 0x54, |
| 552 | 0x22, |
| 553 | ]; |
| 554 | for space in document.spaces.values().flat_map(|s| s.revisions.values()) { |
| 555 | for (oid, node) in &space.nodes { |
| 556 | match &node.kind { |
| 557 | Kind::Image { |
| 558 | alt, |
| 559 | background, |
| 560 | printout, |
| 561 | .. |
| 562 | } if alt.as_deref() == Some("Role pattern") => { |
| 563 | let border = if *printout == Some(true) { 1.44 } else { 0.0 }; |
| 564 | assert!((node.layout.max_width.unwrap() + border - 288.0).abs() < 0.002); |
| 565 | assert!((node.layout.max_height.unwrap() + border - 192.0).abs() < 0.002); |
| 566 | roles.push((*background, *printout)); |
| 567 | } |
| 568 | Kind::Attachment { |
| 569 | filename, |
| 570 | recording_id, |
| 571 | .. |
| 572 | } if filename.as_deref() == Some("silence.wav") => { |
| 573 | assert_eq!(*recording_id, Some(media)); |
| 574 | recording = true; |
| 575 | } |
| 576 | Kind::RichText { text, .. } if text == "Recording annotation" => { |
| 577 | assert_eq!(node.media_ids, [media]); |
| 578 | assert_eq!(node.media_time_ms, Some(500)); |
| 579 | annotation = true; |
| 580 | } |
| 581 | Kind::RichText { text, .. } if text == "Disabled standalone task" => { |
| 582 | assert_eq!(node.tags.len(), 1); |
| 583 | let tag = &node.tags[0]; |
| 584 | assert_eq!( |
| 585 | (tag.definition, tag.action_type, tag.status), |
| 586 | (None, Some(100), 6) |
| 587 | ); |
| 588 | assert_eq!((tag.start, tag.due), (Some(1262390400), Some(1262476800))); |
| 589 | assert_eq!(tag.completed, Some(0)); |
| 590 | assert_eq!(tag.task_id.unwrap()[..4], [0xe3, 0x40, 0xaf, 0x45]); |
| 591 | task = true; |
| 592 | } |
| 593 | Kind::RichText { text, .. } if text == "Inherited strike and spacing" => { |
| 594 | let runs = space.text_runs(*oid).unwrap(); |
| 595 | assert_eq!(runs[0].format.strike, Some(true)); |
| 596 | assert_eq!(runs[0].format.space_before, Some(9.0)); |
| 597 | assert_eq!(runs[0].format.space_after, Some(4.5)); |
| 598 | inherited_strike = true; |
| 599 | } |
| 600 | Kind::List { format, .. } if format.as_deref() == Some("\u{fffd}\0") => { |
| 601 | decimal = true; |
| 602 | } |
| 603 | _ => {} |
| 604 | } |
| 605 | } |
| 606 | } |
| 607 | roles.sort(); |
| 608 | assert_eq!( |
| 609 | roles, |
| 610 | [ |
| 611 | (Some(false), Some(false)), |
| 612 | (Some(false), Some(true)), |
| 613 | (Some(true), Some(false)), |
| 614 | (Some(true), Some(true)) |
| 615 | ] |
| 616 | ); |
| 617 | assert!(task && recording && annotation && decimal && inherited_strike); |
| 618 | } |
| 619 | |
| 620 | #[test] |
| 621 | fn native_partial_black_highlight_and_inline_hyperlink_fields_survive() { |
| 622 | let bytes = fs::read("../../corpus/m6/native-probes-01/notebook/synthetic.one").unwrap(); |
| 623 | let store = Store::parse(&bytes).unwrap(); |
| 624 | let index = RevisionIndex::parse(&store).unwrap(); |
| 625 | let document = Document::parse(&index).unwrap(); |
| 626 | let mut black = false; |
| 627 | let mut link = false; |
| 628 | for space in document.spaces.values().flat_map(|s| s.revisions.values()) { |
| 629 | for (oid, node) in &space.nodes { |
| 630 | if let Kind::RichText { text, runs, .. } = &node.kind { |
| 631 | if text == "Before middle after" { |
| 632 | let highlighted: Vec<_> = runs |
| 633 | .iter() |
| 634 | .filter(|run| { |
| 635 | run.format |
| 636 | .is_some_and(|id| space.nodes[&id].format.highlight == Some(0)) |
| 637 | }) |
| 638 | .collect(); |
| 639 | assert_eq!(highlighted.len(), 1); |
| 640 | assert_eq!((highlighted[0].start, highlighted[0].end), (7, 13)); |
| 641 | black = true; |
| 642 | } |
| 643 | if text.starts_with("Before \u{fddf}HYPERLINK") { |
| 644 | let units: Vec<_> = text.encode_utf16().collect(); |
| 645 | let mut visible = String::new(); |
| 646 | let mut hidden = String::new(); |
| 647 | for run in runs { |
| 648 | let fragment = |
| 649 | String::from_utf16(&units[run.start as usize..run.end as usize]) |
| 650 | .unwrap(); |
| 651 | if run |
| 652 | .format |
| 653 | .is_some_and(|id| space.nodes[&id].format.hidden == Some(true)) |
| 654 | { |
| 655 | hidden.push_str(&fragment); |
| 656 | } else { |
| 657 | visible.push_str(&fragment); |
| 658 | } |
| 659 | } |
| 660 | assert_eq!(visible, "Before Link label after"); |
| 661 | let resolved = space.text_runs(*oid).unwrap(); |
| 662 | let links: Vec<_> = resolved |
| 663 | .iter() |
| 664 | .filter_map(|r| r.link.map(|url| (r.text, url, r.format.bold))) |
| 665 | .collect(); |
| 666 | assert_eq!( |
| 667 | links, |
| 668 | [( |
| 669 | "Link label", |
| 670 | "https://example.invalid/fixture?x=1&y=2", |
| 671 | Some(true) |
| 672 | )] |
| 673 | ); |
| 674 | assert_eq!( |
| 675 | resolved |
| 676 | .iter() |
| 677 | .filter(|r| r.format.hidden != Some(true)) |
| 678 | .map(|r| r.text) |
| 679 | .collect::<String>(), |
| 680 | visible |
| 681 | ); |
| 682 | assert_eq!( |
| 683 | hidden, |
| 684 | "\u{fddf}HYPERLINK \"https://example.invalid/fixture?x=1&y=2\"" |
| 685 | ); |
| 686 | link = true; |
| 687 | } |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | assert!(black && link); |
| 692 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn malformed_run_boundaries_fail_after_valid_storage_decoding() { |
| 696 | let bytes = fs::read("../../corpus/m6/native-probes-01/notebook/synthetic.one").unwrap(); |
| 697 | let store = Store::parse(&bytes).unwrap(); |
| 698 | let index = RevisionIndex::parse(&store).unwrap(); |
| 699 | let document = Document::parse(&index).unwrap(); |
| 700 | let (sid, rid, oid) = document |
| 701 | .spaces |
| 702 | .iter() |
| 703 | .find_map(|(sid, space)| { |
| 704 | space.revisions.iter().find_map(|(rid, revision)| { |
| 705 | revision.nodes.iter().find_map(|(oid, node)| { |
| 706 | matches!(&node.kind, Kind::RichText { text, .. } if text.starts_with("BMP ")) |
| 707 | .then_some((*sid, *rid, *oid)) |
| 708 | }) |
| 709 | }) |
| 710 | }) |
| 711 | .unwrap(); |
| 712 | let revision = index.resolve(sid, rid).unwrap(); |
| 713 | let onestore::ObjectData::Properties(properties) = revision.objects[&oid].data else { |
| 714 | unreachable!() |
| 715 | }; |
| 716 | let properties = onestore::PropertySets::parse(properties).unwrap(); |
| 717 | let onestore::Value::Bytes(boundaries) = properties.sets[0] |
| 718 | .iter() |
| 719 | .find(|p| p.id == 0x1c001e12) |
| 720 | .unwrap() |
| 721 | .value |
| 722 | else { |
| 723 | unreachable!() |
| 724 | }; |
| 725 | // Damage the stored boundaries where they lie, as no writer would store them. |
| 726 | let at = boundaries.as_ptr().addr() - bytes.as_ptr().addr(); |
| 727 | for invalid in [5u32, u32::MAX] { |
| 728 | let mut mutated = bytes.to_vec(); |
| 729 | mutated[at..at + 4].copy_from_slice(&invalid.to_le_bytes()); |
| 730 | let store = Store::parse(&mutated).unwrap(); |
| 731 | let index = RevisionIndex::parse(&store).unwrap(); |
| 732 | index.validate_current().unwrap(); |
| 733 | let error = Document::parse(&index).unwrap_err(); |
| 734 | assert_eq!( |
| 735 | error.message, |
| 736 | if invalid == 5 { |
| 737 | "Text-run boundary splits a surrogate pair" |
| 738 | } else { |
| 739 | "Text-run indices exceed text or are out of order" |
| 740 | } |
| 741 | ); |
| 742 | } |
| 743 | } |
| 744 | |
| 745 | #[test] |
| 746 | fn native_lists_locked_columns_and_named_styles_are_preserved() { |
| 747 | let bytes = fs::read("../../corpus/m6/native-structure-01/notebook/synthetic.one").unwrap(); |
| 748 | let store = Store::parse(&bytes).unwrap(); |
| 749 | let index = RevisionIndex::parse(&store).unwrap(); |
| 750 | let document = Document::parse(&index).unwrap(); |
| 751 | let mut found = (false, false, false); |
| 752 | for space in document.spaces.values().flat_map(|s| s.revisions.values()) { |
| 753 | for (oid, node) in &space.nodes { |
| 754 | match &node.kind { |
| 755 | Kind::Table { |
| 756 | rows, |
| 757 | columns, |
| 758 | locked, |
| 759 | widths, |
| 760 | .. |
| 761 | } => { |
| 762 | assert_eq!((*rows, *columns), (Some(3), Some(2))); |
| 763 | assert_eq!(locked, &[true, false]); |
| 764 | assert_eq!(widths[0], 120.0); |
| 765 | assert!(widths[1] > 37.0 && widths[1] < 38.0); |
| 766 | found.0 = true; |
| 767 | } |
| 768 | Kind::List { |
| 769 | format, |
| 770 | restart: Some(3), |
| 771 | bullet, |
| 772 | .. |
| 773 | } => { |
| 774 | assert_eq!(format.as_deref(), Some("\u{fffd}\u{1}.")); |
| 775 | assert_eq!(*bullet, None); |
| 776 | found.1 = true; |
| 777 | } |
| 778 | Kind::RichText { text, .. } if text == "Named paragraph style" => { |
| 779 | let runs = space.text_runs(*oid).unwrap(); |
| 780 | assert_eq!(runs[0].format.bold, Some(true)); |
| 781 | assert_eq!(runs[0].format.font_size, Some(20.0)); |
| 782 | assert_eq!(runs[0].format.space_before, Some(288.0)); |
| 783 | assert_eq!(runs[0].format.space_after, Some(144.0)); |
| 784 | assert_eq!(runs[0].format.alignment, Some(2)); |
| 785 | found.2 = true; |
| 786 | } |
| 787 | _ => {} |
| 788 | } |
| 789 | } |
| 790 | } |
| 791 | assert_eq!(found, (true, true, true)); |
| 792 | let (revision, outer) = document |
| 793 | .spaces |
| 794 | .values() |
| 795 | .flat_map(|s| s.revisions.values()) |
| 796 | .find_map(|revision| { |
| 797 | revision |
| 798 | .nodes |
| 799 | .values() |
| 800 | .find(|node| { |
| 801 | matches!(node.kind, Kind::Paragraph { .. }) && !node.children.is_empty() |
| 802 | }) |
| 803 | .map(|node| (revision, node)) |
| 804 | }) |
| 805 | .unwrap(); |
| 806 | assert_eq!(outer.child_level, Some(1)); |
| 807 | assert_eq!( |
| 808 | revision.text_runs(outer.content[0]).unwrap()[0].text, |
| 809 | "Outer bullet" |
| 810 | ); |
| 811 | assert_eq!(outer.children.len(), 1); |
| 812 | let nested = &revision.nodes[&outer.children[0]]; |
| 813 | assert_eq!( |
| 814 | revision.text_runs(nested.content[0]).unwrap()[0].text, |
| 815 | "Nested bullet" |
| 816 | ); |
| 817 | } |
| 818 | |
| 819 | #[test] |
| 820 | fn native_conflict_relationship_and_opaque_ink_are_retained() { |
| 821 | let bytes = |
| 822 | fs::read("../../corpus/collaboration/round-01/offline/notebook/synthetic.one").unwrap(); |
| 823 | let store = Store::parse(&bytes).unwrap(); |
| 824 | let index = RevisionIndex::parse(&store).unwrap(); |
| 825 | let document = Document::parse(&index).unwrap(); |
| 826 | let (conflict_id, conflict) = document |
| 827 | .spaces |
| 828 | .iter() |
| 829 | .find_map(|(sid, space)| { |
| 830 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 831 | revision.roots.get(&2).and_then(|root| { |
| 832 | matches!(revision.nodes[root].kind, Kind::ConflictMetadata { .. }) |
| 833 | .then_some((sid, revision)) |
| 834 | }) |
| 835 | }) |
| 836 | .unwrap(); |
| 837 | let Kind::ConflictMetadata { |
| 838 | title, |
| 839 | author, |
| 840 | level, |
| 841 | } = &conflict.nodes[&conflict.roots[&2]].kind |
| 842 | else { |
| 843 | unreachable!() |
| 844 | }; |
| 845 | assert_eq!(title.as_deref(), Some("Native edited while disconnected.")); |
| 846 | assert_eq!(author.as_deref(), Some("snow")); |
| 847 | assert_eq!(*level, Some(1)); |
| 848 | assert!( |
| 849 | document |
| 850 | .spaces |
| 851 | .iter() |
| 852 | .any(|(sid, space)| sid != conflict_id && { |
| 853 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 854 | revision.nodes[&revision.roots[&1]] |
| 855 | .spaces |
| 856 | .contains(conflict_id) |
| 857 | }) |
| 858 | ); |
| 859 | let bytes = fs::read("../../corpus/native-ink/cold-ui-ink/notebook/synthetic.one").unwrap(); |
| 860 | let store = Store::parse(&bytes).unwrap(); |
| 861 | let index = RevisionIndex::parse(&store).unwrap(); |
| 862 | let document = Document::parse(&index).unwrap(); |
| 863 | let ink: Vec<_> = document |
| 864 | .spaces |
| 865 | .values() |
| 866 | .flat_map(|s| s.revisions.values()) |
| 867 | .flat_map(|r| r.nodes.values()) |
| 868 | .filter(|node| matches!(node.kind, Kind::Ink { .. })) |
| 869 | .collect(); |
| 870 | assert_eq!(ink.len(), 2); |
| 871 | assert!( |
| 872 | ink.iter() |
| 873 | .all(|node| node.extra.iter().any(|set| !set.is_empty())) |
| 874 | ); |
| 875 | } |