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 = {}