From d1ad62573d835ddc1f3273975bfe9c4b1a554510 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 8 Sep 2026 12:03:31 -0700 Subject: [PATCH] feat: reconcile durable offline outline layout edits Retain layout intentions with their selected property values and ancestry. Merge independent changes, preserve competing changes for exact-image review, and retain uncertain attempts without acknowledging coincidentally converged values. Migrate prior caches to schema 7 while preserving images, pending edits, attempts, receipts, media and sequence IDs. Share active-path traversal with paragraph reconciliation. Native controls verify five automatic merges, five reviewed merges and four retained deletions, plus dependent text and cold reopening. Tests cover twelve writers with 144 intentions, migration, restart, uncertainty and extended SQLite fuzzing. Assisted-by: gpt-6-astra --- corpus/outline-edit/README.md | 16 + .../offline/candidate/synthetic.one | Bin 0 -> 149936 bytes corpus/outline-edit/offline/cases.json | 154 ++++ .../outline-edit/offline/cold/commands.jsonl | 3 + corpus/outline-edit/offline/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 4800 bytes .../offline/cold/notebook/synthetic.one | Bin 0 -> 149936 bytes .../offline/cold/read/environment.json | 7 + .../offline/cold/read/hierarchy.xml | 2 + .../offline/cold/read/page-000.xml | 5 + .../offline/cold/read/page-001.xml | 20 + .../offline/cold/read/page-002.xml | 5 + .../offline/cold/read/page-003.xml | 2 + .../offline/cold/read/page-004.xml | 5 + .../offline/cold/read/page-005.xml | 5 + .../offline/cold/read/page-006.xml | 5 + .../offline/cold/read/page-007.xml | 5 + .../offline/cold/read/page-008.xml | 2 + .../offline/cold/read/page-009.xml | 4 + .../offline/cold/read/page-010.xml | 5 + .../offline/cold/read/page-011.xml | 2 + .../offline/cold/read/page-012.xml | 5 + .../offline/cold/read/page-013.xml | 2 + .../offline/cold/read/page-014.xml | 5 + .../offline/cold/read/payloads.json | 1 + corpus/outline-edit/offline/cold/run.json | 18 + .../offline/cold/scripts/cold.ps1 | 1 + .../offline/cold/scripts/read.ps1 | 1 + corpus/outline-edit/offline/cold/source.json | 8 + .../outline-edit/offline/cold/teardown.json | 1 + crates/onestore-offline/README.md | 21 +- .../onestore-offline/examples/cache_probe.rs | 6 +- crates/onestore-offline/src/lib.rs | 52 +- crates/onestore-offline/src/outline.rs | 149 ++++ crates/onestore-offline/src/paragraph.rs | 42 +- crates/onestore-offline/src/schema.rs | 2 +- crates/onestore-offline/src/sync.rs | 27 + crates/onestore-offline/tests/cache.rs | 225 +++--- crates/onestore-offline/tests/sync.rs | 8 +- crates/onestore-offline/tests/sync/outline.rs | 656 ++++++++++++++++++ fuzz/fuzz_targets/offline_paragraph.rs | 76 +- tools/test_outline_edit.py | 45 +- 42 files changed, 1456 insertions(+), 143 deletions(-) create mode 100644 corpus/outline-edit/offline/candidate/synthetic.one create mode 100644 corpus/outline-edit/offline/cases.json create mode 100644 corpus/outline-edit/offline/cold/commands.jsonl create mode 100644 corpus/outline-edit/offline/cold/machine.json create mode 100644 corpus/outline-edit/offline/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/outline-edit/offline/cold/notebook/synthetic.one create mode 100644 corpus/outline-edit/offline/cold/read/environment.json create mode 100644 corpus/outline-edit/offline/cold/read/hierarchy.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-000.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-001.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-002.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-003.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-004.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-005.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-006.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-007.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-008.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-009.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-010.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-011.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-012.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-013.xml create mode 100644 corpus/outline-edit/offline/cold/read/page-014.xml create mode 120000 corpus/outline-edit/offline/cold/read/payloads.json create mode 100644 corpus/outline-edit/offline/cold/run.json create mode 120000 corpus/outline-edit/offline/cold/scripts/cold.ps1 create mode 120000 corpus/outline-edit/offline/cold/scripts/read.ps1 create mode 100644 corpus/outline-edit/offline/cold/source.json create mode 100644 corpus/outline-edit/offline/cold/teardown.json create mode 100644 crates/onestore-offline/src/outline.rs create mode 100644 crates/onestore-offline/tests/sync/outline.rs diff --git a/corpus/outline-edit/README.md b/corpus/outline-edit/README.md index 8079748ea71c25f764ac72b539e8158dd01d5497..303d26d450ddfd45c810d2209f9f9950073c7086 100644 --- a/corpus/outline-edit/README.md +++ b/corpus/outline-edit/README.md @@ -60,3 +60,19 @@ all 304 active graph objects, ten tags and 8,457 explicit formatting comparisons To regenerate, run `resizing_a_native_reserved_width_preserves_content` with `ONESTORE_RESERVED_WIDTH_OUTPUT` set to a new absolute directory, then cold-open it with the same runner arguments. + +`offline` reconciles local layout/default intents and later text edits against all +fourteen native changes. Five merge automatically, five require explicit review, +and four deleted targets retain their local branches and dependent queues. The ten +successful cases produce eighteen publications and twenty durable receipts; the +already-converged collapse defaults require confirmation without another append. +The final cold capture preserves all 304 active graph objects, ten tags, and 8,937 +explicit formatting comparisons. The `cases.json` records stable targets and the +retained/dependent outcomes used by the public test. + +Regenerate by running the offline sync test +`native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts` +with `ONESTORE_OFFLINE_OUTLINE_OUTPUT` set to a new absolute directory, then use +the same cold runner arguments. The test also retains a JSON record next to that +directory. Cache migration, uncertain revision retention, stale review, and twelve +offline writers are exercised in `crates/onestore-offline/tests`. diff --git a/corpus/outline-edit/offline/candidate/synthetic.one b/corpus/outline-edit/offline/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..72a2843936fe40a397370ea1bc5d68802ee8b425 GIT binary patch literal 149936 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 literal 0 HcmV?d00001 diff --git a/corpus/outline-edit/offline/cold/read/environment.json b/corpus/outline-edit/offline/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..6ae9b744b00d8ad9a4d76e32a145436c615f6563 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-B0D8A07E", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/offline/cold/read/hierarchy.xml b/corpus/outline-edit/offline/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..0204d58383339adec8feaf54bf500679741f6b9c --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline/cold/read/page-000.xml b/corpus/outline-edit/offline/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..92615e94b447e118621d2a59144184e84e651e6e --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-001.xml b/corpus/outline-edit/offline/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..6ccb6255a089fb2fe6e0c42387cc479f72ebbdea --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-001.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
    +End
    ]]>
    Fictitious: café, 東京, مرحبا]]>
    diff --git a/corpus/outline-edit/offline/cold/read/page-002.xml b/corpus/outline-edit/offline/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..fd2f2fbe02f1229cd212a0c1b88a10595b2870eb --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-002.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-003.xml b/corpus/outline-edit/offline/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..af9b2af57eb448d917e65d456ddb64cd33b4d3e8 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline/cold/read/page-004.xml b/corpus/outline-edit/offline/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..fc84dcf2d2d8d46ecb76ab6d1a9afe5713fa331c --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-004.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-005.xml b/corpus/outline-edit/offline/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..65b18bd345968995b34b8d54d092f6d45df726f1 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-005.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-006.xml b/corpus/outline-edit/offline/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..f8a14bee6efb9f1df88c8dd6d78a1e6b60a34808 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-006.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-007.xml b/corpus/outline-edit/offline/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..0c3c0b97cff7e771638fc0ee59778ec92a074070 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-007.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-008.xml b/corpus/outline-edit/offline/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..4a425d20db03d8ebdf3c5639ac9d1358ca4ab69f --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline/cold/read/page-009.xml b/corpus/outline-edit/offline/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..2e10eaeff992340702c39f5209ce5a82cd68fcb2 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-009.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/offline/cold/read/page-010.xml b/corpus/outline-edit/offline/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..a1d53f47f159ab8288c69cc79036170a54d1d897 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-010.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-011.xml b/corpus/outline-edit/offline/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..0b524b2623a0e04b9a9dea0c6997240d04b7ad26 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline/cold/read/page-012.xml b/corpus/outline-edit/offline/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..3cdeb076e5761dafb8999ba352bca4bfb67d7ad4 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-012.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/page-013.xml b/corpus/outline-edit/offline/cold/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..fe7126cf65360af6f16da4aa84e9916b2894258a --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline/cold/read/page-014.xml b/corpus/outline-edit/offline/cold/read/page-014.xml new file mode 100644 index 0000000000000000000000000000000000000000..95cba123060980b973bd7a83c4437f5eb37b4d89 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/page-014.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline/cold/read/payloads.json b/corpus/outline-edit/offline/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..9e1be3e2b6d87478e7c48d278ecac8ee6c97f6d0 --- /dev/null +++ b/corpus/outline-edit/offline/cold/read/payloads.json @@ -0,0 +1 @@ +../../../../paragraph-edit/before/read/payloads.json \ No newline at end of file diff --git a/corpus/outline-edit/offline/cold/run.json b/corpus/outline-edit/offline/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..afe77f78d76e0bc7a673bf8f335422d71457a911 --- /dev/null +++ b/corpus/outline-edit/offline/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-outline-candidate-01", + "expected_pages": 15, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/outline-edit/offline/cold/scripts/cold.ps1 b/corpus/outline-edit/offline/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..5bc2f1837e1f0b639923a80b79c517c98c5c519a --- /dev/null +++ b/corpus/outline-edit/offline/cold/scripts/cold.ps1 @@ -0,0 +1 @@ +../../../../paragraph-edit/reconciliation/keyboard/scripts/cold.ps1 \ No newline at end of file diff --git a/corpus/outline-edit/offline/cold/scripts/read.ps1 b/corpus/outline-edit/offline/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..85257c76cede599f47da88e69419a767dd6b99ee --- /dev/null +++ b/corpus/outline-edit/offline/cold/scripts/read.ps1 @@ -0,0 +1 @@ +../../../../paragraph-edit/reconciliation/keyboard/scripts/read.ps1 \ No newline at end of file diff --git a/corpus/outline-edit/offline/cold/source.json b/corpus/outline-edit/offline/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..c24898bd5a570d771a210ecb1fd572535e985442 --- /dev/null +++ b/corpus/outline-edit/offline/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 149936, + "sha256": "608532b4ef9be3cbeac3c0ec9b7e624a96aa4ce0ce6c0f86837933703e61fd0c", + "mtime_ns": 1788894002037909008 + } +] diff --git a/corpus/outline-edit/offline/cold/teardown.json b/corpus/outline-edit/offline/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/outline-edit/offline/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/onestore-offline/README.md b/crates/onestore-offline/README.md index cc8593c7be7f02433d59f2c70d3d78e73d59ef0d..7d0731b71d643b3b794b88222bb4926d578e6961 100644 --- a/crates/onestore-offline/README.md +++ b/crates/onestore-offline/README.md @@ -30,8 +30,8 @@ intent identities. `Insertion::with_formatting` queues text and its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies the new text for subsequent offline edits. Pending entries expose `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, -`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)` and -`Operation::Join(JoinEdit)` through their +`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, +`Operation::Join(JoinEdit)` and `Operation::Outline(OutlineEdit)` through their `operation` field. Synchronization applies these in queue order, so an inserted outline can precede its paragraphs and their later edits. Missing anchors or existing insertion identities preserve a conflict and the complete local image. @@ -88,8 +88,18 @@ requested value is accepted. Enabling superscript or subscript also checks the opposite attribute that the operation clears. If the remote image already satisfies the whole operation, guarded confirmation still precedes a durable receipt. -Recognized earlier caches migrate transactionally to version six, which adds -paragraph split/join intents and structural conflicts. The migration retains +`outline` accepts a target object and the core `onestore::OutlineEdit`: outline +position/width or a paragraph's saved collapse default. It retains the target's +ancestry and the properties the operation changes. Independent content, formatting +and layout properties merge; competing selected values produce `LayoutChanged`, +and changed ancestry produces `StructureChanged`. Width changes also check the +reserved wrapping width that they clear. Missing targets preserve the local branch. +`rebase_layout_conflict(id, local, remote)` explicitly reviews the original change +against both current images, preserving dependent intents. An uncertain layout +attempt requires its original revision; converged values cannot establish its receipt. + +Recognized earlier caches migrate transactionally to version seven, which adds +layout intents and layout conflicts. The migration retains images, local IDs, publication attempts, conflicts, receipts and the autoincrement sequence; it does not reuse acknowledged IDs when the pending queue is empty. @@ -258,5 +268,4 @@ identity return `AssetChanged` and preserve the previous download. Downloads do not change pending edits, publication attempts or receipts. Recovery archives include cached media and expose the same bounded `cached_asset` lookup. -Opening older live caches migrates them transactionally to schema 5; original -schema-4 archives remain readable without migration and contain no media cache. +Schema-4 archives remain readable without migration and contain no media cache. diff --git a/crates/onestore-offline/examples/cache_probe.rs b/crates/onestore-offline/examples/cache_probe.rs index 8f016e89f1cee8b67dc9f82b971ec26e1cce240b..4d5b54d41d963123843e4bbea3e58f1abeffbff8 100644 --- a/crates/onestore-offline/examples/cache_probe.rs +++ b/crates/onestore-offline/examples/cache_probe.rs @@ -138,8 +138,10 @@ fn main() -> Result<(), Box> { font_size = Some(*value); *value as u64 - 6 } - onestore_offline::Operation::Split(_) | onestore_offline::Operation::Join(_) => { - panic!("This fixture does not generate paragraph boundary edits") + onestore_offline::Operation::Split(_) + | onestore_offline::Operation::Join(_) + | onestore_offline::Operation::Outline(_) => { + panic!("Unexpected operation for this fixture") } }; assert!(operations.last().is_none_or(|last| *last < operation)); diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs index 47dc8da3c60b5fcf2cba95c2ea33775402a11ea0..37944b6343929b25dd30da6648ff374dd40137e3 100644 --- a/crates/onestore-offline/src/lib.rs +++ b/crates/onestore-offline/src/lib.rs @@ -10,11 +10,13 @@ use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Durati mod assets; mod formatting; +mod outline; mod paragraph; mod rebase; mod recovery; mod schema; pub use formatting::FormatEdit; +pub use outline::OutlineEdit; pub use paragraph::{JoinEdit, SplitEdit}; pub use recovery::{Recovery, RecoverySummary}; mod sync; @@ -47,7 +49,7 @@ pub enum Error { type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 6; +const SCHEMA_VERSION: u32 = 7; /// Text and its observed precondition, retained across cache reopen and rebasing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -67,6 +69,7 @@ pub enum Operation { Format(FormatEdit), Split(SplitEdit), Join(JoinEdit), + Outline(OutlineEdit), } /// A locally acknowledged intent; its ID remains stable across cache reopen. @@ -279,6 +282,53 @@ impl Replica { } } +fn active_paths( + view: &onestore::document::Revision<'_>, + pages: &[ExGuid], + objects: &[ExGuid], +) -> Option>> { + use std::collections::{BTreeMap, BTreeSet}; + let mut pending = pages.to_vec(); + let mut seen = BTreeSet::new(); + let mut parents = BTreeMap::<_, Vec<_>>::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let node = view.nodes.get(&id)?; + for child in node + .children + .iter() + .chain(&node.content) + .chain(&node.structure) + { + parents.entry(*child).or_default().push(id); + pending.push(*child); + } + } + objects + .iter() + .map(|object| { + if !seen.contains(object) { + return None; + } + let mut path = Vec::new(); + let mut at = *object; + while !pages.contains(&at) { + let [parent] = parents.get(&at)?.as_slice() else { + return None; + }; + if path.len() >= view.nodes.len() { + return None; + } + path.push(*parent); + at = *parent; + } + Some(path) + }) + .collect() +} + fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result> { let store = Store::parse(source)?; let index = RevisionIndex::parse(&store)?; diff --git a/crates/onestore-offline/src/outline.rs b/crates/onestore-offline/src/outline.rs new file mode 100644 index 0000000000000000000000000000000000000000..5be997d22e39d5ed6c184032a4c50d3ad847dc77 --- /dev/null +++ b/crates/onestore-offline/src/outline.rs @@ -0,0 +1,149 @@ +use super::*; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Observed { + path: Vec, + values: Vec, +} + +/// A layout or saved expansion intent with its original ancestry and property values. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutlineEdit { + pub object: ExGuid, + pub change: onestore::OutlineEdit, + observed: Observed, +} + +fn observe( + source: &[u8], + space: ExGuid, + object: ExGuid, + change: onestore::OutlineEdit, +) -> Result> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let Some(space_view) = document.spaces.get(&space) else { + return Ok(None); + }; + let Some(view) = space_view + .contexts + .get(&ExGuid::default()) + .and_then(|rid| space_view.revisions.get(rid)) + else { + return Ok(None); + }; + let pages: Vec<_> = document + .pages()? + .into_iter() + .filter_map(|(sid, page)| (sid == space).then_some(page)) + .collect(); + if pages.len() != 1 { + return Ok(None); + } + let Some(mut paths) = active_paths(view, &pages, &[object]) else { + return Ok(None); + }; + let path = paths.pop().unwrap(); + let node = &view.nodes[&object]; + let values = match (change, &node.kind) { + (onestore::OutlineEdit::Position { .. }, Kind::Outline { .. }) => { + vec![json!(node.layout.x), json!(node.layout.y)] + } + (onestore::OutlineEdit::Width { .. }, Kind::Outline { .. }) => { + vec![ + json!(node.layout.max_width), + json!(node.layout.width_set_by_user.unwrap_or(false)), + json!(node.extra[0].iter().find(|field| field.id == 0x14001cdb)), + ] + } + (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => { + vec![json!(collapse_state.unwrap_or(0))] + } + _ => return Ok(None), + }; + Ok(Some(Observed { path, values })) +} + +impl Replica { + /// Durably records layout or saved expansion changes, preserving independent remote edits. + pub fn outline( + &self, + source: &[u8], + space: ExGuid, + object: ExGuid, + change: onestore::OutlineEdit, + ) -> Result> { + let (edit, prepared) = OutlineEdit::capture(source, space, object, change)?; + self.record(source, space, Operation::Outline(edit), &prepared) + } +} + +impl OutlineEdit { + pub(crate) fn capture<'a>( + source: &'a [u8], + space: ExGuid, + object: ExGuid, + change: onestore::OutlineEdit, + ) -> Result<(Self, PreparedEdit<'a>)> { + let prepared = PreparedEdit::outline(source, space, object, change)?; + let observed = observe(source, space, object, change)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "Prepared outline edit has no active target", + ) + })?; + Ok(( + Self { + object, + change, + observed, + }, + prepared, + )) + } + + pub(crate) fn prepare<'a>( + &self, + snapshot: &'a [u8], + space: ExGuid, + ) -> Result, ConflictKind>> { + let Some(current) = observe(snapshot, space, self.object, self.change)? else { + return Ok(Err(ConflictKind::TargetUnavailable)); + }; + if current.path != self.observed.path { + return Ok(Err(ConflictKind::StructureChanged)); + } + let prepared = match PreparedEdit::outline(snapshot, space, self.object, self.change) { + Ok(prepared) => prepared, + Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), + }; + let wanted = + observe(prepared.as_bytes(), space, self.object, self.change)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "Prepared outline edit lost its target", + ) + })?; + if self.observed.values.len() != current.values.len() + || wanted.values.len() != current.values.len() + { + return Ok(Err(ConflictKind::UnsupportedEdit)); + } + if self + .observed + .values + .iter() + .zip(¤t.values) + .zip(&wanted.values) + .any(|((before, current), wanted)| current != before && current != wanted) + { + return Ok(Err(ConflictKind::LayoutChanged)); + } + Ok(Ok(prepared)) + } +} diff --git a/crates/onestore-offline/src/paragraph.rs b/crates/onestore-offline/src/paragraph.rs index 4454bc7c110c4dba5a7c196ad982a0318886ab5a..8f6f4e6e85fc21ea6467b2e995d349f8e3d1d2a9 100644 --- a/crates/onestore-offline/src/paragraph.rs +++ b/crates/onestore-offline/src/paragraph.rs @@ -2,7 +2,6 @@ use super::*; use onestore::{ParagraphJoin, ParagraphSplit}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -41,49 +40,20 @@ fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result = document + let pages: Vec<_> = document .pages()? .into_iter() .filter_map(|(sid, page)| (sid == space).then_some(page)) .collect(); - let pages = pending.clone(); - let mut seen = BTreeSet::new(); - let mut parents = BTreeMap::<_, Vec<_>>::new(); - while let Some(id) = pending.pop() { - if !seen.insert(id) { - continue; - } - let node = &view.nodes[&id]; - for child in node - .children - .iter() - .chain(&node.content) - .chain(&node.structure) - { - parents.entry(*child).or_default().push(id); - pending.push(*child); - } - } + let Some(paths) = active_paths(view, &pages, texts) else { + return Ok(None); + }; let mut observed = Vec::new(); - for text in texts { - let Some(node) = view.nodes.get(text).filter(|_| seen.contains(text)) else { - return Ok(None); - }; + for (text, path) in texts.iter().zip(paths) { + let node = &view.nodes[text]; let Kind::RichText { text: content, .. } = &node.kind else { return Ok(None); }; - let mut path = Vec::new(); - let mut at = *text; - while !pages.contains(&at) { - let Some([parent]) = parents.get(&at).map(Vec::as_slice) else { - return Ok(None); - }; - if path.len() >= view.nodes.len() { - return Ok(None); - } - path.push(*parent); - at = *parent; - } let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else { return Ok(None); }; diff --git a/crates/onestore-offline/src/schema.rs b/crates/onestore-offline/src/schema.rs index 766da91f0e860c4ea378fe250a4c57771368d34b..224fc70e06b40e9c55b14d76acb3dddb4733293c 100644 --- a/crates/onestore-offline/src/schema.rs +++ b/crates/onestore-offline/src/schema.rs @@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, Transaction}; const CONFLICTS: &str = "CREATE TABLE conflicts ( edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, - kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 4) + kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 5) ) STRICT;"; const ASSETS: &str = "CREATE TABLE assets ( diff --git a/crates/onestore-offline/src/sync.rs b/crates/onestore-offline/src/sync.rs index 9f18010cb7bddce1cddcbbf0e442f6692dd5f4fc..dc58cfc652e30f1c51dd4f479a8e8fdb95428027 100644 --- a/crates/onestore-offline/src/sync.rs +++ b/crates/onestore-offline/src/sync.rs @@ -19,6 +19,7 @@ pub enum ConflictKind { UnsupportedEdit = 2, FormattingChanged = 3, StructureChanged = 4, + LayoutChanged = 5, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -134,6 +135,7 @@ impl Replica { Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, + Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Insert(insertion) => { PreparedEdit::insert(&snapshot, intent.space, insertion) .map_err(|_| ConflictKind::UnsupportedEdit) @@ -297,6 +299,13 @@ impl Replica { ) .into()); } + Operation::Outline(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Review layout changes using rebase_layout_conflict", + ) + .into()); + } Operation::Insert(_) => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -372,6 +381,23 @@ impl Replica { }) } + /// Reviews the original layout intent against both current cache images. + /// Competing property values are replaced only after this explicit review. + pub fn rebase_layout_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { + self.resolve_conflict(id, local, remote, |intent| { + let Operation::Outline(edit) = intent.operation else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Select an outline layout conflict", + ) + .into()); + }; + Ok(Operation::Outline( + OutlineEdit::capture(remote, intent.space, edit.object, edit.change)?.0, + )) + }) + } + fn resolve_conflict( &self, id: u64, @@ -492,6 +518,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result ConflictKind::UnsupportedEdit, 3 => ConflictKind::FormattingChanged, 4 => ConflictKind::StructureChanged, + 5 => ConflictKind::LayoutChanged, _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/onestore-offline/tests/cache.rs b/crates/onestore-offline/tests/cache.rs index 08a28a89bfd8eff88a926e1620a61d8a76bd394f..53fc7a27210003c8bc9faed284c3987f4fd75a0a 100644 --- a/crates/onestore-offline/tests/cache.rs +++ b/crates/onestore-offline/tests/cache.rs @@ -710,7 +710,7 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { #[test] fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { - for operation in ["Text", "Insert", "Format", "Split", "Join"] { + for operation in ["Text", "Insert", "Format", "Split", "Join", "Outline"] { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("unknown.sqlite"); let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); @@ -731,6 +731,26 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { let insertion = onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap(); cache.insert(&source, sid, &insertion).unwrap(); + } else if operation == "Outline" { + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let doc = Document::parse(&index).unwrap(); + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let paragraph = *view + .nodes + .iter() + .find(|(_, node)| node.content == [oid]) + .unwrap() + .0; + cache + .outline( + &source, + sid, + paragraph, + onestore::OutlineEdit::Collapsed(true), + ) + .unwrap(); } else if operation == "Text" { cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap(); } else if operation == "Split" { @@ -776,99 +796,120 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { } #[test] -fn version_five_migration_retains_queue_evidence_and_enables_structural_conflicts() { - use onestore_offline::{ConflictKind, EditStatus, Recovery}; - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("version-five.sqlite"); - let source = onestore::create_section("migration.one", "Original", "Author").unwrap(); - let (sid, oid, _) = target(&source); - let cache = Replica::create(&path, &source).unwrap(); - let first = cache - .edit_text(&source, sid, oid, 0..0, "New ") - .unwrap() +fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_layout_conflicts() { + for (version, ceiling) in [(5, 3), (6, 4)] { + use onestore_offline::{ConflictKind, EditStatus, Recovery}; + use sha2::{Digest, Sha256}; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("previous.sqlite"); + let source = onestore::create_section("migration.one", "Original", "Author").unwrap(); + let (sid, oid, _) = target(&source); + let cache = Replica::create(&path, &source).unwrap(); + let first = cache + .edit_text(&source, sid, oid, 0..0, "New ") + .unwrap() + .unwrap(); + let second = cache + .format( + &cache.snapshot().unwrap(), + sid, + oid, + 0..3, + &[onestore::TextAttribute::Bold(true)], + ) + .unwrap() + .unwrap(); + let queue = cache.pending().unwrap(); + let local = cache.snapshot().unwrap(); + let store = Store::parse(&local).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + let current_version: u32 = db + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + let asset = "00112233-4455-6677-8899-aabbccddeeff.onebin"; + let data = b"retained media"; + db.execute( + "INSERT INTO assets VALUES (?1,?2,?3)", + rusqlite::params![asset, data.as_slice(), Sha256::digest(data).as_slice()], + ) + .unwrap(); + db.execute_batch(&format!( + "DROP TABLE conflicts; CREATE TABLE conflicts ( + edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, + kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT; + PRAGMA user_version={version}; + UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';" + )) .unwrap(); - let second = cache - .format( - &cache.snapshot().unwrap(), - sid, - oid, - 0..3, - &[onestore::TextAttribute::Bold(true)], + db.execute( + "INSERT INTO conflicts VALUES (?1,3)", + [i64::try_from(second).unwrap()], ) - .unwrap() .unwrap(); - let queue = cache.pending().unwrap(); - let local = cache.snapshot().unwrap(); - let store = Store::parse(&local).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - db.execute_batch( - "DROP TABLE conflicts; CREATE TABLE conflicts ( - edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, - kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)) STRICT; - PRAGMA user_version=5; - UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';", - ) - .unwrap(); - db.execute( - "INSERT INTO conflicts VALUES (?1,3)", - [i64::try_from(second).unwrap()], - ) - .unwrap(); - db.execute( - "INSERT INTO attempt VALUES (1,?1,?2)", - rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()], - ) - .unwrap(); - db.execute( - "INSERT INTO receipts VALUES (999,?1)", - [revision.to_string()], - ) - .unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), queue); - assert!(cache.snapshot().unwrap() == local); - assert!(cache.remote_snapshot().unwrap() == source); - assert_eq!( - cache.status(first).unwrap(), - Some(EditStatus::AwaitingConfirmation { revision }) - ); - assert_eq!( - cache.status(second).unwrap(), - Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) - ); - assert_eq!( - cache.status(999).unwrap(), - Some(EditStatus::Published { revision }) - ); - let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); - assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001)); - let pending = cache.pending().unwrap(); - let archive = directory.path().join("recovery.sqlite"); - cache.export_recovery(&archive).unwrap(); - let recovery = Recovery::open(&archive).unwrap(); - assert_eq!(recovery.pending().unwrap(), pending); - assert_eq!( - recovery.status(first).unwrap(), - cache.status(first).unwrap() - ); - assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision)); - drop(cache); - let db = rusqlite::Connection::open(&path).unwrap(); - assert_eq!( - db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) - .unwrap(), - 6 - ); - db.execute("INSERT INTO conflicts VALUES (1001,4)", []) + db.execute( + "INSERT INTO attempt VALUES (1,?1,?2)", + rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()], + ) + .unwrap(); + db.execute( + "INSERT INTO receipts VALUES (999,?1)", + [revision.to_string()], + ) .unwrap(); - drop(db); - let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(1001).unwrap(), - Some(EditStatus::Conflict(ConflictKind::StructureChanged)) - ); + drop(db); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!( + cache.cached_asset(asset, 1024).unwrap(), + Some(data.to_vec()) + ); + assert!(cache.snapshot().unwrap() == local); + assert!(cache.remote_snapshot().unwrap() == source); + assert_eq!( + cache.status(first).unwrap(), + Some(EditStatus::AwaitingConfirmation { revision }) + ); + assert_eq!( + cache.status(second).unwrap(), + Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) + ); + assert_eq!( + cache.status(999).unwrap(), + Some(EditStatus::Published { revision }) + ); + let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); + assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001)); + let pending = cache.pending().unwrap(); + let archive = directory.path().join("recovery.sqlite"); + cache.export_recovery(&archive).unwrap(); + let recovery = Recovery::open(&archive).unwrap(); + assert_eq!(recovery.pending().unwrap(), pending); + assert_eq!( + recovery.cached_asset(asset, 1024).unwrap(), + Some(data.to_vec()) + ); + assert_eq!( + recovery.status(first).unwrap(), + cache.status(first).unwrap() + ); + assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision)); + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + assert_eq!( + db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + .unwrap(), + current_version + ); + db.execute("INSERT INTO conflicts VALUES (1001,5)", []) + .unwrap(); + drop(db); + let cache = Replica::open(&path).unwrap(); + assert_eq!( + cache.status(1001).unwrap(), + Some(EditStatus::Conflict(ConflictKind::LayoutChanged)) + ); + } } diff --git a/crates/onestore-offline/tests/sync.rs b/crates/onestore-offline/tests/sync.rs index 72f5f91088e951a2b0fe71ac7b683cbe3619d936..5cfe32abc28e3a5c351f0763c60d72859e2c2f40 100644 --- a/crates/onestore-offline/tests/sync.rs +++ b/crates/onestore-offline/tests/sync.rs @@ -5,6 +5,9 @@ use onestore::{ use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica}; use std::io; +#[path = "sync/outline.rs"] +mod outline; + mod paragraph { use super::*; use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute}; @@ -1657,6 +1660,9 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() { let pending = cache.pending().unwrap(); drop(cache); let db = rusqlite::Connection::open(&path).unwrap(); + let current_version: u32 = db + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); db.execute_batch( "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets; CREATE TABLE edits ( @@ -1683,7 +1689,7 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() { assert_eq!( db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) .unwrap(), - 6 + current_version ); } diff --git a/crates/onestore-offline/tests/sync/outline.rs b/crates/onestore-offline/tests/sync/outline.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f7cb4c9aa997256b49387f2f6339443f03f4d34 --- /dev/null +++ b/crates/onestore-offline/tests/sync/outline.rs @@ -0,0 +1,656 @@ +use super::*; +use onestore::OutlineEdit as Change; + +fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid, ExGuid) { + let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap(); + let (sid, text, _) = text(&source); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let paragraph = *view + .nodes + .iter() + .find(|(_, node)| node.content == [text]) + .unwrap() + .0; + let outline = *view + .nodes + .iter() + .find(|(_, node)| node.children.contains(¶graph)) + .unwrap() + .0; + (source, sid, outline, paragraph, text) +} + +fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + serde_json::to_value(&view.nodes[&object]).unwrap() +} + +#[test] +fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() { + let (source, sid, outline, paragraph, text_id) = fixture(); + for (object, change) in [ + (outline, Change::Position { x: 180.0, y: 216.0 }), + ( + outline, + Change::Width { + points: 144.0, + user_set: true, + }, + ), + (paragraph, Change::Collapsed(true)), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = cache + .outline(&source, sid, object, change) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let expected = node(&local, sid, object); + let dependent = cache + .edit_text(&local, sid, text_id, 0..0, "Local ") + .unwrap() + .unwrap(); + let pending = cache.pending().unwrap(); + let local = cache.snapshot().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), pending); + assert!(cache.snapshot().unwrap() == local); + let remote = PreparedEdit::format( + &source, + sid, + text_id, + 0..8, + &[onestore::TextAttribute::Bold(true)], + ) + .unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) + ); + let actual = node(&server.durable, sid, object); + assert_eq!(actual["layout"], expected["layout"]); + assert_eq!(actual["kind"], expected["kind"]); + assert_eq!(cache.pending().unwrap().len(), 1); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==dependent) + ); + assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); + assert!(cache.pending().unwrap().is_empty()); + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert!(view.text_runs(text_id).unwrap()[0].format.bold.unwrap()); + assert_eq!(server.publications, 2); + drop(cache); + let cache = Replica::open(&path).unwrap(); + for id in [id, dependent] { + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + } + } +} + +#[test] +fn competing_layout_requires_current_review_and_preserves_dependent_edits() { + let (source, sid, outline, _, text_id) = fixture(); + for (local_change, remote_change) in [ + ( + Change::Position { x: 180.0, y: 216.0 }, + Change::Position { x: 288.0, y: 216.0 }, + ), + ( + Change::Width { + points: 144.0, + user_set: true, + }, + Change::Width { + points: 216.0, + user_set: false, + }, + ), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = cache + .outline(&source, sid, outline, local_change) + .unwrap() + .unwrap(); + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let queue = cache.pending().unwrap(); + let remote = PreparedEdit::outline(&source, sid, outline, remote_change).unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) + ); + assert_eq!(server.publications, 0); + assert_eq!(cache.pending().unwrap(), queue); + assert!(cache.snapshot().unwrap() == local); + assert!( + cache + .rebase_layout_conflict(id, &source, &server.visible) + .is_err() + ); + assert!(cache.rebase_layout_conflict(id, &local, &source).is_err()); + assert!( + cache + .rebase_conflict(id, &local, &server.visible, 0..0) + .is_err() + ); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!( + cache.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::LayoutChanged)) + ); + cache + .rebase_layout_conflict(id, &local, &server.visible) + .unwrap(); + let reviewed = cache.pending().unwrap(); + assert_eq!(reviewed[1], queue[1]); + assert!(cache.snapshot().unwrap() == local); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), reviewed); + for id in [id, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + } + assert_eq!( + node(&server.durable, sid, outline)["layout"], + node(&local, sid, outline)["layout"] + ); + assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); + } +} + +#[test] +fn independent_axes_converge_without_overwriting_competing_values() { + let (source, sid, outline, _, _) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let original = node(&source, sid, outline); + let local = Change::Position { x: 180.0, y: 216.0 }; + let id = cache + .outline(&source, sid, outline, local) + .unwrap() + .unwrap(); + let remote = PreparedEdit::outline( + &source, + sid, + outline, + Change::Position { + x: original["layout"]["x"].as_f64().unwrap() as f32, + y: 216.0, + }, + ) + .unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) + ); + let actual = node(&server.durable, sid, outline); + assert_eq!(actual["layout"]["x"], 180.0); + assert_eq!(actual["layout"]["y"], 216.0); +} + +#[test] +fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() { + let (source, sid, outline, paragraph, _) = fixture(); + let directory = tempfile::tempdir().unwrap(); + std::thread::scope(|scope| { + let mut writers = Vec::new(); + for actor in 0..12 { + let source = &source; + let path = directory.path().join(format!("{actor}.sqlite")); + writers.push(scope.spawn(move || { + let cache = Replica::create(&path, source).unwrap(); + for round in 0..4 { + for (object, change) in [ + ( + outline, + Change::Position { + x: (actor + 2) as f32 * 36.0, + y: (round + 2) as f32 * 36.0, + }, + ), + ( + outline, + Change::Width { + points: (actor + round + 2) as f32 * 36.0, + user_set: true, + }, + ), + (paragraph, Change::Collapsed((actor + round) % 2 != 0)), + ] { + assert!( + cache + .outline(&cache.snapshot().unwrap(), sid, object, change) + .unwrap() + .is_some() + ); + } + } + assert_eq!(cache.pending().unwrap().len(), 12); + })); + } + for writer in writers { + writer.join().unwrap(); + } + }); + let mut server = Server::new(&source); + let mut expected_layout = node(&source, sid, outline)["layout"].clone(); + let mut expected_collapse = None; + let mut reviewed = 0; + for round in 0..12 { + for actor in 0..12 { + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = Replica::open(&path).unwrap(); + let first = cache.pending().unwrap()[0].clone(); + let onestore_offline::Operation::Outline(edit) = first.operation else { + panic!() + }; + let local = cache.snapshot().unwrap(); + let result = cache.sync_once(&mut server).unwrap(); + if result == Some((first.id, EditStatus::Conflict(ConflictKind::LayoutChanged))) { + let remote = cache.remote_snapshot().unwrap(); + cache + .rebase_layout_conflict(first.id, &local, &remote) + .unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == first.id) + ); + reviewed += 1; + } else { + assert!( + matches!(result, Some((id, EditStatus::Published { .. })) if id == first.id) + ); + } + match edit.change { + Change::Position { x, y } => { + expected_layout["x"] = x.into(); + expected_layout["y"] = y.into(); + } + Change::Width { points, user_set } => { + expected_layout["max_width"] = points.into(); + expected_layout["width_set_by_user"] = user_set.into(); + } + Change::Collapsed(value) => expected_collapse = Some(u8::from(value)), + } + assert_eq!( + node(&server.durable, sid, outline)["layout"], + expected_layout + ); + assert_eq!( + node(&server.durable, sid, paragraph)["kind"]["collapse_state"], + serde_json::json!(expected_collapse) + ); + assert_eq!(text(&server.durable).2, "Original 🦀 é"); + } + for actor in 0..12 { + let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); + assert_eq!(cache.pending().unwrap().len(), 11 - round); + } + } + assert!(reviewed >= 12); + for actor in 0..12 { + let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); + assert!(cache.pending().unwrap().is_empty()); + for id in 1..=12 { + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + } + } +} + +#[test] +fn uncertain_layout_is_confirmed_by_revision_and_never_by_converged_values() { + let (source, sid, outline, _, text_id) = fixture(); + for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { + let before = matches!(fault, Fault::UnknownBefore); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let change = Change::Width { + points: 144.0, + user_set: true, + }; + let id = cache + .outline(&source, sid, outline, change) + .unwrap() + .unwrap(); + cache + .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") + .unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = Server::new(&source); + server.fault = fault; + assert!(cache.sync_once(&mut server).is_err()); + let status = cache.status(id).unwrap().unwrap(); + assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); + drop(cache); + let cache = Replica::open(&path).unwrap(); + if before { + let converged = PreparedEdit::outline(&source, sid, outline, change).unwrap(); + server.visible = converged.as_bytes().to_vec(); + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); + assert!( + cache + .rebase_layout_conflict(id, &local, &server.visible) + .is_err() + ); + assert_eq!(cache.pending().unwrap().len(), 2); + assert!(cache.snapshot().unwrap() == local); + let archive = directory.path().join("recovery.sqlite"); + cache.export_recovery(&archive).unwrap(); + let recovery = onestore_offline::Recovery::open(&archive).unwrap(); + assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); + assert_eq!(recovery.status(id).unwrap(), Some(status)); + assert_eq!(server.confirmations, 0); + } else { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) + ); + assert_eq!( + node(&server.durable, sid, outline)["layout"]["max_width"], + 144.0 + ); + assert_eq!(server.confirmations, 1); + } + assert_eq!(server.publications, 1); + } +} + +#[test] +fn native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts() { + let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); + let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut server = Server::new(native); + let mut counts = [0; 3]; + let mut records = Vec::new(); + for (sid, page) in document.pages().unwrap() { + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Kind::Metadata { + title: Some(name), .. + } = &view.nodes[&view.roots[&2]].kind + else { + continue; + }; + if name == "Unicode rich text" { + continue; + } + let outlines: Vec<_> = view.nodes[&page] + .children + .iter() + .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .copied() + .collect(); + if outlines.len() != 2 { + continue; + } + let outline = outlines[0]; + let mut pending = vec![outline]; + let mut target = None; + while let Some(id) = pending.pop() { + let node = &view.nodes[&id]; + pending.extend(&node.children); + if node.content.first().is_some_and(|text| matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))) { + target = Some(id); + } + } + let target = target.unwrap(); + let text_id = view.nodes[&view.nodes[&outlines[1]].children[0]].content[0]; + let (object, change, conflict) = match name.as_str() { + "Move outline" => ( + outline, + Change::Position { x: 252.0, y: 288.0 }, + Some(ConflictKind::LayoutChanged), + ), + "Resize outline" | "Automatic outline size" => ( + outline, + Change::Width { + points: 252.0, + user_set: true, + }, + Some(ConflictKind::LayoutChanged), + ), + "Delete outline" => ( + outline, + Change::Width { + points: 252.0, + user_set: true, + }, + Some(ConflictKind::TargetUnavailable), + ), + "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( + target, + Change::Collapsed(true), + Some(ConflictKind::TargetUnavailable), + ), + "Indent subtree" | "Outdent subtree" => ( + target, + Change::Collapsed(true), + Some(ConflictKind::StructureChanged), + ), + "Expand subtree" => (target, Change::Collapsed(false), None), + "Collapse subtree" | "Move leaf down" | "Move subtree down" | "Move subtree up" => { + (target, Change::Collapsed(true), None) + } + _ => panic!("Unexpected native case {name}"), + }; + let expected_layout = if conflict == Some(ConflictKind::TargetUnavailable) { + None + } else { + let mut layout = node(&server.visible, sid, object)["layout"].clone(); + match change { + Change::Position { x, y } => { + layout["x"] = x.into(); + layout["y"] = y.into(); + } + Change::Width { points, user_set } => { + layout["max_width"] = points.into(); + layout["width_set_by_user"] = user_set.into(); + } + Change::Collapsed(_) => {} + } + Some(layout) + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, source).unwrap(); + let id = cache.outline(source, sid, object, change).unwrap().unwrap(); + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let queue = cache.pending().unwrap(); + let result = cache.sync_once(&mut server).unwrap().unwrap(); + let mut retained = false; + if let Some(kind) = conflict { + assert_eq!(result, (id, EditStatus::Conflict(kind)), "{name}"); + assert_eq!(cache.pending().unwrap(), queue); + assert!(cache.snapshot().unwrap() == local); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Conflict(kind))); + if kind == ConflictKind::TargetUnavailable { + assert!( + cache + .rebase_layout_conflict(id, &local, &server.visible) + .is_err() + ); + counts[2] += 1; + retained = true; + } else { + cache + .rebase_layout_conflict(id, &local, &server.visible) + .unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + counts[1] += 1; + } + } else { + assert!( + matches!(result, (n, EditStatus::Published { .. }) if n == id), + "{name}" + ); + counts[0] += 1; + drop(cache); + } + let cache = Replica::open(&path).unwrap(); + if retained { + assert_eq!(cache.pending().unwrap(), queue); + assert!(cache.snapshot().unwrap() == local); + } else { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == dependent) + ); + assert_eq!( + node(&server.durable, sid, object)["layout"], + expected_layout.unwrap(), + "{name}" + ); + if matches!(change, Change::Collapsed(_)) { + assert_eq!( + node(&server.durable, sid, object)["kind"]["collapse_state"], + node(&local, sid, object)["kind"]["collapse_state"] + ); + } + assert!( + node(&server.durable, sid, text_id)["kind"]["text"] + .as_str() + .unwrap() + .starts_with("Local ") + ); + drop(cache); + let cache = Replica::open(&path).unwrap(); + for id in [id, dependent] { + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + } + } + records.push(serde_json::json!({"name": name, "space": sid, "object": object, "change": change, "retained": retained, "dependent_text": text_id})); + } + assert_eq!(counts, [5, 5, 4]); + assert_eq!(server.publications, 18); + if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") { + let output = std::path::PathBuf::from(output); + assert!(output.is_absolute()); + std::fs::create_dir(&output).unwrap(); + std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); + std::fs::write( + output.with_extension("json"), + serde_json::to_vec_pretty(&records).unwrap(), + ) + .unwrap(); + } +} + +#[test] +fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { + let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); + let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { + let space = &document.spaces[sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(name), .. } if name=="Move outline") + }).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let outline = *view.nodes[&page] + .children + .iter() + .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .unwrap(); + let old = node(source, sid, outline); + let remote = node(native, sid, outline); + assert_eq!(old["layout"]["max_width"], remote["layout"]["max_width"]); + assert_eq!( + old["layout"]["width_set_by_user"], + remote["layout"]["width_set_by_user"] + ); + assert!( + remote["extra"][0] + .as_array() + .unwrap() + .iter() + .any(|field| field["id"] == 0x14001cdb_u32) + ); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap(); + let id = cache + .outline( + source, + sid, + outline, + Change::Width { + points: 144.0, + user_set: true, + }, + ) + .unwrap() + .unwrap(); + let mut server = Server::new(native); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) + ); + cache + .rebase_layout_conflict(id, &cache.snapshot().unwrap(), &server.visible) + .unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) + ); + let after = node(&server.durable, sid, outline); + assert_eq!(after["layout"]["x"], remote["layout"]["x"]); + assert_eq!(after["layout"]["y"], remote["layout"]["y"]); + assert_eq!(after["layout"]["max_width"], 144.0); + assert!( + !after["extra"][0] + .as_array() + .unwrap() + .iter() + .any(|field| field["id"] == 0x14001cdb_u32) + ); +} diff --git a/fuzz/fuzz_targets/offline_paragraph.rs b/fuzz/fuzz_targets/offline_paragraph.rs index b826400660dbcc2ca393a2c005adfef6887fa229..3ad34c04feb29be8e5f91212f66b9df55d80a380 100644 --- a/fuzz/fuzz_targets/offline_paragraph.rs +++ b/fuzz/fuzz_targets/offline_paragraph.rs @@ -1,10 +1,11 @@ #![no_main] use libfuzzer_sys::fuzz_target; use onestore::{ - CommitError, ExGuid, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, Store, + CommitError, ExGuid, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, + Store, document::{Document, Kind}, }; -use onestore_offline::{EditStatus, Remote, Replica}; +use onestore_offline::{EditStatus, Operation, Remote, Replica}; use std::{io, sync::LazyLock}; #[path = "../../crates/onestore/tests/support/disk.rs"] @@ -94,7 +95,7 @@ fuzz_target!(|input: &[u8]| { let snapshot = cache.snapshot().unwrap(); let pending = cache.pending().unwrap(); let rows = paragraphs(&snapshot, *sid, *outline); - match step[1] % 5 { + match step[1] % 8 { 0 => { let (text, content) = &rows[usize::from(step[2]) % rows.len()]; let offsets: Vec = std::iter::once(0) @@ -147,7 +148,76 @@ fuzz_target!(|input: &[u8]| { } server.0.visible.clone_from(&server.0.durable); } + 5..=7 => { + let store = Store::parse(&snapshot).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let (object, change) = match step[1] % 8 { + 5 => ( + *outline, + OutlineEdit::Position { + x: f32::from(step[2]) * 18.0, + y: f32::from(step[3]) * 18.0, + }, + ), + 6 => ( + *outline, + OutlineEdit::Width { + points: (2.0 + f32::from(step[2])) * 18.0, + user_set: step[3] & 1 != 0, + }, + ), + _ => ( + view.nodes[outline].children[usize::from(step[2]) % rows.len()], + OutlineEdit::Collapsed(step[3] & 1 != 0), + ), + }; + let mut expected = serde_json::to_value(&view.nodes[&object]).unwrap(); + match change { + OutlineEdit::Position { x, y } => { + expected["layout"]["x"] = x.into(); + expected["layout"]["y"] = y.into(); + } + OutlineEdit::Width { points, user_set } => { + expected["layout"]["max_width"] = points.into(); + expected["layout"]["width_set_by_user"] = user_set.into(); + } + OutlineEdit::Collapsed(value) => { + expected["kind"]["collapse_state"] = u8::from(value).into() + } + } + let id = cache.outline(&snapshot, *sid, object, change).unwrap(); + let next = cache.pending().unwrap(); + assert_eq!(next[..pending.len()], pending); + assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); + if let Some(id) = id { + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + } + let bytes = cache.snapshot().unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let actual = serde_json::to_value(&view.nodes[&object]).unwrap(); + for key in ["kind", "layout", "children", "content"] { + assert_eq!(actual[key], expected[key]); + } + } _ => { + if let Some(edit) = pending.first() + && matches!(edit.operation, Operation::Outline(_)) + && matches!( + cache.status(edit.id).unwrap(), + Some(EditStatus::Conflict(_)) + ) + { + let remote = cache.remote_snapshot().unwrap(); + let _ = cache.rebase_layout_conflict(edit.id, &snapshot, &remote); + } + let pending = cache.pending().unwrap(); drop(replicas[actor].take()); let reopened = Replica::open(&path).unwrap(); assert!(reopened.snapshot().unwrap() == snapshot); diff --git a/tools/test_outline_edit.py b/tools/test_outline_edit.py index a356bbbfcb154765fffda6321e7686e050aa60e0..a3be969412702df7e382223278dbd7ef531011f3 100644 --- a/tools/test_outline_edit.py +++ b/tools/test_outline_edit.py @@ -37,7 +37,7 @@ class OutlineEditTest(unittest.TestCase): self.assertEqual(page, saved_page) for oid, node in walk(old, page): actual = new['nodes'][oid] - for key in ('children', 'content', 'structure', 'child_level'): + for key in ('children', 'content', 'structure', 'child_level', 'layout'): self.assertEqual(actual[key], node[key], (name, oid, key)) self.assertEqual(actual['kind'].get('collapse_state'), node['kind'].get('collapse_state')) if node['kind']['type'] == 'RichText': @@ -86,6 +86,49 @@ class OutlineEditTest(unittest.TestCase): self.assertEqual(size.get('isSetByUser'), 'true') self.assertAlmostEqual(float(size.get('width')), 144, places=3) + def test_offline_native_reconciliation_preserves_graph_and_dependent_edits(self): + models, _ = self.cold_layout(FIXTURE / 'offline') + records = json.loads((FIXTURE / 'offline/cases.json').read_text()) + self.assertEqual(len(records), 14) + self.assertEqual(sum(row['retained'] for row in records), 4) + records = {row['name']: row for row in records} + with TemporaryDirectory() as temporary: + folder = Path(temporary) + subprocess.run([EXPORTER, FIXTURE / 'after/notebook/synthetic.one', folder / 'model'], check=True) + model = json.loads((folder / 'model/document.json').read_text()) + remote = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page) + for _, _, r, page in ordered_pages(model)} + changed = texts = 0 + for name, (candidate, page) in models['candidate'].items(): + original, remote_page = remote[name] + self.assertEqual(page, remote_page) + record = records.get(name) + for oid, node in walk(candidate, page): + before = original['nodes'][oid] + for key in ('children', 'content', 'structure', 'child_level'): + self.assertEqual(node[key], before[key], (name, oid, key)) + layout = dict(before['layout']) + if record and not record['retained'] and oid == record['object']: + change = record['change'] + if 'Position' in change: + layout.update(change['Position']) + elif 'Width' in change: + layout['max_width'] = change['Width']['points'] + layout['width_set_by_user'] = change['Width']['user_set'] + else: + self.assertEqual(node['kind']['collapse_state'], int(change['Collapsed'])) + changed += 1 + else: + self.assertEqual(node['kind'].get('collapse_state'), before['kind'].get('collapse_state')) + self.assertEqual(node['layout'], layout) + if node['kind']['type'] == 'RichText': + expected = before['kind']['text'] + if record and not record['retained'] and oid == record['dependent_text']: + expected = 'Local ' + expected + texts += 1 + self.assertEqual(node['kind']['text'], expected) + self.assertEqual((changed, texts), (10, 10)) + def preserved_node(self, old, new): expected = dict(old) if old['modified'] != new['modified']: -- 2.54.0