| author | |
| committer | |
| log | 550d71c9d8ee053ba3bf16b0269e6bdac3352136 |
| tree | 00fe54abd2b48de1005ee20753f5d3325f078998 |
| parent | 3bc26d2f339e409b94776447c6d0cd661d5da6ff |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
Use the same final edit FILETIME for equivalent page lowerings, keeping revision metadata equality exact. Check timestamp and author separately at writer checkpoints and outline/tree edits while preserving untouched fields, identities and historical bytes.
Assisted-by: gpt-6.1-sol4 files changed, 92 insertions(+), 20 deletions(-)
crates/onestore/tests/outline.rs+32-4| ... | ... | @@ -9,6 +9,8 @@ use std::collections::BTreeSet; |
| 9 | 9 | mod current; |
| 10 | 10 | #[path = "support/disk.rs"] |
| 11 | 11 | mod disk; |
| 12 | #[path = "support/metadata.rs"] | |
| 13 | mod metadata; | |
| 12 | 14 | #[path = "support/ops.rs"] |
| 13 | 15 | mod ops; |
| 14 | 16 | #[path = "support/sweep.rs"] |
| ... | ... | @@ -67,6 +69,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { |
| 67 | 69 | for (name, sid, object, edit) in cases(&document) { |
| 68 | 70 | let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap(); |
| 69 | 71 | assert_eq!(edit, restored); |
| 72 | let started = ops::now(); | |
| 70 | 73 | let prepared = ops::page_op( |
| 71 | 74 | SOURCE, |
| 72 | 75 | sid, |
| ... | ... | @@ -76,6 +79,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { |
| 76 | 79 | }, |
| 77 | 80 | ) |
| 78 | 81 | .unwrap(); |
| 82 | let finished = ops::now(); | |
| 79 | 83 | let after_store = Store::parse(prepared.as_bytes()).unwrap(); |
| 80 | 84 | assert!(after_store.checksum_mismatches.is_empty()); |
| 81 | 85 | let after_index = RevisionIndex::parse(&after_store).unwrap(); |
| ... | ... | @@ -100,15 +104,33 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { |
| 100 | 104 | .then_some(*parent) |
| 101 | 105 | })); |
| 102 | 106 | } |
| 103 | assert_eq!( | |
| 104 | before.nodes.keys().collect::<Vec<_>>(), | |
| 105 | after.nodes.keys().collect::<Vec<_>>() | |
| 107 | let old_raw = index.resolve_active(sid).unwrap(); | |
| 108 | let new_raw = after_index.resolve_active(sid).unwrap(); | |
| 109 | let at = metadata::check(&old_raw, &new_raw, "Author"); | |
| 110 | assert!((started..=finished).contains(&at)); | |
| 111 | let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]); | |
| 112 | assert!( | |
| 113 | after | |
| 114 | .nodes | |
| 115 | .keys() | |
| 116 | .all(|id| before.nodes.contains_key(id) || *id == after.roots[&4] || *id == author) | |
| 106 | 117 | ); |
| 107 | 118 | for (id, node) in &before.nodes { |
| 119 | if Some(id) == before.roots.get(&4) { | |
| 120 | continue; | |
| 121 | } | |
| 108 | 122 | let mut expected = serde_json::to_value(node).unwrap(); |
| 109 | 123 | let actual = serde_json::to_value(&after.nodes[id]).unwrap(); |
| 110 | 124 | if changed.contains(id) { |
| 111 | 125 | expected["modified"] = actual["modified"].clone(); |
| 126 | if matches!(node.kind, Kind::Paragraph { .. }) { | |
| 127 | assert_eq!(after.nodes[id].latest_author, Some(author)); | |
| 128 | assert_eq!( | |
| 129 | after.nodes[id].modified.map(u64::from), | |
| 130 | Some(at / 10_000_000 - 11_960_006_400) | |
| 131 | ); | |
| 132 | expected["latest_author"] = actual["latest_author"].clone(); | |
| 133 | } | |
| 112 | 134 | } |
| 113 | 135 | if *id == object { |
| 114 | 136 | match edit { |
| ... | ... | @@ -344,6 +366,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content |
| 344 | 366 | (144.0, 36.0, "First"), |
| 345 | 367 | (0.0, 144.0, "First"), |
| 346 | 368 | ] { |
| 369 | let started = ops::now(); | |
| 347 | 370 | let edit = ops::page_op( |
| 348 | 371 | &source, |
| 349 | 372 | sid, |
| ... | ... | @@ -353,12 +376,17 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content |
| 353 | 376 | }, |
| 354 | 377 | ) |
| 355 | 378 | .unwrap(); |
| 379 | let finished = ops::now(); | |
| 356 | 380 | let saved = Store::parse(edit.as_bytes()).unwrap(); |
| 357 | 381 | let saved_index = RevisionIndex::parse(&saved).unwrap(); |
| 358 | 382 | saved_index.validate_current().unwrap(); |
| 359 | 383 | let saved_document = Document::parse(&saved_index).unwrap(); |
| 360 | 384 | let saved_space = &saved_document.spaces[&sid]; |
| 361 | 385 | let after = &saved_space.revisions[&saved_space.contexts[&ExGuid::default()]]; |
| 386 | let old_raw = index.resolve_active(sid).unwrap(); | |
| 387 | let new_raw = saved_index.resolve_active(sid).unwrap(); | |
| 388 | let at = metadata::check(&old_raw, &new_raw, "Author"); | |
| 389 | assert!((started..=finished).contains(&at)); | |
| 362 | 390 | let metadata = before.roots[&2]; |
| 363 | 391 | assert!( |
| 364 | 392 | matches!(&after.nodes[&metadata].kind, Kind::Metadata { title: Some(title), .. } if title == expected) |
| ... | ... | @@ -371,7 +399,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content |
| 371 | 399 | after.nodes.keys().collect::<Vec<_>>() |
| 372 | 400 | ); |
| 373 | 401 | for (oid, node) in &before.nodes { |
| 374 | if ![page, metadata, insertion].contains(oid) { | |
| 402 | if ![page, metadata, insertion, before.roots[&4]].contains(oid) { | |
| 375 | 403 | assert_eq!( |
| 376 | 404 | serde_json::to_value(node).unwrap(), |
| 377 | 405 | serde_json::to_value(&after.nodes[oid]).unwrap() |
crates/onestore/tests/page_writer.rs+32-5| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | //! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied |
| 2 | 2 | //! one by one publish, in one transaction, and must round-trip the model it was given. |
| 3 | 3 | |
| 4 | #[path = "support/metadata.rs"] | |
| 5 | mod metadata; | |
| 4 | 6 | #[path = "support/ops.rs"] |
| 5 | 7 | mod ops; |
| 6 | 8 | |
| ... | ... | @@ -164,7 +166,7 @@ fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid |
| 164 | 166 | } |
| 165 | 167 | |
| 166 | 168 | /// Every active revision the ops produced one by one is reproduced by the page lowered whole, |
| 167 | /// ignoring writer-allocated immutable styles and author records. Modification times are | |
| 169 | /// ignoring writer-allocated immutable styles and author records. Paragraph modification times are | |
| 168 | 170 | /// not compared: a reorder can be attributed to either sibling, and the model cannot say which. |
| 169 | 171 | fn equivalent(reference: &[u8], written: &[u8]) { |
| 170 | 172 | let store_a = Store::parse(reference).unwrap(); |
| ... | ... | @@ -201,11 +203,29 @@ fn equivalent(reference: &[u8], written: &[u8]) { |
| 201 | 203 | } |
| 202 | 204 | } |
| 203 | 205 | |
| 204 | /// Lowers the page the ops produced whole and checks both contracts. | |
| 206 | /// Both lowerings use the last op's FILETIME so revision metadata compares exactly. | |
| 205 | 207 | fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec<u8> { |
| 206 | 208 | let after = page_in(reference, space); |
| 207 | let prepared = ops::saved(source, space, &after).unwrap(); | |
| 208 | let written = prepared.as_slice().to_vec(); | |
| 209 | let store = Store::parse(reference).unwrap(); | |
| 210 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 211 | let revision = index.resolve_active(space).unwrap(); | |
| 212 | let source_store = Store::parse(source).unwrap(); | |
| 213 | let source_index = RevisionIndex::parse(&source_store).unwrap(); | |
| 214 | let original = source_index.resolve_active(space).unwrap(); | |
| 215 | let at = metadata::check(&original, &revision, AUTHOR); | |
| 216 | let arena = onestore::Arena::default(); | |
| 217 | let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); | |
| 218 | let before = section.page(space).unwrap(); | |
| 219 | let ops = onestore::op::lower_page(&before, &after) | |
| 220 | .unwrap() | |
| 221 | .into_iter() | |
| 222 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 223 | .collect(); | |
| 224 | section | |
| 225 | .apply(AUTHOR, &onestore::op::Edit { at, ops }) | |
| 226 | .unwrap(); | |
| 227 | section.seal().unwrap().unwrap(); | |
| 228 | let written = section.image(); | |
| 209 | 229 | assert_eq!(page_in(&written, space), after, "model round trip"); |
| 210 | 230 | equivalent(reference, &written); |
| 211 | 231 | let transactions = |bytes: &[u8]| Store::parse(bytes).unwrap().header.transaction_count; |
| ... | ... | @@ -583,7 +603,9 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { |
| 583 | 603 | ); |
| 584 | 604 | outline.paragraphs.push(paragraph); |
| 585 | 605 | after.objects.push(PageObject::Outline(outline)); |
| 606 | let started = ops::now(); | |
| 586 | 607 | let written = ops::saved(TREES, space, &after).unwrap(); |
| 608 | let finished = ops::now(); | |
| 587 | 609 | let stored = page_in(written.as_slice(), space); |
| 588 | 610 | let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> { |
| 589 | 611 | body(page) |
| ... | ... | @@ -616,6 +638,8 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { |
| 616 | 638 | let store = Store::parse(written.as_slice()).unwrap(); |
| 617 | 639 | let index = RevisionIndex::parse(&store).unwrap(); |
| 618 | 640 | let raw_after = index.resolve_active(space).unwrap(); |
| 641 | let at = metadata::check(&raw_before, &raw_after, AUTHOR); | |
| 642 | assert!((started..=finished).contains(&at)); | |
| 619 | 643 | let touched: BTreeSet<ExGuid> = body(&after) |
| 620 | 644 | .iter() |
| 621 | 645 | .flat_map(|o| { |
| ... | ... | @@ -629,7 +653,10 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { |
| 629 | 653 | .collect(); |
| 630 | 654 | let mut untouched = 0; |
| 631 | 655 | for (id, object) in &raw_before.objects { |
| 632 | if touched.contains(id) || matches!(object.jcid, 0x12004d | 0x120001) { | |
| 656 | if touched.contains(id) | |
| 657 | || Some(id) == raw_before.roots.get(&4) | |
| 658 | || matches!(object.jcid, 0x12004d | 0x120001) | |
| 659 | { | |
| 633 | 660 | continue; |
| 634 | 661 | } |
| 635 | 662 | let after = &raw_after.objects[id]; |
crates/onestore/tests/tree.rs+17-9| ... | ... | @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet}; |
| 9 | 9 | mod current; |
| 10 | 10 | #[path = "support/disk.rs"] |
| 11 | 11 | mod disk; |
| 12 | #[path = "support/metadata.rs"] | |
| 13 | mod metadata; | |
| 12 | 14 | #[path = "support/ops.rs"] |
| 13 | 15 | mod ops; |
| 14 | 16 | #[path = "support/sweep.rs"] |
| ... | ... | @@ -191,8 +193,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { |
| 191 | 193 | .into_iter() |
| 192 | 194 | .map(|op| Op::Page { space: sid, op }) |
| 193 | 195 | .collect(); |
| 196 | let started = ops::now(); | |
| 194 | 197 | let prepared = ops::apply(source, "Tree author", edit.clone()) |
| 195 | 198 | .unwrap_or_else(|error| panic!("{name}: {error}")); |
| 199 | let finished = ops::now(); | |
| 196 | 200 | let after_store = Store::parse(prepared.as_bytes()).unwrap(); |
| 197 | 201 | assert!(after_store.checksum_mismatches.is_empty()); |
| 198 | 202 | let after_index = RevisionIndex::parse(&after_store).unwrap(); |
| ... | ... | @@ -200,6 +204,11 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { |
| 200 | 204 | let after_document = Document::parse(&after_index).unwrap(); |
| 201 | 205 | let space = &after_document.spaces[&sid]; |
| 202 | 206 | let after = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 207 | let old_raw = index.resolve_active(sid).unwrap(); | |
| 208 | let new_raw = after_index.resolve_active(sid).unwrap(); | |
| 209 | let at = metadata::check(&old_raw, &new_raw, "Tree author"); | |
| 210 | assert!((started..=finished).contains(&at)); | |
| 211 | let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]); | |
| 203 | 212 | let space = &native_document.spaces[&sid]; |
| 204 | 213 | let reference = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 205 | 214 | let old_ids = active(before, page); |
| ... | ... | @@ -284,15 +293,14 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { |
| 284 | 293 | { |
| 285 | 294 | assert!(node.modified >= old.modified); |
| 286 | 295 | expected["modified"] = actual["modified"].clone(); |
| 287 | } | |
| 288 | if *id == object | |
| 289 | && !name.starts_with("Delete") | |
| 290 | && matches!(node.kind, Kind::Paragraph { .. }) | |
| 291 | { | |
| 292 | expected["latest_author"] = actual["latest_author"].clone(); | |
| 293 | assert!( | |
| 294 | matches!(&after.nodes[&node.latest_author.unwrap()].kind,Kind::Author{name:Some(name)} if name=="Tree author") | |
| 295 | ); | |
| 296 | if matches!(node.kind, Kind::Paragraph { .. }) { | |
| 297 | assert_eq!(node.latest_author, Some(author)); | |
| 298 | assert_eq!( | |
| 299 | node.modified.map(u64::from), | |
| 300 | Some(at / 10_000_000 - 11_960_006_400) | |
| 301 | ); | |
| 302 | expected["latest_author"] = actual["latest_author"].clone(); | |
| 303 | } | |
| 296 | 304 | } |
| 297 | 305 | for value in [&mut expected, &mut actual] { |
| 298 | 306 | value["extra"][0] |
crates/onestore/tests/writer.rs+11-2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | #[path = "support/metadata.rs"] | |
| 2 | mod metadata; | |
| 1 | 3 | #[path = "support/ops.rs"] |
| 2 | 4 | mod ops; |
| 3 | 5 | |
| ... | ... | @@ -390,6 +392,7 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { |
| 390 | 392 | let store = Store::parse(&bytes).unwrap(); |
| 391 | 393 | let before = RevisionIndex::parse(&store).unwrap(); |
| 392 | 394 | let rid = before.spaces[&sid].labels[&(ExGuid::default(), 1)]; |
| 395 | let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000; | |
| 393 | 396 | let written = if section { |
| 394 | 397 | let typed = |range, with: &str| Op::Page { |
| 395 | 398 | space: sid, |
| ... | ... | @@ -400,7 +403,6 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { |
| 400 | 403 | }, |
| 401 | 404 | }; |
| 402 | 405 | // Modification times count seconds: each edit is a second later. |
| 403 | let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000; | |
| 404 | 406 | let ops = vec![typed(0..0, "x"), typed(0..1, "")]; |
| 405 | 407 | let arena = onestore::Arena::default(); |
| 406 | 408 | let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap(); |
| ... | ... | @@ -428,6 +430,9 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { |
| 428 | 430 | assert_preserved_committed_bytes(&bytes, &written); |
| 429 | 431 | let old = before.resolve(sid, rid).unwrap(); |
| 430 | 432 | let current = after.resolve(sid, next).unwrap(); |
| 433 | if section { | |
| 434 | assert_eq!(metadata::check(&old, &current, "Author"), at); | |
| 435 | } | |
| 431 | 436 | assert_eq!(old.roots, current.roots); |
| 432 | 437 | assert_eq!(old.objects.len(), current.objects.len()); |
| 433 | 438 | for (id, object) in &old.objects { |
| ... | ... | @@ -456,7 +461,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { |
| 456 | 461 | assert_eq!(a.len(), b.len()); |
| 457 | 462 | for (a, b) in a.iter().zip(b) { |
| 458 | 463 | assert_eq!(a.id, b.id); |
| 459 | if a.id != property && !matches!(a.value, Value::References { .. }) | |
| 464 | if a.id != property | |
| 465 | && !(section | |
| 466 | && Some(id) == old.roots.get(&4) | |
| 467 | && a.id == 0x18001d77) | |
| 468 | && !matches!(a.value, Value::References { .. }) | |
| 460 | 469 | { |
| 461 | 470 | assert_eq!(a.value, b.value, "{id} {:#x}", a.id); |
| 462 | 471 | } |