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)}