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 Binary files /dev/null and b/corpus/outline-edit/offline/candidate/synthetic.one differ diff --git a/corpus/outline-edit/offline/cases.json b/corpus/outline-edit/offline/cases.json new file mode 100644 index 0000000000000000000000000000000000000000..40623c317bc500504538eb48cc5f9437477aa7c6 --- /dev/null +++ b/corpus/outline-edit/offline/cases.json @@ -0,0 +1,154 @@ +[ + { + "change": { + "Collapsed": true + }, + "dependent_text": "{033CC036-70D6-02D3-2869-6264552F7B9E},64", + "name": "Move leaf down", + "object": "{033CC036-70D6-02D3-2869-6264552F7B9E},45", + "retained": false, + "space": "{B7267338-BF83-0893-27A7-37F606DD20C8},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{E79A0442-BFCF-04C6-1E04-364B68877B88},75", + "name": "Move subtree down", + "object": "{E79A0442-BFCF-04C6-1E04-364B68877B88},45", + "retained": false, + "space": "{9B80D7CA-18B4-02B0-1E85-6E8AA0DAD827},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{3F47069A-F0C5-053E-1DEC-13B552EEE2A0},75", + "name": "Move subtree up", + "object": "{3F47069A-F0C5-053E-1DEC-13B552EEE2A0},45", + "retained": false, + "space": "{7538095C-389B-07D8-2991-B2BC46EBD52A},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{5A89330E-0803-08CF-2BF5-26621F998039},75", + "name": "Indent subtree", + "object": "{5A89330E-0803-08CF-2BF5-26621F998039},45", + "retained": false, + "space": "{651463E3-C768-0C93-363D-FCE642B07408},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{1A8D8A0B-E96E-01B0-3DA2-EF182AA19042},75", + "name": "Outdent subtree", + "object": "{1A8D8A0B-E96E-01B0-3DA2-EF182AA19042},45", + "retained": false, + "space": "{8384083C-60ED-0F7C-2F15-8B20B878B3EB},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{7375C8D1-28F0-0B05-00E5-093957D7F5F1},75", + "name": "Collapse subtree", + "object": "{7375C8D1-28F0-0B05-00E5-093957D7F5F1},45", + "retained": false, + "space": "{FF88B513-029C-00E2-0082-9E3DCE1D7796},1" + }, + { + "change": { + "Collapsed": false + }, + "dependent_text": "{B11DD73E-4234-04EF-0F4D-1F67E8F92C22},75", + "name": "Expand subtree", + "object": "{B11DD73E-4234-04EF-0F4D-1F67E8F92C22},45", + "retained": false, + "space": "{850B34E4-D62A-042B-138A-B2A905CEE28E},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{DC976FEC-CD30-0C41-20AD-BB462A203464},64", + "name": "Delete leaf", + "object": "{DC976FEC-CD30-0C41-20AD-BB462A203464},45", + "retained": true, + "space": "{363FE055-44E1-0B27-14A8-46278FD681FF},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{73987DA5-C014-0EEA-00F8-5C1E149736E4},75", + "name": "Delete subtree", + "object": "{73987DA5-C014-0EEA-00F8-5C1E149736E4},45", + "retained": true, + "space": "{8BBAD88F-D248-0959-00B3-2438A7065330},1" + }, + { + "change": { + "Collapsed": true + }, + "dependent_text": "{C868DD34-6E8C-07E9-116E-4C68D2C45DFC},54", + "name": "Delete only paragraph", + "object": "{C868DD34-6E8C-07E9-116E-4C68D2C45DFC},40", + "retained": true, + "space": "{A4CB3BA2-767F-0AE8-0916-AF142D995822},1" + }, + { + "change": { + "Width": { + "points": 252.0, + "user_set": true + } + }, + "dependent_text": "{78796D8B-FB39-02D8-2502-22530F00073C},75", + "name": "Delete outline", + "object": "{78796D8B-FB39-02D8-2502-22530F00073C},39", + "retained": true, + "space": "{80E80543-87A1-0C3A-0816-22B190BDFC87},1" + }, + { + "change": { + "Position": { + "x": 252.0, + "y": 288.0 + } + }, + "dependent_text": "{462B969B-FCFA-0201-073A-0B4C3C23ABF5},75", + "name": "Move outline", + "object": "{462B969B-FCFA-0201-073A-0B4C3C23ABF5},39", + "retained": false, + "space": "{16207F40-69C9-0CA1-09B5-DC5712DB2BBE},1" + }, + { + "change": { + "Width": { + "points": 252.0, + "user_set": true + } + }, + "dependent_text": "{048364F9-D353-05E3-319E-2633D33434CE},75", + "name": "Resize outline", + "object": "{048364F9-D353-05E3-319E-2633D33434CE},39", + "retained": false, + "space": "{CD420421-73F7-0F11-02EC-35FCA348998C},1" + }, + { + "change": { + "Width": { + "points": 252.0, + "user_set": true + } + }, + "dependent_text": "{CE365E1B-9B76-067E-2EC7-220FB6AC76E9},75", + "name": "Automatic outline size", + "object": "{CE365E1B-9B76-067E-2EC7-220FB6AC76E9},39", + "retained": false, + "space": "{F383096F-2121-0225-1E62-AB37C9B15B33},1" + } +] \ No newline at end of file diff --git a/corpus/outline-edit/offline/cold/commands.jsonl b/corpus/outline-edit/offline/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..3ca2a0d7a0a8100956f32249e22b0b52c430db17 --- /dev/null +++ b/corpus/outline-edit/offline/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-B0D8A07E -ExpectedPages 15", "exit": 0, "stdout": "Read 1 sections and 15 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/outline-edit/offline/cold/machine.json b/corpus/outline-edit/offline/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..2fddeba2bd756293074dce48eecba902509d105e --- /dev/null +++ b/corpus/outline-edit/offline/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-b0d8a07e", "hostname": "ONE-M6-B0D8A07E"} diff --git a/corpus/outline-edit/offline/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/offline/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..ca2d23e914168eb4e1cf10450afb22b8af9e73cf Binary files /dev/null and b/corpus/outline-edit/offline/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/offline/cold/notebook/synthetic.one b/corpus/outline-edit/offline/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..3d2228b42f3bf92091ce641f1caf3ab4a11d11eb Binary files /dev/null and b/corpus/outline-edit/offline/cold/notebook/synthetic.one differ 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']: