diff --git a/corpus/outline-edit/README.md b/corpus/outline-edit/README.md index 68a8009af727b6d82fcafe497980eb103a1d4db1..a0ee8573986ceefb25f5f676ddf57ee9dae3748d 100644 --- a/corpus/outline-edit/README.md +++ b/corpus/outline-edit/README.md @@ -101,3 +101,30 @@ Regenerate using the same source notebook and runner, with controller at inspection readiness, move `before-read` to `before/read`, then cold-open the returned notebook with `--expected-pages 12 --collect-notebook`. The exact authoring script has one home in the reference corpus. + +`rust-tree` retains four Rust-generated notebooks and their fresh-cache OneNote +captures. `ordinary` and `groups-cells` apply eighteen moves/deletions from the +native controls. Explicit bullet-subtree movement preserves its original marker. +`cross-container` moves content between cells and outlines, moves a whole table, +and moves a sibling beneath a paragraph while its enclosing group is normalized. +`unequal-groups` deletes a trailing paragraph after two differently indented +groups; native XML reports the surviving paragraphs at indentation levels three +and two. Every surviving content identity, field, tag and table remains intact; +an emptied cell receives new empty paragraph/text identities. + +The four captures cover forty pages. Public tests compare native text and +formatting, the complete active graphs before/after cold reopen, metadata, and +list objects. Core tests additionally compare eighteen native transformations, +every historical object's property bytes, selected move attribution, protected +content, reused intents, and interrupted publication. The shared stateful model +in `crates/onestore/tests/support/tree_model.rs` checks paragraph order, +indentation, content, automatic titles and old/new publication outcomes across +twelve cached clients; `fuzz/fuzz_targets/tree.rs` runs that oracle under ASan. + +Set `ONESTORE_TREE_OUTPUT` to a new absolute directory and run the +`native_subtree_controls_match_with_preserved_fields_and_history` and +`cross_container_moves_keep_tables_and_replace_emptied_cells` integration tests, +plus the `tree::tests::group_normalization_preserves_unequal_indentation_and_overlapping_moves` +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`. diff --git a/corpus/outline-edit/rust-tree/cross-container/candidate/synthetic.one b/corpus/outline-edit/rust-tree/cross-container/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..a8475a2c24296df9206a3fc5398cf1d0f68cbb7a Binary files /dev/null and b/corpus/outline-edit/rust-tree/cross-container/candidate/synthetic.one differ diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/commands.jsonl b/corpus/outline-edit/rust-tree/cross-container/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..cac79d0a7984e2ec23d51c6ff3068a2b8d5655c7 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/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-E6CE0929 -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/rust-tree/cross-container/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/rust-tree/cross-container/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d1f5ff78c7191294cc359911bc98c91c94a4df69 Binary files /dev/null and b/corpus/outline-edit/rust-tree/cross-container/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/notebook/synthetic.one b/corpus/outline-edit/rust-tree/cross-container/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..e4377ac28e4c85f300a573f343a9e7d2a6f58392 Binary files /dev/null and b/corpus/outline-edit/rust-tree/cross-container/cold/notebook/synthetic.one differ diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/environment.json b/corpus/outline-edit/rust-tree/cross-container/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..b3bf4c3d240f8c3e32a1362296ac78f25a4bbb7e --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-E6CE0929", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/hierarchy.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f489b35f1b4318df51029ec275d88248ec1676b5 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-000.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..91fce4c458c906398bede2244c9d899d03465108 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-001.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..d16f631695e6bf6bd8d32a3ab582cb311e5986d4 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-001.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-002.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb9054d278c8d7fc8e72cd4f67fea70f02c43989 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-002.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-003.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e4b8ee9beb2d8a243b2ed2962b5c837c39aaff4 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-003.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-004.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..631561f4b61231d365d42152230594d0acbd775d --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-004.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-005.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..c28f1296f832943cbdd2d86c932638b8e02ee796 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-005.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-006.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..215a527f3e2a76656eda825161c0274995ead202 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-006.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-007.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..eec578c1676fe7c101e93a406f86ead968f2e1ce --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-007.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-008.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..5c3de844098eac10d79a8b73987b2f1a61d87a72 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-008.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-009.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..a71568b190bb7b6b560e38fe32e5665afe73ffed --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-009.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-010.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..fe192e08e61dc220f3d091b6d76210b8339bc0ed --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-010.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/page-011.xml b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..b8cdbb783a28d7d552dc7907e4eb0d905a579db4 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/read/page-011.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/read/payloads.json b/corpus/outline-edit/rust-tree/cross-container/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..28e766c3d4f4499afc8ee14682bf4467ff3087cd --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/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/rust-tree/cross-container/cold/run.json b/corpus/outline-edit/rust-tree/cross-container/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..ea20549de5f1bc4e70a60ef906203d382a72ea25 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/tree-rust-candidates-01/cross-container", + "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/rust-tree/cross-container/cold/scripts/cold.ps1 b/corpus/outline-edit/rust-tree/cross-container/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..9b4c65b63ddad6e65d7acb85fb7d0accbb23d030 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/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/rust-tree/cross-container/cold/scripts/read.ps1 b/corpus/outline-edit/rust-tree/cross-container/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..113263f9c23ecc3cafa21407510de560fdc964a8 --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/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/rust-tree/cross-container/cold/source.json b/corpus/outline-edit/rust-tree/cross-container/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..bf22c131e13b2aa41a8cfe992cc5e019e5d9a0fd --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 92512, + "sha256": "91c80122b3854e8a91e863638b55c1b8610b80c71c59a2436b3ddae60338c75a", + "mtime_ns": 1788897469441332033 + } +] diff --git a/corpus/outline-edit/rust-tree/cross-container/cold/teardown.json b/corpus/outline-edit/rust-tree/cross-container/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..dbca0643416e3c7b16e1f46f95489ea4415f20fd --- /dev/null +++ b/corpus/outline-edit/rust-tree/cross-container/cold/teardown.json @@ -0,0 +1 @@ +../../../../paragraph-edit/joined/teardown.json \ No newline at end of file diff --git a/corpus/outline-edit/rust-tree/groups-cells/candidate/synthetic.one b/corpus/outline-edit/rust-tree/groups-cells/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..6566dab9dc28e2136352c0bb2f02fb7446f74bb5 Binary files /dev/null and b/corpus/outline-edit/rust-tree/groups-cells/candidate/synthetic.one differ diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/commands.jsonl b/corpus/outline-edit/rust-tree/groups-cells/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..367656273df93b7b918b2a04f06157083afa7537 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/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-619E88D9 -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/rust-tree/groups-cells/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/rust-tree/groups-cells/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..be5ccd3e13a855e2f436d7cccacf193f2140d7ba Binary files /dev/null and b/corpus/outline-edit/rust-tree/groups-cells/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/notebook/synthetic.one b/corpus/outline-edit/rust-tree/groups-cells/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..5b3437570a419c01a7ef7ddeb84769ccafb7e160 Binary files /dev/null and b/corpus/outline-edit/rust-tree/groups-cells/cold/notebook/synthetic.one differ diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/environment.json b/corpus/outline-edit/rust-tree/groups-cells/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..fa3d5f00a201d8d86e1dd94d9be96094a215eae6 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-619E88D9", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/hierarchy.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..a8dcfce17fab6bb24cca1f6b4bc2cfe1a30c9fa3 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-000.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..db08775828dd0133d8125c5a037ebfa047d92dcf --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-001.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..0f9262125344d1057884578e058230efab79f9c6 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-001.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-002.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..ff21a6fde4724f07434d586c90d83101fc35b2b2 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-003.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..201763c2ce7654f2d6a867cca3635467b3fe0eaa --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-003.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-004.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..fd592b4fef07256367f6f84f536c41c6ddec90e2 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-005.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..2946522ab566512e71e1615e82bd601c389560aa --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-005.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-006.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..3f74bfa17af2995f9dd27636796768ee361b41f7 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-007.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..95b02301a98f8f4924394d24bfdf4cd76b3211ed --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-007.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-008.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..e50414c2acad1e7a36ca1caa691a4181f4a4c04d --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-009.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..9582e0deecd8018cea6080f361aeb38c993c3f93 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-010.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..dd9000f0bee61a241bc7c85ea0289a1f73f4e751 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-010.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-011.xml b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..389ffdea9c7cb200e43fdb5ce5206a5153947175 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/read/page-011.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/read/payloads.json b/corpus/outline-edit/rust-tree/groups-cells/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..28e766c3d4f4499afc8ee14682bf4467ff3087cd --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/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/rust-tree/groups-cells/cold/run.json b/corpus/outline-edit/rust-tree/groups-cells/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..428a01ae9b4b6eb37942dde909dd2412695e95cc --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/tree-rust-candidates-01/groups-cells", + "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/rust-tree/groups-cells/cold/scripts/cold.ps1 b/corpus/outline-edit/rust-tree/groups-cells/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..9b4c65b63ddad6e65d7acb85fb7d0accbb23d030 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/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/rust-tree/groups-cells/cold/scripts/read.ps1 b/corpus/outline-edit/rust-tree/groups-cells/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..113263f9c23ecc3cafa21407510de560fdc964a8 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/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/rust-tree/groups-cells/cold/source.json b/corpus/outline-edit/rust-tree/groups-cells/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..3e65b796310a5ba0e8274b5410ceede230fb0a69 --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 98760, + "sha256": "1d61348e067f63ac3e39443085fbc1e8285ef52873b0191943ec439168f48e41", + "mtime_ns": 1788896902499834185 + } +] diff --git a/corpus/outline-edit/rust-tree/groups-cells/cold/teardown.json b/corpus/outline-edit/rust-tree/groups-cells/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..dbca0643416e3c7b16e1f46f95489ea4415f20fd --- /dev/null +++ b/corpus/outline-edit/rust-tree/groups-cells/cold/teardown.json @@ -0,0 +1 @@ +../../../../paragraph-edit/joined/teardown.json \ No newline at end of file diff --git a/corpus/outline-edit/rust-tree/ordinary/candidate/synthetic.one b/corpus/outline-edit/rust-tree/ordinary/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..47d51d27fe692fdea4c2026c4183612fc8067e2d Binary files /dev/null and b/corpus/outline-edit/rust-tree/ordinary/candidate/synthetic.one differ diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/commands.jsonl b/corpus/outline-edit/rust-tree/ordinary/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..026bff93543b6f30311700cf181c394161715440 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/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-91CEBD14 -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/rust-tree/ordinary/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/rust-tree/ordinary/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..91998ad71de46ed9e3c20e9248f00bb92d9beef0 Binary files /dev/null and b/corpus/outline-edit/rust-tree/ordinary/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/notebook/synthetic.one b/corpus/outline-edit/rust-tree/ordinary/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..da7bc2b7314cc966deb7a2e19dc248aec4e07df8 Binary files /dev/null and b/corpus/outline-edit/rust-tree/ordinary/cold/notebook/synthetic.one differ diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/environment.json b/corpus/outline-edit/rust-tree/ordinary/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..c72b5ea584b5c4ebc30d8d970e90d874b83a83ab --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-91CEBD14", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/hierarchy.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..49a89381a4e252d93b8d90b2ff795683cb4a7953 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-000.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..7687a4c4e040e131d68d58b64d5293e15cb63ac4 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-001.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..fc862d0fb532801ee3be19eb3d80e0b04e60bb14 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-001.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-002.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..ad2bb8b3fa7cea17212f8c2035864c69ee925ef3 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-002.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-003.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..a1444695ac1defa80b42b81b2c30fef545d3ffe5 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-004.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..32479a5d91e6118de92dd81d097e5c99d0413d17 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-004.xml @@ -0,0 +1,4 @@ + +Target 🦀 é Link label]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-005.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..f098b46c264643471a1340ad3d4848582c6a0e7a --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-005.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-006.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..2adc7cbb4d9c962df21fc7da2f5c04e7a1b45090 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-007.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..2f02916167e0887177741a4ed40c306f7bf50b46 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-007.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-008.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..a3186b01c1c23c7ca1c98caf1e2a968e4f201322 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-009.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..0c84ea1722fea42860f245cc11b3245139d1a6bf --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-009.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-010.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..3866428f5a817ef1243afe16e991ae29f0e3494b --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-010.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-011.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..3b9cdb947c29ab1b0dde96613b0164cdda0df301 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-011.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-012.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..a8a05324f192996c4722be06d1db6855c52240f2 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-012.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-013.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..329a9944172b3b8d2a7dbc224bc7fffb026083e5 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/page-014.xml b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-014.xml new file mode 100644 index 0000000000000000000000000000000000000000..258f36ba7104ea483588e1e148ad937cbff19aa5 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/read/page-014.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/read/payloads.json b/corpus/outline-edit/rust-tree/ordinary/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..28e766c3d4f4499afc8ee14682bf4467ff3087cd --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/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/rust-tree/ordinary/cold/run.json b/corpus/outline-edit/rust-tree/ordinary/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..1f1379224ca10dd65ab2b1faf8d3de8bd81ed764 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/tree-rust-candidates-01/ordinary", + "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/rust-tree/ordinary/cold/scripts/cold.ps1 b/corpus/outline-edit/rust-tree/ordinary/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..9b4c65b63ddad6e65d7acb85fb7d0accbb23d030 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/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/rust-tree/ordinary/cold/scripts/read.ps1 b/corpus/outline-edit/rust-tree/ordinary/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..113263f9c23ecc3cafa21407510de560fdc964a8 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/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/rust-tree/ordinary/cold/source.json b/corpus/outline-edit/rust-tree/ordinary/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..37f8ce43c5ae661fe1edb020bb08d815df0366c1 --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 111728, + "sha256": "73cdf92d1e016cb7576a8e9165bf0d94fb94e5a1e233a834dd39347011661240", + "mtime_ns": 1788896901734213394 + } +] diff --git a/corpus/outline-edit/rust-tree/ordinary/cold/teardown.json b/corpus/outline-edit/rust-tree/ordinary/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..dbca0643416e3c7b16e1f46f95489ea4415f20fd --- /dev/null +++ b/corpus/outline-edit/rust-tree/ordinary/cold/teardown.json @@ -0,0 +1 @@ +../../../../paragraph-edit/joined/teardown.json \ No newline at end of file diff --git a/corpus/outline-edit/rust-tree/unequal-groups/candidate/groups.one b/corpus/outline-edit/rust-tree/unequal-groups/candidate/groups.one new file mode 100644 index 0000000000000000000000000000000000000000..7bb4ee7a42dcff4c1b872caa717bfd0c2f79bf79 Binary files /dev/null and b/corpus/outline-edit/rust-tree/unequal-groups/candidate/groups.one differ diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/commands.jsonl b/corpus/outline-edit/rust-tree/unequal-groups/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..6cd37520948b780708a12693097d617b33ae8298 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/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-FD0772B4 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 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/rust-tree/unequal-groups/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/rust-tree/unequal-groups/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..2eebdced589b232192a9d8f620c055b9b3657b4d Binary files /dev/null and b/corpus/outline-edit/rust-tree/unequal-groups/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/notebook/groups.one b/corpus/outline-edit/rust-tree/unequal-groups/cold/notebook/groups.one new file mode 100644 index 0000000000000000000000000000000000000000..37b1231df119c5495e2d0d234aa8ec194cd6d437 Binary files /dev/null and b/corpus/outline-edit/rust-tree/unequal-groups/cold/notebook/groups.one differ diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/read/environment.json b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..aa8aff6964870358be881a20791bb36bed7732b9 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-FD0772B4", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/read/hierarchy.xml b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..22a4737b9b98c6f2eb5a3494ecc4b57b673730e1 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/read/page-000.xml b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..3ffaeb7aa3efc4353d34446e2982ed2e4248e1c8 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/read/payloads.json b/corpus/outline-edit/rust-tree/unequal-groups/cold/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..28e766c3d4f4499afc8ee14682bf4467ff3087cd --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/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/rust-tree/unequal-groups/cold/run.json b/corpus/outline-edit/rust-tree/unequal-groups/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..73aa039679d11bf9e8c68452fb08d8f395e37c7d --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/tree-rust-candidates-01/unequal-groups", + "expected_pages": 1, + "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/rust-tree/unequal-groups/cold/scripts/cold.ps1 b/corpus/outline-edit/rust-tree/unequal-groups/cold/scripts/cold.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..9b4c65b63ddad6e65d7acb85fb7d0accbb23d030 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/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/rust-tree/unequal-groups/cold/scripts/read.ps1 b/corpus/outline-edit/rust-tree/unequal-groups/cold/scripts/read.ps1 new file mode 120000 index 0000000000000000000000000000000000000000..113263f9c23ecc3cafa21407510de560fdc964a8 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/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/rust-tree/unequal-groups/cold/source.json b/corpus/outline-edit/rust-tree/unequal-groups/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..81f3fa759ad854c31146ae958abe5d2a4bb104e8 --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/cold/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "groups.one", + "bytes": 8784, + "sha256": "771ef9f745a621829ff616a6009c62f623b0132c31d78f8fc04258646916d49a", + "mtime_ns": 1788897850908407538 + } +] diff --git a/corpus/outline-edit/rust-tree/unequal-groups/cold/teardown.json b/corpus/outline-edit/rust-tree/unequal-groups/cold/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..dbca0643416e3c7b16e1f46f95489ea4415f20fd --- /dev/null +++ b/corpus/outline-edit/rust-tree/unequal-groups/cold/teardown.json @@ -0,0 +1 @@ +../../../../paragraph-edit/joined/teardown.json \ No newline at end of file diff --git a/crates/onestore/README.md b/crates/onestore/README.md index b8d720610fe1d6c48c165834b4b4449b087b7faf..d4ae6e6a8e6a3a2eb81c0c2ec42fac14f1a6ec21 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -53,6 +53,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | +| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | | `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance | @@ -85,6 +86,17 @@ and an automatic maximum-width hint remain distinct. Native layout generates the rendered height. Saved paragraph collapse defaults can be overridden by the native client's cached view. [Native layout captures](../../corpus/outline-edit/README.md) verify these edits through a fresh OneNote cache, including fields and nested content. +`TreeEdit::move_to` takes an existing parent and an optional direct sibling to insert +before; `None` appends. Paragraphs retain their descendants and explicit list styles. +Outlines remain page children, so reordering changes their stacking order while +retaining coordinates. `TreeEdit::delete` removes the selected subtree from the active +graph and preserves historical objects. Empty outlines/groups are removed; surviving +group indentation is normalized without shifting other paragraphs. Empty table cells +receive new empty paragraph/text objects with identities retained in the intent. +Title/protected content, ambiguous ancestry, cycles, and incompatible destinations +reject before publication. Modification times, move attribution and automatic titles +publish with the tree change. These explicit destinations differ from keyboard list +indentation, which can also substitute list markers. Paragraph splits preserve character formatting, retain tags on the left, and clone mutable list objects without restarting numbering. The new right paragraph/text identities belong to the retained `ParagraphSplit` intent. Its publication includes diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 7bea20551acafc40915e9a770c2525df04da4073..4e3d2507a46971d073f8375baddd705eae20a8ed 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -183,6 +183,18 @@ pub struct PreparedEdit<'a> { } impl<'a> PreparedEdit<'a> { + /// Moves or removes a subtree and normalizes its containers in one revision. + pub fn tree( + source: &'a [u8], + space: ExGuid, + edit: &crate::TreeEdit, + ) -> Result { + Ok(Self { + source, + written: edit.apply(source, space)?, + }) + } + /// Changes outline geometry or a paragraph's saved expansion state, preserving content. pub fn outline( source: &'a [u8], diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs index 9cf5af2efde70468e991104a7f5e2c34b3d4d18f..89c4427f84f9e6578bdc92e82ad1af5fb82eaab9 100644 --- a/crates/onestore/src/edit.rs +++ b/crates/onestore/src/edit.rs @@ -1,6 +1,10 @@ -use crate::document::{Document, Kind}; -use crate::{Error, ExGuid, ObjectData, PropertySets, RevisionIndex, Store}; -use std::ops::Range; +use crate::{ + Error, ExGuid, Object, ObjectData, PropertySets, RevisionIndex, Store, + create::string, + document::{Document, Element, Kind}, + write::PropertyObject, +}; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; fn title_line(text: &str) -> &str { text.trim_start().split('\r').next().unwrap() @@ -458,3 +462,49 @@ pub(crate) fn page_title( } Ok(Some((*page, automatic, title_text.to_owned()))) } + +pub(crate) fn update_title( + store: &Store<'_>, + raw: &crate::ResolvedRevision<'_>, + view: crate::document::Revision<'_>, + pages: &[ExGuid], + changed: &mut BTreeMap, +) -> Result<(), Error> { + let invalid = |message| Error { offset: 0, message }; + // Shorten the moved view's lifetime to the changed property buffers. + let mut view = view; + for (id, object) in changed.iter() { + view.nodes.insert( + *id, + Element::parse( + &Object { + jcid: object.jcid, + reference_count: 0, + data: ObjectData::Properties(&object.bytes), + global_ids: Arc::clone(&object.global_ids), + }, + store, + )?, + ); + } + let title = page_title(&view, pages, None)?; + drop(view); + if let Some((_, automatic, title)) = title { + let metadata = raw + .roots + .get(&2) + .ok_or_else(|| invalid("Page title metadata is unavailable"))?; + if raw.objects[metadata].jcid != 0x20030 { + return Err(invalid("Page title metadata is unavailable")); + } + let title = string(&title); + let mut object = PropertyObject::from_object(&raw.objects[metadata])?; + object.set(&[(0x1c001cf3, &title)])?; + changed.insert(*metadata, object); + changed + .get_mut(&pages[0]) + .unwrap() + .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?; + } + Ok(()) +} diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index a9c07773f79e5aeacf87c8175a1612e93878101d..c5e2f825aa45f9d0bdddb9e643ff4ab2451b2691 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -19,6 +19,7 @@ pub mod protected; mod revisions; mod snapshot; mod store; +mod tree; mod write; pub use commit::{ @@ -39,4 +40,5 @@ pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; pub use snapshot::{read_snapshot, read_storage_snapshot}; pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store}; +pub use tree::TreeEdit; pub use write::replace_property_bytes; diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs index 88b96ec981b077a4aef5eef3358577b812d17805..cdc8749091018e43582d70c34420beeb3f32eac6 100644 --- a/crates/onestore/src/paragraph.rs +++ b/crates/onestore/src/paragraph.rs @@ -2,7 +2,7 @@ use crate::{ Error, ExGuid, Object, ObjectData, PropertySets, RevisionIndex, Store, create::{current_timestamps, properties, string}, document::{Document, Element, Kind}, - edit::{editable_parents, page_title}, + edit::{editable_parents, update_title}, write::{PropertyObject, fresh_guid, write_revision}, }; use serde::{Deserialize, Serialize}; @@ -373,51 +373,6 @@ fn ordinary_text<'a>( Ok((text, runs)) } -fn update_title( - store: &Store<'_>, - raw: &crate::ResolvedRevision<'_>, - view: crate::document::Revision<'_>, - pages: &[ExGuid], - changed: &mut BTreeMap, -) -> Result<(), Error> { - // Shorten the moved view's lifetime to the changed property buffers. - let mut view = view; - for (id, object) in changed.iter() { - view.nodes.insert( - *id, - Element::parse( - &Object { - jcid: object.jcid, - reference_count: 0, - data: ObjectData::Properties(&object.bytes), - global_ids: Arc::clone(&object.global_ids), - }, - store, - )?, - ); - } - let title = page_title(&view, pages, None)?; - drop(view); - if let Some((_, automatic, title)) = title { - let metadata = raw - .roots - .get(&2) - .ok_or_else(|| invalid("Page title metadata is unavailable"))?; - if raw.objects[metadata].jcid != 0x20030 { - return Err(invalid("Page title metadata is unavailable")); - } - let title = string(&title); - let mut object = PropertyObject::from_object(&raw.objects[metadata])?; - object.set(&[(0x1c001cf3, &title)])?; - changed.insert(*metadata, object); - changed - .get_mut(&pages[0]) - .unwrap() - .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?; - } - Ok(()) -} - /// Joins adjacent ordinary text in one outline or table cell. /// The left paragraph survives. Empty left text adopts the right text identity; /// otherwise the left text survives. The left paragraph's tags are retained. diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..64a24dcf2624a172bba3fe8dcf631f39e35f420c --- /dev/null +++ b/crates/onestore/src/tree.rs @@ -0,0 +1,597 @@ +use crate::{ + Error, ExGuid, RevisionIndex, Store, + create::{current_timestamps, properties, string}, + document::{Document, Kind, Revision}, + edit::{editable_parents, update_title}, + write::{PropertyObject, fresh_guid, write_revision}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +enum Placement { + Delete, + Move { + parent: ExGuid, + before: Option, + }, +} + +/// An atomic subtree move or deletion on one active page. +/// Retain the intent across retries so an emptied cell's replacement keeps its identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TreeEdit { + guid: [u8; 16], + object: ExGuid, + placement: Placement, + author: String, + created: u32, +} + +impl TreeEdit { + /// 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 { + Self::new(object, Placement::Delete, author) + } + + /// Moves before a direct child, or appends when `before` is None. + /// Paragraph destinations are outlines, groups, paragraphs or cells on the same page. + /// Outlines remain direct page children. Content and explicit list formatting are preserved. + pub fn move_to( + object: ExGuid, + parent: ExGuid, + before: Option, + author: &str, + ) -> Result { + Self::new(object, Placement::Move { parent, before }, author) + } + + fn new(object: ExGuid, placement: Placement, author: &str) -> Result { + let intent = Self { + guid: fresh_guid()?, + object, + placement, + author: author.to_owned(), + created: current_timestamps()?.0, + }; + intent.validate()?; + Ok(intent) + } + + fn validate(&self) -> Result<(), Error> { + if self.guid == [0; 16] || self.object.guid == [0; 16] || self.author.contains('\0') { + return Err(invalid( + "Choose page content and an author name without NUL", + )); + } + if let Placement::Move { parent, before } = self.placement + && (parent.guid == [0; 16] || before.is_some_and(|id| id.guid == [0; 16])) + { + return Err(invalid("Choose a destination and sibling on the same page")); + } + Ok(()) + } + + pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { + self.validate()?; + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + index.validate_current()?; + let mut document = Document::parse(&index)?; + let pages: Vec<_> = document + .pages()? + .into_iter() + .filter_map(|(sid, page)| (sid == space).then_some(page)) + .collect(); + let [page] = pages.as_slice() else { + return Err(invalid("Tree editing requires a single active page")); + }; + let semantic = document.spaces.remove(&space).unwrap(); + let rid = semantic.contexts[&ExGuid::default()]; + let mut view = semantic + .revisions + .into_iter() + .find_map(|(id, view)| (id == rid).then_some(view)) + .unwrap(); + let parents = editable_parents(&view, &pages, self.object)?; + let path = checked_path(&view, &parents, *page, self.object)?; + let source_parent = *path + .get(1) + .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?; + let compatible = |parent: ExGuid| match view.nodes[&self.object].kind { + Kind::Outline { .. } => parent == *page, + Kind::Paragraph { .. } => matches!( + view.nodes[&parent].kind, + Kind::Outline { .. } + | Kind::OutlineGroup + | Kind::Paragraph { .. } + | Kind::Cell { .. } + ), + _ => false, + }; + if !compatible(source_parent) || !view.nodes[&source_parent].children.contains(&self.object) + { + return Err(invalid("Select a paragraph or ordinary page outline")); + } + let mut changed_ids = BTreeSet::from([source_parent]); + let mut affected = BTreeSet::from([self.object]); + if let Placement::Move { parent, before } = self.placement { + if checked_path(&view, &parents, *page, parent)?.contains(&self.object) { + return Err(invalid("A subtree cannot move inside itself")); + } + let destination = &view.nodes[&parent]; + if !compatible(parent) || before.is_some_and(|id| !destination.children.contains(&id)) { + return Err(invalid( + "Choose a compatible destination and one of its direct children", + )); + } + let previous = destination.children.clone(); + if parent == source_parent && before == Some(self.object) { + return Ok(source.to_vec()); + } + view.nodes + .get_mut(&source_parent) + .unwrap() + .children + .retain(|id| *id != self.object); + let children = &mut view.nodes.get_mut(&parent).unwrap().children; + let position = before + .map(|id| children.iter().position(|child| *child == id).unwrap()) + .unwrap_or(children.len()); + children.insert(position, self.object); + if parent == source_parent && *children == previous { + return Ok(source.to_vec()); + } + changed_ids.insert(parent); + } else { + view.nodes + .get_mut(&source_parent) + .unwrap() + .children + .retain(|id| *id != self.object); + } + + let mut empty_cell = None; + let mut at = source_parent; + loop { + let node = &view.nodes[&at]; + if matches!(node.kind, Kind::Page { .. } | Kind::Paragraph { .. }) + && node.children.is_empty() + { + break; + } + if node.children.is_empty() { + if matches!(node.kind, Kind::Cell { .. }) { + empty_cell = Some(at); + break; + } + if !matches!(node.kind, Kind::Outline { .. } | Kind::OutlineGroup) { + return Err(invalid("This container cannot be emptied by a tree edit")); + } + if node.extra[0].iter().any(|field| field.id == 0x08001d0c) { + return Err(invalid("The emptied container cannot be deleted")); + } + let path = checked_path(&view, &parents, *page, at)?; + let parent = path[1]; + view.nodes + .get_mut(&parent) + .unwrap() + .children + .retain(|id| *id != at); + changed_ids.remove(&at); + changed_ids.insert(parent); + at = parent; + continue; + } + if matches!( + node.kind, + Kind::Outline { .. } | Kind::OutlineGroup | Kind::Paragraph { .. } + ) && node + .children + .iter() + .all(|id| matches!(view.nodes[id].kind, Kind::OutlineGroup)) + { + let groups = node.children.clone(); + let minimum = groups + .iter() + .map(|id| view.nodes[id].child_level.unwrap_or(1)) + .min() + .unwrap(); + let level = node + .child_level + .unwrap_or(1) + .checked_add(minimum) + .filter(|level| *level <= 31) + .ok_or_else(|| { + invalid("Removing this group would exceed 31 child indentation levels") + })?; + let mut children = Vec::new(); + for group in groups { + affected.insert(group); + let group_node = view.nodes.get_mut(&group).unwrap(); + let relative = group_node.child_level.unwrap_or(1) - minimum; + if relative == 0 { + if group_node.extra[0] + .iter() + .any(|field| field.id == 0x08001d0c) + { + return Err(invalid("The redundant outline group cannot be deleted")); + } + children.extend_from_slice(&group_node.children); + changed_ids.remove(&group); + } else { + group_node.child_level = Some(relative); + children.push(group); + changed_ids.insert(group); + } + } + let node = view.nodes.get_mut(&at).unwrap(); + node.children = children; + node.child_level = Some(level); + changed_ids.insert(at); + continue; + } + break; + } + + let mut pending: Vec<_> = affected.into_iter().collect(); + let mut checked = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !checked.insert(id) { + continue; + } + checked_path(&view, &parents, *page, id)?; + let node = &view.nodes[&id]; + if matches!(self.placement, Placement::Delete) + && node.extra[0].iter().any(|field| field.id == 0x08001d0c) + { + return Err(invalid( + "The selected subtree contains content that cannot be deleted", + )); + } + pending.extend( + node.children + .iter() + .chain(&node.content) + .chain(&node.structure) + .copied(), + ); + } + let raw = index.resolve(space, rid)?; + let active_parents = editable_parents(&view, &pages, *page)?; + let modified = current_timestamps()?.0.to_le_bytes(); + let mut changed = BTreeMap::new(); + let author_id = ExGuid { + guid: self.guid, + n: 3, + }; + if let Some(cell) = empty_cell { + let paragraph = ExGuid { + guid: self.guid, + n: 1, + }; + let text = ExGuid { + guid: self.guid, + n: 2, + }; + view.nodes.get_mut(&cell).unwrap().children.push(paragraph); + for (id, jcid, values) in [ + ( + paragraph, + 0x6000d, + vec![ + (0x24001c1f, 2_u32.to_le_bytes().to_vec()), + (0x0c001c03, vec![1]), + (0x14001d09, self.created.to_le_bytes().to_vec()), + (0x14001d7a, modified.to_vec()), + (0x20001d78, 3_u32.to_le_bytes().to_vec()), + (0x20001d79, 3_u32.to_le_bytes().to_vec()), + ], + ), + ( + text, + 0x6000e, + vec![(0x1c001c22, Vec::new()), (0x14001d7a, modified.to_vec())], + ), + ] { + changed.insert( + id, + PropertyObject { + jcid, + bytes: properties(&values)?, + global_ids: Arc::new(BTreeMap::from([(0, self.guid)])), + }, + ); + } + } + let moved_paragraph = matches!(self.placement, Placement::Move { .. }) + && matches!(view.nodes[&self.object].kind, Kind::Paragraph { .. }); + if empty_cell.is_some() || moved_paragraph { + changed.insert( + author_id, + PropertyObject { + jcid: 0x120001, + bytes: properties(&[(0x1c001d75, string(&self.author))])?, + global_ids: Arc::new(BTreeMap::from([(0, self.guid)])), + }, + ); + } + if let Some(author) = changed.get(&author_id) + && raw.objects.get(&author_id).is_some_and(|old| { + old.jcid == author.jcid && old.data == crate::ObjectData::Properties(&author.bytes) + }) + { + changed.remove(&author_id); + } + if changed.keys().any(|id| raw.objects.contains_key(id)) { + return Err(invalid( + "A tree-edit identity already exists; reconcile the existing edit", + )); + } + let mut ancestors = BTreeSet::new(); + for id in &changed_ids { + ancestors.extend(checked_path(&view, &active_parents, *page, *id)?); + } + for id in ancestors { + let mut object = PropertyObject::from_object(&raw.objects[&id])?; + if changed_ids.contains(&id) { + let node = &view.nodes[&id]; + let mut references = Vec::new(); + for child in &node.children { + references.extend_from_slice(&object.reference(*child)?); + } + object.set(&[(0x24001c20, &references)])?; + if let Some(level) = node.child_level { + object.set(&[(0x0c001c03, &[level])])?; + } + } + object.set(&[(0x14001d7a, &modified)])?; + changed.insert(id, object); + } + if moved_paragraph { + let mut object = PropertyObject::from_object(&raw.objects[&self.object])?; + let author = object.reference(author_id)?; + object.set(&[(0x20001d79, &author), (0x14001d7a, &modified)])?; + changed.insert(self.object, object); + } + update_title(&store, &raw, view, &pages, &mut changed)?; + write_revision(source, space, |_| Ok(changed)) + } +} + +fn checked_path( + view: &Revision<'_>, + parents: &BTreeMap>, + page: ExGuid, + object: ExGuid, +) -> Result, Error> { + let mut path = Vec::new(); + let mut seen = BTreeSet::new(); + let mut id = object; + loop { + if !seen.insert(id) { + return Err(invalid("Tree ancestry contains a cycle")); + } + let node = view + .nodes + .get(&id) + .ok_or_else(|| invalid("The selected page content is unavailable"))?; + if matches!(node.kind, Kind::Title) + || node.extra[0] + .iter() + .any(|field| matches!(field.id, 0x88001cb4 | 0x88001cf9 | 0x88001cb2 | 0x88001cde)) + { + return Err(invalid( + "Title or protected content cannot be moved or deleted here", + )); + } + path.push(id); + if id == page { + break; + } + let [parent] = parents.get(&id).map(Vec::as_slice).unwrap_or_default() else { + return Err(invalid("Tree content must have one active parent")); + }; + id = *parent; + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Insertion, PreparedEdit}; + + #[test] + fn a_retained_move_intent_can_reuse_its_immutable_author_after_another_move() { + let source = crate::create_section("move.one", "First", "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(); + let first = view.nodes[&outline].children[0]; + let second = Insertion::paragraph(outline, None, "Second", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &second) + .unwrap() + .as_bytes() + .to_vec(); + let retained = TreeEdit::move_to(first, outline, None, "Tree author").unwrap(); + let moved = PreparedEdit::tree(&source, sid, &retained).unwrap(); + let reversed = + TreeEdit::move_to(first, outline, Some(second.object()), "Tree author").unwrap(); + let reversed = PreparedEdit::tree(moved.as_bytes(), sid, &reversed).unwrap(); + let repeated = PreparedEdit::tree(reversed.as_bytes(), sid, &retained).unwrap(); + let store = Store::parse(repeated.as_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()]]; + assert_eq!(view.nodes[&outline].children, [second.object(), first]); + assert_eq!( + view.nodes[&first].latest_author, + Some(ExGuid { + guid: retained.guid, + n: 3 + }) + ); + } + + #[test] + fn group_normalization_preserves_unequal_indentation_and_overlapping_moves() { + let source = crate::create_section("groups.one", "First", "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(); + let first = view.nodes[&outline].children[0]; + let second = Insertion::paragraph(outline, None, "Second", "Author").unwrap(); + let third = Insertion::paragraph(outline, None, "Third", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &second) + .unwrap() + .as_bytes() + .to_vec(); + let source = PreparedEdit::insert(&source, sid, &third) + .unwrap() + .as_bytes() + .to_vec(); + let guid = fresh_guid().unwrap(); + let a = ExGuid { guid, n: 1 }; + let b = ExGuid { guid, n: 2 }; + let source = write_revision(&source, sid, |raw| { + let mut changed = BTreeMap::new(); + for (id, child, level) in [(a, first, 2), (b, second.object(), 1)] { + let mut group = PropertyObject { + jcid: 0x60019, + bytes: properties(&[(0x0c001c03, vec![level])])?, + global_ids: Arc::new(BTreeMap::from([(0, guid)])), + }; + let reference = group.reference(child)?; + group.set(&[(0x24001c20, &reference)])?; + changed.insert(id, group); + } + let mut object = PropertyObject::from_object(&raw.objects[&outline])?; + let mut references = Vec::new(); + for id in [a, b, third.object()] { + references.extend_from_slice(&object.reference(id)?); + } + object.set(&[(0x24001c20, &references)])?; + changed.insert(outline, object); + Ok(changed) + }) + .unwrap(); + for moving in [false, true] { + let intent = if moving { + TreeEdit::move_to(third.object(), a, Some(first), "Author") + } else { + TreeEdit::delete(third.object(), "Author") + } + .unwrap(); + let edit = PreparedEdit::tree(&source, sid, &intent).unwrap(); + let store = Store::parse(edit.as_bytes()).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()]]; + assert_eq!(view.nodes[&outline].children, [a, second.object()]); + assert_eq!(view.nodes[&outline].child_level, Some(2)); + assert_eq!(view.nodes[&a].child_level, Some(1)); + assert_eq!( + view.nodes[&a].children, + if moving { + vec![third.object(), first] + } else { + vec![first] + } + ); + if !moving && let Some(output) = std::env::var_os("ONESTORE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output).join("unequal-groups"); + assert!(output.is_absolute()); + std::fs::create_dir_all(output.parent().unwrap()).unwrap(); + std::fs::create_dir(&output).unwrap(); + std::fs::write(output.join("groups.one"), edit.as_bytes()).unwrap(); + } + } + } + + #[test] + fn protected_descendants_and_ambiguous_parents_reject_before_publication() { + let source = crate::create_section("protected.one", "Text", "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(); + let paragraph = view.nodes[&outline].children[0]; + let text = view.nodes[¶graph].content[0]; + let deletion = TreeEdit::delete(paragraph, "Author").unwrap(); + for target in [page, outline, paragraph, text] { + for property in [0x08001cde, 0x08001cb4, 0x08001cf9, 0x08001cb2] { + for enabled in [false, true] { + let bytes = write_revision(&source, sid, |raw| { + let mut object = PropertyObject::from_object(&raw.objects[&target])?; + object.set(&[(property | (u32::from(enabled) << 31), &[])])?; + Ok(BTreeMap::from([(target, object)])) + }) + .unwrap(); + assert_eq!( + PreparedEdit::tree(&bytes, sid, &deletion).is_err(), + enabled, + "{target}: {property:#x}" + ); + } + } + } + for protected in [outline, paragraph, text] { + let bytes = write_revision(&source, sid, |raw| { + let mut object = PropertyObject::from_object(&raw.objects[&protected])?; + object.set(&[(0x08001d0c, &[])])?; + Ok(BTreeMap::from([(protected, object)])) + }) + .unwrap(); + assert!(PreparedEdit::tree(&bytes, sid, &deletion).is_err()); + } + let bytes = write_revision(&source, sid, |raw| { + let mut object = PropertyObject::from_object(&raw.objects[&outline])?; + let reference = object.reference(paragraph)?; + object.set(&[(0x24001c20, &reference.repeat(2))])?; + Ok(BTreeMap::from([(outline, object)])) + }) + .unwrap(); + assert!(PreparedEdit::tree(&bytes, sid, &deletion).is_err()); + let mut value = serde_json::to_value(&deletion).unwrap(); + value["guid"] = serde_json::to_value([0_u8; 16]).unwrap(); + let restored = serde_json::from_value(value).unwrap(); + assert!(PreparedEdit::tree(&source, sid, &restored).is_err()); + } +} diff --git a/crates/onestore/tests/support/tree_model.rs b/crates/onestore/tests/support/tree_model.rs new file mode 100644 index 0000000000000000000000000000000000000000..117a41157772e63c51134b0323e30c187d2444bb --- /dev/null +++ b/crates/onestore/tests/support/tree_model.rs @@ -0,0 +1,364 @@ +use onestore::{ + CommitState, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, + document::{Document, Kind, Revision}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::LazyLock, +}; + +use crate::{current, disk}; + +static SOURCE: LazyLock> = LazyLock::new(|| { + let mut source = onestore::create_section("tree.one", "Original", "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(); + let other = Insertion::outline(page, 360.0, 72.0, "Other", "Author").unwrap(); + source = PreparedEdit::insert(&source, sid, &other) + .unwrap() + .as_bytes() + .to_vec(); + for parent in [outline, other.object()] { + for text in ["First 🦀 é", "Second 東京", "Third"] { + let insertion = Insertion::paragraph(parent, None, text, "Author").unwrap(); + source = PreparedEdit::insert(&source, sid, &insertion) + .unwrap() + .as_bytes() + .to_vec(); + let child = Insertion::paragraph(insertion.object(), None, "Child", "Author").unwrap(); + source = PreparedEdit::insert(&source, sid, &child) + .unwrap() + .as_bytes() + .to_vec(); + } + } + source +}); + +fn forest(view: &Revision<'_>, root: ExGuid) -> BTreeMap { + let mut positions = BTreeMap::new(); + let mut pending = vec![(root, root, 0)]; + while let Some((id, mut scope, mut level)) = pending.pop() { + let node = &view.nodes[&id]; + if matches!(node.kind, Kind::Outline { .. } | Kind::Cell { .. }) { + scope = id; + level = 0; + } + assert!( + positions + .insert(id, (scope, level, positions.len())) + .is_none() + ); + pending.extend( + node.children + .iter() + .rev() + .map(|id| (*id, scope, level + u32::from(node.child_level.unwrap_or(1)))), + ); + pending.extend(node.content.iter().rev().map(|id| (*id, scope, level))); + } + positions +} + +pub fn run(input: &[u8]) { + let Some((&selector, input)) = input.split_first() else { + return; + }; + let source = if selector & 1 == 0 { + SOURCE.as_slice() + } else { + include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one") + }; + let mut persisted = source.to_vec(); + let mut clients = vec![persisted.clone(); 12]; + for step in input.chunks_exact(10).take(36) { + let client = usize::from(step[0]) % clients.len(); + if step[1] & 1 != 0 { + clients[client] = persisted.clone(); + } + let source = &clients[client]; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + let (sid, page) = pages[usize::from(step[2]) % pages.len()]; + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let before = forest(view, page); + let mut targets: Vec<_> = before + .keys() + .copied() + .filter(|id| { + matches!( + view.nodes[id].kind, + Kind::Paragraph { .. } | Kind::Outline { .. } + ) + }) + .collect(); + targets.sort_by_key(|id| before[id].2); + if targets.is_empty() { + continue; + } + let target = targets[usize::from(step[3]) % targets.len()]; + let subtree = forest(view, target); + let parents: BTreeMap<_, _> = before + .keys() + .flat_map(|id| view.nodes[id].children.iter().map(|child| (*child, *id))) + .collect(); + let parent = parents[&target]; + let deleting = step[1] & 6 == 0; + let mut expected: BTreeMap<_, _> = before + .iter() + .filter_map(|(id, pos)| { + matches!(view.nodes[id].kind, Kind::Paragraph { .. }) + .then_some((*id, (pos.0, pos.1))) + }) + .collect(); + let mut ordered: Vec<_> = expected.keys().copied().collect(); + ordered.sort_by_key(|id| before[id].2); + let mut expected_order = BTreeMap::<_, Vec<_>>::new(); + for id in ordered { + expected_order.entry(before[&id].0).or_default().push(id); + } + let intent; + let mut reversal = None; + let edit = if deleting { + expected.retain(|id, _| !subtree.contains_key(id)); + for ids in expected_order.values_mut() { + ids.retain(|id| !subtree.contains_key(id)); + } + intent = TreeEdit::delete(target, "Tree schedule").unwrap(); + PreparedEdit::tree(source, sid, &intent) + } else { + let mut destinations: Vec<_> = before + .keys() + .copied() + .filter(|id| { + if matches!(view.nodes[&target].kind, Kind::Outline { .. }) { + *id == page + } else { + matches!( + view.nodes[id].kind, + Kind::Outline { .. } + | Kind::OutlineGroup + | Kind::Paragraph { .. } + | Kind::Cell { .. } + ) + } + }) + .collect(); + destinations.sort_by_key(|id| before[id].2); + if destinations.is_empty() { + continue; + } + let destination = destinations[usize::from(step[4]) % destinations.len()]; + let children = &view.nodes[&destination].children; + let anchor = children + .get(usize::from(step[5]) % (children.len() + 1)) + .copied(); + intent = TreeEdit::move_to(target, destination, anchor, "Tree schedule").unwrap(); + let edit = PreparedEdit::tree(source, sid, &intent); + if subtree.contains_key(&destination) { + assert!(edit.is_err()); + continue; + } + if destination == parent && step[9] & 3 == 0 { + let original = &view.nodes[&parent].children; + let next = original.iter().position(|id| *id == target).unwrap() + 1; + reversal = Some( + TreeEdit::move_to(target, parent, original.get(next).copied(), "Tree schedule") + .unwrap(), + ); + } + if matches!(view.nodes[&target].kind, Kind::Paragraph { .. }) { + let (scope, base, _) = before[&destination]; + let level = base + u32::from(view.nodes[&destination].child_level.unwrap_or(1)); + let (old_scope, old_level, _) = before[&target]; + if anchor != Some(target) { + let moved: Vec<_> = expected_order[&old_scope] + .iter() + .copied() + .filter(|id| subtree.contains_key(id)) + .collect(); + expected_order + .get_mut(&old_scope) + .unwrap() + .retain(|id| !subtree.contains_key(id)); + let boundary = anchor.map(|id| before[&id].2).unwrap_or_else(|| { + forest(view, destination) + .keys() + .map(|id| before[id].2) + .max() + .unwrap() + + 1 + }); + let sequence = expected_order.entry(scope).or_default(); + let position = sequence + .iter() + .position(|id| before[id].2 >= boundary) + .unwrap_or(sequence.len()); + sequence.splice(position..position, moved); + } + for (id, pos) in &mut expected { + if subtree.contains_key(id) && pos.0 == old_scope { + *pos = (scope, level + pos.1 - old_level); + } + } + } + edit + }; + let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, restored); + let edit = match edit { + Ok(edit) => edit, + Err(error) + if error.message + == "Removing this group would exceed 31 child indentation levels" => + { + continue; + } + Err(error) => panic!("{intent:?}: {error:?}"), + }; + let after_store = Store::parse(edit.as_bytes()).unwrap(); + let after_index = RevisionIndex::parse(&after_store).unwrap(); + after_index.validate_current().unwrap(); + let after_document = Document::parse(&after_index).unwrap(); + let space = &after_document.spaces[&sid]; + let after = &space.revisions[&space.contexts[&ExGuid::default()]]; + let actual = forest(after, page); + let mut added = BTreeSet::new(); + for (id, pos) in &actual { + let node = &after.nodes[id]; + if let Kind::Paragraph { .. } = node.kind { + if let Some(wanted) = expected.remove(id) { + assert_eq!((pos.0, pos.1), wanted, "{intent:?}: {id}"); + } else { + assert!(!before.contains_key(id)); + assert!(matches!(view.nodes[&parent].kind, Kind::Cell { .. })); + assert_eq!(view.nodes[&parent].children, [target]); + assert_eq!(node.children.len(), 0); + assert_eq!(node.content.len(), 1); + assert!(node.tags.is_empty()); + assert!( + matches!(&after.nodes[&node.content[0]].kind,Kind::RichText{text,..} if text.is_empty()) + ); + added.insert(*id); + } + } + if before.contains_key(id) { + assert_eq!(node.content, view.nodes[id].content); + let mut expected_kind = serde_json::to_value(&view.nodes[id].kind).unwrap(); + if *id == page && expected_kind["alternate_title"].is_string() { + let mut roots = after.nodes[&page].children.clone(); + roots.sort_by(|a, b| { + let a = &after.nodes[a].layout; + let b = &after.nodes[b].layout; + a.y.unwrap_or(0.0) + .total_cmp(&b.y.unwrap_or(0.0)) + .then_with(|| a.x.unwrap_or(0.0).total_cmp(&b.x.unwrap_or(0.0))) + }); + let mut title = String::new(); + for root in roots { + let mut nodes: Vec<_> = forest(after, root).into_iter().collect(); + nodes.sort_by_key(|(_, pos)| pos.2); + for (id, _) in nodes { + if let Kind::RichText { text, .. } = &after.nodes[&id].kind { + title = text + .trim_start() + .split('\r') + .next() + .unwrap() + .trim_end() + .to_owned(); + if !title.is_empty() { + break; + } + } + } + if !title.is_empty() { + break; + } + } + expected_kind["alternate_title"] = title.into(); + let Kind::Metadata { title, .. } = &after.nodes[&after.roots[&2]].kind else { + panic!() + }; + assert_eq!(title.as_deref(), expected_kind["alternate_title"].as_str()); + } + assert_eq!(serde_json::to_value(&node.kind).unwrap(), expected_kind); + assert_eq!( + serde_json::to_value(&node.tags).unwrap(), + serde_json::to_value(&view.nodes[id].tags).unwrap() + ); + } + if matches!( + node.kind, + Kind::Outline { .. } | Kind::OutlineGroup | Kind::Cell { .. } + ) { + assert!( + node.children + .iter() + .any(|id| matches!(after.nodes[id].kind, Kind::Paragraph { .. })) + ); + } + } + assert!(expected.is_empty()); + assert!(added.len() <= 1); + let mut ordered: Vec<_> = actual + .iter() + .filter(|(id, _)| { + matches!(after.nodes[id].kind, Kind::Paragraph { .. }) && !added.contains(id) + }) + .collect(); + ordered.sort_by_key(|(_, pos)| pos.2); + let mut actual_order = BTreeMap::<_, Vec<_>>::new(); + for (id, pos) in ordered { + actual_order.entry(pos.0).or_default().push(*id); + } + expected_order.retain(|_, ids| !ids.is_empty()); + assert_eq!(actual_order, expected_order, "{intent:?}"); + if let Some(reversal) = reversal { + let reversed = PreparedEdit::tree(edit.as_bytes(), sid, &reversal).unwrap(); + let replayed = PreparedEdit::tree(reversed.as_bytes(), sid, &intent).unwrap(); + let store = Store::parse(replayed.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let replayed = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert_eq!(forest(replayed, page), actual); + } + let before = current::current(&persisted); + let after = current::current(edit.as_bytes()); + let mut disk = disk::Disk { + visible: persisted.clone(), + durable: persisted.clone(), + operation: 0, + fail_at: (step[6] != 0).then_some(usize::from(u16::from_le_bytes([step[6], step[7]]))), + write_limit: if step[8] & 1 == 0 { 17 } else { 4096 }, + random: u64::from(step[9]) + 1, + }; + let result = edit.commit(&mut disk); + let actual = current::current(&disk.durable); + match result { + Ok(()) => assert_eq!(actual, after), + Err(error) => { + assert!(actual == before || actual == after); + match error.state { + CommitState::NotCommitted => assert_eq!(actual, before), + CommitState::Committed => assert_eq!(actual, after), + CommitState::Unknown => {} + } + } + } + persisted = disk.durable; + } +} diff --git a/crates/onestore/tests/tree.rs b/crates/onestore/tests/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..13d30afbc15613b51832d82b13f66bddeca68376 --- /dev/null +++ b/crates/onestore/tests/tree.rs @@ -0,0 +1,520 @@ +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, TreeEdit, + document::{Document, Kind, Revision}, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[path = "support/current.rs"] +mod current; +#[path = "support/disk.rs"] +mod disk; +#[path = "support/tree_model.rs"] +mod tree_model; + +const FIXTURES: [(&[u8], &[u8]); 2] = [ + ( + include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"), + include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one"), + ), + ( + include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"), + include_bytes!("../../../corpus/outline-edit/tree/after/notebook/synthetic.one"), + ), +]; + +#[test] +fn twelve_client_tree_schedules_preserve_content_and_indentation() { + let mut random = 1932_u64; + for _ in 0..80 { + let input: Vec<_> = (0..241) + .map(|_| { + random ^= random << 13; + random ^= random >> 7; + random ^= random << 17; + random as u8 + }) + .collect(); + tree_model::run(&input); + } +} + +fn active(view: &Revision<'_>, page: ExGuid) -> BTreeSet { + let mut pending = vec![page]; + let mut ids = BTreeSet::new(); + while let Some(id) = pending.pop() { + assert!(ids.insert(id), "Repeated active identity: {id}"); + let node = &view.nodes[&id]; + pending.extend( + node.children + .iter() + .chain(&node.content) + .chain(&node.structure) + .copied(), + ); + } + ids +} + +fn operation(view: &Revision<'_>, page: ExGuid) -> Option<(String, ExGuid, TreeEdit)> { + let Kind::Metadata { + title: Some(name), .. + } = &view.nodes[&view.roots[&2]].kind + else { + panic!() + }; + let ids = active(view, page); + let paragraphs: BTreeMap<_, _> = ids + .iter() + .filter_map(|id| { + let node = &view.nodes[id]; + let text = node.content.first()?; + match &view.nodes[text].kind { + Kind::RichText { text, .. } if matches!(node.kind, Kind::Paragraph { .. }) => { + Some((text.as_str(), *id)) + } + _ => None, + } + }) + .collect(); + let target = *paragraphs + .iter() + .find(|(text, _)| text.starts_with("Target "))? + .1; + let outline = *view.nodes[&page] + .children + .iter() + .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .unwrap(); + let parent = *ids + .iter() + .find(|id| view.nodes[id].children.contains(&target)) + .unwrap(); + let edit = match name.as_str() { + "Delete outline" => TreeEdit::delete(outline, "Tree author"), + name if name.starts_with("Delete") => TreeEdit::delete(target, "Tree author"), + "Move leaf down" + | "Move subtree down" + | "Move numbered subtree down" + | "Move cell subtree down" => TreeEdit::move_to(target, parent, None, "Tree author"), + "Move subtree up" => { + TreeEdit::move_to(target, parent, Some(paragraphs["Anchor"]), "Tree author") + } + "Indent subtree" | "Indent bullet subtree" => { + TreeEdit::move_to(target, paragraphs["Anchor"], None, "Tree author") + } + "Outdent subtree" | "Outdent first group" => TreeEdit::move_to( + target, + outline, + Some(paragraphs["Trailing sibling"]), + "Tree author", + ), + _ => return None, + } + .unwrap(); + Some(( + name.clone(), + if name == "Delete outline" { + outline + } else { + target + }, + edit, + )) +} + +#[test] +fn native_subtree_controls_match_with_preserved_fields_and_history() { + let mut count = 0; + for (source, native) in FIXTURES { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let native_store = Store::parse(native).unwrap(); + let native_index = RevisionIndex::parse(&native_store).unwrap(); + let native_document = Document::parse(&native_index).unwrap(); + let mut candidate = source.to_vec(); + for (sid, page) in document.pages().unwrap() { + let space = &document.spaces[&sid]; + let before = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Some((name, object, edit)) = operation(before, page) else { + continue; + }; + let restored = serde_json::from_value(serde_json::to_value(&edit).unwrap()).unwrap(); + assert_eq!(edit, restored); + let prepared = PreparedEdit::tree(source, sid, &restored).unwrap(); + let after_store = Store::parse(prepared.as_bytes()).unwrap(); + assert!(after_store.checksum_mismatches.is_empty()); + let after_index = RevisionIndex::parse(&after_store).unwrap(); + after_index.validate_current().unwrap(); + let after_document = Document::parse(&after_index).unwrap(); + let space = &after_document.spaces[&sid]; + let after = &space.revisions[&space.contexts[&ExGuid::default()]]; + let space = &native_document.spaces[&sid]; + let reference = &space.revisions[&space.contexts[&ExGuid::default()]]; + let old_ids = active(before, page); + let actual = active(after, page); + let native_ids = active(reference, page); + assert_eq!( + actual.intersection(&old_ids).collect::>(), + native_ids.intersection(&old_ids).collect::>(), + "{name}" + ); + assert_eq!(actual.len(), native_ids.len(), "{name}"); + let mut mutable = BTreeSet::new(); + for view in [before, after] { + let ids = active(view, page); + if !ids.contains(&object) { + continue; + } + let mut pending = vec![object]; + while let Some(id) = pending.pop() { + mutable.insert(id); + pending.extend( + ids.iter() + .filter(|parent| { + view.nodes[parent] + .children + .iter() + .chain(&view.nodes[parent].content) + .chain(&view.nodes[parent].structure) + .any(|child| *child == id) + }) + .copied(), + ); + } + } + let mut remap = BTreeMap::new(); + if name == "Delete sole cell paragraph" { + for kind in ["Paragraph", "RichText"] { + let selected = |view: &Revision<'_>, ids: &BTreeSet| { + *ids.difference(&old_ids) + .find(|id| { + serde_json::to_value(&view.nodes[id].kind).unwrap()["type"] == kind + }) + .unwrap() + }; + remap.insert(selected(reference, &native_ids), selected(after, &actual)); + } + } + for id in &actual { + let reference_id = remap + .iter() + .find_map(|(a, b)| (b == id).then_some(*a)) + .unwrap_or(*id); + let wanted = &reference.nodes[&reference_id]; + let node = &after.nodes[id]; + for (a, b) in [ + (&node.children, &wanted.children), + (&node.content, &wanted.content), + (&node.structure, &wanted.structure), + ] { + assert_eq!( + *a, + b.iter() + .map(|id| remap.get(id).copied().unwrap_or(*id)) + .collect::>(), + "{name}: {id}" + ); + } + if !node.children.is_empty() { + assert_eq!(node.child_level, wanted.child_level, "{name}: {id}"); + } + if old_ids.contains(id) { + let old = &before.nodes[id]; + let mut expected = serde_json::to_value(old).unwrap(); + let mut actual = serde_json::to_value(node).unwrap(); + expected["children"] = actual["children"].clone(); + if !node.children.is_empty() { + expected["child_level"] = actual["child_level"].clone(); + } + if mutable.contains(id) + || node.child_level != old.child_level + || node.children != old.children + { + assert!(node.modified >= old.modified); + expected["modified"] = actual["modified"].clone(); + } + if *id == object + && !name.starts_with("Delete") + && matches!(node.kind, Kind::Paragraph { .. }) + { + expected["latest_author"] = actual["latest_author"].clone(); + assert!( + matches!(&after.nodes[&node.latest_author.unwrap()].kind,Kind::Author{name:Some(name)} if name=="Tree author") + ); + } + for value in [&mut expected, &mut actual] { + value["extra"][0] + .as_array_mut() + .unwrap() + .sort_by_key(|field| field["id"].as_u64().unwrap()); + } + assert_eq!(actual, expected, "{name}: preserved {id}"); + if matches!(old.kind, Kind::RichText { .. }) { + assert_eq!( + serde_json::to_value(node).unwrap(), + serde_json::to_value(old).unwrap(), + "{name}: text {id}" + ); + } + assert_eq!( + serde_json::to_value(&node.kind).unwrap(), + serde_json::to_value(&old.kind).unwrap(), + "{name}: kind {id}" + ); + assert_eq!( + serde_json::to_value(&node.tags).unwrap(), + serde_json::to_value(&old.tags).unwrap(), + "{name}: tags {id}" + ); + } else if matches!(node.kind, Kind::RichText { .. }) { + assert_eq!( + serde_json::to_value(&node.kind).unwrap(), + serde_json::to_value(&wanted.kind).unwrap() + ); + } + } + for (sid, space) in &index.spaces { + for rid in space.revisions.keys() { + let old = index.resolve(*sid, *rid).unwrap(); + let retained = after_index.resolve(*sid, *rid).unwrap(); + assert_eq!(old.roots, retained.roots); + for (id, object) in old.objects { + assert_eq!(object.data, retained.objects[&id].data); + } + } + } + let another = PreparedEdit::tree(source, sid, &edit).unwrap(); + let second_store = Store::parse(another.as_bytes()).unwrap(); + let second_index = RevisionIndex::parse(&second_store).unwrap(); + let second_doc = Document::parse(&second_index).unwrap(); + let space = &second_doc.spaces[&sid]; + assert_eq!( + actual, + active(&space.revisions[&space.contexts[&ExGuid::default()]], page) + ); + candidate = PreparedEdit::tree(&candidate, sid, &edit) + .unwrap() + .as_bytes() + .to_vec(); + count += 1; + } + if let Some(output) = std::env::var_os("ONESTORE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output).join(if source == FIXTURES[0].0 { + "ordinary" + } else { + "groups-cells" + }); + assert!(output.is_absolute()); + std::fs::create_dir_all(&output).unwrap(); + std::fs::write(output.join("synthetic.one"), candidate).unwrap(); + } + } + assert_eq!(count, 18); +} + +#[test] +fn moves_reject_cycles_wrong_parents_and_stale_siblings() { + let source = onestore::create_section("tree.one", "Text", "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(); + let paragraph = view.nodes[&outline].children[0]; + let text = view.nodes[¶graph].content[0]; + for intent in [ + TreeEdit::delete(page, "Author"), + TreeEdit::delete(text, "Author"), + TreeEdit::move_to(outline, paragraph, None, "Author"), + TreeEdit::move_to(paragraph, paragraph, None, "Author"), + TreeEdit::move_to(paragraph, text, None, "Author"), + TreeEdit::move_to(paragraph, outline, Some(page), "Author"), + ] { + assert!(PreparedEdit::tree(&source, sid, &intent.unwrap()).is_err()); + } + for before in [None, Some(paragraph)] { + let edit = TreeEdit::move_to(paragraph, outline, before, "Author").unwrap(); + assert_eq!( + PreparedEdit::tree(&source, sid, &edit).unwrap().as_bytes(), + source + ); + } +} + +#[test] +fn publication_interruptions_expose_complete_old_or_new_trees() { + let source = FIXTURES[1].0; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let before = current::current(source); + let mut count = 0; + for (sid, page) in document.pages().unwrap() { + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Some((name, _, intent)) = operation(view, page) else { + continue; + }; + if !matches!( + name.as_str(), + "Delete sole cell paragraph" + | "Delete unindented sibling after group" + | "Move cell subtree down" + ) { + continue; + } + let edit = PreparedEdit::tree(source, sid, &intent).unwrap(); + let after = current::current(edit.as_bytes()); + for write_limit in [17, 4096] { + let disk = |fail_at| disk::Disk { + visible: source.to_vec(), + durable: source.to_vec(), + operation: 0, + fail_at, + write_limit, + random: 1931, + }; + let mut success = disk(None); + edit.commit(&mut success).unwrap(); + assert_eq!(success.durable, edit.as_bytes()); + for at in 1..=success.operation { + let mut interrupted = disk(Some(at)); + let failure = edit.commit(&mut interrupted).unwrap_err(); + let actual = current::current(&interrupted.durable); + assert!(actual == before || actual == after, "{name}: {at}"); + match failure.state { + onestore::CommitState::NotCommitted => assert_eq!(actual, before), + onestore::CommitState::Committed => assert_eq!(actual, after), + onestore::CommitState::Unknown => {} + } + } + } + count += 1; + } + assert_eq!(count, 3); +} + +#[test] +fn cross_container_moves_keep_tables_and_replace_emptied_cells() { + let source = FIXTURES[1].0; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut candidate = source.to_vec(); + let mut count = 0; + 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 { + panic!() + }; + if !matches!( + name.as_str(), + "Outdent first group" + | "Delete sole cell paragraph" + | "Move cell subtree down" + | "Delete cell subtree" + ) { + continue; + } + let ids = active(view, page); + let parents: BTreeMap<_, _> = ids + .iter() + .flat_map(|id| view.nodes[id].children.iter().map(|child| (*child, *id))) + .collect(); + let target=*ids.iter().find(|id| view.nodes[id].content.first().is_some_and(|text| matches!(&view.nodes[text].kind,Kind::RichText{text,..} if text.starts_with("Target ")))).unwrap(); + let outlines: Vec<_> = view.nodes[&page] + .children + .iter() + .copied() + .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .collect(); + let cells: Vec<_> = ids + .iter() + .copied() + .filter(|id| matches!(view.nodes[id].kind, Kind::Cell { .. })) + .collect(); + let (object, destination) = match name.as_str() { + "Outdent first group" => (*view.nodes[&outlines[0]].children.last().unwrap(), target), + "Delete sole cell paragraph" => ( + target, + *cells.iter().find(|id| **id != parents[&target]).unwrap(), + ), + "Move cell subtree down" => (target, outlines[1]), + _ => (view.nodes[&outlines[0]].children[0], outlines[1]), + }; + let intent = TreeEdit::move_to(object, destination, None, "Tree author").unwrap(); + let edit = PreparedEdit::tree(source, sid, &intent).unwrap(); + let store = Store::parse(edit.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let after = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert_eq!(after.nodes[&destination].children.last(), Some(&object)); + let actual = active(after, page); + for id in ids.intersection(&actual) { + let old = &view.nodes[id]; + let node = &after.nodes[id]; + assert_eq!(node.content, old.content, "{name}: {id}"); + assert_eq!( + serde_json::to_value(&node.kind).unwrap(), + serde_json::to_value(&old.kind).unwrap() + ); + if matches!( + node.kind, + Kind::RichText { .. } | Kind::Table { .. } | Kind::Row | Kind::Cell { .. } + ) { + assert_eq!( + serde_json::to_value(&node.format).unwrap(), + serde_json::to_value(&old.format).unwrap() + ); + assert_eq!( + serde_json::to_value(&node.layout).unwrap(), + serde_json::to_value(&old.layout).unwrap() + ); + } + } + match name.as_str() { + "Outdent first group" => { + assert_eq!(after.nodes[&outlines[0]].children, [target]); + assert_eq!(after.nodes[&outlines[0]].child_level, Some(2)); + assert!(!actual.contains(&parents[&target])); + } + "Delete sole cell paragraph" => { + let old_cell = parents[&target]; + let replacement = after.nodes[&old_cell].children[0]; + assert!(!ids.contains(&replacement)); + assert_eq!(after.nodes[&old_cell].children.len(), 1); + let text = after.nodes[&replacement].content[0]; + assert!( + matches!(&after.nodes[&text].kind,Kind::RichText{text,..} if text.is_empty()) + ); + } + "Delete cell subtree" => assert!(!actual.contains(&outlines[0])), + _ => assert_eq!(after.nodes[&parents[&target]].children.len(), 1), + } + candidate = PreparedEdit::tree(&candidate, sid, &intent) + .unwrap() + .as_bytes() + .to_vec(); + count += 1; + } + assert_eq!(count, 4); + if let Some(output) = std::env::var_os("ONESTORE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output).join("cross-container"); + assert!(output.is_absolute()); + std::fs::create_dir_all(output.parent().unwrap()).unwrap(); + std::fs::create_dir(&output).unwrap(); + std::fs::write(output.join("synthetic.one"), candidate).unwrap(); + } +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 8030aaced5f9bd27b8999e7e3a26402a8d929126..ba1cb1402bc1864d3c6c46ed5cd72ef809231bdd 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -100,3 +100,10 @@ path = "fuzz_targets/offline_paragraph.rs" test = false doc = false bench = false + +[[bin]] +name = "tree" +path = "fuzz_targets/tree.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/tree.rs b/fuzz/fuzz_targets/tree.rs new file mode 100644 index 0000000000000000000000000000000000000000..0b566cdd27345282e9f25b454c0e6ba0e46b2398 --- /dev/null +++ b/fuzz/fuzz_targets/tree.rs @@ -0,0 +1,11 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +#[path = "../../crates/onestore/tests/support/current.rs"] +mod current; +#[path = "../../crates/onestore/tests/support/disk.rs"] +mod disk; +#[path = "../../crates/onestore/tests/support/tree_model.rs"] +mod tree_model; + +fuzz_target!(|input: &[u8]| tree_model::run(input)); diff --git a/tools/test_outline_edit.py b/tools/test_outline_edit.py index 6cfb508c200184ed6d9f84cd1542ae3f009ab5ed..fe7a48d6c203e31b2e2be004b74e7285e8eb9fd7 100644 --- a/tools/test_outline_edit.py +++ b/tools/test_outline_edit.py @@ -19,6 +19,36 @@ 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 + with self.subTest(case=name), TemporaryDirectory() as temporary: + models = {} + for phase, notebook in [('candidate', fixture / 'candidate'), ('cold', fixture / 'cold/notebook')]: + folder = Path(temporary) / phase + shutil.copytree(fixture / 'cold/read', folder / 'read') + compare(notebook, folder / 'read') + section, = notebook.glob('*.one') + subprocess.run([EXPORTER, section, folder / 'model'], check=True) + model = json.loads((folder / 'model/document.json').read_text()) + models[phase] = {sid: (r, page) for sid, _, r, page in ordered_pages(model)} + self.assertEqual(len(models[phase]), pages) + self.assertEqual(models['candidate'].keys(), models['cold'].keys()) + for sid, (old, page) in models['candidate'].items(): + saved, saved_page = models['cold'][sid] + self.assertEqual(page, saved_page) + self.assertEqual(dict(walk(old, page)), dict(walk(saved, page))) + self.assertEqual(old['nodes'][old['roots']['2']]['kind'], saved['nodes'][saved['roots']['2']]['kind']) + 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': + 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)) + for group in root.findall('one:Outline/one:OEChildren', ns)], + [('3', 'First'), ('2', 'Second')]) + def test_native_group_list_and_cell_tree_controls(self): fixture = FIXTURE / 'tree' models = {}