diff --git a/corpus/outline-edit/README.md b/corpus/outline-edit/README.md index a0ee8573986ceefb25f5f676ddf57ee9dae3748d..289eb81d93d2539ecd768a063bc11f0dfea1b3f6 100644 --- a/corpus/outline-edit/README.md +++ b/corpus/outline-edit/README.md @@ -128,3 +128,21 @@ plus the `tree::tests::group_normalization_preserves_unequal_indentation_and_ove unit test. Cold-open the resulting `ordinary`, `groups-cells`, `cross-container` and `unequal-groups` notebooks with expected page counts 15, 12, 12 and 1, respectively, using `tools/native_runner.py --collect-notebook`. + +`offline-tree` reconciles moves/deletions and dependent text edits against the +fourteen ordinary native controls. Three already-converged moves confirm without +publication, seven competing structure/content changes require explicit review, +and four deleted targets retain the complete local branch. The cold capture +preserves all 247 active graph objects across fifteen pages, three tags, and +6,497 explicit character-format comparisons per image. Its `cases.json` records +the native input cases and outcomes. Regenerate with the offline sync test +`native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content`, +setting `ONESTORE_OFFLINE_TREE_OUTPUT` to a new absolute directory, then cold-open +its `candidate` with `--expected-pages 15 --collect-notebook`. + +Its `cell-delete` and `cell-move` captures additionally verify local edits to an +emptied cell's replacement paragraph while preserving remote text in the other +cell. They retain 272 and 274 active objects, respectively, with 8,201 and 8,377 +explicit format comparisons per image. Regenerate with the offline sync test +`emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay`, +using the same output variable; cold-open each candidate with twelve expected pages. diff --git a/corpus/outline-edit/offline-tree/candidate/synthetic.one b/corpus/outline-edit/offline-tree/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..685ee81cbcd88ee73564b37f6e8118bc42803213 Binary files /dev/null and b/corpus/outline-edit/offline-tree/candidate/synthetic.one differ diff --git a/corpus/outline-edit/offline-tree/cases.json b/corpus/outline-edit/offline-tree/cases.json new file mode 100644 index 0000000000000000000000000000000000000000..f88e7b7ac84f4c18112867710fad315bfb7fec0b --- /dev/null +++ b/corpus/outline-edit/offline-tree/cases.json @@ -0,0 +1,72 @@ +[ + { + "page": "Move leaf down", + "result": "converged", + "space": "{B7267338-BF83-0893-27A7-37F606DD20C8},1" + }, + { + "page": "Move subtree down", + "result": "converged", + "space": "{9B80D7CA-18B4-02B0-1E85-6E8AA0DAD827},1" + }, + { + "page": "Move subtree up", + "result": "converged", + "space": "{7538095C-389B-07D8-2991-B2BC46EBD52A},1" + }, + { + "page": "Indent subtree", + "result": "reviewed", + "space": "{651463E3-C768-0C93-363D-FCE642B07408},1" + }, + { + "page": "Outdent subtree", + "result": "reviewed", + "space": "{8384083C-60ED-0F7C-2F15-8B20B878B3EB},1" + }, + { + "page": "Collapse subtree", + "result": "reviewed", + "space": "{FF88B513-029C-00E2-0082-9E3DCE1D7796},1" + }, + { + "page": "Expand subtree", + "result": "reviewed", + "space": "{850B34E4-D62A-042B-138A-B2A905CEE28E},1" + }, + { + "page": "Delete leaf", + "result": "retained", + "space": "{363FE055-44E1-0B27-14A8-46278FD681FF},1" + }, + { + "page": "Delete subtree", + "result": "retained", + "space": "{8BBAD88F-D248-0959-00B3-2438A7065330},1" + }, + { + "page": "Delete only paragraph", + "result": "retained", + "space": "{A4CB3BA2-767F-0AE8-0916-AF142D995822},1" + }, + { + "page": "Delete outline", + "result": "retained", + "space": "{80E80543-87A1-0C3A-0816-22B190BDFC87},1" + }, + { + "page": "Move outline", + "result": "reviewed", + "space": "{16207F40-69C9-0CA1-09B5-DC5712DB2BBE},1" + }, + { + "page": "Resize outline", + "result": "reviewed", + "space": "{CD420421-73F7-0F11-02EC-35FCA348998C},1" + }, + { + "page": "Automatic outline size", + "result": "reviewed", + "space": "{F383096F-2121-0225-1E62-AB37C9B15B33},1" + } +] \ No newline at end of file diff --git a/corpus/outline-edit/offline-tree/cell-delete/candidate/synthetic.one b/corpus/outline-edit/offline-tree/cell-delete/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..d436dd78487631e41e1449151855924eec9debf4 Binary files /dev/null and b/corpus/outline-edit/offline-tree/cell-delete/candidate/synthetic.one differ diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/commands.jsonl b/corpus/outline-edit/offline-tree/cell-delete/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..376f0fbbc073e2a0fde448f5a6d6a462e418c7e5 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/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-37ACD671 -ExpectedPages 12", "exit": 0, "stdout": "Read 1 sections and 12 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-tree/cell-delete/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/offline-tree/cell-delete/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6aaa8771213ea8929090e4f9321a8f42a7909acc Binary files /dev/null and b/corpus/outline-edit/offline-tree/cell-delete/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/notebook/synthetic.one b/corpus/outline-edit/offline-tree/cell-delete/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..fbc3b6f8b77f9917bc47778188e6de47e313f390 Binary files /dev/null and b/corpus/outline-edit/offline-tree/cell-delete/cold/notebook/synthetic.one differ diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/environment.json b/corpus/outline-edit/offline-tree/cell-delete/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..57f6f05726c48c3876e2a8002e33edf10ddc1bb3 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-37ACD671", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/hierarchy.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..377ee3f31f458c230ecc475dc8abc7066cc65779 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-000.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..5b596ba0cb694a935fd463c1669b51242d3bb544 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-001.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef35bd7d297ba0f0b5725c70f3a1b2e38986772e --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-001.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-002.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..7e3e1a9aeeb1cfb1545433d80ed7543a98b84903 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-002.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-003.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..91c27de6d6060425ae6c54c035cedd9d87c4596c --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-003.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-004.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..e1b4293c682ad863ef826df86205f1501a380e5d --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-005.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..28bd3aaffb53138691a0a0c0274ac6189af15863 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-005.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-006.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..6db2eb3af060b96524dd1cb35b0387bd43c48e84 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-006.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-007.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..7ff4c27aa74a5bc66687b4fd112675d297eefdcf --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-007.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-008.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..d1192626314920b26ae68505cab85dc39ca6999c --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-008.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-009.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..2efd5ef0591f702be1709aa514214ac97c956bd3 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-009.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-010.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..e66be2846ed280e83f5b57fbe02750ddababbac6 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-010.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-011.xml b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..174806a0ca3f05c5e56ed253b2a1a9fd485c2943 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/read/page-011.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/read/payloads.json b/corpus/outline-edit/offline-tree/cell-delete/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..28e766c3d4f4499afc8ee14682bf4467ff3087cd --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/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-tree/cell-delete/cold/run.json b/corpus/outline-edit/offline-tree/cell-delete/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..94b34a8c1b76c8573dd004991339daef6ad62693 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-tree-native-01/cell-delete/candidate", + "expected_pages": 12, + "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-tree/cell-delete/cold/scripts/cold.ps1 b/corpus/outline-edit/offline-tree/cell-delete/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..9b4c65b63ddad6e65d7acb85fb7d0accbb23d030 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/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-tree/cell-delete/cold/scripts/read.ps1 b/corpus/outline-edit/offline-tree/cell-delete/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..113263f9c23ecc3cafa21407510de560fdc964a8 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/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-tree/cell-delete/cold/source.json b/corpus/outline-edit/offline-tree/cell-delete/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..7baa2ca5d5742c75adb26f380f51d8279111a589 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 88240, + "sha256": "963f5c960c690ff21451787f2a0028573cdc3ce4edeb887a8ed115997fcda194", + "mtime_ns": 1788901194571318513 + } +] diff --git a/corpus/outline-edit/offline-tree/cell-delete/cold/teardown.json b/corpus/outline-edit/offline-tree/cell-delete/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..f87f229e17cdf751396fcf149ad2624727a29624 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-delete/cold/teardown.json @@ -0,0 +1 @@ +../../../offline/cold/teardown.json \ No newline at end of file diff --git a/corpus/outline-edit/offline-tree/cell-move/candidate/synthetic.one b/corpus/outline-edit/offline-tree/cell-move/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..a803314f1105069601bb012294d0722b18e1b10d Binary files /dev/null and b/corpus/outline-edit/offline-tree/cell-move/candidate/synthetic.one differ diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/commands.jsonl b/corpus/outline-edit/offline-tree/cell-move/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..81ecca46b8bb05edac92ed89d4e159e008835478 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/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-B3E79CAE -ExpectedPages 12", "exit": 0, "stdout": "Read 1 sections and 12 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-tree/cell-move/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/offline-tree/cell-move/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..ea8defc64e1d250d13f34869bf8c6a720fd221e1 Binary files /dev/null and b/corpus/outline-edit/offline-tree/cell-move/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/notebook/synthetic.one b/corpus/outline-edit/offline-tree/cell-move/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..5225bc6a0238f882b6df42b82c798290144f4901 Binary files /dev/null and b/corpus/outline-edit/offline-tree/cell-move/cold/notebook/synthetic.one differ diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/environment.json b/corpus/outline-edit/offline-tree/cell-move/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..5f9e3bff82977d360ee0e0c0fe8be18ce8df7562 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-B3E79CAE", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/hierarchy.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..b1ec5d7d4a92fcdadbcefffbe6a3d93a9ac9470a --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-000.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..38a5fe2bc583842c23c5f3c5d06c4fdb7498fca2 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-001.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..7c7868edb0e5ad2e0da9c32d46da4a5c71dab36c --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-001.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-002.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..d87dec3e9ec72b23d1da613f0bc6ac5d7d8a4741 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-002.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-003.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..61258b17bd3b4696470e45fd15b2238e85102c8c --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-003.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-004.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..77144bad3020ab4aea23da62a3a7fb2bf2442ec4 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-004.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-005.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..d6691b8df4f4c92c62bd3dc9ca08b5399205b54e --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-005.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-006.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..dc9c3f123f7066f05b7badef1177ef90c5866da5 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-006.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-007.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..2043c5c8487baf35d348cc326f6cd4f6150c0ba1 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-007.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-008.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..815993c199fc421ebf87eb5d7d4ae31ecbbef2bb --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-008.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-009.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..53301e198094d41179ff16c32690f920984e389a --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-009.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-010.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..8d83e900f90dc4d3d4673b33832070e3b1f88f54 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-010.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/page-011.xml b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..2a31def0593f8b0ea52c4f12eb9b9c2fcd79f512 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/read/page-011.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/read/payloads.json b/corpus/outline-edit/offline-tree/cell-move/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..28e766c3d4f4499afc8ee14682bf4467ff3087cd --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/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-tree/cell-move/cold/run.json b/corpus/outline-edit/offline-tree/cell-move/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..90033d1c9018364ba72c03d6e54278b7b2e33c12 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-tree-native-01/cell-move/candidate", + "expected_pages": 12, + "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-tree/cell-move/cold/scripts/cold.ps1 b/corpus/outline-edit/offline-tree/cell-move/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..9b4c65b63ddad6e65d7acb85fb7d0accbb23d030 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/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-tree/cell-move/cold/scripts/read.ps1 b/corpus/outline-edit/offline-tree/cell-move/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..113263f9c23ecc3cafa21407510de560fdc964a8 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/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-tree/cell-move/cold/source.json b/corpus/outline-edit/offline-tree/cell-move/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..d521823056591867a7a1141208e263d8ee1c447d --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 88104, + "sha256": "0d50bcef1e1b80d7920777e4734412e7549c484972aac0d9fbb313757ff85929", + "mtime_ns": 1788901195424267841 + } +] diff --git a/corpus/outline-edit/offline-tree/cell-move/cold/teardown.json b/corpus/outline-edit/offline-tree/cell-move/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..f87f229e17cdf751396fcf149ad2624727a29624 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cell-move/cold/teardown.json @@ -0,0 +1 @@ +../../../offline/cold/teardown.json \ No newline at end of file diff --git a/corpus/outline-edit/offline-tree/cold/commands.jsonl b/corpus/outline-edit/offline-tree/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..1a23ac47beb5b14ed3a96021c3be08de87442207 --- /dev/null +++ b/corpus/outline-edit/offline-tree/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-7605A4F1 -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-tree/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/offline-tree/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..9bcfeb67122a90d8d83001106c707f0fe7bbaebe Binary files /dev/null and b/corpus/outline-edit/offline-tree/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/offline-tree/cold/notebook/synthetic.one b/corpus/outline-edit/offline-tree/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..a98e60962617853d8ccd44cca3d171abc4dc03e6 Binary files /dev/null and b/corpus/outline-edit/offline-tree/cold/notebook/synthetic.one differ diff --git a/corpus/outline-edit/offline-tree/cold/read/environment.json b/corpus/outline-edit/offline-tree/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..09566e094a43f1176d82def6c60999341cb85f71 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7605A4F1", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/offline-tree/cold/read/hierarchy.xml b/corpus/outline-edit/offline-tree/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..8c6ea32186c6f9c27481a971ee09b04fe417bcac --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-000.xml b/corpus/outline-edit/offline-tree/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..f7088b5d78d77ba3aa02e2ffb7b32689e46ac4b1 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-001.xml b/corpus/outline-edit/offline-tree/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..38017a1d7fcc9df5640c30590885df703022fecb --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-002.xml b/corpus/outline-edit/offline-tree/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..67c2e52e667222d5ed459ddbd833d3fb2f74a0aa --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-002.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/offline-tree/cold/read/page-003.xml b/corpus/outline-edit/offline-tree/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..e91b1202b3ef808c70f19f6c39458a644cb3f514 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-004.xml b/corpus/outline-edit/offline-tree/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..c2da7d7bb0f0b72af82e63c463aa418226fa5334 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-004.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cold/read/page-005.xml b/corpus/outline-edit/offline-tree/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..b97a3beace587830b849c7b2f0a035e8c0330211 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-006.xml b/corpus/outline-edit/offline-tree/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..8504acda4447ca06dfe8de1184aabbe2473016db --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-007.xml b/corpus/outline-edit/offline-tree/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..aa15f615fbd3e6a0371999a8c6e01b504db9384b --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-008.xml b/corpus/outline-edit/offline-tree/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..547b18ec2cd492e08b9301773a695a682eb7a913 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-009.xml b/corpus/outline-edit/offline-tree/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..7c09d1e66df39b3e75ebc3e209dfcd66b8bcee0c --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-009.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/offline-tree/cold/read/page-010.xml b/corpus/outline-edit/offline-tree/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..91d86124956dc049a7c3d38d991faa64ac14088f --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-011.xml b/corpus/outline-edit/offline-tree/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..08fc27b702e86ce0c9489b31480b77f1ea191e60 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-011.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/offline-tree/cold/read/page-012.xml b/corpus/outline-edit/offline-tree/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..8352935a309b73e0da8789b9b9a19c7b9116875a --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-012.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-013.xml b/corpus/outline-edit/offline-tree/cold/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..7dc3e80cd912dc5034c0d493d5ec9ff103fe088c --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/page-014.xml b/corpus/outline-edit/offline-tree/cold/read/page-014.xml new file mode 100644 index 0000000000000000000000000000000000000000..5c5c21da02c4028ec2c40f07a2c615607d1f3c63 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/read/page-014.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/offline-tree/cold/read/payloads.json b/corpus/outline-edit/offline-tree/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..9e1be3e2b6d87478e7c48d278ecac8ee6c97f6d0 --- /dev/null +++ b/corpus/outline-edit/offline-tree/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-tree/cold/run.json b/corpus/outline-edit/offline-tree/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..c416e28bcfe053dd9dafaed85cdcc408f344d922 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-tree-native-01/candidate", + "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-tree/cold/scripts/cold.ps1 b/corpus/outline-edit/offline-tree/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..5bc2f1837e1f0b639923a80b79c517c98c5c519a --- /dev/null +++ b/corpus/outline-edit/offline-tree/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-tree/cold/scripts/read.ps1 b/corpus/outline-edit/offline-tree/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..85257c76cede599f47da88e69419a767dd6b99ee --- /dev/null +++ b/corpus/outline-edit/offline-tree/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-tree/cold/source.json b/corpus/outline-edit/offline-tree/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..1e424af0fe0483cb5c76015ea425e248ea4d25bb --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 152736, + "sha256": "c724b5590d30d8a2ba5d12a25ba1e62bb05792947fd5405b65e68e9b2ae7c256", + "mtime_ns": 1788900756034686311 + } +] diff --git a/corpus/outline-edit/offline-tree/cold/teardown.json b/corpus/outline-edit/offline-tree/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..a9088cc9e6bf00c88849f584d2a4967ba2372903 --- /dev/null +++ b/corpus/outline-edit/offline-tree/cold/teardown.json @@ -0,0 +1 @@ +../../offline/cold/teardown.json \ No newline at end of file diff --git a/crates/onestore-offline/README.md b/crates/onestore-offline/README.md index 7d0731b71d643b3b794b88222bb4926d578e6961..a283e6034e093233f7b14a5c2680b71fee2dc49f 100644 --- a/crates/onestore-offline/README.md +++ b/crates/onestore-offline/README.md @@ -31,7 +31,8 @@ its styles as one intent and one publication. Keep that value across retries; it the new text for subsequent offline edits. Pending entries expose `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, -`Operation::Join(JoinEdit)` and `Operation::Outline(OutlineEdit)` through their +`Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)` and +`Operation::Tree(TreeEdit)` 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. @@ -98,8 +99,23 @@ reserved wrapping width that they clear. Missing targets preserve the local bran 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 +`tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote +text, formatting and descendants. A competing ancestor, indentation or sibling +crossing produces `StructureChanged`; unrelated sibling insertions/deletions can +merge. An anchor must remain a direct child of the requested destination. An +already satisfied move still requires guarded confirmation before acknowledgement. +Deletion compares the selected raw property graph, including referenced styles, +tags, unknown fields and internal attachments. Changed content produces +`ContentChanged`; property order, CompactID numbering and modification timestamps +do not affect that comparison. Missing targets retain `TargetUnavailable`. +`rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against +both current images. An emptied cell's replacement paragraph/text identities must +remain the same before replay or review; later queued edits keep their targets. +Uncertain tree attempts require their original revision for confirmation, even +when an independent move or deletion has the same visible effect. + +Recognized earlier caches migrate transactionally to version eight, which adds +tree intents and content 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. diff --git a/crates/onestore-offline/examples/cache_probe.rs b/crates/onestore-offline/examples/cache_probe.rs index 4d5b54d41d963123843e4bbea3e58f1abeffbff8..d7ab493d9e48d0f610994266a1a9cc32b6cb2f68 100644 --- a/crates/onestore-offline/examples/cache_probe.rs +++ b/crates/onestore-offline/examples/cache_probe.rs @@ -140,7 +140,8 @@ fn main() -> Result<(), Box> { } onestore_offline::Operation::Split(_) | onestore_offline::Operation::Join(_) - | onestore_offline::Operation::Outline(_) => { + | onestore_offline::Operation::Outline(_) + | onestore_offline::Operation::Tree(_) => { panic!("Unexpected operation for this fixture") } }; diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs index 37944b6343929b25dd30da6648ff374dd40137e3..28746754a78fe2c29ed534a70270b6ddf6b31434 100644 --- a/crates/onestore-offline/src/lib.rs +++ b/crates/onestore-offline/src/lib.rs @@ -15,10 +15,12 @@ mod paragraph; mod rebase; mod recovery; mod schema; +mod tree; pub use formatting::FormatEdit; pub use outline::OutlineEdit; pub use paragraph::{JoinEdit, SplitEdit}; pub use recovery::{Recovery, RecoverySummary}; +pub use tree::TreeEdit; mod sync; pub use sync::{ConflictKind, EditStatus, Remote}; mod worker; @@ -49,7 +51,7 @@ pub enum Error { type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 7; +const SCHEMA_VERSION: u32 = 8; /// Text and its observed precondition, retained across cache reopen and rebasing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -70,6 +72,7 @@ pub enum Operation { Split(SplitEdit), Join(JoinEdit), Outline(OutlineEdit), + Tree(TreeEdit), } /// A locally acknowledged intent; its ID remains stable across cache reopen. diff --git a/crates/onestore-offline/src/schema.rs b/crates/onestore-offline/src/schema.rs index 224fc70e06b40e9c55b14d76acb3dddb4733293c..ecbbb5b526c81f4247c9e7cbb1942f49223d89bf 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 5) + kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6) ) STRICT;"; const ASSETS: &str = "CREATE TABLE assets ( diff --git a/crates/onestore-offline/src/sync.rs b/crates/onestore-offline/src/sync.rs index dc58cfc652e30f1c51dd4f479a8e8fdb95428027..1e550bf9bc31a836be899bb3ee07648c2eedf46c 100644 --- a/crates/onestore-offline/src/sync.rs +++ b/crates/onestore-offline/src/sync.rs @@ -20,6 +20,7 @@ pub enum ConflictKind { FormattingChanged = 3, StructureChanged = 4, LayoutChanged = 5, + ContentChanged = 6, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -136,6 +137,7 @@ impl Replica { 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::Tree(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Insert(insertion) => { PreparedEdit::insert(&snapshot, intent.space, insertion) .map_err(|_| ConflictKind::UnsupportedEdit) @@ -306,6 +308,13 @@ impl Replica { ) .into()); } + Operation::Tree(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Review the subtree edit using rebase_tree_conflict", + ) + .into()); + } Operation::Insert(_) => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -398,6 +407,21 @@ impl Replica { }) } + /// Reviews the original subtree intent against both current cache images. + /// Replacement paragraph identities must remain unchanged for dependent edits. + pub fn rebase_tree_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { + self.resolve_conflict(id, local, remote, |intent| { + let Operation::Tree(edit) = intent.operation else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Select a subtree move or deletion conflict", + ) + .into()); + }; + Ok(Operation::Tree(edit.review(remote, intent.space)?)) + }) + } + fn resolve_conflict( &self, id: u64, @@ -519,6 +543,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result ConflictKind::FormattingChanged, 4 => ConflictKind::StructureChanged, 5 => ConflictKind::LayoutChanged, + 6 => ConflictKind::ContentChanged, _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/onestore-offline/src/tree.rs b/crates/onestore-offline/src/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..614f6f3b7597263331c76c8b45392a6a8d247e83 --- /dev/null +++ b/crates/onestore-offline/src/tree.rs @@ -0,0 +1,370 @@ +use super::*; +use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Observed { + path: Vec<(ExGuid, u8)>, + siblings: BTreeMap, + destination: Vec<(ExGuid, u8)>, + content: Option<[u8; 32]>, +} + +/// A subtree intent with observed placement, deletion content and replacement identities. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TreeEdit { + pub intent: onestore::TreeEdit, + observed: Observed, + created: BTreeSet, +} + +fn fingerprint(store: &Store<'_>, raw: &ResolvedRevision<'_>, root: ExGuid) -> Result<[u8; 32]> { + let mut objects = BTreeMap::new(); + let mut pending = vec![root]; + while let Some(id) = pending.pop() { + if objects.contains_key(&id) { + continue; + } + let object = &raw.objects[&id]; + let references = object.references()?; + pending.extend(&references.objects); + let mut hash = Sha256::new(); + hash.update(object.jcid.to_le_bytes()); + match object.data { + ObjectData::Properties(bytes) => { + hash.update([0]); + let properties = PropertySets::parse(bytes)?; + let mut objects = references.objects.into_iter(); + let mut spaces = references.object_spaces.into_iter(); + let mut contexts = references.contexts.into_iter(); + let mut fields = Vec::new(); + for set in &properties.sets { + let mut values = BTreeMap::new(); + for property in set { + let mut value = Sha256::new(); + match &property.value { + Value::NoData => {} + Value::Bytes(bytes) => value.update(bytes), + Value::References { + stream, + compact_ids, + } => { + let references = match stream { + IdStream::Objects => &mut objects, + IdStream::ObjectSpaces => &mut spaces, + IdStream::Contexts => &mut contexts, + }; + for reference in references.take(compact_ids.len() / 4) { + value.update(reference.guid); + value.update(reference.n.to_le_bytes()); + } + } + Value::Sets(_) => {} + } + if property.id != 0x14001d7a { + values.insert(property.id, value); + } + } + fields.push(values); + } + // Arena indices and CompactIDs can change without changing property content. + let mut sets = vec![[0; 32]; properties.sets.len()]; + for at in (0..properties.sets.len()).rev() { + for property in &properties.sets[at] { + if let Value::Sets(children) = &property.value { + let value = fields[at].get_mut(&property.id).unwrap(); + for child in children.clone() { + value.update(sets[child]); + } + } + } + let mut set = Sha256::new(); + for (id, value) in &fields[at] { + set.update(id.to_le_bytes()); + set.update(value.clone().finalize()); + } + sets[at] = set.finalize().into(); + } + hash.update(sets[0]); + } + ObjectData::File { + reference, + extension, + } => { + hash.update([1]); + hash.update(Sha256::digest(reference)); + hash.update(Sha256::digest(extension)); + if let Some(FileDataReference::Internal(guid)) = object.file_reference()? { + hash.update(Sha256::digest(store.file_data(guid)?)); + } + } + ObjectData::Encrypted(_) => unreachable!("references rejected encrypted content"), + } + objects.insert(id, <[u8; 32]>::from(hash.finalize())); + } + let mut hash = Sha256::new(); + for (id, value) in objects { + hash.update(id.guid); + hash.update(id.n.to_le_bytes()); + hash.update(value); + } + Ok(hash.finalize().into()) +} + +fn observe(source: &[u8], space: ExGuid, intent: &onestore::TreeEdit) -> Result> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let Some(section) = document.spaces.get(&space) else { + return Ok(None); + }; + let Some(rid) = section.contexts.get(&ExGuid::default()) else { + return Ok(None); + }; + let view = §ion.revisions[rid]; + 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 object = intent.object(); + let mut targets = vec![object]; + if let Some((parent, _)) = intent.destination() { + targets.push(parent); + } + let Some(paths) = active_paths(view, &pages, &targets) else { + return Ok(None); + }; + let Some(parent) = paths[0].first() else { + return Ok(None); + }; + let children = &view.nodes[parent].children; + let Some(position) = children.iter().position(|id| *id == object) else { + return Ok(None); + }; + let levels = |path: &[ExGuid]| { + path.iter() + .map(|id| (*id, view.nodes[id].child_level.unwrap_or(1))) + .collect::>() + }; + let destination = if let Some((parent, _)) = intent.destination() { + levels(&[&[parent], paths[1].as_slice()].concat()) + } else { + Vec::new() + }; + Ok(Some(Observed { + path: levels(&paths[0]), + siblings: children + .iter() + .enumerate() + .filter_map(|(at, id)| (*id != object).then_some((*id, at < position))) + .collect(), + destination, + content: if intent.destination().is_none() { + Some(fingerprint(&store, &index.resolve(space, *rid)?, object)?) + } else { + None + }, + })) +} + +fn mutable_objects(source: &[u8], space: ExGuid) -> Result> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let raw = index.resolve(space, index.spaces[&space].labels[&(ExGuid::default(), 1)])?; + Ok(raw + .reachable()? + .into_iter() + .filter(|id| raw.objects[id].jcid & 0x100000 == 0) + .collect()) +} + +impl Replica { + /// Queues a move or deletion, retaining content changes for explicit deletion review. + pub fn tree( + &self, + source: &[u8], + space: ExGuid, + intent: &onestore::TreeEdit, + ) -> Result> { + let (edit, prepared) = TreeEdit::capture(source, space, intent)?; + self.record(source, space, Operation::Tree(edit), &prepared) + } +} + +impl TreeEdit { + pub(crate) fn capture<'a>( + source: &'a [u8], + space: ExGuid, + intent: &onestore::TreeEdit, + ) -> Result<(Self, PreparedEdit<'a>)> { + let prepared = PreparedEdit::tree(source, space, intent)?; + let observed = observe(source, space, intent)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "Prepared subtree edit has no active target", + ) + })?; + let before = mutable_objects(source, space)?; + let after = mutable_objects(prepared.as_bytes(), space)?; + Ok(( + Self { + intent: intent.clone(), + observed, + created: &after - &before, + }, + prepared, + )) + } + + pub(crate) fn prepare<'a>( + &self, + source: &'a [u8], + space: ExGuid, + ) -> Result, ConflictKind>> { + let Some(current) = observe(source, space, &self.intent)? else { + return Ok(Err(ConflictKind::TargetUnavailable)); + }; + let prepared = match PreparedEdit::tree(source, space, &self.intent) { + Ok(prepared) => prepared, + Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), + }; + let after = mutable_objects(prepared.as_bytes(), space)?; + if prepared.as_bytes() == source { + return Ok(if self.created.is_subset(&after) { + Ok(prepared) + } else { + Err(ConflictKind::StructureChanged) + }); + } + if current.path != self.observed.path + || current.destination != self.observed.destination + || current.siblings.iter().any(|(id, before)| { + self.observed + .siblings + .get(id) + .is_some_and(|was_before| before != was_before) + }) + || &after - &mutable_objects(source, space)? != self.created + { + return Ok(Err(ConflictKind::StructureChanged)); + } + if current.content != self.observed.content { + return Ok(Err(ConflictKind::ContentChanged)); + } + Ok(Ok(prepared)) + } + + pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result { + let (reviewed, _) = Self::capture(source, space, &self.intent)?; + if reviewed.created != self.created { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "The subtree edit would change its replacement paragraph identities", + ) + .into()); + } + Ok(reviewed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use onestore::Object; + use std::sync::Arc; + + fn set(fields: &[(u32, Vec)]) -> Vec { + let mut bytes = u16::try_from(fields.len()).unwrap().to_le_bytes().to_vec(); + for (id, _) in fields { + bytes.extend(id.to_le_bytes()); + } + for (_, value) in fields { + bytes.extend(value); + } + bytes + } + + #[test] + fn deletion_fingerprint_resolves_references_and_nested_sets_without_storage_order() { + let source = onestore::create_section("fingerprint.one", "Original", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let root = ExGuid { + guid: [1; 16], + n: 1, + }; + let child = ExGuid { + guid: [2; 16], + n: 7, + }; + let hash = |reverse: bool, global: u32, timestamp: u32, change: usize| { + let mut fields = vec![ + (0x14001d7a, timestamp.to_le_bytes().to_vec()), + (0x20000001, vec![]), + ( + 0x44000002, + set(&[(0x14000003, (u32::from(change == 1)).to_le_bytes().to_vec())]), + ), + ( + 0x44000004, + set(&[(if change == 2 { 0x08000005 } else { 0x88000005 }, vec![])]), + ), + ]; + if reverse { + fields.reverse(); + } + let mut bytes = 0x80000001_u32.to_le_bytes().to_vec(); + bytes.extend(((global << 8) | child.n).to_le_bytes()); + bytes.extend(set(&fields)); + let mut child_bytes = 0x80000000_u32.to_le_bytes().to_vec(); + child_bytes.extend(set(&[( + 0x14000006, + (u32::from(change == 3)).to_le_bytes().to_vec(), + )])); + let ids = Arc::new(BTreeMap::from([( + global, + if change == 4 { [3; 16] } else { child.guid }, + )])); + let target = ExGuid { + guid: ids[&global], + ..child + }; + let raw = ResolvedRevision { + roots: BTreeMap::from([(1, root)]), + objects: BTreeMap::from([ + ( + root, + Object { + jcid: 0x6000d, + reference_count: 1, + data: ObjectData::Properties(&bytes), + global_ids: ids.clone(), + }, + ), + ( + target, + Object { + jcid: 0x6000e, + reference_count: 1, + data: ObjectData::Properties(&child_bytes), + global_ids: ids, + }, + ), + ]), + }; + fingerprint(&store, &raw, root).unwrap() + }; + let original = hash(false, 0, 1, 0); + assert_eq!(original, hash(true, 137, 2, 0)); + for change in 1..=4 { + assert_ne!(original, hash(true, 137, 2, change)); + } + } +} diff --git a/crates/onestore-offline/tests/cache.rs b/crates/onestore-offline/tests/cache.rs index 53fc7a27210003c8bc9faed284c3987f4fd75a0a..ab72febb3523fb4460b9eeb790d3709f98a96d46 100644 --- a/crates/onestore-offline/tests/cache.rs +++ b/crates/onestore-offline/tests/cache.rs @@ -796,8 +796,8 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { } #[test] -fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_layout_conflicts() { - for (version, ceiling) in [(5, 3), (6, 4)] { +fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() { + for (version, ceiling) in [(5, 3), (6, 4), (7, 5)] { use onestore_offline::{ConflictKind, EditStatus, Recovery}; use sha2::{Digest, Sha256}; let directory = tempfile::tempdir().unwrap(); @@ -903,13 +903,13 @@ fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_layout_confli .unwrap(), current_version ); - db.execute("INSERT INTO conflicts VALUES (1001,5)", []) + db.execute("INSERT INTO conflicts VALUES (1001,6)", []) .unwrap(); drop(db); let cache = Replica::open(&path).unwrap(); assert_eq!( cache.status(1001).unwrap(), - Some(EditStatus::Conflict(ConflictKind::LayoutChanged)) + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) ); } } diff --git a/crates/onestore-offline/tests/support/tree_schedule.rs b/crates/onestore-offline/tests/support/tree_schedule.rs new file mode 100644 index 0000000000000000000000000000000000000000..015711a85d75145ba36d6ec292eadbe16996fd38 --- /dev/null +++ b/crates/onestore-offline/tests/support/tree_schedule.rs @@ -0,0 +1,246 @@ +use crate::disk; +use onestore::{ + CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, + document::{Document, Kind}, +}; +use onestore_offline::{EditStatus, Operation, Remote, Replica}; +use std::{io, sync::LazyLock}; + +static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| { + let mut source = + onestore::create_section("tree-schedule.one", "Original 🦀 é 0", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (sid, page) = document.pages().unwrap()[0]; + 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(); + for at in 1..12 { + let insert = + Insertion::paragraph(outline, None, &format!("Original 🦀 é {at}"), "Author").unwrap(); + source = PreparedEdit::insert(&source, sid, &insert) + .unwrap() + .as_bytes() + .to_vec(); + } + (source, sid, outline) +}); + +fn rows(source: &[u8]) -> Vec<(ExGuid, ExGuid, String)> { + let (_, sid, outline) = &*SOURCE; + let store = Store::parse(source).unwrap(); + assert!(store.checksum_mismatches.is_empty()); + 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()]]; + let (_, page) = document + .pages() + .unwrap() + .into_iter() + .find(|(space, _)| space == sid) + .unwrap(); + if !view.nodes[&page].children.contains(outline) { + return Vec::new(); + } + let node = &view.nodes[outline]; + let mut rows = Vec::new(); + for paragraph in &node.children { + let node = &view.nodes[paragraph]; + assert!(node.children.is_empty()); + let [text] = node.content.as_slice() else { + panic!() + }; + let Kind::RichText { text: value, .. } = &view.nodes[text].kind else { + panic!() + }; + rows.push((*paragraph, *text, value.clone())); + } + rows +} + +struct Session<'a> { + disk: &'a mut disk::Disk, + operation: Option<&'a Operation>, +} + +impl Remote for Session<'_> { + fn read(&mut self) -> io::Result> { + Ok(self.disk.visible.clone()) + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + let before = rows(&self.disk.visible); + let mut expected = before.clone(); + match self.operation.unwrap() { + Operation::Tree(edit) => { + let at = expected + .iter() + .position(|(id, _, _)| *id == edit.intent.object()) + .unwrap(); + let row = expected.remove(at); + if let Some((parent, anchor)) = edit.intent.destination() { + assert_eq!(parent, SOURCE.2); + let at = anchor + .map(|anchor| { + expected + .iter() + .position(|(id, _, _)| *id == anchor) + .unwrap() + }) + .unwrap_or(expected.len()); + expected.insert(at, row); + } + } + Operation::Text(edit) => { + assert_eq!(edit.range, 0..1); + let (_, _, text) = expected + .iter_mut() + .find(|(_, id, _)| *id == edit.object) + .unwrap(); + text.replace_range(0..1, &edit.replacement); + } + _ => panic!(), + } + assert_eq!(rows(edit.as_bytes()), expected); + let result = edit.commit(self.disk); + let durable = rows(&self.disk.durable); + assert!(durable == before || durable == expected); + if result.is_ok() { + assert_eq!(durable, expected); + } + result + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + onestore::confirm_snapshot(self.disk, snapshot) + } +} + +pub fn run(input: &[u8]) { + let (source, sid, outline) = &*SOURCE; + let directory = tempfile::tempdir().unwrap(); + let mut replicas: [Option; 12] = std::array::from_fn(|_| None); + let mut disk = disk::Disk { + visible: source.clone(), + durable: source.clone(), + operation: 0, + fail_at: None, + write_limit: 4096, + random: 1, + }; + for step in input.chunks_exact(8).take(48) { + let actor = usize::from(step[0]) % replicas.len(); + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); + let snapshot = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + let local = rows(&snapshot); + match step[1] % 8 { + 0..=2 if !local.is_empty() => { + let target = usize::from(step[2]) % local.len(); + let id = match step[1] % 8 { + 0 => { + let before = + (step[3] & 1 == 0).then(|| local[usize::from(step[4]) % local.len()].0); + cache + .tree( + &snapshot, + *sid, + &TreeEdit::move_to(local[target].0, *outline, before, "Offline") + .unwrap(), + ) + .unwrap() + } + 1 => cache + .tree( + &snapshot, + *sid, + &TreeEdit::delete(local[target].0, "Offline").unwrap(), + ) + .unwrap(), + _ => cache + .edit_text( + &snapshot, + *sid, + local[target].1, + 0..1, + &char::from(b'A' + step[3] % 26).to_string(), + ) + .unwrap(), + }; + let next = cache.pending().unwrap(); + assert_eq!(next[..pending.len()], pending); + assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); + } + 3 | 4 => { + disk.operation = 0; + disk.fail_at = (step[4] != 0) + .then_some(usize::from(u16::from_le_bytes([step[4], step[5]])) % 2048 + 1); + disk.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 }; + disk.random = u64::from(step[7]) + 1; + let _ = cache.sync_once(&mut Session { + disk: &mut disk, + operation: pending.first().map(|p| &p.operation), + }); + let next = cache.pending().unwrap(); + if next.len() == pending.len() { + assert_eq!(next, pending); + } else { + assert_eq!(next, pending[1..]); + let Some(EditStatus::Published { revision }) = + cache.status(pending[0].id).unwrap() + else { + panic!() + }; + let store = Store::parse(&disk.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(index.spaces[sid].revisions.contains_key(&revision)); + } + if !next.is_empty() { + assert!(cache.snapshot().unwrap() == snapshot); + } + disk.visible.clone_from(&disk.durable); + } + 5 => { + let mut expected = rows(&disk.visible); + if !expected.is_empty() { + let target = usize::from(step[2]) % expected.len(); + let (_, text, content) = &mut expected[target]; + let at = u32::try_from(content.encode_utf16().count()).unwrap(); + content.push('R'); + let snapshot = disk.visible.clone(); + let edit = PreparedEdit::text(&snapshot, *sid, *text, at..at, "R").unwrap(); + disk.fail_at = None; + edit.commit(&mut disk).unwrap(); + assert_eq!(rows(&disk.durable), expected); + } + } + _ => { + if step[1] % 8 == 6 + && let Some(first) = pending.first() + && matches!(first.operation, Operation::Tree(_)) + && matches!( + cache.status(first.id).unwrap(), + Some(EditStatus::Conflict(_)) + ) + { + let remote = cache.remote_snapshot().unwrap(); + let _ = cache.rebase_tree_conflict(first.id, &snapshot, &remote); + } + let pending = cache.pending().unwrap(); + drop(replicas[actor].take()); + let cache = Replica::open(&path).unwrap(); + assert!(cache.snapshot().unwrap() == snapshot); + assert_eq!(cache.pending().unwrap(), pending); + replicas[actor] = Some(cache); + } + } + rows(&disk.durable); + rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); + } +} diff --git a/crates/onestore-offline/tests/sync.rs b/crates/onestore-offline/tests/sync.rs index 5cfe32abc28e3a5c351f0763c60d72859e2c2f40..97b0459683cbff4c92c13acd022e7a3d8cebaf8b 100644 --- a/crates/onestore-offline/tests/sync.rs +++ b/crates/onestore-offline/tests/sync.rs @@ -5,8 +5,14 @@ use onestore::{ use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica}; use std::io; +#[path = "../../onestore/tests/support/disk.rs"] +mod disk; #[path = "sync/outline.rs"] mod outline; +#[path = "sync/tree.rs"] +mod tree; +#[path = "support/tree_schedule.rs"] +mod tree_schedule; mod paragraph { use super::*; diff --git a/crates/onestore-offline/tests/sync/outline.rs b/crates/onestore-offline/tests/sync/outline.rs index 5f7cb4c9aa997256b49387f2f6339443f03f4d34..87e6e1156c054bfc53acb7ad9b0d4ae8edc96ba5 100644 --- a/crates/onestore-offline/tests/sync/outline.rs +++ b/crates/onestore-offline/tests/sync/outline.rs @@ -1,7 +1,7 @@ use super::*; use onestore::OutlineEdit as Change; -fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid, ExGuid) { +pub(super) 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(); @@ -24,7 +24,7 @@ fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid, ExGuid) { (source, sid, outline, paragraph, text) } -fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value { +pub(super) 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(); diff --git a/crates/onestore-offline/tests/sync/tree.rs b/crates/onestore-offline/tests/sync/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..51047122949425ea702d954bdc09ce80da481c70 --- /dev/null +++ b/crates/onestore-offline/tests/sync/tree.rs @@ -0,0 +1,716 @@ +use super::*; +use onestore::{Insertion, TreeEdit}; + +#[test] +fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { + let mut random = 1940_u64; + for _ in 0..60 { + let input: Vec<_> = (0..384) + .map(|_| { + random ^= random << 13; + random ^= random >> 7; + random ^= random << 17; + random as u8 + }) + .collect(); + tree_schedule::run(&input); + } +} + +fn fixture() -> (Vec, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { + let (mut source, sid, outline, first, text) = super::outline::fixture(); + let mut paragraphs = [first; 4]; + let mut texts = [text; 4]; + for at in 1..4 { + let insertion = + Insertion::paragraph(outline, None, &format!("Sibling {at}"), "Author").unwrap(); + source = PreparedEdit::insert(&source, sid, &insertion) + .unwrap() + .as_bytes() + .to_vec(); + paragraphs[at] = insertion.object(); + texts[at] = insertion.text_object(); + } + (source, sid, outline, paragraphs, texts) +} + +fn children(source: &[u8], sid: ExGuid, object: ExGuid) -> Vec { + 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]; + space.revisions[&space.contexts[&ExGuid::default()]].nodes[&object] + .children + .clone() +} + +#[test] +fn move_preserves_remote_content_and_dependent_edits_through_reopen() { + let (source, sid, outline, paragraphs, texts) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let intent = TreeEdit::move_to(paragraphs[0], outline, None, "Offline").unwrap(); + let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let queue = cache.pending().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!(cache.snapshot().unwrap(), local); + let edited = PreparedEdit::format( + &source, + sid, + texts[0], + 0..8, + &[onestore::TextAttribute::Bold(true)], + ) + .unwrap(); + let descendant = + Insertion::paragraph(paragraphs[0], None, "New remote child", "Remote").unwrap(); + let remote = PreparedEdit::insert(edited.as_bytes(), sid, &descendant).unwrap(); + let mut server = Server::new(remote.as_bytes()); + for expected in [id, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) + ); + } + assert_eq!( + children(&server.durable, sid, outline), + [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]] + ); + assert_eq!( + children(&server.durable, sid, paragraphs[0]), + [descendant.object()] + ); + let node = super::outline::node(&server.durable, sid, texts[0]); + assert_eq!(node["kind"]["text"], "Local Original 🦀 é"); + 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_eq!(view.text_runs(texts[0]).unwrap()[0].format.bold, Some(true)); + assert!(cache.pending().unwrap().is_empty()); + assert_eq!(server.publications, 2); +} + +#[test] +fn deletion_requires_review_of_remote_content_and_preserves_later_work() { + let (source, sid, outline, paragraphs, texts) = fixture(); + let child = Insertion::paragraph(paragraphs[0], None, "Descendant", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &child) + .unwrap() + .as_bytes() + .to_vec(); + for remote in [ + PreparedEdit::text(&source, sid, texts[0], 0..0, "Remote ") + .unwrap() + .as_bytes() + .to_vec(), + PreparedEdit::text(&source, sid, child.text_object(), 0..0, "Remote ") + .unwrap() + .as_bytes() + .to_vec(), + PreparedEdit::format( + &source, + sid, + child.text_object(), + 0..10, + &[onestore::TextAttribute::Italic(true)], + ) + .unwrap() + .as_bytes() + .to_vec(), + PreparedEdit::insert( + &source, + sid, + &Insertion::paragraph(paragraphs[0], None, "New child", "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec(), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let intent = TreeEdit::delete(paragraphs[0], "Offline").unwrap(); + let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, texts[1], 0..0, "Local ") + .unwrap() + .unwrap(); + let queue = cache.pending().unwrap(); + let local = cache.snapshot().unwrap(); + let mut server = Server::new(&remote); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + assert_eq!(server.publications, 0); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!(cache.snapshot().unwrap(), local); + assert!(cache.rebase_tree_conflict(id, &source, &remote).is_err()); + assert!(cache.rebase_tree_conflict(id, &local, &source).is_err()); + assert!( + cache + .rebase_tree_conflict(dependent, &local, &remote) + .is_err() + ); + assert!(cache.rebase_conflict(id, &local, &remote, 0..0).is_err()); + 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(), queue); + assert_eq!(recovery.status(id).unwrap(), cache.status(id).unwrap()); + assert!(recovery.snapshot().unwrap() == local); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!( + cache.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + ); + cache.rebase_tree_conflict(id, &local, &remote).unwrap(); + assert_eq!(cache.pending().unwrap()[1], queue[1]); + assert_eq!(cache.snapshot().unwrap(), local); + for expected in [id, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) + ); + } + assert_eq!(children(&server.durable, sid, outline), paragraphs[1..]); + assert_eq!( + super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], + "Local Sibling 1" + ); + } +} + +#[test] +fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { + let (source, sid, outline, p, texts) = fixture(); + let insertion = Insertion::paragraph(outline, Some(p[0]), "New sibling", "Remote").unwrap(); + let cases = [ + ( + PreparedEdit::insert(&source, sid, &insertion) + .unwrap() + .as_bytes() + .to_vec(), + None, + ), + ( + PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[1], "Remote").unwrap()) + .unwrap() + .as_bytes() + .to_vec(), + None, + ), + ( + PreparedEdit::tree( + &source, + sid, + &TreeEdit::move_to(p[1], outline, None, "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec(), + None, + ), + ( + PreparedEdit::tree( + &source, + sid, + &TreeEdit::move_to(p[0], outline, Some(p[2]), "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec(), + Some(ConflictKind::StructureChanged), + ), + ( + PreparedEdit::tree( + &source, + sid, + &TreeEdit::move_to(p[0], p[1], None, "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec(), + Some(ConflictKind::StructureChanged), + ), + ( + PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[0], "Remote").unwrap()) + .unwrap() + .as_bytes() + .to_vec(), + Some(ConflictKind::TargetUnavailable), + ), + ]; + for (remote, expected) in cases { + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let intent = TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(); + let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); + let mut server = Server::new(&remote); + let result = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(result.0, id); + if let Some(kind) = expected { + assert_eq!(result.1, EditStatus::Conflict(kind)); + assert_eq!(server.publications, 0); + } else { + assert!(matches!(result.1, EditStatus::Published { .. })); + assert_eq!(children(&server.durable, sid, outline).last(), Some(&p[0])); + assert_eq!( + super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], + "Original 🦀 é" + ); + } + } +} + +#[test] +fn moved_destination_and_missing_anchor_retain_conflicts_but_unrelated_text_does_not() { + let (source, sid, outline, p, texts) = fixture(); + let child = Insertion::paragraph(p[1], None, "Anchor", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &child) + .unwrap() + .as_bytes() + .to_vec(); + let intent = TreeEdit::move_to(p[0], p[1], Some(child.object()), "Offline").unwrap(); + let cases = [ + ( + PreparedEdit::tree( + &source, + sid, + &TreeEdit::move_to(p[1], p[2], None, "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec(), + ConflictKind::StructureChanged, + ), + ( + PreparedEdit::tree( + &source, + sid, + &TreeEdit::move_to(child.object(), outline, None, "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec(), + ConflictKind::UnsupportedEdit, + ), + ]; + for (remote, kind) in cases { + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); + let mut server = Server::new(&remote); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(kind))) + ); + assert_eq!(server.publications, 0); + } + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let id = cache + .tree(&source, sid, &TreeEdit::delete(p[0], "Offline").unwrap()) + .unwrap() + .unwrap(); + let remote = PreparedEdit::text(&source, sid, texts[1], 0..0, "Remote ").unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!( + super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], + "Remote Sibling 1" + ); +} + +#[test] +fn unknown_tree_attempts_require_revision_evidence_even_when_effect_is_visible() { + let (source, sid, outline, p, _) = fixture(); + for deletion in [false, true] { + for fault in [ + Fault::UnknownBefore, + Fault::UnknownAfter, + Fault::Before, + Fault::Committed, + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let intent = if deletion { + TreeEdit::delete(p[0], "Offline") + } else { + TreeEdit::move_to(p[0], outline, None, "Offline") + } + .unwrap(); + let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); + let mut server = Server::new(&source); + server.fault = fault; + assert!(cache.sync_once(&mut server).is_err()); + let local = cache.snapshot().unwrap(); + let before = cache.status(id).unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(id).unwrap(), before); + if matches!(fault, Fault::UnknownBefore) { + let independent = if deletion { + TreeEdit::delete(p[0], "Other") + } else { + TreeEdit::move_to(p[0], outline, None, "Other") + } + .unwrap(); + server = Server::new( + PreparedEdit::tree(&source, sid, &independent) + .unwrap() + .as_bytes(), + ); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::AwaitingConfirmation { .. })) if n == id) + ); + assert!( + cache + .rebase_tree_conflict(id, &local, &server.visible) + .is_err() + ); + assert_eq!(server.publications, 0); + assert_eq!(cache.snapshot().unwrap(), local); + } else if matches!(fault, Fault::Committed) { + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + } else { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!( + server.publications, + if matches!(fault, Fault::Before) { 2 } else { 1 } + ); + let saved = cache.snapshot().unwrap(); + assert!(saved[..212] == server.durable[..212]); + assert!(saved[252..] == server.durable[252..]); + assert_eq!( + children(&saved, sid, outline), + children(&server.durable, sid, outline) + ); + } + } + } +} + +#[test] +fn independently_satisfied_move_confirms_without_republication() { + let (source, sid, outline, p, _) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let id = cache + .tree( + &source, + sid, + &TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(), + ) + .unwrap() + .unwrap(); + let remote = PreparedEdit::tree( + &source, + sid, + &TreeEdit::move_to(p[0], outline, None, "Remote").unwrap(), + ) + .unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!(server.publications, 0); + assert_eq!(server.confirmations, 1); +} + +#[test] +fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay() { + let source = + include_bytes!("../../../../corpus/outline-edit/tree/before/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(title), .. } if title == "Delete sole cell paragraph") + }).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let mut pending = vec![page]; + let mut selected = None; + let mut other = None; + while let Some(id) = pending.pop() { + let node = &view.nodes[&id]; + pending.extend(&node.children); + pending.extend(&node.content); + if matches!(node.kind, Kind::Cell { .. }) { + let paragraph = node.children[0]; + let text = view.nodes[¶graph].content[0]; + if matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")) + { + selected = Some((id, paragraph)); + } else { + other = Some((id, text)); + } + } + } + let (cell, target) = selected.unwrap(); + let (other_cell, other_text) = other.unwrap(); + for move_out in [false, true] { + for competing_child in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cell.sqlite"); + let cache = Replica::create(&path, source).unwrap(); + let intent = if move_out { + TreeEdit::move_to(target, other_cell, None, "Offline") + } else { + TreeEdit::delete(target, "Offline") + } + .unwrap(); + let id = cache.tree(source, sid, &intent).unwrap().unwrap(); + let local = cache.snapshot().unwrap(); + let replacement = children(&local, sid, cell); + assert_eq!(replacement.len(), 1); + assert_ne!(replacement[0], target); + let replacement_text: ExGuid = + super::outline::node(&local, sid, replacement[0])["content"][0] + .as_str() + .unwrap() + .parse() + .unwrap(); + let dependent = cache + .edit_text(&local, sid, replacement_text, 0..0, "Local replacement") + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let queue = cache.pending().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!(cache.snapshot().unwrap(), local); + let remote = if competing_child { + PreparedEdit::insert( + source, + sid, + &Insertion::paragraph(cell, None, "Remote sibling", "Remote").unwrap(), + ) + .unwrap() + .as_bytes() + .to_vec() + } else { + PreparedEdit::text(source, sid, other_text, 0..0, "Remote ") + .unwrap() + .as_bytes() + .to_vec() + }; + let mut server = Server::new(&remote); + if competing_child { + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) + ); + assert_eq!(server.publications, 0); + assert!(cache.rebase_tree_conflict(id, &local, &remote).is_err()); + assert_eq!(cache.pending().unwrap(), queue); + assert_eq!(cache.snapshot().unwrap(), local); + } else { + for expected in [id, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) + ); + } + assert_eq!(children(&server.durable, sid, cell), replacement); + assert_eq!( + super::outline::node(&server.durable, sid, replacement_text)["kind"]["text"], + "Local replacement" + ); + assert_eq!( + super::outline::node(&server.durable, sid, other_text)["kind"]["text"], + "Remote Other cell" + ); + if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output) + .join(if move_out { "cell-move" } else { "cell-delete" }) + .join("candidate"); + std::fs::create_dir_all(&output).unwrap(); + std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); + } + } + } + } +} + +#[test] +fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() { + 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() + .copied() + .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .collect(); + if outlines.len() != 2 { + continue; + } + let outline = outlines[0]; + let other = view.nodes[&outlines[1]].children[0]; + let dependent_text = view.nodes[&other].content[0]; + let mut pending = vec![outline]; + let mut paragraphs = std::collections::BTreeMap::new(); + while let Some(id) = pending.pop() { + let node = &view.nodes[&id]; + pending.extend(&node.children); + if let Some(text) = node.content.first() + && let Kind::RichText { text, .. } = &view.nodes[text].kind + { + paragraphs.insert(text.as_str(), id); + } + } + let target = *paragraphs + .iter() + .find(|(text, _)| text.starts_with("Target ")) + .unwrap() + .1; + let (intent, conflict) = match name.as_str() { + "Move leaf down" | "Move subtree down" => { + (TreeEdit::move_to(target, outline, None, "Offline"), None) + } + "Move subtree up" => ( + TreeEdit::move_to(target, outline, Some(paragraphs["Anchor"]), "Offline"), + None, + ), + "Indent subtree" | "Outdent subtree" => ( + TreeEdit::delete(target, "Offline"), + Some(ConflictKind::StructureChanged), + ), + "Collapse subtree" | "Expand subtree" => ( + TreeEdit::delete(target, "Offline"), + Some(ConflictKind::ContentChanged), + ), + "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( + TreeEdit::delete(target, "Offline"), + Some(ConflictKind::TargetUnavailable), + ), + "Delete outline" => ( + TreeEdit::delete(outline, "Offline"), + Some(ConflictKind::TargetUnavailable), + ), + "Move outline" | "Resize outline" | "Automatic outline size" => ( + TreeEdit::delete(outline, "Offline"), + Some(ConflictKind::ContentChanged), + ), + _ => panic!("{name}"), + }; + let intent = intent.unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("native.sqlite"); + let cache = Replica::create(&path, source).unwrap(); + let id = cache.tree(source, sid, &intent).unwrap().unwrap(); + let dependent = cache + .edit_text( + &cache.snapshot().unwrap(), + sid, + dependent_text, + 0..0, + "Offline ", + ) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let queue = cache.pending().unwrap(); + let old_publications = server.publications; + let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(actual, id); + if let Some(kind) = conflict { + assert_eq!(status, EditStatus::Conflict(kind), "{name}"); + assert_eq!(server.publications, old_publications); + 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(status)); + if kind == ConflictKind::TargetUnavailable { + counts[2] += 1; + assert!( + cache + .rebase_tree_conflict(id, &local, &server.visible) + .is_err() + ); + records.push(serde_json::json!({"page": name, "space": sid, "result": "retained"})); + continue; + } + counts[1] += 1; + cache + .rebase_tree_conflict(id, &local, &server.visible) + .unwrap(); + assert_eq!(cache.pending().unwrap()[1], queue[1]); + for expected in [id, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) + ); + } + } else { + counts[0] += 1; + assert!(matches!(status, EditStatus::Published { .. }), "{name}"); + assert_eq!(server.publications, old_publications); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) + ); + } + assert_eq!( + super::outline::node(&server.durable, sid, dependent_text)["kind"]["text"], + format!( + "Offline {}", + match &view.nodes[&dependent_text].kind { + Kind::RichText { text, .. } => text, + _ => panic!(), + } + ) + ); + records.push(serde_json::json!({"page": name, "space": sid, "result": if conflict.is_some() { "reviewed" } else { "converged" }})); + } + assert_eq!(counts, [3, 7, 4]); + if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output); + std::fs::create_dir_all(output.join("candidate")).unwrap(); + std::fs::write(output.join("candidate/synthetic.one"), &server.durable).unwrap(); + std::fs::write( + output.join("manifest.json"), + serde_json::to_vec_pretty(&records).unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/onestore/src/objects.rs b/crates/onestore/src/objects.rs index 3900af9f53ba01a38cb4f41907d90a43beb1514d..02d0e02dcea389d053a1cb5724a6162d8233ad10 100644 --- a/crates/onestore/src/objects.rs +++ b/crates/onestore/src/objects.rs @@ -34,6 +34,7 @@ pub struct ResolvedRevision<'a> { } /// Occurrences retain multiplicity for native reference counts. +/// Each stream follows property arena order, including nested sets. #[derive(Debug, Default)] pub struct ObjectReferences { pub objects: Vec, diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs index 64a24dcf2624a172bba3fe8dcf631f39e35f420c..04a19f7240b3467ccae01078b17a0db0a91bf492 100644 --- a/crates/onestore/src/tree.rs +++ b/crates/onestore/src/tree.rs @@ -38,6 +38,18 @@ pub struct TreeEdit { } impl TreeEdit { + pub fn object(&self) -> ExGuid { + self.object + } + + /// Move destination and direct sibling anchor; deletion has no destination. + pub fn destination(&self) -> Option<(ExGuid, Option)> { + match self.placement { + Placement::Delete => None, + Placement::Move { parent, before } => Some((parent, before)), + } + } + /// Removes a paragraph or ordinary outline and its descendants from the active tree. /// Historical objects remain available; an emptied table cell receives an empty paragraph. pub fn delete(object: ExGuid, author: &str) -> Result { diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ba1cb1402bc1864d3c6c46ed5cd72ef809231bdd..05afdb31cbee19b3952f956d79943754d5acb87a 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -107,3 +107,10 @@ path = "fuzz_targets/tree.rs" test = false doc = false bench = false + +[[bin]] +name = "offline_tree" +path = "fuzz_targets/offline_tree.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/offline_tree.rs b/fuzz/fuzz_targets/offline_tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..5463e3709d22564ad0e2e12b0e5ee14aa6cb4d6a --- /dev/null +++ b/fuzz/fuzz_targets/offline_tree.rs @@ -0,0 +1,9 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +#[path = "../../crates/onestore/tests/support/disk.rs"] +mod disk; +#[path = "../../crates/onestore-offline/tests/support/tree_schedule.rs"] +mod tree_schedule; + +fuzz_target!(|input: &[u8]| tree_schedule::run(input)); diff --git a/tools/test_outline_edit.py b/tools/test_outline_edit.py index fe7a48d6c203e31b2e2be004b74e7285e8eb9fd7..4a02c4778c67229a5af116b6159993e0c25868ec 100644 --- a/tools/test_outline_edit.py +++ b/tools/test_outline_edit.py @@ -20,8 +20,12 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] class OutlineEditTest(unittest.TestCase): def test_rust_subtree_moves_and_deletions_survive_cold_native_reopen(self): - for name, pages in [('ordinary', 15), ('groups-cells', 12), ('cross-container', 12), ('unequal-groups', 1)]: - fixture = FIXTURE / 'rust-tree' / name + for name, pages in [ + ('rust-tree/ordinary', 15), ('rust-tree/groups-cells', 12), + ('rust-tree/cross-container', 12), ('rust-tree/unequal-groups', 1), + ('offline-tree', 15), ('offline-tree/cell-delete', 12), ('offline-tree/cell-move', 12), + ]: + fixture = FIXTURE / name with self.subTest(case=name), TemporaryDirectory() as temporary: models = {} for phase, notebook in [('candidate', fixture / 'candidate'), ('cold', fixture / 'cold/notebook')]: @@ -42,7 +46,7 @@ class OutlineEditTest(unittest.TestCase): for _, node in walk(old, page): for oid in node['kind'].get('lists', []): self.assertEqual(old['nodes'][oid], saved['nodes'][oid]) - if name == 'unequal-groups': + if name == 'rust-tree/unequal-groups': capture, = (fixture / 'cold/read').glob('page-*.xml') root = ET.parse(capture).getroot() self.assertEqual([(group.get('indent'), ''.join(Text(group.find('one:OE/one:T', ns).text or '').parts))