diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 53e45e240d3703cfb3316e9bf5903e8a805412ff..ca15018f7f2ca8274348816dcc4f528e484d49bc 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -2766,7 +2766,7 @@ fn a_page_date_stores_what_onenote_stores() { .apply( "Author", &Edit { - at: AT, + at: AT + 12345, ops: vec![Op::Page { space: kept, op }], }, ) @@ -2777,12 +2777,24 @@ fn a_page_date_stores_what_onenote_stores() { assert_eq!(stored.created, Some(DATED_AT)); assert_eq!(date_fields(&stored), fields); assert_eq!(stored, predicted); - assert_eq!(changed_jcids(&image, kept), native); + let mut expected = native; + expected.extend([0x20044, 0x120001]); + expected.sort(); + assert_eq!(changed_jcids(&image, kept), expected); let store = Store::parse(&image).unwrap(); - RevisionIndex::parse(&store) - .unwrap() - .validate_current() - .unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + let revision = document.active(kept).unwrap(); + let metadata = &revision.nodes[&revision.roots[&4]]; + assert!(matches!( + metadata.kind, + Kind::RevisionMetadata { modified_filetime: Some(at) } if at == AT + 12345 + )); + assert!(matches!( + &revision.nodes[&metadata.latest_author.unwrap()].kind, + Kind::Author { name } if name.as_deref() == Some("Author") + )); // The whole-page lowering reaches the same page through the same op. let mut after = before.clone(); diff --git a/crates/onestore/tests/edit.rs b/crates/onestore/tests/edit.rs index 9cb6637e4c0c8ec74d373dba7790edb66532e292..19cde8b002f8867ec66f67c38b11c713df6efbcc 100644 --- a/crates/onestore/tests/edit.rs +++ b/crates/onestore/tests/edit.rs @@ -2,6 +2,8 @@ mod checkpoint; #[path = "support/disk.rs"] mod disk; +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; #[path = "support/trace.rs"] @@ -321,10 +323,24 @@ fn assert_other_objects_preserved( .find(|field| field.id == 0x14001d7a) .unwrap() .value; + let filetime = metadata::check(before, after, "Author"); + let author = metadata::author(&after.objects[&after.roots[&4]]); + assert_eq!( + *modified, + onestore::Value::Bytes( + &u32::try_from(filetime / 10_000_000 - 11_960_006_400) + .unwrap() + .to_le_bytes() + ) + ); let reachable = before.reachable().unwrap(); - assert_eq!(before.objects.len(), after.objects.len()); + assert!( + after.objects.keys().all(|id| { + before.objects.contains_key(id) || *id == after.roots[&4] || *id == author + }) + ); for (id, object) in &before.objects { - if *id == edited { + if *id == edited || Some(id) == before.roots.get(&4) { continue; } let current = &after.objects[id]; @@ -334,7 +350,8 @@ fn assert_other_objects_preserved( current .global_ids .iter() - .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid)) + .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid) + || *guid == author.guid) ); let mut pending = vec![*id]; let mut visited = std::collections::BTreeSet::new(); @@ -349,6 +366,10 @@ fn assert_other_objects_preserved( if ancestor { allowed.push(0x14001d7a); } + if ancestor && object.jcid == 0x6000d { + allowed.push(0x20001d79); + assert_eq!(metadata::author(current), author); + } if ancestor && object.jcid == 0x6000b { allowed.push(0x1c001d3c); } @@ -368,7 +389,11 @@ fn assert_other_objects_preserved( if ancestor { let old_time = old.sets[0].iter().find(|field| field.id == 0x14001d7a); let new_time = new.sets[0].iter().find(|field| field.id == 0x14001d7a); - assert_eq!(old_time.is_some(), new_time.is_some()); + if object.jcid == 0x6000d { + assert!(new_time.is_some()); + } else { + assert_eq!(old_time.is_some(), new_time.is_some()); + } if let Some(time) = new_time { assert_eq!(&time.value, modified); } @@ -413,10 +438,10 @@ fn text_splices_preserve_formats_objects_and_history_in_one_transaction() { index.spaces[space_id].labels[&(ExGuid::default(), 1)], ) .unwrap(); - assert_eq!(previous.objects.len(), current.objects.len()); if *space_id == sid { assert_other_objects_preserved(&previous, ¤t, oid); } else { + assert_eq!(previous.objects.len(), current.objects.len()); for (id, object) in previous.objects { assert_eq!(object.data, current.objects[&id].data); } diff --git a/crates/onestore/tests/insertion.rs b/crates/onestore/tests/insertion.rs index 3850c394cf436496f19ad99e737374fecee65ae0..e3ab5406e0fafb25759b9cc9fc495120bd2713b4 100644 --- a/crates/onestore/tests/insertion.rs +++ b/crates/onestore/tests/insertion.rs @@ -4,6 +4,8 @@ mod checkpoint; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; #[path = "support/sweep.rs"] @@ -415,6 +417,60 @@ fn paragraph_and_outline_insertions_publish_metadata_and_references_together() { } } +#[test] +fn first_revision_metadata_checkpoints_once_and_keeps_history() { + let source = onestore::create_section("metadata.one", "Original", "Same author").unwrap(); + let (sid, _, _, _, text) = targets(&source); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let original = index.resolve_active(sid).unwrap(); + assert!(!original.roots.contains_key(&4)); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, source.clone()).unwrap(); + let at = 134_000_000_000_012_345; + let mut previous = None; + for at in [at, at + 1] { + section + .apply( + "Same author", + &Edit { + at, + ops: vec![Op::Page { + space: sid, + op: PageOp::Text { + text, + range: 0..0, + with: "x".into(), + }, + }], + }, + ) + .unwrap(); + section.seal().unwrap().unwrap(); + let image = section.image(); + let current_store = Store::parse(&image).unwrap(); + let current = RevisionIndex::parse(¤t_store).unwrap(); + current.validate_current().unwrap(); + let rid = current.active(sid).unwrap(); + assert_eq!(current.spaces[&sid].revisions[&rid].dependency, previous); + let revision = current.resolve_active(sid).unwrap(); + assert_eq!(metadata::check(&original, &revision, "Same author"), at); + assert!( + revision + .objects + .keys() + .all(|id| original.objects.contains_key(id) || *id == revision.roots[&4]) + ); + for old in index.spaces[&sid].revisions.keys() { + assert_eq!( + format!("{:?}", index.resolve(sid, *old).unwrap()), + format!("{:?}", current.resolve(sid, *old).unwrap()) + ); + } + previous = Some(rid); + } +} + #[test] fn repeated_insertions_and_formatting_share_immutable_objects() { let mut source = onestore::create_section("shared.one", "Original", "Same author").unwrap(); @@ -468,7 +524,10 @@ fn repeated_insertions_and_formatting_share_immutable_objects() { counts.push((object.jcid, object.reference_count)); } counts.sort(); - assert_eq!(counts, [(0x120001, 26), (0x12004d, 12), (0x12004d, 25)]); + metadata::check(&raw, &raw, "Same author"); + let author = metadata::author(&raw.objects[&raw.roots[&4]]); + assert_eq!(raw.objects[&author].jcid, 0x120001); + assert_eq!(counts, [(0x120001, 27), (0x12004d, 12), (0x12004d, 25)]); let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; diff --git a/crates/onestore/tests/page_movement.rs b/crates/onestore/tests/page_movement.rs index d4723ae5bdd06f5957f88e5e4b0d362d27201d0b..6a30a4c189aa18946d7193c5e65eb2c075b6c196 100644 --- a/crates/onestore/tests/page_movement.rs +++ b/crates/onestore/tests/page_movement.rs @@ -11,6 +11,8 @@ use onestore::{ mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/sweep.rs"] mod sweep; @@ -91,14 +93,33 @@ fn verify(before: &[u8], after: &[u8], native: &[u8]) { let new = indexes[1] .resolve(*sid, indexes[1].spaces[sid].labels[&(ExGuid::default(), 1)]) .unwrap(); - assert_eq!(old.roots, new.roots); - assert_eq!( - old.objects.keys().collect::>(), - new.objects.keys().collect::>() - ); + let changed = old.objects[&old.roots[&2]].data != new.objects[&new.roots[&2]].data; + let mut authors = std::collections::BTreeSet::new(); + if changed { + metadata::check(&old, &new, "Author"); + authors.insert(metadata::author(&new.objects[&new.roots[&4]])); + if let Some(id) = old.roots.get(&4) { + authors.extend(old.objects[id].references().unwrap().objects); + } + assert!(new.objects.keys().all(|id| { + old.objects.contains_key(id) || *id == new.roots[&4] || authors.contains(id) + })); + } else { + assert_eq!(old.roots, new.roots); + assert_eq!( + old.objects.keys().collect::>(), + new.objects.keys().collect::>() + ); + } for (id, object) in &old.objects { - if *id != old.roots[&2] { - assert_eq!(format!("{object:?}"), format!("{:?}", new.objects[id])); + if *id != old.roots[&2] && !(changed && Some(id) == old.roots.get(&4)) { + let current = &new.objects[id]; + assert_eq!(object.data, current.data); + assert_eq!(object.jcid, current.jcid); + assert_eq!(object.global_ids, current.global_ids); + if !authors.contains(id) { + assert_eq!(object.reference_count, current.reference_count); + } } } } @@ -383,13 +404,34 @@ fn metadata_copy_order_does_not_change_page_identity() { let stores = [edited.as_bytes(), expected.as_slice()].map(|b| Store::parse(b).unwrap()); let indexes = stores.each_ref().map(|s| RevisionIndex::parse(s).unwrap()); let documents = indexes.each_ref().map(|i| Document::parse(i).unwrap()); - for sid in documents[0].spaces.keys() { - let nodes = documents.each_ref().map(|d| { - let space = &d.spaces[sid]; - serde_json::to_value(&space.revisions[&space.contexts[&ExGuid::default()]].nodes) - .unwrap() - }); - assert_eq!(nodes[0], nodes[1], "{sid}"); + for space_id in documents[0].spaces.keys() { + let views = documents + .each_ref() + .map(|d| d.spaces[space_id].active().unwrap()); + let mut nodes = views.map(|view| view.nodes.clone()); + if *space_id == sid { + let raw = indexes.each_ref().map(|index| { + index + .resolve( + *space_id, + index.spaces[space_id].labels[&(ExGuid::default(), 1)], + ) + .unwrap() + }); + metadata::check(&raw[1], &raw[0], "Author"); + let author = metadata::author(&raw[0].objects[&raw[0].roots[&4]]); + if !nodes[1].contains_key(&author) { + nodes[0].remove(&author).unwrap(); + } + for (nodes, view) in nodes.iter_mut().zip(views) { + nodes.remove(&view.roots[&4]).unwrap(); + } + } + assert_eq!( + serde_json::to_value(&nodes[0]).unwrap(), + serde_json::to_value(&nodes[1]).unwrap(), + "{space_id}" + ); } } diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs index 6675166893610afada9c2d7c8fbe71d4e8e401e8..d0f62ba8d82fd789ed64c31cbefb98610089d3ed 100644 --- a/crates/onestore/tests/paragraph.rs +++ b/crates/onestore/tests/paragraph.rs @@ -12,6 +12,8 @@ mod checkpoint; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/metadata.rs"] +mod metadata; #[path = "support/ops.rs"] mod ops; @@ -220,6 +222,22 @@ fn joins_match_native_graphs_tags_and_inherited_character_styles() { .unwrap(); let current_rid = current_document.spaces[sid].contexts[&ExGuid::default()]; let new_raw = current_index.resolve(*sid, current_rid).unwrap(); + let modified = metadata::check(&old_raw, &new_raw, "Author"); + let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]); + changed.extend(old_raw.roots.get(&4)); + for id in &changed { + if current + .nodes + .get(id) + .is_some_and(|node| matches!(node.kind, Kind::Paragraph { .. })) + { + assert_eq!(metadata::author(&new_raw.objects[id]), author); + assert_eq!( + current.nodes[id].modified.map(u64::from), + Some(modified / 10_000_000 - 11_960_006_400) + ); + } + } for (id, object) in old_raw.objects { if !changed.contains(&id) { assert_eq!( @@ -409,6 +427,16 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { let new_raw = current_index .resolve(*sid, current_space.contexts[&ExGuid::default()]) .unwrap(); + let modified = metadata::check(&old_raw, &new_raw, "Author"); + let author = metadata::author(&new_raw.objects[&new_raw.roots[&4]]); + changed.extend(old_raw.roots.get(&4)); + for id in [paragraph, new_paragraph] { + assert_eq!(metadata::author(&new_raw.objects[&id]), author); + assert_eq!( + after.nodes[&id].modified.map(u64::from), + Some(modified / 10_000_000 - 11_960_006_400) + ); + } for (id, object) in old_raw.objects { if !changed.contains(&id) { assert_eq!(object.data, new_raw.objects[&id].data, "untouched {id}"); diff --git a/crates/onestore/tests/support/checkpoint.rs b/crates/onestore/tests/support/checkpoint.rs index 03ccf9f8e8d770536dfe2cb2ff2ea81d7d50d137..6047c9ed4e10e376ae8f757ff1cc51605c0e63e9 100644 --- a/crates/onestore/tests/support/checkpoint.rs +++ b/crates/onestore/tests/support/checkpoint.rs @@ -26,7 +26,11 @@ pub fn pending(source: &[u8], space: ExGuid, text: ExGuid) -> Vec { with: with.into(), }, }; - for at in 0..512 - depth(source) as u64 { + for at in 0..=512 { + let image = section.image(); + if depth(&image) == 512 { + return image; + } let ops = vec![retouch(0..0, "x"), retouch(0..1, "")]; let at = 134_000_000_000_000_000 + at * 10_000_000; section.apply("Author", &Edit { at, ops }).unwrap(); diff --git a/crates/onestore/tests/support/metadata.rs b/crates/onestore/tests/support/metadata.rs new file mode 100644 index 0000000000000000000000000000000000000000..df8bd46e1a77f781881ed9cc9b735927292431e1 --- /dev/null +++ b/crates/onestore/tests/support/metadata.rs @@ -0,0 +1,119 @@ +use onestore::{ExGuid, IdStream, Object, ObjectData, PropertySets, ResolvedRevision, Value}; + +pub fn author(object: &Object<'_>) -> ExGuid { + let ObjectData::Properties(bytes) = object.data else { + panic!("AuthorMostRecent needs a property object") + }; + let fields = PropertySets::parse(bytes).unwrap(); + let Value::References { + stream: IdStream::Objects, + compact_ids, + } = fields.sets[0] + .iter() + .find(|field| field.id == 0x20001d79) + .unwrap() + .value + else { + panic!("AuthorMostRecent needs one object reference") + }; + let compact = u32::from_le_bytes(compact_ids.try_into().unwrap()); + ExGuid { + guid: object.global_ids[&(compact >> 8)], + n: compact & 255, + } +} + +/// Checks the revision context's author/time while preserving every other metadata field. +pub fn check(before: &ResolvedRevision<'_>, after: &ResolvedRevision<'_>, name: &str) -> u64 { + let roots = |revision: &ResolvedRevision<'_>| { + revision + .roots + .iter() + .filter(|(kind, _)| **kind != 4) + .map(|(kind, id)| (*kind, *id)) + .collect::>() + }; + assert_eq!(roots(before), roots(after)); + let id = after.roots[&4]; + let metadata = &after.objects[&id]; + assert_eq!(metadata.jcid, 0x20044); + let ObjectData::Properties(bytes) = metadata.data else { + panic!() + }; + let fields = PropertySets::parse(bytes).unwrap(); + let Value::Bytes(time) = fields.sets[0] + .iter() + .find(|field| field.id == 0x18001d77) + .unwrap() + .value + else { + panic!() + }; + let filetime = u64::from_le_bytes(time.try_into().unwrap()); + assert!(filetime >= 119_600_064_000_000_000); + let author_id = author(metadata); + let named = &after.objects[&author_id]; + assert_eq!(named.jcid, 0x120001); + let ObjectData::Properties(bytes) = named.data else { + panic!() + }; + let named_fields = PropertySets::parse(bytes).unwrap(); + let Value::Bytes(stored) = named_fields.sets[0] + .iter() + .find(|field| field.id == 0x1c001d75) + .unwrap() + .value + else { + panic!() + }; + let expected: Vec<_> = name + .encode_utf16() + .chain([0]) + .flat_map(u16::to_le_bytes) + .collect(); + assert_eq!(stored, expected); + if let Some(old) = before.objects.get(&author_id) { + assert_eq!(old.data, named.data); + } + if let Some(old_id) = before.roots.get(&4) { + assert_eq!(*old_id, id); + let old = &before.objects[old_id]; + assert_eq!(old.jcid, metadata.jcid); + assert!(metadata.global_ids.iter().all(|(entry, guid)| { + old.global_ids.get(entry) == Some(guid) || *guid == author_id.guid + })); + let ObjectData::Properties(old_bytes) = old.data else { + panic!() + }; + let mut old_fields = PropertySets::parse(old_bytes).unwrap(); + let mut new = fields; + for fields in [&mut old_fields, &mut new] { + fields.sets[0].retain(|field| ![0x18001d77, 0x20001d79].contains(&field.id)); + } + assert_eq!(old_fields.sets, new.sets); + for field in old_fields.sets.iter().flatten() { + if let Value::References { compact_ids, .. } = field.value { + let ids = compact_ids.chunks_exact(4); + assert!(ids.remainder().is_empty()); + for compact in ids { + let entry = u32::from_le_bytes(compact.try_into().unwrap()) >> 8; + assert_eq!( + metadata.global_ids.get(&entry), + Some(&old.global_ids[&entry]) + ); + } + } + } + } else { + assert!(!before.objects.contains_key(&id)); + assert_eq!(fields.sets.len(), 1); + assert_eq!( + fields.sets[0] + .iter() + .map(|field| field.id) + .collect::>(), + [0x18001d77, 0x20001d79] + ); + } + filetime +}