diff --git a/corpus/cross-run-edit/README.md b/corpus/cross-run-edit/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9416ce06cbe418ecd8feba1b81b25e9a34ecd14b
--- /dev/null
+++ b/corpus/cross-run-edit/README.md
@@ -0,0 +1,20 @@
+# Cross-run text replacements
+
+Six edits of the public formatted-insertion notebooks, independently opened by
+OneNote 2010 in disposable cold-cache machines. Cases cover partial and complete
+replacement, a native table cell, deletion, typing after deletion, and insertion
+at a style boundary. Each directory is a separate notebook because the source
+section identities can be shared.
+
+The inserted characters inherit the style at the start of the replaced range;
+surviving characters retain their styles. Empty final runs retain their typing
+style. These are library edit semantics; the native captures check that OneNote
+reads the resulting content and formatting correctly.
+
+Generate candidates with the ignored `export_native_cross_run_edits` test and
+`ONESTORE_CROSS_RUN_OUTPUT` set to a new absolute directory. Capture each candidate
+with `tools/native_runner.py --expected-pages 1 --collect-notebook` and compare
+with `tools/verify-document.py`. The manifest records source paths, target IDs,
+UTF-16 ranges and file hashes. The attachment export links to its canonical copy.
+`tools/test_cross_run_edit.py` checks the retained
+native oracles without starting a VM.
diff --git a/corpus/cross-run-edit/boundary/candidate/synthetic.one b/corpus/cross-run-edit/boundary/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..691ee25017fc105684e7387918db68cd7d431f66
Binary files /dev/null and b/corpus/cross-run-edit/boundary/candidate/synthetic.one differ
diff --git a/corpus/cross-run-edit/boundary/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-run-edit/boundary/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..8160c30c74cfcf14ea7d5a9a2b6395bff217e273
Binary files /dev/null and b/corpus/cross-run-edit/boundary/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-run-edit/boundary/cold/notebook/synthetic.one b/corpus/cross-run-edit/boundary/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..bbb29bac7ace6131df7fe74288f5ffab3c275ad0
Binary files /dev/null and b/corpus/cross-run-edit/boundary/cold/notebook/synthetic.one differ
diff --git a/corpus/cross-run-edit/boundary/cold/read/environment.json b/corpus/cross-run-edit/boundary/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..3ff1bc20157db4ec67f5b76af3edec3554203a25
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-107F0FF0",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-run-edit/boundary/cold/read/hierarchy.xml b/corpus/cross-run-edit/boundary/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..65d080101c9258e84a91f615e531e812b04fc4ce
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-run-edit/boundary/cold/read/page-000.xml b/corpus/cross-run-edit/boundary/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..bb42447556229173f03bedfd67385398e8845ea4
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/read/page-000.xml
@@ -0,0 +1,12 @@
+
+BoldMiddleitalic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/cross-run-edit/boundary/cold/read/payloads.json b/corpus/cross-run-edit/boundary/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-run-edit/boundary/cold/run.json b/corpus/cross-run-edit/boundary/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..239ffeebea458eaa341e83c9bab1788516432c4c
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/cross-run-native/boundary/candidate",
+ "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/cross-run-edit/boundary/cold/source.json b/corpus/cross-run-edit/boundary/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..506c76e818d92557ac2876ed4ec5b68e94bd9179
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 20840,
+ "sha256": "a3fac402a10648e99d4bdb4b37b41c825e2b8b34f12d8b1c234e28bd2ec8ac0b",
+ "mtime_ns": 1788850928468116429
+ }
+]
diff --git a/corpus/cross-run-edit/boundary/cold/teardown.json b/corpus/cross-run-edit/boundary/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-run-edit/boundary/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-run-edit/clear/candidate/synthetic.one b/corpus/cross-run-edit/clear/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..d5cf382755381ecda3103a0e2c9c30da5ee9aa5b
Binary files /dev/null and b/corpus/cross-run-edit/clear/candidate/synthetic.one differ
diff --git a/corpus/cross-run-edit/clear/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-run-edit/clear/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..061156a1d91b811381ba8e51e42b188d3354ae35
Binary files /dev/null and b/corpus/cross-run-edit/clear/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-run-edit/clear/cold/notebook/synthetic.one b/corpus/cross-run-edit/clear/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..c72e7c04673822792ed74f00f02f677affeeea34
Binary files /dev/null and b/corpus/cross-run-edit/clear/cold/notebook/synthetic.one differ
diff --git a/corpus/cross-run-edit/clear/cold/read/environment.json b/corpus/cross-run-edit/clear/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..ea3d73a65d3460e97244fb197908f3f4328ba3c5
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-8787CA35",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-run-edit/clear/cold/read/hierarchy.xml b/corpus/cross-run-edit/clear/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5e241ed7e4d574a67084499fb2ba7678f64f9fd5
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-run-edit/clear/cold/read/page-000.xml b/corpus/cross-run-edit/clear/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8ed89f3b47df6d65f933485f0391b80386d21b66
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/read/page-000.xml
@@ -0,0 +1,7 @@
+
+Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/cross-run-edit/clear/cold/read/payloads.json b/corpus/cross-run-edit/clear/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-run-edit/clear/cold/run.json b/corpus/cross-run-edit/clear/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..c8cc9eeff1030cfbe54016e5992f9e22e2cfc83b
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/cross-run-native/clear/candidate",
+ "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/cross-run-edit/clear/cold/source.json b/corpus/cross-run-edit/clear/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..36480de8ddeff6fffa27089867389edda10a8aa3
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 21736,
+ "sha256": "d6d8c775b4b87e3b8379757773c313a0a720da52dd5e76b4799f2b8e16bb5320",
+ "mtime_ns": 1788850928454071185
+ }
+]
diff --git a/corpus/cross-run-edit/clear/cold/teardown.json b/corpus/cross-run-edit/clear/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-run-edit/clear/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-run-edit/manifest.json b/corpus/cross-run-edit/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed9823f91ae5c34507bd55d7581dd8c70e4e7c6f
--- /dev/null
+++ b/corpus/cross-run-edit/manifest.json
@@ -0,0 +1,89 @@
+{
+ "generator": "crates/onestore/tests/edit.rs:export_native_cross_run_edits",
+ "cases": [
+ {
+ "name": "partial",
+ "object": "{04B13EBF-F611-4402-829D-C72E23221C8B},2",
+ "range": {
+ "end": 16,
+ "start": 2
+ },
+ "replacement": "\u4e2d\ud83e\udd80",
+ "retype": false,
+ "source": "formatted-insertion/native-paragraph/candidate/synthetic.one",
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "candidate_sha256": "0a93fdb8c8378989a6522b5de803f073109b8972088e61afdf97639eeaea6a02",
+ "native_sha256": "85e90850f7f0582cd9e4e8f93bb8f77cd773d883cd993b24280b57bd70078a4f"
+ },
+ {
+ "name": "table",
+ "object": "{6928B2F5-7D36-44AF-BE3B-36CEBACE8657},2",
+ "range": {
+ "end": 24,
+ "start": 0
+ },
+ "replacement": "Across \ud83d\udc08 ",
+ "retype": false,
+ "source": "formatted-insertion/native-cell/candidate/synthetic.one",
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "candidate_sha256": "89ce5a50f050c05ecbfb92c8c8eb0538bd041d0596290a5b974a9878e171e6e9",
+ "native_sha256": "561222f0ecb1ccc17592c9fd44f08f6f4e0b0bfc6120f3b099a902f46c3e8ed2"
+ },
+ {
+ "name": "clear",
+ "object": "{04B13EBF-F611-4402-829D-C72E23221C8B},2",
+ "range": {
+ "end": 30,
+ "start": 0
+ },
+ "replacement": "",
+ "retype": false,
+ "source": "formatted-insertion/native-paragraph/candidate/synthetic.one",
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "candidate_sha256": "d6d8c775b4b87e3b8379757773c313a0a720da52dd5e76b4799f2b8e16bb5320",
+ "native_sha256": "30dfd26ad6912225f3e637f529f5135f67f45de0c1a0b705ad70bf97866a554e"
+ },
+ {
+ "name": "replace",
+ "object": "{04B13EBF-F611-4402-829D-C72E23221C8B},2",
+ "range": {
+ "end": 30,
+ "start": 0
+ },
+ "replacement": "Replacement e\u0301\ud83e\udd80",
+ "retype": false,
+ "source": "formatted-insertion/native-paragraph/candidate/synthetic.one",
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "candidate_sha256": "4b0ae05431ade4777541592cd8040f7ff18dbfe4ada35a686cda963299988e81",
+ "native_sha256": "f0cca32ec56e15b7d19aa581dbe7ae3d59bd0cc498450a0576ae9d168365c377"
+ },
+ {
+ "name": "retype",
+ "object": "{04B13EBF-F611-4402-829D-C72E23221C8B},2",
+ "range": {
+ "end": 30,
+ "start": 0
+ },
+ "replacement": "",
+ "retype": true,
+ "source": "formatted-insertion/native-paragraph/candidate/synthetic.one",
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "candidate_sha256": "0d37a5a762e9b08f40ea47b2fad48591fcb5f782d1a130f5363f5116b1175fb3",
+ "native_sha256": "7b70f0ebe27f09c31a623f27b37ffac4362f9513dd26b2d1a69d1e2c0a38c15a"
+ },
+ {
+ "name": "boundary",
+ "object": "{04B13EBF-F611-4402-829D-C72E23221C8B},2",
+ "range": {
+ "end": 8,
+ "start": 4
+ },
+ "replacement": "Middle",
+ "retype": false,
+ "source": "formatted-insertion/native-paragraph/candidate/synthetic.one",
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "candidate_sha256": "a3fac402a10648e99d4bdb4b37b41c825e2b8b34f12d8b1c234e28bd2ec8ac0b",
+ "native_sha256": "e9852bed7545600f4fec537be8e614a1cdb4ec8d7ee40c7933a609bd51a7ce8b"
+ }
+ ]
+}
diff --git a/corpus/cross-run-edit/partial/candidate/synthetic.one b/corpus/cross-run-edit/partial/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..634ae10b5cd74fa0f7399f588d6a1b3a93224abe
Binary files /dev/null and b/corpus/cross-run-edit/partial/candidate/synthetic.one differ
diff --git a/corpus/cross-run-edit/partial/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-run-edit/partial/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..9005a0116a8b1baa8daccb2f7fb2e66a71a5d80c
Binary files /dev/null and b/corpus/cross-run-edit/partial/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-run-edit/partial/cold/notebook/synthetic.one b/corpus/cross-run-edit/partial/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..be0efbd30193e6b07d33f2f909ab59d127af1dd6
Binary files /dev/null and b/corpus/cross-run-edit/partial/cold/notebook/synthetic.one differ
diff --git a/corpus/cross-run-edit/partial/cold/read/environment.json b/corpus/cross-run-edit/partial/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..beaa6e49ac80c2290654acff6de5347d4e37da95
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-A348FDB7",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-run-edit/partial/cold/read/hierarchy.xml b/corpus/cross-run-edit/partial/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cdc37aa7da063380d6a1acc243ca2fdd19d2c09d
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-run-edit/partial/cold/read/page-000.xml b/corpus/cross-run-edit/partial/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0ebb7adfd9558b764ad4286e856f0dc975681063
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/read/page-000.xml
@@ -0,0 +1,11 @@
+
+Bo中🦀́ color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/cross-run-edit/partial/cold/read/payloads.json b/corpus/cross-run-edit/partial/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-run-edit/partial/cold/run.json b/corpus/cross-run-edit/partial/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..89a36f65838e777e1877117e08ace2d77625b9e7
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/cross-run-native/partial/candidate",
+ "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/cross-run-edit/partial/cold/source.json b/corpus/cross-run-edit/partial/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..a0bfe3927b4f56ee31934bbc3a88f33d30378b8a
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 20944,
+ "sha256": "0a93fdb8c8378989a6522b5de803f073109b8972088e61afdf97639eeaea6a02",
+ "mtime_ns": 1788850928443537242
+ }
+]
diff --git a/corpus/cross-run-edit/partial/cold/teardown.json b/corpus/cross-run-edit/partial/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-run-edit/partial/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-run-edit/replace/candidate/synthetic.one b/corpus/cross-run-edit/replace/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..7e69f1c7b7f287cd7252c42b39e5a37f727725d1
Binary files /dev/null and b/corpus/cross-run-edit/replace/candidate/synthetic.one differ
diff --git a/corpus/cross-run-edit/replace/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-run-edit/replace/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5dfb21929e5e62ff43a56a406938e2a5218a3f3f
Binary files /dev/null and b/corpus/cross-run-edit/replace/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-run-edit/replace/cold/notebook/synthetic.one b/corpus/cross-run-edit/replace/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..e5feb87ed555ad3e1013636bef200930289be1cc
Binary files /dev/null and b/corpus/cross-run-edit/replace/cold/notebook/synthetic.one differ
diff --git a/corpus/cross-run-edit/replace/cold/read/environment.json b/corpus/cross-run-edit/replace/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..3212fdcfd750ec6485bd090dd0bf63c7f10f5937
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-105B87CC",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-run-edit/replace/cold/read/hierarchy.xml b/corpus/cross-run-edit/replace/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6ef307ff7814b9cdfe9f105ddb92e66e50a9e207
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-run-edit/replace/cold/read/page-000.xml b/corpus/cross-run-edit/replace/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f10dedcdb2b7f695de065e52fd0bf6d6462c313e
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/read/page-000.xml
@@ -0,0 +1,8 @@
+
+Replacement é🦀]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/cross-run-edit/replace/cold/read/payloads.json b/corpus/cross-run-edit/replace/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-run-edit/replace/cold/run.json b/corpus/cross-run-edit/replace/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..a421791cbb1827517874dbde77bcf45634e2fc77
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/cross-run-native/replace/candidate",
+ "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/cross-run-edit/replace/cold/source.json b/corpus/cross-run-edit/replace/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..23bf8c03feb0025d2fa10ba70220792b69fc3329
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 21520,
+ "sha256": "4b0ae05431ade4777541592cd8040f7ff18dbfe4ada35a686cda963299988e81",
+ "mtime_ns": 1788850928458083445
+ }
+]
diff --git a/corpus/cross-run-edit/replace/cold/teardown.json b/corpus/cross-run-edit/replace/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-run-edit/replace/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-run-edit/retype/candidate/synthetic.one b/corpus/cross-run-edit/retype/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..a7609c4d20b05283165885d231e8f6227ecb6c1e
Binary files /dev/null and b/corpus/cross-run-edit/retype/candidate/synthetic.one differ
diff --git a/corpus/cross-run-edit/retype/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-run-edit/retype/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..d0b971145acfc6a6db35f1c9dee64cf234a595a4
Binary files /dev/null and b/corpus/cross-run-edit/retype/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-run-edit/retype/cold/notebook/synthetic.one b/corpus/cross-run-edit/retype/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..a129ad0badf711baaa5cc994d50bb0ba548da80d
Binary files /dev/null and b/corpus/cross-run-edit/retype/cold/notebook/synthetic.one differ
diff --git a/corpus/cross-run-edit/retype/cold/read/environment.json b/corpus/cross-run-edit/retype/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..ef9d31dc2d371df8cecde82f35d9bfe5e22d0cba
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-71FBAE77",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-run-edit/retype/cold/read/hierarchy.xml b/corpus/cross-run-edit/retype/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c7886e9da3bc4f99de8cad43afd6884bd9b9ca8d
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-run-edit/retype/cold/read/page-000.xml b/corpus/cross-run-edit/retype/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6da713b37f3e48d05bac1420d982c374726fbfe6
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/read/page-000.xml
@@ -0,0 +1,7 @@
+
+Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/cross-run-edit/retype/cold/read/payloads.json b/corpus/cross-run-edit/retype/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-run-edit/retype/cold/run.json b/corpus/cross-run-edit/retype/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..0228b61c79988c46951bd8896478e47234b8fd31
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/cross-run-native/retype/candidate",
+ "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/cross-run-edit/retype/cold/source.json b/corpus/cross-run-edit/retype/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..385dde5d64479ba4d46aed9eca7d995d50aa64ce
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 22832,
+ "sha256": "0d37a5a762e9b08f40ea47b2fad48591fcb5f782d1a130f5363f5116b1175fb3",
+ "mtime_ns": 1788850928464340419
+ }
+]
diff --git a/corpus/cross-run-edit/retype/cold/teardown.json b/corpus/cross-run-edit/retype/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-run-edit/retype/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-run-edit/table/candidate/synthetic.one b/corpus/cross-run-edit/table/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..eb64f3a47ae33ebdfe1a12bea13b8972e2e3c863
Binary files /dev/null and b/corpus/cross-run-edit/table/candidate/synthetic.one differ
diff --git a/corpus/cross-run-edit/table/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-run-edit/table/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..b834c5577784a50913c37ff1a6b39a834828057c
Binary files /dev/null and b/corpus/cross-run-edit/table/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-run-edit/table/cold/notebook/synthetic.one b/corpus/cross-run-edit/table/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..4b3593c4aaaa51de2427c32348f225bd7240641f
Binary files /dev/null and b/corpus/cross-run-edit/table/cold/notebook/synthetic.one differ
diff --git a/corpus/cross-run-edit/table/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/cross-run-edit/table/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment
new file mode 120000
index 0000000000000000000000000000000000000000..643deb5931b7cff2017653974a315859cebaff6a
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment
@@ -0,0 +1 @@
+../../../../append/round-01/native/append-01-complex/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment
\ No newline at end of file
diff --git a/corpus/cross-run-edit/table/cold/read/environment.json b/corpus/cross-run-edit/table/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..d92f329af7c9adcd1e9d272afd92a6dd63e7dcd0
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-46174C24",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-run-edit/table/cold/read/hierarchy.xml b/corpus/cross-run-edit/table/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..abcc819a1becb97f17b7512125a32e636b2c4725
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-run-edit/table/cold/read/page-000.xml b/corpus/cross-run-edit/table/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3c00e0c6e019ff620d2285cc587529403b93940d
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/read/page-000.xml
@@ -0,0 +1,11 @@
+
+Fictitious: café, 東京, مرحبا]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA
+AAAASUVORK5CYII=Across 🐈 東京
+End]]>
diff --git a/corpus/cross-run-edit/table/cold/read/payloads.json b/corpus/cross-run-edit/table/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..4ca75b134c6ab5f611d249014f04264be3699cf2
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7",
+ "name": "fictitious-attachment.txt",
+ "object": "{D063C31D-7E07-4993-9344-99F205C533AF}{11}{B0}",
+ "kind": "InsertedFile",
+ "page": "{592CC75B-A6C5-4A10-95DD-5BAB86E9B53A}{1}{B0}",
+ "bytes": 43
+ }
+]
\ No newline at end of file
diff --git a/corpus/cross-run-edit/table/cold/run.json b/corpus/cross-run-edit/table/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..1936f13f905e4710b3cdcebfa70e63d9623473ba
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/cross-run-native/table/candidate",
+ "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/cross-run-edit/table/cold/source.json b/corpus/cross-run-edit/table/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..579110b25025df76061d9cd19ba14d001327467c
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 30864,
+ "sha256": "89ce5a50f050c05ecbfb92c8c8eb0538bd041d0596290a5b974a9878e171e6e9",
+ "mtime_ns": 1788850928450254092
+ }
+]
diff --git a/corpus/cross-run-edit/table/cold/teardown.json b/corpus/cross-run-edit/table/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-run-edit/table/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/onestore-offline/examples/smb_offline_client.rs b/crates/onestore-offline/examples/smb_offline_client.rs
index 4cea5e7d44f8dd4ffd85d0db8a51f20d8bc4832d..3b80ea77392b8b240e16e4b7bfd0fc920062349f 100644
--- a/crates/onestore-offline/examples/smb_offline_client.rs
+++ b/crates/onestore-offline/examples/smb_offline_client.rs
@@ -23,6 +23,8 @@ mod support {
use concurrent::document_view;
use support::view::view;
+const DOCUMENT_OPERATIONS: [&str; 3] = ["insert", "format", "text"];
+
fn now() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -275,7 +277,7 @@ fn queue_document(
operation: usize,
parent: Option,
deadline: Instant,
-) -> Result<(ExGuid, [u64; 2]), Box> {
+) -> Result<(ExGuid, [u64; DOCUMENT_OPERATIONS.len()]), Box> {
let source = cache.snapshot()?;
let store = Store::parse(&source)?;
let index = RevisionIndex::parse(&store)?;
@@ -320,8 +322,15 @@ fn queue_document(
TextAttribute::FontSize(18.0 + (operation % 9) as f32),
TextAttribute::Color(Some([0x12, 0x34, 0x56])),
];
- let mut ids = [0; 2];
+ let mut ids = [0; DOCUMENT_OPERATIONS.len()];
for (step, id) in ids.iter_mut().enumerate() {
+ let kind = DOCUMENT_OPERATIONS[step];
+ let range = if step == 2 {
+ let end = u32::try_from(text.encode_utf16().count())?;
+ end - 3..end
+ } else {
+ range.clone()
+ };
loop {
if Instant::now() >= deadline {
return Err("Document queue timed out; cache retained".into());
@@ -330,7 +339,7 @@ fn queue_document(
let started = now();
let result = if step == 0 {
cache.insert(&source, space, &insertion)
- } else {
+ } else if step == 1 {
cache.format(
&source,
space,
@@ -338,13 +347,21 @@ fn queue_document(
range.clone(),
&attributes,
)
+ } else {
+ cache.edit_text(
+ &source,
+ space,
+ insertion.text_object(),
+ range.clone(),
+ " e\u{301}🐈",
+ )
};
match result {
Ok(Some(acknowledged)) => {
*id = acknowledged;
println!(
"{}",
- json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":if step==0 {"insert"} else {"format"},"space":space.to_string(),"object":insertion.text_object().to_string(),"text":text,"insertion":if step==0 {Some(&insertion)} else {None},"range":[range.start,range.end],"attributes":attributes,"started_us":started,"finished_us":now()})
+ json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":kind,"space":space.to_string(),"object":insertion.text_object().to_string(),"text":text,"insertion":if step==0 {Some(&insertion)} else {None},"range":[range.start,range.end],"attributes":attributes,"replacement":if step==2 {Some(" e\u{301}🐈")} else {None},"started_us":started,"finished_us":now()})
);
break;
}
@@ -400,7 +417,7 @@ fn main() -> Result<(), Box> {
let cache = Arc::new(Replica::create(&cache_path, &source)?);
println!(
"{}",
- json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents})
+ json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}})
);
while !Path::new(&args[4]).exists() {
if Instant::now() >= deadline {
@@ -470,7 +487,7 @@ fn main() -> Result<(), Box> {
{
1
} else if documents && outage.is_some() {
- 24
+ 8 * (1 + DOCUMENT_OPERATIONS.len())
} else {
8
};
diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs
index a21ba1afca74f36ba8932b5c338e82f725192808..31c7c9505ecf1fbc406f82fe2ac3df17ef9c0407 100644
--- a/crates/onestore-offline/src/lib.rs
+++ b/crates/onestore-offline/src/lib.rs
@@ -180,6 +180,7 @@ impl Replica {
/// Atomically records an intent and its resulting local image; returns its durable ID.
/// Unchanged text returns `None`. A stale image returns `Io(ResourceBusy)`.
+ /// On synchronization, replacement text inherits the remote style at the rebased start.
/// After a database error, reopen and inspect the cache before retrying the edit.
pub fn edit_text(
&self,
diff --git a/crates/onestore-offline/tests/sync.rs b/crates/onestore-offline/tests/sync.rs
index 8cc2aada4ffffdfa93bcb5e201d3e640ef9579eb..52d1f6b8a2115edf0fcc2d43fc41408c7c0ff349 100644
--- a/crates/onestore-offline/tests/sync.rs
+++ b/crates/onestore-offline/tests/sync.rs
@@ -1888,6 +1888,82 @@ fn uncertain_insertions_reconcile_the_original_revision_without_duplicate_object
}
}
+#[test]
+fn cross_run_text_rebases_preserve_remote_styles_and_survive_lost_replies() {
+ use onestore::TextAttribute as A;
+ for fault in [Fault::None, Fault::UnknownAfter, Fault::PanicAfter] {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("cross-run.sqlite");
+ let plain = onestore::create_section("cross.one", "ab🦀cde\u{301}fg", "Author").unwrap();
+ let (sid, oid, _) = text(&plain);
+ let bold = PreparedEdit::format(&plain, sid, oid, 1..4, &[A::Bold(true)]).unwrap();
+ let source = PreparedEdit::format(bold.as_bytes(), sid, oid, 4..7, &[A::Italic(true)])
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let cache = Replica::create(&path, &source).unwrap();
+ let first = cache
+ .edit_text(&source, sid, oid, 2..7, "日本語")
+ .unwrap()
+ .unwrap();
+ let local = cache.snapshot().unwrap();
+ let second = cache
+ .edit_text(&local, sid, oid, 3..4, "🐈")
+ .unwrap()
+ .unwrap();
+ let pending = cache.pending().unwrap();
+ drop(cache);
+ let cache = Replica::open(&path).unwrap();
+ assert_eq!(cache.pending().unwrap(), pending);
+ let prefix = PreparedEdit::text(&source, sid, oid, 0..0, "Prefix ").unwrap();
+ let remote =
+ PreparedEdit::format(prefix.as_bytes(), sid, oid, 9..11, &[A::Underline(true)])
+ .unwrap();
+ let mut server = Server::new(remote.as_bytes());
+ server.fault = fault;
+ let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ cache.sync_once(&mut server)
+ }));
+ if matches!(fault, Fault::None) {
+ assert!(
+ matches!(attempted.unwrap().unwrap(), Some((id, EditStatus::Published { .. })) if id == first)
+ );
+ } else {
+ assert!(matches!(
+ cache.status(first).unwrap(),
+ Some(EditStatus::AwaitingConfirmation { .. })
+ ));
+ }
+ drop(cache);
+ let cache = Replica::open(&path).unwrap();
+ while let Some((_, status)) = cache.sync_once(&mut server).unwrap() {
+ assert!(matches!(status, EditStatus::Published { .. }));
+ }
+ assert_eq!(server.publications, 2);
+ assert!(matches!(
+ cache.status(second).unwrap(),
+ Some(EditStatus::Published { .. })
+ ));
+ assert_eq!(text(&server.durable).2, "Prefix ab日🐈語\u{301}fg");
+ assert_eq!(cache.snapshot().unwrap(), server.durable);
+ let store = Store::parse(&server.durable).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let space = &document.spaces[&sid];
+ let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let runs = revision.text_runs(oid).unwrap();
+ let replacement = runs.iter().find(|run| run.text == "日🐈語").unwrap();
+ assert_eq!(replacement.format.bold, Some(true));
+ assert_eq!(replacement.format.underline, Some(true));
+ assert_ne!(replacement.format.italic, Some(true));
+ let suffix = runs.last().unwrap();
+ assert_eq!(suffix.text, "\u{301}fg");
+ assert_ne!(suffix.format.bold, Some(true));
+ assert_ne!(suffix.format.underline, Some(true));
+ assert_ne!(suffix.format.italic, Some(true));
+ }
+}
+
#[test]
fn offline_formatting_rebases_text_and_merges_independent_attributes() {
use onestore::TextAttribute as A;
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index 77cf64f3c7bd8e0d0e9545af30ad7fd043f03a7b..2e63064f53df82ca64ab15fb2a70604d78bd5ce6 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -47,7 +47,7 @@ harness also accepts `--client-profile release`.
| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
-| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range within one ordinary text run; publish text, run boundaries and modification time together |
+| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |
| `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases |
| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs
index a33f06a263e69dfd777f497a16ccf53e0a2821ad..9cf5af2efde70468e991104a7f5e2c34b3d4d18f 100644
--- a/crates/onestore/src/edit.rs
+++ b/crates/onestore/src/edit.rs
@@ -18,9 +18,11 @@ pub(crate) fn automatic_title(text: &str) -> &str {
line
}
-/// Replaces UTF-16 character positions within one ordinary text run.
-/// The inserted text inherits that run's formatting; other runs retain their identities.
+/// Replaces UTF-16 character positions across ordinary text runs.
+/// Inserted text inherits the run at the start; surviving text retains its formatting.
/// Insertion at a run boundary uses the following run, except at the end of text.
+/// The final run retains its insertion style even when emptied.
+/// Replacing a range with identical text leaves its existing formatting unchanged.
/// Returns a complete file image without I/O; use `commit_file_text` to update an existing file.
pub fn replace_text(
source: &[u8],
@@ -79,22 +81,25 @@ pub fn replace_text(
}
let selected = runs
.iter()
- .rposition(|run| run.start <= range.start && range.end <= run.end)
- .ok_or_else(|| invalid("The edit must stay within one text run"))?;
+ .rposition(|run| run.start <= range.start && range.start <= run.end)
+ .ok_or_else(|| invalid("The edit range exceeds the text"))?;
let resolved = revision.text_runs(object)?;
- let format = &resolved[selected].format;
- if [
- format.hidden,
- format.hyperlink,
- format.math,
- format.embedded_object,
- ]
- .contains(&Some(true))
- || runs[selected]
- .extra_set
- .is_some_and(|set| !node.extra[set].is_empty())
- {
- return Err(invalid("This text run contains a field or embedded data"));
+ for (i, run) in runs.iter().enumerate() {
+ if i == selected || (run.start < range.end && range.start < run.end) {
+ let format = &resolved[i].format;
+ if [
+ format.hidden,
+ format.hyperlink,
+ format.math,
+ format.embedded_object,
+ ]
+ .contains(&Some(true))
+ || resolved[i].text.contains(['\u{fffc}', '\u{fddf}'])
+ || run.extra_set.is_some_and(|set| !node.extra[set].is_empty())
+ {
+ return Err(invalid("This text run contains a field or embedded data"));
+ }
+ }
}
let ObjectData::Properties(blob) = raw.objects[&object].data else {
unreachable!()
@@ -141,20 +146,47 @@ pub fn replace_text(
.checked_sub(removed)
.and_then(|n| n.checked_add(added))
.ok_or_else(|| invalid("Edited text exceeds the UTF-16 offset range"))?;
- let mut boundaries = Vec::new();
- let mut previous = None;
- for (i, run) in runs[..runs.len() - 1].iter().enumerate() {
- let end = if i >= selected {
- run.end - removed + added
- } else {
- run.end
- };
- if previous.is_some_and(|p| p >= end) {
- return Err(invalid("The edit would collapse a formatting boundary"));
+ let mut segments = Vec::new();
+ let mut position = 0;
+ for (i, run) in runs.iter().enumerate() {
+ let length = run.end.min(range.start).saturating_sub(run.start)
+ + run.end.saturating_sub(run.start.max(range.end))
+ + if i == selected { added } else { 0 };
+ let untouched = i != selected && (run.end <= range.start || range.end <= run.start);
+ if length > 0 || i == runs.len() - 1 || untouched {
+ position += length;
+ segments.push((i, position));
}
- previous = Some(end);
- boundaries.extend_from_slice(&end.to_le_bytes());
}
+ if segments.len() != runs.len() && properties.sets[0].iter().any(|p| p.id == 0x40003499) {
+ return Err(invalid("Text edits cannot remove preserved run data"));
+ }
+ if segments[..segments.len() - 1]
+ .windows(2)
+ .any(|pair| pair[0].1 >= pair[1].1)
+ {
+ return Err(invalid("Text-run boundaries must be strictly increasing"));
+ }
+ let boundaries: Vec<_> = segments[..segments.len() - 1]
+ .iter()
+ .flat_map(|(_, end)| end.to_le_bytes())
+ .collect();
+ let formats = properties.sets[0]
+ .iter()
+ .find(|p| p.id == 0x24001e13)
+ .map(|p| {
+ let crate::Value::References { compact_ids, .. } = p.value else {
+ unreachable!()
+ };
+ if compact_ids.is_empty() {
+ Vec::new()
+ } else {
+ segments
+ .iter()
+ .flat_map(|(i, _)| compact_ids[i * 4..i * 4 + 4].iter().copied())
+ .collect()
+ }
+ });
let mut changed = String::with_capacity(text.len() - (end - start) + replacement.len());
changed.push_str(&text[..start]);
changed.push_str(replacement);
@@ -183,6 +215,9 @@ pub fn replace_text(
if properties.sets[0].iter().any(|p| p.id == 0x1c001e12) {
updates.push((0x1c001e12, &boundaries));
}
+ if let Some(formats) = &formats {
+ updates.push((0x24001e13, formats));
+ }
let modified = crate::create::current_timestamps()?.0.to_le_bytes();
updates.push((0x14001d7a, &modified));
let mut edits = vec![crate::write::ObjectEdit {
diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs
index f10f53299d01dbb391d47cf15898ec1db91ad6d6..845064f1c0da2afbadca12938a8496554f36eb78 100644
--- a/crates/onestore/src/write/tests.rs
+++ b/crates/onestore/src/write/tests.rs
@@ -589,7 +589,7 @@ fn character_formatting_preserves_inheritance_and_associated_data() {
.find_map(|(id, o)| (o.jcid == 0x6000e).then_some((*sid, *id)))
})
.unwrap();
- for variant in 0..7 {
+ for variant in 0..10 {
let fixture = write_revision(&source, sid, |raw| {
let mut target = PropertyObject::from_object(&raw.objects[&text])?;
target.bytes = properties(&[
@@ -617,8 +617,17 @@ fn character_formatting_preserves_inheritance_and_associated_data() {
let reference = target.reference(author)?;
target.set(&[(0x24003458, &reference)])?;
}
- 3 => {
- // An unknown nested run property must survive a style-only edit byte for byte.
+ 3 | 7 | 8 | 9 => {
+ if variant != 3 {
+ let ends = if variant == 9 { [2_u32, 2] } else { [2, 4] };
+ target.set(&[(
+ 0x1c001e12,
+ &ends
+ .into_iter()
+ .flat_map(u32::to_le_bytes)
+ .collect::>(),
+ )])?;
+ }
let parsed = PropertySets::parse(&target.bytes)?;
let at = parsed.root_ids.as_ptr().addr() - target.bytes.as_ptr().addr();
let count = parsed.sets[0].len();
@@ -626,11 +635,17 @@ fn character_formatting_preserves_inheritance_and_associated_data() {
let mut bytes = target.bytes[..end].to_vec();
bytes[at - 2..at].copy_from_slice(&((count + 1) as u16).to_le_bytes());
bytes.splice(at + count * 4..at + count * 4, 0x40003499_u32.to_le_bytes());
- bytes.extend_from_slice(&1_u32.to_le_bytes());
+ let runs: u32 = if variant == 3 { 1 } else { 3 };
+ bytes.extend_from_slice(&runs.to_le_bytes());
bytes.extend_from_slice(&0x44001234_u32.to_le_bytes());
- bytes.extend_from_slice(&1_u16.to_le_bytes());
- bytes.extend_from_slice(&0x14001234_u32.to_le_bytes());
- bytes.extend_from_slice(&0xdeadbeef_u32.to_le_bytes());
+ for run in 0..runs {
+ let populated = (variant == 3 || variant == 8) && run == runs - 1;
+ bytes.extend_from_slice(&u16::from(populated).to_le_bytes());
+ if populated {
+ bytes.extend_from_slice(&0x14001234_u32.to_le_bytes());
+ bytes.extend_from_slice(&0xdeadbeef_u32.to_le_bytes());
+ }
+ }
bytes.resize(bytes.len().next_multiple_of(8), 0);
target.bytes = bytes;
}
@@ -639,6 +654,34 @@ fn character_formatting_preserves_inheritance_and_associated_data() {
Ok(BTreeMap::from([(text, target)]))
})
.unwrap();
+ if variant == 9 {
+ assert!(PreparedEdit::text(&fixture, sid, text, 0..0, "x").is_err());
+ continue;
+ }
+ if variant >= 7 {
+ let edit = PreparedEdit::text(&fixture, sid, text, 1..3, "longer").unwrap();
+ let [before, after] = [&fixture, edit.as_bytes()].map(|bytes| {
+ let store = Store::parse(bytes).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let Kind::RichText { runs, .. } = &view.nodes[&text].kind else {
+ panic!()
+ };
+ assert_eq!(runs.len(), 3);
+ format!("{:?}", view.nodes[&text].extra)
+ });
+ assert_eq!(before, after);
+ assert!(PreparedEdit::text(&fixture, sid, text, 0..4, "x").is_err());
+ assert_eq!(
+ PreparedEdit::text(&fixture, sid, text, 3..5, "x").is_ok(),
+ variant == 7
+ );
+ continue;
+ }
+ let text_edit = PreparedEdit::text(&fixture, sid, text, 1..5, "x");
+ assert_eq!(text_edit.is_ok(), variant == 0);
let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]);
if matches!(variant, 1 | 2 | 4 | 5 | 6) {
assert!(edit.is_err());
diff --git a/crates/onestore/tests/edit.rs b/crates/onestore/tests/edit.rs
index 67294f2abd0d8d34c6335d825f9b5151ecd8af13..f810dc59e22494f04049aff333beab50353dbd0f 100644
--- a/crates/onestore/tests/edit.rs
+++ b/crates/onestore/tests/edit.rs
@@ -368,9 +368,10 @@ fn invalid_text_edits_never_touch_storage() {
for (range, replacement) in [
(1..1, "x"),
(0..999, "x"),
- (0..21, "x"),
(0..0, "\n"),
(0..0, "\0"),
+ (0..0, "\u{fffc}"),
+ (0..0, "\u{fddf}"),
] {
let mut disk = Disk {
visible: source.clone(),
@@ -388,6 +389,179 @@ fn invalid_text_edits_never_touch_storage() {
}
}
+#[test]
+fn cross_run_edits_reject_native_fields_before_storage_io() {
+ let source = include_bytes!("../../../corpus/m6/native-probes-01/notebook/synthetic.one");
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let mut fields = 0;
+ for (sid, space) in &document.spaces {
+ let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
+ for (oid, node) in &revision.nodes {
+ let Kind::RichText { text, runs, .. } = &node.kind else {
+ continue;
+ };
+ let resolved = revision.text_runs(*oid).unwrap();
+ for (run, resolved) in runs.iter().zip(resolved) {
+ if resolved.format.hyperlink != Some(true) {
+ continue;
+ }
+ fields += 1;
+ for range in [0..run.end, run.start..text.encode_utf16().count() as u32] {
+ let mut disk = Disk {
+ visible: source.to_vec(),
+ durable: source.to_vec(),
+ operation: 0,
+ fail_at: None,
+ write_limit: 17,
+ random: 9,
+ };
+ let error = onestore::commit_text(&mut disk, source, *sid, *oid, range, "x")
+ .unwrap_err();
+ assert_eq!(error.state, CommitState::NotCommitted);
+ assert_eq!(disk.operation, 0);
+ assert_eq!(disk.durable, source);
+ }
+ }
+ }
+ }
+ assert!(fields > 0);
+}
+
+#[test]
+fn cross_run_splices_match_a_character_model_and_preserve_history() {
+ let (sid, oid) = target(SOURCE);
+ let characters = |source: &[u8]| {
+ let runs = text_runs(source, sid, oid);
+ let runs = runs.as_array().unwrap();
+ let mut result = Vec::new();
+ for run in runs {
+ result.extend(
+ run["text"]
+ .as_str()
+ .unwrap()
+ .chars()
+ .map(|c| (c, run["format"].clone())),
+ );
+ }
+ result.push(('\0', runs.last().unwrap()["format"].clone()));
+ result
+ };
+ let before = characters(SOURCE);
+ let offsets: Vec<_> = std::iter::once(0)
+ .chain(before[..before.len() - 1].iter().scan(0, |at, (c, _)| {
+ *at += c.len_utf16() as u32;
+ Some(*at)
+ }))
+ .collect();
+ let store = Store::parse(SOURCE).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ for a in 0..before.len() {
+ for b in a..before.len() {
+ let replacement = ["", "🦀e\u{301}", "日本語"][b % 3];
+ let mut expected = before.clone();
+ expected.splice(a..b, replacement.chars().map(|c| (c, before[a].1.clone())));
+ let edited =
+ onestore::replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement)
+ .unwrap();
+ assert_eq!(characters(&edited), expected, "characters {a}..{b}");
+ let current_store = Store::parse(&edited).unwrap();
+ assert_eq!(
+ current_store.header.transaction_count,
+ store.header.transaction_count
+ + usize::from(a != b || !replacement.is_empty()) as u32
+ );
+ let current = RevisionIndex::parse(¤t_store).unwrap();
+ for (space, history) in &index.spaces {
+ for revision in history.revisions.keys() {
+ let old = index.resolve(*space, *revision).unwrap();
+ let retained = current.resolve(*space, *revision).unwrap();
+ assert_eq!(old.roots, retained.roots);
+ for (id, object) in old.objects {
+ assert_eq!(object.data, retained.objects[&id].data);
+ }
+ }
+ }
+ }
+ }
+ let cleared =
+ onestore::replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap();
+ let typed = onestore::replace_text(&cleared, sid, oid, 0..0, "new").unwrap();
+ let format = &before.last().unwrap().1;
+ assert_eq!(
+ characters(&typed),
+ "new\0"
+ .chars()
+ .map(|c| (c, format.clone()))
+ .collect::>()
+ );
+ let text: String = before[..before.len() - 1].iter().map(|(c, _)| *c).collect();
+ assert_eq!(
+ onestore::replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), &text).unwrap(),
+ SOURCE
+ );
+}
+
+#[test]
+#[ignore = "exports cross-run edits for independent native validation"]
+fn export_native_cross_run_edits() {
+ use std::{fs, path::PathBuf};
+ let output = PathBuf::from(std::env::var_os("ONESTORE_CROSS_RUN_OUTPUT").unwrap());
+ assert!(output.is_absolute());
+ fs::create_dir(&output).unwrap();
+ let mut manifest = Vec::new();
+ for (name, fixture, range, replacement, retype) in [
+ ("partial", "native-paragraph", 2..16, "中🦀", false),
+ ("table", "native-cell", 0..24, "Across 🐈 ", false),
+ ("clear", "native-paragraph", 0..u32::MAX, "", false),
+ (
+ "replace",
+ "native-paragraph",
+ 0..u32::MAX,
+ "Replacement e\u{301}🦀",
+ false,
+ ),
+ ("retype", "native-paragraph", 0..u32::MAX, "", true),
+ ("boundary", "native-paragraph", 4..8, "Middle", false),
+ ] {
+ let input = format!("../../corpus/formatted-insertion/{fixture}/candidate/synthetic.one");
+ let source = fs::read(&input).unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (sid, oid, length) = document
+ .spaces
+ .iter()
+ .find_map(|(sid, space)| {
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ view.nodes.iter().find_map(|(oid, node)| {
+ let Kind::RichText { text, .. } = &node.kind else {
+ return None;
+ };
+ text.starts_with("Bold ")
+ .then(|| (*sid, *oid, text.encode_utf16().count() as u32))
+ })
+ })
+ .unwrap();
+ let range = range.start..range.end.min(length);
+ let mut edited =
+ onestore::replace_text(&source, sid, oid, range.clone(), replacement).unwrap();
+ if retype {
+ edited = onestore::replace_text(&edited, sid, oid, 0..0, "Retyped 🦀").unwrap();
+ }
+ let candidate = output.join(name).join("candidate");
+ fs::create_dir_all(&candidate).unwrap();
+ fs::write(candidate.join("synthetic.one"), &edited).unwrap();
+ manifest.push(serde_json::json!({"name":name,"source":input,"space":sid,"object":oid,"range":range,"replacement":replacement,"retype":retype}));
+ }
+ fs::write(
+ output.join("manifest.json"),
+ serde_json::to_vec_pretty(&manifest).unwrap(),
+ )
+ .unwrap();
+}
+
#[test]
fn insertion_at_a_style_boundary_uses_the_following_style() {
let (sid, oid) = target(SOURCE);
@@ -701,14 +875,14 @@ fn interrupted_text_commits_never_publish_mismatched_run_boundaries() {
};
let before = text_runs(source, sid, oid);
let mut success = make_disk(None, 1);
- onestore::commit_text(&mut success, source, sid, oid, 0..10, "🐈 mixed edit").unwrap();
+ onestore::commit_text(&mut success, source, sid, oid, 0..23, "🐈 mixed edit").unwrap();
let after = text_runs(&success.durable, sid, oid);
assert_ne!(before, after);
for at in source.len().div_ceil(193)..=success.operation {
for seed in [1, 42] {
let mut disk = make_disk(Some(at), seed);
let error =
- onestore::commit_text(&mut disk, source, sid, oid, 0..10, "🐈 mixed edit")
+ onestore::commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit")
.unwrap_err();
let observed = text_runs(&disk.durable, sid, oid);
match error.state {
diff --git a/fuzz/fuzz_targets/edit_text.rs b/fuzz/fuzz_targets/edit_text.rs
index b515803829fa8d87929cc868bc9ee70a8228814e..4cf020c18a9b88b8d6452e8dcba57e526358fd06 100644
--- a/fuzz/fuzz_targets/edit_text.rs
+++ b/fuzz/fuzz_targets/edit_text.rs
@@ -129,7 +129,7 @@ fuzz_target!(|input: &[u8]| {
let (source, sid, oid) = &CASES[usize::from(input.last().copied().unwrap_or(0)) % CASES.len()];
let (sid, oid) = (*sid, *oid);
let mut persisted_source = source.clone();
- let mut caches = std::array::from_fn::<_, 6, _>(|_| source.clone());
+ let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
for step in input.chunks_exact(8).take(16) {
let actor = usize::from(step[6]) % caches.len();
if step[7] % 3 == 0 {
@@ -154,7 +154,14 @@ fuzz_target!(|input: &[u8]| {
let replacement = ["", "a", "🦀e\u{301}", "日本語", "\n", "\0"][usize::from(step[2]) % 6];
let mut expected = before.clone();
let format = before[start].1.clone();
- expected.splice(start..end, replacement.chars().map(|c| (c, format.clone())));
+ if before[start..end]
+ .iter()
+ .map(|(c, _)| *c)
+ .collect::()
+ != replacement
+ {
+ expected.splice(start..end, replacement.chars().map(|c| (c, format.clone())));
+ }
let mut storage = Disk {
visible: persisted_source.clone(),
durable: persisted_source.clone(),
@@ -173,6 +180,15 @@ fuzz_target!(|input: &[u8]| {
replacement,
);
let persisted = characters(&storage.durable, sid, oid);
+ if !replacement.contains(['\n', '\0']) {
+ assert!(
+ result
+ .as_ref()
+ .err()
+ .is_none_or(|error| error.error.kind() != std::io::ErrorKind::InvalidData),
+ "Valid ordinary text edit was rejected: {result:?}"
+ );
+ }
match result {
Ok(()) => assert_eq!(persisted, expected),
Err(error) => match error.state {
diff --git a/tools/native_collaboration.py b/tools/native_collaboration.py
index e51f7e04d0ba24f45006018565460e6619f7dc17..063727f37b744277cb9f4163e4ee89284c404852 100644
--- a/tools/native_collaboration.py
+++ b/tools/native_collaboration.py
@@ -54,6 +54,14 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.')
output = output.resolve()
output.mkdir(parents=True, exist_ok=False)
+ for profile in (('debug', 'release') if client_profile == 'release' else ('debug',)):
+ build = ['cargo', 'build', '--locked', '--workspace', '--all-features', '--examples']
+ if profile == 'release': build.append('--release')
+ with (output / f'build-{profile}.log').open('w') as log:
+ result = subprocess.run(build, cwd=ROOT, env={**os.environ, 'CARGO_TARGET_DIR': str(ROOT / 'target')},
+ stdout=log, stderr=subprocess.STDOUT)
+ (output / f'build-{profile}.json').write_text(json.dumps({'command': build, 'exit': result.returncode}, indent=2))
+ result.check_returncode()
mount = output / 'mount'
mount.mkdir()
scripts = output / 'scripts'
diff --git a/tools/native_stress.py b/tools/native_stress.py
index c82f2a642db999599a2830250e748e37aadd6ef1..dd486844600f9d0035f900f166c8ceba61a18f3b 100644
--- a/tools/native_stress.py
+++ b/tools/native_stress.py
@@ -96,7 +96,7 @@ def verify_capture(output, capture):
if config.get('document_operations'):
from offline_document_history import document_history
documents = document_history(logs, operations)
- expected.extend(document['text'] for document in documents.values())
+ expected.extend(''.join(char for char, *_ in list(document['states'].values())[-1]['characters']) for document in documents.values())
page_file, = capture.glob('page-*.xml')
page = ET.parse(page_file).getroot()
paragraphs = native_characters(page, page.findall('one:Outline', ns))
@@ -111,8 +111,8 @@ def verify_capture(output, capture):
checks += 1
if documents:
from offline_document_history import verify_native
- checks += verify_native(paragraphs, documents)
- return {'rust_intents': len(commits), 'document_intents': len(documents)*2, 'native_intents': sum(map(len, native)),
+ checks += verify_native(paragraphs, (list(document['states'].values())[-1]['characters'] for document in documents.values()))
+ return {'rust_intents': len(commits), 'document_intents': sum(len(document['states']) for document in documents.values()), 'native_intents': sum(map(len, native)),
'exact_paragraphs': len(expected), 'native_intended_format_checks': checks}
@@ -231,7 +231,7 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint
if config.get('document_operations'):
from offline_document_history import document_history
documents = document_history(rust, operations)
- expected.extend(document['text'] for document in documents.values())
+ expected.extend(''.join(char for char, *_ in list(document['states'].values())[-1]['characters']) for document in documents.values())
for client in clients: wait_text(client, expected)
checkpoint('stress-final')
model = json.loads((output / 'stress-final/model/document.json').read_text())
@@ -262,7 +262,7 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint
earliest_end = (native['updated_ticks'] - 621355968000000000) // 10 - high
overlap += sum(max(latest_start, rust['started_us']) < min(earliest_end, rust['finished_us']) for rust in commits)
result = {'native_writers': len(clients), 'rust_writers': rust_writers, 'rust_readers': rust_readers,
- 'sync_every': sync_every, 'edit': edit, 'seed': seed, 'offline': config.get('offline', False), 'native_edits': operations * len(clients), 'rust_commits': len(commits), 'document_commits': len(documents)*2, 'rust_reads': len(reads),
+ 'sync_every': sync_every, 'edit': edit, 'seed': seed, 'offline': config.get('offline', False), 'native_edits': operations * len(clients), 'rust_commits': len(commits), 'document_commits': sum(len(document['states']) for document in documents.values()), 'rust_reads': len(reads),
'clock_bounded_native_rust_call_overlaps': overlap, 'converged_paragraphs': len(expected)}
(output / 'result.json').write_text(json.dumps(result, indent=2))
assert overlap, 'No native/Rust call overlap established within clock uncertainty'
diff --git a/tools/offline_document_history.py b/tools/offline_document_history.py
index 23f0f8cc469866ebf280ff378958e2451d55bcc6..3edb363b0a141f66068e86d5b6ad21fd4e22c917 100644
--- a/tools/offline_document_history.py
+++ b/tools/offline_document_history.py
@@ -15,14 +15,21 @@ def characters(observed):
return actual
+def operation_kinds(events):
+ kinds = tuple(events[0].get('document_kinds', ('insert', 'format')))
+ assert kinds in (('insert', 'format'), ('insert', 'format', 'text')), 'Unknown document workload'
+ return kinds
+
+
def document_history(logs, operations):
documents = {}
for actor, events in logs.items():
if not actor.startswith('w'): continue
assert events[0].get('document_operations') is True, 'Writer omitted document operations'
+ kinds = operation_kinds(events)
edits = [row for row in events if row['event'] == 'local_document_commit']
assert [(row['operation'], row['kind']) for row in edits] == [
- (i, kind) for i in range(operations) for kind in ('insert', 'format')], 'Missing or duplicate document intent'
+ (i, kind) for i in range(operations) for kind in kinds], 'Missing or duplicate document intent'
ids = [row['id'] for row in edits]
assert ids == sorted(set(ids)), 'Document intent IDs are duplicated or unordered'
assert not set(ids) & {row['id'] for row in events if row['event'] == 'local_commit'}, 'Text and document intents share an ID'
@@ -64,7 +71,8 @@ def document_history(logs, operations):
else:
assert receipt['revision'] == attempt['revision']
linked[intent['id']] = {**attempt, 'acknowledged_us': receipt['at_us'], 'receipt_revision': receipt['revision']}
- for inserted, formatted in zip(edits[::2], edits[1::2], strict=True):
+ for at in range(0, len(edits), len(kinds)):
+ inserted, formatted, *replaced = edits[at:at + len(kinds)]
number = inserted['operation']
text = f'Document {actor}:{number} 🦀'
insertion = inserted['insertion']
@@ -76,7 +84,7 @@ def document_history(logs, operations):
assert insertion['placement'] == {'Outline': {'x': 144 + int(actor[1:]) * 240, 'y': 144 + number * 72}}, 'Outline placement differs from intent'
else:
assert insertion['placement'] == {'Paragraph': {'before': None}}
- assert insertion['parent'] == identity(edits[(number-1)*2]['insertion'], 1), 'Paragraph lost its outline parent'
+ assert insertion['parent'] == identity(edits[(number-1)*len(kinds)]['insertion'], 1), 'Paragraph lost its outline parent'
assert formatted['range'] == [1, len(text.encode('utf-16-le')) // 2 - 2]
assert formatted['attributes'] == [{'Bold': True}, {'FontSize': 18 + number % 9}, {'Color': [18, 52, 86]}]
old = [(char, False, 11, 0xff000000) for char in text]
@@ -91,8 +99,20 @@ def document_history(logs, operations):
assert created['finished_us'] <= changed['started_us'], 'Formatting preceded its insertion'
assert characters(created['documents'][target]) == old, 'Insertion publication differs from its local intent'
assert characters(changed['documents'][target]) == new, 'Formatting publication differs from its local intent'
- documents[target] = {'text': text, 'insertion': insertion, 'space': inserted['space'],
- 'old': old, 'new': new, 'insert': created, 'format': changed}
+ states = {'insert': {'characters': old, 'attempt': created},
+ 'format': {'characters': new, 'attempt': changed}}
+ if replaced:
+ replacement, = replaced
+ end = len(text.encode('utf-16-le')) // 2
+ assert replacement['object'] == target and replacement['space'] == inserted['space'], 'Text edit addresses another object'
+ assert replacement['text'] == text and replacement['range'] == [end-3, end], 'Cross-run text range differs from workload'
+ assert replacement['replacement'] == ' e\u0301🐈', 'Cross-run replacement differs from workload'
+ final = new[:-2] + [(char, *new[-2][1:]) for char in replacement['replacement']]
+ attempt = linked[replacement['id']]
+ assert changed['finished_us'] <= attempt['started_us'], 'Text replacement preceded its formatting'
+ assert characters(attempt['documents'][target]) == final, 'Text publication differs from its local intent'
+ states['text'] = {'characters': final, 'attempt': attempt}
+ documents[target] = {'insertion': insertion, 'space': inserted['space'], 'states': states}
assert documents, 'No document operations were recorded'
for actor, events in logs.items():
previous = {}
@@ -103,19 +123,20 @@ def document_history(logs, operations):
observed = read['documents']
assert set(previous) <= set(observed) <= set(documents), 'Reader lost an object or observed an unrecorded insertion'
for target, document in documents.items():
- if read['started_us'] > document['insert']['acknowledged_us']:
+ states = list(document['states'].values())
+ if read['started_us'] > states[0]['attempt']['acknowledged_us']:
assert target in observed, 'Reader missed an acknowledged insertion'
if target not in observed: continue
- assert read['finished_us'] >= document['insert']['started_us'], 'Reader observed a future insertion'
actual = characters(observed[target])
- assert actual in (document['old'], document['new']), 'Reader observed partial or invented formatting'
- formatted = actual == document['new']
- assert not previous.get(target, False) or formatted, 'Reader reverted acknowledged formatting'
- if read['started_us'] > document['format']['acknowledged_us']:
- assert formatted, 'Reader missed acknowledged formatting'
- if formatted:
- assert read['finished_us'] >= document['format']['started_us'], 'Reader observed future formatting'
- previous[target] = formatted
+ matches = [i for i, state in enumerate(states) if actual == state['characters']]
+ assert len(matches) == 1, 'Reader observed partial or invented document content'
+ current, = matches
+ assert current >= previous.get(target, 0), 'Reader reverted document content'
+ assert read['finished_us'] >= states[current]['attempt']['started_us'], 'Reader observed future document content'
+ for i, state in enumerate(states):
+ if read['started_us'] > state['attempt']['acknowledged_us']:
+ assert current >= i, 'Reader missed acknowledged document content'
+ previous[target] = current
return documents
@@ -138,7 +159,8 @@ def verify_model(model, documents):
found.add(target)
expected = documents[target]
insertion = expected['insertion']
- assert sid == expected['space'] and node['kind']['text'] == expected['text']
+ final = list(expected['states'].values())[-1]['characters']
+ assert sid == expected['space'] and node['kind']['text'] == ''.join(char for char, *_ in final)
object_id = identity(insertion, 1)
assert object_id in nodes[insertion['parent']]['children'], 'Insertion lost its parent'
paragraph = identity(insertion, 3) if 'Outline' in insertion['placement'] else object_id
@@ -150,13 +172,13 @@ def verify_model(model, documents):
assert found == set(documents), 'Final model omitted an inserted object'
-def verify_native(paragraphs, documents):
+def verify_native(paragraphs, expected):
from PIL import ImageColor
by_text = {''.join(char for char, _ in paragraph): paragraph for paragraph in paragraphs}
checks = 0
- for document in documents.values():
- actual = by_text[document['text']]
- for (char, style), (wanted, bold, size, color) in zip(actual, document['new'], strict=True):
+ for final in expected:
+ actual = by_text[''.join(char for char, *_ in final)]
+ for (char, style), (wanted, bold, size, color) in zip(actual, final, strict=True):
assert char == wanted and bool(style.get('bold')) == bold, 'Native text or bold differs from intent'
assert style.get('font_size', 11) == size, 'Native font size differs from intent'
native_color = style.get('color', 'automatic')
diff --git a/tools/offline_outage.py b/tools/offline_outage.py
index 1e066857d23636b27b274dbac8aa49015c7f7dec..126413ec7d434b3095e7bc0235b7041f63ca9008 100644
--- a/tools/offline_outage.py
+++ b/tools/offline_outage.py
@@ -8,6 +8,7 @@ import time
from native_runner import windows
import linux_vm
from verify_smb_overlap import verify
+from offline_document_history import operation_kinds
def interrupt(output, clients, sequences, processes):
@@ -104,7 +105,7 @@ def interrupt(output, clients, sequences, processes):
return
wait_for(lambda: all((folder / f'offline-paused-{actor}').exists() for actor in writers)
and (not config.get('document_operations') or all(
- sum(row['event'] == 'local_document_commit' for row in logs()[actor]) == 2 for actor in writers))
+ sum(row['event'] == 'local_document_commit' for row in logs()[actor]) == len(operation_kinds(logs()[actor])) for actor in writers))
and all(any(row['event'] == 'read' for row in logs()[actor]) for actor in readers),
'Clients did not reach the pre-publication outage barrier')
samples['native_before'] = native_counts('before')
@@ -115,7 +116,7 @@ def interrupt(output, clients, sequences, processes):
(folder / 'offline-outage-down').touch()
wait_for(lambda: all(sum(row['event'] == 'local_commit' for row in events) == 8 for actor, events in logs().items() if actor in writers)
and (not config.get('document_operations') or all(
- sum(row['event'] == 'local_document_commit' for row in logs()[actor]) == 16 for actor in writers))
+ sum(row['event'] == 'local_document_commit' for row in logs()[actor]) == 8 * len(operation_kinds(logs()[actor])) for actor in writers))
and all(sum(row['event'] == 'transport_read_error' for row in logs()[actor]) > samples['reader_errors_before'][actor] for actor in readers),
'Local queues or disconnected readers failed to progress during the outage')
time.sleep(3)
@@ -185,16 +186,17 @@ def verify_outage(output):
receipts = [row for row in events if row['event'] == 'remote_receipt']
assert len(receipts) == config['stress_operations'] and all(row['at_us'] > up for row in receipts)
if config.get('document_operations'):
+ kinds = operation_kinds(events)
edits = [row for row in events if row['event'] == 'local_document_commit']
assert [(row['operation'], row['kind']) for row in edits] == [
- (operation, kind) for operation in range(config['stress_operations']) for kind in ('insert', 'format')]
- assert all(row['finished_us'] < down for row in edits[:2]), 'Initial document edits missed the outage barrier'
+ (operation, kind) for operation in range(config['stress_operations']) for kind in kinds]
+ assert all(row['finished_us'] < down for row in edits[:len(kinds)]), 'Initial document edits missed the outage barrier'
queued = [row for row in edits if down < row['started_us'] <= row['finished_us'] < up]
assert [(row['operation'], row['kind']) for row in queued] == [
- (operation, kind) for operation in range(1, 8) for kind in ('insert', 'format')], 'Document edits did not persist during the outage'
+ (operation, kind) for operation in range(1, 8) for kind in kinds], 'Document edits did not persist during the outage'
queues[actor] += len(queued)
receipts = [row for row in events if row['event'] == 'document_receipt']
- assert len(receipts) == config['stress_operations'] * 2 and all(row['at_us'] > up for row in receipts)
+ assert len(receipts) == config['stress_operations'] * len(kinds) and all(row['at_us'] > up for row in receipts)
trace = [json.loads(line) for line in (output / 'smb-trace.jsonl').read_text().splitlines()]
controls = [row['control'] for row in trace if row.get('control', {}).get('phase', '').startswith('offline-')]
assert controls == [{'phase': 'offline-down', 'mode': 'down'}, {'phase': 'offline-reconnected'}], 'Unexpected outage control sequence'
@@ -233,7 +235,7 @@ def verify_lost_reply(output):
receipts = [row for row in logs[actor] if row['event'] in ('remote_receipt', 'document_receipt') and row['revision'] == attempt['revision']]
if not receipts and documents:
target, = attempt['document_changes']
- confirmed_revision = documents[target]['format']['receipt_revision']
+ confirmed_revision = documents[target]['states']['format']['attempt']['receipt_revision']
receipts = [row for row in logs[actor] if row['event'] == 'document_receipt' and row['revision'] == confirmed_revision]
assert len(receipts) == 1 and receipts[0]['at_us'] > sample['up_started_us']
peer_progress = {}
diff --git a/tools/test_cross_run_edit.py b/tools/test_cross_run_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..245477c7a1335eadc5bbf83ca6d3ca667666310d
--- /dev/null
+++ b/tools/test_cross_run_edit.py
@@ -0,0 +1,25 @@
+import json
+from pathlib import Path
+import runpy
+import shutil
+from tempfile import TemporaryDirectory
+import unittest
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / 'corpus/cross-run-edit'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+class CrossRunEditTest(unittest.TestCase):
+ def test_cold_native_text_styles_and_structure(self):
+ for case in json.loads((FIXTURE / 'manifest.json').read_text())['cases']:
+ with self.subTest(case=case['name']), TemporaryDirectory() as temporary:
+ fixture = FIXTURE / case['name']
+ native = Path(temporary) / 'read'
+ shutil.copytree(fixture / 'cold/read', native)
+ compare(fixture / 'candidate', native)
+ compare(fixture / 'cold/notebook', native)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tools/test_native_runner.py b/tools/test_native_runner.py
index 3efb3d890b84b6c8b394283699feb11580937164..b48e69836c9e707506555aadea89e6a6e36b7f61 100644
--- a/tools/test_native_runner.py
+++ b/tools/test_native_runner.py
@@ -1,4 +1,5 @@
import json
+import subprocess
from pathlib import Path
import tempfile
import unittest
@@ -8,6 +9,26 @@ import native_runner as runner
class NativeRunnerTest(unittest.TestCase):
+ def test_failed_client_build_cannot_start_a_lab_with_stale_binaries(self):
+ import native_collaboration as collaboration
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ for profile, failed in (('debug', 'debug'), ('release', 'debug'), ('release', 'release')):
+ output = root / (profile + '-' + failed)
+ results = [subprocess.CompletedProcess(['cargo'], 1)]
+ if failed == 'release': results.insert(0, subprocess.CompletedProcess(['cargo'], 0))
+ with patch.object(collaboration, 'linux_vm') as linux, \
+ patch.object(collaboration.subprocess, 'run', side_effect=results) as build:
+ linux.instance_path.return_value = root / 'absent'
+ with self.assertRaises(subprocess.CalledProcessError):
+ collaboration.replay(output, 'fixture', client_profile=profile)
+ self.assertEqual(build.call_count, len(results))
+ linux.create_instance.assert_not_called()
+ linux.launch.assert_not_called()
+ receipt = json.loads((output / f'build-{failed}.json').read_text())
+ self.assertEqual(receipt['exit'], 1)
+ self.assertEqual('--release' in receipt['command'], failed == 'release')
+
def test_shutdown_timeout_still_removes_the_owned_clone(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
diff --git a/tools/test_offline_confirmation.py b/tools/test_offline_confirmation.py
index 47d33448819c4af15e0713a7124068e4c30b247c..c20e9733e75bbb04d9d3cb86927a8214e0fec90f 100644
--- a/tools/test_offline_confirmation.py
+++ b/tools/test_offline_confirmation.py
@@ -62,14 +62,14 @@ class ConfirmationOracle(unittest.TestCase):
markup = 'x]]>'
content = xml('Concurrent edits: [w0:0]', 'Native 0:').replace('', markup + '')
page.write_text(content)
- with patch('offline_document_history.document_history', return_value={'target': {'text': 'x', 'format': {'receipt_revision': 'revision'}}}) as documents:
+ with patch('offline_document_history.document_history', return_value={'target': {'states': {'format': {'attempt': {'receipt_revision': 'revision'}}}}}) as documents:
result = verify(root, cold)
self.assertEqual(result['validated_paragraphs'], 3)
self.assertEqual(result['native_intended_format_checks'], 3)
documents.assert_called_once_with({'w0': rows}, 1)
rows[3]['revisions'] = {'space': ['current']}
rows[3]['current_revisions'] = {'space': 'current'}
- documents.return_value['target']['format']['receipt_revision'] = 'current'
+ documents.return_value['target']['states']['format']['attempt']['receipt_revision'] = 'current'
(root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
self.assertEqual(verify(root, cold)['confirmed_revision'], 'current')
rows[3]['current_revisions'] = {'space': 'unrelated'}
diff --git a/tools/test_offline_document_history.py b/tools/test_offline_document_history.py
index 4bea175efc829711e9cce266f0558ffde284c3f5..1d51a253ee6ecabb4b75550c963bae971a998871 100644
--- a/tools/test_offline_document_history.py
+++ b/tools/test_offline_document_history.py
@@ -6,52 +6,92 @@ from unittest.mock import patch
from offline_document_history import document_history, identity, verify_model, verify_native
+def history(text_edits=False):
+ logs = {'w0': [{'event': 'ready', 'document_operations': True, 'document_kinds': ['insert', 'format', 'text'] if text_edits else ['insert', 'format']}]}
+ events = logs['w0']
+ observed = {}
+ previous = None
+ for operation in range(2):
+ insertion = {'guid': list(uuid.UUID(int=operation+1).bytes_le), 'text': f'Document w0:{operation} 🦀',
+ 'parent': 'page' if operation == 0 else identity(previous, 1), 'author': 'Offline document writer',
+ 'placement': {'Outline': {'x': 144, 'y': 144}} if operation == 0 else {'Paragraph': {'before': None}}}
+ previous = insertion
+ target = identity(insertion, 2)
+ for step, kind in enumerate(('insert', 'format', 'text') if text_edits else ('insert', 'format')):
+ timestamp = 10 + operation*30 + step*10
+ local_id = operation*3 + step + 2
+ event = {'event': 'local_document_commit', 'id': local_id, 'operation': operation, 'kind': kind,
+ 'space': 'space', 'object': target, 'text': insertion['text'], 'insertion': insertion if step == 0 else None,
+ 'range': [1, len(insertion['text'].encode('utf-16-le'))//2-2],
+ 'attributes': [{'Bold': True}, {'FontSize': 18+operation}, {'Color': [18, 52, 86]}],
+ 'started_us': timestamp-2, 'finished_us': timestamp-1}
+ if kind == 'text':
+ end = len(insertion['text'].encode('utf-16-le'))//2
+ event.update(range=[end-3, end], replacement=' e\u0301🐈')
+ events.append(event)
+ value = insertion['text'].replace(' 🦀', ' e\u0301🐈') if kind == 'text' else insertion['text']
+ runs = []
+ for index, char in enumerate(value):
+ selected = index > 0 and (kind == 'text' or (kind == 'format' and index < len(value)-1))
+ runs.append({'text': char, 'bold': selected, 'size': 18+operation if selected else 11,
+ 'color': 0x563412 if selected else 0xff000000})
+ observed[target] = {'text': value, 'runs': runs}
+ revision = f'revision-{local_id}'
+ events.append({'event': 'remote_attempt', 'revision': revision, 'state': 'Committed',
+ 'document_changes': {target: copy.deepcopy(observed[target])},
+ 'documents': copy.deepcopy(observed), 'started_us': timestamp, 'finished_us': timestamp+1})
+ events.append({'event': 'document_receipt', 'id': local_id, 'revision': revision, 'at_us': timestamp+2})
+ if text_edits:
+ events.append({'event': 'read', 'started_us': timestamp+3, 'finished_us': timestamp+4, 'documents': copy.deepcopy(observed)})
+ events.extend({'event': 'reopened_document_receipt', 'id': row['id'], 'revision': row['revision']}
+ for row in list(events) if row['event'] == 'document_receipt')
+ read = {'event': 'read', 'started_us': 100, 'finished_us': 101, 'documents': observed}
+ events.extend([read, {'event': 'done'}])
+ logs['r0'] = [{'event': 'ready'}, copy.deepcopy(read), {'event': 'done'}]
+ return logs
+
+
class DocumentHistoryTests(unittest.TestCase):
def setUp(self):
- self.logs = {'w0': [{'event': 'ready', 'document_operations': True}]}
- events = self.logs['w0']
- observed = {}
- previous = None
- for operation in range(2):
- insertion = {'guid': list(uuid.UUID(int=operation+1).bytes_le), 'text': f'Document w0:{operation} 🦀',
- 'parent': 'page' if operation == 0 else identity(previous, 1), 'author': 'Offline document writer',
- 'placement': {'Outline': {'x': 144, 'y': 144}} if operation == 0 else {'Paragraph': {'before': None}}}
- previous = insertion
- target = identity(insertion, 2)
- for step, kind in enumerate(('insert', 'format')):
- timestamp = 10 + operation*30 + step*10
- local_id = operation*3 + step + 2
- event = {'event': 'local_document_commit', 'id': local_id, 'operation': operation, 'kind': kind,
- 'space': 'space', 'object': target, 'text': insertion['text'], 'insertion': insertion if step == 0 else None,
- 'range': [1, len(insertion['text'].encode('utf-16-le'))//2-2],
- 'attributes': [{'Bold': True}, {'FontSize': 18+operation}, {'Color': [18, 52, 86]}],
- 'started_us': timestamp-2, 'finished_us': timestamp-1}
- events.append(event)
- runs = []
- for index, char in enumerate(insertion['text']):
- selected = kind == 'format' and 0 < index < len(insertion['text'])-1
- runs.append({'text': char, 'bold': selected, 'size': 18+operation if selected else 11,
- 'color': 0x563412 if selected else 0xff000000})
- observed[target] = {'text': insertion['text'], 'runs': runs}
- revision = f'revision-{local_id}'
- events.append({'event': 'remote_attempt', 'revision': revision, 'state': 'Committed',
- 'document_changes': {target: copy.deepcopy(observed[target])},
- 'documents': copy.deepcopy(observed), 'started_us': timestamp, 'finished_us': timestamp+1})
- events.append({'event': 'document_receipt', 'id': local_id, 'revision': revision, 'at_us': timestamp+2})
- events.extend({'event': 'reopened_document_receipt', 'id': row['id'], 'revision': row['revision']}
- for row in list(events) if row['event'] == 'document_receipt')
- read = {'event': 'read', 'started_us': 100, 'finished_us': 101, 'documents': observed}
- events.extend([read, {'event': 'done'}])
- self.logs['r0'] = [{'event': 'ready'}, copy.deepcopy(read), {'event': 'done'}]
+ self.logs = history()
def test_document_receipts_and_reader_states_match_the_intents(self):
documents = document_history(self.logs, 2)
self.assertEqual(len(documents), 2)
+ expected = [list(row['states'].values())[-1]['characters'] for row in documents.values()]
paragraphs = [[(char, {'bold': bold, 'font_size': size, 'color': 'automatic' if color == 0xff000000 else '#123456'})
- for char, bold, size, color in row['new']] for row in documents.values()]
- self.assertEqual(verify_native(paragraphs, documents), sum(len(row['new'])*3 for row in documents.values()))
+ for char, bold, size, color in row] for row in expected]
+ self.assertEqual(verify_native(paragraphs, expected), sum(len(row)*3 for row in expected))
paragraphs[0][1][1]['font_size'] = 19
- with self.assertRaisesRegex(AssertionError, 'font size'): verify_native(paragraphs, documents)
+ with self.assertRaisesRegex(AssertionError, 'font size'): verify_native(paragraphs, expected)
+
+ def test_cross_run_text_requires_complete_ordered_states_and_exact_receipts(self):
+ logs = history(True)
+ documents = document_history(logs, 2)
+ for document in documents.values():
+ self.assertEqual(list(document['states']), ['insert', 'format', 'text'])
+ final = document['states']['text']['characters']
+ self.assertEqual(''.join(char for char, *_ in final), document['insertion']['text'].replace(' 🦀', ' e\u0301🐈'))
+ for event, field, value in [('local_document_commit', 'replacement', 'wrong'),
+ ('local_document_commit', 'range', [0, 1]),
+ ('document_receipt', 'revision', 'wrong')]:
+ changed = copy.deepcopy(logs)
+ row = next(row for row in changed['w0'] if row['event'] == event and
+ (row.get('kind') == 'text' or row.get('id') == 4))
+ row[field] = value
+ with self.subTest(event=event, field=field), self.assertRaises(AssertionError):
+ document_history(changed, 2)
+ changed = copy.deepcopy(logs)
+ read = changed['r0'][1]
+ target = next(iter(read['documents']))
+ read['documents'][target]['runs'][-1]['bold'] = False
+ with self.assertRaisesRegex(AssertionError, 'partial or invented'):
+ document_history(changed, 2)
+ prior = next(row for row in logs['w0'] if row['event'] == 'read' and row['started_us'] == 23)
+ changed = copy.deepcopy(logs)
+ changed['r0'][1]['documents'][target] = copy.deepcopy(prior['documents'][target])
+ with self.assertRaisesRegex(AssertionError, 'missed acknowledged'):
+ document_history(changed, 2)
def test_missing_intents_receipts_or_reopen_records_are_rejected(self):
for name in ('local_document_commit', 'document_receipt', 'reopened_document_receipt', 'remote_attempt'):
@@ -75,10 +115,11 @@ class DocumentHistoryTests(unittest.TestCase):
run['size'] = 13.5
run['color'] = 0x665544
documents = document_history(self.logs, 2)
+ expected = [list(row['states'].values())[-1]['characters'] for row in documents.values()]
paragraphs = [[(char, {'bold': bold, 'font_size': size,
'color': '#123456' if color == 0x563412 else '#445566'})
- for char, bold, size, color in row['new']] for row in documents.values()]
- self.assertEqual(verify_native(paragraphs, documents), sum(len(row['new'])*3 for row in documents.values()))
+ for char, bold, size, color in row] for row in expected]
+ self.assertEqual(verify_native(paragraphs, expected), sum(len(row)*3 for row in expected))
baseline = copy.deepcopy(self.logs)
for event, key in [('remote_attempt', 'document_changes'), ('read', 'documents')]:
for field, value in [('size', 11), ('color', 0xff000000)]:
@@ -102,7 +143,7 @@ class DocumentHistoryTests(unittest.TestCase):
events[index:index] = [read, confirmation]
result = document_history(self.logs, 2)
target, = attempt['document_changes']
- self.assertEqual(result[target]['format']['receipt_revision'], 'current-revision')
+ self.assertEqual(result[target]['states']['format']['attempt']['receipt_revision'], 'current-revision')
for field, value in [('current_revisions', {'space': 'unrelated'}),
('revisions', {'space': ['revision-3', 'current-revision']}),
('state', 'NotCommitted')]:
@@ -123,7 +164,7 @@ class DocumentHistoryTests(unittest.TestCase):
nodes[outline].update(children=[paragraph, appended], layout={'x': 144, 'y': 144})
for parent, (target, document) in zip([paragraph, appended], documents.items(), strict=True):
nodes[parent]['content'] = [target]
- nodes[target]['kind'] = {'text': document['text']}
+ nodes[target]['kind'] = {'text': ''.join(char for char, *_ in list(document['states'].values())[-1]['characters'])}
revision = {'nodes': nodes}
with patch('offline_document_history.ordered_pages', return_value=[('space', 'revision', revision, 'page')]):
verify_model({}, documents)
diff --git a/tools/test_offline_outage.py b/tools/test_offline_outage.py
index 15ad50c69a0d3f9e62c2f2b86a7c0e8123d1fbf7..d108efb97991ba2fc3590f0b95254249f412e6c2 100644
--- a/tools/test_offline_outage.py
+++ b/tools/test_offline_outage.py
@@ -85,7 +85,7 @@ class OutageOracle(unittest.TestCase):
(self.root / 'rust' / f'{actor}.jsonl').write_text('\n'.join(map(json.dumps, rows)))
with patch('offline_history.publication_links') as ledger, patch('offline_document_history.document_history') as documents, patch('offline_outage.verify', return_value={'guarded_pairs': 1}) as overlap:
if self.config.get('offline_client_reply'):
- documents.return_value = {'target': {'format': {'receipt_revision': 'effect-revision'}}}
+ documents.return_value = {'target': {'states': {'format': {'attempt': {'receipt_revision': 'effect-revision'}}}}}
result = verify_lost_reply(self.root)
ledger.assert_called_once_with(self.logs, self.config['stress_operations'])
if self.config.get('document_operations'):
diff --git a/tools/verify_offline.py b/tools/verify_offline.py
index af75cd70087a6ef4fed100d90d9ca4d869237cfb..11016d4ea26eeabc6ee8b9e57282fd57c27ff9d9 100644
--- a/tools/verify_offline.py
+++ b/tools/verify_offline.py
@@ -32,7 +32,7 @@ def verify(output, max_gap=120):
edits = {event['id']: event for event in events if event['event'] in ('local_commit', 'local_document_commit')}
receipts = {event['id']: event for event in events if event['event'] in ('remote_receipt', 'document_receipt')}
attempts = {event['revision']: event for event in events if event['event'] == 'remote_attempt'}
- publication_starts = {id: documents[edit['object']][edit['kind']]['started_us'] if edit['event'] == 'local_document_commit'
+ publication_starts = {id: documents[edit['object']]['states'][edit['kind']]['attempt']['started_us'] if edit['event'] == 'local_document_commit'
else attempts[receipts[id]['revision']]['started_us'] for id, edit in edits.items()}
queues[actor] = max(sum(edit['finished_us'] <= at < publication_starts[id] for id, edit in edits.items()) for at in [edit['finished_us'] for edit in edits.values()])
assert queues[actor] >= 2, 'Writer did not establish a durable local queue before publication'
@@ -57,7 +57,7 @@ def verify(output, max_gap=120):
assert times == sorted(times)
progress[f'n{i}'] = max((b-a for a, b in zip(times, times[1:])), default=0) / 10_000_000
assert all(gap <= max_gap for gap in progress.values()), f'Client progress exceeded {max_gap}s: {progress}'
- return {'remote_publications': len(commits), 'document_publications': len(documents)*2, 'remote_text_sha256': hashlib.sha256(text.encode()).hexdigest(), 'maximum_progress_gap_seconds': progress,
+ return {'remote_publications': len(commits), 'document_publications': sum(len(document['states']) for document in documents.values()), 'remote_text_sha256': hashlib.sha256(text.encode()).hexdigest(), 'maximum_progress_gap_seconds': progress,
'queued_before_publication_lower_bound': queues, 'reviewed_placements': sum(event['event'] == 'reviewed_append' for events in logs.values() for event in events), 'caches': caches}
diff --git a/tools/verify_offline_confirmation.py b/tools/verify_offline_confirmation.py
index 2ae74fb22e13fd771d2656f69eff2f3db983777d..8e8ea4823da3bc79a8e96827a048773007f06f56 100644
--- a/tools/verify_offline_confirmation.py
+++ b/tools/verify_offline_confirmation.py
@@ -68,9 +68,8 @@ def verify(output, cold, *, partial=False):
paragraphs.remove(expected)
if observed:
from offline_document_history import characters, verify_native
- expected_documents = {target: {'text': documents[target]['text'], 'new': characters(value)} for target, value in observed.items()}
- format_checks += verify_native(formatted, expected_documents)
- for document in expected_documents.values():
+ format_checks += verify_native(formatted, (characters(value) for value in observed.values()))
+ for document in observed.values():
assert paragraphs.count(document['text']) == 1, 'Confirmation duplicated a document paragraph'
paragraphs.remove(document['text'])
for index, versions in enumerate(native):
@@ -78,7 +77,7 @@ def verify(output, cold, *, partial=False):
assert len(selected) == 1 and selected[0] in versions, 'Native confirmation image contains an unrecorded edit or loses a prefix'
checks += len(native) + 1 + len(observed)
assert json.loads((cold / 'teardown.json').read_text()) == {'absent': True}
- confirmed_revision = documents[next(iter(attempt['document_changes']))]['format']['receipt_revision'] if documents else attempt['revision']
+ confirmed_revision = documents[next(iter(attempt['document_changes']))]['states']['format']['attempt']['receipt_revision'] if documents else attempt['revision']
return {'complete_workload': not partial, 'attempted_revision': attempt['revision'], 'confirmed_revision': confirmed_revision, 'native_images': len(results), 'exact_rust_paragraphs': len(results),
'validated_paragraphs': checks, 'native_intended_format_checks': format_checks,
'maximum_native_export_seconds': max(row['seconds'] for row in results)}