| 1 | #[path = "support/ops.rs"] |
| 2 | mod ops; |
| 3 | use onestore::{ |
| 4 | ExGuid, FileDataReference, IdStream, ObjectData, PropertySets, RevisionIndex, Store, Value, |
| 5 | document::Document, |
| 6 | }; |
| 7 | use std::collections::{BTreeMap, BTreeSet}; |
| 8 | |
| 9 | fn compare_copy(left: &Store<'_>, right: &Store<'_>, source: ExGuid, copied: ExGuid) { |
| 10 | let indexes = [left, right].map(|store| RevisionIndex::parse(store).unwrap()); |
| 11 | let mut contexts = vec![(source, ExGuid::default(), copied, ExGuid::default())]; |
| 12 | let mut mapped_contexts = BTreeMap::new(); |
| 13 | let mut inverse_contexts = BTreeMap::new(); |
| 14 | while let Some((ls, lc, rs, rc)) = contexts.pop() { |
| 15 | if let Some(previous) = mapped_contexts.insert((ls, lc), (rs, rc)) { |
| 16 | assert_eq!(previous, (rs, rc)); |
| 17 | continue; |
| 18 | } |
| 19 | assert_eq!(inverse_contexts.insert((rs, rc), (ls, lc)), None); |
| 20 | let revisions = |
| 21 | [(ls, lc, &indexes[0]), (rs, rc, &indexes[1])].map(|(sid, context, index)| { |
| 22 | index |
| 23 | .resolve(sid, index.spaces[&sid].labels[&(context, 1)]) |
| 24 | .unwrap() |
| 25 | }); |
| 26 | assert_eq!( |
| 27 | revisions[0].roots.keys().collect::<Vec<_>>(), |
| 28 | revisions[1].roots.keys().collect::<Vec<_>>() |
| 29 | ); |
| 30 | let manifest = &revisions[1].objects[&revisions[1].roots[&1]]; |
| 31 | let copied_time = if manifest.jcid == 0x60037 { |
| 32 | let pages = manifest.references().unwrap().objects; |
| 33 | assert_eq!(pages.len(), 1); |
| 34 | let ObjectData::Properties(bytes) = revisions[1].objects[&pages[0]].data else { |
| 35 | panic!() |
| 36 | }; |
| 37 | let properties = PropertySets::parse(bytes).unwrap(); |
| 38 | let Value::Bytes(time) = properties.sets[0] |
| 39 | .iter() |
| 40 | .find(|p| p.id == 0x14001d7a) |
| 41 | .unwrap() |
| 42 | .value |
| 43 | else { |
| 44 | panic!() |
| 45 | }; |
| 46 | Some(time) |
| 47 | } else { |
| 48 | None |
| 49 | }; |
| 50 | let mut pending: Vec<_> = revisions[0] |
| 51 | .roots |
| 52 | .iter() |
| 53 | .map(|(role, id)| (*id, revisions[1].roots[role])) |
| 54 | .collect(); |
| 55 | let mut mapped = BTreeMap::new(); |
| 56 | let mut inverse = BTreeMap::new(); |
| 57 | while let Some((a, b)) = pending.pop() { |
| 58 | if let Some(previous) = mapped.insert(a, b) { |
| 59 | assert_eq!(previous, b); |
| 60 | continue; |
| 61 | } |
| 62 | if let Some(previous) = inverse.insert(b, a) { |
| 63 | let originals = [&revisions[0].objects[&previous], &revisions[0].objects[&a]]; |
| 64 | assert!(originals.iter().all(|object| object.jcid == 0x120001)); |
| 65 | assert_eq!(originals[0].data, originals[1].data); |
| 66 | for object in originals { |
| 67 | let references = object.references().unwrap(); |
| 68 | assert!(references.objects.is_empty()); |
| 69 | assert!(references.object_spaces.is_empty()); |
| 70 | assert!(references.contexts.is_empty()); |
| 71 | } |
| 72 | } |
| 73 | let objects = [&revisions[0].objects[&a], &revisions[1].objects[&b]]; |
| 74 | assert_eq!(objects[0].jcid, objects[1].jcid); |
| 75 | match (objects[0].data, objects[1].data) { |
| 76 | (ObjectData::Properties(a), ObjectData::Properties(b)) => { |
| 77 | let sets = [a, b].map(|bytes| PropertySets::parse(bytes).unwrap()); |
| 78 | let mut nested = vec![(0, 0)]; |
| 79 | while let Some((a, b)) = nested.pop() { |
| 80 | let fields = [(0, a), (1, b)].map(|(side, set)| { |
| 81 | let properties = &sets[side].sets[set]; |
| 82 | let fields = properties |
| 83 | .iter() |
| 84 | .map(|p| (p.id, &p.value)) |
| 85 | .collect::<BTreeMap<_, _>>(); |
| 86 | assert_eq!(fields.len(), properties.len()); |
| 87 | fields |
| 88 | }); |
| 89 | assert_eq!( |
| 90 | fields[0].keys().collect::<Vec<_>>(), |
| 91 | fields[1].keys().collect::<Vec<_>>() |
| 92 | ); |
| 93 | for (id, value) in &fields[0] { |
| 94 | let other = fields[1][id]; |
| 95 | match (value, other) { |
| 96 | ( |
| 97 | Value::References { |
| 98 | stream, |
| 99 | compact_ids, |
| 100 | }, |
| 101 | Value::References { |
| 102 | stream: other_stream, |
| 103 | compact_ids: other_ids, |
| 104 | }, |
| 105 | ) => { |
| 106 | assert_eq!(stream, other_stream); |
| 107 | assert_eq!(compact_ids.len(), other_ids.len()); |
| 108 | for (a, b) in |
| 109 | compact_ids.chunks_exact(4).zip(other_ids.chunks_exact(4)) |
| 110 | { |
| 111 | let ids = [a, b].map(|bytes| { |
| 112 | u32::from_le_bytes(bytes.try_into().unwrap()) |
| 113 | }); |
| 114 | let [a, b] = [0, 1].map(|side| ExGuid { |
| 115 | guid: objects[side].global_ids[&(ids[side] >> 8)], |
| 116 | n: ids[side] & 255, |
| 117 | }); |
| 118 | match stream { |
| 119 | IdStream::Objects => pending.push((a, b)), |
| 120 | IdStream::Contexts => contexts.push((ls, a, rs, b)), |
| 121 | IdStream::ObjectSpaces => contexts.push(( |
| 122 | a, |
| 123 | ExGuid::default(), |
| 124 | b, |
| 125 | ExGuid::default(), |
| 126 | )), |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | (Value::Sets(a), Value::Sets(b)) => { |
| 131 | assert_eq!(a.len(), b.len()); |
| 132 | nested.extend(a.clone().zip(b.clone())); |
| 133 | } |
| 134 | (Value::Bytes(_), Value::Bytes(level)) |
| 135 | if objects[0].jcid == 0x20030 |
| 136 | && *id == 0x14001dff |
| 137 | && lc == ExGuid::default() => |
| 138 | { |
| 139 | assert_eq!(*level, 1_u32.to_le_bytes()); |
| 140 | } |
| 141 | (Value::Bytes(_), Value::Bytes(time)) |
| 142 | if matches!( |
| 143 | objects[0].jcid, |
| 144 | 0x6000b |
| 145 | | 0x6000c |
| 146 | | 0x6002c |
| 147 | | 0x60022 |
| 148 | | 0x60012 |
| 149 | | 0x60023 |
| 150 | | 0x60024 |
| 151 | ) && *id == 0x14001d7a |
| 152 | && lc == ExGuid::default() => |
| 153 | { |
| 154 | if value != &other { |
| 155 | assert_eq!(Some(*time), copied_time); |
| 156 | } |
| 157 | } |
| 158 | _ => assert_eq!( |
| 159 | value, &other, |
| 160 | "property {id:#x}, jcid {:#x}, context {lc}, source set {a}, copied set {b}", |
| 161 | objects[0].jcid |
| 162 | ), |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | (ObjectData::File { extension: a, .. }, ObjectData::File { extension: b, .. }) => { |
| 168 | assert_eq!(a, b); |
| 169 | let references = |
| 170 | objects.map(|object| object.file_reference().unwrap().unwrap()); |
| 171 | let [ |
| 172 | FileDataReference::Internal(a), |
| 173 | FileDataReference::Internal(b), |
| 174 | ] = references |
| 175 | else { |
| 176 | panic!("The copy fixture requires its external payloads"); |
| 177 | }; |
| 178 | assert_eq!(left.file_data(a).unwrap(), right.file_data(b).unwrap()); |
| 179 | } |
| 180 | _ => panic!("Copied object representations differ"), |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | #[test] |
| 187 | fn native_parent_removal_retains_history_and_copies_the_complete_page() { |
| 188 | let bytes = [ |
| 189 | include_bytes!("../../../corpus/page-lifecycle/05-reordered/notebook/Lifecycle.one").as_slice(), |
| 190 | include_bytes!("../../../corpus/page-lifecycle/06-deleted-parent/notebook/Lifecycle.one").as_slice(), |
| 191 | include_bytes!("../../../corpus/page-lifecycle/06-deleted-parent/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one").as_slice(), |
| 192 | ]; |
| 193 | let stores = bytes.map(|b| Store::parse(b).unwrap()); |
| 194 | let indexes = stores.each_ref().map(|s| RevisionIndex::parse(s).unwrap()); |
| 195 | let documents = indexes.each_ref().map(|i| Document::parse(i).unwrap()); |
| 196 | let sid = documents[0].pages().unwrap()[4].0; |
| 197 | assert!(!documents[1].spaces.contains_key(&sid)); |
| 198 | assert_eq!( |
| 199 | indexes[0].spaces.keys().collect::<Vec<_>>(), |
| 200 | indexes[1].spaces.keys().collect::<Vec<_>>() |
| 201 | ); |
| 202 | for (space, revisions) in &indexes[0].spaces { |
| 203 | for rid in revisions.revisions.keys() { |
| 204 | assert_eq!( |
| 205 | format!("{:?}", indexes[0].resolve(*space, *rid).unwrap()), |
| 206 | format!("{:?}", indexes[1].resolve(*space, *rid).unwrap()) |
| 207 | ); |
| 208 | } |
| 209 | } |
| 210 | let current = indexes[1] |
| 211 | .resolve(sid, indexes[1].spaces[&sid].labels[&(ExGuid::default(), 1)]) |
| 212 | .unwrap(); |
| 213 | let ObjectData::Properties(manifest) = current.objects[&current.roots[&1]].data else { |
| 214 | panic!() |
| 215 | }; |
| 216 | assert!(PropertySets::parse(manifest).unwrap().sets[0].is_empty()); |
| 217 | let ObjectData::Properties(metadata) = current.objects[&current.roots[&2]].data else { |
| 218 | panic!() |
| 219 | }; |
| 220 | assert!( |
| 221 | PropertySets::parse(metadata).unwrap().sets[0] |
| 222 | .iter() |
| 223 | .any(|p| p.id == 0x88001de9) |
| 224 | ); |
| 225 | let copied = documents[2].pages().unwrap(); |
| 226 | assert_eq!(copied.len(), 1); |
| 227 | compare_copy(&stores[0], &stores[2], sid, copied[0].0); |
| 228 | } |
| 229 | |
| 230 | fn compare_labels(before: &RevisionIndex<'_>, after: &RevisionIndex<'_>) { |
| 231 | assert_eq!( |
| 232 | before.spaces.keys().collect::<Vec<_>>(), |
| 233 | after.spaces.keys().collect::<Vec<_>>() |
| 234 | ); |
| 235 | for (sid, space) in &before.spaces { |
| 236 | assert_eq!( |
| 237 | space.labels.keys().collect::<Vec<_>>(), |
| 238 | after.spaces[sid].labels.keys().collect::<Vec<_>>() |
| 239 | ); |
| 240 | for (label, rid) in &space.labels { |
| 241 | assert_eq!( |
| 242 | format!("{:?}", before.resolve(*sid, *rid).unwrap()), |
| 243 | format!( |
| 244 | "{:?}", |
| 245 | after |
| 246 | .resolve(*sid, after.spaces[sid].labels[label]) |
| 247 | .unwrap() |
| 248 | ), |
| 249 | "cold context {sid}/{label:?}" |
| 250 | ); |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | fn native_case(name: &str) { |
| 256 | let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 257 | .join("../../corpus/page-lifecycle/removal") |
| 258 | .join(name); |
| 259 | let request: serde_json::Value = |
| 260 | serde_json::from_slice(&std::fs::read(root.join("removal.json")).unwrap()).unwrap(); |
| 261 | let selected: BTreeSet<usize> = serde_json::from_value(request["selected"].clone()).unwrap(); |
| 262 | let bytes = ["native/before", "native/after", "cold"] |
| 263 | .map(|phase| std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap()); |
| 264 | let stores = bytes.each_ref().map(|bytes| Store::parse(bytes).unwrap()); |
| 265 | let indexes = stores.each_ref().map(|store| { |
| 266 | assert!(store.checksum_mismatches.is_empty()); |
| 267 | let index = RevisionIndex::parse(store).unwrap(); |
| 268 | index.validate_current().unwrap(); |
| 269 | index |
| 270 | }); |
| 271 | let documents = indexes |
| 272 | .each_ref() |
| 273 | .map(|index| Document::parse(index).unwrap()); |
| 274 | let before = documents[0].pages().unwrap(); |
| 275 | let retained: Vec<_> = before |
| 276 | .iter() |
| 277 | .enumerate() |
| 278 | .filter(|(index, _)| !selected.contains(index)) |
| 279 | .map(|(_, page)| *page) |
| 280 | .collect(); |
| 281 | assert_eq!(documents[1].pages().unwrap(), retained); |
| 282 | assert_eq!(documents[2].pages().unwrap(), retained); |
| 283 | let groups = documents.each_ref().map(|document| { |
| 284 | let space = &document.spaces[&document.root]; |
| 285 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 286 | revision.nodes[&revision.roots[&1]] |
| 287 | .children |
| 288 | .iter() |
| 289 | .map(|id| (*id, revision.nodes[id].spaces.clone())) |
| 290 | .collect::<Vec<_>>() |
| 291 | }); |
| 292 | let mut expected: Vec<(ExGuid, Vec<ExGuid>)> = Vec::new(); |
| 293 | for (sid, _) in &retained { |
| 294 | let space = &documents[0].spaces[sid]; |
| 295 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 296 | let onestore::document::Kind::Metadata { level, .. } = |
| 297 | revision.nodes[&revision.roots[&2]].kind |
| 298 | else { |
| 299 | panic!() |
| 300 | }; |
| 301 | if expected.is_empty() || level == Some(1) { |
| 302 | let (series, _) = groups[0] |
| 303 | .iter() |
| 304 | .find(|(_, pages)| pages.contains(sid)) |
| 305 | .unwrap(); |
| 306 | expected.push((*series, Vec::new())); |
| 307 | } |
| 308 | expected.last_mut().unwrap().1.push(*sid); |
| 309 | } |
| 310 | assert_eq!(groups[1], expected); |
| 311 | assert_eq!(groups[2], expected); |
| 312 | for (phase, index) in indexes[1..].iter().enumerate() { |
| 313 | assert_eq!( |
| 314 | indexes[0].spaces.keys().collect::<Vec<_>>(), |
| 315 | index.spaces.keys().collect::<Vec<_>>() |
| 316 | ); |
| 317 | let mut discarded = 0; |
| 318 | for (sid, space) in &indexes[0].spaces { |
| 319 | for rid in space.revisions.keys() { |
| 320 | if !index.spaces[sid].revisions.contains_key(rid) { |
| 321 | discarded += 1; |
| 322 | continue; |
| 323 | } |
| 324 | assert_eq!( |
| 325 | format!("{:?}", indexes[0].resolve(*sid, *rid).unwrap()), |
| 326 | format!("{:?}", index.resolve(*sid, *rid).unwrap()), |
| 327 | "historical revision {sid}/{rid}" |
| 328 | ); |
| 329 | } |
| 330 | for ((context, role), rid) in &space.labels { |
| 331 | if *context == ExGuid::default() |
| 332 | || selected.iter().any(|ordinal| before[*ordinal].0 == *sid) |
| 333 | { |
| 334 | continue; |
| 335 | } |
| 336 | let retained = index.spaces[sid].labels[&(*context, *role)]; |
| 337 | assert_eq!( |
| 338 | format!("{:?}", indexes[0].resolve(*sid, *rid).unwrap()), |
| 339 | format!("{:?}", index.resolve(*sid, retained).unwrap()), |
| 340 | "labeled history {sid}/{context}/{role}" |
| 341 | ); |
| 342 | } |
| 343 | } |
| 344 | assert_eq!( |
| 345 | discarded, |
| 346 | match name { |
| 347 | "ink" => 9, |
| 348 | "features" => 8, |
| 349 | _ => 0, |
| 350 | }, |
| 351 | "native compaction phase {phase}" |
| 352 | ); |
| 353 | } |
| 354 | compare_labels(&indexes[1], &indexes[2]); |
| 355 | let active = |side: usize, sid| { |
| 356 | indexes[side] |
| 357 | .resolve( |
| 358 | sid, |
| 359 | indexes[side].spaces[&sid].labels[&(ExGuid::default(), 1)], |
| 360 | ) |
| 361 | .unwrap() |
| 362 | }; |
| 363 | for (ordinal, (sid, _)) in before.iter().enumerate() { |
| 364 | let old = active(0, *sid); |
| 365 | let after = active(1, *sid); |
| 366 | assert_eq!(old.roots, after.roots); |
| 367 | if selected.contains(&ordinal) { |
| 368 | let ObjectData::Properties(manifest) = after.objects[&after.roots[&1]].data else { |
| 369 | panic!() |
| 370 | }; |
| 371 | assert!(PropertySets::parse(manifest).unwrap().sets[0].is_empty()); |
| 372 | let ObjectData::Properties(metadata) = after.objects[&after.roots[&2]].data else { |
| 373 | panic!() |
| 374 | }; |
| 375 | assert!( |
| 376 | PropertySets::parse(metadata).unwrap().sets[0] |
| 377 | .iter() |
| 378 | .any(|p| p.id == 0x88001de9) |
| 379 | ); |
| 380 | } else { |
| 381 | assert_eq!( |
| 382 | old.objects.keys().collect::<Vec<_>>(), |
| 383 | after.objects.keys().collect::<Vec<_>>() |
| 384 | ); |
| 385 | for (id, object) in &old.objects { |
| 386 | if *id != old.roots[&2] { |
| 387 | assert_eq!( |
| 388 | format!("{object:?}"), |
| 389 | format!("{:?}", after.objects[id]), |
| 390 | "survivor {sid}/{id}" |
| 391 | ); |
| 392 | } else { |
| 393 | let fields = [object, &after.objects[id]].map(|object| { |
| 394 | let ObjectData::Properties(bytes) = object.data else { |
| 395 | panic!() |
| 396 | }; |
| 397 | PropertySets::parse(bytes).unwrap().sets[0] |
| 398 | .iter() |
| 399 | .filter(|p| p.id != 0x14001dff) |
| 400 | .map(|p| (p.id, format!("{:?}", p.value))) |
| 401 | .collect::<BTreeMap<_, _>>() |
| 402 | }); |
| 403 | assert_eq!(fields[0], fields[1]); |
| 404 | use onestore::document::Kind; |
| 405 | let levels = [0, 1].map(|side| { |
| 406 | let space = &documents[side].spaces[sid]; |
| 407 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 408 | let Kind::Metadata { level, .. } = view.nodes[id].kind else { |
| 409 | panic!() |
| 410 | }; |
| 411 | level |
| 412 | }); |
| 413 | assert_eq!( |
| 414 | levels[1], |
| 415 | if retained.first().map(|p| p.0) == Some(*sid) { |
| 416 | Some(1) |
| 417 | } else { |
| 418 | levels[0] |
| 419 | } |
| 420 | ); |
| 421 | } |
| 422 | } |
| 423 | } |
| 424 | } |
| 425 | if request["permanent"].as_bool().unwrap() { |
| 426 | assert!( |
| 427 | !root |
| 428 | .join("native/after/notebook/OneNote_RecycleBin") |
| 429 | .exists() |
| 430 | ); |
| 431 | return; |
| 432 | } |
| 433 | let recycle_bytes = std::fs::read( |
| 434 | root.join("native/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one"), |
| 435 | ) |
| 436 | .unwrap(); |
| 437 | let recycle = Store::parse(&recycle_bytes).unwrap(); |
| 438 | let recycled_index = RevisionIndex::parse(&recycle).unwrap(); |
| 439 | recycled_index.validate_current().unwrap(); |
| 440 | let recycled_document = Document::parse(&recycled_index).unwrap(); |
| 441 | let cold_recycle_bytes = |
| 442 | std::fs::read(root.join("cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one")) |
| 443 | .unwrap(); |
| 444 | let cold_recycle = Store::parse(&cold_recycle_bytes).unwrap(); |
| 445 | let cold_index = RevisionIndex::parse(&cold_recycle).unwrap(); |
| 446 | cold_index.validate_current().unwrap(); |
| 447 | compare_labels(&recycled_index, &cold_index); |
| 448 | let pages = recycled_document.pages().unwrap(); |
| 449 | assert_eq!(pages.len(), selected.len()); |
| 450 | let identity = |index: &RevisionIndex<'_>, sid| { |
| 451 | let revision = index |
| 452 | .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)]) |
| 453 | .unwrap(); |
| 454 | let ObjectData::Properties(bytes) = revision.objects[&revision.roots[&2]].data else { |
| 455 | panic!() |
| 456 | }; |
| 457 | let sets = PropertySets::parse(bytes).unwrap(); |
| 458 | let Value::Bytes(guid) = sets.sets[0] |
| 459 | .iter() |
| 460 | .find(|p| p.id == 0x1c001c30) |
| 461 | .unwrap() |
| 462 | .value |
| 463 | else { |
| 464 | panic!() |
| 465 | }; |
| 466 | guid.to_vec() |
| 467 | }; |
| 468 | let mut copies = BTreeMap::new(); |
| 469 | for (sid, _) in pages { |
| 470 | assert!(copies.insert(identity(&recycled_index, sid), sid).is_none()); |
| 471 | } |
| 472 | for ordinal in selected { |
| 473 | let sid = before[ordinal].0; |
| 474 | let copied = copies.remove(&identity(&indexes[0], sid)).unwrap(); |
| 475 | compare_copy(&stores[0], &recycle, sid, copied); |
| 476 | compare_copy(&stores[0], &cold_recycle, sid, copied); |
| 477 | } |
| 478 | assert!(copies.is_empty()); |
| 479 | } |
| 480 | |
| 481 | macro_rules! native_cases { |
| 482 | ($($name:ident => $case:literal),* $(,)?) => {$( |
| 483 | #[test] |
| 484 | fn $name() { native_case($case); } |
| 485 | )*}; |
| 486 | } |
| 487 | |
| 488 | native_cases! { |
| 489 | trailing => "trailing", parent => "parent", child => "child", grandchild => "grandchild", |
| 490 | group => "group", all => "all", permanent_parent => "permanent-parent", |
| 491 | leading_parent => "leading-parent", leading_group => "leading-group", |
| 492 | features => "features", ink => "ink", |
| 493 | } |
| 494 | |
| 495 | #[test] |
| 496 | fn copy_comparison_rejects_text_and_payload_changes() { |
| 497 | let original = include_bytes!( |
| 498 | "../../../corpus/page-lifecycle/removal/ink/native/before/notebook/Lifecycle.one" |
| 499 | ); |
| 500 | let copied = include_bytes!( |
| 501 | "../../../corpus/page-lifecycle/removal/ink/native/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one" |
| 502 | ); |
| 503 | let stores = [original.as_slice(), copied.as_slice()].map(|bytes| Store::parse(bytes).unwrap()); |
| 504 | let indexes = stores |
| 505 | .each_ref() |
| 506 | .map(|store| RevisionIndex::parse(store).unwrap()); |
| 507 | let documents = indexes |
| 508 | .each_ref() |
| 509 | .map(|index| Document::parse(index).unwrap()); |
| 510 | let [source_sid, copy_sid] = documents |
| 511 | .each_ref() |
| 512 | .map(|document| document.pages().unwrap()[0].0); |
| 513 | compare_copy(&stores[0], &stores[1], source_sid, copy_sid); |
| 514 | let space = &documents[1].spaces[&copy_sid]; |
| 515 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 516 | let text = revision |
| 517 | .nodes |
| 518 | .iter() |
| 519 | .find_map(|(oid, node)| { |
| 520 | matches!( |
| 521 | node.kind, |
| 522 | onestore::document::Kind::RichText { |
| 523 | boilerplate: false, |
| 524 | .. |
| 525 | } |
| 526 | ) |
| 527 | .then_some(*oid) |
| 528 | }) |
| 529 | .unwrap(); |
| 530 | let changed = ops::page_edited( |
| 531 | copied, |
| 532 | copy_sid, |
| 533 | vec![onestore::op::PageOp::Text { |
| 534 | text, |
| 535 | range: 0..0, |
| 536 | with: "Changed ".into(), |
| 537 | }], |
| 538 | ) |
| 539 | .unwrap(); |
| 540 | let changed_store = Store::parse(&changed).unwrap(); |
| 541 | RevisionIndex::parse(&changed_store) |
| 542 | .unwrap() |
| 543 | .validate_current() |
| 544 | .unwrap(); |
| 545 | assert!( |
| 546 | std::panic::catch_unwind(|| compare_copy(&stores[0], &changed_store, source_sid, copy_sid)) |
| 547 | .is_err() |
| 548 | ); |
| 549 | |
| 550 | let raw = indexes[1] |
| 551 | .resolve( |
| 552 | copy_sid, |
| 553 | indexes[1].spaces[&copy_sid].labels[&(ExGuid::default(), 1)], |
| 554 | ) |
| 555 | .unwrap(); |
| 556 | let attachment = revision |
| 557 | .nodes |
| 558 | .values() |
| 559 | .find_map(|node| match node.kind { |
| 560 | onestore::document::Kind::Attachment { container, .. } => container, |
| 561 | _ => None, |
| 562 | }) |
| 563 | .unwrap(); |
| 564 | let Some(FileDataReference::Internal(guid)) = |
| 565 | raw.objects[&attachment].file_reference().unwrap() |
| 566 | else { |
| 567 | panic!() |
| 568 | }; |
| 569 | let payload = stores[1].file_data(guid).unwrap(); |
| 570 | assert!(!payload.is_empty()); |
| 571 | let offset = payload.as_ptr().addr() - copied.as_ptr().addr(); |
| 572 | let mut changed = copied.to_vec(); |
| 573 | changed[offset] ^= 1; |
| 574 | let changed_store = Store::parse(&changed).unwrap(); |
| 575 | assert!(changed_store.checksum_mismatches.is_empty()); |
| 576 | RevisionIndex::parse(&changed_store) |
| 577 | .unwrap() |
| 578 | .validate_current() |
| 579 | .unwrap(); |
| 580 | assert_ne!(changed_store.file_data(guid).unwrap(), payload); |
| 581 | assert!( |
| 582 | std::panic::catch_unwind(|| compare_copy(&stores[0], &changed_store, source_sid, copy_sid)) |
| 583 | .is_err() |
| 584 | ); |
| 585 | } |