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;
99mod current;
1010#[path = "support/disk.rs"]
1111mod disk;
12#[path = "support/metadata.rs"]
13mod metadata;
1214#[path = "support/ops.rs"]
1315mod ops;
1416#[path = "support/sweep.rs"]
......@@ -67,6 +69,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
6769 for (name, sid, object, edit) in cases(&document) {
6870 let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap();
6971 assert_eq!(edit, restored);
72 let started = ops::now();
7073 let prepared = ops::page_op(
7174 SOURCE,
7275 sid,
......@@ -76,6 +79,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
7679 },
7780 )
7881 .unwrap();
82 let finished = ops::now();
7983 let after_store = Store::parse(prepared.as_bytes()).unwrap();
8084 assert!(after_store.checksum_mismatches.is_empty());
8185 let after_index = RevisionIndex::parse(&after_store).unwrap();
......@@ -100,15 +104,33 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
100104 .then_some(*parent)
101105 }));
102106 }
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)
106117 );
107118 for (id, node) in &before.nodes {
119 if Some(id) == before.roots.get(&4) {
120 continue;
121 }
108122 let mut expected = serde_json::to_value(node).unwrap();
109123 let actual = serde_json::to_value(&after.nodes[id]).unwrap();
110124 if changed.contains(id) {
111125 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 }
112134 }
113135 if *id == object {
114136 match edit {
......@@ -344,6 +366,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
344366 (144.0, 36.0, "First"),
345367 (0.0, 144.0, "First"),
346368 ] {
369 let started = ops::now();
347370 let edit = ops::page_op(
348371 &source,
349372 sid,
......@@ -353,12 +376,17 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
353376 },
354377 )
355378 .unwrap();
379 let finished = ops::now();
356380 let saved = Store::parse(edit.as_bytes()).unwrap();
357381 let saved_index = RevisionIndex::parse(&saved).unwrap();
358382 saved_index.validate_current().unwrap();
359383 let saved_document = Document::parse(&saved_index).unwrap();
360384 let saved_space = &saved_document.spaces[&sid];
361385 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));
362390 let metadata = before.roots[&2];
363391 assert!(
364392 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
371399 after.nodes.keys().collect::<Vec<_>>()
372400 );
373401 for (oid, node) in &before.nodes {
374 if ![page, metadata, insertion].contains(oid) {
402 if ![page, metadata, insertion, before.roots[&4]].contains(oid) {
375403 assert_eq!(
376404 serde_json::to_value(node).unwrap(),
377405 serde_json::to_value(&after.nodes[oid]).unwrap()
crates/onestore/tests/page_writer.rs+32-5
......@@ -1,6 +1,8 @@
11//! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied
22//! one by one publish, in one transaction, and must round-trip the model it was given.
33
4#[path = "support/metadata.rs"]
5mod metadata;
46#[path = "support/ops.rs"]
57mod ops;
68
......@@ -164,7 +166,7 @@ fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid
164166}
165167
166168/// 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
168170/// not compared: a reorder can be attributed to either sibling, and the model cannot say which.
169171fn equivalent(reference: &[u8], written: &[u8]) {
170172 let store_a = Store::parse(reference).unwrap();
......@@ -201,11 +203,29 @@ fn equivalent(reference: &[u8], written: &[u8]) {
201203 }
202204}
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.
205207fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec<u8> {
206208 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();
209229 assert_eq!(page_in(&written, space), after, "model round trip");
210230 equivalent(reference, &written);
211231 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() {
583603 );
584604 outline.paragraphs.push(paragraph);
585605 after.objects.push(PageObject::Outline(outline));
606 let started = ops::now();
586607 let written = ops::saved(TREES, space, &after).unwrap();
608 let finished = ops::now();
587609 let stored = page_in(written.as_slice(), space);
588610 let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> {
589611 body(page)
......@@ -616,6 +638,8 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {
616638 let store = Store::parse(written.as_slice()).unwrap();
617639 let index = RevisionIndex::parse(&store).unwrap();
618640 let raw_after = index.resolve_active(space).unwrap();
641 let at = metadata::check(&raw_before, &raw_after, AUTHOR);
642 assert!((started..=finished).contains(&at));
619643 let touched: BTreeSet<ExGuid> = body(&after)
620644 .iter()
621645 .flat_map(|o| {
......@@ -629,7 +653,10 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() {
629653 .collect();
630654 let mut untouched = 0;
631655 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 {
633660 continue;
634661 }
635662 let after = &raw_after.objects[id];
crates/onestore/tests/tree.rs+17-9
......@@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet};
99mod current;
1010#[path = "support/disk.rs"]
1111mod disk;
12#[path = "support/metadata.rs"]
13mod metadata;
1214#[path = "support/ops.rs"]
1315mod ops;
1416#[path = "support/sweep.rs"]
......@@ -191,8 +193,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
191193 .into_iter()
192194 .map(|op| Op::Page { space: sid, op })
193195 .collect();
196 let started = ops::now();
194197 let prepared = ops::apply(source, "Tree author", edit.clone())
195198 .unwrap_or_else(|error| panic!("{name}: {error}"));
199 let finished = ops::now();
196200 let after_store = Store::parse(prepared.as_bytes()).unwrap();
197201 assert!(after_store.checksum_mismatches.is_empty());
198202 let after_index = RevisionIndex::parse(&after_store).unwrap();
......@@ -200,6 +204,11 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
200204 let after_document = Document::parse(&after_index).unwrap();
201205 let space = &after_document.spaces[&sid];
202206 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]]);
203212 let space = &native_document.spaces[&sid];
204213 let reference = &space.revisions[&space.contexts[&ExGuid::default()]];
205214 let old_ids = active(before, page);
......@@ -284,15 +293,14 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
284293 {
285294 assert!(node.modified >= old.modified);
286295 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 }
296304 }
297305 for value in [&mut expected, &mut actual] {
298306 value["extra"][0]
crates/onestore/tests/writer.rs+11-2
......@@ -1,3 +1,5 @@
1#[path = "support/metadata.rs"]
2mod metadata;
13#[path = "support/ops.rs"]
24mod ops;
35
......@@ -390,6 +392,7 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
390392 let store = Store::parse(&bytes).unwrap();
391393 let before = RevisionIndex::parse(&store).unwrap();
392394 let rid = before.spaces[&sid].labels[&(ExGuid::default(), 1)];
395 let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000;
393396 let written = if section {
394397 let typed = |range, with: &str| Op::Page {
395398 space: sid,
......@@ -400,7 +403,6 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
400403 },
401404 };
402405 // 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;
404406 let ops = vec![typed(0..0, "x"), typed(0..1, "")];
405407 let arena = onestore::Arena::default();
406408 let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap();
......@@ -428,6 +430,9 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
428430 assert_preserved_committed_bytes(&bytes, &written);
429431 let old = before.resolve(sid, rid).unwrap();
430432 let current = after.resolve(sid, next).unwrap();
433 if section {
434 assert_eq!(metadata::check(&old, &current, "Author"), at);
435 }
431436 assert_eq!(old.roots, current.roots);
432437 assert_eq!(old.objects.len(), current.objects.len());
433438 for (id, object) in &old.objects {
......@@ -456,7 +461,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() {
456461 assert_eq!(a.len(), b.len());
457462 for (a, b) in a.iter().zip(b) {
458463 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 { .. })
460469 {
461470 assert_eq!(a.value, b.value, "{id} {:#x}", a.id);
462471 }