authorclover caruso <git@paperclover.net> 2026-10-10 20:23:52-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log550d71c9d8ee053ba3bf16b0269e6bdac3352136
tree00fe54abd2b48de1005ee20753f5d3325f078998
parent3bc26d2f339e409b94776447c6d0cd661d5da6ff
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

4 files changed, 92 insertions(+), 20 deletions(-)

crates/onestore/tests/outline.rs+32-4
...@@ -9,6 +9,8 @@ use std::collections::BTreeSet;...@@ -9,6 +9,8 @@ use std::collections::BTreeSet;
9mod current;9mod current;
10#[path = "support/disk.rs"]10#[path = "support/disk.rs"]
11mod disk;11mod disk;
12#[path = "support/metadata.rs"]
13mod metadata;
12#[path = "support/ops.rs"]14#[path = "support/ops.rs"]
13mod ops;15mod ops;
14#[path = "support/sweep.rs"]16#[path = "support/sweep.rs"]
...@@ -67,6 +69,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {...@@ -67,6 +69,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
67 for (name, sid, object, edit) in cases(&document) {69 for (name, sid, object, edit) in cases(&document) {
68 let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap();70 let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap();
69 assert_eq!(edit, restored);71 assert_eq!(edit, restored);
72 let started = ops::now();
70 let prepared = ops::page_op(73 let prepared = ops::page_op(
71 SOURCE,74 SOURCE,
72 sid,75 sid,
...@@ -76,6 +79,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {...@@ -76,6 +79,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
76 },79 },
77 )80 )
78 .unwrap();81 .unwrap();
82 let finished = ops::now();
79 let after_store = Store::parse(prepared.as_bytes()).unwrap();83 let after_store = Store::parse(prepared.as_bytes()).unwrap();
80 assert!(after_store.checksum_mismatches.is_empty());84 assert!(after_store.checksum_mismatches.is_empty());
81 let after_index = RevisionIndex::parse(&after_store).unwrap();85 let after_index = RevisionIndex::parse(&after_store).unwrap();
...@@ -100,15 +104,33 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {...@@ -100,15 +104,33 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
100 .then_some(*parent)104 .then_some(*parent)
101 }));105 }));
102 }106 }
103 assert_eq!(107 let old_raw = index.resolve_active(sid).unwrap();
104 before.nodes.keys().collect::<Vec<_>>(),108 let new_raw = after_index.resolve_active(sid).unwrap();
105 after.nodes.keys().collect::<Vec<_>>()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 for (id, node) in &before.nodes {118 for (id, node) in &before.nodes {
119 if Some(id) == before.roots.get(&4) {
120 continue;
121 }
108 let mut expected = serde_json::to_value(node).unwrap();122 let mut expected = serde_json::to_value(node).unwrap();
109 let actual = serde_json::to_value(&after.nodes[id]).unwrap();123 let actual = serde_json::to_value(&after.nodes[id]).unwrap();
110 if changed.contains(id) {124 if changed.contains(id) {
111 expected["modified"] = actual["modified"].clone();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 if *id == object {135 if *id == object {
114 match edit {136 match edit {
...@@ -344,6 +366,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content...@@ -344,6 +366,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
344 (144.0, 36.0, "First"),366 (144.0, 36.0, "First"),
345 (0.0, 144.0, "First"),367 (0.0, 144.0, "First"),
346 ] {368 ] {
369 let started = ops::now();
347 let edit = ops::page_op(370 let edit = ops::page_op(
348 &source,371 &source,
349 sid,372 sid,
...@@ -353,12 +376,17 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content...@@ -353,12 +376,17 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
353 },376 },
354 )377 )
355 .unwrap();378 .unwrap();
379 let finished = ops::now();
356 let saved = Store::parse(edit.as_bytes()).unwrap();380 let saved = Store::parse(edit.as_bytes()).unwrap();
357 let saved_index = RevisionIndex::parse(&saved).unwrap();381 let saved_index = RevisionIndex::parse(&saved).unwrap();
358 saved_index.validate_current().unwrap();382 saved_index.validate_current().unwrap();
359 let saved_document = Document::parse(&saved_index).unwrap();383 let saved_document = Document::parse(&saved_index).unwrap();
360 let saved_space = &saved_document.spaces[&sid];384 let saved_space = &saved_document.spaces[&sid];
361 let after = &saved_space.revisions[&saved_space.contexts[&ExGuid::default()]];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 let metadata = before.roots[&2];390 let metadata = before.roots[&2];
363 assert!(391 assert!(
364 matches!(&after.nodes[&metadata].kind, Kind::Metadata { title: Some(title), .. } if title == expected)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,7 +399,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
371 after.nodes.keys().collect::<Vec<_>>()399 after.nodes.keys().collect::<Vec<_>>()
372 );400 );
373 for (oid, node) in &before.nodes {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 assert_eq!(403 assert_eq!(
376 serde_json::to_value(node).unwrap(),404 serde_json::to_value(node).unwrap(),
377 serde_json::to_value(&after.nodes[oid]).unwrap()405 serde_json::to_value(&after.nodes[oid]).unwrap()
crates/onestore/tests/page_writer.rs+32-5
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1//! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied1//! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied
2//! one by one publish, in one transaction, and must round-trip the model it was given.2//! one by one publish, in one transaction, and must round-trip the model it was given.
33
4#[path = "support/metadata.rs"]
5mod metadata;
4#[path = "support/ops.rs"]6#[path = "support/ops.rs"]
5mod ops;7mod ops;
68
...@@ -164,7 +166,7 @@ fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid...@@ -164,7 +166,7 @@ fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid
164}166}
165167
166/// Every active revision the ops produced one by one is reproduced by the page lowered whole,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 are169/// ignoring writer-allocated immutable styles and author records. Paragraph modification times are
168/// not compared: a reorder can be attributed to either sibling, and the model cannot say which.170/// not compared: a reorder can be attributed to either sibling, and the model cannot say which.
169fn equivalent(reference: &[u8], written: &[u8]) {171fn equivalent(reference: &[u8], written: &[u8]) {
170 let store_a = Store::parse(reference).unwrap();172 let store_a = Store::parse(reference).unwrap();
...@@ -201,11 +203,29 @@ fn equivalent(reference: &[u8], written: &[u8]) {...@@ -201,11 +203,29 @@ fn equivalent(reference: &[u8], written: &[u8]) {
201 }203 }
202}204}
203205
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.
205fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec<u8> {207fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec<u8> {
206 let after = page_in(reference, space);208 let after = page_in(reference, space);
207 let prepared = ops::saved(source, space, &after).unwrap();209 let store = Store::parse(reference).unwrap();
208 let written = prepared.as_slice().to_vec();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 assert_eq!(page_in(&written, space), after, "model round trip");229 assert_eq!(page_in(&written, space), after, "model round trip");
210 equivalent(reference, &written);230 equivalent(reference, &written);
211 let transactions = |bytes: &[u8]| Store::parse(bytes).unwrap().header.transaction_count;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,7 +603,9 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {
583 );603 );
584 outline.paragraphs.push(paragraph);604 outline.paragraphs.push(paragraph);
585 after.objects.push(PageObject::Outline(outline));605 after.objects.push(PageObject::Outline(outline));
606 let started = ops::now();
586 let written = ops::saved(TREES, space, &after).unwrap();607 let written = ops::saved(TREES, space, &after).unwrap();
608 let finished = ops::now();
587 let stored = page_in(written.as_slice(), space);609 let stored = page_in(written.as_slice(), space);
588 let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> {610 let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> {
589 body(page)611 body(page)
...@@ -616,6 +638,8 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {...@@ -616,6 +638,8 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {
616 let store = Store::parse(written.as_slice()).unwrap();638 let store = Store::parse(written.as_slice()).unwrap();
617 let index = RevisionIndex::parse(&store).unwrap();639 let index = RevisionIndex::parse(&store).unwrap();
618 let raw_after = index.resolve_active(space).unwrap();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 let touched: BTreeSet<ExGuid> = body(&after)643 let touched: BTreeSet<ExGuid> = body(&after)
620 .iter()644 .iter()
621 .flat_map(|o| {645 .flat_map(|o| {
...@@ -629,7 +653,10 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {...@@ -629,7 +653,10 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {
629 .collect();653 .collect();
630 let mut untouched = 0;654 let mut untouched = 0;
631 for (id, object) in &raw_before.objects {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 continue;660 continue;
634 }661 }
635 let after = &raw_after.objects[id];662 let after = &raw_after.objects[id];
crates/onestore/tests/tree.rs+17-9
...@@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet};...@@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet};
9mod current;9mod current;
10#[path = "support/disk.rs"]10#[path = "support/disk.rs"]
11mod disk;11mod disk;
12#[path = "support/metadata.rs"]
13mod metadata;
12#[path = "support/ops.rs"]14#[path = "support/ops.rs"]
13mod ops;15mod ops;
14#[path = "support/sweep.rs"]16#[path = "support/sweep.rs"]
...@@ -191,8 +193,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {...@@ -191,8 +193,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
191 .into_iter()193 .into_iter()
192 .map(|op| Op::Page { space: sid, op })194 .map(|op| Op::Page { space: sid, op })
193 .collect();195 .collect();
196 let started = ops::now();
194 let prepared = ops::apply(source, "Tree author", edit.clone())197 let prepared = ops::apply(source, "Tree author", edit.clone())
195 .unwrap_or_else(|error| panic!("{name}: {error}"));198 .unwrap_or_else(|error| panic!("{name}: {error}"));
199 let finished = ops::now();
196 let after_store = Store::parse(prepared.as_bytes()).unwrap();200 let after_store = Store::parse(prepared.as_bytes()).unwrap();
197 assert!(after_store.checksum_mismatches.is_empty());201 assert!(after_store.checksum_mismatches.is_empty());
198 let after_index = RevisionIndex::parse(&after_store).unwrap();202 let after_index = RevisionIndex::parse(&after_store).unwrap();
...@@ -200,6 +204,11 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {...@@ -200,6 +204,11 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
200 let after_document = Document::parse(&after_index).unwrap();204 let after_document = Document::parse(&after_index).unwrap();
201 let space = &after_document.spaces[&sid];205 let space = &after_document.spaces[&sid];
202 let after = &space.revisions[&space.contexts[&ExGuid::default()]];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 let space = &native_document.spaces[&sid];212 let space = &native_document.spaces[&sid];
204 let reference = &space.revisions[&space.contexts[&ExGuid::default()]];213 let reference = &space.revisions[&space.contexts[&ExGuid::default()]];
205 let old_ids = active(before, page);214 let old_ids = active(before, page);
...@@ -284,15 +293,14 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {...@@ -284,15 +293,14 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
284 {293 {
285 assert!(node.modified >= old.modified);294 assert!(node.modified >= old.modified);
286 expected["modified"] = actual["modified"].clone();295 expected["modified"] = actual["modified"].clone();
287 }296 if matches!(node.kind, Kind::Paragraph { .. }) {
288 if *id == object297 assert_eq!(node.latest_author, Some(author));
289 && !name.starts_with("Delete")298 assert_eq!(
290 && matches!(node.kind, Kind::Paragraph { .. })299 node.modified.map(u64::from),
291 {300 Some(at / 10_000_000 - 11_960_006_400)
292 expected["latest_author"] = actual["latest_author"].clone();301 );
293 assert!(302 expected["latest_author"] = actual["latest_author"].clone();
294 matches!(&after.nodes[&node.latest_author.unwrap()].kind,Kind::Author{name:Some(name)} if name=="Tree author")303 }
295 );
296 }304 }
297 for value in [&mut expected, &mut actual] {305 for value in [&mut expected, &mut actual] {
298 value["extra"][0]306 value["extra"][0]
crates/onestore/tests/writer.rs+11-2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1#[path = "support/metadata.rs"]
2mod metadata;
1#[path = "support/ops.rs"]3#[path = "support/ops.rs"]
2mod ops;4mod ops;
35
...@@ -390,6 +392,7 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {...@@ -390,6 +392,7 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
390 let store = Store::parse(&bytes).unwrap();392 let store = Store::parse(&bytes).unwrap();
391 let before = RevisionIndex::parse(&store).unwrap();393 let before = RevisionIndex::parse(&store).unwrap();
392 let rid = before.spaces[&sid].labels[&(ExGuid::default(), 1)];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 let written = if section {396 let written = if section {
394 let typed = |range, with: &str| Op::Page {397 let typed = |range, with: &str| Op::Page {
395 space: sid,398 space: sid,
...@@ -400,7 +403,6 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {...@@ -400,7 +403,6 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
400 },403 },
401 };404 };
402 // Modification times count seconds: each edit is a second later.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 let ops = vec![typed(0..0, "x"), typed(0..1, "")];406 let ops = vec![typed(0..0, "x"), typed(0..1, "")];
405 let arena = onestore::Arena::default();407 let arena = onestore::Arena::default();
406 let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap();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,6 +430,9 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
428 assert_preserved_committed_bytes(&bytes, &written);430 assert_preserved_committed_bytes(&bytes, &written);
429 let old = before.resolve(sid, rid).unwrap();431 let old = before.resolve(sid, rid).unwrap();
430 let current = after.resolve(sid, next).unwrap();432 let current = after.resolve(sid, next).unwrap();
433 if section {
434 assert_eq!(metadata::check(&old, &current, "Author"), at);
435 }
431 assert_eq!(old.roots, current.roots);436 assert_eq!(old.roots, current.roots);
432 assert_eq!(old.objects.len(), current.objects.len());437 assert_eq!(old.objects.len(), current.objects.len());
433 for (id, object) in &old.objects {438 for (id, object) in &old.objects {
...@@ -456,7 +461,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {...@@ -456,7 +461,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
456 assert_eq!(a.len(), b.len());461 assert_eq!(a.len(), b.len());
457 for (a, b) in a.iter().zip(b) {462 for (a, b) in a.iter().zip(b) {
458 assert_eq!(a.id, b.id);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 assert_eq!(a.value, b.value, "{id} {:#x}", a.id);470 assert_eq!(a.value, b.value, "{id} {:#x}", a.id);
462 }471 }