From 550d71c9d8ee053ba3bf16b0269e6bdac3352136 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 10 Oct 2026 20:23:52 -0700 Subject: [PATCH] test: retain attribution invariants across writer comparisons 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-sol --- crates/onestore/tests/outline.rs | 36 ++++++++++++++++++++++++--- crates/onestore/tests/page_writer.rs | 37 ++++++++++++++++++++++++---- crates/onestore/tests/tree.rs | 26 ++++++++++++------- crates/onestore/tests/writer.rs | 13 ++++++++-- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs index 0465495fa25646184eae83ef951a62d33e35c4fc..090ad79575b62bb00504d0fa9899d4e79338c23f 100644 --- a/crates/onestore/tests/outline.rs +++ b/crates/onestore/tests/outline.rs @@ -9,6 +9,8 @@ use std::collections::BTreeSet; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; #[path = "support/sweep.rs"] @@ -67,6 +69,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { for (name, sid, object, edit) in cases(&document) { let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap(); assert_eq!(edit, restored); + let started = ops::now(); let prepared = ops::page_op( SOURCE, sid, @@ -76,6 +79,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { }, ) .unwrap(); + let finished = ops::now(); let after_store = Store::parse(prepared.as_bytes()).unwrap(); assert!(after_store.checksum_mismatches.is_empty()); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -100,15 +104,33 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { .then_some(*parent) })); } - assert_eq!( - before.nodes.keys().collect::>(), - after.nodes.keys().collect::>() + let old_raw = index.resolve_active(sid).unwrap(); + let new_raw = after_index.resolve_active(sid).unwrap(); + let at = metadata::check(&old_raw, &new_raw, "Author"); + assert!((started..=finished).contains(&at)); + let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]); + assert!( + after + .nodes + .keys() + .all(|id| before.nodes.contains_key(id) || *id == after.roots[&4] || *id == author) ); for (id, node) in &before.nodes { + if Some(id) == before.roots.get(&4) { + continue; + } let mut expected = serde_json::to_value(node).unwrap(); let actual = serde_json::to_value(&after.nodes[id]).unwrap(); if changed.contains(id) { expected["modified"] = actual["modified"].clone(); + if matches!(node.kind, Kind::Paragraph { .. }) { + assert_eq!(after.nodes[id].latest_author, Some(author)); + assert_eq!( + after.nodes[id].modified.map(u64::from), + Some(at / 10_000_000 - 11_960_006_400) + ); + expected["latest_author"] = actual["latest_author"].clone(); + } } if *id == object { match edit { @@ -344,6 +366,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content (144.0, 36.0, "First"), (0.0, 144.0, "First"), ] { + let started = ops::now(); let edit = ops::page_op( &source, sid, @@ -353,12 +376,17 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content }, ) .unwrap(); + let finished = ops::now(); let saved = Store::parse(edit.as_bytes()).unwrap(); let saved_index = RevisionIndex::parse(&saved).unwrap(); saved_index.validate_current().unwrap(); let saved_document = Document::parse(&saved_index).unwrap(); let saved_space = &saved_document.spaces[&sid]; let after = &saved_space.revisions[&saved_space.contexts[&ExGuid::default()]]; + let old_raw = index.resolve_active(sid).unwrap(); + let new_raw = saved_index.resolve_active(sid).unwrap(); + let at = metadata::check(&old_raw, &new_raw, "Author"); + assert!((started..=finished).contains(&at)); let metadata = before.roots[&2]; assert!( 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 after.nodes.keys().collect::>() ); for (oid, node) in &before.nodes { - if ![page, metadata, insertion].contains(oid) { + if ![page, metadata, insertion, before.roots[&4]].contains(oid) { assert_eq!( serde_json::to_value(node).unwrap(), serde_json::to_value(&after.nodes[oid]).unwrap() diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs index 0903175360eb0c8ec361012f840f569613869e9d..b6b8e2f8dd0e753f673e7b8d5d266bdbc3cc2b7b 100644 --- a/crates/onestore/tests/page_writer.rs +++ b/crates/onestore/tests/page_writer.rs @@ -1,6 +1,8 @@ //! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied //! one by one publish, in one transaction, and must round-trip the model it was given. +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; @@ -164,7 +166,7 @@ fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid } /// Every active revision the ops produced one by one is reproduced by the page lowered whole, -/// ignoring writer-allocated immutable styles and author records. Modification times are +/// ignoring writer-allocated immutable styles and author records. Paragraph modification times are /// not compared: a reorder can be attributed to either sibling, and the model cannot say which. fn equivalent(reference: &[u8], written: &[u8]) { let store_a = Store::parse(reference).unwrap(); @@ -201,11 +203,29 @@ fn equivalent(reference: &[u8], written: &[u8]) { } } -/// Lowers the page the ops produced whole and checks both contracts. +/// Both lowerings use the last op's FILETIME so revision metadata compares exactly. fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec { let after = page_in(reference, space); - let prepared = ops::saved(source, space, &after).unwrap(); - let written = prepared.as_slice().to_vec(); + let store = Store::parse(reference).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let revision = index.resolve_active(space).unwrap(); + let source_store = Store::parse(source).unwrap(); + let source_index = RevisionIndex::parse(&source_store).unwrap(); + let original = source_index.resolve_active(space).unwrap(); + let at = metadata::check(&original, &revision, AUTHOR); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); + let before = section.page(space).unwrap(); + let ops = onestore::op::lower_page(&before, &after) + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(); + section + .apply(AUTHOR, &onestore::op::Edit { at, ops }) + .unwrap(); + section.seal().unwrap().unwrap(); + let written = section.image(); assert_eq!(page_in(&written, space), after, "model round trip"); equivalent(reference, &written); 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() { ); outline.paragraphs.push(paragraph); after.objects.push(PageObject::Outline(outline)); + let started = ops::now(); let written = ops::saved(TREES, space, &after).unwrap(); + let finished = ops::now(); let stored = page_in(written.as_slice(), space); let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> { body(page) @@ -616,6 +638,8 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { let store = Store::parse(written.as_slice()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let raw_after = index.resolve_active(space).unwrap(); + let at = metadata::check(&raw_before, &raw_after, AUTHOR); + assert!((started..=finished).contains(&at)); let touched: BTreeSet = body(&after) .iter() .flat_map(|o| { @@ -629,7 +653,10 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { .collect(); let mut untouched = 0; for (id, object) in &raw_before.objects { - if touched.contains(id) || matches!(object.jcid, 0x12004d | 0x120001) { + if touched.contains(id) + || Some(id) == raw_before.roots.get(&4) + || matches!(object.jcid, 0x12004d | 0x120001) + { continue; } let after = &raw_after.objects[id]; diff --git a/crates/onestore/tests/tree.rs b/crates/onestore/tests/tree.rs index 72e44ae7b1870a0cf63cf61dd07bea66bc512932..0969dfff67dd726624d1a7a2559bba8cb04d5324 100644 --- a/crates/onestore/tests/tree.rs +++ b/crates/onestore/tests/tree.rs @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet}; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; #[path = "support/sweep.rs"] @@ -191,8 +193,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { .into_iter() .map(|op| Op::Page { space: sid, op }) .collect(); + let started = ops::now(); let prepared = ops::apply(source, "Tree author", edit.clone()) .unwrap_or_else(|error| panic!("{name}: {error}")); + let finished = ops::now(); let after_store = Store::parse(prepared.as_bytes()).unwrap(); assert!(after_store.checksum_mismatches.is_empty()); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -200,6 +204,11 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { let after_document = Document::parse(&after_index).unwrap(); let space = &after_document.spaces[&sid]; let after = &space.revisions[&space.contexts[&ExGuid::default()]]; + let old_raw = index.resolve_active(sid).unwrap(); + let new_raw = after_index.resolve_active(sid).unwrap(); + let at = metadata::check(&old_raw, &new_raw, "Tree author"); + assert!((started..=finished).contains(&at)); + let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]); let space = &native_document.spaces[&sid]; let reference = &space.revisions[&space.contexts[&ExGuid::default()]]; let old_ids = active(before, page); @@ -284,15 +293,14 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { { assert!(node.modified >= old.modified); expected["modified"] = actual["modified"].clone(); - } - if *id == object - && !name.starts_with("Delete") - && matches!(node.kind, Kind::Paragraph { .. }) - { - expected["latest_author"] = actual["latest_author"].clone(); - assert!( - matches!(&after.nodes[&node.latest_author.unwrap()].kind,Kind::Author{name:Some(name)} if name=="Tree author") - ); + if matches!(node.kind, Kind::Paragraph { .. }) { + assert_eq!(node.latest_author, Some(author)); + assert_eq!( + node.modified.map(u64::from), + Some(at / 10_000_000 - 11_960_006_400) + ); + expected["latest_author"] = actual["latest_author"].clone(); + } } for value in [&mut expected, &mut actual] { value["extra"][0] diff --git a/crates/onestore/tests/writer.rs b/crates/onestore/tests/writer.rs index b515b41859e354db80e52b00cd52eb13f01ba21e..55e1eb046610ab934803a76130071590333fb86e 100644 --- a/crates/onestore/tests/writer.rs +++ b/crates/onestore/tests/writer.rs @@ -1,3 +1,5 @@ +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; @@ -390,6 +392,7 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { let store = Store::parse(&bytes).unwrap(); let before = RevisionIndex::parse(&store).unwrap(); let rid = before.spaces[&sid].labels[&(ExGuid::default(), 1)]; + let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000; let written = if section { let typed = |range, with: &str| Op::Page { space: sid, @@ -400,7 +403,6 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { }, }; // Modification times count seconds: each edit is a second later. - let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000; let ops = vec![typed(0..0, "x"), typed(0..1, "")]; let arena = onestore::Arena::default(); let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap(); @@ -428,6 +430,9 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { assert_preserved_committed_bytes(&bytes, &written); let old = before.resolve(sid, rid).unwrap(); let current = after.resolve(sid, next).unwrap(); + if section { + assert_eq!(metadata::check(&old, ¤t, "Author"), at); + } assert_eq!(old.roots, current.roots); assert_eq!(old.objects.len(), current.objects.len()); for (id, object) in &old.objects { @@ -456,7 +461,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { assert_eq!(a.len(), b.len()); for (a, b) in a.iter().zip(b) { assert_eq!(a.id, b.id); - if a.id != property && !matches!(a.value, Value::References { .. }) + if a.id != property + && !(section + && Some(id) == old.roots.get(&4) + && a.id == 0x18001d77) + && !matches!(a.value, Value::References { .. }) { assert_eq!(a.value, b.value, "{id} {:#x}", a.id); } -- 2.54.0