diff --git a/src/wp-includes/assets/script-loader-packages.php b/src/wp-includes/assets/script-loader-packages.php index 840f4ba1c8b31..7155afe2a79e7 100644 --- a/src/wp-includes/assets/script-loader-packages.php +++ b/src/wp-includes/assets/script-loader-packages.php @@ -4,7 +4,7 @@ 'wp-dom-ready', 'wp-i18n' ), - 'version' => 'af934e5259bc51b8718e' + 'version' => '483af07a6016f640f456' ), 'annotations.js' => array( 'dependencies' => array( @@ -13,14 +13,14 @@ 'wp-i18n', 'wp-rich-text' ), - 'version' => '4b07d06c67c3b5ea590c' + 'version' => 'd4fe1eeb787c2fd5ee89' ), 'api-fetch.js' => array( 'dependencies' => array( 'wp-i18n', 'wp-url' ), - 'version' => 'd7efe4dc1468d36c39b8' + 'version' => 'b76aeca1c88ecc790e48' ), 'autop.js' => array( 'dependencies' => array( @@ -42,6 +42,8 @@ ), 'block-directory.js' => array( 'dependencies' => array( + 'react', + 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', @@ -59,9 +61,11 @@ 'wp-notices', 'wp-plugins', 'wp-primitives', + 'wp-private-apis', + 'wp-theme', 'wp-url' ), - 'version' => '23207f52d0d266f6e1c4' + 'version' => '43a9d7ab2fbaa04615a1' ), 'block-editor.js' => array( 'dependencies' => array( @@ -69,7 +73,6 @@ 'react-dom', 'react-jsx-runtime', 'wp-a11y', - 'wp-api-fetch', 'wp-blob', 'wp-block-serialization-default-parser', 'wp-blocks', @@ -100,11 +103,12 @@ 'wp-url', 'wp-warning' ), - 'version' => '7e969d1c58fd6b032753' + 'version' => '95b54b173459535d814e' ), 'block-library.js' => array( 'dependencies' => array( 'react', + 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', @@ -132,6 +136,8 @@ 'wp-private-apis', 'wp-rich-text', 'wp-server-side-render', + 'wp-shortcode', + 'wp-theme', 'wp-upload-media', 'wp-url', 'wp-wordcount' @@ -142,7 +148,7 @@ 'import' => 'dynamic' ) ), - 'version' => 'bbcc73335599ce2b8d51' + 'version' => '74c4b4c69416ac4e1a8a' ), 'block-serialization-default-parser.js' => array( 'dependencies' => array( @@ -175,7 +181,7 @@ 'wp-shortcode', 'wp-warning' ), - 'version' => 'ef38e42500165bfda301' + 'version' => 'ece1f172d5b708916ebc' ), 'commands.js' => array( 'dependencies' => array( @@ -187,10 +193,11 @@ 'wp-element', 'wp-i18n', 'wp-keyboard-shortcuts', + 'wp-preferences', 'wp-primitives', 'wp-private-apis' ), - 'version' => 'e3d8bba53f4ffea4fcd2' + 'version' => '8b8663311faa33540c1b' ), 'components.js' => array( 'dependencies' => array( @@ -214,7 +221,7 @@ 'wp-rich-text', 'wp-warning' ), - 'version' => '5dedfe13f08880193a28' + 'version' => '83936472a0d07a3a4c92' ), 'compose.js' => array( 'dependencies' => array( @@ -228,7 +235,7 @@ 'wp-priority-queue', 'wp-undo-manager' ), - 'version' => 'edb5a8c0b5bf71686403' + 'version' => '2b5a9d090a41c1120be7' ), 'core-commands.js' => array( 'dependencies' => array( @@ -245,7 +252,7 @@ 'wp-router', 'wp-url' ), - 'version' => 'b209152e7e51279d7c28' + 'version' => 'c5adbb84012bd7834c04' ), 'core-data.js' => array( 'dependencies' => array( @@ -257,7 +264,6 @@ 'wp-data', 'wp-deprecated', 'wp-element', - 'wp-hooks', 'wp-html-entities', 'wp-i18n', 'wp-private-apis', @@ -266,11 +272,14 @@ 'wp-url', 'wp-warning' ), - 'version' => '515bcc5a2962232ccea7' + 'version' => '7d02248989e798a0df47' ), 'customize-widgets.js' => array( 'dependencies' => array( + 'react', + 'react-dom', 'react-jsx-runtime', + 'wp-a11y', 'wp-block-editor', 'wp-block-library', 'wp-blocks', @@ -289,9 +298,10 @@ 'wp-preferences', 'wp-primitives', 'wp-private-apis', + 'wp-theme', 'wp-widgets' ), - 'version' => '524dc7a4326b77064831' + 'version' => '4da0091c281df82bd222' ), 'data.js' => array( 'dependencies' => array( @@ -304,7 +314,7 @@ 'wp-private-apis', 'wp-redux-routine' ), - 'version' => 'dc7feb6ad8da53887680' + 'version' => 'ee4e907a070c9780da2b' ), 'data-controls.js' => array( 'dependencies' => array( @@ -319,7 +329,7 @@ 'moment', 'wp-deprecated' ), - 'version' => 'c9f8e7dd3232716f34e9' + 'version' => '2faaf49020b2074de156' ), 'deprecated.js' => array( 'dependencies' => array( @@ -331,7 +341,7 @@ 'dependencies' => array( 'wp-deprecated' ), - 'version' => '66a6cf58e0c4cd128af0' + 'version' => '1acdd4ebd6969685a9d3' ), 'dom-ready.js' => array( 'dependencies' => array( @@ -381,7 +391,7 @@ 'import' => 'static' ) ), - 'version' => '69ec189de328df478ab5' + 'version' => 'f1ecd1fe9a2e03c32e0a' ), 'edit-site.js' => array( 'dependencies' => array( @@ -409,6 +419,7 @@ 'wp-i18n', 'wp-keyboard-shortcuts', 'wp-keycodes', + 'wp-media-utils', 'wp-notices', 'wp-patterns', 'wp-plugins', @@ -420,7 +431,8 @@ 'wp-theme', 'wp-url', 'wp-warning', - 'wp-widgets' + 'wp-widgets', + 'wp-wordcount' ), 'module_dependencies' => array( array( @@ -428,7 +440,7 @@ 'import' => 'static' ) ), - 'version' => '34fbf7f8c7d55055d9cd' + 'version' => 'bbf06e0a5fd1c0217d61' ), 'edit-widgets.js' => array( 'dependencies' => array( @@ -469,7 +481,7 @@ 'import' => 'static' ) ), - 'version' => '899c5ac5dcb94e19d378' + 'version' => '3382b8166d24bc8ebc42' ), 'editor.js' => array( 'dependencies' => array( @@ -519,7 +531,7 @@ 'import' => 'static' ) ), - 'version' => 'f8cdc22abc621b3f9409' + 'version' => '8d1ec9ceec3630c2c461' ), 'element.js' => array( 'dependencies' => array( @@ -527,7 +539,7 @@ 'react-dom', 'wp-escape-html' ), - 'version' => '15ba804677f72a8db97b' + 'version' => 'ce395381f7d64d2a6d71' ), 'escape-html.js' => array( 'dependencies' => array( @@ -537,6 +549,8 @@ ), 'format-library.js' => array( 'dependencies' => array( + 'react', + 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-block-editor', @@ -549,6 +563,7 @@ 'wp-primitives', 'wp-private-apis', 'wp-rich-text', + 'wp-theme', 'wp-url' ), 'module_dependencies' => array( @@ -557,7 +572,7 @@ 'import' => 'dynamic' ) ), - 'version' => 'f89be9586f2d9ce4545a' + 'version' => 'b38d376fe79b3eac1578' ), 'hooks.js' => array( 'dependencies' => array( @@ -575,7 +590,7 @@ 'dependencies' => array( 'wp-hooks' ), - 'version' => '781d11515ad3d91786ec' + 'version' => '125448662852c5e18937' ), 'is-shallow-equal.js' => array( 'dependencies' => array( @@ -590,7 +605,7 @@ 'wp-element', 'wp-keycodes' ), - 'version' => '2ed78d3b4c23f38804e0' + 'version' => '0dd268b2132a3f82b1d4' ), 'keycodes.js' => array( 'dependencies' => array( @@ -608,7 +623,7 @@ 'wp-element', 'wp-i18n' ), - 'version' => '2e35ebd5dbaccb5a90c5' + 'version' => 'a44da9be02cdfef6e44d' ), 'media-utils.js' => array( 'dependencies' => array( @@ -628,13 +643,14 @@ 'wp-i18n', 'wp-keycodes', 'wp-notices', + 'wp-preferences', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url', 'wp-warning' ), - 'version' => '85f1375ab5f23cd5d13c' + 'version' => '744ed1f36d9fc95adf3f' ), 'notices.js' => array( 'dependencies' => array( @@ -642,7 +658,7 @@ 'wp-components', 'wp-data' ), - 'version' => '218d0173a31ae7269246' + 'version' => '1869781df3f0e4f0c6b8' ), 'nux.js' => array( 'dependencies' => array( @@ -655,7 +671,7 @@ 'wp-i18n', 'wp-primitives' ), - 'version' => '14d2335a0007b36b9112' + 'version' => 'ee8845ac5a9ad98ee3f7' ), 'patterns.js' => array( 'dependencies' => array( @@ -687,7 +703,7 @@ 'wp-is-shallow-equal', 'wp-primitives' ), - 'version' => '72e3cf01c2b3535a9432' + 'version' => '9bce3a8f6306f5380b9a' ), 'preferences.js' => array( 'dependencies' => array( @@ -703,7 +719,7 @@ 'wp-primitives', 'wp-private-apis' ), - 'version' => '035813168e404aa30193' + 'version' => '6595a0115a9c144c0f3a' ), 'preferences-persistence.js' => array( 'dependencies' => array( @@ -728,7 +744,7 @@ 'dependencies' => array( ), - 'version' => '835912f0086b9e59aed4' + 'version' => '0284e0dd580bcd471519' ), 'react-i18n.js' => array( 'dependencies' => array( @@ -758,7 +774,7 @@ 'wp-primitives', 'wp-url' ), - 'version' => '21d86e46535b79d9afda' + 'version' => '372c845659b9a298e4fb' ), 'rich-text.js' => array( 'dependencies' => array( @@ -773,7 +789,7 @@ 'wp-keycodes', 'wp-private-apis' ), - 'version' => '16449e6108f48327f368' + 'version' => '1b3e411a54ef29d2bf7a' ), 'router.js' => array( 'dependencies' => array( @@ -809,7 +825,7 @@ 'dependencies' => array( ), - 'version' => 'faa37ce61b7ec8394b2a' + 'version' => '10a88969c2fbccc89f91' ), 'theme.js' => array( 'dependencies' => array( @@ -817,7 +833,7 @@ 'wp-element', 'wp-private-apis' ), - 'version' => 'e22ce547a4420507b323' + 'version' => '3b1949512f2ec0c938bd' ), 'token-list.js' => array( 'dependencies' => array( @@ -845,21 +861,21 @@ 'module_dependencies' => array( ), - 'version' => 'd359c2cccf866d7082d2' + 'version' => '1399274c1ad48fc29498' ), 'url.js' => array( 'dependencies' => array( ), - 'version' => 'bb0f766c3d2efe497871' + 'version' => '9dd5f16a5ce37bf4ba2c' ), 'viewport.js' => array( 'dependencies' => array( - 'react-jsx-runtime', 'wp-compose', - 'wp-data' + 'wp-data', + 'wp-element' ), - 'version' => '8614025b8075d220d78f' + 'version' => '97845df4d1a7269c5c2b' ), 'warning.js' => array( 'dependencies' => array( @@ -882,7 +898,7 @@ 'wp-notices', 'wp-primitives' ), - 'version' => '02b8dd683bc610f979fa' + 'version' => '3ab93e442c755a6b2b4e' ), 'wordcount.js' => array( 'dependencies' => array( diff --git a/src/wp-includes/assets/script-modules-packages.php b/src/wp-includes/assets/script-modules-packages.php index a3f51229f1e63..f2ba82e12892c 100644 --- a/src/wp-includes/assets/script-modules-packages.php +++ b/src/wp-includes/assets/script-modules-packages.php @@ -34,7 +34,7 @@ 'import' => 'static' ) ), - 'version' => '2af01b43d30739c3fb8d' + 'version' => 'f77b871ece5a791f449e' ), 'block-library/file/view.js' => array( 'dependencies' => array( @@ -46,7 +46,7 @@ 'import' => 'static' ) ), - 'version' => '7d4d261d10dca47ebecb' + 'version' => 'a9114a756e418400594c' ), 'block-library/form/view.js' => array( 'dependencies' => array( @@ -88,7 +88,7 @@ 'import' => 'static' ) ), - 'version' => '99f747d731f80246db11' + 'version' => '1ecf748f10b95c76b349' ), 'block-library/query/view.js' => array( 'dependencies' => array( @@ -166,7 +166,7 @@ 'import' => 'static' ) ), - 'version' => '42d3f09bba14cce3054d' + 'version' => '5e02fdb03b9e05e7ba82' ), 'connectors/index.js' => array( 'dependencies' => array( @@ -177,7 +177,41 @@ 'wp-i18n', 'wp-private-apis' ), - 'version' => '274797868955a828dfdc' + 'version' => 'dce5e2b0fc240815717b' + ), + 'content-types/index.js' => array( + 'dependencies' => array( + 'react', + 'react-dom', + 'react-jsx-runtime', + 'wp-components', + 'wp-compose', + 'wp-core-data', + 'wp-data', + 'wp-date', + 'wp-deprecated', + 'wp-element', + 'wp-i18n', + 'wp-is-shallow-equal', + 'wp-keycodes', + 'wp-notices', + 'wp-primitives', + 'wp-private-apis', + 'wp-theme', + 'wp-url', + 'wp-warning' + ), + 'module_dependencies' => array( + array( + 'id' => '@wordpress/a11y', + 'import' => 'static' + ), + array( + 'id' => '@wordpress/route', + 'import' => 'static' + ) + ), + 'version' => '1705fe35ff04f22c2530' ), 'core-abilities/index.js' => array( 'dependencies' => array( @@ -205,13 +239,13 @@ 'import' => 'static' ) ), - 'version' => 'e57f44d1a9f69e75d2d9' + 'version' => '3e9b6e117adbaf70a10f' ), 'interactivity/index.js' => array( 'dependencies' => array( ), - 'version' => '4d2a3a72c7410d548881' + 'version' => 'efaa5193bbad9c60ffd1' ), 'interactivity-router/full-page.js' => array( 'dependencies' => array( @@ -273,7 +307,7 @@ 'wp-private-apis', 'wp-style-engine' ), - 'version' => '30ab62f45bfe9f971ea0' + 'version' => '8bd91519756b243fc835' ), 'route/index.js' => array( 'dependencies' => array( @@ -282,7 +316,7 @@ 'react-jsx-runtime', 'wp-private-apis' ), - 'version' => 'c5843b6c5e84b352f43b' + 'version' => '48a77bfa70722b4254e4' ), 'workflow/index.js' => array( 'dependencies' => array( @@ -303,6 +337,6 @@ 'import' => 'static' ) ), - 'version' => '13556bc597bbf2a8d620' + 'version' => 'c1055ffa9d3634a7dfe7' ) ); \ No newline at end of file diff --git a/src/wp-includes/automerge-php/CONTRARIAN_UPSTREAM_TEST_AUDIT.json b/src/wp-includes/automerge-php/CONTRARIAN_UPSTREAM_TEST_AUDIT.json new file mode 100644 index 0000000000000..556c35dd60dd4 --- /dev/null +++ b/src/wp-includes/automerge-php/CONTRARIAN_UPSTREAM_TEST_AUDIT.json @@ -0,0 +1,32 @@ +{ + "library": "automerge", + "generatedAt": "2026-05-23T00:23:00Z", + "verdict": "corrected-runtime-denominator", + "reportedMappedUpstreamTests": 680, + "reportedTotalKnownUpstreamTests": 680, + "reportedRegisteredMappedUpstreamTests": 688, + "reportedRegisteredTotalUpstreamTests": 688, + "reportedOptionalMappedUpstreamTests": 8, + "reportedOptionalPendingMappedTests": 1, + "reportedOptionalIgnoredMappedTests": 7, + "reportedWordPressScenarios": "10/10", + "contrarianEstimatedUpstreamTests": 680, + "contrarianEstimatedRegisteredUpstreamTests": 688, + "upstreamCommit": "44cd91582bd3ed9af05ef1a7843bb1074ad11112", + "upstreamTestCommands": [ + "cd upstream/automerge/javascript && npm test", + "cd upstream/automerge/rust && cargo test -p automerge" + ], + "upstreamCommandResult": "Live audit rerun succeeded: JavaScript npm test reported 312 passing and 1 pending; Rust cargo test -p automerge reported 368 passing and 7 ignored. These match UPSTREAM_BENCHMARK.json and the raw artifacts under artifacts/upstream-benchmark/.", + "phpComposerTestResult": "Not rerun in this audit because composer test rewrites PORTING_STATUS.json timestamps. Current committed PORTING_STATUS.json at HEAD 6fc76de reports active=680/680, registeredMapped=688/688, optional=8/8, pending=1/1, ignored=7/7, and wp=10/10.", + "evidence": [ + "UPSTREAM.md says the old 724 static audit estimate is superseded by runtime upstream counts.", + "UPSTREAM_BENCHMARK.json records countMethod runtime-runner-counts, activeTotal 680, registeredTotal 688, passed 680, failed 0, pending 1, ignored 7.", + "artifacts/upstream-benchmark/javascript-npm-test-current.txt and a live rerun both report 312 passing and 1 pending.", + "artifacts/upstream-benchmark/rust-cargo-test-current.txt and a live rerun both report Rust summaries totaling 368 passing and 7 ignored.", + "UPSTREAM_TEST_MANIFEST.json records 688 total entries, 680 active entries, 680 ported-passing active entries, 1 ported-pending-passing entry, 7 ported-ignored-passing entries, and 0 todo-unported entries.", + "The one pending JavaScript row is sync_test.ts:672 should sync three nodes; it is mapped by an optional PHP parity test.", + "The seven ignored Rust doctests are all storage parse/document doctest rows and are mapped by optional PHP parity tests." + ], + "recommendedStatusCorrection": "No correction needed for HEAD 6fc76de. Keep totalKnownUpstreamTests 680 and registeredTotalUpstreamTests 688. The 724 static estimate is stale and should not be used unless a future upstream runtime run reports additional active or registered rows." +} diff --git a/src/wp-includes/automerge-php/CONTRARIAN_UPSTREAM_TEST_AUDIT.md b/src/wp-includes/automerge-php/CONTRARIAN_UPSTREAM_TEST_AUDIT.md new file mode 100644 index 0000000000000..ead8858800a6c --- /dev/null +++ b/src/wp-includes/automerge-php/CONTRARIAN_UPSTREAM_TEST_AUDIT.md @@ -0,0 +1,104 @@ +# Contrarian Upstream Test Audit: Automerge + +Generated: 2026-05-23T00:23:00Z + +## Verdict + +`corrected-runtime-denominator`: the old May 21 static estimate of 724 upstream +tests is superseded by current runtime upstream runner evidence. + +Current Automerge PHP status is not relying on the old fallback denominator. It +reports: + +- Active upstream parity: `680/680` +- Registered manifest parity: `688/688` +- Optional upstream-pending coverage: `1/1` +- Optional upstream-ignored coverage: `7/7` +- WordPress scenarios: `10/10` + +I do not find missing rows against the current runtime-derived manifest. The +old 724 estimate should not be used as the active denominator. + +## Upstream Checkout + +- Upstream submodule: `upstream/automerge` +- Pinned commit: `44cd91582bd3ed9af05ef1a7843bb1074ad11112` +- JavaScript package: `upstream/automerge/javascript/package.json` +- Rust workspace: `upstream/automerge/rust/Cargo.toml` + +## Runner Evidence + +Canonical commands: + +```bash +cd upstream/automerge/javascript && npm test +cd upstream/automerge/rust && cargo test -p automerge +``` + +The current runtime evidence is recorded in: + +- `UPSTREAM_BENCHMARK.json` +- `UPSTREAM_BENCHMARK.md` +- `UPSTREAM_TEST_MANIFEST.json` +- `artifacts/upstream-benchmark/javascript-npm-test-current.txt` +- `artifacts/upstream-benchmark/rust-cargo-test-current.txt` + +I reran both canonical test commands during this audit. Results matched the +stored benchmark evidence: + +| Surface | Active Passing | Pending/Ignored | Registered | Result | +| --- | ---: | ---: | ---: | --- | +| JavaScript mocha suite | 312 | 1 pending | 313 | passed | +| Rust `automerge` crate | 368 | 7 ignored | 375 | passed | +| Combined runtime manifest | 680 | 8 | 688 | passed | + +`UPSTREAM_TEST_MANIFEST.json` agrees with those counts: + +- `activeTotal: 680` +- `registeredTotal: 688` +- `portedPassing: 680` +- `portedPendingPassing: 1` +- `portedIgnoredPassing: 7` +- `todoUnported: 0` + +## What Changed Since The May 21 Audit + +The May 21 audit used static grep-style counting because JavaScript and Rust +upstream runners were blocked in that environment. That estimate counted 724 +entries. The current repo now has installed JS dependencies and a working Rust +toolchain, and `UPSTREAM.md` explicitly says the static estimate is superseded. + +The current denominator is based on actual upstream runner output, not a static +scan. Runtime active tests are the correct progress denominator. Pending and +ignored upstream rows remain registered separately and are also mapped by +optional PHP parity tests. + +## Optional Rows + +The one JavaScript pending row is mapped: + +- `upstream/automerge/javascript/test/sync_test.ts:672` `should sync three nodes` + +The seven Rust ignored doctests are mapped: + +- `storage::document::Document::parse` +- `storage::parse` +- `storage::parse` line 56 +- `storage::parse` line 69 +- `storage::parse::Input::split` +- `storage::parse::Split::remaining` +- `storage::parse::range_of` + +## Recommendation + +Keep the current status model: + +- `totalKnownUpstreamTests: 680` +- `mappedUpstreamTests: 680` +- `registeredTotalUpstreamTests: 688` +- `registeredMappedUpstreamTests: 688` + +Do not restore the stale 724 denominator unless a future upstream runner +execution reports additional active or registered rows. If that happens, the +manifest should identify the exact new upstream row IDs before changing the +coordinator progress report. diff --git a/src/wp-includes/automerge-php/PORTING_STATUS.json b/src/wp-includes/automerge-php/PORTING_STATUS.json new file mode 100644 index 0000000000000..a470a7652737e --- /dev/null +++ b/src/wp-includes/automerge-php/PORTING_STATUS.json @@ -0,0 +1,6310 @@ +{ + "library": "automerge", + "phase": "runtime-active-upstream-parity", + "passPercent": 100, + "mappedPassPercent": 100, + "passingTests": 680, + "failingTests": 0, + "skippedTests": 0, + "mappedUpstreamTests": 680, + "registeredMappedUpstreamTests": 688, + "registeredMappedPassingTests": 688, + "optionalMappedUpstreamTests": 8, + "optionalMappedPassingTests": 8, + "optionalPendingMappedTests": 1, + "optionalPendingPassingTests": 1, + "optionalIgnoredMappedTests": 7, + "optionalIgnoredPassingTests": 7, + "totalKnownUpstreamTests": 680, + "registeredTotalUpstreamTests": 688, + "unmappedKnownUpstreamTests": 0, + "upstreamSuiteBreakdown": { + "benchmarkArtifact": "UPSTREAM_BENCHMARK.json", + "manifestArtifact": "UPSTREAM_TEST_MANIFEST.json", + "countMethod": "runtime-runner-counts", + "javascriptMocha": { + "mapped": 312, + "passing": 312, + "knownActive": 312, + "registered": 313, + "pending": 1, + "pendingMapped": 1, + "pendingPassing": 1 + }, + "rustAutomerge": { + "mapped": 368, + "passing": 368, + "knownActive": 368, + "registered": 375, + "ignored": 7, + "ignoredMapped": 7, + "ignoredPassing": 7 + }, + "combined": { + "mapped": 680, + "passing": 680, + "knownActive": 680, + "registered": 688 + } + }, + "wordpressScenariosPassing": 10, + "wordpressScenariosTotal": 10, + "currentTask": "Runtime-active and registered upstream parity reached; no unmapped active, pending, or ignored manifest rows remain.", + "denominatorNote": "The older static 724 audit has been superseded by successful runtime upstream runs: 312 JavaScript passing plus 1 pending, and 368 Rust passing plus 7 ignored. Active parity uses 680; the registered manifest total is 688.", + "blocker": null, + "upstream": { + "url": "https://github.com/automerge/automerge", + "submodulePath": "upstream/automerge", + "referenceCommit": "44cd91582bd3ed9af05ef1a7843bb1074ad11112", + "canonicalJavascriptTestCommand": "cd upstream/automerge/javascript && npm test", + "canonicalRustCoreTestCommand": "cd upstream/automerge/rust && cargo test -p automerge" + }, + "lastTestCommand": "composer test", + "lastTestAt": "2026-05-25T23:51:28+00:00", + "lastCommit": null, + "tests": [ + { + "name": "init clone and free creates independent PHP document values", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 12, + "test": "should init clone and free" + }, + "passed": true + }, + { + "name": "basic view materializes a document at specific heads", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 21, + "test": "should be able to make a view with specifc heads" + }, + "passed": true + }, + { + "name": "basic clone of a view can be changed independently", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 33, + "test": "should allow you to change a clone of a view" + }, + "passed": true + }, + { + "name": "legacy initialization starts as an empty map", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 23, + "test": "should initially be an empty map" + }, + "passed": true + }, + { + "name": "legacy initialization accepts an existing object", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 28, + "test": "should allow instantiating from an existing object" + }, + "passed": true + }, + { + "name": "legacy initialization merges an object initialized with from", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 34, + "test": "should allow merging of an object initialized with `from`" + }, + "passed": true + }, + { + "name": "legacy initialization preserves actor id from from", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 40, + "test": "should allow passing an actorId when instantiating from an existing object" + }, + "passed": true + }, + { + "name": "legacy initialization accepts an empty object", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 46, + "test": "accepts an empty object as initial state" + }, + "passed": true + }, + { + "name": "legacy initialization converts array input to root map entries", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 51, + "test": "accepts an array as initial state, but converts it to an object" + }, + "passed": true + }, + { + "name": "legacy initialization converts string input to character entries", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 57, + "test": "accepts strings as initial values, but treats them as an array of characters" + }, + "passed": true + }, + { + "name": "legacy initialization ignores numeric initial values", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 63, + "test": "ignores numbers provided as initial values" + }, + "passed": true + }, + { + "name": "legacy initialization ignores boolean initial values", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 69, + "test": "ignores booleans provided as initial values" + }, + "passed": true + }, + { + "name": "root map set/read materializes PHP array in insertion order", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 44, + "test": "handle basic set and read on root object" + }, + "passed": true + }, + { + "name": "legacy sequential changes do not mutate the input document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 85, + "test": "should not mutate objects" + }, + "passed": true + }, + { + "name": "legacy changes expose the last local change", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 91, + "test": "changes should be retrievable" + }, + "passed": true + }, + { + "name": "legacy sequential repeated assignment records no conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 135, + "test": "should not register any conflicts on repeated assignment" + }, + "passed": true + }, + { + "name": "legacy changes group multiple root assignments", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 144, + "test": "should group several changes" + }, + "passed": true + }, + { + "name": "legacy sequential repeated writes keep the final value", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 189, + "test": "should allow repeated reading and writing of values" + }, + "passed": true + }, + { + "name": "legacy sequential same-field writes have no conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 201, + "test": "should not record conflicts when writing the same field several times within one change" + }, + "passed": true + }, + { + "name": "legacy sequential no-op change returns the same document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 211, + "test": "should return the unchanged state object if nothing changed" + }, + "passed": true + }, + { + "name": "legacy sequential existing-value updates are ignored", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 216, + "test": "should ignore field updates that write the existing value" + }, + "passed": true + }, + { + "name": "legacy root existing-value update resolves conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 222, + "test": "should not ignore field updates that resolve a conflict" + }, + "passed": true + }, + { + "name": "legacy sequential list existing-value updates are ignored", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 237, + "test": "should ignore list element updates that write the existing value" + }, + "passed": true + }, + { + "name": "legacy list existing-value update resolves conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 243, + "test": "should not ignore list element updates that resolve a conflict" + }, + "passed": true + }, + { + "name": "legacy concurrent updates of the same list element expose conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1134, + "test": "should detect concurrent updates of the same list element" + }, + "passed": true + }, + { + "name": "legacy conflicting list element maps retain nested changes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1176, + "test": "should handle changes within a conflicting list element" + }, + "passed": true + }, + { + "name": "legacy concurrent insertions at different list positions merge cleanly", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1222, + "test": "should handle concurrent insertions at different list positions" + }, + "passed": true + }, + { + "name": "legacy concurrent insertions at the same list position merge cleanly", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1232, + "test": "should handle concurrent insertions at the same list position" + }, + "passed": true + }, + { + "name": "legacy concurrent assignment and deletion of a map entry is add-wins", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1247, + "test": "should handle concurrent assignment and deletion of a map entry" + }, + "passed": true + }, + { + "name": "legacy concurrent assignment and deletion of a list element is add-wins", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1260, + "test": "should handle concurrent assignment and deletion of a list element" + }, + "passed": true + }, + { + "name": "legacy insertion after a concurrently deleted list element survives", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1278, + "test": "should handle insertion after a deleted list element" + }, + "passed": true + }, + { + "name": "legacy concurrent deletion of the same list element is idempotent", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1293, + "test": "should handle concurrent deletion of the same element" + }, + "passed": true + }, + { + "name": "legacy concurrent deletion of different list elements removes both originals", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1305, + "test": "should handle concurrent deletion of different elements" + }, + "passed": true + }, + { + "name": "legacy concurrent sequence insertions at the same position stay grouped", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1355, + "test": "should not interleave sequence insertions at the same position" + }, + "passed": true + }, + { + "name": "legacy list insertion works when the inserting actor id is greater", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1374, + "test": "should handle insertion by greater actor ID" + }, + "passed": true + }, + { + "name": "legacy list insertion works when the inserting actor id is lesser", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1383, + "test": "should handle insertion by lesser actor ID" + }, + "passed": true + }, + { + "name": "legacy list insertion before an existing element is actor-id independent", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1392, + "test": "should handle insertion regardless of actor ID" + }, + "passed": true + }, + { + "name": "legacy causal list prepends maintain insertion order", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1399, + "test": "should make insertion order consistent with causality" + }, + "passed": true + }, + { + "name": "legacy delete higher in a tree wins over a concurrent subtree update", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1317, + "test": "should handle concurrent updates at different levels of the tree" + }, + "passed": true + }, + { + "name": "legacy updates inside concurrently deleted objects do not resurrect the object", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1343, + "test": "should handle updates of concurrently deleted objects" + }, + "passed": true + }, + { + "name": "legacy change API sanity-checks document root arguments", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 259, + "test": "should sanity-check arguments" + }, + "passed": true + }, + { + "name": "legacy change API rejects nested and outdated change attempts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 271, + "test": "should not allow nested change blocks" + }, + "passed": true + }, + { + "name": "legacy change API rejects reusing the same base document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 288, + "test": "should not allow the same base document to be used for multiple changes" + }, + "passed": true + }, + { + "name": "error handling throws an exception object for invalid list assignment", + "mappedFrom": { + "file": "javascript/test/error.ts", + "line": 5, + "test": "proxy handler throws an error, not a string" + }, + "passed": true + }, + { + "name": "legacy sequential clone can diverge independently", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 295, + "test": "should allow a document to be cloned" + }, + "passed": true + }, + { + "name": "legacy sequential object assign style replacement works", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 306, + "test": "should work with Object.assign merges" + }, + "passed": true + }, + { + "name": "legacy sequential supports Date objects in maps", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 316, + "test": "should support Date objects in maps" + }, + "passed": true + }, + { + "name": "legacy sequential supports Date objects in lists", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 325, + "test": "should support Date objects in lists" + }, + "passed": true + }, + { + "name": "legacy patch callback receives list assignment patches", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 334, + "test": "should call patchCallback if supplied" + }, + "passed": true + }, + { + "name": "legacy initialization-level patch callback receives string assignment patches", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 374, + "test": "should call a patchCallback set up on document initialisation" + }, + "passed": true + }, + { + "name": "legacy load invokes a supplied patch callback", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1570, + "test": "should call patchCallback if supplied to load" + }, + "passed": true + }, + { + "name": "legacy applyChanges invokes a supplied patch callback", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1780, + "test": "should call patchCallback if supplied when applying changes" + }, + "passed": true + }, + { + "name": "legacy applyChanges merges multiple string-list changes into one patch batch", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1820, + "test": "should merge multiple applied changes into one patch" + }, + "passed": true + }, + { + "name": "legacy applyChanges invokes patch callback registered on document initialization", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1838, + "test": "should call a patchCallback registered on doc initialisation" + }, + "passed": true + }, + { + "name": "root map delete keeps only the live property across repeated changes", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 66, + "test": "should be able to insert and delete a large number of properties" + }, + "passed": true + }, + { + "name": "basic isAutomerge detects only native documents", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 82, + "test": "can detect an automerge doc with isAutomerge()" + }, + "passed": true + }, + { + "name": "basic freeze option recursively marks document materialization frozen", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 94, + "test": "it should recursively freeze the document if requested" + }, + "passed": true + }, + { + "name": "legacy freeze rejects direct document mutation outside change", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 158, + "test": "should freeze objects if desired" + }, + "passed": true + }, + { + "name": "basic root sets over many changes preserve scalar and typed values", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 132, + "test": "handle basic sets over many changes" + }, + "passed": true + }, + { + "name": "basic object ids return null for scalar-like values", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 558, + "test": "should return null for scalar values" + }, + "passed": true + }, + { + "name": "basic object id returns root for a native document", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 567, + "test": "should return _root for the root object" + }, + "passed": true + }, + { + "name": "basic object ids distinguish containers from text scalars", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 571, + "test": "should return non-null for map, list, text, and objects" + }, + "passed": true + }, + { + "name": "block split inserts a block boundary and exposes spans", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 18, + "test": "can split a block" + }, + "passed": true + }, + { + "name": "rust marks in spans cross block markers", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:marks-in-spans-cross-block-markers", + "file": "rust/automerge/tests/block_tests.rs", + "test": "marks_in_spans_cross_block_markers" + }, + "passed": true + }, + { + "name": "rust block diff emits block insertion updates", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:diff-emits-block-updates", + "file": "rust/automerge/tests/block_tests.rs", + "test": "diff_emits_block_updates" + }, + "passed": true + }, + { + "name": "rust merge produces block insertion diffs", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:merge-produces-block-insertion-diffs", + "file": "rust/automerge/tests/block_tests.rs", + "test": "merge_produces_block_insertion_diffs" + }, + "passed": true + }, + { + "name": "block join removes a block boundary", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 61, + "test": "can join a block" + }, + "passed": true + }, + { + "name": "block updateSpans replaces text and all block spans", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 81, + "test": "allows updating all blocks at once" + }, + "passed": true + }, + { + "name": "rust block updateSpans noop leaves diff cursor quiet", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-noop", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_blocks_noop" + }, + "passed": true + }, + { + "name": "rust block updateSpans changes block properties", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-change-block-properties", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_blocks_change_block_properties" + }, + "passed": true + }, + { + "name": "rust block updateSpans updates text", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-text", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_blocks_updates_text" + }, + "passed": true + }, + { + "name": "rust block updateSpans updates marks", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-marks", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_blocks_updates_marks" + }, + "passed": true + }, + { + "name": "rust block updateSpans updates text and blocks together", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-text-and-blocks-at-once", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_blocks_updates_text_and_blocks_at_once" + }, + "passed": true + }, + { + "name": "rust block metadata supports complex text-like properties", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:text-complex-block-properties", + "file": "rust/automerge/tests/block_tests.rs", + "test": "text_complex_block_properties" + }, + "passed": true + }, + { + "name": "block updateSpans emits ImmutableString parent insert patches", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 135, + "test": "emits insert patches with ImmutableString for attribute updatese" + }, + "passed": true + }, + { + "name": "rust updateSpans deletes a block attribute list entry", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-delete-attribute", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_spans_delete_attribute" + }, + "passed": true + }, + { + "name": "block updateSpans materializes marks in spans", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 179, + "test": "should update marks" + }, + "passed": true + }, + { + "name": "rust updateSpans diffs existing and desired marks", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-diffs-marks", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_spans_diffs_marks" + }, + "passed": true + }, + { + "name": "rust diff marks expands an existing mark range", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-expands", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_expands" + }, + "passed": true + }, + { + "name": "rust diff marks contracts an existing mark range", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-contracts", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_contracts" + }, + "passed": true + }, + { + "name": "rust diff marks shifts a mark position", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-shifts-position", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_shifts_position" + }, + "passed": true + }, + { + "name": "rust diff marks splits one mark into two ranges", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-splits", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_splits" + }, + "passed": true + }, + { + "name": "rust diff marks merges adjacent same-valued marks", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:adjacent-marks-merge", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "adjacent_marks_merge" + }, + "passed": true + }, + { + "name": "rust diff marks keeps separated same-valued marks apart", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:adjacent-marks-stay-separate", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "adjacent_marks_stay_separate" + }, + "passed": true + }, + { + "name": "rust diff marks keeps different adjacent marks separate", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:different-adjacent-marks", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "different_adjacent_marks" + }, + "passed": true + }, + { + "name": "rust diff marks removes empty spans between equal marks", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:empty-spans-between-marks", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "empty_spans_between_marks" + }, + "passed": true + }, + { + "name": "rust diff marks updateSpans is idempotent", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:idempotent-update-spans", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "idempotent_update_spans" + }, + "passed": true + }, + { + "name": "rust diff marks preserves many marks on the same text", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:many-marks-on-same-text", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "many_marks_on_same_text" + }, + "passed": true + }, + { + "name": "rust diff marks ignores marks on empty strings", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-on-empty-string", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_on_empty_string" + }, + "passed": true + }, + { + "name": "rust diff marks applies marks to whitespace text", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-on-whitespace", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_on_whitespace" + }, + "passed": true + }, + { + "name": "rust diff marks changes a color mark value", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-value-changes-color", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_value_changes_color" + }, + "passed": true + }, + { + "name": "rust diff marks changes a link mark value", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-value-changes-link-url", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_value_changes_link_url" + }, + "passed": true + }, + { + "name": "rust diff marks changes a mark value type", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-value-type-changes", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_value_type_changes" + }, + "passed": true + }, + { + "name": "rust diff marks alternates mark changes", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:alternating-mark-changes", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "alternating_mark_changes" + }, + "passed": true + }, + { + "name": "rust diff marks handles complex unicode text", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:complex-unicode-text", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "complex_unicode_text" + }, + "passed": true + }, + { + "name": "rust diff marks applies marks to emoji graphemes", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-on-emoji", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "marks_on_emoji" + }, + "passed": true + }, + { + "name": "rust diff marks applies marks to accented text", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-on-combining-characters", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "marks_on_combining_characters" + }, + "passed": true + }, + { + "name": "rust diff marks spans a mark across a block marker", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-spans-across-block", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_spans_across_block" + }, + "passed": true + }, + { + "name": "rust diff marks ends a mark at a block boundary", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-ends-at-block-boundary", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "mark_ends_at_block_boundary" + }, + "passed": true + }, + { + "name": "rust diff marks changes block properties while preserving marks", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:block-properties-change-with-marks", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "block_properties_change_with_marks" + }, + "passed": true + }, + { + "name": "rust diff marks survive block updates", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-survive-block-updates", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "marks_survive_block_updates" + }, + "passed": true + }, + { + "name": "rust diff marks boundary insert expands existing mark", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:update-spans-which-inserts-at-the-end-of-expand-mark-doesnt-generate-mark-changes", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes" + }, + "passed": true + }, + { + "name": "rust diff marks removes all text from a marked span", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:removing-all-text-from-marked-span", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "removing_all_text_from_marked_span" + }, + "passed": true + }, + { + "name": "rust diff marks preserves nested marks", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:nested-marks", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "nested_marks" + }, + "passed": true + }, + { + "name": "rust diff marks unmarks part of a range", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:unmark-part-of-range", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "unmark_part_of_range" + }, + "passed": true + }, + { + "name": "rust diff marks creates gaps when unmarking", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:unmark-creates-gaps", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "unmark_creates_gaps" + }, + "passed": true + }, + { + "name": "rust diff marks combines different expand behaviors", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:multiple-marks-different-expand-behaviors", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "multiple_marks_different_expand_behaviors" + }, + "passed": true + }, + { + "name": "rust diff marks respects expand none at boundaries", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-with-expand-none-at-boundaries", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "marks_with_expand_none_at_boundaries" + }, + "passed": true + }, + { + "name": "rust diff marks supports marked block content", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:block-with-marked-content", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "block_with_marked_content" + }, + "passed": true + }, + { + "name": "rust diff marks preserves same-name different values", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-with-different-values-same-name", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "marks_with_different_values_same_name" + }, + "passed": true + }, + { + "name": "rust diff marks updates spans to only blocks", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:update-spans-with-only-blocks", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "update_spans_with_only_blocks" + }, + "passed": true + }, + { + "name": "rust diff marks removes one overlapping mark and keeps another", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:overlapping-marks-remove-one-keep-other", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "overlapping_marks_remove_one_keep_other" + }, + "passed": true + }, + { + "name": "rust diff marks changes overlapping mark boundaries", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:overlapping-marks-change-boundaries", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "overlapping_marks_change_boundaries" + }, + "passed": true + }, + { + "name": "rust diff marks adds a third overlapping mark", + "mappedFrom": { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:overlapping-marks-add-third-mark", + "file": "rust/automerge/tests/diff_marks.rs", + "test": "overlapping_marks_add_third_mark" + }, + "passed": true + }, + { + "name": "block updateSpans honors default mark expand none", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 200, + "test": "allows configuring the default expand value of created marks" + }, + "passed": true + }, + { + "name": "block updateSpans honors per mark expand override", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 225, + "test": "should allow overriding the default expand on a per mark basis" + }, + "passed": true + }, + { + "name": "rust block updateSpans accepts upstream after expand config", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-uses-expand-config", + "file": "rust/automerge/tests/block_tests.rs", + "test": "update_spans_uses_expand_config" + }, + "passed": true + }, + { + "name": "rust marked splice replacement keeps only expanding marks", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:test-splice-with-mark", + "file": "rust/automerge/tests/block_tests.rs", + "test": "test_splice_with_mark" + }, + "passed": true + }, + { + "name": "rust deleted marked text does not mark a later insertion at the same index", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:test-mark-behavior-on-delete-insert", + "file": "rust/automerge/tests/block_tests.rs", + "test": "test_mark_behavior_on_delete_insert" + }, + "passed": true + }, + { + "name": "rust spans consolidate deleted mark gaps", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-which-are-empty-due-to-deleted-marks", + "file": "rust/automerge/tests/block_tests.rs", + "test": "spans_consolidates_marks_which_are_empty_due_to_deleted_marks" + }, + "passed": true + }, + { + "name": "rust spans consolidate a fully deleted mark interval", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-with-deleted-marks-followed-by-empty-marks", + "file": "rust/automerge/tests/block_tests.rs", + "test": "spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks" + }, + "passed": true + }, + { + "name": "rust spans consolidate a fully deleted trailing mark interval", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-with-empty-marks-followed-by-deleted-marks", + "file": "rust/automerge/tests/block_tests.rs", + "test": "spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks" + }, + "passed": true + }, + { + "name": "block updateSpans accepts partial or omitted mark config", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 250, + "test": "should allow omitting any part of the update spans config" + }, + "passed": true + }, + { + "name": "block attributes preserve ImmutableString attrs when loading blocks", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 291, + "test": "when loading blocks" + }, + "passed": true + }, + { + "name": "block attributes preserve ImmutableString metadata in spans", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 308, + "test": "when loading spans" + }, + "passed": true + }, + { + "name": "block updateSpans metadata-only changes remain editable", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 331, + "test": "updates the document even if the only change was to a block attribute" + }, + "passed": true + }, + { + "name": "block view shows historical marks", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 371, + "test": "should show historical marks" + }, + "passed": true + }, + { + "name": "rust spans at heads respect historical marks", + "mappedFrom": { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:marks-on-spans-respect-heads", + "file": "rust/automerge/tests/block_tests.rs", + "test": "marks_on_spans_respect_heads" + }, + "passed": true + }, + { + "name": "block attributes preserve very small floating point values", + "mappedFrom": { + "file": "javascript/test/block_test.ts", + "line": 388, + "test": "can allow small values in block attributes" + }, + "passed": true + }, + { + "name": "legacy root handles single-property assignment", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 429, + "test": "should handle single-property assignment" + }, + "passed": true + }, + { + "name": "legacy root allows floating point values", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 437, + "test": "should allow floating-point values" + }, + "passed": true + }, + { + "name": "legacy root handles multi-property assignment", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 442, + "test": "should handle multi-property assignment" + }, + "passed": true + }, + { + "name": "legacy root handles property deletion", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 451, + "test": "should handle root property deletion" + }, + "passed": true + }, + { + "name": "legacy root follows JavaScript delete behavior", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 464, + "test": "should follow JS delete behavior" + }, + "passed": true + }, + { + "name": "legacy root allows property type changes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 482, + "test": "should allow the type of a property to be changed" + }, + "passed": true + }, + { + "name": "legacy root allows empty string keys", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 493, + "test": "should not error on empty string keys" + }, + "passed": true + }, + { + "name": "legacy root rejects unsupported assignment datatypes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 502, + "test": "should not allow assignment of unsupported datatypes" + }, + "passed": true + }, + { + "name": "legacy nested maps expose Automerge-shaped object ids", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 521, + "test": "should assign an objectId to nested maps" + }, + "passed": true + }, + { + "name": "legacy nested maps handle assignment of a nested property", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 533, + "test": "should handle assignment of a nested property" + }, + "passed": true + }, + { + "name": "legacy nested maps handle assignment of an object literal", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 547, + "test": "should handle assignment of an object literal" + }, + "passed": true + }, + { + "name": "legacy nested maps handle multiple nested property assignment", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 559, + "test": "should handle assignment of multiple nested properties" + }, + "passed": true + }, + { + "name": "legacy nested maps handle arbitrary-depth nesting", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 574, + "test": "should handle arbitrary-depth nesting" + }, + "passed": true + }, + { + "name": "legacy nested maps allow object replacement", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 588, + "test": "should allow an old object to be replaced with a new one" + }, + "passed": true + }, + { + "name": "legacy nested maps allow primitive map type changes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 615, + "test": "should allow fields to be changed between primitive and nested map" + }, + "passed": true + }, + { + "name": "legacy nested maps reject references to existing document objects", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 627, + "test": "should not allow several references to the same map object" + }, + "passed": true + }, + { + "name": "legacy nested maps reject object-copying idioms", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 647, + "test": "should not allow object-copying idioms" + }, + "passed": true + }, + { + "name": "legacy nested maps handle deletion of properties within a map", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 664, + "test": "should handle deletion of properties within a map" + }, + "passed": true + }, + { + "name": "legacy nested maps handle deletion of references to a map", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 676, + "test": "should handle deletion of references to a map" + }, + "passed": true + }, + { + "name": "legacy lists allow elements to be inserted", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 690, + "test": "should allow elements to be inserted" + }, + "passed": true + }, + { + "name": "legacy lists handle assignment of a list literal", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 704, + "test": "should handle assignment of a list literal" + }, + "passed": true + }, + { + "name": "legacy lists handle deletion of list elements", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 738, + "test": "should handle deletion of list elements" + }, + "passed": true + }, + { + "name": "legacy lists accept only numeric indexes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 718, + "test": "should only allow numeric indexes" + }, + "passed": true + }, + { + "name": "legacy lists handle assignment of individual list indexes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 753, + "test": "should handle assignment of individual list indexes" + }, + "passed": true + }, + { + "name": "legacy concurrent list insertions with equal counters use reverse actor order", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 767, + "test": "concurrent edits insert in reverse actorid order if counters equal" + }, + "passed": true + }, + { + "name": "legacy concurrent list insertions with different counters use reverse counter order", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 778, + "test": "concurrent edits insert in reverse counter order if different" + }, + "passed": true + }, + { + "name": "legacy lists treat out-by-one assignment as insertion", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 790, + "test": "should treat out-by-one assignment as insertion" + }, + "passed": true + }, + { + "name": "legacy lists reject out-of-range assignment", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 800, + "test": "should not allow out-of-range assignment" + }, + "passed": true + }, + { + "name": "legacy lists allow bulk assignment of multiple list indexes", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 807, + "test": "should allow bulk assignment of multiple list indexes" + }, + "passed": true + }, + { + "name": "legacy lists handle nested objects", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 822, + "test": "should handle nested objects" + }, + "passed": true + }, + { + "name": "legacy lists handle nested lists", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 848, + "test": "should handle nested lists" + }, + "passed": true + }, + { + "name": "legacy lists handle replacement of the entire list", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 911, + "test": "should handle replacement of the entire list" + }, + "passed": true + }, + { + "name": "legacy lists allow assignment to change the type of a list element", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 932, + "test": "should allow assignment to change the type of a list element" + }, + "passed": true + }, + { + "name": "legacy lists allow list creation and assignment in one logical callback", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 964, + "test": "should allow list creation and assignment in the same change callback" + }, + "passed": true + }, + { + "name": "legacy lists allow adding and removing list elements in one logical callback", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 972, + "test": "should allow adding and removing list elements in the same change callback" + }, + "passed": true + }, + { + "name": "legacy lists handle arbitrary-depth nesting", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 994, + "test": "should handle arbitrary-depth nesting" + }, + "passed": true + }, + { + "name": "legacy lists reject references to existing document objects", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1010, + "test": "should not allow several references to the same list object" + }, + "passed": true + }, + { + "name": "legacy nested maps and lists handle deep mixed mutations", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 870, + "test": "should handle deep nesting" + }, + "passed": true + }, + { + "name": "legacy save/load allows a reloaded list to be mutated", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1547, + "test": "should allow a reloaded list to be mutated" + }, + "passed": true + }, + { + "name": "legacy save/load reloads a large inserted list", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1555, + "test": "should reload a document containing deflated columns" + }, + "passed": true + }, + { + "name": "legacy counters can be incremented and deleted from nested maps", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1033, + "test": "should allow deleting counters from maps" + }, + "passed": true + }, + { + "name": "legacy concurrent merge preserves updates of different root properties", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1070, + "test": "should merge concurrent updates of different properties" + }, + "passed": true + }, + { + "name": "legacy counters add concurrent increments of the same property", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1083, + "test": "should add concurrent increments of the same property" + }, + "passed": true + }, + { + "name": "legacy counters add increments only to the values they precede", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1097, + "test": "should add increments only to the values they precede" + }, + "passed": true + }, + { + "name": "legacy concurrent same-field updates retain conflict values", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1119, + "test": "should detect concurrent updates of the same field" + }, + "passed": true + }, + { + "name": "legacy concurrent same-field assignments retain different-type conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1151, + "test": "should handle assignment conflicts of different types" + }, + "passed": true + }, + { + "name": "legacy concurrent root conflicts keep nested map changes on the assigned object id", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1164, + "test": "should handle changes within a conflicting map field" + }, + "passed": true + }, + { + "name": "basic conflict values are stable regardless of merge order", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 526, + "test": "should obtain the same conflicts, regardless of merge order" + }, + "passed": true + }, + { + "name": "basic merge conflict materialization matches after save and load", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 342, + "test": "it should handle conflicts the same in merges as with loads" + }, + "passed": true + }, + { + "name": "conflict inspection returns detached map values", + "mappedFrom": { + "file": "javascript/test/conflicts.ts", + "line": 5, + "test": "should not allow updating values inside a conflict outside of the change callback" + }, + "passed": true + }, + { + "name": "conflicted map values can be updated together", + "mappedFrom": { + "file": "javascript/test/conflicts.ts", + "line": 56, + "test": "should allow updating values inside a conflicted map" + }, + "passed": true + }, + { + "name": "conflicted list element map values can be updated together", + "mappedFrom": { + "file": "javascript/test/conflicts.ts", + "line": 100, + "test": "should allow updating values inside a conflicted list" + }, + "passed": true + }, + { + "name": "legacy concurrent nested map assignments stay conflicted at the root", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1195, + "test": "should not merge concurrently assigned nested maps" + }, + "passed": true + }, + { + "name": "legacy root assignment clears prior same-field conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1210, + "test": "should clear conflicts after assigning a new value" + }, + "passed": true + }, + { + "name": "root map overwrites retain the last scalar value", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 183, + "test": "handle overwrites to values" + }, + "passed": true + }, + { + "name": "object values materialize through root map set", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 200, + "test": "handle set with object value" + }, + "passed": true + }, + { + "name": "list creation materializes an empty PHP list", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 210, + "test": "handle simple list creation" + }, + "passed": true + }, + { + "name": "simple list values can be read and replaced", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 216, + "test": "handle simple lists" + }, + "passed": true + }, + { + "name": "basic getChanges/applyChanges replays simple list assignment", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 238, + "test": "handle simple lists" + }, + "passed": true + }, + { + "name": "basic getChanges/applyChanges replays text splice at arbitrary root key", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 248, + "test": "handle text" + }, + "passed": true + }, + { + "name": "text insertion exposes length, index access, and string materialization", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 17, + "test": "should support insertion" + }, + "passed": true + }, + { + "name": "rust text encoding length counts code points code units and graphemes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:length", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "length" + }, + "passed": true + }, + { + "name": "rust text encoding splice maps code point code unit and grapheme indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:splice-text", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "splice_text" + }, + "passed": true + }, + { + "name": "rust text encoding get maps code point code unit and grapheme indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:get", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "get" + }, + "passed": true + }, + { + "name": "rust text encoding put maps code point code unit and grapheme indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:put", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "put" + }, + "passed": true + }, + { + "name": "rust text encoding insert maps code point code unit and grapheme indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:insert", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "insert" + }, + "passed": true + }, + { + "name": "rust text encoding delete maps code point code unit and grapheme indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:delete", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "delete" + }, + "passed": true + }, + { + "name": "rust text encoding mark maps code point code unit and grapheme ranges", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:mark", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "mark" + }, + "passed": true + }, + { + "name": "rust text encoding unmark maps code point code unit and grapheme ranges", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:unmark", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "unmark" + }, + "passed": true + }, + { + "name": "rust text encoding splitBlock maps code point code unit and grapheme indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:split-block", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "split_block" + }, + "passed": true + }, + { + "name": "rust text encoding cursors report encoded positions after edits", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:cursors", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "cursors" + }, + "passed": true + }, + { + "name": "rust text encoding putSeq patches report encoded indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-put-seq", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "patch_put_seq" + }, + "passed": true + }, + { + "name": "rust text encoding insert patches report encoded indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-insert", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "patch_insert" + }, + "passed": true + }, + { + "name": "rust text encoding splice patches report encoded indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-splice-text", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "patch_splice_text" + }, + "passed": true + }, + { + "name": "rust text encoding delete patches report encoded indexes", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-delete", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "patch_delete" + }, + "passed": true + }, + { + "name": "rust text encoding mark patches report encoded ranges", + "mappedFrom": { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-mark", + "file": "rust/automerge/tests/text_encoding.rs", + "test": "patch_mark" + }, + "passed": true + }, + { + "name": "text deletion removes the visible character at the splice range", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 25, + "test": "should support deletion" + }, + "passed": true + }, + { + "name": "text zero-length splice after deletion is a no-op", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 36, + "test": "should support implicit and explicit deletion" + }, + "passed": true + }, + { + "name": "rust UTF-16 splice deletion inside multibyte characters snaps after the character", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:deleting-in-middle-of-multibyte-char-moves-the-cursor-to-after-the-character", + "file": "rust/automerge/tests/text.rs", + "test": "deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character" + }, + "passed": true + }, + { + "name": "rust legacy multi-character text op splices on op boundaries", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:splicing-into-multibyte-characters", + "file": "rust/automerge/tests/text.rs", + "test": "splicing_into_multibyte_characters" + }, + "passed": true + }, + { + "name": "rust text diff common prefix length matches byte ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-utils-test-common-prefix-len", + "file": "rust/automerge/src/text_diff/utils.rs", + "test": "text_diff::utils::test_common_prefix_len" + }, + "passed": true + }, + { + "name": "rust text diff common suffix length matches byte ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-utils-test-common-suffix-len", + "file": "rust/automerge/src/text_diff/utils.rs", + "test": "text_diff::utils::test_common_suffix_len" + }, + "passed": true + }, + { + "name": "rust clock covers op ids by actor counter", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:clock-tests-covers", + "file": "rust/automerge/src/clock.rs", + "test": "clock::tests::covers" + }, + "passed": true + }, + { + "name": "rust clock comparison handles ordering and concurrency", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:clock-tests-comparison", + "file": "rust/automerge/src/clock.rs", + "test": "clock::tests::comparison" + }, + "passed": true + }, + { + "name": "rust change graph derives sequence clocks for heads", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:change-graph-tests-clock-by-heads", + "file": "rust/automerge/src/change_graph.rs", + "test": "change_graph::tests::clock_by_heads" + }, + "passed": true + }, + { + "name": "rust change graph removes ancestors of selected heads", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:change-graph-tests-remove-ancestors", + "file": "rust/automerge/src/change_graph.rs", + "test": "change_graph::tests::remove_ancestors" + }, + "passed": true + }, + { + "name": "rust columnar unsigned LEB128 size examples match encoded byte counts", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-ulebsize-examples", + "file": "rust/automerge/src/columnar/encoding/leb128.rs", + "test": "columnar::encoding::leb128::tests::ulebsize_examples" + }, + "passed": true + }, + { + "name": "rust columnar signed LEB128 size examples match encoded byte counts", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-lebsize-examples", + "file": "rust/automerge/src/columnar/encoding/leb128.rs", + "test": "columnar::encoding::leb128::tests::lebsize_examples" + }, + "passed": true + }, + { + "name": "rust columnar unsigned LEB128 property boundary sizes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-test-ulebsize", + "file": "rust/automerge/src/columnar/encoding/leb128.rs", + "test": "columnar::encoding::leb128::tests::test_ulebsize" + }, + "passed": true + }, + { + "name": "rust columnar signed LEB128 property boundary sizes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-test-lebsize", + "file": "rust/automerge/src/columnar/encoding/leb128.rs", + "test": "columnar::encoding::leb128::tests::test_lebsize" + }, + "passed": true + }, + { + "name": "ignored Rust storage parse doc example consumes three bytes and leaves remaining input", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-17", + "file": "rust/automerge/src/storage/parse.rs", + "test": "automerge/src/storage/parse.rs - basic usage doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "ignored Rust storage parse split doc example separates chunk bytes from remaining input", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-input-a-split-line-264", + "file": "rust/automerge/src/storage/parse.rs", + "test": "automerge/src/storage/parse.rs - Input::split doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "ignored Rust storage parse range_of doc example records the consumed byte range", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-range-of-line-561", + "file": "rust/automerge/src/storage/parse.rs", + "test": "automerge/src/storage/parse.rs - range_of doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "ignored Rust storage parse split remaining doc example exposes post-split backing bytes", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-split-remaining-line-325", + "file": "rust/automerge/src/storage/parse.rs", + "test": "automerge/src/storage/parse.rs - Split::remaining doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "ignored Rust storage document parse doc example materializes a parsed fixture", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-document-rs-storage-document-document-a-parse-line-54", + "file": "rust/automerge/src/storage/document.rs", + "test": "automerge/src/storage/document.rs - Document::parse doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "ignored Rust storage parse error doc example wraps parser-specific errors", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-56", + "file": "rust/automerge/src/storage/parse.rs", + "test": "automerge/src/storage/parse.rs - ParseError::Error doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "ignored Rust storage parse lift doc example maps application errors and preserves incomplete errors", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-69", + "file": "rust/automerge/src/storage/parse.rs", + "test": "automerge/src/storage/parse.rs - ParseError::lift doc example", + "upstreamStatus": "ignored" + }, + "passed": true + }, + { + "name": "rust storage parser decodes canonical u64 LEB128 values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-u64", + "file": "rust/automerge/src/storage/parse/leb128.rs", + "test": "storage::parse::leb128::tests::leb_128_u64" + }, + "passed": true + }, + { + "name": "rust storage parser decodes canonical u32 LEB128 values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-u32", + "file": "rust/automerge/src/storage/parse/leb128.rs", + "test": "storage::parse::leb128::tests::leb_128_u32" + }, + "passed": true + }, + { + "name": "rust storage parser decodes canonical i64 LEB128 values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-i64", + "file": "rust/automerge/src/storage/parse/leb128.rs", + "test": "storage::parse::leb128::tests::leb_128_i64" + }, + "passed": true + }, + { + "name": "rust change encoding expanded change round-trips raw storage bytes", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-change-encoding-expanded-change-round-trip", + "file": "rust/automerge/tests/test.rs", + "test": "test_change_encoding_expanded_change_round_trip" + }, + "passed": true + }, + { + "name": "rust compressed change chunks inflate to the original raw change bytes", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-compressed-changes", + "file": "rust/automerge/tests/test.rs", + "test": "test_compressed_changes" + }, + "passed": true + }, + { + "name": "rust compressed document columns ignore deflate bit while preserving storage order", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-compressed-doc-cols", + "file": "rust/automerge/tests/test.rs", + "test": "test_compressed_doc_cols" + }, + "passed": true + }, + { + "name": "rust load rejects overlong counter LEB128 fixture encodings", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:overlong-leb", + "file": "rust/automerge/tests/test.rs", + "test": "overlong_leb" + }, + "passed": true + }, + { + "name": "rust load materializes two-change storage fixtures", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load", + "file": "rust/automerge/tests/test.rs", + "test": "load" + }, + "passed": true + }, + { + "name": "rust load rejects fuzz-crasher fixture bytes", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:fuzz-crashers", + "file": "rust/automerge/tests/test.rs", + "test": "fuzz_crashers" + }, + "passed": true + }, + { + "name": "rust ExId root serializes and parses as the root object id", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:exid-tests-test-root-roundtrip", + "file": "rust/automerge/src/exid.rs", + "test": "exid::tests::test_root_roundtrip" + }, + "passed": true + }, + { + "name": "rust ExId non-root object ids round-trip through bytes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:exid-tests-objid-roundtrip", + "file": "rust/automerge/src/exid.rs", + "test": "exid::tests::objid_roundtrip" + }, + "passed": true + }, + { + "name": "rust 64-bit object ids do not truncate to root", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:obj-id-64bits", + "file": "rust/automerge/tests/test.rs", + "test": "obj_id_64bits" + }, + "passed": true + }, + { + "name": "rust storage column specifications encode id type and deflate bit", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-columns-column-specification-tests-column-spec-encoding", + "file": "rust/automerge/src/storage/columns/column_specification.rs", + "test": "storage::columns::column_specification::tests::column_spec_encoding" + }, + "passed": true + }, + { + "name": "rust sequence tree push appends values at the back", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-push-back", + "file": "rust/automerge/src/sequence_tree.rs", + "test": "sequence_tree::tests::push_back" + }, + "passed": true + }, + { + "name": "rust sequence tree insert accepts interior and prefix insertions", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert", + "file": "rust/automerge/src/sequence_tree.rs", + "test": "sequence_tree::tests::insert" + }, + "passed": true + }, + { + "name": "rust sequence tree repeated book insertions remain iterable", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert-book", + "file": "rust/automerge/src/sequence_tree.rs", + "test": "sequence_tree::tests::insert_book" + }, + "passed": true + }, + { + "name": "rust sequence tree indexed insertion matches vector behavior", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert-book-vec", + "file": "rust/automerge/src/sequence_tree.rs", + "test": "sequence_tree::tests::insert_book_vec" + }, + "passed": true + }, + { + "name": "rust sequence tree proptest insert workload matches vector behavior", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-proptest-insert", + "file": "rust/automerge/src/sequence_tree.rs", + "test": "sequence_tree::tests::proptest_insert" + }, + "passed": true + }, + { + "name": "rust sequence tree proptest remove workload matches vector behavior", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-proptest-remove", + "file": "rust/automerge/src/sequence_tree.rs", + "test": "sequence_tree::tests::proptest_remove" + }, + "passed": true + }, + { + "name": "rust columnar boolean encoder round-trips boolean runs", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-boolean-tests-encode-decode-bools", + "file": "rust/automerge/src/columnar/encoding/boolean.rs", + "test": "columnar::encoding::boolean::tests::encode_decode_bools" + }, + "passed": true + }, + { + "name": "rust columnar RLE integer encoder round-trips runs and literals", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-rle-int-round-trip", + "file": "rust/automerge/src/columnar/column_range/rle.rs", + "test": "columnar::column_range::rle::tests::rle_int_round_trip" + }, + "passed": true + }, + { + "name": "rust columnar RLE integer encoder preserves inserted value position", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-rle-int-insert", + "file": "rust/automerge/src/columnar/column_range/rle.rs", + "test": "columnar::column_range::rle::tests::rle_int_insert" + }, + "passed": true + }, + { + "name": "rust columnar RLE integer splice replaces optional integer ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-splice-ints", + "file": "rust/automerge/src/columnar/column_range/rle.rs", + "test": "columnar::column_range::rle::tests::splice_ints" + }, + "passed": true + }, + { + "name": "rust columnar RLE string splice replaces optional string ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-splice-strings", + "file": "rust/automerge/src/columnar/column_range/rle.rs", + "test": "columnar::column_range::rle::tests::splice_strings" + }, + "passed": true + }, + { + "name": "rust columnar delta encoder round-trips the upstream regression vector", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-bugbug", + "file": "rust/automerge/src/columnar/column_range/delta.rs", + "test": "columnar::column_range::delta::tests::bugbug" + }, + "passed": true + }, + { + "name": "rust columnar delta encoder round-trips optional absolute integers", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-encode-decode-delta", + "file": "rust/automerge/src/columnar/column_range/delta.rs", + "test": "columnar::column_range::delta::tests::encode_decode_delta" + }, + "passed": true + }, + { + "name": "rust columnar delta splice preserves optional absolute integer order", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-splice-delta", + "file": "rust/automerge/src/columnar/column_range/delta.rs", + "test": "columnar::column_range::delta::tests::splice_delta" + }, + "passed": true + }, + { + "name": "rust columnar OpId-list encoder round-trips grouped operation ids", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-opid-list-tests-encode-decode-opid-list", + "file": "rust/automerge/src/columnar/column_range/opid_list.rs", + "test": "columnar::column_range::opid_list::tests::encode_decode_opid_list" + }, + "passed": true + }, + { + "name": "rust columnar OpId-list splice replaces grouped operation ids", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-opid-list-tests-splice-opid-list", + "file": "rust/automerge/src/columnar/column_range/opid_list.rs", + "test": "columnar::column_range::opid_list::tests::splice_opid_list" + }, + "passed": true + }, + { + "name": "rust storage change op columns round-trip mixed operations", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-change-change-op-columns-tests-test-encode-decode-change-ops", + "file": "rust/automerge/src/storage/change/change_op_columns.rs", + "test": "storage::change::change_op_columns::tests::test_encode_decode_change_ops" + }, + "passed": true + }, + { + "name": "rust op_set2 ValueMeta accumulator tracks raw value offsets", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-meta-tests-column-data-meta-group", + "file": "rust/automerge/src/op_set2/meta.rs", + "test": "op_set2::meta::tests::column_data_meta_group" + }, + "passed": true + }, + { + "name": "rust op_set2 object id iterator seeks exact and missing ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-op-iter-tests-obj-id-iter-seek", + "file": "rust/automerge/src/op_set2/op_set/op_iter.rs", + "test": "op_set2::op_set::op_iter::tests::obj_id_iter_seek" + }, + "passed": true + }, + { + "name": "rust op_set2 skip iterator selects op ids by counter and successor ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-op-iter-tests-skip-op-ids", + "file": "rust/automerge/src/op_set2/op_set/op_iter.rs", + "test": "op_set2::op_set::op_iter::tests::skip_op_ids" + }, + "passed": true + }, + { + "name": "rust op_set2 mixed workload survives save load and change replay", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-suspend-resume-op-set-iter", + "file": "rust/automerge/src/op_set2/op_set.rs", + "test": "op_set2::op_set::tests::suspend_resume_op_set_iter" + }, + "passed": true + }, + { + "name": "rust op_set2 mark index encodes start and end operation ids", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-mark-index-tests-column-data-delta-simple", + "file": "rust/automerge/src/op_set2/op_set/mark_index.rs", + "test": "op_set2::op_set::mark_index::tests::column_data_delta_simple" + }, + "passed": true + }, + { + "name": "rust op_set2 column data iterates saved operation rows", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-basic-iteration", + "file": "rust/automerge/src/op_set2/op_set.rs", + "test": "op_set2::op_set::tests::column_data_basic_iteration" + }, + "passed": true + }, + { + "name": "rust op_set2 column data iter_range scopes rows to one object", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-iter-range", + "file": "rust/automerge/src/op_set2/op_set.rs", + "test": "op_set2::op_set::tests::column_data_iter_range" + }, + "passed": true + }, + { + "name": "rust op_set2 column data op iterators group visible and top rows", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-op-iterators", + "file": "rust/automerge/src/op_set2/op_set.rs", + "test": "op_set2::op_set::tests::column_data_op_iterators" + }, + "passed": true + }, + { + "name": "rust op_set2 parents reports invisible deleted list object parent", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-parents-tests-test-invisible-parents", + "file": "rust/automerge/src/op_set2/parents.rs", + "test": "op_set2::parents::tests::test_invisible_parents" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply matches iterative map changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-map-batch-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::map_batch_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply matches iterative list changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-list-batch-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::list_batch_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply matches iterative text changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-text-batch-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::text_batch_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply handles many concurrent list puts", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-put-batch-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::multi_put_batch_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply handles many concurrent list inserts", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-insert-batch-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::multi_insert_batch_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply handles repeated concurrent list updates", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-update-batch-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::multi_update_batch_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply preserves map key conflicts", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-map-key-conflict", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::map_key_conflict" + }, + "passed": true + }, + { + "name": "rust op_set2 batch apply preserves list element conflicts", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-list-element-conflict", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::list_element_conflict" + }, + "passed": true + }, + { + "name": "rust op_set2 isolation conflict workload applies without index corruption", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-conflicts-with-isolate", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::conflicts_with_isolate" + }, + "passed": true + }, + { + "name": "rust op_set2 fuzz batch list apply matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-list-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::fuzz_batch_list_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 fuzz batch nested map apply matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map1-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::fuzz_batch_map1_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 fuzz batch sparse nested map apply matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map2-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::fuzz_batch_map2_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 fuzz batch nested counter map apply matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map-counter-apply", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::fuzz_batch_map_counter_apply" + }, + "passed": true + }, + { + "name": "rust op_set2 batch list patch diff matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-list-patch", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::batch_list_patch" + }, + "passed": true + }, + { + "name": "rust op_set2 batch text patch diff matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-text-patch", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::batch_text_patch" + }, + "passed": true + }, + { + "name": "rust op_set2 batch counter list patch diff matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-counter-list-patch", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::batch_counter_list_patch" + }, + "passed": true + }, + { + "name": "rust op_set2 batch marks patch diff matches iterative application", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-marks-patch", + "file": "rust/automerge/src/op_set2/change/batch.rs", + "test": "op_set2::change::batch::tests::batch_marks_patch" + }, + "passed": true + }, + { + "name": "rust columnar value ULEB metadata decodes unsigned scalars", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-value-uleb", + "file": "rust/automerge/src/columnar/column_range/value.rs", + "test": "columnar::column_range::value::tests::test_value_uleb" + }, + "passed": true + }, + { + "name": "rust columnar value initialization round-trips scalar values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-initialize-splice", + "file": "rust/automerge/src/columnar/column_range/value.rs", + "test": "columnar::column_range::value::tests::test_initialize_splice" + }, + "passed": true + }, + { + "name": "rust columnar value row-wise and column-wise encoders match", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-encode-row-wise-and-columnwise-equal", + "file": "rust/automerge/src/columnar/column_range/value.rs", + "test": "columnar::column_range::value::tests::encode_row_wise_and_columnwise_equal" + }, + "passed": true + }, + { + "name": "rust columnar value splice replaces scalar value ranges", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-splice-values", + "file": "rust/automerge/src/columnar/column_range/value.rs", + "test": "columnar::column_range::value::tests::test_splice_values" + }, + "passed": true + }, + { + "name": "rust Myers text diff finds the middle snake", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-myers-test-find-middle-snake", + "file": "rust/automerge/src/text_diff/myers.rs", + "test": "text_diff::myers::test_find_middle_snake" + }, + "passed": true + }, + { + "name": "concurrent text insertion deterministically preserves both branches", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 48, + "test": "should handle concurrent insertion" + }, + "passed": true + }, + { + "name": "rust text updateText merges independent replacements", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:simple-update-text", + "file": "rust/automerge/tests/text.rs", + "test": "simple_update_text" + }, + "passed": true + }, + { + "name": "rust text updateText merges multicodepoint graphemes", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:update-text-big-ole-graphemes", + "file": "rust/automerge/tests/text.rs", + "test": "update_text_big_ole_graphemes" + }, + "passed": true + }, + { + "name": "rust text updateText at historical heads integrates later insertions", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:update-text-change-at", + "file": "rust/automerge/tests/text.rs", + "test": "update_text_change_at" + }, + "passed": true + }, + { + "name": "rust text can create separated marks after insertion", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:mark-created-after-insertion", + "file": "rust/automerge/tests/text.rs", + "test": "mark_created_after_insertion" + }, + "passed": true + }, + { + "name": "rust text local patches are segmented by marks", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:local-patches-created-for-marks", + "file": "rust/automerge/tests/text.rs", + "test": "local_patches_created_for_marks" + }, + "passed": true + }, + { + "name": "rust text consolidates zero-length mark spans", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:spans-are-consolidated-in-the-presence-of-zero-length-spans", + "file": "rust/automerge/tests/text.rs", + "test": "spans_are_consolidated_in_the_presence_of_zero_length_spans" + }, + "passed": true + }, + { + "name": "rust text empty marks before block markers do not repeat text", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:empty-marks-before-block-marker-dont-repeat-text", + "file": "rust/automerge/tests/text.rs", + "test": "empty_marks_before_block_marker_dont_repeat_text" + }, + "passed": true + }, + { + "name": "rust text marks property keeps spans consolidated", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:marks-are-okay", + "file": "rust/automerge/tests/text.rs", + "test": "marks_are_okay" + }, + "passed": true + }, + { + "name": "rust text removed marks do not appear in get marks", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:removed-marks-should-not-appear-in-get-marks", + "file": "rust/automerge/tests/text.rs", + "test": "removed_marks_should_not_appear_in_get_marks" + }, + "passed": true + }, + { + "name": "rust text incremental splices inherit active marks", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:incremental-splice-patches-include-marks", + "file": "rust/automerge/tests/text.rs", + "test": "incremental_splice_patches_include_marks" + }, + "passed": true + }, + { + "name": "rust text expand-both marks cover boundary insertions", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:expand-marks-are-reported-in-patches", + "file": "rust/automerge/tests/text.rs", + "test": "expand_marks_are_reported_in_patches" + }, + "passed": true + }, + { + "name": "rust text remote expand-after mark merge matches local insertion", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:test-remote-patches-for-marks-with-expand-after", + "file": "rust/automerge/tests/text.rs", + "test": "test_remote_patches_for_marks_with_expand_after" + }, + "passed": true + }, + { + "name": "rust mark patches at end of text survive incremental load", + "mappedFrom": { + "id": "rust:tests-test-mark-patches-rs-target-debug-deps-test-mark-patches-5c7d6b43cf1dbe46:mark-patches-at-end-of-text", + "file": "rust/automerge/tests/test_mark_patches.rs", + "test": "mark_patches_at_end_of_text" + }, + "passed": true + }, + { + "name": "rust text insertions after noexpand spans stay unmarked", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:insertions-after-noexpand-spans-are-not-marked", + "file": "rust/automerge/tests/text.rs", + "test": "insertions_after_noexpand_spans_are_not_marked" + }, + "passed": true + }, + { + "name": "rust text noexpand terminal mark excludes following insertions", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:noexpand-marks-at-the-end-of-text-should-not-emit-marked-patches-on-following-insertions", + "file": "rust/automerge/tests/text.rs", + "test": "noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions" + }, + "passed": true + }, + { + "name": "rust text far-end insertions after crossed marks stay unmarked", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:marks-which-cross-optree-boundaries-are-not-double-counted-in-splice-patches", + "file": "rust/automerge/tests/text.rs", + "test": "marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches" + }, + "passed": true + }, + { + "name": "text and scalar root operations can happen in the same document state", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 60, + "test": "should handle text and other ops in the same change" + }, + "passed": true + }, + { + "name": "document JSON encoding serializes text as a plain string", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 70, + "test": "should serialize to JSON as a simple string" + }, + "passed": true + }, + { + "name": "text can be modified after assignment to a document root key", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 77, + "test": "should allow modification after an object is assigned to a document" + }, + "passed": true + }, + { + "name": "public text splice rejects documents outside a change callback", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 87, + "test": "should not allow modification outside of a change callback" + }, + "passed": true + }, + { + "name": "unicode text values survive root materialization", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 115, + "test": "should support unicode when creating text" + }, + "passed": true + }, + { + "name": "text splice can target string values nested in arrays", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 122, + "test": "should allow splicing into text in arrays" + }, + "passed": true + }, + { + "name": "from initializes text values with string length and index access", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 95, + "test": "should initialize text in Automerge.from()" + }, + "passed": true + }, + { + "name": "from encodes the initial root as one replayable native change", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 105, + "test": "should encode the initial value as a change" + }, + "passed": true + }, + { + "name": "updateText merges non-overlapping replacements from concurrent actors", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 132, + "test": "should calculate a diff when updating text" + }, + "passed": true + }, + { + "name": "updateText handles multi-character grapheme clusters", + "mappedFrom": { + "file": "javascript/test/text_test.ts", + "line": 148, + "test": "should handle multi character grapheme clusters" + }, + "passed": true + }, + { + "name": "change metadata since heads matches decoded native changes", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 300, + "test": "get change metadata" + }, + "passed": true + }, + { + "name": "basic load can explicitly allow a change with missing dependencies", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 327, + "test": "should work in unstable" + }, + "passed": true + }, + { + "name": "rust save retains orphaned changes until missing deps arrive", + "mappedFrom": { + "id": "rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:save-orphaned-changes", + "file": "rust/automerge/tests/test_save_load_orphans.rs", + "test": "save_orphaned_changes" + }, + "passed": true + }, + { + "name": "rust save can discard orphaned changes", + "mappedFrom": { + "id": "rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:discard-orphans", + "file": "rust/automerge/tests/test_save_load_orphans.rs", + "test": "discard_orphans" + }, + "passed": true + }, + { + "name": "rust loading standalone incremental change without deps throws", + "mappedFrom": { + "id": "rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:load-incremental-change-without-deps-throws", + "file": "rust/automerge/tests/test_save_load_orphans.rs", + "test": "load_incremental_change_without_deps_throws" + }, + "passed": true + }, + { + "name": "change metadata defaults to the current timestamp", + "mappedFrom": { + "file": "javascript/test/change_time.ts", + "line": 7, + "test": "should default to current timestamp" + }, + "passed": true + }, + { + "name": "change metadata accepts a user-provided timestamp", + "mappedFrom": { + "file": "javascript/test/change_time.ts", + "line": 18, + "test": "should allow user provided timestamp" + }, + "passed": true + }, + { + "name": "change metadata can explicitly record no timestamp", + "mappedFrom": { + "file": "javascript/test/change_time.ts", + "line": 27, + "test": "should allow no timestamp" + }, + "passed": true + }, + { + "name": "emptyChange metadata defaults to the current timestamp", + "mappedFrom": { + "file": "javascript/test/change_time.ts", + "line": 37, + "test": "should default to current timestamp" + }, + "passed": true + }, + { + "name": "emptyChange metadata accepts a user-provided timestamp", + "mappedFrom": { + "file": "javascript/test/change_time.ts", + "line": 48, + "test": "should allow user provided timestamp" + }, + "passed": true + }, + { + "name": "emptyChange metadata can explicitly record no timestamp", + "mappedFrom": { + "file": "javascript/test/change_time.ts", + "line": 57, + "test": "should allow no timestamp" + }, + "passed": true + }, + { + "name": "emptyChange advances document heads with a new hash", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 396, + "test": "should generate a hash" + }, + "passed": true + }, + { + "name": "legacy emptyChange appends an empty change to history", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 402, + "test": "should append an empty change to the history" + }, + "passed": true + }, + { + "name": "legacy emptyChange references merged dependencies", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 413, + "test": "should reference dependencies" + }, + "passed": true + }, + { + "name": "legacy save/load restores an empty document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1413, + "test": "should save and restore an empty document" + }, + "passed": true + }, + { + "name": "legacy save/load assigns a new actor id by default", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1418, + "test": "should generate a new random actor ID" + }, + "passed": true + }, + { + "name": "legacy save/load accepts a custom actor id", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1432, + "test": "should allow a custom actor ID to be set" + }, + "passed": true + }, + { + "name": "legacy save/load reconstitutes nested list and map data", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1437, + "test": "should reconstitute complex datatypes" + }, + "passed": true + }, + { + "name": "legacy save/load keeps map keys containing at-signs", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1448, + "test": "should save and load maps with @ symbols in the keys" + }, + "passed": true + }, + { + "name": "legacy save/load reconstitutes root conflicts", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1457, + "test": "should reconstitute conflicts" + }, + "passed": true + }, + { + "name": "legacy save/load reconstitutes element id counters", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1480, + "test": "should reconstitute element ID counters" + }, + "passed": true + }, + { + "name": "legacy history returns an empty list for an empty document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1596, + "test": "should return an empty history for an empty document" + }, + "passed": true + }, + { + "name": "legacy history exposes past document snapshots", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1600, + "test": "should make past document states accessible" + }, + "passed": true + }, + { + "name": "legacy history exposes change messages", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1618, + "test": "should make change messages accessible" + }, + "passed": true + }, + { + "name": "hasHeads returns true for heads present in the document history", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 685, + "test": "should return true if the document in question has all the heads" + }, + "passed": true + }, + { + "name": "hasHeads returns false for heads absent from another document", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 692, + "test": "should return false if the document does not have the heads" + }, + "passed": true + }, + { + "name": "basic topo history traversal returns merged change hashes in order", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 703, + "test": "should return the correct history" + }, + "passed": true + }, + { + "name": "basic inspectChange returns decoded change metadata", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 730, + "test": "should return a decoded representation of the change" + }, + "passed": true + }, + { + "name": "basic stats reports native change and operation counts", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 765, + "test": "should return stats about the document" + }, + "passed": true + }, + { + "name": "native save/load round trip hydrates a materialized document", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 578, + "test": "can load a doc without checking the heads" + }, + "passed": true + }, + { + "name": "legacy changes API returns an empty list for an empty document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1639, + "test": "should return an empty list on an empty document" + }, + "passed": true + }, + { + "name": "legacy changes API returns an empty list when nothing changed", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1644, + "test": "should return an empty list when nothing changed" + }, + "passed": true + }, + { + "name": "legacy changes API does nothing when applying an empty change list", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1652, + "test": "should do nothing when applying an empty list of changes" + }, + "passed": true + }, + { + "name": "legacy changes API returns all changes compared to an empty document", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1660, + "test": "should return all changes when compared to an empty document" + }, + "passed": true + }, + { + "name": "legacy changes API reconstructs a document copy from scratch", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1673, + "test": "should allow a document copy to be reconstructed from scratch" + }, + "passed": true + }, + { + "name": "legacy changes API returns changes since the last given version", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1687, + "test": "should return changes since the last given version" + }, + "passed": true + }, + { + "name": "legacy changes API incrementally applies changes since the last version", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1702, + "test": "should incrementally apply changes since the last given version" + }, + "passed": true + }, + { + "name": "legacy changes API handles updates to a list element", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1719, + "test": "should handle updates to a list element" + }, + "passed": true + }, + { + "name": "legacy changes API handles updates to a text object", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1734, + "test": "should handle updates to a text object" + }, + "passed": true + }, + { + "name": "legacy changes API reports missing dependencies for out-of-order applyChanges", + "mappedFrom": { + "file": "javascript/test/legacy_tests.ts", + "line": 1764, + "test": "should report missing dependencies with out-of-order applyChanges" + }, + "passed": true + }, + { + "name": "sync protocol sends an empty-data sync message", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 54, + "test": "should send a sync message implying no local data" + }, + "passed": true + }, + { + "name": "rust sync message encoding round-trips an empty message", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-encode-decode-empty-message", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::encode_decode_empty_message" + }, + "passed": true + }, + { + "name": "sync protocol does not keep replying for two empty documents", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 68, + "test": "should not reply after the first round if we have no data as well" + }, + "passed": true + }, + { + "name": "sync protocol equal heads do not need a reply message", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 91, + "test": "repos with equal heads do not need a reply message" + }, + "passed": true + }, + { + "name": "rust sync message generation is quiet after first send", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-sync-message-twice-does-nothing", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::generate_sync_message_twice_does_nothing" + }, + "passed": true + }, + { + "name": "rust sync first response is sent even with no missing changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-first-response-is-some-even-if-no-changes", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::first_response_is_some_even_if_no_changes" + }, + "passed": true + }, + { + "name": "sync protocol offers all changes to an empty peer", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 127, + "test": "n1 should offer all changes to n2 when starting from nothing" + }, + "passed": true + }, + { + "name": "sync protocol synchronizes peers when one has commits", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 141, + "test": "should sync peers where one has commits the other does not" + }, + "passed": true + }, + { + "name": "sync protocol converges with prior sync state", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 155, + "test": "should work with prior sync state" + }, + "passed": true + }, + { + "name": "sync protocol records shared heads after synchronization", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 403, + "test": "should ensure non-empty state after sync" + }, + "passed": true + }, + { + "name": "sync protocol resyncs after peer crash with older data", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 417, + "test": "should re-sync after one node crashed with data loss" + }, + "passed": true + }, + { + "name": "sync protocol resyncs after peer data loss without reconnect", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 459, + "test": "should resync after one node experiences data loss without disconnecting" + }, + "passed": true + }, + { + "name": "sync protocol converges diverged documents without prior sync state", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 350, + "test": "should work without prior sync state" + }, + "passed": true + }, + { + "name": "sync protocol converges diverged documents with prior sync state", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 374, + "test": "should work with prior sync state" + }, + "passed": true + }, + { + "name": "sync protocol handles changes concurrent to last sync heads", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 482, + "test": "should handle changes concurrent to the last sync heads" + }, + "passed": true + }, + { + "name": "sync protocol handles branching and merging histories", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 518, + "test": "should handle histories with lots of branching and merging" + }, + "passed": true + }, + { + "name": "rust sync handles lots of branching and merging", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-lots-of-branching-and-merging", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::should_handle_lots_of_branching_and_merging" + }, + "passed": true + }, + { + "name": "sync protocol converges two nodes without connection reset", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 657, + "test": "should sync two nodes without connection reset" + }, + "passed": true + }, + { + "name": "sync protocol converges two nodes with connection reset", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 664, + "test": "should sync two nodes with connection reset" + }, + "passed": true + }, + { + "name": "sync protocol explicitly recovers a false-positive advertised head", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 565, + "test": "should handle a false-positive head" + }, + "passed": true + }, + { + "name": "rust sync handles a false-positive advertised head", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-false-positive-head", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::should_handle_false_positive_head" + }, + "passed": true + }, + { + "name": "sync protocol allows explicitly requesting a false-positive hash", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 818, + "test": "should allow the false-positive hash to be explicitly requested" + }, + "passed": true + }, + { + "name": "sync protocol resolves a false-positive dependency without an extra request", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 701, + "test": "should not require an additional request when a false-positive depends on a true-negative" + }, + "passed": true + }, + { + "name": "pending sync protocol surfaces an unresolved false-positive branch to a third node", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 672, + "test": "should sync three nodes", + "upstreamStatus": "pending" + }, + "passed": true + }, + { + "name": "sync protocol handles chains of false-positive dependencies", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 769, + "test": "should handle chains of false-positives" + }, + "passed": true + }, + { + "name": "rust sync handles chains of false-positive dependencies", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-chains-of-false-positives", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::should_handle_chains_of_false_positives" + }, + "passed": true + }, + { + "name": "sync protocol suppresses duplicate in-flight changes from multiple have filters", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 882, + "test": "should allow multiple Bloom filters" + }, + "passed": true + }, + { + "name": "rust sync v1 messages can drive a v2 receiver", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-from-v1-to-v2", + "file": "rust/automerge/src/sync/v1_compat_test/mod.rs", + "test": "sync::v1_compat_test::sync_from_v1_to_v2" + }, + "passed": true + }, + { + "name": "rust sync v2 messages can drive a v1 receiver", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-from-v2-to-v1", + "file": "rust/automerge/src/sync/v1_compat_test/mod.rs", + "test": "sync::v1_compat_test::sync_from_v2_to_v1" + }, + "passed": true + }, + { + "name": "rust sync v1 to v2 accepts a large compressed-change payload", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-v1-to-v2-with-compressed-change", + "file": "rust/automerge/src/sync/v1_compat_test/mod.rs", + "test": "sync::v1_compat_test::sync_v1_to_v2_with_compressed_change" + }, + "passed": true + }, + { + "name": "sync protocol does not generate messages once synced", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 175, + "test": "should not generate messages once synced" + }, + "passed": true + }, + { + "name": "sync protocol works regardless of which peer initiates later exchange", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 327, + "test": "should work regardless of who initiates the exchange" + }, + "passed": true + }, + { + "name": "sync protocol preserves independent diverged peer changes", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 219, + "test": "should allow simultaneous messages during synchronization" + }, + "passed": true + }, + { + "name": "rust sync allows simultaneous independent messages and acknowledgements", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-allow-simultaneous-messages-during-synchronisation", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::should_allow_simultaneous_messages_during_synchronisation" + }, + "passed": true + }, + { + "name": "rust sync in-flight acknowledgement does not hide a later local change", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-in-flight-logic-should-not-sabotage-concurrent-changes", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::in_flight_logic_should_not_sabotage_concurrent_changes" + }, + "passed": true + }, + { + "name": "rust sync sends whole document when first message has no heads", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-if-first-message-has-no-heads-and-supports-v2-message-send-whole-doc", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc" + }, + "passed": true + }, + { + "name": "rust sync does not reply when empty peers have no data after first round", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-not-reply-if-we-have-no-data-after-first-round", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::should_not_reply_if_we_have_no_data_after_first_round" + }, + "passed": true + }, + { + "name": "sync protocol reports when peers have our changes", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 1096, + "test": "should report whether the other end has our changes" + }, + "passed": true + }, + { + "name": "sync protocol continues sending unacknowledged local changes", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 299, + "test": "should assume sent changes were recieved until we hear otherwise" + }, + "passed": true + }, + { + "name": "sync read-only state does not apply incoming changes", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 1139, + "test": "should not apply incoming changes when read-only" + }, + "passed": true + }, + { + "name": "sync read-only state is reported to the peer", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 1155, + "test": "should discover peer read-only status" + }, + "passed": true + }, + { + "name": "sync read-only state can switch back to read-write", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 1172, + "test": "should allow switching from read-only to read-write" + }, + "passed": true + }, + { + "name": "rust read-only sync does not apply incoming changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-sync-does-not-apply-incoming-changes", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_sync_does_not_apply_incoming_changes" + }, + "passed": true + }, + { + "name": "rust sync peer discovers remote read-only status", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-peer-discovers-remote-read-only-status", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::peer_discovers_remote_read_only_status" + }, + "passed": true + }, + { + "name": "rust read-only empty peer syncs with data peer without applying data", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-empty-peer-syncs-with-data-peer", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_empty_peer_syncs_with_data_peer" + }, + "passed": true + }, + { + "name": "rust both read-only peers ignore each other changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-peers-read-only", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_peers_read_only" + }, + "passed": true + }, + { + "name": "rust both read-only peers converge to no further messages", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-peers-read-only-converges-to-none", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_peers_read_only_converges_to_none" + }, + "passed": true + }, + { + "name": "rust both read-only peers exchange updated heads for one local writer", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-one-makes-local-changes", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_read_only_one_makes_local_changes" + }, + "passed": true + }, + { + "name": "rust both read-only peers exchange updated heads for both local writers", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-both-make-local-changes", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_read_only_both_make_local_changes" + }, + "passed": true + }, + { + "name": "rust both read-only peers converge after simultaneous local changes during sync", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-simultaneous-changes-during-sync", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_read_only_simultaneous_changes_during_sync" + }, + "passed": true + }, + { + "name": "rust read-only publisher sends new local changes between sync rounds", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-new-changes-between-sync-rounds", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_peer_new_changes_between_sync_rounds" + }, + "passed": true + }, + { + "name": "rust read-only peer can publish a local change made during sync", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-concurrent-changes-during-sync", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_peer_concurrent_changes_during_sync" + }, + "passed": true + }, + { + "name": "rust sync omits changes when peer is known read-only", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-changes-not-sent-to-read-only-peer", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::changes_not_sent_to_read_only_peer" + }, + "passed": true + }, + { + "name": "rust sync advertises read-only after mode change with an in-flight message", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-message-after-set-read-only-even-with-in-flight", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::generate_message_after_set_read_only_even_with_in_flight" + }, + "passed": true + }, + { + "name": "rust sync advertises read-write after read-only mode change with an in-flight message", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-message-after-set-read-only-false-even-with-in-flight", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::generate_message_after_set_read_only_false_even_with_in_flight" + }, + "passed": true + }, + { + "name": "rust sync switches read-only peer to read-write with old-peer empty-head fallback", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-to-read-write-with-old-peer", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::switch_to_read_write_with_old_peer" + }, + "passed": true + }, + { + "name": "rust both read-only peers exchange changes after simultaneous read-write toggle", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-read-only-to-read-write-simultaneously", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_toggle_read_only_to_read_write_simultaneously" + }, + "passed": true + }, + { + "name": "rust both read-only peers exchange original and new changes after read-write toggle", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-read-only-to-read-write-with-new-changes", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_toggle_read_only_to_read_write_with_new_changes" + }, + "passed": true + }, + { + "name": "rust both read-only peers exchange accumulated changes after multiple rounds", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-after-multiple-read-only-rounds", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::both_toggle_after_multiple_read_only_rounds" + }, + "passed": true + }, + { + "name": "rust read-only peer receives accumulated remote changes after switching read-write mid-session", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-only-to-read-write-mid-session", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::switch_read_only_to_read_write_mid_session" + }, + "passed": true + }, + { + "name": "rust read-write peer switched read-only publishes local changes but ignores remote ones", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-write-to-read-only-mid-session", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::switch_read_write_to_read_only_mid_session" + }, + "passed": true + }, + { + "name": "rust read-only peer receives all accumulated remote rounds after switching read-write", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-only-to-read-write-with-multiple-rounds", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::switch_read_only_to_read_write_with_multiple_rounds" + }, + "passed": true + }, + { + "name": "rust read-only mode can toggle multiple times while preserving withheld changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-toggle-read-only-multiple-times", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::toggle_read_only_multiple_times" + }, + "passed": true + }, + { + "name": "rust read-only publisher does not relay ignored consumer changes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-publisher-to-multiple-consumers", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_publisher_to_multiple_consumers" + }, + "passed": true + }, + { + "name": "rust triangle sync preserves read-only publisher state when changes arrive via two paths", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-triangle-changes-arrive-via-two-paths", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::triangle_changes_arrive_via_two_paths" + }, + "passed": true + }, + { + "name": "rust read-only fully connected triangle keeps publisher isolated while consumers converge", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-fully-connected-triangle", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_fully_connected_triangle" + }, + "passed": true + }, + { + "name": "rust stale shared heads after read-only sync do not corrupt direct publisher sync", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-stale-shared-heads-after-read-only-sync", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::stale_shared_heads_after_read_only_sync" + }, + "passed": true + }, + { + "name": "rust read-only peer handles same remote changes from two peers and continues publishing", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-receives-same-changes-from-two-peers", + "file": "rust/automerge/src/sync.rs", + "test": "sync::tests::read_only_peer_receives_same_changes_from_two_peers" + }, + "passed": true + }, + { + "name": "sync protocol sends explicitly requested known changes", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 956, + "test": "should allow any change to be requested" + }, + "passed": true + }, + { + "name": "sync protocol ignores requests for nonexistent changes", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 985, + "test": "should ignore requests for a nonexistent change" + }, + "passed": true + }, + { + "name": "sync protocol requests remaining heads after a subset response", + "mappedFrom": { + "file": "javascript/test/sync_test.ts", + "line": 1022, + "test": "should allow a subset of changes to be sent" + }, + "passed": true + }, + { + "name": "changeAt text splice merges with later text edits", + "mappedFrom": { + "file": "javascript/test/change_at.ts", + "line": 6, + "test": "should be able to change a doc at a prior state" + }, + "passed": true + }, + { + "name": "changeAt empty changes preserve forked document heads", + "mappedFrom": { + "file": "javascript/test/change_at.ts", + "line": 22, + "test": "should leave multiple heads intact on empty changes" + }, + "passed": true + }, + { + "name": "changeAt returns the heads of the change document", + "mappedFrom": { + "file": "javascript/test/change_at.ts", + "line": 47, + "test": "should return the heads of the change document from changeAt" + }, + "passed": true + }, + { + "name": "basic diff emits text patches between before and after heads", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 587, + "test": "can diff a document with before and hafter heads" + }, + "passed": true + }, + { + "name": "basic saveSince matches saveIncremental from the last incremental heads", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 606, + "test": "should be the same as saveIncremental since heads of the last saveIncremental" + }, + "passed": true + }, + { + "name": "extra API loadIncremental applies full and incremental native batches", + "mappedFrom": { + "file": "javascript/test/extra_api_tests.ts", + "line": 6, + "test": "should allow you to load incrementally" + }, + "passed": true + }, + { + "name": "bundle format saves selected changes and loads them incrementally", + "mappedFrom": { + "file": "javascript/test/bundle_test.ts", + "line": 5, + "test": "should allow saving and loading a bundle" + }, + "passed": true + }, + { + "name": "bundle format exposes inspectable changes by hash", + "mappedFrom": { + "file": "javascript/test/bundle_test.ts", + "line": 27, + "test": "should allow getting the list of changes in a bundle" + }, + "passed": true + }, + { + "name": "bundle format reports dependencies outside the selected changes", + "mappedFrom": { + "file": "javascript/test/bundle_test.ts", + "line": 56, + "test": "should show the dependencies of a bundle" + }, + "passed": true + }, + { + "name": "rust storage bundle preserves selected changes and loads into a fork", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-bundle-test-make-bundle", + "file": "rust/automerge/src/storage/bundle.rs", + "test": "storage::bundle::test::make_bundle" + }, + "passed": true + }, + { + "name": "patch callback exposes before and after heads", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 7, + "test": "should provide access to before and after states" + }, + "passed": true + }, + { + "name": "patch callback exposes before and after states for list deletion", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 27, + "test": "should provide correct before and after states when an array has a value deleted" + }, + "passed": true + }, + { + "name": "patch callback exposes before and after states for nested property deletion", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 49, + "test": "should provide correct before and after states when an object property has been removed" + }, + "passed": true + }, + { + "name": "patch diff returns insertion and container patches between heads", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 76, + "test": "should return a set of patches" + }, + "passed": true + }, + { + "name": "patch diff rejects invalid before and after heads", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 96, + "test": "should throw a nice exception if before or after are not an array" + }, + "passed": true + }, + { + "name": "patch diffPath supports nested map scopes and shallow recursion", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 120, + "test": "should allow diffing a sub-object" + }, + "passed": true + }, + { + "name": "patch diff reverses deletion of a string list value", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 201, + "test": "should correctly diff the reverse of deleting a string value on next" + }, + "passed": true + }, + { + "name": "patch changeAt style updates do not mix stale scalar content", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 225, + "test": "should produce correct patches during changeAt" + }, + "passed": true + }, + { + "name": "patches apply a map update to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 254, + "test": "should apply a map update" + }, + "passed": true + }, + { + "name": "patches apply a list update to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 267, + "test": "should apply a list update patch" + }, + "passed": true + }, + { + "name": "patches apply a list insertion to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 278, + "test": "should apply a list insertion patch" + }, + "passed": true + }, + { + "name": "patches apply a list deletion without length to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 289, + "test": "should apply a list deletion patch without length" + }, + "passed": true + }, + { + "name": "patches apply a list deletion with length to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 301, + "test": "should apply a list deletion patch with length" + }, + "passed": true + }, + { + "name": "patches apply a text splice to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 314, + "test": "should apply a text splice patch" + }, + "passed": true + }, + { + "name": "patches apply a text deletion without length to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 325, + "test": "should apply a text deletion patch without length" + }, + "passed": true + }, + { + "name": "patches apply a text deletion with length to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 335, + "test": "should apply a text deletion patch with length" + }, + "passed": true + }, + { + "name": "patches apply an increment patch to a document counter", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 346, + "test": "should apply an increment patch" + }, + "passed": true + }, + { + "name": "patches apply a mark patch to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 359, + "test": "should apply a mark patch" + }, + "passed": true + }, + { + "name": "patches apply an unmark patch to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 380, + "test": "should apply an unmark patch" + }, + "passed": true + }, + { + "name": "patches apply a map update to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 405, + "test": "should apply a map update to a nested map" + }, + "passed": true + }, + { + "name": "patches apply a list update to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 416, + "test": "should apply a list update patch" + }, + "passed": true + }, + { + "name": "patches apply a list insertion to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 427, + "test": "should apply a list insertion patch" + }, + "passed": true + }, + { + "name": "patches apply a list deletion without length to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 438, + "test": "should apply a list deletion patch without length" + }, + "passed": true + }, + { + "name": "patches apply a list deletion with length to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 450, + "test": "should apply a list deletion patch with length" + }, + "passed": true + }, + { + "name": "patches apply a text splice to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 463, + "test": "should apply a text splice patch" + }, + "passed": true + }, + { + "name": "patches apply a text deletion without length to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 474, + "test": "should apply a text deletion patch without length" + }, + "passed": true + }, + { + "name": "patches apply a text deletion with length to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 484, + "test": "should apply a text deletion patch with length" + }, + "passed": true + }, + { + "name": "patches apply an increment patch to a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 495, + "test": "should apply an increment patch" + }, + "passed": true + }, + { + "name": "patches ignore a mark patch on a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 506, + "test": "should ignore a mark patch" + }, + "passed": true + }, + { + "name": "patches ignore an unmark patch on a plain PHP array", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 523, + "test": "should ignore an unmark patch" + }, + "passed": true + }, + { + "name": "patches apply a deep map update to a document", + "mappedFrom": { + "file": "javascript/test/patches.ts", + "line": 535, + "test": "should apply a map update to a map in a list in a map in a list" + }, + "passed": true + }, + { + "name": "basic toJS returns each document at its own heads", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 778, + "test": "should return the document at its correct heads" + }, + "passed": true + }, + { + "name": "basic immutable string accepts symbol-compatible objects", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 792, + "test": "should treat any class which has the correct symbol as a ImmutableString" + }, + "passed": true + }, + { + "name": "next export initializes native documents", + "mappedFrom": { + "file": "javascript/test/next_test.ts", + "line": 5, + "test": "should expose a next export to maintain backwards compatiblity with 2.0" + }, + "passed": true + }, + { + "name": "next export has the same public API as the main port", + "mappedFrom": { + "file": "javascript/test/next_test.ts", + "line": 9, + "test": "should have the same types as the main export" + }, + "passed": true + }, + { + "name": "basic RawString aliases ImmutableString semantics", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 821, + "test": "should export RawString and isRawString for backwards compatibility" + }, + "passed": true + }, + { + "name": "basic immutable string predicate distinguishes raw strings", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 829, + "test": "should export a predicate to check if something is an immutablestring" + }, + "passed": true + }, + { + "name": "basic transaction rollback preserves the original document", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 842, + "test": "it should be able to roll back a transaction" + }, + "passed": true + }, + { + "name": "basic clone does not copy patch callbacks", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 384, + "test": "should not copy the patchCallback" + }, + "passed": true + }, + { + "name": "basic list convenience methods update materialized arrays", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 260, + "test": "have many list methods" + }, + "passed": true + }, + { + "name": "basic proxy lists behave like PHP arrays", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 412, + "test": "behave like arrays" + }, + "passed": true + }, + { + "name": "proxy list entries iterator exposes indexes and values", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 28, + "test": "should return iterable entries" + }, + "passed": true + }, + { + "name": "proxy list values iterator exposes ordered values", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 41, + "test": "should return iterable values" + }, + "passed": true + }, + { + "name": "proxy list keys iterator exposes ordered indexes", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 53, + "test": "should return iterable keys" + }, + "passed": true + }, + { + "name": "rust list range bounds slice ordered values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-list-range-tests-list-range-bounds", + "file": "rust/automerge/src/iter/list_range.rs", + "test": "iter::list_range::tests::list_range_bounds" + }, + "passed": true + }, + { + "name": "rust list range reports conflicting element flags", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-list-range-tests-list-range-conflict", + "file": "rust/automerge/src/iter/list_range.rs", + "test": "iter::list_range::tests::list_range_conflict" + }, + "passed": true + }, + { + "name": "proxy list indexOf returns matching string index", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 72, + "test": "should return the index of a value for a string in a list of strings" + }, + "passed": true + }, + { + "name": "proxy list indexOf returns -1 for a missing value", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 78, + "test": "should return -1 if the value is not found" + }, + "passed": true + }, + { + "name": "proxy list splice removes a defined number of entries", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 86, + "test": "should be able to remove a defined number of list entries" + }, + "passed": true + }, + { + "name": "proxy list splice replaces removed entries with new values", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 95, + "test": "should be able to remove a defined number of list entries and add new ones" + }, + "passed": true + }, + { + "name": "proxy list splice inserts new values without deletion", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 104, + "test": "should be able to insert new values" + }, + "passed": true + }, + { + "name": "proxy list splice with only a start removes through the end", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 113, + "test": "should work with only a start parameter" + }, + "passed": true + }, + { + "name": "proxy list splice rejects undefined inserted values", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 122, + "test": "should throw a useful RangeError when attempting to splice undefined values" + }, + "passed": true + }, + { + "name": "proxy recursive document assignment is rejected", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 16, + "test": "should throw a useful RangeError when attempting to set a document inside itself" + }, + "passed": true + }, + { + "name": "proxy map allows null values", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 133, + "test": "does allow null values" + }, + "passed": true + }, + { + "name": "proxy map rejects undefined values", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 141, + "test": "does not allow undefined values" + }, + "passed": true + }, + { + "name": "proxy map undefined errors include the property path", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 150, + "test": "should print the property path in the error when setting an undefined key" + }, + "passed": true + }, + { + "name": "proxy list undefined errors include the property path", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 161, + "test": "should print the property path in the error when setting an undefined key" + }, + "passed": true + }, + { + "name": "proxy list at returns values by index", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 170, + "test": "should support .at() to access values" + }, + "passed": true + }, + { + "name": "proxy structured clone input materializes as a nested map", + "mappedFrom": { + "file": "javascript/test/proxies.ts", + "line": 181, + "test": "should support objects cloned with structuredClone" + }, + "passed": true + }, + { + "name": "basic backend access materializes the document", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 285, + "test": "allows access to the backend" + }, + "passed": true + }, + { + "name": "basic lists and text expose indexOf semantics", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 292, + "test": "lists and text have indexof" + }, + "passed": true + }, + { + "name": "basic mark and unmark do not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 656, + "test": "mark/unmark" + }, + "passed": true + }, + { + "name": "basic marks lookup does not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 673, + "test": "marks" + }, + "passed": true + }, + { + "name": "basic marksAt lookup does not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 678, + "test": "marksAt" + }, + "passed": true + }, + { + "name": "marks patch callbacks expose mark splits and marked load splices", + "mappedFrom": { + "file": "javascript/test/marks.ts", + "line": 6, + "test": "should allow marks that can be seen in patches" + }, + "passed": true + }, + { + "name": "marks shift across unicode text splices", + "mappedFrom": { + "file": "javascript/test/marks.ts", + "line": 73, + "test": "should do unicode sensibly" + }, + "passed": true + }, + { + "name": "marks expand at splice boundaries and report marked splice patches", + "mappedFrom": { + "file": "javascript/test/marks.ts", + "line": 107, + "test": "patches properly report marks on end of expand true" + }, + "passed": true + }, + { + "name": "basic path splice does not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 631, + "test": "splice" + }, + "passed": true + }, + { + "name": "basic path updateText does not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 638, + "test": "updateText" + }, + "passed": true + }, + { + "name": "cursor from accepts an existing document as shallow copy input", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 24, + "test": "should be able to pass a doc to from() to make a shallow copy" + }, + "passed": true + }, + { + "name": "cursor Date values from one document can be reused in another change", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 122, + "test": "should allow dates from an existing document to be used in another document" + }, + "passed": true + }, + { + "name": "cursor values can be used in splice calls after earlier text edits", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 5, + "test": "can use cursors in splice calls" + }, + "passed": true + }, + { + "name": "cursor values support common text operations with backward deletes", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 37, + "test": "can use cursors in common text operations" + }, + "passed": true + }, + { + "name": "cursor splices use JavaScript UTF-16 string indices", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 61, + "test": "should use javascript string indices" + }, + "passed": true + }, + { + "name": "cursor patch callbacks report their source operation", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 76, + "test": "patch callbacks inform where they came from" + }, + "passed": true + }, + { + "name": "cursor start and end sentinels can drive text splices", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 178, + "test": "should allow for usage of start/end cursors" + }, + "passed": true + }, + { + "name": "cursor creation clamps negative indices to start", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 212, + "test": "should convert negative indices into a start cursor" + }, + "passed": true + }, + { + "name": "cursor creation clamps too-large indices to end", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 223, + "test": "should convert indices >= string length into an end cursor" + }, + "passed": true + }, + { + "name": "cursor position resolves against a historical view", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 135, + "test": "getCursorPosition should work" + }, + "passed": true + }, + { + "name": "cursor creation respects view heads for before after start and end", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 153, + "test": "getCursor should respect heads" + }, + "passed": true + }, + { + "name": "cursor move before and after survive text replacement", + "mappedFrom": { + "file": "javascript/test/cursors.ts", + "line": 197, + "test": "should allow for usage of move before/after" + }, + "passed": true + }, + { + "name": "basic getCursor does not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 645, + "test": "getCursor" + }, + "passed": true + }, + { + "name": "basic getCursorPosition does not mutate path arguments", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 650, + "test": "getCursorPosition" + }, + "passed": true + }, + { + "name": "new change API supports simple root assignment", + "mappedFrom": { + "file": "javascript/test/new-change-api.ts", + "line": 5, + "test": "should be able to make simple changes to a document" + }, + "passed": true + }, + { + "name": "new change API supports insertAt-style list insertion", + "mappedFrom": { + "file": "javascript/test/new-change-api.ts", + "line": 17, + "test": "should be able to insert into a list" + }, + "passed": true + }, + { + "name": "new change API supports deleteAt-style list deletion", + "mappedFrom": { + "file": "javascript/test/new-change-api.ts", + "line": 25, + "test": "should be able to delete from a list" + }, + "passed": true + }, + { + "name": "basic save load and change preserve integer and float edge values", + "mappedFrom": { + "file": "javascript/test/basic_test.ts", + "line": 855, + "test": "it should be able to handle ints and floats at their limits" + }, + "passed": true + }, + { + "name": "rust legacy op serde deserializes object ids", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-deserialize-obj", + "file": "rust/automerge/src/legacy/serde_impls/op.rs", + "test": "legacy::serde_impls::op::tests::test_deserialize_obj" + }, + "passed": true + }, + { + "name": "rust legacy op serde serializes map and element id keys", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-serialize-key", + "file": "rust/automerge/src/legacy/serde_impls/op.rs", + "test": "legacy::serde_impls::op::tests::test_serialize_key" + }, + "passed": true + }, + { + "name": "rust legacy op serde deserializes action values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-deserialize-action", + "file": "rust/automerge/src/legacy/serde_impls/op.rs", + "test": "legacy::serde_impls::op::tests::test_deserialize_action" + }, + "passed": true + }, + { + "name": "rust legacy op serde round trips normalized operations", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-round-trips", + "file": "rust/automerge/src/legacy/serde_impls/op.rs", + "test": "legacy::serde_impls::op::tests::test_round_trips" + }, + "passed": true + }, + { + "name": "rust AutoSerde serializes a root map as JSON", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-autoserde-rs-autoserde-autoserde-line-9", + "file": "rust/automerge/src/autoserde.rs", + "test": "automerge/src/autoserde.rs AutoSerde example" + }, + "passed": true + }, + { + "name": "rust lib address book example saves nested contacts", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-lib-rs-line-117", + "file": "rust/automerge/src/lib.rs", + "test": "automerge/src/lib.rs address book creation example" + }, + "passed": true + }, + { + "name": "rust lib address book merge example preserves independent nested edits", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-lib-rs-line-147", + "file": "rust/automerge/src/lib.rs", + "test": "automerge/src/lib.rs address book merge example" + }, + "passed": true + }, + { + "name": "rust sync protocol doc example converges a new peer", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-sync-rs-sync-line-25", + "file": "rust/automerge/src/sync.rs", + "test": "automerge/src/sync.rs sync protocol example" + }, + "passed": true + }, + { + "name": "rust patch log doc example records patches from a sync receive", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-patches-patch-log-rs-patches-patch-log-patchlog-line-28-compile", + "file": "rust/automerge/src/patches/patch_log.rs", + "test": "automerge/src/patches/patch_log.rs PatchLog sync receive example" + }, + "passed": true + }, + { + "name": "rust applying changes with a patch log from another document reports mismatch", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:applying-changes-with-patch-log-from-another-document-returns-error-not-panic", + "file": "rust/automerge/tests/test.rs", + "test": "applying_changes_with_patch_log_from_another_document_returns_error_not_panic" + }, + "passed": true + }, + { + "name": "rust transaction with a patch log from another document reports mismatch", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:transaction-with-patch-log-from-another-document-does-not-panic", + "file": "rust/automerge/tests/test.rs", + "test": "transaction_with_patch_log_from_another_document_does_not_panic" + }, + "passed": true + }, + { + "name": "rust transactionAt with a patch log from another document reports mismatch", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:transaction-at-with-patch-log-from-another-document-does-not-panic", + "file": "rust/automerge/tests/test.rs", + "test": "transaction_at_with_patch_log_from_another_document_does_not_panic" + }, + "passed": true + }, + { + "name": "rust owned transaction with a patch log from another document reports mismatch", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:owned-transaction-with-patch-log-from-another-document-does-not-panic", + "file": "rust/automerge/tests/test.rs", + "test": "owned_transaction_with_patch_log_from_another_document_does_not_panic" + }, + "passed": true + }, + { + "name": "rust big_list patch log reports a large list insertion", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:big-list", + "file": "rust/automerge/tests/test.rs", + "test": "big_list" + }, + "passed": true + }, + { + "name": "rust large list patches count string list elements as one slot", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:large-patches-in-lists-are-correct", + "file": "rust/automerge/tests/test.rs", + "test": "large_patches_in_lists_are_correct" + }, + "passed": true + }, + { + "name": "rust repeated out-of-order changes converge after dependencies arrive", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:handle-repeated-out-of-order-changes", + "file": "rust/automerge/tests/test.rs", + "test": "handle_repeated_out_of_order_changes" + }, + "passed": true + }, + { + "name": "rust reverse diff reinserts deleted text object in a list", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-object-in-list-correctly", + "file": "rust/automerge/tests/test.rs", + "test": "diff_should_reverse_deletion_of_object_in_list_correctly" + }, + "passed": true + }, + { + "name": "rust forked documents save without missing actor ids", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:missing-actors-when-docs-are-forked", + "file": "rust/automerge/tests/test.rs", + "test": "missing_actors_when_docs_are_forked" + }, + "passed": true + }, + { + "name": "rust reverse diff reinserts deleted text object in a map", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-object-in-map-correctly", + "file": "rust/automerge/tests/test.rs", + "test": "diff_should_reverse_deletion_of_object_in_map_correctly" + }, + "passed": true + }, + { + "name": "rust reverse diff reinserts deleted block marker in text", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-block-in-text-correctly", + "file": "rust/automerge/tests/test.rs", + "test": "diff_should_reverse_deletion_of_block_in_text_correctly" + }, + "passed": true + }, + { + "name": "rust AutoCommit commit_with doc example records message and time", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-commit-with-line-638", + "file": "rust/automerge/src/autocommit.rs", + "test": "automerge/src/autocommit.rs AutoCommit::commit_with example" + }, + "passed": true + }, + { + "name": "rust AutoCommit send-equivalent save load boundary stays independent", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:autocommit-tests-test-autocommit-is-send", + "file": "rust/automerge/src/autocommit.rs", + "test": "autocommit::tests::test_autocommit_is_send" + }, + "passed": true + }, + { + "name": "rust transaction commit_with doc example records message and time", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-transaction-manual-transaction-rs-transaction-manual-transaction-transaction-commit-with-line-83", + "file": "rust/automerge/src/transaction/manual_transaction.rs", + "test": "automerge/src/transaction/manual_transaction.rs Transaction::commit_with example" + }, + "passed": true + }, + { + "name": "rust owned transaction put and get roundtrip", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-put-and-get-roundtrip", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::put_and_get_roundtrip" + }, + "passed": true + }, + { + "name": "rust owned transaction reads writes before commit", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-read-during-transaction", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::read_during_transaction" + }, + "passed": true + }, + { + "name": "rust owned transaction supports nested list objects", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-nested-objects", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::nested_objects" + }, + "passed": true + }, + { + "name": "rust owned transaction commit_with records options", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-commit-with-options", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::commit_with_options" + }, + "passed": true + }, + { + "name": "rust owned transaction logs patches", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-log-patches", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::log_patches" + }, + "passed": true + }, + { + "name": "rust owned transaction at historical heads commits against current document", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-owned-transaction-at", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::owned_transaction_at" + }, + "passed": true + }, + { + "name": "rust owned transaction exposes pre-transaction heads", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-get-heads-returns-pre-tx-heads", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::get_heads_returns_pre_tx_heads" + }, + "passed": true + }, + { + "name": "rust owned transaction counts pending operations", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-pending-ops", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::pending_ops" + }, + "passed": true + }, + { + "name": "rust owned transaction empty commit returns null hash", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-empty-commit-returns-none-hash", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::empty_commit_returns_none_hash" + }, + "passed": true + }, + { + "name": "rust owned transaction rollback with no writes cancels nothing", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-rollback-discards-ops", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::rollback_discards_ops" + }, + "passed": true + }, + { + "name": "rust owned transaction rollback undoes pending writes", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-rollback-undoes-writes", + "file": "rust/automerge/src/transaction/owned_transaction.rs", + "test": "transaction::owned_transaction::tests::rollback_undoes_writes" + }, + "passed": true + }, + { + "name": "rust transaction inner map rollback setup reads nested write", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-inner-tests-map-rollback-doesnt-panic", + "file": "rust/automerge/src/transaction/inner.rs", + "test": "transaction::inner::tests::map_rollback_doesnt_panic" + }, + "passed": true + }, + { + "name": "rust transaction rollback with no ops after merge is stable", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:rollback-with-no-ops", + "file": "rust/automerge/tests/test.rs", + "test": "rollback_with_no_ops" + }, + "passed": true + }, + { + "name": "rust rollback with several actors discards complex draft", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:rollback-with-several-actors", + "file": "rust/automerge/tests/test.rs", + "test": "rollback_with_several_actors" + }, + "passed": true + }, + { + "name": "rust AutoCommit diff cursor example emits patches", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-diff-line-223", + "file": "rust/automerge/src/autocommit.rs", + "test": "automerge/src/autocommit.rs AutoCommit::diff example" + }, + "passed": true + }, + { + "name": "rust AutoCommit diffIncremental example advances the cursor", + "mappedFrom": { + "id": "rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-diff-incremental-line-319", + "file": "rust/automerge/src/autocommit.rs", + "test": "automerge/src/autocommit.rs AutoCommit::diff_incremental example" + }, + "passed": true + }, + { + "name": "rust concurrent increments of the same property are added", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:add-concurrent-increments-of-same-property", + "file": "rust/automerge/tests/test.rs", + "test": "add_concurrent_increments_of_same_property" + }, + "passed": true + }, + { + "name": "rust increments only apply to preceding counter values", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:add-increments-only-to-preceeded-values", + "file": "rust/automerge/tests/test.rs", + "test": "add_increments_only_to_preceeded_values" + }, + "passed": true + }, + { + "name": "rust transaction_at applies historical text and scalar edits", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:can-transaction-at", + "file": "rust/automerge/tests/test.rs", + "test": "can_transaction_at" + }, + "passed": true + }, + { + "name": "rust isolate hides later heads until explicit integration", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:can-isolate", + "file": "rust/automerge/tests/test.rs", + "test": "can_isolate" + }, + "passed": true + }, + { + "name": "rust isolate integrate patch log omits stale conflicted text", + "mappedFrom": { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:incorrect-patches-produced-when-isolating-and-integrating", + "file": "rust/automerge/tests/text.rs", + "test": "incorrect_patches_produced_when_isolating_and_integrating" + }, + "passed": true + }, + { + "name": "rust marks survive expansion unmarking and prefix insertions", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:marks", + "file": "rust/automerge/tests/test.rs", + "test": "marks" + }, + "passed": true + }, + { + "name": "rust marks can be queried at historical heads", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:get-marks-at-heads", + "file": "rust/automerge/tests/test.rs", + "test": "get_marks_at_heads" + }, + "passed": true + }, + { + "name": "rust sync state reports acknowledged local changes", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:has-our-changes", + "file": "rust/automerge/tests/test.rs", + "test": "has_our_changes" + }, + "passed": true + }, + { + "name": "rust local map increment applies to counter conflicts only", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-local-inc-in-map", + "file": "rust/automerge/tests/test.rs", + "test": "test_local_inc_in_map" + }, + "passed": true + }, + { + "name": "rust list counter conflicts can be incremented and deleted", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:list-counter-del", + "file": "rust/automerge/tests/test.rs", + "test": "list_counter_del" + }, + "passed": true + }, + { + "name": "rust save/load preserves concurrent todo map conflicts", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-restore-complex1", + "file": "rust/automerge/tests/test.rs", + "test": "save_restore_complex1" + }, + "passed": true + }, + { + "name": "rust transactional save/load preserves concurrent todo map conflicts", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-restore-complex-transactional", + "file": "rust/automerge/tests/test.rs", + "test": "save_restore_complex_transactional" + }, + "passed": true + }, + { + "name": "rust incremental load keeps concurrent heads when one head is common", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:make-sure-load-incremental-doesnt-skip-a-load-with-a-common-head", + "file": "rust/automerge/tests/test.rs", + "test": "make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head" + }, + "passed": true + }, + { + "name": "rust mappings allow empty string keys", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:allows-empty-keys-in-mappings", + "file": "rust/automerge/tests/test.rs", + "test": "allows_empty_keys_in_mappings" + }, + "passed": true + }, + { + "name": "rust concurrent updates of the same field expose both values", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-of-same-field", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_updates_of_same_field" + }, + "passed": true + }, + { + "name": "rust assignment conflicts of different types expose all contenders", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:assignment-conflicts-of-different-types", + "file": "rust/automerge/tests/test.rs", + "test": "assignment_conflicts_of_different_types" + }, + "passed": true + }, + { + "name": "rust concurrent updates of the same list element expose both values", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-of-same-list-element", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_updates_of_same_list_element" + }, + "passed": true + }, + { + "name": "rust changes within a conflicting map field stay attached", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:changes-within-conflicting-map-field", + "file": "rust/automerge/tests/test.rs", + "test": "changes_within_conflicting_map_field" + }, + "passed": true + }, + { + "name": "rust changes within a conflicting list element stay attached", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:changes-within-conflicting-list-element", + "file": "rust/automerge/tests/test.rs", + "test": "changes_within_conflicting_list_element" + }, + "passed": true + }, + { + "name": "rust concurrent assignment and deletion of a map entry is add-wins", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-assignment-and-deletion-of-a-map-entry", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_assignment_and_deletion_of_a_map_entry" + }, + "passed": true + }, + { + "name": "rust concurrent assignment and deletion of a list entry is add-wins", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-assignment-and-deletion-of-list-entry", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_assignment_and_deletion_of_list_entry" + }, + "passed": true + }, + { + "name": "rust concurrent deletion of the same list element is idempotent", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-deletion-of-same-list-element", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_deletion_of_same_list_element" + }, + "passed": true + }, + { + "name": "rust concurrent insertions at different list positions merge cleanly", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-insertions-at-different-list-positions", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_insertions_at_different_list_positions" + }, + "passed": true + }, + { + "name": "rust concurrent insertions at the same list position merge cleanly", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-insertions-at-same-list-position", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_insertions_at_same_list_position" + }, + "passed": true + }, + { + "name": "rust concurrent updates at different tree levels prefer the delete", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-at-different-levels", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_updates_at_different_levels" + }, + "passed": true + }, + { + "name": "rust concurrent update under a deleted object does not resurrect it", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-of-concurrently-deleted-objects", + "file": "rust/automerge/tests/test.rs", + "test": "concurrent_updates_of_concurrently_deleted_objects" + }, + "passed": true + }, + { + "name": "rust concurrently assigned nested maps do not merge", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrently-assigned-nested-maps-should-not-merge", + "file": "rust/automerge/tests/test.rs", + "test": "concurrently_assigned_nested_maps_should_not_merge" + }, + "passed": true + }, + { + "name": "rust same-position sequence insertions do not interleave", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:does-not-interleave-sequence-insertions-at-same-position", + "file": "rust/automerge/tests/test.rs", + "test": "does_not_interleave_sequence_insertions_at_same_position" + }, + "passed": true + }, + { + "name": "rust insertion after a deleted list element survives", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:insertion-after-a-deleted-list-element", + "file": "rust/automerge/tests/test.rs", + "test": "insertion_after_a_deleted_list_element" + }, + "passed": true + }, + { + "name": "rust causal list insertions preserve insertion order", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:insertion-consistent-with-causality", + "file": "rust/automerge/tests/test.rs", + "test": "insertion_consistent_with_causality" + }, + "passed": true + }, + { + "name": "rust repeated map assignment has no conflict", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:no-conflict-on-repeated-assignment", + "file": "rust/automerge/tests/test.rs", + "test": "no_conflict_on_repeated_assignment" + }, + "passed": true + }, + { + "name": "rust save and restore empty document", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-and-restore-empty", + "file": "rust/automerge/tests/test.rs", + "test": "save_and_restore_empty" + }, + "passed": true + }, + { + "name": "rust same-position insertion by greater actor id prepends to the list", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:mutliple-insertions-at-same-list-position-with-insertion-by-greater-actor-id", + "file": "rust/automerge/tests/test.rs", + "test": "mutliple_insertions_at_same_list_position_with_insertion_by_greater_actor_id" + }, + "passed": true + }, + { + "name": "rust same-position insertion by lesser actor id prepends to the list", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:mutliple-insertions-at-same-list-position-with-insertion-by-lesser-actor-id", + "file": "rust/automerge/tests/test.rs", + "test": "mutliple_insertions_at_same_list_position_with_insertion_by_lesser_actor_id" + }, + "passed": true + }, + { + "name": "rust save and reload preserves object created without child operations", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-and-reload-create-object", + "file": "rust/automerge/tests/test.rs", + "test": "save_and_reload_create_object" + }, + "passed": true + }, + { + "name": "rust counter changes can be applied to a fresh document", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:observe-counter-change-application", + "file": "rust/automerge/tests/test.rs", + "test": "observe_counter_change_application" + }, + "passed": true + }, + { + "name": "rust negative 64-bit-adjacent integer stores and loads", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:negative-64", + "file": "rust/automerge/tests/test.rs", + "test": "negative_64" + }, + "passed": true + }, + { + "name": "rust repeated optree-boundary map updates apply after save-load", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:bad-change-on-optree-node-boundary", + "file": "rust/automerge/tests/test.rs", + "test": "bad_change_on_optree_node_boundary" + }, + "passed": true + }, + { + "name": "rust load document with deleted root objects succeeds", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load-doc-with-deleted-objects", + "file": "rust/automerge/tests/test.rs", + "test": "load_doc_with_deleted_objects" + }, + "passed": true + }, + { + "name": "rust insertion after many map deletes keeps the container valid", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:insert-after-many-deletes", + "file": "rust/automerge/tests/test.rs", + "test": "insert_after_many_deletes" + }, + "passed": true + }, + { + "name": "rust simple no-op bad save/load regression loads successfully", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:simple-bad-saveload", + "file": "rust/automerge/tests/test.rs", + "test": "simple_bad_saveload" + }, + "passed": true + }, + { + "name": "rust load incremental applies complete prefix and ignores corrupted tail", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load-incremental-with-corrupted-tail", + "file": "rust/automerge/tests/test.rs", + "test": "load_incremental_with_corrupted_tail" + }, + "passed": true + }, + { + "name": "rust regression nth miscount resolves nested list maps", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:regression-nth-miscount", + "file": "rust/automerge/tests/test.rs", + "test": "regression_nth_miscount" + }, + "passed": true + }, + { + "name": "rust regression nth miscount smaller resolves overwritten list scalars", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:regression-nth-miscount-smaller", + "file": "rust/automerge/tests/test.rs", + "test": "regression_nth_miscount_smaller" + }, + "passed": true + }, + { + "name": "rust regression insert opid replays list insertions", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:regression-insert-opid", + "file": "rust/automerge/tests/test.rs", + "test": "regression_insert_opid" + }, + "passed": true + }, + { + "name": "rust text splice save/load keeps loaded edits", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-merging-test-conflicts-then-saving-and-loading", + "file": "rust/automerge/tests/test.rs", + "test": "test_merging_test_conflicts_then_saving_and_loading" + }, + "passed": true + }, + { + "name": "rust inserting text near deleted marks keeps text coherent", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:inserting-text-near-deleted-marks", + "file": "rust/automerge/tests/test.rs", + "test": "inserting_text_near_deleted_marks" + }, + "passed": true + }, + { + "name": "rust overwriting identical scalar conflicts clears duplicates", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-overwriting-a-conflict", + "file": "rust/automerge/tests/test.rs", + "test": "test_overwriting_a_conflict" + }, + "passed": true + }, + { + "name": "rust repeated map assignment resolves existing conflict", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:repeated-map-assignment-which-resolves-conflict-not-ignored", + "file": "rust/automerge/tests/test.rs", + "test": "repeated_map_assignment_which_resolves_conflict_not_ignored" + }, + "passed": true + }, + { + "name": "rust repeated list assignment resolves existing conflict", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:repeated-list-assignment-which-resolves-conflict-not-ignored", + "file": "rust/automerge/tests/test.rs", + "test": "repeated_list_assignment_which_resolves_conflict_not_ignored" + }, + "passed": true + }, + { + "name": "rust list deletion removes the selected element", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:list-deletion", + "file": "rust/automerge/tests/test.rs", + "test": "list_deletion" + }, + "passed": true + }, + { + "name": "rust concurrent map property updates merge", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:merge-concurrent-map-prop-updates", + "file": "rust/automerge/tests/test.rs", + "test": "merge_concurrent_map_prop_updates" + }, + "passed": true + }, + { + "name": "rust incrementing non-counter map values fails but counters still increment", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:increment-non-counter-map", + "file": "rust/automerge/tests/test.rs", + "test": "increment_non_counter_map" + }, + "passed": true + }, + { + "name": "rust incrementing non-counter list values fails but counters still increment", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:increment-non-counter-list", + "file": "rust/automerge/tests/test.rs", + "test": "increment_non_counter_list" + }, + "passed": true + }, + { + "name": "rust list operations reject invalid indexes", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:invalid-index", + "file": "rust/automerge/tests/test.rs", + "test": "invalid_index" + }, + "passed": true + }, + { + "name": "rust operations on the wrong object type are rejected", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:ops-on-wrong-objets", + "file": "rust/automerge/tests/test.rs", + "test": "ops_on_wrong_objets" + }, + "passed": true + }, + { + "name": "rust save and load incremented counter change metadata", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-and-load-incremented-counter", + "file": "rust/automerge/tests/test.rs", + "test": "save_and_load_incremented_counter" + }, + "passed": true + }, + { + "name": "rust get change metadata since heads returns the next change", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-get-change-meta", + "file": "rust/automerge/tests/test.rs", + "test": "test_get_change_meta" + }, + "passed": true + }, + { + "name": "rust get changes after an empty-change head is empty", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:get-changes-with-hash-of-empty-change-produces-correct-result", + "file": "rust/automerge/tests/test.rs", + "test": "get_changes_with_hash_of_empty_change_produces_correct_result" + }, + "passed": true + }, + { + "name": "rust change graph clock cache regression keeps merged branch ancestors covered", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:reproduce-clock-cache-bug", + "file": "rust/automerge/tests/test.rs", + "test": "reproduce_clock_cache_bug" + }, + "passed": true + }, + { + "name": "rust last local change hash matches document heads", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-get-last-local-change-generation", + "file": "rust/automerge/tests/test.rs", + "test": "test_get_last_local_change_generation" + }, + "passed": true + }, + { + "name": "rust stats reports change and operation counts", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:stats-smoke-test", + "file": "rust/automerge/tests/test.rs", + "test": "stats_smoke_test" + }, + "passed": true + }, + { + "name": "rust zero-length strings and bytes store as values", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:zero-length-data", + "file": "rust/automerge/tests/test.rs", + "test": "zero_length_data" + }, + "passed": true + }, + { + "name": "rust save with delete-only actor references loads", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-with-ops-which-reference-actors-only-via-delete", + "file": "rust/automerge/tests/test.rs", + "test": "save_with_ops_which_reference_actors_only_via_delete" + }, + "passed": true + }, + { + "name": "rust delete-only list change survives load and later insert", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:delete-only-change", + "file": "rust/automerge/tests/test.rs", + "test": "delete_only_change" + }, + "passed": true + }, + { + "name": "rust save with empty commits loads", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-with-empty-commits", + "file": "rust/automerge/tests/test.rs", + "test": "save_with_empty_commits" + }, + "passed": true + }, + { + "name": "rust partial incremental load applies selected changes", + "mappedFrom": { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-load-incremental-partial-load", + "file": "rust/automerge/tests/test.rs", + "test": "test_load_incremental_partial_load" + }, + "passed": true + }, + { + "name": "rust current_state renders root, map, list, and text values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-basic-test", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::basic_test" + }, + "passed": true + }, + { + "name": "rust current_state omits deleted operations while keeping live containers", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-deleted-ops-omitted", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_deleted_ops_omitted" + }, + "passed": true + }, + { + "name": "rust current_state text splice coalesces delete-insert output", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-text-spliced", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_text_spliced" + }, + "passed": true + }, + { + "name": "rust current_state reports counter conflicts with the current counter value", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-counters", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_counters" + }, + "passed": true + }, + { + "name": "rust current_state load emits counter put patches", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-load-changes", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_load_changes" + }, + "passed": true + }, + { + "name": "rust current_state renders multiple sequential list insertions", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-multiple-list-insertions", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_multiple_list_insertions" + }, + "passed": true + }, + { + "name": "rust current_state renders concurrent same-index list insertions", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-concurrent-insertions-at-same-index", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_concurrent_insertions_at_same_index" + }, + "passed": true + }, + { + "name": "rust current_state renders inserted map objects in lists", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-insert-objects", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_insert_objects" + }, + "passed": true + }, + { + "name": "rust current_state renders list inserts after element updates", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-insert-and-update", + "file": "rust/automerge/src/automerge/current_state.rs", + "test": "automerge::current_state::tests::test_insert_and_update" + }, + "passed": true + }, + { + "name": "rust hydrate materializes root data and applies text patches", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:hydrate-tests-simple-hydrate", + "file": "rust/automerge/src/hydrate/tests.rs", + "test": "hydrate::tests::simple_hydrate" + }, + "passed": true + }, + { + "name": "rust document iterator walks live root and child values", + "mappedFrom": { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-doc-tests-doc-iter", + "file": "rust/automerge/src/iter/doc.rs", + "test": "iter::doc::tests::doc_iter" + }, + "passed": true + }, + { + "name": "rust batch insert creates a flat map", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-flat-map", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_flat_map" + }, + "passed": true + }, + { + "name": "rust batch insert creates nested maps", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-nested-maps", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_nested_maps" + }, + "passed": true + }, + { + "name": "rust batch insert map overwrites an existing root key", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-map-overwrites-existing-key", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_map_overwrites_existing_key" + }, + "passed": true + }, + { + "name": "rust batch insert rejects scalar object creation", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-scalar-fails", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_scalar_fails" + }, + "passed": true + }, + { + "name": "rust batch insert creates a flat list", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-flat-list", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_flat_list" + }, + "passed": true + }, + { + "name": "rust batch insert creates a list with nested objects", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-list-with-nested-objects", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_list_with_nested_objects" + }, + "passed": true + }, + { + "name": "rust batch insert appends an object to an existing list", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-list-at-end", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_into_list_at_end" + }, + "passed": true + }, + { + "name": "rust batch insert inserts an object into the middle of a list", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-list-at-middle", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_into_list_at_middle" + }, + "passed": true + }, + { + "name": "rust batch put overwrites an existing list element", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-put-overwrites-existing-list-element", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_put_overwrites_existing_list_element" + }, + "passed": true + }, + { + "name": "rust batch insert supports text values in a map", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-with-text", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_with_text" + }, + "passed": true + }, + { + "name": "rust batch insert supports text values in a list", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-text-in-list", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_text_in_list" + }, + "passed": true + }, + { + "name": "rust batch insert supports deeply nested maps", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-deeply-nested", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_deeply_nested" + }, + "passed": true + }, + { + "name": "rust batch insert supports mixed map and list nesting", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-mixed-nesting", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_mixed_nesting" + }, + "passed": true + }, + { + "name": "rust batch insert survives save and load", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-survives-save-load", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_survives_save_load" + }, + "passed": true + }, + { + "name": "rust batch insert merges independent root objects", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-merges-correctly", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_merges_correctly" + }, + "passed": true + }, + { + "name": "rust multiple batch inserts preserve all root objects", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:multiple-batch-inserts", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "multiple_batch_inserts" + }, + "passed": true + }, + { + "name": "rust batch insert into an existing map", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-existing-map", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_into_existing_map" + }, + "passed": true + }, + { + "name": "rust batch insert into an existing list", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-existing-list", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_into_existing_list" + }, + "passed": true + }, + { + "name": "rust batch insert hydrate output materializes matching data", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-matches-hydrate-output", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_matches_hydrate_output" + }, + "passed": true + }, + { + "name": "rust batch insert works inside a committed transaction", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-with-transaction", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_with_transaction" + }, + "passed": true + }, + { + "name": "rust batch insert transaction rollback discards the draft", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-transaction-rollback", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_transaction_rollback" + }, + "passed": true + }, + { + "name": "rust batch insert supports various scalar types", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-various-scalar-types", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_various_scalar_types" + }, + "passed": true + }, + { + "name": "rust batch insert is equivalent to individual nested operations", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-equivalent-to-individual-ops", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_equivalent_to_individual_ops" + }, + "passed": true + }, + { + "name": "rust batch put overwrites a list element with nested structure", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-put-overwrite-with-nested-structure", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_put_overwrite_with_nested_structure" + }, + "passed": true + }, + { + "name": "rust splice inserts scalar list values", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-insert-scalars", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_insert_scalars" + }, + "passed": true + }, + { + "name": "rust splice inserts object list values", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-insert-objects", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_insert_objects" + }, + "passed": true + }, + { + "name": "rust splice inserts mixed list values", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-insert-mixed", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_insert_mixed" + }, + "passed": true + }, + { + "name": "rust splice deletes and inserts list values", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-delete-and-insert", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_delete_and_insert" + }, + "passed": true + }, + { + "name": "rust splice merges concurrent list insertions", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-merges-correctly", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_merges_correctly" + }, + "passed": true + }, + { + "name": "rust batch insert creates an empty map object", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-empty-map", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_empty_map" + }, + "passed": true + }, + { + "name": "rust batch insert creates an empty list object", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-empty-list", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_empty_list" + }, + "passed": true + }, + { + "name": "rust batch insert creates an empty text object", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-empty-text", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_empty_text" + }, + "passed": true + }, + { + "name": "rust batch insert supports a list of lists", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-list-of-lists", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_list_of_lists" + }, + "passed": true + }, + { + "name": "rust batch init map creates flat root keys", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-flat", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_init_map_flat" + }, + "passed": true + }, + { + "name": "rust batch init map creates nested root objects", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-nested", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_init_map_nested" + }, + "passed": true + }, + { + "name": "rust batch init map supports text values", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-with-text", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_init_map_with_text" + }, + "passed": true + }, + { + "name": "rust batch init map survives save and load", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-survives-save-load", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_init_map_survives_save_load" + }, + "passed": true + }, + { + "name": "rust batch init map is equivalent to individual root operations", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-equivalent-to-individual-ops", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_init_map_equivalent_to_individual_ops" + }, + "passed": true + }, + { + "name": "rust batch insert generates root container patches", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-generates-patches", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_generates_patches" + }, + "passed": true + }, + { + "name": "rust batch init map generates root patches", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-generates-patches", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_init_map_generates_patches" + }, + "passed": true + }, + { + "name": "rust batch insert text generates splice patches", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-text-generates-splice-patch", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "batch_insert_text_generates_splice_patch" + }, + "passed": true + }, + { + "name": "rust splice deletes list values without insertion", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-delete-only", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_delete_only" + }, + "passed": true + }, + { + "name": "rust splice inserts text values into a list", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-with-text", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_with_text" + }, + "passed": true + }, + { + "name": "rust splice inserts deeply nested list values", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-deeply-nested", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_deeply_nested" + }, + "passed": true + }, + { + "name": "rust splice survives save and load", + "mappedFrom": { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-survives-save-load", + "file": "rust/automerge/tests/batch_insert.rs", + "test": "splice_survives_save_load" + }, + "passed": true + }, + { + "name": "rust string migration converts map strings to text", + "mappedFrom": { + "id": "rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:test-strings-in-maps-are-converted-to-text", + "file": "rust/automerge/tests/convert_string_to_text.rs", + "test": "test_strings_in_maps_are_converted_to_text" + }, + "passed": true + }, + { + "name": "rust string migration converts list strings to text", + "mappedFrom": { + "id": "rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:test-strings-in-lists-are-converted-to-text", + "file": "rust/automerge/tests/convert_string_to_text.rs", + "test": "test_strings_in_lists_are_converted_to_text" + }, + "passed": true + }, + { + "name": "rust string migration does not grow an empty document", + "mappedFrom": { + "id": "rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:test-does-not-add-size-when-strings-are-not-converted", + "file": "rust/automerge/tests/convert_string_to_text.rs", + "test": "test_does_not_add_size_when_strings_are_not_converted" + }, + "passed": true + } + ], + "wordpressScenarios": [ + { + "name": "Different top-level paragraph blocks edited by two users", + "passed": true + }, + { + "name": "Same paragraph block concurrent text insertion", + "passed": true + }, + { + "name": "One-sided edge insertion at start and end of a post", + "passed": true + }, + { + "name": "One-sided deletion of an unchanged block", + "passed": true + }, + { + "name": "Server wp_update_post edit merges with editor edit on another block", + "passed": true + }, + { + "name": "Server wp_update_post deletion merges with editor edit on another block", + "passed": true + }, + { + "name": "Server wp_update_post insertion merges with editor edit on another block", + "passed": true + }, + { + "name": "Identical editor and wp_update_post replacements merge without conflict", + "passed": true + }, + { + "name": "Server wp_update_post no-op preserves a Gutenberg editor edit", + "passed": true + }, + { + "name": "Overlapping editor and wp_update_post edits report a merge conflict", + "passed": true + } + ], + "unmappedNextTargets": [] +} diff --git a/src/wp-includes/automerge-php/README.md b/src/wp-includes/automerge-php/README.md new file mode 100644 index 0000000000000..efb46b6c780a8 --- /dev/null +++ b/src/wp-includes/automerge-php/README.md @@ -0,0 +1,130 @@ +# Native PHP Port: automerge + +This repository is the native PHP porting workspace for automerge. + +- Upstream: https://github.com/automerge/automerge +- Reference: Automerge document/change format +- Initial focus: Binary/change decoding, map/list/text operations, and deterministic materialization in native PHP. + +No process bridge, FFI, WASM runtime, sidecar service, or Node/Rust/C helper is allowed in the PHP implementation path. + +## Upstream Benchmark + +The pinned upstream runners now complete with the required Rust target and +wasm-bindgen version available on `PATH`. The active correctness denominator is +680 tests: 312 JavaScript Mocha tests plus 368 Rust `automerge` tests. The +registered manifest contains 688 entries, including 1 JavaScript pending test +and 7 Rust ignored tests. + +## Commands + +```bash +composer validate --no-interaction --no-check-publish +composer dump-autoload +composer test +``` + +Update `PORTING_STATUS.json` after every meaningful test run. + +## Current Scope + +This checkpoint implements a small native PHP document model for the first +mapped Automerge upstream slice: + +- root map set/read/delete and scalar overwrite behavior +- nested object and simple list materialization +- text insertion, deletion, JSON serialization, Unicode materialization, and + deterministic concurrent insertion ordering +- native in-process change capture and replay for initial document values +- `applyLocalEdit()` / `encodeUpdate()` / `merge()` for different Gutenberg + paragraph blocks edited concurrently +- same-paragraph concurrent insertion through serialized Gutenberg post content +- one-sided start/end block insertion through the save-time adapter path +- one-sided deletion of an unchanged Gutenberg block +- native heads, metadata-since-heads, empty changes, and save/load payload + round trips +- legacy changes API basics: empty diffs, getChanges, applyChanges, and + reconstruction from native change records +- legacy list element and text object updates replayed through native change + records +- empty-document sync protocol message generation and no-data reply suppression +- equal-head sync state tracking for documents that already share the same + changes +- one-sided sync transfer from a data-bearing peer to an empty peer +- prior-sync-state convergence when one peer adds later commits +- concurrent-head tracking for independent diverged sync messages +- legacy `Automerge.from()` initialization coercion for maps, lists, strings, + scalars, and explicit actor IDs +- legacy sequential immutability, no-conflict overwrites, no-op changes, and + ignored existing-value updates +- legacy list element existing-value no-ops plus root map assignment, + deletion, clone, and object-replacement behavior +- legacy root scalar type changes and empty string keys +- native DateTime values in maps and lists through change replay +- empty-change history entries and merged-head dependency metadata +- rejection of unsupported PHP assignment values before history changes +- replay of list assignment and root-key text splices through getChanges/applyChanges +- nested map assignment, object literals, and arbitrary-depth nested updates +- nested map replacement and primitive/map type changes +- nested map property deletion and root map-reference deletion +- sync-state acknowledgement checks with `hasOurChanges()` +- `patches.ts` map-update patch application on documents +- `patches.ts` list update, insertion, and deletion patch application +- `patches.ts` text splice and deletion patch application +- `patches.ts` map/list/text patch application against plain PHP arrays +- `patches.ts` plain-array increment, mark no-op, and deep document map update +- legacy list insertion, deletion, index assignment, and bulk-style updates +- legacy list assignment boundary checks for append versus out-of-range writes +- legacy nested object/list updates, list replacement, list element type + changes, and arbitrary-depth list insertion +- legacy patch callback emission for root string and list assignment +- native Counter values for nested-map deletion and same-counter concurrent + increment merges +- root-level same-field conflict reporting with deterministic winner + materialization, including different-type and nested-map assignment conflicts +- causal root assignment merges that clear prior same-field conflicts +- legacy save/load actor reassignment, nested data, at-sign keys, root conflicts, + and basic history snapshots +- legacy history change-message metadata for root/list updates +- native last-local-change decoding and grouped root assignment changes +- same-value root writes that intentionally resolve existing conflicts +- Automerge document patch application for native Counter increment patches +- Automerge document patch application for text mark and unmark metadata +- native document detection and plain PHP materialization helpers +- native head-based views over the local change log and editable view clones +- native text mark, unmark, marks, and marksAt API behavior +- repeated root writes for scalar, null, date, counter, and byte-array values +- basic object-id classification for root documents, arrays, and scalar values +- topological history traversal over native merged change logs +- native stats wrapper for change and operation counts +- sync acknowledgement state stops follow-up messages after convergence +- deterministic conflict metadata independent of merge order +- path-based text splice and updateText helpers preserve caller path arrays +- simple cursor and cursor-position helpers preserve caller path arrays +- native list/text indexOf helpers for materialized Automerge values +- backend materialization view for native PHP documents +- list convenience operations for splice, push, unshift, shift, and insertAt +- clone boundaries do not carry patch callbacks between port instances +- patch callback before/after metadata exposes matching document heads +- patch callback before/after metadata covers list deletion materialization +- nested property deletion emits callback metadata with native delete paths +- native head-to-head diff emits map/list/text insertion patches and validates heads +- reverse head-to-head diff reconstructs deleted string list values +- path-scoped diff supports nested map replacement and shallow recursion +- changeAt-style historical-head scalar updates preserve unrelated visible content +- text head-to-head diffs emit put/splice patch pairs in both directions +- native incremental save batches match saveSince from the last saved heads +- inspectChange exposes decoded change metadata and text operation shape +- toJS materializes each immutable document at its own heads +- ImmutableString/RawString value objects preserve scalar string identity +- sync state carries read-only metadata and can ignore incoming changes +- text splice can target string values nested inside arrays +- updateText diffs and merges multi-codepoint grapheme clusters as text units +- sync messages keep sending unacknowledged local changes after new edits +- failed change transactions roll back clone-local mutations +- server-side post updates can merge with editor edits on separate blocks + +Binary change decoding, upstream-compatible save/load bytes, views, object IDs, +full list CRDT semantics, and Automerge's complete change format are not +implemented yet. Those are the next correctness targets; no bridge or sidecar +is used. diff --git a/src/wp-includes/automerge-php/UPSTREAM.md b/src/wp-includes/automerge-php/UPSTREAM.md new file mode 100644 index 0000000000000..e86a1d18d8143 --- /dev/null +++ b/src/wp-includes/automerge-php/UPSTREAM.md @@ -0,0 +1,45 @@ +# Upstream Reference + +- URL: https://github.com/automerge/automerge +- Reference scope: Automerge document/change format +- Local checkout: `upstream/automerge` submodule + +## Canonical Upstream Test Commands + +The JavaScript package declares this canonical test script in +`upstream/automerge/javascript/package.json`: + +```bash +cd upstream/automerge/javascript && npm test +``` + +The Rust core crate test command for the Automerge implementation is: + +```bash +cd upstream/automerge/rust && cargo test -p automerge +``` + +The first PHP checkpoint maps behavior from +`upstream/automerge/javascript/test/basic_test.ts` and +`upstream/automerge/javascript/test/text_test.ts`. See +`tests/upstream-test-map.md` for row-level mapping. + +## Benchmark Denominator + +The full upstream benchmark checkpoint is recorded in: + +- `UPSTREAM_BENCHMARK.json` +- `UPSTREAM_BENCHMARK.md` +- `UPSTREAM_TEST_MANIFEST.json` + +The older static audit denominator from commit `7f6a950` estimated 724 upstream +entries while the upstream runners were blocked. That estimate is now +superseded by current runtime benchmark artifacts: + +- JavaScript: `npm test` reports 312 passing and 1 pending entry. +- Rust: `cargo test -p automerge` reports 368 passing and 7 ignored entries. + +The active correctness denominator is therefore 680 runtime-passing upstream +tests. The registered manifest total is 688 entries when the upstream pending +and ignored rows are included. `PORTING_STATUS.json` reports active progress +against 680 and registered manifest coverage against 688. diff --git a/src/wp-includes/automerge-php/UPSTREAM_BENCHMARK.json b/src/wp-includes/automerge-php/UPSTREAM_BENCHMARK.json new file mode 100644 index 0000000000000..57d07f5fb847c --- /dev/null +++ b/src/wp-includes/automerge-php/UPSTREAM_BENCHMARK.json @@ -0,0 +1,75 @@ +{ + "library": "automerge", + "generatedAt": "2026-05-23T00:09:08Z", + "upstreamCommit": "44cd91582bd3ed9af05ef1a7843bb1074ad11112", + "status": "passed-with-pending-ignored", + "benchmarkScope": "full upstream default suite: JavaScript npm test plus Rust cargo test -p automerge", + "countMethod": "runtime-runner-counts", + "totalRan": 680, + "registeredTotal": 688, + "activeTotal": 680, + "passed": 680, + "failed": 0, + "skipped": 8, + "pending": 1, + "ignored": 7, + "commands": [ + { + "command": "cd upstream/automerge/javascript && npm run build", + "cwd": ".", + "status": "passed", + "exitCode": 0, + "totalRan": 0, + "passed": 0, + "failed": 0, + "skipped": 0, + "outputArtifact": "artifacts/upstream-benchmark/javascript-npm-build-current.txt", + "notes": "Build passed with PATH preferring wasm-bindgen 0.2.121 and the installed wasm32-unknown-unknown Rust target." + }, + { + "command": "cd upstream/automerge/javascript && npm test", + "cwd": ".", + "status": "passed-with-pending", + "exitCode": 0, + "totalRan": 312, + "registeredTotal": 313, + "passed": 312, + "failed": 0, + "skipped": 1, + "pending": 1, + "outputArtifact": "artifacts/upstream-benchmark/javascript-npm-test-current.txt", + "notes": "Runtime result: 312 passing, 0 failing, and 1 pending." + }, + { + "command": "cd upstream/automerge/rust && cargo test -p automerge", + "cwd": ".", + "status": "passed-with-ignored", + "exitCode": 0, + "totalRan": 368, + "registeredTotal": 375, + "passed": 368, + "failed": 0, + "skipped": 7, + "ignored": 7, + "outputArtifact": "artifacts/upstream-benchmark/rust-cargo-test-current.txt", + "notes": "Runtime result: 368 passing, 0 failing, and 7 ignored." + } + ], + "blocker": null, + "runtimeInventory": { + "javascriptActive": 312, + "javascriptRegistered": 313, + "javascriptPassing": 312, + "javascriptFailing": 0, + "javascriptPending": 1, + "javascriptExcludedSkippedDeclarations": 0, + "rustActive": 368, + "rustRegistered": 375, + "rustPassing": 368, + "rustFailing": 0, + "rustIgnored": 7, + "combinedActive": 680, + "combinedRegistered": 688 + }, + "notes": "The benchmark derives counts from the current raw upstream artifacts: JavaScript npm test reported 312 passing, 0 failing, and 1 pending; Rust cargo test -p automerge reported 368 passing, 0 failing, and 7 ignored. Active progress uses the 680 active-test denominator, while the manifest registers 688 total units including pending/ignored entries." +} diff --git a/src/wp-includes/automerge-php/UPSTREAM_BENCHMARK.md b/src/wp-includes/automerge-php/UPSTREAM_BENCHMARK.md new file mode 100644 index 0000000000000..9d2217d0c927e --- /dev/null +++ b/src/wp-includes/automerge-php/UPSTREAM_BENCHMARK.md @@ -0,0 +1,46 @@ +# Upstream Benchmark: Automerge + +Generated: 2026-05-23T00:09:08Z + +Upstream commit: `44cd91582bd3ed9af05ef1a7843bb1074ad11112` + +## Result + +Status: `passed-with-pending-ignored` + +Count method: `runtime-runner-counts` + +The canonical upstream runners now complete in this environment. Active +progress uses passing/runnable tests as the denominator, while the manifest also +registers upstream pending/ignored entries: + +| Surface | Active | Pending/Ignored | Registered | +| --- | ---: | ---: | ---: | +| JavaScript mocha suite | 312 | 1 | 313 | +| Rust automerge crate | 368 | 7 | 375 | +| Combined | 680 | 8 | 688 | + +`UPSTREAM_TEST_MANIFEST.json` contains 688 entries, of which +680 are active. Current PHP ported-passing entries: +680 active, 1 optional upstream-pending, +and 7 optional upstream-ignored. + +## Runner Attempts + +| Command | CWD | Status | Exit | Output | +| --- | --- | --- | ---: | --- | +| `cd upstream/automerge/javascript && npm run build` | `.` | `passed` | 0 | `artifacts/upstream-benchmark/javascript-npm-build-current.txt` | +| `cd upstream/automerge/javascript && npm test` | `.` | `passed-with-pending` | 0 | `artifacts/upstream-benchmark/javascript-npm-test-current.txt` | +| `cd upstream/automerge/rust && cargo test -p automerge` | `.` | `passed-with-ignored` | 0 | `artifacts/upstream-benchmark/rust-cargo-test-current.txt` | + +## Runtime Artifacts + +- `artifacts/upstream-benchmark/javascript-npm-build-current.txt` +- `artifacts/upstream-benchmark/javascript-npm-test-current.txt` +- `artifacts/upstream-benchmark/rust-cargo-test-current.txt` + +## Notes + +The benchmark derives counts from the current raw upstream artifacts: JavaScript npm test reported 312 passing, 0 failing, and 1 pending; Rust cargo test -p automerge reported 368 passing, 0 failing, and 7 ignored. Active progress uses the 680 active-test denominator, while the manifest registers 688 total units including pending/ignored entries. + +Skipped JavaScript declarations excluded from the registered runtime total: diff --git a/src/wp-includes/automerge-php/UPSTREAM_TEST_MANIFEST.json b/src/wp-includes/automerge-php/UPSTREAM_TEST_MANIFEST.json new file mode 100644 index 0000000000000..be609f6fca29e --- /dev/null +++ b/src/wp-includes/automerge-php/UPSTREAM_TEST_MANIFEST.json @@ -0,0 +1,4835 @@ +{ + "library": "automerge", + "generatedAt": "2026-05-23T00:09:08Z", + "upstreamCommit": "44cd91582bd3ed9af05ef1a7843bb1074ad11112", + "total": 688, + "registeredTotal": 688, + "activeTotal": 680, + "portedPassing": 680, + "portedFailing": 0, + "portedPendingPassing": 1, + "portedPendingFailing": 0, + "portedIgnoredPassing": 7, + "portedIgnoredFailing": 0, + "todoUnported": 0, + "upstreamPending": 1, + "upstreamIgnored": 7, + "tests": [ + { + "id": "js:javascript/test/basic_test.ts:12:should-init-clone-and-free", + "source": "upstream/automerge/javascript/test/basic_test.ts:12", + "status": "ported-passing", + "phpTest": "tests/run.php:init clone and free creates independent PHP document values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:21:should-be-able-to-make-a-view-with-specifc-heads", + "source": "upstream/automerge/javascript/test/basic_test.ts:21", + "status": "ported-passing", + "phpTest": "tests/run.php:basic view materializes a document at specific heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:33:should-allow-you-to-change-a-clone-of-a-view", + "source": "upstream/automerge/javascript/test/basic_test.ts:33", + "status": "ported-passing", + "phpTest": "tests/run.php:basic clone of a view can be changed independently", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:44:handle-basic-set-and-read-on-root-object", + "source": "upstream/automerge/javascript/test/basic_test.ts:44", + "status": "ported-passing", + "phpTest": "tests/run.php:root map set/read materializes PHP array in insertion order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:66:should-be-able-to-insert-and-delete-a-large-number-of-properties", + "source": "upstream/automerge/javascript/test/basic_test.ts:66", + "status": "ported-passing", + "phpTest": "tests/run.php:root map delete keeps only the live property across repeated changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:82:can-detect-an-automerge-doc-with-isautomerge", + "source": "upstream/automerge/javascript/test/basic_test.ts:82", + "status": "ported-passing", + "phpTest": "tests/run.php:basic isAutomerge detects only native documents", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:94:it-should-recursively-freeze-the-document-if-requested", + "source": "upstream/automerge/javascript/test/basic_test.ts:94", + "status": "ported-passing", + "phpTest": "tests/run.php:basic freeze option recursively marks document materialization frozen", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:132:handle-basic-sets-over-many-changes", + "source": "upstream/automerge/javascript/test/basic_test.ts:132", + "status": "ported-passing", + "phpTest": "tests/run.php:basic root sets over many changes preserve scalar and typed values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:183:handle-overwrites-to-values", + "source": "upstream/automerge/javascript/test/basic_test.ts:183", + "status": "ported-passing", + "phpTest": "tests/run.php:root map overwrites retain the last scalar value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:200:handle-set-with-object-value", + "source": "upstream/automerge/javascript/test/basic_test.ts:200", + "status": "ported-passing", + "phpTest": "tests/run.php:object values materialize through root map set", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:210:handle-simple-list-creation", + "source": "upstream/automerge/javascript/test/basic_test.ts:210", + "status": "ported-passing", + "phpTest": "tests/run.php:list creation materializes an empty PHP list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:216:handle-simple-lists", + "source": "upstream/automerge/javascript/test/basic_test.ts:216", + "status": "ported-passing", + "phpTest": "tests/run.php:simple list values can be read and replaced", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:238:handle-simple-lists", + "source": "upstream/automerge/javascript/test/basic_test.ts:238", + "status": "ported-passing", + "phpTest": "tests/run.php:basic getChanges/applyChanges replays simple list assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:248:handle-text", + "source": "upstream/automerge/javascript/test/basic_test.ts:248", + "status": "ported-passing", + "phpTest": "tests/run.php:basic getChanges/applyChanges replays text splice at arbitrary root key", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:260:have-many-list-methods", + "source": "upstream/automerge/javascript/test/basic_test.ts:260", + "status": "ported-passing", + "phpTest": "tests/run.php:basic list convenience methods update materialized arrays", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:285:allows-access-to-the-backend", + "source": "upstream/automerge/javascript/test/basic_test.ts:285", + "status": "ported-passing", + "phpTest": "tests/run.php:basic backend access materializes the document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:292:lists-and-text-have-indexof", + "source": "upstream/automerge/javascript/test/basic_test.ts:292", + "status": "ported-passing", + "phpTest": "tests/run.php:basic lists and text expose indexOf semantics", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:300:get-change-metadata", + "source": "upstream/automerge/javascript/test/basic_test.ts:300", + "status": "ported-passing", + "phpTest": "tests/run.php:change metadata since heads matches decoded native changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:327:should-work-in-unstable", + "source": "upstream/automerge/javascript/test/basic_test.ts:327", + "status": "ported-passing", + "phpTest": "tests/run.php:basic load can explicitly allow a change with missing dependencies", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:342:it-should-handle-conflicts-the-same-in-merges-as-with-loads", + "source": "upstream/automerge/javascript/test/basic_test.ts:342", + "status": "ported-passing", + "phpTest": "tests/run.php:basic merge conflict materialization matches after save and load", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:384:should-not-copy-the-patchcallback", + "source": "upstream/automerge/javascript/test/basic_test.ts:384", + "status": "ported-passing", + "phpTest": "tests/run.php:basic clone does not copy patch callbacks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:396:should-generate-a-hash", + "source": "upstream/automerge/javascript/test/basic_test.ts:396", + "status": "ported-passing", + "phpTest": "tests/run.php:emptyChange advances document heads with a new hash", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:412:behave-like-arrays", + "source": "upstream/automerge/javascript/test/basic_test.ts:412", + "status": "ported-passing", + "phpTest": "tests/run.php:basic proxy lists behave like PHP arrays", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:526:should-obtain-the-same-conflicts-regardless-of-merge-order", + "source": "upstream/automerge/javascript/test/basic_test.ts:526", + "status": "ported-passing", + "phpTest": "tests/run.php:basic conflict values are stable regardless of merge order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:558:should-return-null-for-scalar-values", + "source": "upstream/automerge/javascript/test/basic_test.ts:558", + "status": "ported-passing", + "phpTest": "tests/run.php:basic object ids return null for scalar-like values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:567:should-return-root-for-the-root-object", + "source": "upstream/automerge/javascript/test/basic_test.ts:567", + "status": "ported-passing", + "phpTest": "tests/run.php:basic object id returns root for a native document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:571:should-return-non-null-for-map-list-text-and-objects", + "source": "upstream/automerge/javascript/test/basic_test.ts:571", + "status": "ported-passing", + "phpTest": "tests/run.php:basic object ids distinguish containers from text scalars", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:578:can-load-a-doc-without-checking-the-heads", + "source": "upstream/automerge/javascript/test/basic_test.ts:578", + "status": "ported-passing", + "phpTest": "tests/run.php:native save/load round trip hydrates a materialized document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:587:can-diff-a-document-with-before-and-hafter-heads", + "source": "upstream/automerge/javascript/test/basic_test.ts:587", + "status": "ported-passing", + "phpTest": "tests/run.php:basic diff emits text patches between before and after heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:606:should-be-the-same-as-saveincremental-since-heads-of-the-last-saveincremental", + "source": "upstream/automerge/javascript/test/basic_test.ts:606", + "status": "ported-passing", + "phpTest": "tests/run.php:basic saveSince matches saveIncremental from the last incremental heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:631:splice", + "source": "upstream/automerge/javascript/test/basic_test.ts:631", + "status": "ported-passing", + "phpTest": "tests/run.php:basic path splice does not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:638:updatetext", + "source": "upstream/automerge/javascript/test/basic_test.ts:638", + "status": "ported-passing", + "phpTest": "tests/run.php:basic path updateText does not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:645:getcursor", + "source": "upstream/automerge/javascript/test/basic_test.ts:645", + "status": "ported-passing", + "phpTest": "tests/run.php:basic getCursor does not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:650:getcursorposition", + "source": "upstream/automerge/javascript/test/basic_test.ts:650", + "status": "ported-passing", + "phpTest": "tests/run.php:basic getCursorPosition does not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:656:mark-unmark", + "source": "upstream/automerge/javascript/test/basic_test.ts:656", + "status": "ported-passing", + "phpTest": "tests/run.php:basic mark and unmark do not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:673:marks", + "source": "upstream/automerge/javascript/test/basic_test.ts:673", + "status": "ported-passing", + "phpTest": "tests/run.php:basic marks lookup does not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:678:marksat", + "source": "upstream/automerge/javascript/test/basic_test.ts:678", + "status": "ported-passing", + "phpTest": "tests/run.php:basic marksAt lookup does not mutate path arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:685:should-return-true-if-the-document-in-question-has-all-the-heads", + "source": "upstream/automerge/javascript/test/basic_test.ts:685", + "status": "ported-passing", + "phpTest": "tests/run.php:hasHeads returns true for heads present in the document history", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:692:should-return-false-if-the-document-does-not-have-the-heads", + "source": "upstream/automerge/javascript/test/basic_test.ts:692", + "status": "ported-passing", + "phpTest": "tests/run.php:hasHeads returns false for heads absent from another document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:703:should-return-the-correct-history", + "source": "upstream/automerge/javascript/test/basic_test.ts:703", + "status": "ported-passing", + "phpTest": "tests/run.php:basic topo history traversal returns merged change hashes in order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:730:should-return-a-decoded-representation-of-the-change", + "source": "upstream/automerge/javascript/test/basic_test.ts:730", + "status": "ported-passing", + "phpTest": "tests/run.php:basic inspectChange returns decoded change metadata", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:765:should-return-stats-about-the-document", + "source": "upstream/automerge/javascript/test/basic_test.ts:765", + "status": "ported-passing", + "phpTest": "tests/run.php:basic stats reports native change and operation counts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:778:should-return-the-document-at-its-correct-heads", + "source": "upstream/automerge/javascript/test/basic_test.ts:778", + "status": "ported-passing", + "phpTest": "tests/run.php:basic toJS returns each document at its own heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:792:should-treat-any-class-which-has-the-correct-symbol-as-a-immutablestring", + "source": "upstream/automerge/javascript/test/basic_test.ts:792", + "status": "ported-passing", + "phpTest": "tests/run.php:basic immutable string accepts symbol-compatible objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:821:should-export-rawstring-and-israwstring-for-backwards-compatibility", + "source": "upstream/automerge/javascript/test/basic_test.ts:821", + "status": "ported-passing", + "phpTest": "tests/run.php:basic RawString aliases ImmutableString semantics", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:829:should-export-a-predicate-to-check-if-something-is-an-immutablestring", + "source": "upstream/automerge/javascript/test/basic_test.ts:829", + "status": "ported-passing", + "phpTest": "tests/run.php:basic immutable string predicate distinguishes raw strings", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:842:it-should-be-able-to-roll-back-a-transaction", + "source": "upstream/automerge/javascript/test/basic_test.ts:842", + "status": "ported-passing", + "phpTest": "tests/run.php:basic transaction rollback preserves the original document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/basic_test.ts:855:it-should-be-able-to-handle-ints-and-floats-at-their-limits", + "source": "upstream/automerge/javascript/test/basic_test.ts:855", + "status": "ported-passing", + "phpTest": "tests/run.php:basic save load and change preserve integer and float edge values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:18:can-split-a-block", + "source": "upstream/automerge/javascript/test/block_test.ts:18", + "status": "ported-passing", + "phpTest": "tests/run.php:block split inserts a block boundary and exposes spans", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:61:can-join-a-block", + "source": "upstream/automerge/javascript/test/block_test.ts:61", + "status": "ported-passing", + "phpTest": "tests/run.php:block join removes a block boundary", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:81:allows-updating-all-blocks-at-once", + "source": "upstream/automerge/javascript/test/block_test.ts:81", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans replaces text and all block spans", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:135:emits-insert-patches-with-immutablestring-for-attribute-updatese", + "source": "upstream/automerge/javascript/test/block_test.ts:135", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans emits ImmutableString parent insert patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:179:should-update-marks", + "source": "upstream/automerge/javascript/test/block_test.ts:179", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans materializes marks in spans", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:200:allows-configuring-the-default-expand-value-of-created-marks", + "source": "upstream/automerge/javascript/test/block_test.ts:200", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans honors default mark expand none", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:225:should-allow-overriding-the-default-expand-on-a-per-mark-basis", + "source": "upstream/automerge/javascript/test/block_test.ts:225", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans honors per mark expand override", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:250:should-allow-omitting-any-part-of-the-update-spans-config", + "source": "upstream/automerge/javascript/test/block_test.ts:250", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans accepts partial or omitted mark config", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:291:when-loading-blocks", + "source": "upstream/automerge/javascript/test/block_test.ts:291", + "status": "ported-passing", + "phpTest": "tests/run.php:block attributes preserve ImmutableString attrs when loading blocks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:308:when-loading-spans", + "source": "upstream/automerge/javascript/test/block_test.ts:308", + "status": "ported-passing", + "phpTest": "tests/run.php:block attributes preserve ImmutableString metadata in spans", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:331:updates-the-document-even-if-the-only-change-was-to-a-block-attribute", + "source": "upstream/automerge/javascript/test/block_test.ts:331", + "status": "ported-passing", + "phpTest": "tests/run.php:block updateSpans metadata-only changes remain editable", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:371:should-show-historical-marks", + "source": "upstream/automerge/javascript/test/block_test.ts:371", + "status": "ported-passing", + "phpTest": "tests/run.php:block view shows historical marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/block_test.ts:388:can-allow-small-values-in-block-attributes", + "source": "upstream/automerge/javascript/test/block_test.ts:388", + "status": "ported-passing", + "phpTest": "tests/run.php:block attributes preserve very small floating point values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/bundle_test.ts:5:should-allow-saving-and-loading-a-bundle", + "source": "upstream/automerge/javascript/test/bundle_test.ts:5", + "status": "ported-passing", + "phpTest": "tests/run.php:bundle format saves selected changes and loads them incrementally", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/bundle_test.ts:27:should-allow-getting-the-list-of-changes-in-a-bundle", + "source": "upstream/automerge/javascript/test/bundle_test.ts:27", + "status": "ported-passing", + "phpTest": "tests/run.php:bundle format exposes inspectable changes by hash", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/bundle_test.ts:56:should-show-the-dependencies-of-a-bundle", + "source": "upstream/automerge/javascript/test/bundle_test.ts:56", + "status": "ported-passing", + "phpTest": "tests/run.php:bundle format reports dependencies outside the selected changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_at.ts:6:should-be-able-to-change-a-doc-at-a-prior-state", + "source": "upstream/automerge/javascript/test/change_at.ts:6", + "status": "ported-passing", + "phpTest": "tests/run.php:changeAt text splice merges with later text edits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_at.ts:22:should-leave-multiple-heads-intact-on-empty-changes", + "source": "upstream/automerge/javascript/test/change_at.ts:22", + "status": "ported-passing", + "phpTest": "tests/run.php:changeAt empty changes preserve forked document heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_at.ts:47:should-return-the-heads-of-the-change-document-from-changeat", + "source": "upstream/automerge/javascript/test/change_at.ts:47", + "status": "ported-passing", + "phpTest": "tests/run.php:changeAt returns the heads of the change document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_time.ts:7:should-default-to-current-timestamp", + "source": "upstream/automerge/javascript/test/change_time.ts:7", + "status": "ported-passing", + "phpTest": "tests/run.php:change metadata defaults to the current timestamp", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_time.ts:18:should-allow-user-provided-timestamp", + "source": "upstream/automerge/javascript/test/change_time.ts:18", + "status": "ported-passing", + "phpTest": "tests/run.php:change metadata accepts a user-provided timestamp", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_time.ts:27:should-allow-no-timestamp", + "source": "upstream/automerge/javascript/test/change_time.ts:27", + "status": "ported-passing", + "phpTest": "tests/run.php:change metadata can explicitly record no timestamp", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_time.ts:37:should-default-to-current-timestamp", + "source": "upstream/automerge/javascript/test/change_time.ts:37", + "status": "ported-passing", + "phpTest": "tests/run.php:emptyChange metadata defaults to the current timestamp", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_time.ts:48:should-allow-user-provided-timestamp", + "source": "upstream/automerge/javascript/test/change_time.ts:48", + "status": "ported-passing", + "phpTest": "tests/run.php:emptyChange metadata accepts a user-provided timestamp", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/change_time.ts:57:should-allow-no-timestamp", + "source": "upstream/automerge/javascript/test/change_time.ts:57", + "status": "ported-passing", + "phpTest": "tests/run.php:emptyChange metadata can explicitly record no timestamp", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/conflicts.ts:5:should-not-allow-updating-values-inside-a-conflict-outside-of-the-change-callback", + "source": "upstream/automerge/javascript/test/conflicts.ts:5", + "status": "ported-passing", + "phpTest": "tests/run.php:conflict inspection returns detached map values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/conflicts.ts:56:should-allow-updating-values-inside-a-conflicted-map", + "source": "upstream/automerge/javascript/test/conflicts.ts:56", + "status": "ported-passing", + "phpTest": "tests/run.php:conflicted map values can be updated together", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/conflicts.ts:100:should-allow-updating-values-inside-a-conflicted-list", + "source": "upstream/automerge/javascript/test/conflicts.ts:100", + "status": "ported-passing", + "phpTest": "tests/run.php:conflicted list element map values can be updated together", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:5:can-use-cursors-in-splice-calls", + "source": "upstream/automerge/javascript/test/cursors.ts:5", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor values can be used in splice calls after earlier text edits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:24:should-be-able-to-pass-a-doc-to-from-to-make-a-shallow-copy", + "source": "upstream/automerge/javascript/test/cursors.ts:24", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor from accepts an existing document as shallow copy input", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:37:can-use-cursors-in-common-text-operations", + "source": "upstream/automerge/javascript/test/cursors.ts:37", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor values support common text operations with backward deletes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:61:should-use-javascript-string-indices", + "source": "upstream/automerge/javascript/test/cursors.ts:61", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor splices use JavaScript UTF-16 string indices", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:76:patch-callbacks-inform-where-they-came-from", + "source": "upstream/automerge/javascript/test/cursors.ts:76", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor patch callbacks report their source operation", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:122:should-allow-dates-from-an-existing-document-to-be-used-in-another-document", + "source": "upstream/automerge/javascript/test/cursors.ts:122", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor Date values from one document can be reused in another change", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:135:getcursorposition-should-work", + "source": "upstream/automerge/javascript/test/cursors.ts:135", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor position resolves against a historical view", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:153:getcursor-should-respect-heads", + "source": "upstream/automerge/javascript/test/cursors.ts:153", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor creation respects view heads for before after start and end", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:178:should-allow-for-usage-of-start-end-cursors", + "source": "upstream/automerge/javascript/test/cursors.ts:178", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor start and end sentinels can drive text splices", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:197:should-allow-for-usage-of-move-before-after", + "source": "upstream/automerge/javascript/test/cursors.ts:197", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor move before and after survive text replacement", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:212:should-convert-negative-indices-into-a-start-cursor", + "source": "upstream/automerge/javascript/test/cursors.ts:212", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor creation clamps negative indices to start", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/cursors.ts:223:should-convert-indices-string-length-into-an-end-cursor", + "source": "upstream/automerge/javascript/test/cursors.ts:223", + "status": "ported-passing", + "phpTest": "tests/run.php:cursor creation clamps too-large indices to end", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/error.ts:5:proxy-handler-throws-an-error-not-a-string", + "source": "upstream/automerge/javascript/test/error.ts:5", + "status": "ported-passing", + "phpTest": "tests/run.php:error handling throws an exception object for invalid list assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/extra_api_tests.ts:6:should-allow-you-to-load-incrementally", + "source": "upstream/automerge/javascript/test/extra_api_tests.ts:6", + "status": "ported-passing", + "phpTest": "tests/run.php:extra API loadIncremental applies full and incremental native batches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:23:should-initially-be-an-empty-map", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:23", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization starts as an empty map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:28:should-allow-instantiating-from-an-existing-object", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:28", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization accepts an existing object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:34:should-allow-merging-of-an-object-initialized-with-from", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:34", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization merges an object initialized with from", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:40:should-allow-passing-an-actorid-when-instantiating-from-an-existing-object", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:40", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization preserves actor id from from", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:46:accepts-an-empty-object-as-initial-state", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:46", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization accepts an empty object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:51:accepts-an-array-as-initial-state-but-converts-it-to-an-object", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:51", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization converts array input to root map entries", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:57:accepts-strings-as-initial-values-but-treats-them-as-an-array-of-characters", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:57", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization converts string input to character entries", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:63:ignores-numbers-provided-as-initial-values", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:63", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization ignores numeric initial values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:69:ignores-booleans-provided-as-initial-values", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:69", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization ignores boolean initial values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:85:should-not-mutate-objects", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:85", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential changes do not mutate the input document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:91:changes-should-be-retrievable", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:91", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes expose the last local change", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:135:should-not-register-any-conflicts-on-repeated-assignment", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:135", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential repeated assignment records no conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:144:should-group-several-changes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:144", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes group multiple root assignments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:158:should-freeze-objects-if-desired", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:158", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy freeze rejects direct document mutation outside change", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:189:should-allow-repeated-reading-and-writing-of-values", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:189", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential repeated writes keep the final value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:201:should-not-record-conflicts-when-writing-the-same-field-several-times-within-one-change", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:201", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential same-field writes have no conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:211:should-return-the-unchanged-state-object-if-nothing-changed", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:211", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential no-op change returns the same document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:216:should-ignore-field-updates-that-write-the-existing-value", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:216", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential existing-value updates are ignored", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:222:should-not-ignore-field-updates-that-resolve-a-conflict", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:222", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root existing-value update resolves conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:237:should-ignore-list-element-updates-that-write-the-existing-value", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:237", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential list existing-value updates are ignored", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:243:should-not-ignore-list-element-updates-that-resolve-a-conflict", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:243", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy list existing-value update resolves conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:259:should-sanity-check-arguments", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:259", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy change API sanity-checks document root arguments", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:271:should-not-allow-nested-change-blocks", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:271", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy change API rejects nested and outdated change attempts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:288:should-not-allow-the-same-base-document-to-be-used-for-multiple-changes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:288", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy change API rejects reusing the same base document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:295:should-allow-a-document-to-be-cloned", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:295", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential clone can diverge independently", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:306:should-work-with-object-assign-merges", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:306", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential object assign style replacement works", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:316:should-support-date-objects-in-maps", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:316", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential supports Date objects in maps", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:325:should-support-date-objects-in-lists", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:325", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy sequential supports Date objects in lists", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:334:should-call-patchcallback-if-supplied", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:334", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy patch callback receives list assignment patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:374:should-call-a-patchcallback-set-up-on-document-initialisation", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:374", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy initialization-level patch callback receives string assignment patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:402:should-append-an-empty-change-to-the-history", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:402", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy emptyChange appends an empty change to history", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:413:should-reference-dependencies", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:413", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy emptyChange references merged dependencies", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:429:should-handle-single-property-assignment", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:429", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root handles single-property assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:437:should-allow-floating-point-values", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:437", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root allows floating point values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:442:should-handle-multi-property-assignment", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:442", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root handles multi-property assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:451:should-handle-root-property-deletion", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:451", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root handles property deletion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:464:should-follow-js-delete-behavior", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:464", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root follows JavaScript delete behavior", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:482:should-allow-the-type-of-a-property-to-be-changed", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:482", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root allows property type changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:493:should-not-error-on-empty-string-keys", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:493", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root allows empty string keys", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:502:should-not-allow-assignment-of-unsupported-datatypes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:502", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root rejects unsupported assignment datatypes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:521:should-assign-an-objectid-to-nested-maps", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:521", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps expose Automerge-shaped object ids", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:533:should-handle-assignment-of-a-nested-property", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:533", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps handle assignment of a nested property", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:547:should-handle-assignment-of-an-object-literal", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:547", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps handle assignment of an object literal", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:559:should-handle-assignment-of-multiple-nested-properties", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:559", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps handle multiple nested property assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:574:should-handle-arbitrary-depth-nesting", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:574", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps handle arbitrary-depth nesting", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:588:should-allow-an-old-object-to-be-replaced-with-a-new-one", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:588", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps allow object replacement", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:615:should-allow-fields-to-be-changed-between-primitive-and-nested-map", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:615", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps allow primitive map type changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:627:should-not-allow-several-references-to-the-same-map-object", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:627", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps reject references to existing document objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:647:should-not-allow-object-copying-idioms", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:647", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps reject object-copying idioms", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:664:should-handle-deletion-of-properties-within-a-map", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:664", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps handle deletion of properties within a map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:676:should-handle-deletion-of-references-to-a-map", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:676", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps handle deletion of references to a map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:690:should-allow-elements-to-be-inserted", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:690", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists allow elements to be inserted", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:704:should-handle-assignment-of-a-list-literal", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:704", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle assignment of a list literal", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:718:should-only-allow-numeric-indexes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:718", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists accept only numeric indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:738:should-handle-deletion-of-list-elements", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:738", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle deletion of list elements", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:753:should-handle-assignment-of-individual-list-indexes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:753", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle assignment of individual list indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:767:concurrent-edits-insert-in-reverse-actorid-order-if-counters-equal", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:767", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent list insertions with equal counters use reverse actor order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:778:concurrent-edits-insert-in-reverse-counter-order-if-different", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:778", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent list insertions with different counters use reverse counter order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:790:should-treat-out-by-one-assignment-as-insertion", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:790", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists treat out-by-one assignment as insertion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:800:should-not-allow-out-of-range-assignment", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:800", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists reject out-of-range assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:807:should-allow-bulk-assignment-of-multiple-list-indexes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:807", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists allow bulk assignment of multiple list indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:822:should-handle-nested-objects", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:822", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle nested objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:848:should-handle-nested-lists", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:848", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle nested lists", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:870:should-handle-deep-nesting", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:870", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy nested maps and lists handle deep mixed mutations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:911:should-handle-replacement-of-the-entire-list", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:911", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle replacement of the entire list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:932:should-allow-assignment-to-change-the-type-of-a-list-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:932", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists allow assignment to change the type of a list element", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:964:should-allow-list-creation-and-assignment-in-the-same-change-callback", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:964", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists allow list creation and assignment in one logical callback", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:972:should-allow-adding-and-removing-list-elements-in-the-same-change-callback", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:972", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists allow adding and removing list elements in one logical callback", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:994:should-handle-arbitrary-depth-nesting", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:994", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists handle arbitrary-depth nesting", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1010:should-not-allow-several-references-to-the-same-list-object", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1010", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy lists reject references to existing document objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1033:should-allow-deleting-counters-from-maps", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1033", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy counters can be incremented and deleted from nested maps", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1070:should-merge-concurrent-updates-of-different-properties", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1070", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent merge preserves updates of different root properties", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1083:should-add-concurrent-increments-of-the-same-property", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1083", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy counters add concurrent increments of the same property", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1097:should-add-increments-only-to-the-values-they-precede", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1097", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy counters add increments only to the values they precede", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1119:should-detect-concurrent-updates-of-the-same-field", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1119", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent same-field updates retain conflict values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1134:should-detect-concurrent-updates-of-the-same-list-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1134", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent updates of the same list element expose conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1151:should-handle-assignment-conflicts-of-different-types", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1151", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent same-field assignments retain different-type conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1164:should-handle-changes-within-a-conflicting-map-field", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1164", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent root conflicts keep nested map changes on the assigned object id", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1176:should-handle-changes-within-a-conflicting-list-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1176", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy conflicting list element maps retain nested changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1195:should-not-merge-concurrently-assigned-nested-maps", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1195", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent nested map assignments stay conflicted at the root", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1210:should-clear-conflicts-after-assigning-a-new-value", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1210", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy root assignment clears prior same-field conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1222:should-handle-concurrent-insertions-at-different-list-positions", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1222", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent insertions at different list positions merge cleanly", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1232:should-handle-concurrent-insertions-at-the-same-list-position", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1232", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent insertions at the same list position merge cleanly", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1247:should-handle-concurrent-assignment-and-deletion-of-a-map-entry", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1247", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent assignment and deletion of a map entry is add-wins", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1260:should-handle-concurrent-assignment-and-deletion-of-a-list-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1260", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent assignment and deletion of a list element is add-wins", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1278:should-handle-insertion-after-a-deleted-list-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1278", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy insertion after a concurrently deleted list element survives", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1293:should-handle-concurrent-deletion-of-the-same-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1293", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent deletion of the same list element is idempotent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1305:should-handle-concurrent-deletion-of-different-elements", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1305", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent deletion of different list elements removes both originals", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1317:should-handle-concurrent-updates-at-different-levels-of-the-tree", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1317", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy delete higher in a tree wins over a concurrent subtree update", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1343:should-handle-updates-of-concurrently-deleted-objects", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1343", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy updates inside concurrently deleted objects do not resurrect the object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1355:should-not-interleave-sequence-insertions-at-the-same-position", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1355", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy concurrent sequence insertions at the same position stay grouped", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1374:should-handle-insertion-by-greater-actor-id", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1374", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy list insertion works when the inserting actor id is greater", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1383:should-handle-insertion-by-lesser-actor-id", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1383", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy list insertion works when the inserting actor id is lesser", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1392:should-handle-insertion-regardless-of-actor-id", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1392", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy list insertion before an existing element is actor-id independent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1399:should-make-insertion-order-consistent-with-causality", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1399", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy causal list prepends maintain insertion order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1413:should-save-and-restore-an-empty-document", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1413", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load restores an empty document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1418:should-generate-a-new-random-actor-id", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1418", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load assigns a new actor id by default", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1432:should-allow-a-custom-actor-id-to-be-set", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1432", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load accepts a custom actor id", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1437:should-reconstitute-complex-datatypes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1437", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load reconstitutes nested list and map data", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1448:should-save-and-load-maps-with-symbols-in-the-keys", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1448", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load keeps map keys containing at-signs", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1457:should-reconstitute-conflicts", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1457", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load reconstitutes root conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1480:should-reconstitute-element-id-counters", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1480", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load reconstitutes element id counters", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1547:should-allow-a-reloaded-list-to-be-mutated", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1547", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load allows a reloaded list to be mutated", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1555:should-reload-a-document-containing-deflated-columns", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1555", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy save/load reloads a large inserted list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1570:should-call-patchcallback-if-supplied-to-load", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1570", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy load invokes a supplied patch callback", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1596:should-return-an-empty-history-for-an-empty-document", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1596", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy history returns an empty list for an empty document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1600:should-make-past-document-states-accessible", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1600", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy history exposes past document snapshots", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1618:should-make-change-messages-accessible", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1618", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy history exposes change messages", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1639:should-return-an-empty-list-on-an-empty-document", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1639", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API returns an empty list for an empty document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1644:should-return-an-empty-list-when-nothing-changed", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1644", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API returns an empty list when nothing changed", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1652:should-do-nothing-when-applying-an-empty-list-of-changes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1652", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API does nothing when applying an empty change list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1660:should-return-all-changes-when-compared-to-an-empty-document", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1660", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API returns all changes compared to an empty document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1673:should-allow-a-document-copy-to-be-reconstructed-from-scratch", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1673", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API reconstructs a document copy from scratch", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1687:should-return-changes-since-the-last-given-version", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1687", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API returns changes since the last given version", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1702:should-incrementally-apply-changes-since-the-last-given-version", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1702", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API incrementally applies changes since the last version", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1719:should-handle-updates-to-a-list-element", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1719", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API handles updates to a list element", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1734:should-handle-updates-to-a-text-object", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1734", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API handles updates to a text object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1764:should-report-missing-dependencies-with-out-of-order-applychanges", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1764", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy changes API reports missing dependencies for out-of-order applyChanges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1780:should-call-patchcallback-if-supplied-when-applying-changes", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1780", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy applyChanges invokes a supplied patch callback", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1820:should-merge-multiple-applied-changes-into-one-patch", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1820", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy applyChanges merges multiple string-list changes into one patch batch", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/legacy_tests.ts:1838:should-call-a-patchcallback-registered-on-doc-initialisation", + "source": "upstream/automerge/javascript/test/legacy_tests.ts:1838", + "status": "ported-passing", + "phpTest": "tests/run.php:legacy applyChanges invokes patch callback registered on document initialization", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/marks.ts:6:should-allow-marks-that-can-be-seen-in-patches", + "source": "upstream/automerge/javascript/test/marks.ts:6", + "status": "ported-passing", + "phpTest": "tests/run.php:marks patch callbacks expose mark splits and marked load splices", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/marks.ts:73:should-do-unicode-sensibly", + "source": "upstream/automerge/javascript/test/marks.ts:73", + "status": "ported-passing", + "phpTest": "tests/run.php:marks shift across unicode text splices", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/marks.ts:107:patches-properly-report-marks-on-end-of-expand-true", + "source": "upstream/automerge/javascript/test/marks.ts:107", + "status": "ported-passing", + "phpTest": "tests/run.php:marks expand at splice boundaries and report marked splice patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/new-change-api.ts:5:should-be-able-to-make-simple-changes-to-a-document", + "source": "upstream/automerge/javascript/test/new-change-api.ts:5", + "status": "ported-passing", + "phpTest": "tests/run.php:new change API supports simple root assignment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/new-change-api.ts:17:should-be-able-to-insert-into-a-list", + "source": "upstream/automerge/javascript/test/new-change-api.ts:17", + "status": "ported-passing", + "phpTest": "tests/run.php:new change API supports insertAt-style list insertion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/new-change-api.ts:25:should-be-able-to-delete-from-a-list", + "source": "upstream/automerge/javascript/test/new-change-api.ts:25", + "status": "ported-passing", + "phpTest": "tests/run.php:new change API supports deleteAt-style list deletion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/next_test.ts:5:should-expose-a-next-export-to-maintain-backwards-compatiblity-with-2-0", + "source": "upstream/automerge/javascript/test/next_test.ts:5", + "status": "ported-passing", + "phpTest": "tests/run.php:next export initializes native documents", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/next_test.ts:9:should-have-the-same-types-as-the-main-export", + "source": "upstream/automerge/javascript/test/next_test.ts:9", + "status": "ported-passing", + "phpTest": "tests/run.php:next export has the same public API as the main port", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:7:should-provide-access-to-before-and-after-states", + "source": "upstream/automerge/javascript/test/patches.ts:7", + "status": "ported-passing", + "phpTest": "tests/run.php:patch callback exposes before and after heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:27:should-provide-correct-before-and-after-states-when-an-array-has-a-value-deleted", + "source": "upstream/automerge/javascript/test/patches.ts:27", + "status": "ported-passing", + "phpTest": "tests/run.php:patch callback exposes before and after states for list deletion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:49:should-provide-correct-before-and-after-states-when-an-object-property-has-been-removed", + "source": "upstream/automerge/javascript/test/patches.ts:49", + "status": "ported-passing", + "phpTest": "tests/run.php:patch callback exposes before and after states for nested property deletion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:76:should-return-a-set-of-patches", + "source": "upstream/automerge/javascript/test/patches.ts:76", + "status": "ported-passing", + "phpTest": "tests/run.php:patch diff returns insertion and container patches between heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:96:should-throw-a-nice-exception-if-before-or-after-are-not-an-array", + "source": "upstream/automerge/javascript/test/patches.ts:96", + "status": "ported-passing", + "phpTest": "tests/run.php:patch diff rejects invalid before and after heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:120:should-allow-diffing-a-sub-object", + "source": "upstream/automerge/javascript/test/patches.ts:120", + "status": "ported-passing", + "phpTest": "tests/run.php:patch diffPath supports nested map scopes and shallow recursion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:201:should-correctly-diff-the-reverse-of-deleting-a-string-value-on-next", + "source": "upstream/automerge/javascript/test/patches.ts:201", + "status": "ported-passing", + "phpTest": "tests/run.php:patch diff reverses deletion of a string list value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:225:should-produce-correct-patches-during-changeat", + "source": "upstream/automerge/javascript/test/patches.ts:225", + "status": "ported-passing", + "phpTest": "tests/run.php:patch changeAt style updates do not mix stale scalar content", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:254:should-apply-a-map-update", + "source": "upstream/automerge/javascript/test/patches.ts:254", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a map update to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:267:should-apply-a-list-update-patch", + "source": "upstream/automerge/javascript/test/patches.ts:267", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list update to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:278:should-apply-a-list-insertion-patch", + "source": "upstream/automerge/javascript/test/patches.ts:278", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list insertion to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:289:should-apply-a-list-deletion-patch-without-length", + "source": "upstream/automerge/javascript/test/patches.ts:289", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list deletion without length to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:301:should-apply-a-list-deletion-patch-with-length", + "source": "upstream/automerge/javascript/test/patches.ts:301", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list deletion with length to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:314:should-apply-a-text-splice-patch", + "source": "upstream/automerge/javascript/test/patches.ts:314", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a text splice to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:325:should-apply-a-text-deletion-patch-without-length", + "source": "upstream/automerge/javascript/test/patches.ts:325", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a text deletion without length to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:335:should-apply-a-text-deletion-patch-with-length", + "source": "upstream/automerge/javascript/test/patches.ts:335", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a text deletion with length to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:346:should-apply-an-increment-patch", + "source": "upstream/automerge/javascript/test/patches.ts:346", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply an increment patch to a document counter", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:359:should-apply-a-mark-patch", + "source": "upstream/automerge/javascript/test/patches.ts:359", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a mark patch to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:380:should-apply-an-unmark-patch", + "source": "upstream/automerge/javascript/test/patches.ts:380", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply an unmark patch to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:405:should-apply-a-map-update-to-a-nested-map", + "source": "upstream/automerge/javascript/test/patches.ts:405", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a map update to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:416:should-apply-a-list-update-patch", + "source": "upstream/automerge/javascript/test/patches.ts:416", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list update to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:427:should-apply-a-list-insertion-patch", + "source": "upstream/automerge/javascript/test/patches.ts:427", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list insertion to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:438:should-apply-a-list-deletion-patch-without-length", + "source": "upstream/automerge/javascript/test/patches.ts:438", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list deletion without length to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:450:should-apply-a-list-deletion-patch-with-length", + "source": "upstream/automerge/javascript/test/patches.ts:450", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a list deletion with length to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:463:should-apply-a-text-splice-patch", + "source": "upstream/automerge/javascript/test/patches.ts:463", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a text splice to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:474:should-apply-a-text-deletion-patch-without-length", + "source": "upstream/automerge/javascript/test/patches.ts:474", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a text deletion without length to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:484:should-apply-a-text-deletion-patch-with-length", + "source": "upstream/automerge/javascript/test/patches.ts:484", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a text deletion with length to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:495:should-apply-an-increment-patch", + "source": "upstream/automerge/javascript/test/patches.ts:495", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply an increment patch to a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:506:should-ignore-a-mark-patch", + "source": "upstream/automerge/javascript/test/patches.ts:506", + "status": "ported-passing", + "phpTest": "tests/run.php:patches ignore a mark patch on a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:523:should-ignore-an-unmark-patch", + "source": "upstream/automerge/javascript/test/patches.ts:523", + "status": "ported-passing", + "phpTest": "tests/run.php:patches ignore an unmark patch on a plain PHP array", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/patches.ts:535:should-apply-a-map-update-to-a-map-in-a-list-in-a-map-in-a-list", + "source": "upstream/automerge/javascript/test/patches.ts:535", + "status": "ported-passing", + "phpTest": "tests/run.php:patches apply a deep map update to a document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:16:should-throw-a-useful-rangeerror-when-attempting-to-set-a-document-inside-itself", + "source": "upstream/automerge/javascript/test/proxies.ts:16", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy recursive document assignment is rejected", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:28:should-return-iterable-entries", + "source": "upstream/automerge/javascript/test/proxies.ts:28", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list entries iterator exposes indexes and values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:41:should-return-iterable-values", + "source": "upstream/automerge/javascript/test/proxies.ts:41", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list values iterator exposes ordered values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:53:should-return-iterable-keys", + "source": "upstream/automerge/javascript/test/proxies.ts:53", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list keys iterator exposes ordered indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:72:should-return-the-index-of-a-value-for-a-string-in-a-list-of-strings", + "source": "upstream/automerge/javascript/test/proxies.ts:72", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list indexOf returns matching string index", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:78:should-return-1-if-the-value-is-not-found", + "source": "upstream/automerge/javascript/test/proxies.ts:78", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list indexOf returns -1 for a missing value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:86:should-be-able-to-remove-a-defined-number-of-list-entries", + "source": "upstream/automerge/javascript/test/proxies.ts:86", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list splice removes a defined number of entries", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:95:should-be-able-to-remove-a-defined-number-of-list-entries-and-add-new-ones", + "source": "upstream/automerge/javascript/test/proxies.ts:95", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list splice replaces removed entries with new values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:104:should-be-able-to-insert-new-values", + "source": "upstream/automerge/javascript/test/proxies.ts:104", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list splice inserts new values without deletion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:113:should-work-with-only-a-start-parameter", + "source": "upstream/automerge/javascript/test/proxies.ts:113", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list splice with only a start removes through the end", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:122:should-throw-a-useful-rangeerror-when-attempting-to-splice-undefined-values", + "source": "upstream/automerge/javascript/test/proxies.ts:122", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list splice rejects undefined inserted values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:133:does-allow-null-values", + "source": "upstream/automerge/javascript/test/proxies.ts:133", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy map allows null values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:141:does-not-allow-undefined-values", + "source": "upstream/automerge/javascript/test/proxies.ts:141", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy map rejects undefined values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:150:should-print-the-property-path-in-the-error-when-setting-an-undefined-key", + "source": "upstream/automerge/javascript/test/proxies.ts:150", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy map undefined errors include the property path", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:161:should-print-the-property-path-in-the-error-when-setting-an-undefined-key", + "source": "upstream/automerge/javascript/test/proxies.ts:161", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list undefined errors include the property path", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:170:should-support-at-to-access-values", + "source": "upstream/automerge/javascript/test/proxies.ts:170", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy list at returns values by index", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/proxies.ts:181:should-support-objects-cloned-with-structuredclone", + "source": "upstream/automerge/javascript/test/proxies.ts:181", + "status": "ported-passing", + "phpTest": "tests/run.php:proxy structured clone input materializes as a nested map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:54:should-send-a-sync-message-implying-no-local-data", + "source": "upstream/automerge/javascript/test/sync_test.ts:54", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol sends an empty-data sync message", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:68:should-not-reply-after-the-first-round-if-we-have-no-data-as-well", + "source": "upstream/automerge/javascript/test/sync_test.ts:68", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol does not keep replying for two empty documents", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:91:repos-with-equal-heads-do-not-need-a-reply-message", + "source": "upstream/automerge/javascript/test/sync_test.ts:91", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol equal heads do not need a reply message", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:127:n1-should-offer-all-changes-to-n2-when-starting-from-nothing", + "source": "upstream/automerge/javascript/test/sync_test.ts:127", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol offers all changes to an empty peer", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:141:should-sync-peers-where-one-has-commits-the-other-does-not", + "source": "upstream/automerge/javascript/test/sync_test.ts:141", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol synchronizes peers when one has commits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:155:should-work-with-prior-sync-state", + "source": "upstream/automerge/javascript/test/sync_test.ts:155", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol converges with prior sync state", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:175:should-not-generate-messages-once-synced", + "source": "upstream/automerge/javascript/test/sync_test.ts:175", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol does not generate messages once synced", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:219:should-allow-simultaneous-messages-during-synchronization", + "source": "upstream/automerge/javascript/test/sync_test.ts:219", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol preserves independent diverged peer changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:299:should-assume-sent-changes-were-recieved-until-we-hear-otherwise", + "source": "upstream/automerge/javascript/test/sync_test.ts:299", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol continues sending unacknowledged local changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:327:should-work-regardless-of-who-initiates-the-exchange", + "source": "upstream/automerge/javascript/test/sync_test.ts:327", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol works regardless of which peer initiates later exchange", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:350:should-work-without-prior-sync-state", + "source": "upstream/automerge/javascript/test/sync_test.ts:350", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol converges diverged documents without prior sync state", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:374:should-work-with-prior-sync-state", + "source": "upstream/automerge/javascript/test/sync_test.ts:374", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol converges diverged documents with prior sync state", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:403:should-ensure-non-empty-state-after-sync", + "source": "upstream/automerge/javascript/test/sync_test.ts:403", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol records shared heads after synchronization", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:417:should-re-sync-after-one-node-crashed-with-data-loss", + "source": "upstream/automerge/javascript/test/sync_test.ts:417", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol resyncs after peer crash with older data", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:459:should-resync-after-one-node-experiences-data-loss-without-disconnecting", + "source": "upstream/automerge/javascript/test/sync_test.ts:459", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol resyncs after peer data loss without reconnect", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:482:should-handle-changes-concurrent-to-the-last-sync-heads", + "source": "upstream/automerge/javascript/test/sync_test.ts:482", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol handles changes concurrent to last sync heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:518:should-handle-histories-with-lots-of-branching-and-merging", + "source": "upstream/automerge/javascript/test/sync_test.ts:518", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol handles branching and merging histories", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:565:should-handle-a-false-positive-head", + "source": "upstream/automerge/javascript/test/sync_test.ts:565", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol explicitly recovers a false-positive advertised head", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:657:should-sync-two-nodes-without-connection-reset", + "source": "upstream/automerge/javascript/test/sync_test.ts:657", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol converges two nodes without connection reset", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:664:should-sync-two-nodes-with-connection-reset", + "source": "upstream/automerge/javascript/test/sync_test.ts:664", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol converges two nodes with connection reset", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:672:should-sync-three-nodes", + "source": "upstream/automerge/javascript/test/sync_test.ts:672", + "status": "ported-pending-passing", + "phpTest": "tests/run.php:pending sync protocol surfaces an unresolved false-positive branch to a third node", + "notes": "Upstream JavaScript pending test has optional native PHP parity coverage." + }, + { + "id": "js:javascript/test/sync_test.ts:701:should-not-require-an-additional-request-when-a-false-positive-depends-on-a-true-negative", + "source": "upstream/automerge/javascript/test/sync_test.ts:701", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol resolves a false-positive dependency without an extra request", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:769:should-handle-chains-of-false-positives", + "source": "upstream/automerge/javascript/test/sync_test.ts:769", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol handles chains of false-positive dependencies", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:818:should-allow-the-false-positive-hash-to-be-explicitly-requested", + "source": "upstream/automerge/javascript/test/sync_test.ts:818", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol allows explicitly requesting a false-positive hash", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:882:should-allow-multiple-bloom-filters", + "source": "upstream/automerge/javascript/test/sync_test.ts:882", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol suppresses duplicate in-flight changes from multiple have filters", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:956:should-allow-any-change-to-be-requested", + "source": "upstream/automerge/javascript/test/sync_test.ts:956", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol sends explicitly requested known changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:985:should-ignore-requests-for-a-nonexistent-change", + "source": "upstream/automerge/javascript/test/sync_test.ts:985", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol ignores requests for nonexistent changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:1022:should-allow-a-subset-of-changes-to-be-sent", + "source": "upstream/automerge/javascript/test/sync_test.ts:1022", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol requests remaining heads after a subset response", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:1096:should-report-whether-the-other-end-has-our-changes", + "source": "upstream/automerge/javascript/test/sync_test.ts:1096", + "status": "ported-passing", + "phpTest": "tests/run.php:sync protocol reports when peers have our changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:1139:should-not-apply-incoming-changes-when-read-only", + "source": "upstream/automerge/javascript/test/sync_test.ts:1139", + "status": "ported-passing", + "phpTest": "tests/run.php:sync read-only state does not apply incoming changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:1155:should-discover-peer-read-only-status", + "source": "upstream/automerge/javascript/test/sync_test.ts:1155", + "status": "ported-passing", + "phpTest": "tests/run.php:sync read-only state is reported to the peer", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/sync_test.ts:1172:should-allow-switching-from-read-only-to-read-write", + "source": "upstream/automerge/javascript/test/sync_test.ts:1172", + "status": "ported-passing", + "phpTest": "tests/run.php:sync read-only state can switch back to read-write", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:17:should-support-insertion", + "source": "upstream/automerge/javascript/test/text_test.ts:17", + "status": "ported-passing", + "phpTest": "tests/run.php:text insertion exposes length, index access, and string materialization", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:25:should-support-deletion", + "source": "upstream/automerge/javascript/test/text_test.ts:25", + "status": "ported-passing", + "phpTest": "tests/run.php:text deletion removes the visible character at the splice range", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:36:should-support-implicit-and-explicit-deletion", + "source": "upstream/automerge/javascript/test/text_test.ts:36", + "status": "ported-passing", + "phpTest": "tests/run.php:text zero-length splice after deletion is a no-op", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:48:should-handle-concurrent-insertion", + "source": "upstream/automerge/javascript/test/text_test.ts:48", + "status": "ported-passing", + "phpTest": "tests/run.php:concurrent text insertion deterministically preserves both branches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:60:should-handle-text-and-other-ops-in-the-same-change", + "source": "upstream/automerge/javascript/test/text_test.ts:60", + "status": "ported-passing", + "phpTest": "tests/run.php:text and scalar root operations can happen in the same document state", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:70:should-serialize-to-json-as-a-simple-string", + "source": "upstream/automerge/javascript/test/text_test.ts:70", + "status": "ported-passing", + "phpTest": "tests/run.php:document JSON encoding serializes text as a plain string", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:77:should-allow-modification-after-an-object-is-assigned-to-a-document", + "source": "upstream/automerge/javascript/test/text_test.ts:77", + "status": "ported-passing", + "phpTest": "tests/run.php:text can be modified after assignment to a document root key", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:87:should-not-allow-modification-outside-of-a-change-callback", + "source": "upstream/automerge/javascript/test/text_test.ts:87", + "status": "ported-passing", + "phpTest": "tests/run.php:public text splice rejects documents outside a change callback", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:95:should-initialize-text-in-automerge-from", + "source": "upstream/automerge/javascript/test/text_test.ts:95", + "status": "ported-passing", + "phpTest": "tests/run.php:from initializes text values with string length and index access", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:105:should-encode-the-initial-value-as-a-change", + "source": "upstream/automerge/javascript/test/text_test.ts:105", + "status": "ported-passing", + "phpTest": "tests/run.php:from encodes the initial root as one replayable native change", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:115:should-support-unicode-when-creating-text", + "source": "upstream/automerge/javascript/test/text_test.ts:115", + "status": "ported-passing", + "phpTest": "tests/run.php:unicode text values survive root materialization", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:122:should-allow-splicing-into-text-in-arrays", + "source": "upstream/automerge/javascript/test/text_test.ts:122", + "status": "ported-passing", + "phpTest": "tests/run.php:text splice can target string values nested in arrays", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:132:should-calculate-a-diff-when-updating-text", + "source": "upstream/automerge/javascript/test/text_test.ts:132", + "status": "ported-passing", + "phpTest": "tests/run.php:updateText merges non-overlapping replacements from concurrent actors", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "js:javascript/test/text_test.ts:148:should-handle-multi-character-grapheme-clusters", + "source": "upstream/automerge/javascript/test/text_test.ts:148", + "status": "ported-passing", + "phpTest": "tests/run.php:updateText handles multi-character grapheme clusters", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-commit-with-line-638", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust AutoCommit commit_with doc example records message and time", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-diff-incremental-line-319", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust AutoCommit diffIncremental example advances the cursor", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-diff-line-223", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust AutoCommit diff cursor example emits patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-autoserde-rs-autoserde-autoserde-line-9", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust AutoSerde serializes a root map as JSON", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-lib-rs-line-117", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust lib address book example saves nested contacts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-lib-rs-line-147", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust lib address book merge example preserves independent nested edits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-patches-patch-log-rs-patches-patch-log-patchlog-line-28-compile", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust patch log doc example records patches from a sync receive", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-document-rs-storage-document-document-a-parse-line-54", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage document parse doc example materializes a parsed fixture", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-input-a-split-line-264", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage parse split doc example separates chunk bytes from remaining input", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-17", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage parse doc example consumes three bytes and leaves remaining input", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-56", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage parse error doc example wraps parser-specific errors", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-69", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage parse lift doc example maps application errors and preserves incomplete errors", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-range-of-line-561", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage parse range_of doc example records the consumed byte range", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-split-remaining-line-325", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-ignored-passing", + "phpTest": "tests/run.php:ignored Rust storage parse split remaining doc example exposes post-split backing bytes", + "notes": "Upstream Rust ignored test has optional native PHP parity coverage." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-sync-rs-sync-line-25", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync protocol doc example converges a new peer", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:doc-tests-automerge:automerge-src-transaction-manual-transaction-rs-transaction-manual-transaction-transaction-commit-with-line-83", + "source": "upstream/automerge/rust runtime:doc-tests:automerge", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transaction commit_with doc example records message and time", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-equivalent-to-individual-ops", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch init map is equivalent to individual root operations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-flat", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch init map creates flat root keys", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-generates-patches", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch init map generates root patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-nested", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch init map creates nested root objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-survives-save-load", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch init map survives save and load", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-init-map-with-text", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch init map supports text values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-deeply-nested", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert supports deeply nested maps", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-empty-list", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates an empty list object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-empty-map", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates an empty map object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-empty-text", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates an empty text object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-equivalent-to-individual-ops", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert is equivalent to individual nested operations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-flat-list", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates a flat list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-flat-map", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates a flat map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-generates-patches", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert generates root container patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-existing-list", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert into an existing list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-existing-map", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert into an existing map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-list-at-end", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert appends an object to an existing list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-into-list-at-middle", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert inserts an object into the middle of a list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-list-of-lists", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert supports a list of lists", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-list-with-nested-objects", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates a list with nested objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-map-overwrites-existing-key", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert map overwrites an existing root key", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-matches-hydrate-output", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert hydrate output materializes matching data", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-merges-correctly", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert merges independent root objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-mixed-nesting", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert supports mixed map and list nesting", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-nested-maps", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert creates nested maps", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-scalar-fails", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert rejects scalar object creation", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-survives-save-load", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert survives save and load", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-text-generates-splice-patch", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert text generates splice patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-text-in-list", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert supports text values in a list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-transaction-rollback", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert transaction rollback discards the draft", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-various-scalar-types", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert supports various scalar types", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-with-text", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert supports text values in a map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-insert-with-transaction", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch insert works inside a committed transaction", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-put-overwrite-with-nested-structure", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch put overwrites a list element with nested structure", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:batch-put-overwrites-existing-list-element", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust batch put overwrites an existing list element", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:multiple-batch-inserts", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust multiple batch inserts preserve all root objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-deeply-nested", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice inserts deeply nested list values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-delete-and-insert", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice deletes and inserts list values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-delete-only", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice deletes list values without insertion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-insert-mixed", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice inserts mixed list values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-insert-objects", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice inserts object list values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-insert-scalars", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice inserts scalar list values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-merges-correctly", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice merges concurrent list insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-survives-save-load", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice survives save and load", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:splice-with-text", + "source": "upstream/automerge/rust runtime:tests/batch_insert.rs (target/debug/deps/batch_insert-5dd7718f4978a0d4)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust splice inserts text values into a list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:diff-emits-block-updates", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block diff emits block insertion updates", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:marks-in-spans-cross-block-markers", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust marks in spans cross block markers", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:marks-on-spans-respect-heads", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust spans at heads respect historical marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:merge-produces-block-insertion-diffs", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust merge produces block insertion diffs", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-which-are-empty-due-to-deleted-marks", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust spans consolidate deleted mark gaps", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-with-deleted-marks-followed-by-empty-marks", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust spans consolidate a fully deleted mark interval", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-with-empty-marks-followed-by-deleted-marks", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust spans consolidate a fully deleted trailing mark interval", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:test-mark-behavior-on-delete-insert", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust deleted marked text does not mark a later insertion at the same index", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:test-splice-with-mark", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust marked splice replacement keeps only expanding marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:text-complex-block-properties", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block metadata supports complex text-like properties", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-change-block-properties", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block updateSpans changes block properties", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-noop", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block updateSpans noop leaves diff cursor quiet", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-marks", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block updateSpans updates marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-text", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block updateSpans updates text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-text-and-blocks-at-once", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block updateSpans updates text and blocks together", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-delete-attribute", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust updateSpans deletes a block attribute list entry", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-diffs-marks", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust updateSpans diffs existing and desired marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-uses-expand-config", + "source": "upstream/automerge/rust runtime:tests/block_tests.rs (target/debug/deps/block_tests-405aaf2cd395742f)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust block updateSpans accepts upstream after expand config", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:test-does-not-add-size-when-strings-are-not-converted", + "source": "upstream/automerge/rust runtime:tests/convert_string_to_text.rs (target/debug/deps/convert_string_to_text-1c3ac8392298535c)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust string migration does not grow an empty document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:test-strings-in-lists-are-converted-to-text", + "source": "upstream/automerge/rust runtime:tests/convert_string_to_text.rs (target/debug/deps/convert_string_to_text-1c3ac8392298535c)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust string migration converts list strings to text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:test-strings-in-maps-are-converted-to-text", + "source": "upstream/automerge/rust runtime:tests/convert_string_to_text.rs (target/debug/deps/convert_string_to_text-1c3ac8392298535c)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust string migration converts map strings to text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:adjacent-marks-merge", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks merges adjacent same-valued marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:adjacent-marks-stay-separate", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks keeps separated same-valued marks apart", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:alternating-mark-changes", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks alternates mark changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:block-properties-change-with-marks", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks changes block properties while preserving marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:block-with-marked-content", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks supports marked block content", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:complex-unicode-text", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks handles complex unicode text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:different-adjacent-marks", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks keeps different adjacent marks separate", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:empty-spans-between-marks", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks removes empty spans between equal marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:idempotent-update-spans", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks updateSpans is idempotent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:many-marks-on-same-text", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks preserves many marks on the same text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-contracts", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks contracts an existing mark range", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-ends-at-block-boundary", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks ends a mark at a block boundary", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-expands", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks expands an existing mark range", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-on-empty-string", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks ignores marks on empty strings", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-on-whitespace", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks applies marks to whitespace text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-shifts-position", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks shifts a mark position", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-spans-across-block", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks spans a mark across a block marker", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-splits", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks splits one mark into two ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-value-changes-color", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks changes a color mark value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-value-changes-link-url", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks changes a link mark value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:mark-value-type-changes", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks changes a mark value type", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-on-combining-characters", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks applies marks to accented text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-on-emoji", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks applies marks to emoji graphemes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-survive-block-updates", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks survive block updates", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-with-different-values-same-name", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks preserves same-name different values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:marks-with-expand-none-at-boundaries", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks respects expand none at boundaries", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:multiple-marks-different-expand-behaviors", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks combines different expand behaviors", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:nested-marks", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks preserves nested marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:overlapping-marks-add-third-mark", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks adds a third overlapping mark", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:overlapping-marks-change-boundaries", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks changes overlapping mark boundaries", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:overlapping-marks-remove-one-keep-other", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks removes one overlapping mark and keeps another", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:removing-all-text-from-marked-span", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks removes all text from a marked span", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:unmark-creates-gaps", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks creates gaps when unmarking", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:unmark-part-of-range", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks unmarks part of a range", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:update-spans-which-inserts-at-the-end-of-expand-mark-doesnt-generate-mark-changes", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks boundary insert expands existing mark", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:update-spans-with-only-blocks", + "source": "upstream/automerge/rust runtime:tests/diff_marks.rs (target/debug/deps/diff_marks-faa6fc17c830fc19)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust diff marks updates spans to only blocks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-mark-patches-rs-target-debug-deps-test-mark-patches-5c7d6b43cf1dbe46:mark-patches-at-end-of-text", + "source": "upstream/automerge/rust runtime:tests/test_mark_patches.rs (target/debug/deps/test_mark_patches-5c7d6b43cf1dbe46)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust mark patches at end of text survive incremental load", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:add-concurrent-increments-of-same-property", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent increments of the same property are added", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:add-increments-only-to-preceeded-values", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust increments only apply to preceding counter values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:allows-empty-keys-in-mappings", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust mappings allow empty string keys", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:applying-changes-with-patch-log-from-another-document-returns-error-not-panic", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust applying changes with a patch log from another document reports mismatch", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:assignment-conflicts-of-different-types", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust assignment conflicts of different types expose all contenders", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:bad-change-on-optree-node-boundary", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust repeated optree-boundary map updates apply after save-load", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:big-list", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust big_list patch log reports a large list insertion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:can-isolate", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust isolate hides later heads until explicit integration", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:can-transaction-at", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transaction_at applies historical text and scalar edits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:changes-within-conflicting-list-element", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust changes within a conflicting list element stay attached", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:changes-within-conflicting-map-field", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust changes within a conflicting map field stay attached", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-assignment-and-deletion-of-a-map-entry", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent assignment and deletion of a map entry is add-wins", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-assignment-and-deletion-of-list-entry", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent assignment and deletion of a list entry is add-wins", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-deletion-of-same-list-element", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent deletion of the same list element is idempotent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-insertions-at-different-list-positions", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent insertions at different list positions merge cleanly", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-insertions-at-same-list-position", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent insertions at the same list position merge cleanly", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-at-different-levels", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent updates at different tree levels prefer the delete", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-of-concurrently-deleted-objects", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent update under a deleted object does not resurrect it", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-of-same-field", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent updates of the same field expose both values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrent-updates-of-same-list-element", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent updates of the same list element expose both values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:concurrently-assigned-nested-maps-should-not-merge", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrently assigned nested maps do not merge", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:delete-only-change", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust delete-only list change survives load and later insert", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-block-in-text-correctly", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust reverse diff reinserts deleted block marker in text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-object-in-list-correctly", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust reverse diff reinserts deleted text object in a list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-object-in-map-correctly", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust reverse diff reinserts deleted text object in a map", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:does-not-interleave-sequence-insertions-at-same-position", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust same-position sequence insertions do not interleave", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:fuzz-crashers", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust load rejects fuzz-crasher fixture bytes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:get-changes-with-hash-of-empty-change-produces-correct-result", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust get changes after an empty-change head is empty", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:get-marks-at-heads", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust marks can be queried at historical heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:handle-repeated-out-of-order-changes", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust repeated out-of-order changes converge after dependencies arrive", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:has-our-changes", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync state reports acknowledged local changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:increment-non-counter-list", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust incrementing non-counter list values fails but counters still increment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:increment-non-counter-map", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust incrementing non-counter map values fails but counters still increment", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:insert-after-many-deletes", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust insertion after many map deletes keeps the container valid", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:inserting-text-near-deleted-marks", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust inserting text near deleted marks keeps text coherent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:insertion-after-a-deleted-list-element", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust insertion after a deleted list element survives", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:insertion-consistent-with-causality", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust causal list insertions preserve insertion order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:invalid-index", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust list operations reject invalid indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:large-patches-in-lists-are-correct", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust large list patches count string list elements as one slot", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:list-counter-del", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust list counter conflicts can be incremented and deleted", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:list-deletion", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust list deletion removes the selected element", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust load materializes two-change storage fixtures", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load-doc-with-deleted-objects", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust load document with deleted root objects succeeds", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load-incremental-with-corrupted-tail", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust load incremental applies complete prefix and ignores corrupted tail", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:make-sure-load-incremental-doesnt-skip-a-load-with-a-common-head", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust incremental load keeps concurrent heads when one head is common", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:marks", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust marks survive expansion unmarking and prefix insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:merge-concurrent-map-prop-updates", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust concurrent map property updates merge", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:missing-actors-when-docs-are-forked", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust forked documents save without missing actor ids", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:mutliple-insertions-at-same-list-position-with-insertion-by-greater-actor-id", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust same-position insertion by greater actor id prepends to the list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:mutliple-insertions-at-same-list-position-with-insertion-by-lesser-actor-id", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust same-position insertion by lesser actor id prepends to the list", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:negative-64", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust negative 64-bit-adjacent integer stores and loads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:no-conflict-on-repeated-assignment", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust repeated map assignment has no conflict", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:obj-id-64bits", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust 64-bit object ids do not truncate to root", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:observe-counter-change-application", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust counter changes can be applied to a fresh document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:ops-on-wrong-objets", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust operations on the wrong object type are rejected", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:overlong-leb", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust load rejects overlong counter LEB128 fixture encodings", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:owned-transaction-with-patch-log-from-another-document-does-not-panic", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction with a patch log from another document reports mismatch", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:regression-insert-opid", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust regression insert opid replays list insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:regression-nth-miscount", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust regression nth miscount resolves nested list maps", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:regression-nth-miscount-smaller", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust regression nth miscount smaller resolves overwritten list scalars", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:repeated-list-assignment-which-resolves-conflict-not-ignored", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust repeated list assignment resolves existing conflict", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:repeated-map-assignment-which-resolves-conflict-not-ignored", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust repeated map assignment resolves existing conflict", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:reproduce-clock-cache-bug", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust change graph clock cache regression keeps merged branch ancestors covered", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:rollback-with-no-ops", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transaction rollback with no ops after merge is stable", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:rollback-with-several-actors", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust rollback with several actors discards complex draft", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-and-load-incremented-counter", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save and load incremented counter change metadata", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-and-reload-create-object", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save and reload preserves object created without child operations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-and-restore-empty", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save and restore empty document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-restore-complex-transactional", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transactional save/load preserves concurrent todo map conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-restore-complex1", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save/load preserves concurrent todo map conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-with-empty-commits", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save with empty commits loads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-with-ops-which-reference-actors-only-via-delete", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save with delete-only actor references loads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:simple-bad-saveload", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust simple no-op bad save/load regression loads successfully", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:stats-smoke-test", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust stats reports change and operation counts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-change-encoding-expanded-change-round-trip", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust change encoding expanded change round-trips raw storage bytes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-compressed-changes", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust compressed change chunks inflate to the original raw change bytes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-compressed-doc-cols", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust compressed document columns ignore deflate bit while preserving storage order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-get-change-meta", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust get change metadata since heads returns the next change", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-get-last-local-change-generation", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust last local change hash matches document heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-load-incremental-partial-load", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust partial incremental load applies selected changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-local-inc-in-map", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust local map increment applies to counter conflicts only", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-merging-test-conflicts-then-saving-and-loading", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text splice save/load keeps loaded edits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-overwriting-a-conflict", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust overwriting identical scalar conflicts clears duplicates", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:transaction-at-with-patch-log-from-another-document-does-not-panic", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transactionAt with a patch log from another document reports mismatch", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:transaction-with-patch-log-from-another-document-does-not-panic", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transaction with a patch log from another document reports mismatch", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:zero-length-data", + "source": "upstream/automerge/rust runtime:tests/test.rs (target/debug/deps/test-769fe2d72b51cc45)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust zero-length strings and bytes store as values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:discard-orphans", + "source": "upstream/automerge/rust runtime:tests/test_save_load_orphans.rs (target/debug/deps/test_save_load_orphans-f9b6758020e89e58)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save can discard orphaned changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:load-incremental-change-without-deps-throws", + "source": "upstream/automerge/rust runtime:tests/test_save_load_orphans.rs (target/debug/deps/test_save_load_orphans-f9b6758020e89e58)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust loading standalone incremental change without deps throws", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:save-orphaned-changes", + "source": "upstream/automerge/rust runtime:tests/test_save_load_orphans.rs (target/debug/deps/test_save_load_orphans-f9b6758020e89e58)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust save retains orphaned changes until missing deps arrive", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:cursors", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding cursors report encoded positions after edits", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:delete", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding delete maps code point code unit and grapheme indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:get", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding get maps code point code unit and grapheme indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:insert", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding insert maps code point code unit and grapheme indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:length", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding length counts code points code units and graphemes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:mark", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding mark maps code point code unit and grapheme ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-delete", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding delete patches report encoded indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-insert", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding insert patches report encoded indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-mark", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding mark patches report encoded ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-put-seq", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding putSeq patches report encoded indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-splice-text", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding splice patches report encoded indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:put", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding put maps code point code unit and grapheme indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:splice-text", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding splice maps code point code unit and grapheme indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:split-block", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding splitBlock maps code point code unit and grapheme indexes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:unmark", + "source": "upstream/automerge/rust runtime:tests/text_encoding.rs (target/debug/deps/text_encoding-3bef81c1b0c759ee)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text encoding unmark maps code point code unit and grapheme ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:deleting-in-middle-of-multibyte-char-moves-the-cursor-to-after-the-character", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust UTF-16 splice deletion inside multibyte characters snaps after the character", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:empty-marks-before-block-marker-dont-repeat-text", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text empty marks before block markers do not repeat text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:expand-marks-are-reported-in-patches", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text expand-both marks cover boundary insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:incorrect-patches-produced-when-isolating-and-integrating", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust isolate integrate patch log omits stale conflicted text", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:incremental-splice-patches-include-marks", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text incremental splices inherit active marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:insertions-after-noexpand-spans-are-not-marked", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text insertions after noexpand spans stay unmarked", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:local-patches-created-for-marks", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text local patches are segmented by marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:mark-created-after-insertion", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text can create separated marks after insertion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:marks-are-okay", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text marks property keeps spans consolidated", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:marks-which-cross-optree-boundaries-are-not-double-counted-in-splice-patches", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text far-end insertions after crossed marks stay unmarked", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:noexpand-marks-at-the-end-of-text-should-not-emit-marked-patches-on-following-insertions", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text noexpand terminal mark excludes following insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:removed-marks-should-not-appear-in-get-marks", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text removed marks do not appear in get marks", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:simple-update-text", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text updateText merges independent replacements", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:spans-are-consolidated-in-the-presence-of-zero-length-spans", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text consolidates zero-length mark spans", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:splicing-into-multibyte-characters", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust legacy multi-character text op splices on op boundaries", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:test-remote-patches-for-marks-with-expand-after", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text remote expand-after mark merge matches local insertion", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:update-text-big-ole-graphemes", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text updateText merges multicodepoint graphemes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:update-text-change-at", + "source": "upstream/automerge/rust runtime:tests/text.rs (target/debug/deps/text-e79c4b56267af860)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text updateText at historical heads integrates later insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:autocommit-tests-test-autocommit-is-send", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust AutoCommit send-equivalent save load boundary stays independent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-basic-test", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state renders root, map, list, and text values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-concurrent-insertions-at-same-index", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state renders concurrent same-index list insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-counters", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state reports counter conflicts with the current counter value", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-deleted-ops-omitted", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state omits deleted operations while keeping live containers", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-insert-and-update", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state renders list inserts after element updates", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-insert-objects", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state renders inserted map objects in lists", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-load-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state load emits counter put patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-multiple-list-insertions", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state renders multiple sequential list insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-test-text-spliced", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust current_state text splice coalesces delete-insert output", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:change-graph-tests-clock-by-heads", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust change graph derives sequence clocks for heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:change-graph-tests-remove-ancestors", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust change graph removes ancestors of selected heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:clock-tests-comparison", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust clock comparison handles ordering and concurrency", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:clock-tests-covers", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust clock covers op ids by actor counter", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-bugbug", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar delta encoder round-trips the upstream regression vector", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-encode-decode-delta", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar delta encoder round-trips optional absolute integers", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-splice-delta", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar delta splice preserves optional absolute integer order", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-opid-list-tests-encode-decode-opid-list", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar OpId-list encoder round-trips grouped operation ids", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-opid-list-tests-splice-opid-list", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar OpId-list splice replaces grouped operation ids", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-rle-int-insert", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar RLE integer encoder preserves inserted value position", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-rle-int-round-trip", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar RLE integer encoder round-trips runs and literals", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-splice-ints", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar RLE integer splice replaces optional integer ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-splice-strings", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar RLE string splice replaces optional string ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-encode-row-wise-and-columnwise-equal", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar value row-wise and column-wise encoders match", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-initialize-splice", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar value initialization round-trips scalar values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-splice-values", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar value splice replaces scalar value ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-value-uleb", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar value ULEB metadata decodes unsigned scalars", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-boolean-tests-encode-decode-bools", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar boolean encoder round-trips boolean runs", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-lebsize-examples", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar signed LEB128 size examples match encoded byte counts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-test-lebsize", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar signed LEB128 property boundary sizes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-test-ulebsize", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar unsigned LEB128 property boundary sizes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-ulebsize-examples", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust columnar unsigned LEB128 size examples match encoded byte counts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:exid-tests-objid-roundtrip", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust ExId non-root object ids round-trip through bytes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:exid-tests-test-root-roundtrip", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust ExId root serializes and parses as the root object id", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:hydrate-tests-simple-hydrate", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust hydrate materializes root data and applies text patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-doc-tests-doc-iter", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust document iterator walks live root and child values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-list-range-tests-list-range-bounds", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust list range bounds slice ordered values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-list-range-tests-list-range-conflict", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust list range reports conflicting element flags", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-deserialize-action", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust legacy op serde deserializes action values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-deserialize-obj", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust legacy op serde deserializes object ids", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-round-trips", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust legacy op serde round trips normalized operations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-test-serialize-key", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust legacy op serde serializes map and element id keys", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-counter-list-patch", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch counter list patch diff matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-list-patch", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch list patch diff matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-marks-patch", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch marks patch diff matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-text-patch", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch text patch diff matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-conflicts-with-isolate", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 isolation conflict workload applies without index corruption", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-list-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 fuzz batch list apply matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map-counter-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 fuzz batch nested counter map apply matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map1-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 fuzz batch nested map apply matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map2-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 fuzz batch sparse nested map apply matches iterative application", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-list-batch-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply matches iterative list changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-list-element-conflict", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply preserves list element conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-map-batch-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply matches iterative map changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-map-key-conflict", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply preserves map key conflicts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-insert-batch-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply handles many concurrent list inserts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-put-batch-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply handles many concurrent list puts", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-update-batch-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply handles repeated concurrent list updates", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-text-batch-apply", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 batch apply matches iterative text changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-meta-tests-column-data-meta-group", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 ValueMeta accumulator tracks raw value offsets", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-mark-index-tests-column-data-delta-simple", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 mark index encodes start and end operation ids", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-op-iter-tests-obj-id-iter-seek", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 object id iterator seeks exact and missing ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-op-iter-tests-skip-op-ids", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 skip iterator selects op ids by counter and successor ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-basic-iteration", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 column data iterates saved operation rows", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-iter-range", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 column data iter_range scopes rows to one object", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-op-iterators", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 column data op iterators group visible and top rows", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-suspend-resume-op-set-iter", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 mixed workload survives save load and change replay", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-parents-tests-test-invisible-parents", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust op_set2 parents reports invisible deleted list object parent", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sequence tree insert accepts interior and prefix insertions", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert-book", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sequence tree repeated book insertions remain iterable", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert-book-vec", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sequence tree indexed insertion matches vector behavior", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-proptest-insert", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sequence tree proptest insert workload matches vector behavior", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-proptest-remove", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sequence tree proptest remove workload matches vector behavior", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-push-back", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sequence tree push appends values at the back", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-bundle-test-make-bundle", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust storage bundle preserves selected changes and loads into a fork", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-change-change-op-columns-tests-test-encode-decode-change-ops", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust storage change op columns round-trip mixed operations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-columns-column-specification-tests-column-spec-encoding", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust storage column specifications encode id type and deflate bit", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-i64", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust storage parser decodes canonical i64 LEB128 values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-u32", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust storage parser decodes canonical u32 LEB128 values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-u64", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust storage parser decodes canonical u64 LEB128 values", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-peers-read-only", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers ignore each other changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-peers-read-only-converges-to-none", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers converge to no further messages", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-both-make-local-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers exchange updated heads for both local writers", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-one-makes-local-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers exchange updated heads for one local writer", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-simultaneous-changes-during-sync", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers converge after simultaneous local changes during sync", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-after-multiple-read-only-rounds", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers exchange accumulated changes after multiple rounds", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-read-only-to-read-write-simultaneously", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers exchange changes after simultaneous read-write toggle", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-read-only-to-read-write-with-new-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust both read-only peers exchange original and new changes after read-write toggle", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-changes-not-sent-to-read-only-peer", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync omits changes when peer is known read-only", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-encode-decode-empty-message", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync message encoding round-trips an empty message", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-first-response-is-some-even-if-no-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync first response is sent even with no missing changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-message-after-set-read-only-even-with-in-flight", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync advertises read-only after mode change with an in-flight message", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-message-after-set-read-only-false-even-with-in-flight", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync advertises read-write after read-only mode change with an in-flight message", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-sync-message-twice-does-nothing", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync message generation is quiet after first send", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-if-first-message-has-no-heads-and-supports-v2-message-send-whole-doc", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync sends whole document when first message has no heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-in-flight-logic-should-not-sabotage-concurrent-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync in-flight acknowledgement does not hide a later local change", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-peer-discovers-remote-read-only-status", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync peer discovers remote read-only status", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-empty-peer-syncs-with-data-peer", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only empty peer syncs with data peer without applying data", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-fully-connected-triangle", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only fully connected triangle keeps publisher isolated while consumers converge", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-concurrent-changes-during-sync", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only peer can publish a local change made during sync", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-new-changes-between-sync-rounds", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only publisher sends new local changes between sync rounds", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-receives-same-changes-from-two-peers", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only peer handles same remote changes from two peers and continues publishing", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-publisher-to-multiple-consumers", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only publisher does not relay ignored consumer changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-sync-does-not-apply-incoming-changes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only sync does not apply incoming changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-allow-simultaneous-messages-during-synchronisation", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync allows simultaneous independent messages and acknowledgements", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-chains-of-false-positives", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync handles chains of false-positive dependencies", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-false-positive-head", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync handles a false-positive advertised head", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-lots-of-branching-and-merging", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync handles lots of branching and merging", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-not-reply-if-we-have-no-data-after-first-round", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync does not reply when empty peers have no data after first round", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-stale-shared-heads-after-read-only-sync", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust stale shared heads after read-only sync do not corrupt direct publisher sync", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-only-to-read-write-mid-session", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only peer receives accumulated remote changes after switching read-write mid-session", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-only-to-read-write-with-multiple-rounds", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only peer receives all accumulated remote rounds after switching read-write", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-write-to-read-only-mid-session", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-write peer switched read-only publishes local changes but ignores remote ones", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-to-read-write-with-old-peer", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync switches read-only peer to read-write with old-peer empty-head fallback", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-toggle-read-only-multiple-times", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust read-only mode can toggle multiple times while preserving withheld changes", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-triangle-changes-arrive-via-two-paths", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust triangle sync preserves read-only publisher state when changes arrive via two paths", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-from-v1-to-v2", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync v1 messages can drive a v2 receiver", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-from-v2-to-v1", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync v2 messages can drive a v1 receiver", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-v1-to-v2-with-compressed-change", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust sync v1 to v2 accepts a large compressed-change payload", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-myers-test-find-middle-snake", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust Myers text diff finds the middle snake", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-utils-test-common-prefix-len", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text diff common prefix length matches byte ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-utils-test-common-suffix-len", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust text diff common suffix length matches byte ranges", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-inner-tests-map-rollback-doesnt-panic", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust transaction inner map rollback setup reads nested write", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-commit-with-options", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction commit_with records options", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-empty-commit-returns-none-hash", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction empty commit returns null hash", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-get-heads-returns-pre-tx-heads", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction exposes pre-transaction heads", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-log-patches", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction logs patches", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-nested-objects", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction supports nested list objects", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-owned-transaction-at", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction at historical heads commits against current document", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-pending-ops", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction counts pending operations", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-put-and-get-roundtrip", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction put and get roundtrip", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-read-during-transaction", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction reads writes before commit", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-rollback-discards-ops", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction rollback with no writes cancels nothing", + "notes": "Mapped PHP parity test passes." + }, + { + "id": "rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-rollback-undoes-writes", + "source": "upstream/automerge/rust runtime:unittests src/lib.rs (target/debug/deps/automerge-f7a40786b3c8bc35)", + "status": "ported-passing", + "phpTest": "tests/run.php:rust owned transaction rollback undoes pending writes", + "notes": "Mapped PHP parity test passes." + } + ] +} diff --git a/src/wp-includes/automerge-php/composer.json b/src/wp-includes/automerge-php/composer.json new file mode 100644 index 0000000000000..4213e0dc86892 --- /dev/null +++ b/src/wp-includes/automerge-php/composer.json @@ -0,0 +1,22 @@ +{ + "name": "wordpress-de/automerge-php", + "description": "Native PHP port of automerge for WordPress distributed editing research.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.2", + "ext-json": "*", + "ext-mbstring": "*", + "ext-intl": "*", + "ext-libxml": "*" + }, + "autoload": { + "psr-4": { + "WordPress\\DistributedEditing\\Automerge\\": "src/" + } + }, + "scripts": { + "test": "php tests/run.php", + "porting-status": "php tests/run.php --status" + } +} diff --git a/src/wp-includes/automerge-php/src/BackendView.php b/src/wp-includes/automerge-php/src/BackendView.php new file mode 100644 index 0000000000000..bf73e797970cc --- /dev/null +++ b/src/wp-includes/automerge-php/src/BackendView.php @@ -0,0 +1,19 @@ + $materialized */ + public function __construct(private readonly array $materialized) + { + } + + /** @return array */ + public function materialize(): array + { + return $this->materialized; + } +} diff --git a/src/wp-includes/automerge-php/src/BigIntValue.php b/src/wp-includes/automerge-php/src/BigIntValue.php new file mode 100644 index 0000000000000..85202bf7c1b18 --- /dev/null +++ b/src/wp-includes/automerge-php/src/BigIntValue.php @@ -0,0 +1,39 @@ +decimal = $negative && $digits !== '0' ? '-' . $digits : $digits; + } + + public function toString(): string + { + return $this->decimal; + } + + public function jsonSerialize(): string + { + return $this->decimal; + } +} diff --git a/src/wp-includes/automerge-php/src/BytesValue.php b/src/wp-includes/automerge-php/src/BytesValue.php new file mode 100644 index 0000000000000..d01d41440ef8a --- /dev/null +++ b/src/wp-includes/automerge-php/src/BytesValue.php @@ -0,0 +1,45 @@ + */ + private array $bytes; + + /** @param list $bytes */ + public function __construct(array $bytes) + { + $normalized = []; + foreach ($bytes as $byte) { + if (! is_int($byte) || $byte < 0 || $byte > 255) { + throw new \InvalidArgumentException('BytesValue entries must be integers from 0 through 255.'); + } + + $normalized[] = $byte; + } + + $this->bytes = $normalized; + } + + /** @return list */ + public function bytes(): array + { + return $this->bytes; + } + + public function copy(): self + { + return new self($this->bytes); + } + + /** @return list */ + public function jsonSerialize(): array + { + return $this->bytes; + } +} diff --git a/src/wp-includes/automerge-php/src/Counter.php b/src/wp-includes/automerge-php/src/Counter.php new file mode 100644 index 0000000000000..5694a3d54e06d --- /dev/null +++ b/src/wp-includes/automerge-php/src/Counter.php @@ -0,0 +1,71 @@ + $increments */ + public function __construct( + private int $initialValue = 0, + private ?string $counterId = null, + private array $increments = [] + ) { + $this->counterId ??= bin2hex(random_bytes(8)); + } + + public function value(): int + { + return $this->initialValue + array_sum($this->increments); + } + + public function id(): string + { + return $this->counterId; + } + + public function initialValue(): int + { + return $this->initialValue; + } + + /** @return array */ + public function increments(): array + { + return $this->increments; + } + + public function incremented(int $amount = 1, ?string $operationId = null): self + { + $increments = $this->increments; + $increments[$operationId ?? bin2hex(random_bytes(8))] = $amount; + + return new self($this->initialValue, $this->counterId, $increments); + } + + public function merge(self $other): self + { + if ($this->counterId !== $other->counterId) { + return strcmp($this->counterId, $other->counterId) >= 0 ? $this->copy() : $other->copy(); + } + + return new self( + $this->initialValue, + $this->counterId, + $this->increments + $other->increments + ); + } + + public function copy(): self + { + return new self($this->initialValue, $this->counterId, $this->increments); + } + + public function jsonSerialize(): int + { + return $this->value(); + } +} diff --git a/src/wp-includes/automerge-php/src/Document.php b/src/wp-includes/automerge-php/src/Document.php new file mode 100644 index 0000000000000..08eef85815a6b --- /dev/null +++ b/src/wp-includes/automerge-php/src/Document.php @@ -0,0 +1,3306 @@ + */ + private array $root; + + /** @var list */ + private array $heads; + + /** @var list> */ + private array $changes; + + /** @var array> */ + private array $conflicts; + + /** @var array> */ + private array $marks; + + /** @var list|null */ + private ?array $incrementalHeads; + + private bool $frozen; + + /** + * @param array $root + * @param list $heads + * @param list> $changes + * @param array> $conflicts + * @param array> $marks + */ + public function __construct( + string $actorId, + int $sequence = 0, + array $root = [], + array $heads = [], + array $changes = [], + array $conflicts = [], + array $marks = [], + ?array $incrementalHeads = null, + bool $frozen = false + ) { + $this->actorId = $actorId; + $this->sequence = $sequence; + $this->root = $this->copyValue($root); + $this->heads = $this->sortedUniqueHeads($heads); + $this->changes = $changes; + $this->conflicts = $this->copyConflicts($conflicts); + $this->marks = $this->copyMarks($marks); + $this->incrementalHeads = $incrementalHeads === null ? null : $this->sortedUniqueHeads($incrementalHeads); + $this->frozen = $frozen; + } + + public static function init(?string $actorId = null): self + { + return new self($actorId ?? self::defaultActorId()); + } + + public static function fromArray(array $root, ?string $actorId = null): self + { + $document = self::init($actorId); + if ($root === []) { + return $document; + } + + $deps = $document->heads; + $document->advanceClock(); + $ops = []; + + foreach ($root as $key => $value) { + $key = (string) $key; + $document->assertSupportedValue($value, '/' . $key); + $storedValue = $document->valueForKey($key, $value); + $document->root[$key] = $document->copyValue($storedValue); + $ops[] = [ + 'action' => 'set', + 'key' => $key, + 'value' => $document->encodeValue($storedValue), + ]; + } + + $document->recordChange($deps, $ops); + + return $document; + } + + /** + * @param list> $changes + */ + public static function applyChanges(self $base, array $changes): self + { + $document = $base->clone(); + $initialChangeCount = count($document->changes); + foreach ($changes as $change) { + $document->applyRecordedChange($change); + } + + if (count($document->changes) !== $initialChangeCount) { + $document->rebuildMaterializedStateFromDependencyOrder(); + } + + return $document; + } + + public static function load(string $payload): self + { + return self::loadFromPayload($payload, false); + } + + public static function loadWithStringMigration(string $payload): self + { + return self::loadFromPayload($payload, true); + } + + private static function loadFromPayload(string $payload, bool $convertStringScalarsToText): self + { + $state = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + if (! is_array($state) || ($state['format'] ?? null) !== 'wordpress-de/automerge-php-native-v1') { + throw new \InvalidArgumentException('Unsupported native Automerge PHP save payload.'); + } + + $document = new self( + is_string($state['actor'] ?? null) ? $state['actor'] : self::defaultActorId(), + (int) ($state['sequence'] ?? 0), + [], + is_array($state['heads'] ?? null) ? array_values($state['heads']) : [], + is_array($state['changes'] ?? null) ? array_values($state['changes']) : [], + [], + is_array($state['marks'] ?? null) ? $state['marks'] : [] + ); + $textSequence = $document->sequence; + $root = is_array($state['root'] ?? null) ? $state['root'] : []; + foreach ($root as $key => $value) { + $document->root[(string) $key] = $document->decodeValue($value, $convertStringScalarsToText, $textSequence); + } + + $conflicts = is_array($state['conflicts'] ?? null) ? $state['conflicts'] : []; + foreach ($conflicts as $key => $values) { + if (! is_array($values)) { + continue; + } + + foreach ($values as $operationId => $value) { + if (is_string($operationId)) { + $document->conflicts[(string) $key][$operationId] = $document->decodeValue($value, $convertStringScalarsToText, $textSequence); + } + } + } + $document->sequence = max($document->sequence, $textSequence); + + return $document; + } + + public function clone(?string $actorId = null, ?bool $frozen = null): self + { + return new self( + $actorId ?? $this->actorId, + $this->sequence, + $this->root, + $this->heads, + $this->changes, + $this->conflicts, + $this->marks, + $this->incrementalHeads, + $frozen ?? $this->frozen + ); + } + + /** + * @param list $heads + */ + public function view(array $heads): self + { + $heads = $this->sortedUniqueHeads($heads); + if ($heads === $this->heads) { + return $this->clone(); + } + + if ($heads === []) { + return self::init($this->actorId); + } + + $changesByHash = []; + foreach ($this->changes as $change) { + if (is_string($change['hash'] ?? null)) { + $changesByHash[$change['hash']] = $change; + } + } + + $needed = []; + $stack = $heads; + while ($stack !== []) { + $hash = array_pop($stack); + if (! is_string($hash) || isset($needed[$hash])) { + continue; + } + + if (! isset($changesByHash[$hash])) { + throw new \InvalidArgumentException('Cannot create a view for unknown heads.'); + } + + $needed[$hash] = true; + $deps = is_array($changesByHash[$hash]['deps'] ?? null) ? $changesByHash[$hash]['deps'] : []; + foreach ($deps as $dep) { + if (is_string($dep)) { + $stack[] = $dep; + } + } + } + + $view = self::init($this->actorId); + foreach ($this->changes as $change) { + if (is_string($change['hash'] ?? null) && isset($needed[$change['hash']])) { + $view->applyRecordedChange($change); + } + } + + return $view; + } + + public function actorId(): string + { + return $this->actorId; + } + + public function isFrozen(): bool + { + return $this->frozen; + } + + /** + * @param list $path + */ + public function isFrozenPath(array $path): bool + { + if (! $this->frozen) { + return false; + } + + if ($path === []) { + return true; + } + + $value = $this->toArray(); + foreach ($path as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + return false; + } + + $value = $value[$key]; + } + + return is_array($value); + } + + public function withFrozen(bool $frozen): self + { + return $this->clone(null, $frozen); + } + + public function ensureSequenceAtLeast(int $sequence): void + { + $this->assertMutable(); + $this->sequence = max($this->sequence, $sequence); + } + + /** + * @return list + */ + public function heads(): array + { + return $this->sortedUniqueHeads($this->heads); + } + + /** + * @param list $heads + */ + public function hasHeads(array $heads): bool + { + $known = array_fill_keys($this->heads, true); + foreach ($this->changes as $change) { + if (is_string($change['hash'] ?? null)) { + $known[$change['hash']] = true; + } + } + + foreach ($heads as $head) { + if (! isset($known[$head])) { + return false; + } + } + + return true; + } + + public function set(string $key, mixed $value, ?string $message = null, ?int $time = null): void + { + $this->assertMutable(); + $this->assertSupportedValue($value, '/' . $key); + $deps = $this->heads; + $this->advanceClock(); + + if ($value instanceof TextValue) { + $this->root[$key] = $value->copy(); + unset($this->conflicts[$key]); + $this->recordChange($deps, [['action' => 'set', 'key' => $key, 'value' => $this->encodeValue($value)]], $message, $time); + return; + } + + $storedValue = $this->valueForKey($key, $value); + $this->root[$key] = $this->copyValue($storedValue); + unset($this->conflicts[$key]); + $this->recordChange($deps, [['action' => 'set', 'key' => $key, 'value' => $this->encodeValue($storedValue)]], $message, $time); + } + + /** + * @param list $heads + */ + public function setAtHeads(array $heads, string $key, mixed $value): void + { + $this->assertMutable(); + $this->assertSupportedValue($value, '/' . $key); + $deps = $this->sortedUniqueHeads($heads); + $this->advanceClock(); + + $storedValue = $value instanceof TextValue ? $value->copy() : $this->valueForKey($key, $value); + $this->root[$key] = $this->copyValue($storedValue); + unset($this->conflicts[$key]); + $this->recordChange( + $deps, + [['action' => 'set', 'key' => $key, 'value' => $this->encodeValue($storedValue)]], + preserveUnrelatedHeads: true + ); + } + + /** @param array $values */ + public function setMany(array $values, ?string $message = null): void + { + $this->assertMutable(); + if ($values === []) { + return; + } + + foreach ($values as $key => $value) { + $this->assertSupportedValue($value, '/' . (string) $key); + } + + $deps = $this->heads; + $this->advanceClock(); + $ops = []; + foreach ($values as $key => $value) { + $key = (string) $key; + $storedValue = $value instanceof TextValue ? $value->copy() : $this->valueForKey($key, $value); + $this->root[$key] = $this->copyValue($storedValue); + unset($this->conflicts[$key]); + $ops[] = ['action' => 'set', 'key' => $key, 'value' => $this->encodeValue($storedValue)]; + } + + $this->recordChange($deps, $ops, $message); + } + + /** + * @param list $path + */ + public function setNested(array $path, mixed $value): void + { + $this->assertMutable(); + $path = $this->normalizePath($path); + $this->assertSupportedValue($value, $this->pathString($path)); + $deps = $this->heads; + $this->advanceClock(); + + $storedValue = $this->copyValue($value); + $this->assignNestedPath($this->root, $path, $storedValue); + if (is_string($path[0])) { + unset($this->conflicts[$path[0]]); + } + $this->recordChange($deps, [['action' => 'setNested', 'path' => $path, 'value' => $this->encodeValue($storedValue)]]); + } + + /** + * @param list $values + */ + public function insertListValues(string $key, int $index, array $values): void + { + $this->assertMutable(); + foreach ($values as $offset => $value) { + $this->assertSupportedValue($value, '/' . $key . '/' . (string) ($index + (int) $offset)); + } + + $deps = $this->heads; + $this->advanceClock(); + $list = $this->root[$key] ?? []; + $list = is_array($list) && array_is_list($list) ? array_values($list) : []; + $index = max(0, min($index, count($list))); + $storedValues = array_map(fn (mixed $value): mixed => $this->copyValue($value), array_values($values)); + array_splice($list, $index, 0, $storedValues); + $this->root[$key] = array_values($list); + unset($this->conflicts[$key]); + $this->recordChange($deps, [[ + 'action' => 'insertList', + 'key' => $key, + 'index' => $index, + 'values' => array_map(fn (mixed $value): mixed => $this->encodeValue($value), $storedValues), + 'value' => $this->encodeValue($this->root[$key]), + ]]); + } + + public function setRootConflictMapValue(string $key, string $nestedKey, mixed $value): void + { + $this->assertMutable(); + $this->assertSupportedValue($value, '/' . $key . '/' . $nestedKey); + if (! isset($this->conflicts[$key])) { + return; + } + + $updated = $this->setConflictMapValue($key, $nestedKey, $value); + if (! $updated) { + return; + } + + $deps = $this->heads; + $this->advanceClock(); + $this->recordChange($deps, [ + [ + 'action' => 'setRootConflictMapValue', + 'key' => $key, + 'nestedKey' => $nestedKey, + 'value' => $this->encodeValue($value), + ], + ]); + } + + public function setRootConflictListElementMapValue(string $key, int $index, string $nestedKey, mixed $value): void + { + $this->assertMutable(); + $index = max(0, $index); + $this->assertSupportedValue($value, '/' . $key . '/' . $index . '/' . $nestedKey); + if (! isset($this->conflicts[$key])) { + return; + } + + $updated = $this->setConflictListElementMapValue($key, $index, $nestedKey, $value); + if (! $updated) { + return; + } + + $deps = $this->heads; + $this->advanceClock(); + $this->recordChange($deps, [[ + 'action' => 'setRootConflictListElementMapValue', + 'key' => $key, + 'index' => $index, + 'nestedKey' => $nestedKey, + 'value' => $this->encodeValue($value), + ]]); + } + + public function resolveRootConflictListElement(string $key, int $index, mixed $value): void + { + $this->assertMutable(); + $index = max(0, $index); + $this->assertSupportedValue($value, '/' . $key . '/' . $index); + if (! isset($this->conflicts[$key])) { + return; + } + + $updated = $this->resolveConflictListElementValue($key, $index, $value); + if (! $updated) { + return; + } + + $deps = $this->heads; + $this->advanceClock(); + $this->recordChange($deps, [[ + 'action' => 'resolveRootConflictListElement', + 'key' => $key, + 'index' => $index, + 'value' => $this->encodeValue($value), + ]]); + } + + public function incrementRootConflictCounters(string $key, int $amount, string $operationId): bool + { + $this->assertMutable(); + if (! isset($this->conflicts[$key])) { + return false; + } + + $updated = $this->incrementConflictCounters($key, $amount, $operationId); + if (! $updated) { + return false; + } + + $deps = $this->heads; + $this->advanceClock(); + $this->recordChange($deps, [[ + 'action' => 'incrementRootConflictCounters', + 'key' => $key, + 'amount' => $amount, + 'operationId' => $operationId, + ]]); + + return true; + } + + public function incrementRootConflictListElementCounters(string $key, int $index, int $amount, string $operationId): bool + { + $this->assertMutable(); + $index = max(0, $index); + if (! isset($this->conflicts[$key])) { + return false; + } + + $updated = $this->incrementConflictListElementCounters($key, $index, $amount, $operationId); + if (! $updated) { + return false; + } + + $deps = $this->heads; + $this->advanceClock(); + $this->recordChange($deps, [[ + 'action' => 'incrementRootConflictListElementCounters', + 'key' => $key, + 'index' => $index, + 'amount' => $amount, + 'operationId' => $operationId, + ]]); + + return true; + } + + public function get(string $key): mixed + { + return $this->root[$key] ?? null; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->root); + } + + public function delete(string $key): void + { + $this->assertMutable(); + $deps = $this->heads; + $this->advanceClock(); + unset($this->root[$key]); + unset($this->conflicts[$key]); + $this->recordChange($deps, [['action' => 'delete', 'key' => $key]]); + } + + /** + * @param list $path + */ + public function deleteNested(array $path): void + { + $this->assertMutable(); + $path = $this->normalizePath($path); + $deps = $this->heads; + $this->advanceClock(); + + $this->deleteNestedPath($this->root, $path); + if (is_string($path[0])) { + unset($this->conflicts[$path[0]]); + } + $this->recordChange($deps, [['action' => 'deleteNested', 'path' => $path]]); + } + + public function spliceText(string $key, int $index, int $deleteCount, string $insert = ''): void + { + $this->assertMutable(); + $deps = $this->heads; + $this->advanceClock(); + $value = $this->root[$key] ?? null; + if (! $value instanceof TextValue) { + $value = $this->textValueForExistingKey($key, $value); + } + + $value->splice($index, $deleteCount, $insert, $this->actorId, $this->sequence); + $this->root[$key] = $value; + $this->adjustMarksForTextSplice($key, $index, $deleteCount, $insert); + $this->recordChange( + $deps, + [ + [ + 'action' => 'splice', + 'key' => $key, + 'index' => $index, + 'deleteCount' => $deleteCount, + 'insert' => $insert, + ], + ] + ); + } + + public function putText(string $key, int $index, string $value): void + { + $this->assertMutable(); + $deps = $this->heads; + $this->advanceClock(); + $text = $this->root[$key] ?? null; + if (! $text instanceof TextValue) { + $text = $this->textValueForExistingKey($key, $text); + } + + $text->splice($index, 1, $value, $this->actorId, $this->sequence); + $this->root[$key] = $text; + $this->adjustMarksForTextSplice($key, $index, 1, $value); + $this->recordChange( + $deps, + [ + [ + 'action' => 'putText', + 'key' => $key, + 'index' => $index, + 'value' => $value, + ], + ] + ); + } + + public function updateText(string $key, string $newText): void + { + $oldText = $this->text($key)->toString(); + if ($oldText === $newText) { + return; + } + + [$index, $deleteCount, $insert] = $this->diffText($oldText, $newText); + $this->spliceText($key, $index, $deleteCount, $insert); + } + + /** + * @param list $marks + */ + public function replaceTextAndMarks(string $key, string $text, array $marks): void + { + $this->assertMutable(); + $deps = $this->heads; + $this->advanceClock(); + + $value = TextValue::fromString($text, $this->actorId, $this->sequence); + $this->root[$key] = $value; + unset($this->conflicts[$key]); + + $pathKey = $this->pathKey([$key]); + if ($marks === []) { + unset($this->marks[$pathKey]); + } else { + $this->marks[$pathKey] = $this->copyMarks([$pathKey => $marks])[$pathKey] ?? []; + } + + $ops = [[ + 'action' => 'set', + 'key' => $key, + 'value' => $this->encodeValue($value), + ]]; + if ($marks !== []) { + $ops[] = [ + 'action' => 'mark', + 'path' => [$key], + 'marks' => $this->marks[$pathKey], + ]; + } + + $this->recordChange($deps, $ops); + } + + /** + * @param list $path + * @param list> $marks + */ + public function markText(array $path, array $marks): void + { + $this->assertMutable(); + $path = $this->normalizePath($path); + $normalized = []; + foreach ($marks as $mark) { + if (! is_array($mark) || ! is_string($mark['name'] ?? null)) { + continue; + } + + $start = max(0, (int) ($mark['start'] ?? 0)); + $end = max($start, (int) ($mark['end'] ?? $start)); + $normalizedMark = [ + 'name' => $mark['name'], + 'value' => $mark['value'] ?? true, + 'start' => $start, + 'end' => $end, + ]; + if (is_string($mark['expand'] ?? null) && in_array($mark['expand'], ['none', 'start', 'end', 'both'], true)) { + $normalizedMark['expand'] = $mark['expand']; + } + + $normalized[] = $normalizedMark; + } + + if ($normalized === []) { + return; + } + + $deps = $this->heads; + $this->advanceClock(); + $pathKey = $this->pathKey($path); + $this->marks[$pathKey] = array_values(array_merge($this->marks[$pathKey] ?? [], $normalized)); + $this->recordChange($deps, [['action' => 'mark', 'path' => $path, 'marks' => $normalized]]); + } + + /** + * @param list $path + */ + public function unmarkText(array $path, string $name, int $start, int $end): void + { + $this->assertMutable(); + $path = $this->normalizePath($path); + $start = max(0, $start); + $end = max($start, $end); + $pathKey = $this->pathKey($path); + $existing = $this->marks[$pathKey] ?? []; + if ($existing === []) { + return; + } + + [$remaining, $changed] = $this->marksAfterUnmark($existing, $name, $start, $end); + if (! $changed) { + return; + } + + $deps = $this->heads; + $this->advanceClock(); + if ($remaining === []) { + unset($this->marks[$pathKey]); + } else { + $this->marks[$pathKey] = $remaining; + } + + $this->recordChange($deps, [['action' => 'unmark', 'path' => $path, 'name' => $name, 'start' => $start, 'end' => $end]]); + } + + /** + * @param list $path + * @return list + */ + public function marksFor(array $path): array + { + $pathKey = $this->pathKey($this->normalizePath($path)); + + return $this->copyMarks([$pathKey => $this->marks[$pathKey] ?? []], false)[$pathKey] ?? []; + } + + /** + * @return list,marks:list}> + */ + public function allMarks(): array + { + $entries = []; + foreach ($this->marks as $pathKey => $pathMarks) { + $path = json_decode($pathKey, true); + if (! is_array($path)) { + continue; + } + + $marks = $this->copyMarks([$pathKey => $pathMarks], false)[$pathKey] ?? []; + if ($marks === []) { + continue; + } + + $entries[] = [ + 'path' => array_values($path), + 'marks' => $marks, + ]; + } + + return $entries; + } + + public function emptyChange(?string $message = null, ?int $time = null): void + { + $this->assertMutable(); + $deps = $this->heads; + $this->advanceClock(); + $this->recordChange($deps, [], $message, $time); + } + + public function text(string $key): TextValue + { + $value = $this->root[$key] ?? null; + if ($value instanceof TextValue) { + return $value->copy(); + } + + $sequence = $this->sequence; + + return TextValue::fromString(is_string($value) ? $value : '', $this->actorId, $sequence); + } + + public function merge(self $other): self + { + $merged = $this->clone(); + $commonAncestor = $this->commonAncestorView($other); + foreach ($other->root as $key => $otherValue) { + if (isset($merged->root[$key]) && $merged->root[$key] instanceof TextValue && $otherValue instanceof TextValue) { + $baseHadKey = $commonAncestor !== null && array_key_exists($key, $commonAncestor->root); + if (! $baseHadKey && ! $other->hasHeads($this->heads) && ! $this->hasHeads($other->heads)) { + $merged->recordRootConflict($key, $merged->root[$key], $other, $otherValue); + $merged->root[$key] = $merged->rootConflictWinner($merged->conflicts[$key] ?? []); + continue; + } + + $merged->root[$key] = $merged->root[$key]->merge($otherValue); + continue; + } + + if (isset($merged->root[$key]) && $merged->root[$key] instanceof Counter && $otherValue instanceof Counter) { + if ($merged->root[$key]->id() === $otherValue->id()) { + $merged->root[$key] = $merged->root[$key]->merge($otherValue); + continue; + } + + $merged->recordRootConflict($key, $merged->root[$key], $other, $otherValue); + $merged->root[$key] = $merged->rootConflictWinner($merged->conflicts[$key] ?? []); + continue; + } + + if (! array_key_exists($key, $merged->root)) { + $merged->root[$key] = $merged->copyValue($otherValue); + continue; + } + + if ($merged->root[$key] === $otherValue) { + if ($other->hasHeads($this->heads)) { + if (isset($other->conflicts[$key]) && is_array($other->conflicts[$key]) && count($other->conflicts[$key]) >= 2) { + $merged->conflicts[$key] = $merged->copyValue($other->conflicts[$key]); + } else { + unset($merged->conflicts[$key]); + } + + continue; + } + + if ($merged->shouldRecordConcurrentEqualScalarConflict($key, $other, $otherValue, $commonAncestor)) { + $merged->recordRootConflict($key, $merged->root[$key], $other, $otherValue); + $merged->root[$key] = $merged->rootConflictWinner($merged->conflicts[$key] ?? []); + } + + continue; + } + + if ($other->hasHeads($this->heads)) { + $merged->root[$key] = $merged->copyValue($otherValue); + unset($merged->conflicts[$key]); + continue; + } + + if ($this->hasHeads($other->heads)) { + unset($merged->conflicts[$key]); + continue; + } + + if (is_array($merged->root[$key]) && is_array($otherValue)) { + $baseValue = $commonAncestor?->root[$key] ?? null; + if (is_array($baseValue)) { + if (array_is_list($baseValue) && array_is_list($merged->root[$key]) && array_is_list($otherValue)) { + [$insertionsCleanly, $insertionsValue] = $merged->mergeConcurrentListInsertions( + $baseValue, + $merged->root[$key], + $otherValue, + $merged->latestListInsertionForKey($key), + $other->latestListInsertionForKey($key) + ); + if ($insertionsCleanly) { + $merged->root[$key] = $merged->copyValue($insertionsValue); + unset($merged->conflicts[$key]); + continue; + } + } + + [$mergedCleanly, $mergedValue] = $merged->mergeSharedContainerValue($baseValue, $merged->root[$key], $otherValue); + if ($mergedCleanly) { + $merged->root[$key] = $merged->copyValue($mergedValue); + unset($merged->conflicts[$key]); + continue; + } + } + } + + $merged->recordRootConflict($key, $merged->root[$key], $other, $otherValue); + $merged->root[$key] = $merged->rootConflictWinner($merged->conflicts[$key] ?? []); + } + + if ($other->hasHeads($this->heads)) { + foreach (array_keys($merged->root) as $key) { + if (! array_key_exists($key, $other->root)) { + unset($merged->root[$key], $merged->conflicts[$key]); + } + } + } + + $merged->sequence = max($this->sequence, $other->sequence); + $merged->heads = array_values(array_unique(array_merge($this->heads, $other->heads))); + $merged->changes = $this->mergeChangeLists($this->changes, $other->changes); + if ($other->hasHeads($this->heads)) { + $merged->marks = $merged->copyMarks($other->marks); + } elseif (! $this->hasHeads($other->heads)) { + $merged->marks = $merged->mergeMarkSets($merged->marks, $other->marks); + } + + return $merged; + } + + private function commonAncestorView(self $other): ?self + { + $leftChanges = []; + foreach ($this->changes as $change) { + if (is_string($change['hash'] ?? null)) { + $leftChanges[$change['hash']] = $change; + } + } + + $common = []; + foreach ($other->changes as $change) { + if (is_string($change['hash'] ?? null) && isset($leftChanges[$change['hash']])) { + $common[$change['hash']] = $leftChanges[$change['hash']]; + } + } + + if ($common === []) { + return null; + } + + $commonDeps = []; + foreach ($common as $change) { + foreach (is_array($change['deps'] ?? null) ? $change['deps'] : [] as $dep) { + if (is_string($dep) && isset($common[$dep])) { + $commonDeps[$dep] = true; + } + } + } + + $heads = array_values(array_diff(array_keys($common), array_keys($commonDeps))); + if ($heads === []) { + return null; + } + + return $this->view($this->sortedUniqueHeads($heads)); + } + + private function shouldRecordConcurrentEqualScalarConflict(string $key, self $other, mixed $value, ?self $commonAncestor): bool + { + if (! is_scalar($value) && $value !== null) { + return false; + } + + if ($other->hasHeads($this->heads) || $this->hasHeads($other->heads)) { + return false; + } + + $baseHasKey = $commonAncestor !== null && array_key_exists($key, $commonAncestor->root); + if ($baseHasKey && $commonAncestor->root[$key] === $value) { + return false; + } + + return $this->latestOperationIdForKey($key) !== $other->latestOperationIdForKey($key); + } + + /** + * @return array{0:bool,1:mixed} + */ + private function mergeSharedContainerValue(mixed $base, mixed $left, mixed $right): array + { + if ($left === $right) { + return [true, $this->copyValue($left)]; + } + + if ($left === $base) { + return [true, $this->copyValue($right)]; + } + + if ($right === $base) { + return [true, $this->copyValue($left)]; + } + + if (! is_array($base) || ! is_array($left) || ! is_array($right)) { + return [false, null]; + } + + $containerKinds = array_values(array_filter( + [ + $base === [] ? null : array_is_list($base), + $left === [] ? null : array_is_list($left), + $right === [] ? null : array_is_list($right), + ], + static fn (?bool $kind): bool => $kind !== null + )); + $isList = $containerKinds[0] ?? true; + foreach ($containerKinds as $containerKind) { + if ($containerKind !== $isList) { + return [false, null]; + } + } + + if ($base !== [] && array_is_list($base) !== $isList) { + return [false, null]; + } + + if ($isList) { + [$clean, $value] = $this->mergeListConcurrentDeletions($base, $left, $right); + if ($clean) { + return [true, $value]; + } + + [$clean, $value] = $this->mergeListInsertionAndDeletion($base, $left, $right); + if ($clean) { + return [true, $value]; + } + + [$clean, $value] = $this->mergeListAssignmentAndDeletion($base, $left, $right); + if ($clean) { + return [true, $value]; + } + } + + $merged = []; + $keys = array_values(array_unique(array_merge(array_keys($base), array_keys($left), array_keys($right)))); + foreach ($keys as $key) { + $baseHas = array_key_exists($key, $base); + $leftHas = array_key_exists($key, $left); + $rightHas = array_key_exists($key, $right); + + if ($baseHas && $leftHas && $rightHas) { + [$clean, $value] = $this->mergeSharedContainerValue($base[$key], $left[$key], $right[$key]); + if (! $clean) { + return [false, null]; + } + $merged[$key] = $value; + continue; + } + + if (! $baseHas && $leftHas && $rightHas) { + if ($left[$key] !== $right[$key]) { + return [false, null]; + } + $merged[$key] = $this->copyValue($left[$key]); + continue; + } + + if ($baseHas && $leftHas && ! $rightHas) { + if ($left[$key] !== $base[$key]) { + if ($this->isDescendantOnlyContainerChange($base[$key], $left[$key])) { + continue; + } + + return [false, null]; + } + continue; + } + + if ($baseHas && ! $leftHas && $rightHas) { + if ($right[$key] !== $base[$key]) { + if ($this->isDescendantOnlyContainerChange($base[$key], $right[$key])) { + continue; + } + + return [false, null]; + } + continue; + } + + if (! $baseHas && $leftHas) { + $merged[$key] = $this->copyValue($left[$key]); + continue; + } + + if (! $baseHas && $rightHas) { + $merged[$key] = $this->copyValue($right[$key]); + } + } + + if ($isList) { + ksort($merged); + + return [true, array_values($merged)]; + } + + return [true, $merged]; + } + + private function isDescendantOnlyContainerChange(mixed $base, mixed $candidate): bool + { + if (! is_array($base) || ! is_array($candidate) || array_is_list($base) !== array_is_list($candidate)) { + return false; + } + + foreach ($base as $key => $value) { + if (! array_key_exists($key, $candidate)) { + return false; + } + + if ($candidate[$key] === $value) { + continue; + } + + if (! $this->isDescendantOnlyContainerChange($value, $candidate[$key])) { + return false; + } + } + + return $candidate !== $base; + } + + /** + * @param list $base + * @param list $left + * @param list $right + * @return array{0:bool,1:mixed} + */ + private function mergeListConcurrentDeletions(array $base, array $left, array $right): array + { + $leftDeletion = $this->listDeletionRange($base, $left); + $rightDeletion = $this->listDeletionRange($base, $right); + if ($leftDeletion === null || $rightDeletion === null) { + return [false, null]; + } + + $deleted = []; + foreach ([$leftDeletion, $rightDeletion] as $range) { + for ($offset = 0; $offset < $range['length']; ++$offset) { + $deleted[$range['index'] + $offset] = true; + } + } + + $merged = []; + foreach ($base as $index => $value) { + if (isset($deleted[$index])) { + continue; + } + + $merged[] = $this->copyValue($value); + } + + return [true, $merged]; + } + + /** + * @param list $base + * @param list $left + * @param list $right + * @return array{0:bool,1:mixed} + */ + private function mergeListInsertionAndDeletion(array $base, array $left, array $right): array + { + $leftDeletion = $this->listDeletionRange($base, $left); + $rightInsertion = $this->listInsertionRange($base, $right); + if ($leftDeletion !== null && $rightInsertion !== null) { + return [true, $this->listWithDeletionAndInsertion($base, $leftDeletion, $rightInsertion)]; + } + + $rightDeletion = $this->listDeletionRange($base, $right); + $leftInsertion = $this->listInsertionRange($base, $left); + if ($rightDeletion !== null && $leftInsertion !== null) { + return [true, $this->listWithDeletionAndInsertion($base, $rightDeletion, $leftInsertion)]; + } + + return [false, null]; + } + + /** + * @param list $base + * @param array{index:int,length:int} $deletion + * @param array{index:int,values:list} $insertion + * @return list + */ + private function listWithDeletionAndInsertion(array $base, array $deletion, array $insertion): array + { + $merged = array_values(array_map(fn (mixed $value): mixed => $this->copyValue($value), $base)); + array_splice($merged, $deletion['index'], $deletion['length']); + + $insertIndex = $insertion['index']; + $deleteEnd = $deletion['index'] + $deletion['length']; + if ($insertIndex > $deleteEnd) { + $insertIndex -= $deletion['length']; + } elseif ($insertIndex >= $deletion['index']) { + $insertIndex = $deletion['index']; + } + + $values = array_map(fn (mixed $value): mixed => $this->copyValue($value), $insertion['values']); + array_splice($merged, max(0, min($insertIndex, count($merged))), 0, $values); + + return array_values($merged); + } + + /** + * @param list $base + * @param list $left + * @param list $right + * @return array{0:bool,1:mixed} + */ + private function mergeListAssignmentAndDeletion(array $base, array $left, array $right): array + { + $leftAssignmentIndex = $this->singleListAssignmentIndex($base, $left); + $rightDeletionIndex = $this->singleListDeletionIndex($base, $right); + if ($leftAssignmentIndex !== null && $rightDeletionIndex === $leftAssignmentIndex) { + return [true, $this->copyValue($left)]; + } + + $rightAssignmentIndex = $this->singleListAssignmentIndex($base, $right); + $leftDeletionIndex = $this->singleListDeletionIndex($base, $left); + if ($rightAssignmentIndex !== null && $leftDeletionIndex === $rightAssignmentIndex) { + return [true, $this->copyValue($right)]; + } + + return [false, null]; + } + + /** + * @param list $base + * @param list $candidate + */ + private function singleListAssignmentIndex(array $base, array $candidate): ?int + { + if (count($candidate) !== count($base)) { + return null; + } + + $changedIndex = null; + foreach ($base as $index => $value) { + if ($candidate[$index] === $value) { + continue; + } + + if ($changedIndex !== null) { + return null; + } + $changedIndex = $index; + } + + return $changedIndex; + } + + /** + * @param list $base + * @param list $candidate + */ + private function singleListDeletionIndex(array $base, array $candidate): ?int + { + $range = $this->listDeletionRange($base, $candidate); + if ($range === null || $range['length'] !== 1) { + return null; + } + + return $range['index']; + } + + /** + * @param list $base + * @param list $candidate + * @return array{index:int,length:int}|null + */ + private function listDeletionRange(array $base, array $candidate): ?array + { + $deletedLength = count($base) - count($candidate); + if ($deletedLength <= 0) { + return null; + } + + for ($deletedIndex = 0; $deletedIndex <= count($base) - $deletedLength; ++$deletedIndex) { + $expected = array_values($base); + array_splice($expected, $deletedIndex, $deletedLength); + if ($candidate === $expected) { + return ['index' => $deletedIndex, 'length' => $deletedLength]; + } + } + + return null; + } + + /** + * @param list $base + * @param list $candidate + * @return array{index:int,values:list}|null + */ + private function listInsertionRange(array $base, array $candidate): ?array + { + $insertedLength = count($candidate) - count($base); + if ($insertedLength <= 0) { + return null; + } + + for ($insertedIndex = 0; $insertedIndex <= count($base); ++$insertedIndex) { + $values = array_values(array_slice($candidate, $insertedIndex, $insertedLength)); + $expected = array_values($base); + array_splice($expected, $insertedIndex, 0, $values); + if ($candidate === $expected) { + return ['index' => $insertedIndex, 'values' => $values]; + } + } + + return null; + } + + /** + * @param list $base + * @param list $left + * @param list $right + * @param array{operationId:string,index:int,values:list}|null $leftInsertion + * @param array{operationId:string,index:int,values:list}|null $rightInsertion + * @return array{0:bool,1:mixed} + */ + private function mergeConcurrentListInsertions(array $base, array $left, array $right, ?array $leftInsertion, ?array $rightInsertion): array + { + if ($leftInsertion === null || $rightInsertion === null) { + return [false, null]; + } + + $leftExpected = $this->listWithInsertion($base, $leftInsertion); + $rightExpected = $this->listWithInsertion($base, $rightInsertion); + if ($left !== $leftExpected || $right !== $rightExpected) { + return [false, null]; + } + + $groupsByIndex = []; + foreach ([$leftInsertion, $rightInsertion] as $insertion) { + $index = max(0, min($insertion['index'], count($base))); + $groupsByIndex[$index][] = $insertion; + } + ksort($groupsByIndex, SORT_NUMERIC); + + $merged = []; + for ($index = 0; $index <= count($base); ++$index) { + if (isset($groupsByIndex[$index])) { + usort( + $groupsByIndex[$index], + fn (array $leftGroup, array $rightGroup): int => $this->compareOperationIds($rightGroup['operationId'], $leftGroup['operationId']) + ); + foreach ($groupsByIndex[$index] as $group) { + foreach ($group['values'] as $value) { + $merged[] = $this->copyValue($value); + } + } + } + + if (array_key_exists($index, $base)) { + $merged[] = $this->copyValue($base[$index]); + } + } + + return [true, $merged]; + } + + /** + * @param array{operationId:string,index:int,values:list} $insertion + * @return list + */ + private function listWithInsertion(array $base, array $insertion): array + { + $list = array_values($base); + $index = max(0, min($insertion['index'], count($list))); + $values = array_map(fn (mixed $value): mixed => $this->copyValue($value), $insertion['values']); + array_splice($list, $index, 0, $values); + + return array_values($list); + } + + /** + * @return array + */ + public function toArray(): array + { + $materialized = []; + foreach ($this->root as $key => $value) { + $materialized[$key] = $this->materializeValue($value); + } + + return $materialized; + } + + /** + * @return array + */ + public function rootValues(): array + { + return $this->copyValue($this->root); + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @return list> + */ + public function getAllChanges(): array + { + return $this->changes; + } + + /** + * @param list $heads + * @return list> + */ + public function getChangesSince(array $heads): array + { + if ($heads === []) { + return $this->changes; + } + + $changesByHash = []; + foreach ($this->changes as $change) { + if (is_string($change['hash'] ?? null)) { + $changesByHash[$change['hash']] = $change; + } + } + + $known = []; + $stack = $this->sortedUniqueHeads($heads); + while ($stack !== []) { + $hash = array_pop($stack); + if (! is_string($hash) || isset($known[$hash])) { + continue; + } + + $known[$hash] = true; + $change = $changesByHash[$hash] ?? null; + if (! is_array($change) || ! is_array($change['deps'] ?? null)) { + continue; + } + + foreach ($change['deps'] as $dep) { + if (is_string($dep) && ! isset($known[$dep])) { + $stack[] = $dep; + } + } + } + + return array_values(array_filter( + $this->changes, + static fn (array $change): bool => ! is_string($change['hash'] ?? null) || ! isset($known[$change['hash']]) + )); + } + + /** + * @param list $heads + * @return list,startOp:int,seq:int}> + */ + public function getChangesMetaSince(array $heads): array + { + return array_map( + fn (array $change): array => $this->changeMetadata($change), + $this->getChangesSince($heads) + ); + } + + /** + * @return list,snapshot:self}> + */ + public function getHistory(): array + { + $snapshot = self::init($this->actorId); + $history = []; + foreach ($this->changes as $change) { + $snapshot->applyRecordedChange($change); + $history[] = [ + 'change' => $this->changeMetadata($change) + [ + 'ops' => is_array($change['ops'] ?? null) ? array_values($change['ops']) : [], + ], + 'snapshot' => $snapshot->clone(), + ]; + } + + return $history; + } + + /** + * @return array|null + */ + public function getLastLocalChange(): ?array + { + for ($index = count($this->changes) - 1; $index >= 0; --$index) { + if (($this->changes[$index]['actor'] ?? null) === $this->actorId) { + return $this->changes[$index]; + } + } + + return null; + } + + public function amendLastLocalChange(?string $message = null, ?int $time = null): void + { + $this->assertMutable(); + for ($index = count($this->changes) - 1; $index >= 0; --$index) { + if (($this->changes[$index]['actor'] ?? null) !== $this->actorId) { + continue; + } + + $oldHash = is_string($this->changes[$index]['hash'] ?? null) ? $this->changes[$index]['hash'] : null; + $change = $this->changes[$index]; + $change['message'] = $message; + $change['time'] = $time ?? time(); + + $changeForHash = $change; + unset($changeForHash['hash']); + $change['hash'] = hash('sha256', json_encode($changeForHash, JSON_THROW_ON_ERROR)); + $this->changes[$index] = $change; + + if ($oldHash !== null) { + $this->heads = $this->sortedUniqueHeads(array_map( + static fn (string $head): string => $head === $oldHash ? $change['hash'] : $head, + $this->heads + )); + } + + return; + } + } + + /** + * @return array|null + */ + public function conflictsFor(string|int $key): ?array + { + $conflicts = $this->conflicts[(string) $key] ?? null; + if (! is_array($conflicts) || count($conflicts) < 2) { + return null; + } + + $materialized = []; + foreach ($conflicts as $operationId => $value) { + $materialized[$operationId] = $this->materializeValue($value); + } + + return $materialized; + } + + /** + * @return array|null + */ + public function listElementConflictsFor(string $key, int $index): ?array + { + $conflicts = $this->conflicts[$key] ?? null; + if (! is_array($conflicts) || count($conflicts) < 2) { + return null; + } + + $materialized = []; + $counterOnly = $this->listElementCounterConflictsOnly($key, $index); + foreach ($conflicts as $operationId => $value) { + if (is_array($value) && array_is_list($value) && array_key_exists($index, $value)) { + if ($counterOnly && ! $value[$index] instanceof Counter) { + continue; + } + + $materialized[$operationId] = $this->materializeValue($value[$index]); + } + } + + return count($materialized) < 2 ? null : $materialized; + } + + /** + * @return array|null + */ + public function inspectChange(string $hash): ?array + { + foreach ($this->changes as $change) { + if (($change['hash'] ?? null) === $hash) { + return $this->inspectableChange($change); + } + } + + return null; + } + + public function save(): string + { + $encodedRoot = []; + foreach ($this->root as $key => $value) { + $encodedRoot[$key] = $this->encodeValue($value); + } + + return json_encode( + [ + 'format' => 'wordpress-de/automerge-php-native-v1', + 'actor' => $this->actorId, + 'sequence' => $this->sequence, + 'heads' => $this->heads, + 'root' => $encodedRoot, + 'changes' => $this->changes, + 'conflicts' => $this->encodeConflicts(), + 'marks' => $this->copyMarks($this->marks), + ], + JSON_THROW_ON_ERROR + ); + } + + public function saveWithoutOrphanedChanges(): string + { + $rebuilt = new self($this->actorId, 0, [], [], [], [], [], $this->incrementalHeads, $this->frozen); + foreach ($this->changesWithSatisfiedDependencies() as $change) { + $rebuilt->applyRecordedChange($change); + } + + return $rebuilt->save(); + } + + public function saveIncremental(): string + { + $payload = $this->saveSince($this->incrementalHeads ?? []); + $this->incrementalHeads = $this->heads(); + + return $payload; + } + + /** + * @param list $heads + */ + public function saveSince(array $heads): string + { + return json_encode( + [ + 'format' => 'wordpress-de/automerge-php-native-incremental-v1', + 'changes' => $this->getChangesSince($heads), + ], + JSON_THROW_ON_ERROR + ); + } + + /** + * @return array{actor:string,sequence:int,heads:list,keys:int,textElements:int,bytes:int} + */ + public function stats(): array + { + $textElements = 0; + foreach ($this->root as $value) { + if ($value instanceof TextValue) { + $textElements += $value->elementCount(); + } + } + + return [ + 'actor' => $this->actorId, + 'sequence' => $this->sequence, + 'heads' => $this->heads, + 'keys' => count($this->root), + 'textElements' => $textElements, + 'bytes' => strlen(json_encode($this, JSON_THROW_ON_ERROR)), + ]; + } + + private static function defaultActorId(): string + { + return bin2hex(random_bytes(4)); + } + + private function assertMutable(): void + { + if ($this->frozen) { + throw new \RuntimeException('frozen document cannot be modified directly'); + } + } + + private function advanceClock(): void + { + ++$this->sequence; + } + + /** + * @param list $heads + * @param list $deps + */ + private function headsCoveredByDeps(array $heads, array $deps): bool + { + foreach ($heads as $head) { + if (! in_array($head, $deps, true)) { + return false; + } + } + + return true; + } + + /** + * @param array $change + */ + private function operationIdForChange(array $change, string $actor): string + { + return (int) ($change['seq'] ?? 0) . '@' . $actor; + } + + /** + * @param array $conflicts + * @return array + */ + private function withoutSupersededActorConflicts(array $conflicts, string $actor, int $sequence): array + { + foreach (array_keys($conflicts) as $operationId) { + if (! is_string($operationId)) { + continue; + } + + [$conflictSequence, $conflictActor] = $this->splitOperationId($operationId); + if ($conflictActor === $actor && $conflictSequence <= $sequence) { + unset($conflicts[$operationId]); + } + } + + return $conflicts; + } + + /** + * @param array $conflicts + */ + private function rootConflictWinner(array $conflicts): mixed + { + $winner = null; + $winnerOperationId = null; + foreach ($conflicts as $operationId => $value) { + if (! is_string($operationId)) { + continue; + } + + if ($winnerOperationId === null || $this->compareOperationIds($operationId, $winnerOperationId) > 0) { + $winnerOperationId = $operationId; + $winner = $value; + } + } + + return $this->copyValue($winner); + } + + private function compareOperationIds(string $left, string $right): int + { + [$leftSeq, $leftActor] = $this->splitOperationId($left); + [$rightSeq, $rightActor] = $this->splitOperationId($right); + if ($leftSeq !== $rightSeq) { + return $leftSeq <=> $rightSeq; + } + + return strcmp($leftActor, $rightActor); + } + + /** + * @return array{0:int,1:string} + */ + private function splitOperationId(string $operationId): array + { + [$sequence, $actor] = array_pad(explode('@', $operationId, 2), 2, ''); + + return [(int) $sequence, $actor]; + } + + private function copyValue(mixed $value): mixed + { + if ($value instanceof TextValue) { + return $value->copy(); + } + + if ($value instanceof Counter) { + return $value->copy(); + } + + if ($value instanceof BytesValue) { + return $value->copy(); + } + + if ($value instanceof BigIntValue) { + return new BigIntValue($value->toString()); + } + + if ($value instanceof ImmutableString) { + return new ImmutableString($value->toString()); + } + + if ($value instanceof \DateTimeInterface) { + return \DateTimeImmutable::createFromInterface($value); + } + + if (is_array($value)) { + $copy = []; + foreach ($value as $key => $item) { + $copy[$key] = $this->copyValue($item); + } + + return $copy; + } + + return $value; + } + + /** + * @param array> $conflicts + * @return array> + */ + private function copyConflicts(array $conflicts): array + { + $copy = []; + foreach ($conflicts as $key => $values) { + foreach ($values as $operationId => $value) { + if (is_string($operationId)) { + $copy[(string) $key][$operationId] = $this->copyValue($value); + } + } + } + + return $copy; + } + + /** + * @param array> $marks + * @return array> + */ + private function copyMarks(array $marks, bool $includeExpand = true): array + { + $copy = []; + foreach ($marks as $pathKey => $pathMarks) { + if (! is_string($pathKey) || ! is_array($pathMarks)) { + continue; + } + + foreach ($pathMarks as $mark) { + if (! is_array($mark) || ! is_string($mark['name'] ?? null)) { + continue; + } + + $start = max(0, (int) ($mark['start'] ?? 0)); + $end = max($start, (int) ($mark['end'] ?? $start)); + $copiedMark = [ + 'name' => $mark['name'], + 'value' => $this->copyValue($mark['value'] ?? true), + 'start' => $start, + 'end' => $end, + ]; + if ($includeExpand && is_string($mark['expand'] ?? null) && in_array($mark['expand'], ['none', 'start', 'end', 'both'], true)) { + $copiedMark['expand'] = $mark['expand']; + } + + $copy[$pathKey][] = $copiedMark; + } + } + + return $copy; + } + + /** + * @param array> $left + * @param array> $right + * @return array> + */ + private function mergeMarkSets(array $left, array $right): array + { + $merged = $this->copyMarks($left); + foreach ($this->copyMarks($right) as $pathKey => $marks) { + $seen = []; + foreach ($merged[$pathKey] ?? [] as $mark) { + $seen[$this->markIdentity($mark)] = true; + } + + foreach ($marks as $mark) { + $identity = $this->markIdentity($mark); + if (isset($seen[$identity])) { + continue; + } + + $merged[$pathKey][] = $mark; + $seen[$identity] = true; + } + + usort( + $merged[$pathKey], + static fn (array $a, array $b): int => [$a['start'], $a['end'], $a['name']] <=> [$b['start'], $b['end'], $b['name']] + ); + } + + ksort($merged, SORT_STRING); + + return $merged; + } + + /** + * @param array{name:string,value:mixed,start:int,end:int,expand?:string} $mark + */ + private function markIdentity(array $mark): string + { + return json_encode( + [ + $mark['name'], + $mark['value'], + $mark['start'], + $mark['end'], + $mark['expand'] ?? null, + ], + JSON_THROW_ON_ERROR + ); + } + + /** + * @param list $existing + * @return array{0:list,1:bool} + */ + private function marksAfterUnmark(array $existing, string $name, int $start, int $end): array + { + if ($start >= $end) { + return [$existing, false]; + } + + $remaining = []; + $changed = false; + foreach ($existing as $mark) { + if ( + $mark['name'] !== $name + || $mark['end'] <= $start + || $mark['start'] >= $end + ) { + $remaining[] = $mark; + continue; + } + + $changed = true; + if ($mark['start'] < $start) { + $left = $mark; + $left['end'] = $start; + $remaining[] = $left; + } + + if ($end < $mark['end']) { + $right = $mark; + $right['start'] = $end; + $remaining[] = $right; + } + } + + return [array_values($remaining), $changed]; + } + + private function adjustMarksForTextSplice(string $key, int $index, int $deleteCount, string $insert): void + { + $pathKey = $this->pathKey([$key]); + if (! isset($this->marks[$pathKey])) { + return; + } + + $insertLength = $this->utf16CodeUnitLength($insert); + $deleteStart = max(0, $index); + $deleteEnd = $deleteStart + max(0, $deleteCount); + $delta = $insertLength - max(0, $deleteCount); + $adjusted = []; + + foreach ($this->marks[$pathKey] as $mark) { + $start = $mark['start']; + $end = $mark['end']; + $expand = is_string($mark['expand'] ?? null) ? $mark['expand'] : 'none'; + + if ($deleteCount === 0) { + if ($deleteStart < $start) { + $start += $insertLength; + $end += $insertLength; + } elseif ($deleteStart === $start) { + if ($expand === 'start' || $expand === 'both') { + $end += $insertLength; + } else { + $start += $insertLength; + $end += $insertLength; + } + } elseif ($start < $deleteStart && $deleteStart < $end) { + $end += $insertLength; + } elseif ($deleteStart === $end && ($expand === 'end' || $expand === 'both')) { + $end += $insertLength; + } + } elseif ($deleteStart <= $start && $end <= $deleteEnd) { + if ($insertLength > 0 && in_array($expand, ['start', 'end', 'both'], true)) { + $start = $deleteStart; + $end = $deleteStart + $insertLength; + } else { + $start = $deleteStart; + $end = $deleteStart; + } + } elseif ($end <= $deleteStart) { + // Mark is fully before the edit. + } elseif ($start >= $deleteEnd) { + $start += $delta; + $end += $delta; + } else { + $start = min($start, $deleteStart); + $end = max($start, $end + $delta); + } + + if ($start < $end) { + $mark['start'] = max(0, $start); + $mark['end'] = max($mark['start'], $end); + $adjusted[] = $mark; + } + } + + if ($adjusted === []) { + unset($this->marks[$pathKey]); + return; + } + + $this->marks[$pathKey] = $adjusted; + } + + private function utf16CodeUnitLength(string $text): int + { + if ($text === '') { + return 0; + } + + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } + + private function setConflictMapValue(string $key, string $nestedKey, mixed $value): bool + { + $updated = false; + foreach ($this->conflicts[$key] ?? [] as $operationId => $conflictValue) { + if (! is_array($conflictValue)) { + continue; + } + + $conflictValue[$nestedKey] = $this->copyValue($value); + $this->conflicts[$key][$operationId] = $conflictValue; + $updated = true; + } + + if ($updated) { + $this->root[$key] = $this->rootConflictWinner($this->conflicts[$key]); + } + + return $updated; + } + + private function setConflictListElementMapValue(string $key, int $index, string $nestedKey, mixed $value): bool + { + $updated = false; + foreach ($this->conflicts[$key] ?? [] as $operationId => $conflictValue) { + if (! is_array($conflictValue) || ! array_is_list($conflictValue) || ! is_array($conflictValue[$index] ?? null)) { + continue; + } + + $conflictValue[$index][$nestedKey] = $this->copyValue($value); + $this->conflicts[$key][$operationId] = $conflictValue; + $updated = true; + } + + if ($updated) { + $this->root[$key] = $this->rootConflictWinner($this->conflicts[$key]); + } + + return $updated; + } + + private function resolveConflictListElementValue(string $key, int $index, mixed $value): bool + { + $conflicts = $this->conflicts[$key] ?? null; + $rootList = $this->root[$key] ?? null; + if (! is_array($conflicts) || ! is_array($rootList) || ! array_is_list($rootList) || ! array_key_exists($index, $rootList)) { + return false; + } + + $storedValue = $this->copyValue($value); + $rootList[$index] = $this->copyValue($storedValue); + $this->root[$key] = array_values($rootList); + $rootFingerprint = $this->valueFingerprint($this->root[$key]); + $remaining = []; + $updated = false; + + foreach ($conflicts as $operationId => $conflictValue) { + if (is_array($conflictValue) && array_is_list($conflictValue) && array_key_exists($index, $conflictValue)) { + $conflictValue[$index] = $this->copyValue($storedValue); + $updated = true; + } + + if ($this->valueFingerprint($conflictValue) !== $rootFingerprint) { + $remaining[$operationId] = $conflictValue; + } + } + + if (! $updated) { + return false; + } + + if (count($remaining) < 2) { + unset($this->conflicts[$key]); + return true; + } + + ksort($remaining, SORT_STRING); + $this->conflicts[$key] = $remaining; + $this->root[$key] = $this->rootConflictWinner($remaining); + + return true; + } + + private function incrementConflictCounters(string $key, int $amount, string $operationId): bool + { + $conflicts = $this->conflicts[$key] ?? null; + if (! is_array($conflicts)) { + return false; + } + + $remaining = []; + foreach ($conflicts as $conflictOperationId => $conflictValue) { + if (! $conflictValue instanceof Counter) { + continue; + } + + $remaining[$conflictOperationId] = $conflictValue->incremented($amount, $operationId); + } + + if ($remaining === []) { + return false; + } + + ksort($remaining, SORT_STRING); + $this->root[$key] = $this->rootConflictWinner($remaining); + if (count($remaining) < 2) { + unset($this->conflicts[$key]); + return true; + } + + $this->conflicts[$key] = $remaining; + + return true; + } + + private function incrementConflictListElementCounters(string $key, int $index, int $amount, string $operationId): bool + { + $conflicts = $this->conflicts[$key] ?? null; + $rootList = $this->root[$key] ?? null; + if (! is_array($conflicts) || ! is_array($rootList) || ! array_is_list($rootList) || ! array_key_exists($index, $rootList)) { + return false; + } + + $remaining = $conflicts; + $updated = false; + foreach ($conflicts as $conflictOperationId => $conflictValue) { + if ( + ! is_array($conflictValue) + || ! array_is_list($conflictValue) + || ! array_key_exists($index, $conflictValue) + || ! $conflictValue[$index] instanceof Counter + ) { + continue; + } + + $conflictValue[$index] = $conflictValue[$index]->incremented($amount, $operationId); + $remaining[$conflictOperationId] = $conflictValue; + $updated = true; + } + + if (! $updated) { + return false; + } + + ksort($remaining, SORT_STRING); + $this->root[$key] = $this->rootConflictWinner($remaining); + if (count($remaining) < 2) { + unset($this->conflicts[$key]); + return true; + } + + $this->conflicts[$key] = $remaining; + + return true; + } + + private function listElementCounterConflictsOnly(string $key, int $index): bool + { + foreach ($this->changes as $change) { + foreach (is_array($change['ops'] ?? null) ? $change['ops'] : [] as $op) { + if ( + ($op['action'] ?? null) === 'incrementRootConflictListElementCounters' + && ($op['key'] ?? null) === $key + && (int) ($op['index'] ?? -1) === $index + ) { + return true; + } + } + } + + return false; + } + + private function recordRootConflict(string $key, mixed $leftValue, self $rightDocument, mixed $rightValue): void + { + $conflicts = $this->conflicts[$key] ?? []; + if ($conflicts === []) { + $conflicts[$this->latestOperationIdForKey($key)] = $this->copyValue($leftValue); + } + + $conflicts[$rightDocument->latestOperationIdForKey($key)] = $this->copyValue($rightValue); + ksort($conflicts, SORT_STRING); + $this->conflicts[$key] = $conflicts; + } + + private function latestOperationIdForKey(string $key): string + { + for ($changeIndex = count($this->changes) - 1; $changeIndex >= 0; --$changeIndex) { + $change = $this->changes[$changeIndex]; + $ops = is_array($change['ops'] ?? null) ? $change['ops'] : []; + for ($opIndex = count($ops) - 1; $opIndex >= 0; --$opIndex) { + $op = $ops[$opIndex]; + if (! is_array($op) || ! is_string($op['action'] ?? null)) { + continue; + } + + if (is_string($op['key'] ?? null) && $op['key'] === $key && ! $this->isCounterIncrementSetOp($op)) { + return (int) ($change['seq'] ?? 0) . '@' . (is_string($change['actor'] ?? null) ? $change['actor'] : $this->actorId); + } + } + } + + for ($changeIndex = count($this->changes) - 1; $changeIndex >= 0; --$changeIndex) { + $change = $this->changes[$changeIndex]; + $ops = is_array($change['ops'] ?? null) ? $change['ops'] : []; + for ($opIndex = count($ops) - 1; $opIndex >= 0; --$opIndex) { + $op = $ops[$opIndex]; + if (! is_array($op) || ! is_string($op['action'] ?? null)) { + continue; + } + + if (is_array($op['path'] ?? null) && ($op['path'][0] ?? null) === $key) { + return (int) ($change['seq'] ?? 0) . '@' . (is_string($change['actor'] ?? null) ? $change['actor'] : $this->actorId); + } + } + } + + return '0@' . $this->actorId; + } + + /** + * @return array{operationId:string,index:int,values:list}|null + */ + private function latestListInsertionForKey(string $key): ?array + { + for ($changeIndex = count($this->changes) - 1; $changeIndex >= 0; --$changeIndex) { + $change = $this->changes[$changeIndex]; + $ops = is_array($change['ops'] ?? null) ? $change['ops'] : []; + for ($opIndex = count($ops) - 1; $opIndex >= 0; --$opIndex) { + $op = $ops[$opIndex]; + if (! is_array($op) || ($op['action'] ?? null) !== 'insertList' || ($op['key'] ?? null) !== $key) { + continue; + } + + $values = []; + foreach (is_array($op['values'] ?? null) ? $op['values'] : [] as $value) { + $values[] = $this->decodeValue($value); + } + + return [ + 'operationId' => $this->operationIdForChange( + $change, + is_string($change['actor'] ?? null) ? $change['actor'] : $this->actorId + ), + 'index' => (int) ($op['index'] ?? 0), + 'values' => $values, + ]; + } + } + + return null; + } + + /** + * @param array $op + */ + private function isCounterIncrementSetOp(array $op): bool + { + if (($op['action'] ?? null) !== 'set' || ! is_array($op['value'] ?? null)) { + return false; + } + + $value = $op['value']; + + return ($value['type'] ?? null) === 'counter' + && is_array($value['increments'] ?? null) + && $value['increments'] !== []; + } + + private function assertSupportedValue(mixed $value, string $path): void + { + if ( + $value instanceof TextValue + || $value instanceof Counter + || $value instanceof BytesValue + || $value instanceof BigIntValue + || $value instanceof ImmutableString + || $this->isImmutableStringLike($value) + || $value instanceof \DateTimeInterface + ) { + return; + } + + if ($value instanceof UndefinedValue) { + throw new \InvalidArgumentException('Cannot assign undefined value at ' . $path); + } + + if ($value instanceof \Closure) { + throw new \InvalidArgumentException('Cannot assign function value at ' . $path); + } + + if (is_resource($value)) { + throw new \InvalidArgumentException('Cannot assign resource value at ' . $path); + } + + if ($value instanceof self || $value instanceof DocumentObjectReference) { + throw new \InvalidArgumentException('Cannot create a reference to an existing document object at ' . $path); + } + + if (is_object($value)) { + throw new \InvalidArgumentException('Cannot assign object value at ' . $path); + } + + if (! is_array($value)) { + return; + } + + foreach ($value as $key => $item) { + $this->assertSupportedValue($item, $path . '/' . (string) $key); + } + } + + /** + * @param list $path + * @return list + */ + private function normalizePath(array $path): array + { + if ($path === []) { + throw new \InvalidArgumentException('Nested assignment requires a non-empty path.'); + } + + return array_map( + static function (mixed $key): string|int { + if (is_int($key) || is_string($key)) { + return $key; + } + + throw new \InvalidArgumentException('Nested assignment path keys must be strings or integers.'); + }, + array_values($path) + ); + } + + /** + * @param array $container + * @param list $path + */ + private function assignNestedPath(array &$container, array $path, mixed $value): void + { + $key = array_shift($path); + if ($path === []) { + $container[$key] = $this->copyValue($value); + return; + } + + if (! array_key_exists($key, $container) || ! is_array($container[$key])) { + $container[$key] = []; + } + + $this->assignNestedPath($container[$key], $path, $value); + } + + /** + * @param array $container + * @param list $path + */ + private function deleteNestedPath(array &$container, array $path): void + { + $key = array_shift($path); + if ($path === []) { + unset($container[$key]); + return; + } + + if (! array_key_exists($key, $container) || ! is_array($container[$key])) { + return; + } + + $this->deleteNestedPath($container[$key], $path); + } + + /** + * @param list $path + */ + private function pathString(array $path): string + { + return '/' . implode('/', array_map(static fn (string|int $key): string => (string) $key, $path)); + } + + /** + * @param list $path + */ + private function pathKey(array $path): string + { + return json_encode($path, JSON_THROW_ON_ERROR); + } + + private function valueForKey(string $key, mixed $value): mixed + { + if ($this->isImmutableStringLike($value)) { + return new ImmutableString((string) $value); + } + + if (is_string($value) && $this->isTextKey($key)) { + return TextValue::fromString($value, $this->actorId, $this->sequence); + } + + return $value; + } + + private function textValueForExistingKey(string $key, mixed $value): TextValue + { + [$originSequence, $originActor] = $this->splitOperationId($this->latestOperationIdForKey($key)); + $seedSequence = max(0, $originSequence); + $text = TextValue::fromString( + is_string($value) ? $value : '', + $originActor !== '' ? $originActor : $this->actorId, + $seedSequence + ); + $this->sequence = max($this->sequence, $seedSequence); + + return $text; + } + + private function isImmutableStringLike(mixed $value): bool + { + if ($value instanceof ImmutableString) { + return true; + } + + if (! is_object($value)) { + return false; + } + + return ($value->isImmutableString ?? false) === true && method_exists($value, '__toString'); + } + + private function isTextKey(string $key): bool + { + return $key === 'text' || $key === 'postContent'; + } + + private function materializeValue(mixed $value): mixed + { + if ($value instanceof TextValue) { + return $value->toString(); + } + + if ($value instanceof Counter) { + return $value->copy(); + } + + if ($value instanceof BytesValue) { + return $value->copy(); + } + + if ($value instanceof ImmutableString) { + return new ImmutableString($value->toString()); + } + + if ($value instanceof \DateTimeInterface) { + return \DateTimeImmutable::createFromInterface($value); + } + + if (is_array($value)) { + $materialized = []; + foreach ($value as $key => $item) { + $materialized[$key] = $this->materializeValue($item); + } + + return $materialized; + } + + return $value; + } + + /** + * @return array{0:int,1:int,2:string} + */ + private function diffText(string $oldText, string $newText): array + { + $old = $this->splitCharacters($oldText); + $new = $this->splitCharacters($newText); + $oldLength = count($old); + $newLength = count($new); + $prefix = 0; + + while ($prefix < $oldLength && $prefix < $newLength && $old[$prefix] === $new[$prefix]) { + ++$prefix; + } + + $suffix = 0; + while ( + $suffix < ($oldLength - $prefix) + && $suffix < ($newLength - $prefix) + && $old[$oldLength - 1 - $suffix] === $new[$newLength - 1 - $suffix] + ) { + ++$suffix; + } + + return [ + $prefix, + $oldLength - $prefix - $suffix, + implode('', array_slice($new, $prefix, $newLength - $prefix - $suffix)), + ]; + } + + /** + * @return list + */ + private function splitCharacters(string $text): array + { + if (! preg_match_all('/\X/u', $text, $matches)) { + if ($text === '') { + return []; + } + + throw new \InvalidArgumentException('Text must be valid UTF-8.'); + } + + return $matches[0]; + } + + /** + * @param list $deps + * @param list> $ops + */ + private function recordChange(array $deps, array $ops, ?string $message = null, ?int $time = null, bool $preserveUnrelatedHeads = false): void + { + $deps = $this->sortedUniqueHeads($deps); + $previousHeads = $this->heads; + $change = [ + 'actor' => $this->actorId, + 'seq' => $this->sequence, + 'deps' => $deps, + 'message' => $message, + 'time' => $time ?? time(), + 'startOp' => $this->nextStartOp(), + 'ops' => $ops, + ]; + $change['hash'] = hash('sha256', json_encode($change, JSON_THROW_ON_ERROR)); + + $this->changes[] = $change; + if ($preserveUnrelatedHeads) { + $remainingHeads = array_values(array_filter( + $previousHeads, + static fn (string $head): bool => ! in_array($head, $deps, true) + )); + $this->heads = $this->sortedUniqueHeads(array_merge($remainingHeads, [$change['hash']])); + return; + } + + $this->heads = [$change['hash']]; + } + + /** + * @param array $change + */ + private function applyRecordedChange(array $change): void + { + $hash = is_string($change['hash'] ?? null) + ? $change['hash'] + : hash('sha256', json_encode($change, JSON_THROW_ON_ERROR)); + if ($this->hasChangeHash($hash)) { + return; + } + + $previousHeads = $this->heads; + $actor = is_string($change['actor'] ?? null) ? $change['actor'] : $this->actorId; + $ops = is_array($change['ops'] ?? null) ? $change['ops'] : []; + $deps = $this->sortedUniqueHeads(is_array($change['deps'] ?? null) ? $change['deps'] : []); + $textReplaySequence = $this->sequence; + $generatedTextElements = $this->recordedChangeGeneratedTextElementCount($ops); + if ($generatedTextElements > 0) { + $textReplaySequence = max($textReplaySequence, (int) ($change['seq'] ?? 0) - $generatedTextElements); + } + + foreach ($ops as $op) { + if (! is_array($op) || ! is_string($op['action'] ?? null)) { + continue; + } + + if ( + $op['action'] === 'setRootConflictMapValue' + && is_string($op['key'] ?? null) + && is_string($op['nestedKey'] ?? null) + ) { + $this->setConflictMapValue( + $op['key'], + $op['nestedKey'], + $this->decodeValue($op['value'] ?? null) + ); + continue; + } + + if ( + $op['action'] === 'setRootConflictListElementMapValue' + && is_string($op['key'] ?? null) + && is_string($op['nestedKey'] ?? null) + ) { + $this->setConflictListElementMapValue( + $op['key'], + (int) ($op['index'] ?? 0), + $op['nestedKey'], + $this->decodeValue($op['value'] ?? null) + ); + continue; + } + + if ($op['action'] === 'resolveRootConflictListElement' && is_string($op['key'] ?? null)) { + $this->resolveConflictListElementValue( + $op['key'], + (int) ($op['index'] ?? 0), + $this->decodeValue($op['value'] ?? null) + ); + continue; + } + + if ($op['action'] === 'incrementRootConflictCounters' && is_string($op['key'] ?? null)) { + $this->incrementConflictCounters( + $op['key'], + (int) ($op['amount'] ?? 0), + is_string($op['operationId'] ?? null) ? $op['operationId'] : $hash + ); + continue; + } + + if ($op['action'] === 'incrementRootConflictListElementCounters' && is_string($op['key'] ?? null)) { + $this->incrementConflictListElementCounters( + $op['key'], + (int) ($op['index'] ?? 0), + (int) ($op['amount'] ?? 0), + is_string($op['operationId'] ?? null) ? $op['operationId'] : $hash + ); + continue; + } + + if ($op['action'] === 'setNested' && is_array($op['path'] ?? null)) { + $this->assignNestedPath( + $this->root, + $this->normalizePath($op['path']), + $this->decodeValue($op['value'] ?? null) + ); + continue; + } + + if ($op['action'] === 'insertList' && is_string($op['key'] ?? null)) { + $key = $op['key']; + $currentList = $this->root[$key] ?? null; + $hasExistingListItems = is_array($currentList) && array_is_list($currentList) && count($currentList) > 0; + if (! $hasExistingListItems && ! $this->headsCoveredByDeps($deps, $previousHeads) && is_array($op['value'] ?? null)) { + $this->root[$key] = $this->decodeValue($op['value']); + unset($this->conflicts[$key]); + continue; + } + + $list = $currentList ?? []; + $list = is_array($list) && array_is_list($list) ? array_values($list) : []; + $values = []; + foreach (is_array($op['values'] ?? null) ? $op['values'] : [] as $value) { + $values[] = $this->decodeValue($value); + } + $index = max(0, min((int) ($op['index'] ?? 0), count($list))); + array_splice($list, $index, 0, $values); + $this->root[$key] = array_values($list); + unset($this->conflicts[$key]); + continue; + } + + if ($op['action'] === 'deleteNested' && is_array($op['path'] ?? null)) { + $this->deleteNestedPath($this->root, $this->normalizePath($op['path'])); + continue; + } + + if ($op['action'] === 'mark' && is_array($op['path'] ?? null) && is_array($op['marks'] ?? null)) { + $path = $this->normalizePath($op['path']); + $pathKey = $this->pathKey($path); + $this->marks[$pathKey] = array_values(array_merge( + $this->marks[$pathKey] ?? [], + $this->copyMarks([$pathKey => $op['marks']])[$pathKey] ?? [] + )); + continue; + } + + if ($op['action'] === 'unmark' && is_array($op['path'] ?? null) && is_string($op['name'] ?? null)) { + $pathKey = $this->pathKey($this->normalizePath($op['path'])); + $start = max(0, (int) ($op['start'] ?? 0)); + $end = max($start, (int) ($op['end'] ?? $start)); + [$remaining] = $this->marksAfterUnmark($this->marks[$pathKey] ?? [], $op['name'], $start, $end); + if ($remaining === []) { + unset($this->marks[$pathKey]); + } else { + $this->marks[$pathKey] = $remaining; + } + continue; + } + + if (! is_string($op['key'] ?? null)) { + continue; + } + + if ($op['action'] === 'set') { + $key = $op['key']; + $value = $this->decodeValue($op['value'] ?? null); + if ($this->headsCoveredByDeps($previousHeads, $deps) || ! array_key_exists($key, $this->root)) { + $this->root[$key] = $value; + unset($this->conflicts[$key]); + continue; + } + + if (! isset($this->conflicts[$key])) { + $this->conflicts[$key][$this->latestOperationIdForKey($key)] = $this->copyValue($this->root[$key]); + } + + $this->conflicts[$key] = $this->withoutSupersededActorConflicts( + $this->conflicts[$key], + $actor, + (int) ($change['seq'] ?? 0) + ); + $this->conflicts[$key][$this->operationIdForChange($change, $actor)] = $this->copyValue($value); + ksort($this->conflicts[$key], SORT_STRING); + $this->root[$key] = $this->rootConflictWinner($this->conflicts[$key]); + continue; + } + + if ($op['action'] === 'delete') { + unset($this->root[$op['key']]); + continue; + } + + if ($op['action'] === 'splice') { + $value = $this->root[$op['key']] ?? null; + if (! $value instanceof TextValue) { + $value = $this->textValueForExistingKey($op['key'], $value); + } + + $value->splice( + (int) ($op['index'] ?? 0), + (int) ($op['deleteCount'] ?? 0), + is_string($op['insert'] ?? null) ? $op['insert'] : '', + $actor, + $textReplaySequence + ); + $this->sequence = max($this->sequence, $textReplaySequence); + $this->root[$op['key']] = $value; + } + + if ($op['action'] === 'putText') { + $value = $this->root[$op['key']] ?? null; + if (! $value instanceof TextValue) { + $value = $this->textValueForExistingKey($op['key'], $value); + } + + $value->splice( + (int) ($op['index'] ?? 0), + 1, + is_string($op['value'] ?? null) ? $op['value'] : '', + $actor, + $textReplaySequence + ); + $this->sequence = max($this->sequence, $textReplaySequence); + $this->root[$op['key']] = $value; + } + } + + $this->sequence = max($this->sequence, (int) ($change['seq'] ?? 0)); + $remainingHeads = array_values(array_filter( + $previousHeads, + static fn (string $head): bool => ! in_array($head, $deps, true) + )); + $this->heads = $this->sortedUniqueHeads(array_merge($remainingHeads, [$hash])); + $this->changes[] = $change + ['hash' => $hash, 'deps' => $deps]; + } + + private function nextStartOp(): int + { + $next = 1; + foreach ($this->changes as $change) { + foreach (is_array($change['ops'] ?? null) ? $change['ops'] : [] as $op) { + if (is_array($op)) { + $next += $this->legacyOperationCount($op); + } + } + } + + return $next; + } + + /** + * @param array $op + */ + private function legacyOperationCount(array $op): int + { + if ($op['action'] === 'set' && is_array($op['value'] ?? null)) { + return $this->encodedValueOperationCount($op['value']); + } + + if ($op['action'] === 'insertList' && is_array($op['values'] ?? null)) { + $count = 0; + foreach ($op['values'] as $value) { + $count += $this->encodedValueOperationCount($value); + } + + return max(1, $count); + } + + if ($op['action'] === 'setNested' && is_array($op['value'] ?? null)) { + return $this->encodedValueOperationCount($op['value']); + } + + return 1; + } + + /** + * @param list> $ops + */ + private function recordedChangeGeneratedTextElementCount(array $ops): int + { + $count = 0; + foreach ($ops as $op) { + if (! is_array($op) || ! is_string($op['action'] ?? null)) { + continue; + } + + if ($op['action'] === 'splice' && is_string($op['insert'] ?? null)) { + $count += count($this->splitCharacters($op['insert'])); + continue; + } + + if ($op['action'] === 'putText' && is_string($op['value'] ?? null)) { + $count += count($this->splitCharacters($op['value'])); + } + } + + return $count; + } + + private function encodedValueOperationCount(mixed $encoded): int + { + if (! is_array($encoded)) { + return 1; + } + + if (($encoded['type'] ?? null) === 'scalar') { + return is_string($encoded['value'] ?? null) ? 2 : 1; + } + + if (($encoded['type'] ?? null) === 'text') { + if (is_int($encoded['elementCount'] ?? null)) { + return 1 + max(0, $encoded['elementCount']); + } + + $elements = is_array($encoded['elements'] ?? null) ? $encoded['elements'] : []; + + return 1 + count($elements); + } + + if (($encoded['type'] ?? null) === 'array') { + $count = 1; + foreach (is_array($encoded['value'] ?? null) ? $encoded['value'] : [] as $value) { + $count += $this->encodedValueOperationCount($value); + } + + return $count; + } + + return 1; + } + + /** + * @return array{actor:string,hash:string,message:?string,time:?int,deps:list,startOp:int,seq:int} + */ + private function changeMetadata(array $change): array + { + return [ + 'actor' => is_string($change['actor'] ?? null) ? $change['actor'] : '', + 'hash' => is_string($change['hash'] ?? null) ? $change['hash'] : '', + 'message' => is_string($change['message'] ?? null) ? $change['message'] : null, + 'time' => is_int($change['time'] ?? null) ? $change['time'] : null, + 'deps' => is_array($change['deps'] ?? null) ? array_values($change['deps']) : [], + 'startOp' => (int) ($change['startOp'] ?? 1), + 'seq' => (int) ($change['seq'] ?? 0), + ]; + } + + /** + * @return array{actor:string,deps:list,hash:string,message:?string,ops:list>,seq:int,startOp:int,time:?int} + */ + private function inspectableChange(array $change): array + { + $metadata = $this->changeMetadata($change); + + return [ + 'actor' => $metadata['actor'], + 'deps' => $metadata['deps'], + 'hash' => $metadata['hash'], + 'message' => $metadata['message'], + 'ops' => $this->inspectableOps($change), + 'seq' => $metadata['seq'], + 'startOp' => $metadata['startOp'], + 'time' => $metadata['time'], + ]; + } + + /** + * @return list> + */ + private function inspectableOps(array $change): array + { + $ops = []; + $actor = is_string($change['actor'] ?? null) ? $change['actor'] : $this->actorId; + $startOp = (int) ($change['startOp'] ?? 1); + foreach (is_array($change['ops'] ?? null) ? $change['ops'] : [] as $index => $op) { + if (! is_array($op) || ! is_string($op['action'] ?? null)) { + continue; + } + + if ( + $op['action'] === 'set' + && is_string($op['key'] ?? null) + && is_array($op['value'] ?? null) + && ($op['value']['type'] ?? null) === 'scalar' + && is_string($op['value']['value'] ?? null) + ) { + $objectId = ($startOp + $index) . '@' . $actor; + $ops[] = [ + 'action' => 'makeText', + 'key' => $op['key'], + 'obj' => '_root', + 'pred' => [], + ]; + $ops[] = [ + 'action' => 'set', + 'elemId' => '_head', + 'insert' => true, + 'obj' => $objectId, + 'pred' => [], + 'value' => $op['value']['value'], + ]; + continue; + } + + $ops[] = $op; + } + + return $ops; + } + + /** + * @return array> + */ + private function encodeConflicts(): array + { + $encoded = []; + foreach ($this->conflicts as $key => $values) { + foreach ($values as $operationId => $value) { + $encoded[$key][$operationId] = $this->encodeValue($value); + } + } + + return $encoded; + } + + private function encodeValue(mixed $value): mixed + { + if ($value instanceof TextValue) { + return $value->encodedValue(); + } + + if ($value instanceof Counter) { + return [ + 'type' => 'counter', + 'id' => $value->id(), + 'initial' => $value->initialValue(), + 'increments' => $value->increments(), + ]; + } + + if ($value instanceof BytesValue) { + return [ + 'type' => 'bytes', + 'value' => $value->bytes(), + ]; + } + + if ($value instanceof BigIntValue) { + return [ + 'type' => 'bigint', + 'decimal' => $value->toString(), + ]; + } + + if ($value instanceof ImmutableString) { + return [ + 'type' => 'immutableString', + 'value' => $value->toString(), + ]; + } + + if ($value instanceof \DateTimeInterface) { + return [ + 'type' => 'date', + 'millis' => $this->dateMillis($value), + ]; + } + + if (is_float($value) && is_nan($value)) { + return [ + 'type' => 'float', + 'value' => 'NaN', + ]; + } + + if (is_float($value) && is_infinite($value)) { + return [ + 'type' => 'float', + 'value' => $value > 0 ? 'Infinity' : '-Infinity', + ]; + } + + if (is_array($value)) { + $encoded = []; + foreach ($value as $key => $item) { + $encoded[$key] = $this->encodeValue($item); + } + + return [ + 'type' => 'array', + 'value' => $encoded, + ]; + } + + return [ + 'type' => 'scalar', + 'value' => $value, + ]; + } + + private function valueFingerprint(mixed $value): string + { + return json_encode($this->encodeValue($value), JSON_THROW_ON_ERROR); + } + + private function decodeValue(mixed $encoded, bool $convertStringScalarsToText = false, ?int &$textSequence = null): mixed + { + if (! is_array($encoded) || ! is_string($encoded['type'] ?? null)) { + return $encoded; + } + + if ($encoded['type'] === 'text') { + if (is_string($encoded['value'] ?? null)) { + return TextValue::fromCompactString( + $encoded['value'], + is_string($encoded['actor'] ?? null) ? $encoded['actor'] : $this->actorId, + max(1, (int) ($encoded['startSeq'] ?? 1)), + is_int($encoded['elementCount'] ?? null) ? $encoded['elementCount'] : null + ); + } + + if (is_array($encoded['elements'] ?? null)) { + return new TextValue($encoded['elements']); + } + } + + if ($encoded['type'] === 'counter') { + $increments = []; + if (is_array($encoded['increments'] ?? null)) { + foreach ($encoded['increments'] as $operationId => $amount) { + if (is_string($operationId) && is_int($amount)) { + $increments[$operationId] = $amount; + } + } + } + + return new Counter( + (int) ($encoded['initial'] ?? 0), + is_string($encoded['id'] ?? null) ? $encoded['id'] : null, + $increments + ); + } + + if ($encoded['type'] === 'bytes' && is_array($encoded['value'] ?? null)) { + return new BytesValue(array_values(array_map(static fn (mixed $byte): int => (int) $byte, $encoded['value']))); + } + + if ($encoded['type'] === 'bigint' && is_string($encoded['decimal'] ?? null)) { + return new BigIntValue($encoded['decimal']); + } + + if ($encoded['type'] === 'float' && is_string($encoded['value'] ?? null)) { + return match ($encoded['value']) { + 'NaN' => NAN, + 'Infinity' => INF, + '-Infinity' => -INF, + default => null, + }; + } + + if ($encoded['type'] === 'immutableString' && is_string($encoded['value'] ?? null)) { + return new ImmutableString($encoded['value']); + } + + if ($encoded['type'] === 'array' && is_array($encoded['value'] ?? null)) { + $decoded = []; + foreach ($encoded['value'] as $key => $value) { + $decoded[$key] = $this->decodeValue($value, $convertStringScalarsToText, $textSequence); + } + + return $decoded; + } + + if ($encoded['type'] === 'date') { + return $this->dateFromMillis((int) ($encoded['millis'] ?? 0)); + } + + if ($convertStringScalarsToText && ($encoded['type'] ?? null) === 'scalar' && is_string($encoded['value'] ?? null)) { + $textSequence ??= $this->sequence; + + return TextValue::fromString($encoded['value'], $this->actorId, $textSequence); + } + + return $encoded['value'] ?? null; + } + + /** + * @param list> $left + * @param list> $right + * @return list> + */ + private function mergeChangeLists(array $left, array $right): array + { + $changes = []; + foreach (array_merge($left, $right) as $change) { + $hash = is_string($change['hash'] ?? null) + ? $change['hash'] + : hash('sha256', json_encode($change, JSON_THROW_ON_ERROR)); + $changes[$hash] = $change + ['hash' => $hash]; + } + + return array_values($changes); + } + + private function hasChangeHash(string $hash): bool + { + foreach ($this->changes as $change) { + if (($change['hash'] ?? null) === $hash) { + return true; + } + } + + return false; + } + + private function rebuildMaterializedStateFromDependencyOrder(): void + { + $rebuilt = new self($this->actorId, 0, [], [], [], [], [], $this->incrementalHeads, $this->frozen); + foreach ($this->changesInDependencyOrder($this->changes) as $change) { + $rebuilt->applyRecordedChange($change); + } + + $this->sequence = $rebuilt->sequence; + $this->root = $rebuilt->root; + $this->heads = $rebuilt->heads; + $this->changes = $rebuilt->changes; + $this->conflicts = $rebuilt->conflicts; + $this->marks = $rebuilt->marks; + } + + /** + * @param list> $changes + * @return list> + */ + private function changesInDependencyOrder(array $changes): array + { + $remaining = []; + foreach ($changes as $change) { + $hash = $this->changeHash($change); + if (! isset($remaining[$hash])) { + $remaining[$hash] = $change + ['hash' => $hash]; + } + } + + $ordered = []; + $orderedHashes = []; + while ($remaining !== []) { + $madeProgress = false; + foreach ($remaining as $hash => $change) { + if ($this->changeHasUnorderedInternalDependency($change, $remaining, $orderedHashes)) { + continue; + } + + $ordered[] = $change; + $orderedHashes[$hash] = true; + unset($remaining[$hash]); + $madeProgress = true; + } + + if (! $madeProgress) { + array_push($ordered, ...array_values($remaining)); + break; + } + } + + return $ordered; + } + + /** + * @return list> + */ + private function changesWithSatisfiedDependencies(): array + { + $retained = []; + $known = []; + foreach ($this->changesInDependencyOrder($this->changes) as $change) { + $deps = $this->sortedUniqueHeads(is_array($change['deps'] ?? null) ? $change['deps'] : []); + foreach ($deps as $dep) { + if (! isset($known[$dep])) { + continue 2; + } + } + + $hash = $this->changeHash($change); + $retained[] = $change + ['hash' => $hash, 'deps' => $deps]; + $known[$hash] = true; + } + + return $retained; + } + + /** + * @param array $change + * @param array> $remaining + * @param array $orderedHashes + */ + private function changeHasUnorderedInternalDependency(array $change, array $remaining, array $orderedHashes): bool + { + foreach (is_array($change['deps'] ?? null) ? $change['deps'] : [] as $dep) { + if (is_string($dep) && isset($remaining[$dep]) && ! isset($orderedHashes[$dep])) { + return true; + } + } + + return false; + } + + /** + * @param array $change + */ + private function changeHash(array $change): string + { + return is_string($change['hash'] ?? null) + ? $change['hash'] + : hash('sha256', json_encode($change, JSON_THROW_ON_ERROR)); + } + + /** + * @param list $heads + * @return list + */ + private function sortedUniqueHeads(array $heads): array + { + $unique = []; + foreach ($heads as $head) { + if (is_string($head)) { + $unique[$head] = true; + } + } + + $sorted = array_keys($unique); + sort($sorted, SORT_STRING); + + return $sorted; + } + + private function dateMillis(\DateTimeInterface $value): int + { + return ((int) $value->format('U')) * 1000 + intdiv((int) $value->format('u'), 1000); + } + + private function dateFromMillis(int $millis): \DateTimeImmutable + { + $seconds = intdiv($millis, 1000); + $microseconds = ($millis % 1000) * 1000; + $date = \DateTimeImmutable::createFromFormat( + 'U.u', + sprintf('%d.%06d', $seconds, $microseconds), + new \DateTimeZone('UTC') + ); + if (! $date instanceof \DateTimeImmutable) { + throw new \InvalidArgumentException('Unable to decode native date value.'); + } + + return $date->setTimezone(new \DateTimeZone('UTC')); + } +} diff --git a/src/wp-includes/automerge-php/src/DocumentObjectReference.php b/src/wp-includes/automerge-php/src/DocumentObjectReference.php new file mode 100644 index 0000000000000..ad0a079ed1f78 --- /dev/null +++ b/src/wp-includes/automerge-php/src/DocumentObjectReference.php @@ -0,0 +1,27 @@ + */ + private array $path; + + /** + * @param list $path + */ + public function __construct(array $path) + { + $this->path = array_values($path); + } + + /** + * @return list + */ + public function path(): array + { + return $this->path; + } +} diff --git a/src/wp-includes/automerge-php/src/ImmutableString.php b/src/wp-includes/automerge-php/src/ImmutableString.php new file mode 100644 index 0000000000000..6d0ce67e78a78 --- /dev/null +++ b/src/wp-includes/automerge-php/src/ImmutableString.php @@ -0,0 +1,31 @@ +value; + } + + public function __toString(): string + { + return $this->value; + } + + public function jsonSerialize(): string + { + return $this->value; + } +} diff --git a/src/wp-includes/automerge-php/src/IsolatedDocument.php b/src/wp-includes/automerge-php/src/IsolatedDocument.php new file mode 100644 index 0000000000000..8cb355e4ffa5b --- /dev/null +++ b/src/wp-includes/automerge-php/src/IsolatedDocument.php @@ -0,0 +1,89 @@ + */ + private array $heads; + + /** @var list|null */ + private ?array $diffCursor; + + /** + * @param list $heads + * @param list|null $diffCursor + */ + public function __construct(Document $hiddenDocument, Document $visibleDocument, array $heads, ?array $diffCursor = null) + { + $this->hiddenDocument = $hiddenDocument->clone(); + $this->visibleDocument = $visibleDocument->clone(); + $this->heads = $this->normalizeHeads($heads); + $this->diffCursor = $diffCursor === null ? null : $this->normalizeHeads($diffCursor); + } + + public function hiddenDocument(): Document + { + return $this->hiddenDocument->clone(); + } + + public function visibleDocument(): Document + { + return $this->visibleDocument->clone(); + } + + /** + * @return list + */ + public function heads(): array + { + return $this->heads; + } + + public function withHiddenDocument(Document $hiddenDocument): self + { + return new self($hiddenDocument, $this->visibleDocument, $this->heads, $this->diffCursor); + } + + public function withVisibleDocument(Document $visibleDocument): self + { + return new self($this->hiddenDocument, $visibleDocument, $this->heads, $this->diffCursor); + } + + /** + * @return list|null + */ + public function diffCursor(): ?array + { + return $this->diffCursor; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->visibleDocument->toArray(); + } + + /** + * @param list $heads + * @return list + */ + private function normalizeHeads(array $heads): array + { + foreach ($heads as $head) { + if (! is_string($head)) { + throw new \InvalidArgumentException('Isolated document heads must be strings.'); + } + } + + return array_values($heads); + } +} diff --git a/src/wp-includes/automerge-php/src/NativePort.php b/src/wp-includes/automerge-php/src/NativePort.php new file mode 100644 index 0000000000000..cb4283cf5c57e --- /dev/null +++ b/src/wp-includes/automerge-php/src/NativePort.php @@ -0,0 +1,9486 @@ +patchCallback = $patchCallback; + $this->outdatedChangeBases = new \SplObjectStorage(); + $this->documentPatchCallbacks = new \SplObjectStorage(); + $this->documentDiffCursors = new \SplObjectStorage(); + } + + public function withPatchCallback(callable $patchCallback): self + { + return new self($patchCallback); + } + + public function next(): self + { + return new self($this->patchCallback); + } + + public function init(?string $actorId = null): Document + { + return Document::init($actorId); + } + + public function initFrozen(?string $actorId = null): Document + { + return $this->init($actorId)->withFrozen(true); + } + + public function initWithPatchCallback(callable $patchCallback, ?string $actorId = null): Document + { + $document = $this->init($actorId); + $this->documentPatchCallbacks[$document] = $patchCallback; + + return $document; + } + + public function from(mixed $root, ?string $actorId = null): Document + { + return Document::fromArray($this->initialRootMap($root), $actorId); + } + + public function fromWithPatchCallback(mixed $root, callable $patchCallback, ?string $actorId = null): Document + { + $document = $this->from($root, $actorId); + $before = $this->init($document->actorId()); + $patches = $this->patchesBetweenDocuments($before, $document); + if ($patches !== []) { + $patchCallback($patches, ['before' => $before, 'after' => $document, 'source' => 'from']); + } + $this->documentPatchCallbacks[$document] = $patchCallback; + + return $document; + } + + public function fromFrozen(mixed $root, ?string $actorId = null): Document + { + return $this->from($root, $actorId)->withFrozen(true); + } + + public function clone(Document $document, ?string $actorId = null): Document + { + return $document->clone($actorId); + } + + public function cloneWithPatchCallback(Document $document, callable $patchCallback, ?string $actorId = null): Document + { + $cloned = $this->clone($document, $actorId); + $this->documentPatchCallbacks[$cloned] = $patchCallback; + + return $cloned; + } + + /** + * @param list $heads + */ + public function view(Document $document, array $heads): Document + { + return $document->view($heads); + } + + /** + * @param list $heads + */ + public function isolate(Document|IsolatedDocument $document, array $heads): IsolatedDocument + { + $hiddenDocument = $document instanceof IsolatedDocument + ? $this->mergeDocuments($document->hiddenDocument(), $document->visibleDocument()) + : $document; + $heads = $this->validateDiffHeads($hiddenDocument, $heads, 'isolate'); + $visibleDocument = $hiddenDocument->view($heads)->withFrozen(false); + $visibleDocument->ensureSequenceAtLeast($hiddenDocument->stats()['sequence']); + $diffCursor = $document instanceof IsolatedDocument ? $document->diffCursor() : null; + if (! $document instanceof IsolatedDocument && $this->documentDiffCursors->contains($document)) { + $cursor = $this->documentDiffCursors[$document]; + $diffCursor = is_array($cursor) ? array_values($cursor) : null; + } + + return new IsolatedDocument($hiddenDocument, $visibleDocument, $heads, $diffCursor); + } + + public function isolatedDocument(IsolatedDocument $document): Document + { + return $document->visibleDocument(); + } + + public function mergeIntoIsolation(IsolatedDocument $document, Document $other): IsolatedDocument + { + return $document->withHiddenDocument($this->mergeDocuments($document->hiddenDocument(), $other)); + } + + public function integrate(IsolatedDocument $document): Document + { + $integrated = $this->mergeDocuments($document->hiddenDocument(), $document->visibleDocument()); + $diffCursor = $document->diffCursor(); + if ($diffCursor !== null) { + $this->documentDiffCursors[$integrated] = $diffCursor; + } + + return $integrated; + } + + public function setInIsolation(IsolatedDocument $document, string $key, mixed $value): IsolatedDocument + { + return $document->withVisibleDocument($this->set($this->isolationEditDocument($document), $key, $value)); + } + + /** + * @param list $path + */ + public function setNestedInIsolation(IsolatedDocument $document, array $path, mixed $value): IsolatedDocument + { + return $document->withVisibleDocument($this->setNested($this->isolationEditDocument($document), $path, $value)); + } + + public function deleteInIsolation(IsolatedDocument $document, string $key): IsolatedDocument + { + return $document->withVisibleDocument($this->delete($this->isolationEditDocument($document), $key)); + } + + /** + * @param list $path + */ + public function deleteNestedInIsolation(IsolatedDocument $document, array $path): IsolatedDocument + { + return $document->withVisibleDocument($this->deleteNested($this->isolationEditDocument($document), $path)); + } + + /** + * @param list $values + */ + public function insertListElementsInIsolation(IsolatedDocument $document, string $key, int $index, array $values): IsolatedDocument + { + return $document->withVisibleDocument($this->insertListElements($this->isolationEditDocument($document), $key, $index, $values)); + } + + public function setListElementInIsolation(IsolatedDocument $document, string $key, int $index, mixed $value): IsolatedDocument + { + return $document->withVisibleDocument($this->setListElement($this->isolationEditDocument($document), $key, $index, $value)); + } + + public function deleteListElementsInIsolation(IsolatedDocument $document, string $key, int $index, int $length = 1): IsolatedDocument + { + return $document->withVisibleDocument($this->deleteListElements($this->isolationEditDocument($document), $key, $index, $length)); + } + + public function spliceInIsolation(IsolatedDocument $document, string $key, int $index, int $deleteCount, string $insert = ''): IsolatedDocument + { + return $document->withVisibleDocument($this->splice($this->isolationEditDocument($document), $key, $index, $deleteCount, $insert)); + } + + public function isAutomerge(mixed $value): bool + { + return $value instanceof Document; + } + + public function immutableString(string $value): ImmutableString + { + return new ImmutableString($value); + } + + public function rawString(string $value): RawString + { + return new RawString($value); + } + + public function bigInt(string|int $decimal): BigIntValue + { + return new BigIntValue($decimal); + } + + public function undefined(): UndefinedValue + { + return new UndefinedValue(); + } + + public function isImmutableString(mixed $value): bool + { + return $value instanceof ImmutableString; + } + + public function isRawString(mixed $value): bool + { + return $this->isImmutableString($value); + } + + /** + * @return array + */ + public function toJS(Document $document): array + { + return $document->toArray(); + } + + public function getBackend(Document $document): BackendView + { + return new BackendView($document->toArray()); + } + + public function getObjectId(mixed $value): ?string + { + if ($value instanceof Document) { + return '_root'; + } + + if (is_array($value)) { + return '1@' . hash('sha256', json_encode($value, JSON_THROW_ON_ERROR)); + } + + return null; + } + + /** + * @return array{type:string,id:string,counter?:string,actor?:string} + */ + public function legacyDeserializeObjectId(string $objectId): array + { + if ($objectId === '_root') { + return ['type' => 'root', 'id' => '_root']; + } + + if (! preg_match('/^(0|[1-9][0-9]*)@((?:[0-9a-fA-F]{2})+)$/', $objectId, $matches)) { + throw new \InvalidArgumentException('A valid ObjectID must be _root or an operation id.'); + } + + return [ + 'type' => 'op', + 'id' => $matches[1] . '@' . strtolower($matches[2]), + 'counter' => $matches[1], + 'actor' => strtolower($matches[2]), + ]; + } + + /** + * @return array + */ + public function legacyDeserializeOp(array $op): array + { + if (! is_string($op['obj'] ?? null)) { + throw new \InvalidArgumentException('A valid ObjectID is required for a legacy op object.'); + } + + $key = null; + if (array_key_exists('key', $op)) { + $key = ['type' => 'key', 'value' => (string) $op['key']]; + } + + if (array_key_exists('elemId', $op)) { + if ($key !== null) { + throw new \InvalidArgumentException('Legacy op may not contain both key and elemId.'); + } + + $objectId = $this->legacyDeserializeObjectId((string) $op['elemId']); + if ($objectId['type'] !== 'op') { + throw new \InvalidArgumentException('Legacy elemId keys must be operation ids.'); + } + + $key = ['type' => 'elemId', 'id' => $objectId['id']]; + } + + if ($key === null) { + throw new \InvalidArgumentException('Legacy op requires a key or elemId.'); + } + + return [ + 'action' => $this->legacyDeserializeOpAction($op), + 'obj' => $this->legacyDeserializeObjectId($op['obj']), + 'key' => $key, + 'insert' => (bool) ($op['insert'] ?? false), + 'pred' => $this->legacyDeserializePred($op), + ]; + } + + /** + * @return array + */ + public function legacyDeserializeOpAction(array $op): array + { + if (! is_string($op['action'] ?? null)) { + throw new \InvalidArgumentException('Legacy op requires an action.'); + } + + $datatype = is_string($op['datatype'] ?? null) ? $op['datatype'] : null; + + return match ($op['action']) { + 'makeMap' => ['type' => 'make', 'objectType' => 'map'], + 'makeTable' => ['type' => 'make', 'objectType' => 'table'], + 'makeList' => ['type' => 'make', 'objectType' => 'list'], + 'makeText' => ['type' => 'make', 'objectType' => 'text'], + 'del' => ['type' => 'delete'], + 'set' => ['type' => 'put', 'value' => $this->legacyDeserializeScalarValueFromOp($op, $datatype)], + 'inc' => ['type' => 'increment', 'value' => $this->legacyDeserializeIncrementValue($op, $datatype)], + 'markBegin' => [ + 'type' => 'markBegin', + 'name' => (string) ($op['name'] ?? throw new \InvalidArgumentException('Legacy markBegin requires a name.')), + 'value' => $this->legacyDeserializeScalarValueFromOp($op, $datatype), + 'expand' => (bool) ($op['expand'] ?? false), + ], + 'markEnd' => ['type' => 'markEnd', 'expand' => (bool) ($op['expand'] ?? false)], + default => throw new \InvalidArgumentException('Unknown legacy op action: ' . $op['action']), + }; + } + + /** + * @return array{key?:string,elemId?:string} + */ + public function legacySerializeOpKey(string|int|array $key): array + { + if (is_array($key) && ($key['type'] ?? null) === 'elemId' && is_string($key['id'] ?? null)) { + $key = ['elemId' => $key['id']]; + } + + if (is_array($key) && ($key['type'] ?? null) === 'key' && array_key_exists('value', $key)) { + $key = ['key' => $key['value']]; + } + + if (is_array($key) && is_string($key['elemId'] ?? null)) { + $objectId = $this->legacyDeserializeObjectId($key['elemId']); + if ($objectId['type'] !== 'op') { + throw new \InvalidArgumentException('Legacy elemId keys must be operation ids.'); + } + + return ['elemId' => $objectId['id']]; + } + + if (is_array($key) && array_key_exists('key', $key)) { + return ['key' => (string) $key['key']]; + } + + if (! is_array($key)) { + return ['key' => (string) $key]; + } + + throw new \InvalidArgumentException('Legacy op key must be a map key or elemId descriptor.'); + } + + /** + * @param array $op + * @return array + */ + public function legacySerializeOp(array $op): array + { + if (! is_array($op['action'] ?? null)) { + throw new \InvalidArgumentException('Legacy op serialization requires a normalized action descriptor.'); + } + + $serialized = $this->legacySerializeOpAction($op['action']); + $serialized['obj'] = $this->legacySerializeObjectId($op['obj'] ?? null); + $serialized += $this->legacySerializeOpKey($op['key'] ?? throw new \InvalidArgumentException('Legacy op serialization requires a key.')); + + if (($op['insert'] ?? false) === true) { + $serialized['insert'] = true; + } + + $serialized['pred'] = array_map( + fn (mixed $pred): string => $this->legacySerializeObjectId($pred), + is_array($op['pred'] ?? null) ? array_values($op['pred']) : [] + ); + + return $serialized; + } + + public function legacySerializeObjectId(mixed $objectId): string + { + if (is_array($objectId) && is_string($objectId['id'] ?? null)) { + $objectId = $objectId['id']; + } + + if (! is_string($objectId)) { + throw new \InvalidArgumentException('Legacy object id serialization requires an object id string.'); + } + + return $this->legacyDeserializeObjectId($objectId)['id']; + } + + /** + * @param array $op + * @return list + */ + private function legacyDeserializePred(array $op): array + { + if (! array_key_exists('pred', $op) || ! is_array($op['pred'])) { + throw new \InvalidArgumentException('Legacy op requires a pred list.'); + } + + $pred = []; + foreach (array_values($op['pred']) as $id) { + if (! is_string($id)) { + throw new \InvalidArgumentException('Legacy pred entries must be operation ids.'); + } + + $objectId = $this->legacyDeserializeObjectId($id); + if ($objectId['type'] !== 'op') { + throw new \InvalidArgumentException('Legacy pred entries must be operation ids.'); + } + + $pred[] = $objectId['id']; + } + + return $pred; + } + + /** + * @param array $op + * @return array{type:string,value:mixed} + */ + private function legacyDeserializeScalarValueFromOp(array $op, ?string $datatype): array + { + if (! array_key_exists('value', $op)) { + throw new \InvalidArgumentException('missing field value'); + } + + return $this->legacyDeserializeScalarValue($op['value'], $datatype); + } + + /** + * @return array{type:string,value:mixed} + */ + private function legacyDeserializeScalarValue(mixed $value, ?string $datatype): array + { + if ($datatype !== null) { + return match ($datatype) { + 'uint' => ['type' => 'uint', 'value' => $this->legacyIntegerValue($value, 'an integer')], + 'int' => ['type' => 'int', 'value' => $this->legacyIntegerValue($value, 'an integer')], + 'float64' => ['type' => 'float64', 'value' => $this->legacyNumberValue($value, 'a number')], + 'counter' => ['type' => 'counter', 'value' => $this->legacyIntegerValue($value, 'an integer')], + 'timestamp' => ['type' => 'timestamp', 'value' => $this->legacyIntegerValue($value, 'an integer')], + default => throw new \InvalidArgumentException('Unknown legacy datatype: ' . $datatype), + }; + } + + if ($value === null) { + return ['type' => 'null', 'value' => null]; + } + + if (is_bool($value)) { + return ['type' => 'boolean', 'value' => $value]; + } + + if (is_int($value)) { + return ['type' => 'int', 'value' => $value]; + } + + if (is_float($value)) { + return ['type' => 'float64', 'value' => $value]; + } + + if (is_string($value)) { + return ['type' => 'str', 'value' => $value]; + } + + throw new \InvalidArgumentException('Legacy scalar value is not supported.'); + } + + /** + * @param array $op + */ + private function legacyDeserializeIncrementValue(array $op, ?string $datatype): int + { + $value = $this->legacyDeserializeScalarValueFromOp($op, $datatype); + + return match ($value['type']) { + 'int', 'uint', 'counter', 'timestamp' => (int) $value['value'], + 'float64' => (int) $value['value'], + default => throw new \InvalidArgumentException('Legacy inc value must be a number.'), + }; + } + + /** + * @param array $action + * @return array + */ + private function legacySerializeOpAction(array $action): array + { + $type = $action['type'] ?? null; + + if ($type === 'make') { + return ['action' => match ($action['objectType'] ?? null) { + 'map' => 'makeMap', + 'table' => 'makeTable', + 'list' => 'makeList', + 'text' => 'makeText', + default => throw new \InvalidArgumentException('Unknown legacy make object type.'), + }]; + } + + if ($type === 'delete') { + return ['action' => 'del']; + } + + if ($type === 'put') { + if (! is_array($action['value'] ?? null)) { + throw new \InvalidArgumentException('Legacy put action requires a scalar value descriptor.'); + } + + return ['action' => 'set'] + $this->legacySerializeScalarValue($action['value']); + } + + if ($type === 'increment') { + if (! array_key_exists('value', $action) || (! is_int($action['value']) && ! is_float($action['value']))) { + throw new \InvalidArgumentException('Legacy increment action requires a numeric value.'); + } + + return ['action' => 'inc', 'value' => (int) $action['value']]; + } + + if ($type === 'markBegin') { + if (! is_string($action['name'] ?? null) || ! is_array($action['value'] ?? null)) { + throw new \InvalidArgumentException('Legacy markBegin action requires name and scalar value descriptors.'); + } + + return ['action' => 'markBegin', 'name' => $action['name'], 'expand' => (bool) ($action['expand'] ?? false)] + + $this->legacySerializeScalarValue($action['value']); + } + + if ($type === 'markEnd') { + return ['action' => 'markEnd', 'expand' => (bool) ($action['expand'] ?? false)]; + } + + throw new \InvalidArgumentException('Unknown legacy action descriptor.'); + } + + /** + * @param array $scalar + * @return array + */ + private function legacySerializeScalarValue(array $scalar): array + { + if (! array_key_exists('type', $scalar) || ! array_key_exists('value', $scalar)) { + throw new \InvalidArgumentException('Legacy scalar descriptor requires type and value.'); + } + + return match ($scalar['type']) { + 'uint', 'int', 'float64', 'counter', 'timestamp' => [ + 'datatype' => $scalar['type'], + 'value' => $scalar['type'] === 'float64' ? (float) $scalar['value'] : (int) $scalar['value'], + ], + 'str' => ['value' => (string) $scalar['value']], + 'boolean' => ['value' => (bool) $scalar['value']], + 'null' => ['value' => null], + default => throw new \InvalidArgumentException('Unknown legacy scalar descriptor type.'), + }; + } + + private function legacyIntegerValue(mixed $value, string $expected): int + { + if (! is_int($value)) { + throw new \InvalidArgumentException('invalid value: expected ' . $expected); + } + + return $value; + } + + private function legacyNumberValue(mixed $value, string $expected): float + { + if (! is_int($value) && ! is_float($value)) { + throw new \InvalidArgumentException('invalid value: expected ' . $expected); + } + + return (float) $value; + } + + /** + * @return array{type:string,counter:int,actor:int} + */ + private function opSet2NormalizeObjectId(mixed $objectId): array + { + if ($objectId === '_root' || $objectId === 'root' || (is_array($objectId) && ($objectId['type'] ?? null) === 'root')) { + return ['type' => 'root', 'counter' => 0, 'actor' => 0]; + } + + if (is_string($objectId) && preg_match('/^(0|[1-9][0-9]*)@(0|[1-9][0-9]*)$/', $objectId, $matches)) { + $objectId = ['type' => 'op', 'counter' => (int) $matches[1], 'actor' => (int) $matches[2]]; + } + + if (is_array($objectId)) { + $counter = $objectId['counter'] ?? null; + $actor = $objectId['actor'] ?? null; + if (is_int($counter) && $counter >= 0 && is_int($actor) && $actor >= 0) { + return ['type' => 'op', 'counter' => $counter, 'actor' => $actor]; + } + } + + throw new \InvalidArgumentException('op_set2 object ids must be root or non-negative actor/counter pairs.'); + } + + /** + * @param array{type:string,counter:int,actor:int} $left + * @param array{type:string,counter:int,actor:int} $right + */ + private function opSet2CompareObjectIds(array $left, array $right): int + { + if ($left['type'] === 'root' || $right['type'] === 'root') { + return ($left['type'] === 'root' ? 0 : 1) <=> ($right['type'] === 'root' ? 0 : 1); + } + + return ($left['counter'] <=> $right['counter']) ?: ($left['actor'] <=> $right['actor']); + } + + /** + * @return array{counter:int,actor:int} + */ + private function opSet2NormalizeOperationId(mixed $operationId): array + { + if (is_string($operationId) && preg_match('/^(0|[1-9][0-9]*)@(0|[1-9][0-9]*)$/', $operationId, $matches)) { + $operationId = ['counter' => (int) $matches[1], 'actor' => (int) $matches[2]]; + } + + if (is_array($operationId)) { + $counter = $operationId['counter'] ?? null; + $actor = $operationId['actor'] ?? null; + if (is_int($counter) && $counter >= 0 && is_int($actor) && $actor >= 0) { + return ['counter' => $counter, 'actor' => $actor]; + } + } + + throw new \InvalidArgumentException('op_set2 operation ids must contain non-negative actor/counter pairs.'); + } + + private function validateOpSet2CounterRange(int $start, int $end): void + { + if ($start < 0 || $end < $start) { + throw new \OutOfBoundsException('op_set2 counter ranges must be non-negative and ascending.'); + } + } + + public function getActorId(Document $document): string + { + return $document->actorId(); + } + + /** + * @param list $path + */ + public function objectReference(Document $document, array $path): DocumentObjectReference + { + $path = array_values($path); + $value = $document->toArray(); + foreach ($path as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + throw new \InvalidArgumentException('Cannot create a reference to a missing document object.'); + } + + $value = $value[$key]; + } + + if (! is_array($value)) { + throw new \InvalidArgumentException('Cannot create a reference to a scalar document value.'); + } + + return new DocumentObjectReference($path); + } + + public function set(Document $document, string $key, mixed $value): Document + { + $root = $document->toArray(); + if (array_key_exists($key, $root) && $root[$key] === $value && $document->conflictsFor($key) === null) { + return $document; + } + + $next = $this->mutableClone($document); + $next->set($key, $value); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, $this->assignmentPatches($key, $value)); + + return $next; + } + + public function batchCreateObject(Document $document, string $key, mixed $value): Document + { + if (! is_array($value) && ! $value instanceof TextValue) { + throw new \InvalidArgumentException('Batch object creation requires a map list or text value.'); + } + + return $this->set($document, $key, $value); + } + + public function batchCreateObjectWithCommitOptions(Document $document, string $key, mixed $value, string $message, int $time): Document + { + if (! is_array($value) && ! $value instanceof TextValue) { + throw new \InvalidArgumentException('Batch object creation requires a map list or text value.'); + } + + $next = $this->mutableClone($document); + $next->set($key, $value, $message, $time); + + return $this->preserveFrozen($document, $next); + } + + public function setWithMessage(Document $document, string $key, mixed $value, string $message): Document + { + $root = $document->toArray(); + if (array_key_exists($key, $root) && $root[$key] === $value && $document->conflictsFor($key) === null) { + return $document; + } + + $next = $this->mutableClone($document); + $next->set($key, $value, $message); + + return $this->preserveFrozen($document, $next); + } + + public function setWithTime(Document $document, string $key, mixed $value, int $time): Document + { + $root = $document->toArray(); + if (array_key_exists($key, $root) && $root[$key] === $value && $document->conflictsFor($key) === null) { + return $document; + } + + $next = $this->mutableClone($document); + $next->set($key, $value, null, $time); + + return $this->preserveFrozen($document, $next); + } + + public function setWithoutTime(Document $document, string $key, mixed $value): Document + { + return $this->setWithTime($document, $key, $value, 0); + } + + /** @param array $values */ + public function setMany(Document $document, array $values, ?string $message = null): Document + { + if ($values === []) { + return $document; + } + + $next = $this->mutableClone($document); + $next->setMany($values, $message); + + return $this->preserveFrozen($document, $next); + } + + public function setWithPatchCallback(Document $document, string $key, mixed $value, callable $patchCallback): Document + { + return $this->withPatchCallback($patchCallback)->set($document, $key, $value); + } + + public function changeNoop(Document $document): Document + { + return $document; + } + + public function changeTransaction(Document $document, callable $change): Document + { + $working = $this->mutableClone($document); + $result = $change($working, $this); + + return $this->preserveFrozen($document, $result instanceof Document ? $result : $working); + } + + public function transaction(Document $document): Transaction + { + return new Transaction($document, $this); + } + + /** + * @param list $heads + */ + public function transactionAt(Document $document, array $heads): Transaction + { + $this->validateDiffHeads($document, $heads, 'transactionAt'); + + return new Transaction($document, $this, array_values($heads)); + } + + public function change(mixed $document, callable $change): Document + { + if (! $document instanceof Document) { + throw new \InvalidArgumentException('Automerge.change argument must be the document root.'); + } + + if ($this->outdatedChangeBases->contains($document)) { + throw new \RuntimeException('Attempting to change an outdated document.'); + } + + if ($this->activeChangeDraft !== null) { + if ($document === $this->activeChangeBase) { + throw new \RuntimeException('Attempting to change an outdated document.'); + } + + throw new \RuntimeException('Calls to Automerge.change cannot be nested.'); + } + + $working = $this->mutableClone($document); + $baseChangeCount = count($document->getAllChanges()); + $this->activeChangeBase = $document; + $this->activeChangeDraft = $working; + try { + $result = $change($working, $this); + $next = $this->preserveFrozen($document, $result instanceof Document ? $result : $working); + if ($next->heads() === $document->heads()) { + return $document; + } + $this->outdatedChangeBases->attach($document); + $newChanges = array_slice($next->getAllChanges(), $baseChangeCount); + $patches = $this->patchesForChanges($next, $newChanges); + if ($patches === []) { + $patches = $this->patchesBetweenDocuments($document, $next); + } + $this->emitPatches($document, $next, $patches); + $this->emitDocumentPatchCallback($document, $next, 'change', $patches); + + return $next; + } finally { + $this->activeChangeBase = null; + $this->activeChangeDraft = null; + } + } + + public function delete(Document $document, string $key): Document + { + if (! array_key_exists($key, $document->toArray())) { + return $document; + } + + $next = $this->mutableClone($document); + $next->delete($key); + + return $this->preserveFrozen($document, $next); + } + + /** + * @return array{0:Document,1:bool} + */ + public function deleteWithResult(Document $document, string $key): array + { + return [$this->delete($document, $key), true]; + } + + /** + * @param list $path + */ + public function deleteNested(Document $document, array $path): Document + { + $next = $this->mutableClone($document); + $next->deleteNested($path); + $next = $this->preserveFrozen($document, $next); + if ($next->toArray() !== $document->toArray()) { + $this->emitPatches($document, $next, [['action' => 'del', 'path' => array_values($path)]]); + } + + return $next; + } + + public function setListElement(Document $document, string $key, int $index, mixed $value): Document + { + $this->assertListOperationTarget($document, $key); + $root = $document->toArray(); + $list = is_array($root[$key] ?? null) ? $root[$key] : []; + if ($index < 0 || $index > count($list)) { + throw new \OutOfBoundsException('List assignment index is out of bounds.'); + } + + if (array_key_exists($index, $list) && $list[$index] === $value) { + if ($document->listElementConflictsFor($key, $index) !== null) { + $next = $this->mutableClone($document); + $next->resolveRootConflictListElement($key, $index, $value); + + return $this->preserveFrozen($document, $next); + } + + return $document; + } + + $list[$index] = $value; + ksort($list); + + return $this->set($document, $key, array_values($list)); + } + + public function setListKey(Document $document, string $key, string|int $index, mixed $value): Document + { + if (is_int($index)) { + return $this->setListElement($document, $key, $index, $value); + } + + if ($index !== '' && ctype_digit($index)) { + return $this->setListElement($document, $key, (int) $index, $value); + } + + throw new \InvalidArgumentException('list index must be a number'); + } + + /** + * @param list $values + */ + public function insertListElements(Document $document, string $key, int $index, array $values): Document + { + $this->assertListOperationTarget($document, $key); + $this->assertListInputHasNoUndefined($key, $values); + + if ($values === []) { + return $document; + } + + $next = $this->mutableClone($document); + $next->insertListValues($key, $index, $values); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [['action' => 'insert', 'path' => [$key, $index], 'values' => array_values($values)]]); + + return $next; + } + + public function setListElementStrict(Document $document, string $key, int $index, mixed $value): Document + { + $root = $document->toArray(); + $list = is_array($root[$key] ?? null) && array_is_list($root[$key]) ? $root[$key] : []; + if ($index < 0 || $index >= count($list)) { + throw new \OutOfBoundsException('List assignment index is out of bounds.'); + } + + return $this->setListElement($document, $key, $index, $value); + } + + /** + * @param list $values + */ + public function insertListElementsStrict(Document $document, string $key, int $index, array $values): Document + { + $root = $document->toArray(); + $list = is_array($root[$key] ?? null) && array_is_list($root[$key]) ? $root[$key] : []; + if ($index < 0 || $index > count($list)) { + throw new \OutOfBoundsException('List insertion index is out of bounds.'); + } + + return $this->insertListElements($document, $key, $index, $values); + } + + /** + * @param list $values + */ + public function insertListElementsWithMessage(Document $document, string $key, int $index, array $values, string $message): Document + { + $this->assertListOperationTarget($document, $key); + $this->assertListInputHasNoUndefined($key, $values); + + $root = $document->toArray(); + $list = is_array($root[$key] ?? null) ? array_values($root[$key]) : []; + array_splice($list, $index, 0, $values); + + return $this->setWithMessage($document, $key, array_values($list), $message); + } + + public function deleteListElements(Document $document, string $key, int $index, int $length = 1): Document + { + $this->assertListOperationTarget($document, $key); + + return $this->applyPatches( + $document, + [['action' => 'del', 'path' => [$key, $index], 'length' => $length]] + ); + } + + /** + * @param list $values + */ + public function spliceList(Document $document, string $key, int $index, int $deleteCount, array $values = []): Document + { + [$next] = $this->spliceListWithDeleted($document, $key, $index, $deleteCount, $values); + + return $next; + } + + /** + * @param list $values + * @return array{0:Document,1:list} + */ + public function spliceListWithDeleted(Document $document, string $key, int $index, ?int $deleteCount = null, array $values = []): array + { + $this->assertListOperationTarget($document, $key); + $list = $this->listValues($document, $key); + $index = max(0, min($index, count($list))); + $deleteCount ??= count($list) - $index; + $deleteCount = max(0, min($deleteCount, count($list) - $index)); + $deleted = array_values(array_slice($list, $index, $deleteCount)); + + $next = $document; + if ($deleteCount > 0) { + $next = $this->deleteListElements($next, $key, $index, $deleteCount); + } + + if ($values !== []) { + $next = $this->insertListElements($next, $key, $index, $values); + } + + return [$next, $deleted]; + } + + /** + * @param list $values + */ + public function pushList(Document $document, string $key, array $values): Document + { + $root = $document->toArray(); + $list = is_array($root[$key] ?? null) ? $root[$key] : []; + + return $this->insertListElements($document, $key, count($list), $values); + } + + /** + * @param list $values + */ + public function unshiftList(Document $document, string $key, array $values): Document + { + return $this->insertListElements($document, $key, 0, $values); + } + + public function shiftList(Document $document, string $key): Document + { + return $this->deleteListElements($document, $key, 0); + } + + public function listIndexOf(Document $document, string $key, mixed $needle): int + { + $index = array_search($needle, $this->listValues($document, $key), true); + + return $index === false ? -1 : (int) $index; + } + + /** + * @return list + */ + public function listEntries(Document $document, string $key): array + { + $entries = []; + foreach ($this->listValues($document, $key) as $index => $value) { + $entries[] = [$index, $value]; + } + + return $entries; + } + + /** + * @return list + */ + public function listKeys(Document $document, string $key): array + { + return array_keys($this->listValues($document, $key)); + } + + /** + * @return list + */ + public function listValues(Document $document, string $key): array + { + $root = $document->toArray(); + $list = $root[$key] ?? null; + + return is_array($list) && array_is_list($list) ? array_values($list) : []; + } + + /** + * @return list + */ + public function listRange(Document $document, string $key, ?int $start = null, ?int $end = null, bool $endInclusive = false): array + { + $values = $this->listValues($document, $key); + $count = count($values); + $start = $start === null ? 0 : max(0, min($start, $count)); + $end = $end === null ? $count : max(0, min($end + ($endInclusive ? 1 : 0), $count)); + if ($end < $start) { + return []; + } + + return array_values(array_slice($values, $start, $end - $start)); + } + + /** + * @return list + */ + public function listRangeEntries(Document $document, string $key, ?int $start = null, ?int $end = null, bool $endInclusive = false): array + { + $values = $this->listValues($document, $key); + $count = count($values); + $start = $start === null ? 0 : max(0, min($start, $count)); + $end = $end === null ? $count : max(0, min($end + ($endInclusive ? 1 : 0), $count)); + if ($end < $start) { + return []; + } + + $entries = []; + for ($index = $start; $index < $end; ++$index) { + $conflicts = $document->listElementConflictsFor($key, $index); + $entries[] = [ + 'value' => $values[$index], + 'conflict' => $conflicts !== null && $this->hasDistinctValues(array_values($conflicts)), + ]; + } + + return $entries; + } + + /** + * @param list $values + */ + private function hasDistinctValues(array $values): bool + { + if (count($values) < 2) { + return false; + } + + $seen = []; + foreach ($values as $value) { + $seen[serialize($value)] = true; + if (count($seen) > 1) { + return true; + } + } + + return false; + } + + private function assertListOperationTarget(Document $document, string $key): void + { + $root = $document->toArray(); + if (! array_key_exists($key, $root)) { + return; + } + + if (! is_array($root[$key]) || ! array_is_list($root[$key])) { + throw new \InvalidArgumentException('Invalid list operation on non-list value.'); + } + } + + public function listAt(Document $document, string $key, int $index): mixed + { + $values = $this->listValues($document, $key); + if ($index < 0) { + $index = count($values) + $index; + } + + return $values[$index] ?? null; + } + + /** + * @param list $values + * @return list + */ + public function listConcat(Document $document, string $key, array $values): array + { + return array_values(array_merge($this->listValues($document, $key), $values)); + } + + public function listJoin(Document $document, string $key, string $separator = ','): string + { + return implode($separator, array_map( + fn (mixed $value): string => $this->stringifyListValue($value), + $this->listValues($document, $key) + )); + } + + /** + * @return list + */ + public function listMap(Document $document, string $key, callable $callback): array + { + $mapped = []; + foreach ($this->listValues($document, $key) as $index => $value) { + $mapped[] = $callback($value, $index); + } + + return $mapped; + } + + public function listForEach(Document $document, string $key, callable $callback): void + { + foreach ($this->listValues($document, $key) as $index => $value) { + $callback($value, $index); + } + } + + public function listEvery(Document $document, string $key, callable $callback): bool + { + foreach ($this->listValues($document, $key) as $index => $value) { + if (! $callback($value, $index)) { + return false; + } + } + + return true; + } + + /** + * @return list + */ + public function listFilter(Document $document, string $key, callable $callback): array + { + $filtered = []; + foreach ($this->listValues($document, $key) as $index => $value) { + if ($callback($value, $index)) { + $filtered[] = $value; + } + } + + return $filtered; + } + + public function listFind(Document $document, string $key, callable $callback): mixed + { + foreach ($this->listValues($document, $key) as $index => $value) { + if ($callback($value, $index)) { + return $value; + } + } + + return null; + } + + public function listFindIndex(Document $document, string $key, callable $callback): int + { + foreach ($this->listValues($document, $key) as $index => $value) { + if ($callback($value, $index)) { + return $index; + } + } + + return -1; + } + + public function listIncludes(Document $document, string $key, mixed $needle): bool + { + return $this->listIndexOf($document, $key, $needle) !== -1; + } + + public function listSome(Document $document, string $key, callable $callback): bool + { + return $this->listFindIndex($document, $key, $callback) !== -1; + } + + public function listReduce(Document $document, string $key, callable $callback, mixed $initial): mixed + { + $accumulator = $initial; + foreach ($this->listValues($document, $key) as $index => $value) { + $accumulator = $callback($accumulator, $value, $index); + } + + return $accumulator; + } + + public function listReduceRight(Document $document, string $key, callable $callback, mixed $initial): mixed + { + $accumulator = $initial; + $values = $this->listValues($document, $key); + for ($index = count($values) - 1; $index >= 0; --$index) { + $accumulator = $callback($accumulator, $values[$index], $index); + } + + return $accumulator; + } + + public function listLastIndexOf(Document $document, string $key, mixed $needle, ?int $fromIndex = null): int + { + $values = $this->listValues($document, $key); + $index = $fromIndex === null ? count($values) - 1 : min($fromIndex, count($values) - 1); + for (; $index >= 0; --$index) { + if ($values[$index] === $needle) { + return $index; + } + } + + return -1; + } + + /** + * @return array{0:Document,1:list} + */ + public function fillListWithValues(Document $document, string $key, mixed $value, int $start = 0, ?int $end = null): array + { + $values = $this->listValues($document, $key); + $start = max(0, min($start, count($values))); + $end = $end === null ? count($values) : max($start, min($end, count($values))); + for ($index = $start; $index < $end; ++$index) { + $values[$index] = $value; + } + + $next = $this->set($document, $key, $values); + + return [$next, $values]; + } + + public function textIndexOf(Document $document, string $key, string $needle): int + { + $root = $document->toArray(); + $text = $root[$key] ?? null; + if (! is_string($text)) { + return -1; + } + + $index = mb_strpos($text, $needle); + + return $index === false ? -1 : $index; + } + + /** + * @param list $path + */ + public function incrementCounter(Document $document, array $path, int $amount = 1): Document + { + $operationId = $document->actorId() . ':' . (count($document->getAllChanges()) + 1) . ':' . implode('/', array_map( + static fn (string|int $key): string => (string) $key, + $path + )); + + if (count($path) === 1 && (is_string($path[0]) || is_int($path[0]))) { + $key = (string) $path[0]; + $conflicts = $document->conflictsFor($key); + if (is_array($conflicts)) { + foreach ($conflicts as $conflictValue) { + if (! $conflictValue instanceof Counter) { + continue; + } + + $next = $this->mutableClone($document); + if ($next->incrementRootConflictCounters($key, $amount, $operationId)) { + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [['action' => 'inc', 'path' => [$key], 'value' => $amount]]); + + return $next; + } + } + } + } + + if (count($path) === 2 && (is_string($path[0]) || is_int($path[0])) && is_int($path[1])) { + $key = (string) $path[0]; + $index = $path[1]; + $conflicts = $document->listElementConflictsFor($key, $index); + if (is_array($conflicts)) { + foreach ($conflicts as $conflictValue) { + if (! $conflictValue instanceof Counter) { + continue; + } + + $next = $this->mutableClone($document); + if ($next->incrementRootConflictListElementCounters($key, $index, $amount, $operationId)) { + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [['action' => 'inc', 'path' => [$key, $index], 'value' => $amount]]); + + return $next; + } + } + } + } + + $current = $this->readPath($document, $path); + if (! $current instanceof Counter) { + throw new \InvalidArgumentException('Cannot increment a non-counter value.'); + } + + return $this->writePath($document, $path, $current->incremented($amount, $operationId)); + } + + /** + * @param list $path + */ + public function setNested(Document $document, array $path, mixed $value): Document + { + $path = array_values($path); + $this->assertPathContainerCompatibility($document, $path); + $next = $this->mutableClone($document); + $next->setNested($path, $value); + + return $this->preserveFrozen($document, $next); + } + + public function setRootConflictMapValue(Document $document, string $key, string $nestedKey, mixed $value): Document + { + $next = $this->mutableClone($document); + $next->setRootConflictMapValue($key, $nestedKey, $value); + + return $this->preserveFrozen($document, $next); + } + + public function setRootConflictListElementMapValue(Document $document, string $key, int $index, string $nestedKey, mixed $value): Document + { + $next = $this->mutableClone($document); + $next->setRootConflictListElementMapValue($key, $index, $nestedKey, $value); + + return $this->preserveFrozen($document, $next); + } + + public function splice(Document $document, string $key, int $index, int $deleteCount, string $insert = ''): Document + { + $this->assertTextOperationTarget($document, [$key]); + $next = $this->mutableClone($document); + $next->spliceText($key, $index, $deleteCount, $insert); + + return $this->preserveFrozen($document, $next); + } + + /** + * @param list $path + */ + public function spliceInChange(Document $document, array $path, int|string $index, int $deleteCount, string $insert = ''): Document + { + if ($this->activeChangeDraft !== $document) { + throw new \RuntimeException('object cannot be modified outside of a change block'); + } + + [$index, $deleteCount] = $this->resolveTextSpliceRange($document, $path, $index, $deleteCount); + if (count($path) === 1 && (is_string($path[0]) || is_int($path[0]))) { + $document->spliceText((string) $path[0], $index, $deleteCount, $insert); + + return $document; + } + + return $this->spliceAtPath($document, $path, $index, $deleteCount, $insert); + } + + public function updateText(Document $document, string $key, string $newText): Document + { + $next = $this->mutableClone($document); + $next->updateText($key, $newText); + + return $this->preserveFrozen($document, $next); + } + + /** + * @param list $path + */ + public function textLength(Document $document, array $path, string $encoding = 'grapheme'): int + { + $value = $this->readPath($document, array_values($path)); + if (! is_string($value)) { + throw new \InvalidArgumentException('Text length requires a text value.'); + } + + return match ($encoding) { + 'UnicodeCodePoint', 'codepoint', 'code_point', 'unicode-code-point' => mb_strlen($value, 'UTF-8'), + 'Utf8CodeUnit', 'utf8', 'utf8-code-unit' => strlen($value), + 'Utf16CodeUnit', 'utf16', 'utf16-code-unit' => $this->utf16Length($value), + 'GraphemeCluster', 'grapheme', 'grapheme-cluster' => count($this->splitCharacters($value)), + default => throw new \InvalidArgumentException('Unsupported text length encoding.'), + }; + } + + public function textDiffCommonPrefixLen(string $old, int $oldStart, int $oldEnd, string $new, int $newStart, int $newEnd): int + { + [$oldStart, $oldEnd] = $this->normalizeByteRange($old, $oldStart, $oldEnd); + [$newStart, $newEnd] = $this->normalizeByteRange($new, $newStart, $newEnd); + if ($oldStart >= $oldEnd || $newStart >= $newEnd) { + return 0; + } + + $length = 0; + $oldIndex = $oldStart; + $newIndex = $newStart; + while ($oldIndex < $oldEnd && $newIndex < $newEnd && $old[$oldIndex] === $new[$newIndex]) { + ++$length; + ++$oldIndex; + ++$newIndex; + } + + return $length; + } + + public function textDiffCommonSuffixLen(string $old, int $oldStart, int $oldEnd, string $new, int $newStart, int $newEnd): int + { + [$oldStart, $oldEnd] = $this->normalizeByteRange($old, $oldStart, $oldEnd); + [$newStart, $newEnd] = $this->normalizeByteRange($new, $newStart, $newEnd); + if ($oldStart >= $oldEnd || $newStart >= $newEnd) { + return 0; + } + + $length = 0; + $oldIndex = $oldEnd - 1; + $newIndex = $newEnd - 1; + while ($oldIndex >= $oldStart && $newIndex >= $newStart && $old[$oldIndex] === $new[$newIndex]) { + ++$length; + --$oldIndex; + --$newIndex; + } + + return $length; + } + + /** + * @return list + */ + public function clock(int $actorCount): array + { + if ($actorCount < 0) { + throw new \InvalidArgumentException('Clock actor count cannot be negative.'); + } + + return array_fill(0, $actorCount, 0); + } + + /** + * @param list $clock + * @return list + */ + public function clockInclude(array $clock, int $actorIndex, int $counter): array + { + $clock = $this->normalizeClock($clock); + if (! array_key_exists($actorIndex, $clock)) { + throw new \OutOfBoundsException('Clock actor index is out of range.'); + } + + if ($counter < 0) { + throw new \InvalidArgumentException('Clock counter cannot be negative.'); + } + + if ($counter > $clock[$actorIndex]) { + $clock[$actorIndex] = $counter; + } + + return array_values($clock); + } + + /** + * @param list $clock + */ + public function clockCovers(array $clock, int $counter, int $actorIndex): bool + { + $clock = $this->normalizeClock($clock); + if (! array_key_exists($actorIndex, $clock) || $counter < 0) { + return false; + } + + return $clock[$actorIndex] >= $counter; + } + + /** + * @param list $left + * @param list $right + */ + public function clockCompare(array $left, array $right): ?string + { + $left = $this->normalizeClock($left); + $right = $this->normalizeClock($right); + $maxLength = max(count($left), count($right)); + $leftGreater = false; + $rightGreater = false; + + for ($index = 0; $index < $maxLength; ++$index) { + $leftCounter = $left[$index] ?? 0; + $rightCounter = $right[$index] ?? 0; + if ($leftCounter > $rightCounter) { + $leftGreater = true; + } elseif ($rightCounter > $leftCounter) { + $rightGreater = true; + } + } + + if (! $leftGreater && ! $rightGreater) { + return 'equal'; + } + + if ($leftGreater && ! $rightGreater) { + return 'greater'; + } + + if ($rightGreater && ! $leftGreater) { + return 'less'; + } + + return null; + } + + /** + * @param list}> $changes + * @param list $heads + * @return list + */ + public function changeGraphSeqClockForHeads(array $changes, array $heads): array + { + $graph = $this->normalizeChangeGraph($changes); + $clock = array_fill(0, $graph['actorCount'], null); + foreach ($this->changeGraphAncestors($graph['nodes'], $heads) as $hash) { + $node = $graph['nodes'][$hash]; + $seq = $node['seq']; + if ($seq === null) { + continue; + } + + $actor = $node['actor']; + if ($clock[$actor] === null || $clock[$actor] < $seq) { + $clock[$actor] = $seq; + } + } + + return $clock; + } + + /** + * @param list}> $changes + * @param list $candidateHashes + * @param list $heads + * @return list + */ + public function changeGraphRemoveAncestors(array $changes, array $candidateHashes, array $heads): array + { + $graph = $this->normalizeChangeGraph($changes); + $ancestorHashes = array_fill_keys($this->changeGraphAncestors($graph['nodes'], $heads), true); + $remaining = []; + foreach ($candidateHashes as $hash) { + if (! is_string($hash) || $hash === '') { + throw new \InvalidArgumentException('Change graph candidate hashes must be non-empty strings.'); + } + + if (! isset($ancestorHashes[$hash])) { + $remaining[$hash] = $hash; + } + } + + $remaining = array_values($remaining); + sort($remaining, SORT_STRING); + + return $remaining; + } + + public function uleb128Size(int|string $value): int + { + $decimal = $this->normalizeUnsignedDecimal($value, '18446744073709551615'); + if ($decimal === '0') { + return 1; + } + + return intdiv($this->decimalBitLength($decimal) + 6, 7); + } + + public function leb128Size(int|string $value): int + { + $decimal = $this->normalizeSignedDecimal($value); + if (str_starts_with($decimal, '-')) { + $magnitude = $this->decrementDecimalString(substr($decimal, 1)); + } else { + $magnitude = $decimal; + } + + $bits = 1 + ($magnitude === '0' ? 0 : $this->decimalBitLength($magnitude)); + + return intdiv($bits + 6, 7); + } + + /** + * @param list $bytes + * @return array{bytes:list,position:int,original:list} + */ + public function storageParseInput(array $bytes): array + { + $this->columnarByteListToString($bytes); + $bytes = array_values($bytes); + + return ['bytes' => $bytes, 'position' => 0, 'original' => $bytes]; + } + + /** + * @param array{bytes:list,position:int,original:list} $input + * @return array{0:array{bytes:list,position:int,original:list},1:int} + */ + public function storageInputTakeOne(array $input): array + { + $bytes = array_values($input['bytes'] ?? []); + if ($bytes === []) { + throw new \InvalidArgumentException('Parse input is incomplete.'); + } + + $position = (int) ($input['position'] ?? 0); + $remaining = array_values(array_slice($bytes, 1)); + + return [ + [ + 'bytes' => $remaining, + 'position' => $position + 1, + 'original' => array_values($input['original'] ?? $bytes), + ], + $bytes[0], + ]; + } + + /** + * @param array{bytes:list,position:int,original:list} $input + * @return array{0:array{bytes:list,position:int,original:list},1:array{range:array{start:int,end:int},value:int}} + */ + public function storageInputRangeOfTakeOne(array $input): array + { + $start = (int) ($input['position'] ?? 0); + [$newInput, $value] = $this->storageInputTakeOne($input); + + return [ + $newInput, + [ + 'range' => [ + 'start' => $start, + 'end' => (int) $newInput['position'], + ], + 'value' => $value, + ], + ]; + } + + /** + * @param array{bytes:list,position:int,original:list} $input + * @return array{first:array{bytes:list,position:int,original:list},remaining:array{bytes:list,position:int,original:list}} + */ + public function storageInputSplit(array $input, int $length): array + { + $bytes = array_values($input['bytes'] ?? []); + $position = (int) ($input['position'] ?? 0); + $original = array_values($input['original'] ?? $bytes); + $length = max(0, min($length, count($bytes))); + + return [ + 'first' => [ + 'bytes' => array_values(array_slice($bytes, 0, $length)), + 'position' => $position, + 'original' => array_values(array_slice($original, 0, $position + $length)), + ], + 'remaining' => [ + 'bytes' => array_values(array_slice($bytes, $length)), + 'position' => $position + $length, + 'original' => array_values(array_slice($original, $position + $length)), + ], + ]; + } + + /** + * @param array{bytes:list,position:int,original:list} $input + * @return list + */ + public function storageInputRemainingBytes(array $input): array + { + return array_values($input['bytes'] ?? []); + } + + /** + * @param array{bytes:list,position:int,original:list} $input + * @return list + */ + public function storageInputBytes(array $input): array + { + return array_values($input['original'] ?? []); + } + + /** + * @return array{type:string,value:string} + */ + public function storageParseApplicationError(string $value): array + { + return ['type' => 'error', 'value' => $value]; + } + + /** + * @return array{type:string,needed:int} + */ + public function storageParseIncomplete(int $needed): array + { + if ($needed < 1) { + throw new \InvalidArgumentException('Incomplete parser errors must request at least one byte.'); + } + + return ['type' => 'incomplete', 'needed' => $needed]; + } + + /** + * @param array $error + * @return array + */ + public function storageParseErrorLift(array $error, string $variant): array + { + if (($error['type'] ?? null) !== 'error') { + return $error; + } + + return [ + 'type' => 'error', + 'value' => [ + 'variant' => $variant, + 'source' => $error['value'] ?? null, + ], + ]; + } + + /** + * @param list $bytes + * @return array{value:string,offset:int} + */ + public function storageParseLeb128U64(array $bytes): array + { + [$value, $offset] = $this->parseStorageUnsignedLeb128($this->columnarByteListToString($bytes)); + + return ['value' => $value, 'offset' => $offset]; + } + + /** + * @param list $bytes + * @return array{value:string,offset:int} + */ + public function storageParseLeb128U64Exact(array $bytes): array + { + $result = $this->storageParseLeb128U64($bytes); + if ($result['offset'] !== count($bytes)) { + throw new \InvalidArgumentException('LEB128 value metadata has trailing bytes.'); + } + + return $result; + } + + /** + * @param list $bytes + * @return array{value:string,offset:int} + */ + public function storageParseLeb128U32(array $bytes): array + { + [$value, $offset] = $this->parseStorageUnsignedLeb128($this->columnarByteListToString($bytes)); + if ($this->compareDecimalStrings($value, '4294967295') > 0) { + throw new \InvalidArgumentException('LEB128 value is too large for u32.'); + } + + return ['value' => $value, 'offset' => $offset]; + } + + /** + * @param list $bytes + * @return array{value:string,offset:int} + */ + public function storageParseLeb128I64(array $bytes): array + { + [$value, $offset] = $this->parseStorageSignedLeb128($this->columnarByteListToString($bytes)); + + return ['value' => $value, 'offset' => $offset]; + } + + /** + * @param list $bytes + * @return array + */ + public function storageChangeFromBytes(array $bytes): array + { + $raw = $this->columnarByteListToString($bytes); + if (strlen($raw) < 10) { + throw new \InvalidArgumentException('Automerge change chunk is too short.'); + } + + if (substr($raw, 0, 4) !== "\x85\x6f\x4a\x83") { + throw new \InvalidArgumentException('Invalid Automerge storage magic bytes.'); + } + + $offset = 4; + $checksum = substr($raw, $offset, 4); + $offset += 4; + $chunkType = ord($raw[$offset]); + ++$offset; + if ($chunkType === 2) { + return $this->storageChangeFromBytes($this->storageDecompressChangeBytes($bytes)); + } + + if ($chunkType !== 1) { + throw new \InvalidArgumentException('Automerge storage chunk is not a change chunk.'); + } + + $lengthStart = $offset; + $dataLength = $this->decodeUnsignedLeb128Int($raw, $offset); + $headerLength = $offset; + if ($offset + $dataLength !== strlen($raw)) { + throw new \InvalidArgumentException('Automerge change chunk length does not match payload bytes.'); + } + + $data = substr($raw, $offset, $dataLength); + $hashPayload = chr($chunkType) . substr($raw, $lengthStart, $headerLength - $lengthStart) . $data; + $hash = hash('sha256', $hashPayload, true); + if (substr($hash, 0, 4) !== $checksum) { + throw new \InvalidArgumentException('Automerge change chunk checksum mismatch.'); + } + + $bodyOffset = 0; + $deps = $this->storageChangeReadHashList($data, $bodyOffset); + $actor = $this->storageChangeReadActorId($data, $bodyOffset); + $seq = $this->decodeUnsignedLeb128Int($data, $bodyOffset); + $startOp = $this->decodeUnsignedLeb128Int($data, $bodyOffset); + if ($startOp < 1) { + throw new \InvalidArgumentException('Automerge change startOp must be non-zero.'); + } + + $time = $this->decodeSignedLeb128Int($data, $bodyOffset); + $messageLength = $this->decodeUnsignedLeb128Int($data, $bodyOffset); + if ($bodyOffset + $messageLength > strlen($data)) { + throw new \InvalidArgumentException('Automerge change message is truncated.'); + } + + $message = substr($data, $bodyOffset, $messageLength); + $bodyOffset += $messageLength; + $otherActors = $this->storageChangeReadActorList($data, $bodyOffset); + $rawColumns = $this->storageChangeReadRawColumns($data, $bodyOffset); + $opsDataLength = array_sum(array_map( + static fn (array $column): int => (int) $column['length'], + $rawColumns + )); + if ($bodyOffset + $opsDataLength > strlen($data)) { + throw new \InvalidArgumentException('Automerge change operation column data is truncated.'); + } + + $opsData = substr($data, $bodyOffset, $opsDataLength); + $bodyOffset += $opsDataLength; + $extraBytes = substr($data, $bodyOffset); + + return [ + 'rawBytes' => $this->columnarBytesToList($raw), + 'chunkType' => $chunkType, + 'dataLength' => $dataLength, + 'checksum' => bin2hex($checksum), + 'hash' => bin2hex($hash), + 'deps' => $deps, + 'actor' => $actor, + 'seq' => $seq, + 'startOp' => $startOp, + 'time' => $time, + 'message' => $message === '' ? null : $message, + 'otherActors' => $otherActors, + 'rawColumns' => $rawColumns, + 'opsData' => $this->columnarBytesToList($opsData), + 'extraBytes' => $this->columnarBytesToList($extraBytes), + ]; + } + + /** + * @param list $bytes + */ + public function storageDocumentFromBytes(array $bytes, ?string $actorId = null): Document + { + return $this->loadStorageDocument($this->columnarByteListToString($bytes), $actorId); + } + + public function loadStorageDocument(string $payload, ?string $actorId = null): Document + { + $document = $this->init($actorId); + $objectPaths = []; + foreach ($this->storageChangesFromPayload($payload) as $change) { + [$document, $objectPaths] = $this->storageApplyLoadedChange($document, $objectPaths, $change); + } + + return $document; + } + + /** + * @param list $bytes + * @return list + */ + public function storageCompressChangeBytes(array $bytes): array + { + $change = $this->storageChangeFromBytes($bytes); + $raw = $this->columnarByteListToString($change['rawBytes']); + $body = substr($raw, $this->storageChunkBodyOffset($raw)); + $compressed = gzdeflate($body); + if ($compressed === false) { + throw new \RuntimeException('Unable to deflate Automerge change body.'); + } + + $checksum = hex2bin((string) $change['checksum']); + if (! is_string($checksum)) { + throw new \InvalidArgumentException('Automerge change checksum is not valid hex.'); + } + + return $this->columnarBytesToList( + "\x85\x6f\x4a\x83" + . $checksum + . chr(2) + . $this->encodeUnsignedLeb128Int(strlen($compressed)) + . $compressed + ); + } + + /** + * @param list $bytes + * @return list + */ + public function storageDecompressChangeBytes(array $bytes): array + { + $raw = $this->columnarByteListToString($bytes); + if (strlen($raw) < 10) { + throw new \InvalidArgumentException('Automerge compressed change chunk is too short.'); + } + + if (substr($raw, 0, 4) !== "\x85\x6f\x4a\x83") { + throw new \InvalidArgumentException('Invalid Automerge storage magic bytes.'); + } + + $offset = 4; + $checksum = substr($raw, $offset, 4); + $offset += 4; + $chunkType = ord($raw[$offset]); + ++$offset; + if ($chunkType !== 2) { + throw new \InvalidArgumentException('Automerge storage chunk is not a compressed change chunk.'); + } + + $dataLength = $this->decodeUnsignedLeb128Int($raw, $offset); + if ($offset + $dataLength !== strlen($raw)) { + throw new \InvalidArgumentException('Automerge compressed change chunk length does not match payload bytes.'); + } + + $inflated = gzinflate(substr($raw, $offset, $dataLength)); + if ($inflated === false) { + throw new \InvalidArgumentException('Unable to inflate Automerge compressed change chunk.'); + } + + $uncompressed = "\x85\x6f\x4a\x83" + . $checksum + . chr(1) + . $this->encodeUnsignedLeb128Int(strlen($inflated)) + . $inflated; + + $this->storageChangeFromBytes($this->columnarBytesToList($uncompressed)); + + return $this->columnarBytesToList($uncompressed); + } + + /** + * @param array $change + * @return array + */ + public function storageExpandedChangeFromChange(array $change): array + { + $rawBytes = $change['rawBytes'] ?? null; + if (! is_array($rawBytes)) { + throw new \InvalidArgumentException('Expanded change conversion requires raw change bytes.'); + } + + return [ + 'deps' => $this->normalizeHeads($change['deps'] ?? []), + 'actor' => (string) ($change['actor'] ?? ''), + 'seq' => (int) ($change['seq'] ?? 0), + 'startOp' => (int) ($change['startOp'] ?? 0), + 'time' => (int) ($change['time'] ?? 0), + 'message' => $change['message'] ?? null, + 'otherActors' => is_array($change['otherActors'] ?? null) ? array_values($change['otherActors']) : [], + 'rawColumns' => is_array($change['rawColumns'] ?? null) ? array_values($change['rawColumns']) : [], + 'opsData' => is_array($change['opsData'] ?? null) ? array_values($change['opsData']) : [], + 'extraBytes' => is_array($change['extraBytes'] ?? null) ? array_values($change['extraBytes']) : [], + 'rawBytes' => array_values($rawBytes), + ]; + } + + /** + * @param array $expanded + * @return array + */ + public function storageChangeFromExpandedChange(array $expanded): array + { + $rawBytes = $expanded['rawBytes'] ?? null; + if (! is_array($rawBytes)) { + throw new \InvalidArgumentException('Expanded change must include raw bytes for deterministic native round-trip.'); + } + + $change = $this->storageChangeFromBytes(array_values($rawBytes)); + $decoded = $this->storageExpandedChangeFromChange($change); + foreach (['deps', 'actor', 'seq', 'startOp', 'time', 'message', 'otherActors', 'rawColumns', 'opsData', 'extraBytes'] as $field) { + if (($decoded[$field] ?? null) !== ($expanded[$field] ?? null)) { + throw new \InvalidArgumentException('Expanded change metadata does not match raw bytes.'); + } + } + + return $change; + } + + /** + * @param array $change + * @return list + */ + public function storageChangeRawBytes(array $change): array + { + $rawBytes = $change['rawBytes'] ?? null; + if (! is_array($rawBytes)) { + throw new \InvalidArgumentException('Change does not contain raw bytes.'); + } + + return array_values($rawBytes); + } + + /** + * @param array{type:string,counter?:int|string,actor?:string,actorIndex?:int|string} $exId + * @return list + */ + public function exIdToBytes(array $exId): array + { + $type = $exId['type'] ?? null; + if ($type === 'root') { + return [0]; + } + + if ($type !== 'id') { + throw new \InvalidArgumentException('ExId type must be root or id.'); + } + + $actor = $exId['actor'] ?? null; + if (! is_string($actor) || strlen($actor) % 2 !== 0 || ($actor !== '' && ! ctype_xdigit($actor))) { + throw new \InvalidArgumentException('ExId actor must be a hex string.'); + } + + $actorBytes = $actor === '' ? '' : (string) hex2bin($actor); + $counter = $this->normalizeUnsignedDecimal($exId['counter'] ?? 0, '18446744073709551615'); + $actorIndex = $this->normalizeUnsignedDecimal($exId['actorIndex'] ?? 0, '18446744073709551615'); + + $bytes = chr(0x10) + . $this->encodeUnsignedLeb128Decimal((string) strlen($actorBytes)) + . $actorBytes + . $this->encodeUnsignedLeb128Decimal($actorIndex) + . $this->encodeUnsignedLeb128Decimal($counter); + + return $this->columnarBytesToList($bytes); + } + + /** + * @param list $bytes + * @return array{type:string,counter?:string,actor?:string,actorIndex?:string,display:string} + */ + public function exIdFromBytes(array $bytes): array + { + $data = $this->columnarByteListToString($bytes); + if ($data === '') { + throw new \InvalidArgumentException('ExId is missing a version tag.'); + } + + $offset = 0; + $tag = ord($data[$offset]); + ++$offset; + + $version = $tag & 0x0f; + if ($version !== 0) { + throw new \InvalidArgumentException('Invalid ExId version tag.'); + } + + $type = $tag >> 4; + if ($type === 0) { + return ['type' => 'root', 'display' => '_root']; + } + + if ($type !== 1) { + throw new \InvalidArgumentException('Invalid ExId type tag.'); + } + + [$actorLengthDecimal, $used] = $this->parseStorageUnsignedLeb128(substr($data, $offset)); + $offset += $used; + $actorLength = $this->decimalStringToPhpInt($actorLengthDecimal, 'ExId actor length'); + if (strlen($data) - $offset < $actorLength) { + throw new \InvalidArgumentException('Not enough bytes in ExId actor ID.'); + } + + $actorBytes = substr($data, $offset, $actorLength); + $offset += $actorLength; + [$actorIndex, $used] = $this->parseStorageUnsignedLeb128(substr($data, $offset)); + $offset += $used; + [$counter] = $this->parseStorageUnsignedLeb128(substr($data, $offset)); + $actor = bin2hex($actorBytes); + + return [ + 'type' => 'id', + 'counter' => $counter, + 'actor' => $actor, + 'actorIndex' => $actorIndex, + 'display' => $counter . '@' . $actor, + ]; + } + + /** + * @param array{type:string,counter?:int|string,actor?:string,actorIndex?:int|string,display?:string} $exId + */ + public function exIdDisplay(array $exId): string + { + if (($exId['type'] ?? null) === 'root') { + return '_root'; + } + + if (($exId['type'] ?? null) !== 'id') { + throw new \InvalidArgumentException('ExId type must be root or id.'); + } + + $counter = $this->normalizeUnsignedDecimal($exId['counter'] ?? 0, '18446744073709551615'); + $actor = $exId['actor'] ?? null; + if (! is_string($actor) || strlen($actor) % 2 !== 0 || ($actor !== '' && ! ctype_xdigit($actor))) { + throw new \InvalidArgumentException('ExId actor must be a hex string.'); + } + + return $counter . '@' . strtolower($actor); + } + + public function columnSpecEncode(int $id, string $type, bool $deflate = false): int + { + if ($id < 0 || $id > 0x0fffffff) { + throw new \InvalidArgumentException('Column spec id must fit in 28 bits.'); + } + + $raw = ($id << 4) | $this->columnTypeCode($type); + + return $deflate ? ($raw | 0x08) : ($raw & ~0x08); + } + + /** + * @return array{id:int,type:string,deflate:bool,normalized:int} + */ + public function columnSpecDecode(int $raw): array + { + $this->assertColumnSpecRaw($raw); + + return [ + 'id' => $raw >> 4, + 'type' => $this->columnTypeName($raw & 0x07), + 'deflate' => ($raw & 0x08) !== 0, + 'normalized' => $raw & ~0x08, + ]; + } + + public function columnSpecDeflated(int $raw): int + { + $this->assertColumnSpecRaw($raw); + + return $raw | 0x08; + } + + public function columnSpecInflated(int $raw): int + { + $this->assertColumnSpecRaw($raw); + + return $raw & ~0x08; + } + + public function columnSpecNormalize(int $raw): int + { + return $this->columnSpecInflated($raw); + } + + /** + * @param list $columns + * @param list $data + * @return array{columns:list,data:list} + */ + public function storageCompressRawColumns(array $columns, array $data, int $threshold = 256): array + { + if ($threshold < 1) { + throw new \InvalidArgumentException('Column compression threshold must be positive.'); + } + + $rawData = $this->columnarByteListToString($data); + $normalizedColumns = $this->storageNormalizeRawColumns($columns, strlen($rawData)); + $output = ''; + $compressedColumns = []; + + foreach ($normalizedColumns as $column) { + $slice = substr($rawData, $column['range'][0], $column['length']); + $spec = $this->columnSpecInflated($column['spec']); + if ($column['deflate']) { + $inflated = gzinflate($slice); + if ($inflated === false) { + throw new \InvalidArgumentException('Unable to inflate existing Automerge column data.'); + } + + $slice = $inflated; + } + + if (strlen($slice) >= $threshold) { + $deflated = gzdeflate($slice); + if ($deflated === false) { + throw new \RuntimeException('Unable to deflate Automerge column data.'); + } + + if (strlen($deflated) < strlen($slice)) { + $slice = $deflated; + $spec = $this->columnSpecDeflated($spec); + } + } + + $start = strlen($output); + $output .= $slice; + $compressedColumns[] = $this->storageRawColumnDescriptor($spec, strlen($slice), $start); + } + + return [ + 'columns' => $compressedColumns, + 'data' => $this->columnarBytesToList($output), + ]; + } + + /** + * @param list $columns + * @param list $data + * @return array{columns:list,data:list} + */ + public function storageDecompressRawColumns(array $columns, array $data): array + { + $rawData = $this->columnarByteListToString($data); + $normalizedColumns = $this->storageNormalizeRawColumns($columns, strlen($rawData)); + $output = ''; + $inflatedColumns = []; + + foreach ($normalizedColumns as $column) { + $slice = substr($rawData, $column['range'][0], $column['length']); + $spec = $this->columnSpecInflated($column['spec']); + if ($column['deflate']) { + $inflated = gzinflate($slice); + if ($inflated === false) { + throw new \InvalidArgumentException('Unable to inflate Automerge column data.'); + } + + $slice = $inflated; + } + + $start = strlen($output); + $output .= $slice; + $inflatedColumns[] = $this->storageRawColumnDescriptor($spec, strlen($slice), $start); + } + + return [ + 'columns' => $inflatedColumns, + 'data' => $this->columnarBytesToList($output), + ]; + } + + /** + * @return array{values:list} + */ + public function sequenceTreeNew(): array + { + return ['values' => []]; + } + + /** + * @param array{values?:list} $tree + */ + public function sequenceTreeLen(array $tree): int + { + return count($this->sequenceTreeValues($tree)); + } + + /** + * @param array{values?:list} $tree + * @return array{values:list} + */ + public function sequenceTreePush(array $tree, mixed $value): array + { + return $this->sequenceTreeInsert($tree, $this->sequenceTreeLen($tree), $value); + } + + /** + * @param array{values?:list} $tree + * @return array{values:list} + */ + public function sequenceTreeInsert(array $tree, int $index, mixed $value): array + { + $values = $this->sequenceTreeValues($tree); + if ($index < 0 || $index > count($values)) { + throw new \OutOfBoundsException('SequenceTree insert index is out of bounds.'); + } + + array_splice($values, $index, 0, [$value]); + + return ['values' => array_values($values)]; + } + + /** + * @param array{values?:list} $tree + * @return array{tree:array{values:list},value:mixed} + */ + public function sequenceTreeRemove(array $tree, int $index): array + { + $values = $this->sequenceTreeValues($tree); + if ($index < 0 || $index >= count($values)) { + throw new \OutOfBoundsException('SequenceTree remove index is out of bounds.'); + } + + $removed = array_splice($values, $index, 1); + + return ['tree' => ['values' => array_values($values)], 'value' => $removed[0]]; + } + + /** + * @param array{values?:list} $tree + */ + public function sequenceTreeGet(array $tree, int $index): mixed + { + $values = $this->sequenceTreeValues($tree); + + return $values[$index] ?? null; + } + + /** + * @param array{values?:list} $tree + * @return list + */ + public function sequenceTreeIter(array $tree): array + { + return $this->sequenceTreeValues($tree); + } + + /** + * @param array{values?:list} $tree + * @param list $values + */ + public function sequenceTreeEqualsList(array $tree, array $values): bool + { + return $this->sequenceTreeValues($tree) === array_values($values); + } + + /** + * @param list $values + */ + public function columnarEncodeBooleans(array $values): string + { + $bytes = ''; + $last = false; + $count = 0; + + foreach ($values as $value) { + if (! is_bool($value)) { + throw new \InvalidArgumentException('Boolean column values must be booleans.'); + } + + if ($value === $last) { + ++$count; + continue; + } + + $bytes .= $this->encodeUnsignedLeb128Int($count); + $last = $value; + $count = 1; + } + + if ($count > 0) { + $bytes .= $this->encodeUnsignedLeb128Int($count); + } + + return $bytes; + } + + /** + * @return list + */ + public function columnarDecodeBooleans(string $bytes): array + { + $values = []; + $offset = 0; + $lastValue = true; + $length = strlen($bytes); + + while ($offset < $length) { + $count = $this->decodeUnsignedLeb128Int($bytes, $offset); + $lastValue = ! $lastValue; + for ($index = 0; $index < $count; ++$index) { + $values[] = $lastValue; + } + } + + return $values; + } + + /** + * @param list $values + */ + public function columnarEncodeRleInts(array $values): string + { + $bytes = ''; + $state = ['type' => 'empty']; + + foreach ($values as $value) { + if ($value === null) { + $state = $this->appendRleNull($state, $bytes); + continue; + } + + if (! is_int($value)) { + throw new \InvalidArgumentException('RLE integer column values must be integers or null.'); + } + + $state = $this->appendRleIntValue($state, $value, $bytes); + } + + $this->flushRleIntState($state, $bytes); + + return $bytes; + } + + /** + * @return list + */ + public function columnarDecodeRleInts(string $bytes): array + { + $values = []; + $offset = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $count = $this->decodeSignedLeb128Int($bytes, $offset); + if ($count > 0) { + $value = $this->decodeSignedLeb128Int($bytes, $offset); + for ($index = 0; $index < $count; ++$index) { + $values[] = $value; + } + } elseif ($count < 0) { + for ($index = 0; $index < abs($count); ++$index) { + $values[] = $this->decodeSignedLeb128Int($bytes, $offset); + } + } else { + $nullCount = $this->decodeUnsignedLeb128Int($bytes, $offset); + for ($index = 0; $index < $nullCount; ++$index) { + $values[] = null; + } + } + } + + return $values; + } + + /** + * @param list $replacement + */ + public function columnarSpliceRleInts(string $bytes, int $start, int $deleteCount, array $replacement): string + { + $values = $this->columnarDecodeRleInts($bytes); + $count = count($values); + if ($start < 0 || $deleteCount < 0 || $start > $count || $start + $deleteCount > $count) { + throw new \OutOfBoundsException('RLE integer splice range is out of bounds.'); + } + + foreach ($replacement as $value) { + if ($value !== null && ! is_int($value)) { + throw new \InvalidArgumentException('RLE integer splice replacements must be integers or null.'); + } + } + + array_splice($values, $start, $deleteCount, $replacement); + + return $this->columnarEncodeRleInts($values); + } + + /** + * @param list $values + */ + public function columnarEncodeRleStrings(array $values): string + { + $bytes = ''; + $state = ['type' => 'empty']; + + foreach ($values as $value) { + if ($value === null) { + $state = $this->appendRleStringNull($state, $bytes); + continue; + } + + if (! is_string($value)) { + throw new \InvalidArgumentException('RLE string column values must be strings or null.'); + } + + $state = $this->appendRleStringValue($state, $value, $bytes); + } + + $this->flushRleStringState($state, $bytes); + + return $bytes; + } + + /** + * @return list + */ + public function columnarDecodeRleStrings(string $bytes): array + { + $values = []; + $offset = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $count = $this->decodeSignedLeb128Int($bytes, $offset); + if ($count > 0) { + $value = $this->decodeColumnarString($bytes, $offset); + for ($index = 0; $index < $count; ++$index) { + $values[] = $value; + } + } elseif ($count < 0) { + for ($index = 0; $index < abs($count); ++$index) { + $values[] = $this->decodeColumnarString($bytes, $offset); + } + } else { + $nullCount = $this->decodeUnsignedLeb128Int($bytes, $offset); + for ($index = 0; $index < $nullCount; ++$index) { + $values[] = null; + } + } + } + + return $values; + } + + /** + * @param list $replacement + */ + public function columnarSpliceRleStrings(string $bytes, int $start, int $deleteCount, array $replacement): string + { + $values = $this->columnarDecodeRleStrings($bytes); + $count = count($values); + if ($start < 0 || $deleteCount < 0 || $start > $count || $start + $deleteCount > $count) { + throw new \OutOfBoundsException('RLE string splice range is out of bounds.'); + } + + foreach ($replacement as $value) { + if ($value !== null && ! is_string($value)) { + throw new \InvalidArgumentException('RLE string splice replacements must be strings or null.'); + } + } + + array_splice($values, $start, $deleteCount, $replacement); + + return $this->columnarEncodeRleStrings($values); + } + + /** + * @param list $values + */ + public function columnarEncodeRleUints(array $values): string + { + $bytes = ''; + $state = ['type' => 'empty']; + + foreach ($values as $value) { + if (! is_int($value) || $value < 0) { + throw new \InvalidArgumentException('RLE unsigned integer column values must be non-negative integers.'); + } + + $state = $this->appendRleUintValue($state, $value, $bytes); + } + + $this->flushRleUintState($state, $bytes); + + return $bytes; + } + + /** + * @return list + */ + public function columnarDecodeRleUints(string $bytes): array + { + $values = []; + $offset = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $count = $this->decodeSignedLeb128Int($bytes, $offset); + if ($count > 0) { + $value = $this->decodeUnsignedLeb128Int($bytes, $offset); + for ($index = 0; $index < $count; ++$index) { + $values[] = $value; + } + } elseif ($count < 0) { + for ($index = 0; $index < abs($count); ++$index) { + $values[] = $this->decodeUnsignedLeb128Int($bytes, $offset); + } + } else { + $nullCount = $this->decodeUnsignedLeb128Int($bytes, $offset); + for ($index = 0; $index < $nullCount; ++$index) { + $values[] = null; + } + } + } + + return $values; + } + + /** + * @param list $values + */ + public function columnarEncodeDeltaInts(array $values): string + { + $absolute = 0; + $deltas = []; + + foreach ($values as $value) { + if ($value === null) { + $deltas[] = null; + continue; + } + + if (! is_int($value)) { + throw new \InvalidArgumentException('Delta integer column values must be integers or null.'); + } + + $deltas[] = $value - $absolute; + $absolute = $value; + } + + return $this->columnarEncodeRleInts($deltas); + } + + /** + * @return list + */ + public function columnarDecodeDeltaInts(string $bytes): array + { + $absolute = 0; + $values = []; + + foreach ($this->columnarDecodeRleInts($bytes) as $delta) { + if ($delta === null) { + $values[] = null; + continue; + } + + $absolute += $delta; + $values[] = $absolute; + } + + return $values; + } + + /** + * @param list $replacement + */ + public function columnarSpliceDeltaInts(string $bytes, int $start, int $deleteCount, array $replacement): string + { + $values = $this->columnarDecodeDeltaInts($bytes); + $count = count($values); + if ($start < 0 || $deleteCount < 0 || $start > $count || $start + $deleteCount > $count) { + throw new \OutOfBoundsException('Delta integer splice range is out of bounds.'); + } + + foreach ($replacement as $value) { + if ($value !== null && ! is_int($value)) { + throw new \InvalidArgumentException('Delta integer splice replacements must be integers or null.'); + } + } + + array_splice($values, $start, $deleteCount, $replacement); + + return $this->columnarEncodeDeltaInts($values); + } + + /** + * @param list> $opidGroups + * @return array{bytes:string,ranges:array{num:array{0:int,1:int},actor:array{0:int,1:int},counter:array{0:int,1:int}}} + */ + public function columnarEncodeOpIdLists(array $opidGroups): array + { + $nums = []; + $actors = []; + $counters = []; + + foreach ($opidGroups as $group) { + if (! is_array($group)) { + throw new \InvalidArgumentException('OpId list groups must be arrays.'); + } + + $nums[] = count($group); + foreach ($group as $opid) { + if (! is_array($opid)) { + throw new \InvalidArgumentException('OpId list entries must be arrays.'); + } + + $actor = $opid['actor'] ?? null; + $counter = $opid['counter'] ?? null; + if (! is_int($actor) || $actor < 0 || ! is_int($counter) || $counter < 0) { + throw new \InvalidArgumentException('OpId entries must contain non-negative integer actor and counter values.'); + } + + $actors[] = $actor; + $counters[] = $counter; + } + } + + $numBytes = $this->columnarEncodeRleUints($nums); + $actorBytes = $this->columnarEncodeRleUints($actors); + $counterBytes = $this->columnarEncodeDeltaInts($counters); + $numEnd = strlen($numBytes); + $actorEnd = $numEnd + strlen($actorBytes); + $counterEnd = $actorEnd + strlen($counterBytes); + + return [ + 'bytes' => $numBytes . $actorBytes . $counterBytes, + 'ranges' => [ + 'num' => [0, $numEnd], + 'actor' => [$numEnd, $actorEnd], + 'counter' => [$actorEnd, $counterEnd], + ], + ]; + } + + /** + * @param array{bytes:string,ranges:array{num:array{0:int,1:int},actor:array{0:int,1:int},counter:array{0:int,1:int}}} $encoded + * @return list> + */ + public function columnarDecodeOpIdLists(array $encoded): array + { + $bytes = $encoded['bytes'] ?? null; + $ranges = $encoded['ranges'] ?? null; + if (! is_string($bytes) || ! is_array($ranges)) { + throw new \InvalidArgumentException('Encoded OpId lists require bytes and column ranges.'); + } + + $nums = $this->columnarDecodeRleUints($this->columnarRangeSlice($bytes, $ranges['num'] ?? null, 'num')); + $actors = $this->columnarDecodeRleUints($this->columnarRangeSlice($bytes, $ranges['actor'] ?? null, 'actor')); + $counters = $this->columnarDecodeDeltaInts($this->columnarRangeSlice($bytes, $ranges['counter'] ?? null, 'counter')); + $groups = []; + $offset = 0; + + foreach ($nums as $num) { + if ($num === null) { + throw new \InvalidArgumentException('OpId group counts cannot be null.'); + } + + $group = []; + for ($index = 0; $index < $num; ++$index) { + if (! array_key_exists($offset, $actors) || ! array_key_exists($offset, $counters)) { + throw new \InvalidArgumentException('OpId actor/counter columns ended before all group entries were decoded.'); + } + + $actor = $actors[$offset]; + $counter = $counters[$offset]; + if ($actor === null || $counter === null || $counter < 0) { + throw new \InvalidArgumentException('OpId actor and counter columns must contain non-null non-negative values.'); + } + + $group[] = ['actor' => $actor, 'counter' => $counter]; + ++$offset; + } + + $groups[] = $group; + } + + if ($offset !== count($actors) || $offset !== count($counters)) { + throw new \InvalidArgumentException('OpId actor/counter columns contain ungrouped trailing entries.'); + } + + return $groups; + } + + /** + * @param array{bytes:string,ranges:array{num:array{0:int,1:int},actor:array{0:int,1:int},counter:array{0:int,1:int}}} $encoded + * @param list> $replacement + * @return array{bytes:string,ranges:array{num:array{0:int,1:int},actor:array{0:int,1:int},counter:array{0:int,1:int}}} + */ + public function columnarSpliceOpIdLists(array $encoded, int $start, int $deleteCount, array $replacement): array + { + $groups = $this->columnarDecodeOpIdLists($encoded); + $count = count($groups); + if ($start < 0 || $deleteCount < 0 || $start > $count || $start + $deleteCount > $count) { + throw new \OutOfBoundsException('OpId list splice range is out of bounds.'); + } + + $replacement = $this->columnarDecodeOpIdLists($this->columnarEncodeOpIdLists($replacement)); + array_splice($groups, $start, $deleteCount, $replacement); + + return $this->columnarEncodeOpIdLists($groups); + } + + /** + * @param list> $ops + * @return array{rowCount:int,obj:string,key:string,val:string,pred:string,action:string,insert:string,expand:string,markName:string} + */ + public function storageChangeEncodeChangeOps(array $ops): array + { + $rows = []; + foreach ($ops as $op) { + if (! is_array($op)) { + throw new \InvalidArgumentException('Change op rows must be arrays.'); + } + + $rows[] = [ + 'obj' => $this->storageChangeJsonColumn($op['obj'] ?? null), + 'key' => $this->storageChangeJsonColumn($op['key'] ?? null), + 'val' => $this->storageChangeJsonColumn($op['val'] ?? null), + 'pred' => $this->storageChangeJsonColumn(is_array($op['pred'] ?? null) ? array_values($op['pred']) : []), + 'action' => is_string($op['action'] ?? null) ? $op['action'] : throw new \InvalidArgumentException('Change op action must be a string.'), + 'insert' => (bool) ($op['insert'] ?? false), + 'expand' => (bool) ($op['expand'] ?? false), + 'markName' => is_string($op['markName'] ?? null) ? $op['markName'] : null, + ]; + } + + return [ + 'rowCount' => count($rows), + 'obj' => $this->columnarEncodeRleStrings(array_column($rows, 'obj')), + 'key' => $this->columnarEncodeRleStrings(array_column($rows, 'key')), + 'val' => $this->columnarEncodeRleStrings(array_column($rows, 'val')), + 'pred' => $this->columnarEncodeRleStrings(array_column($rows, 'pred')), + 'action' => $this->columnarEncodeRleStrings(array_column($rows, 'action')), + 'insert' => $this->columnarEncodeBooleans(array_column($rows, 'insert')), + 'expand' => $this->columnarEncodeBooleans(array_column($rows, 'expand')), + 'markName' => $this->columnarEncodeRleStrings(array_column($rows, 'markName')), + ]; + } + + /** + * @param array{rowCount:int,obj:string,key:string,val:string,pred:string,action:string,insert:string,expand:string,markName:string} $columns + * @return list,action:string,insert:bool,expand:bool,markName:?string}> + */ + public function storageChangeDecodeChangeOps(array $columns): array + { + $rowCount = $columns['rowCount'] ?? null; + if (! is_int($rowCount) || $rowCount < 0) { + throw new \InvalidArgumentException('Change op columns require a non-negative row count.'); + } + + $objs = $this->columnarDecodeRleStrings($columns['obj']); + $keys = $this->columnarDecodeRleStrings($columns['key']); + $vals = $this->columnarDecodeRleStrings($columns['val']); + $preds = $this->columnarDecodeRleStrings($columns['pred']); + $actions = $this->columnarDecodeRleStrings($columns['action']); + $inserts = $this->columnarDecodeBooleans($columns['insert']); + $expands = $this->columnarDecodeBooleans($columns['expand']); + $markNames = $this->columnarDecodeRleStrings($columns['markName']); + + foreach ([$objs, $keys, $vals, $preds, $actions, $inserts, $expands, $markNames] as $column) { + if (count($column) !== $rowCount) { + throw new \InvalidArgumentException('Change op column lengths must match the row count.'); + } + } + + $ops = []; + for ($index = 0; $index < $rowCount; ++$index) { + $pred = $this->storageChangeJsonColumnValue($preds[$index], 'pred'); + if (! is_array($pred)) { + throw new \InvalidArgumentException('Decoded change op pred column must be a list.'); + } + + $ops[] = [ + 'obj' => $this->storageChangeJsonColumnValue($objs[$index], 'obj'), + 'key' => $this->storageChangeJsonColumnValue($keys[$index], 'key'), + 'val' => $this->storageChangeJsonColumnValue($vals[$index], 'val'), + 'pred' => array_values($pred), + 'action' => $this->nonNullStringColumnValue($actions[$index], 'action'), + 'insert' => (bool) $inserts[$index], + 'expand' => (bool) $expands[$index], + 'markName' => $markNames[$index], + ]; + } + + return $ops; + } + + private function storageChangeJsonColumn(mixed $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + private function storageChangeJsonColumnValue(?string $value, string $column): mixed + { + if ($value === null) { + throw new \InvalidArgumentException('Change op ' . $column . ' column cannot be null.'); + } + + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @param list> $values + * @return array{bytes:string,ranges:array{meta:array{0:int,1:int},raw:array{0:int,1:int}}} + */ + public function columnarEncodeScalarValues(array $values): array + { + $metas = []; + $raw = ''; + + foreach ($values as $value) { + [$meta, $rawBytes] = $this->columnarEncodeScalarValue($value); + $metas[] = $meta; + $raw .= $rawBytes; + } + + $metaBytes = $this->columnarEncodeRleUints($metas); + $metaEnd = strlen($metaBytes); + $rawEnd = $metaEnd + strlen($raw); + + return [ + 'bytes' => $metaBytes . $raw, + 'ranges' => [ + 'meta' => [0, $metaEnd], + 'raw' => [$metaEnd, $rawEnd], + ], + ]; + } + + /** + * @param list> $values + * @return array{bytes:string,ranges:array{meta:array{0:int,1:int},raw:array{0:int,1:int}}} + */ + public function columnarEncodeScalarValuesRowWise(array $values): array + { + $metaBytes = ''; + $metaState = ['type' => 'empty']; + $raw = ''; + + foreach ($values as $value) { + [$meta, $rawBytes] = $this->columnarEncodeScalarValue($value); + $metaState = $this->appendRleUintValue($metaState, $meta, $metaBytes); + $raw .= $rawBytes; + } + + $this->flushRleUintState($metaState, $metaBytes); + $metaEnd = strlen($metaBytes); + $rawEnd = $metaEnd + strlen($raw); + + return [ + 'bytes' => $metaBytes . $raw, + 'ranges' => [ + 'meta' => [0, $metaEnd], + 'raw' => [$metaEnd, $rawEnd], + ], + ]; + } + + /** + * @param array{bytes:string,ranges:array{meta:array{0:int,1:int},raw:array{0:int,1:int}}} $encoded + * @return list> + */ + public function columnarDecodeScalarValues(array $encoded): array + { + $bytes = $encoded['bytes'] ?? null; + $ranges = $encoded['ranges'] ?? null; + if (! is_string($bytes) || ! is_array($ranges)) { + throw new \InvalidArgumentException('Encoded scalar values require bytes and column ranges.'); + } + + $metas = $this->columnarDecodeRleUints($this->columnarRangeSlice($bytes, $ranges['meta'] ?? null, 'value meta')); + $raw = $this->columnarRangeSlice($bytes, $ranges['raw'] ?? null, 'value raw'); + $offset = 0; + $values = []; + + foreach ($metas as $meta) { + if ($meta === null) { + throw new \InvalidArgumentException('Scalar value metadata cannot be null.'); + } + + $values[] = $this->columnarDecodeScalarValue($meta, $raw, $offset); + } + + if ($offset !== strlen($raw)) { + throw new \InvalidArgumentException('Scalar value raw column contains trailing bytes.'); + } + + return $values; + } + + /** + * @param list $metas + * @return list + */ + public function opSet2ValueMetaWithAccumulator(array $metas, int $start = 0, ?int $length = null): array + { + foreach ($metas as $meta) { + if (! is_int($meta) || $meta < 0) { + throw new \InvalidArgumentException('ValueMeta entries must be non-negative integers.'); + } + } + + $decoded = $this->columnarDecodeRleUints($this->columnarEncodeRleUints($metas)); + $count = count($decoded); + if ($start < 0 || $start > $count) { + throw new \OutOfBoundsException('ValueMeta iterator start is out of bounds.'); + } + + $end = $length === null ? $count : $start + $length; + if ($length !== null && ($length < 0 || $end > $count)) { + throw new \OutOfBoundsException('ValueMeta iterator range is out of bounds.'); + } + + $rows = []; + $acc = 0; + foreach ($decoded as $index => $meta) { + if ($meta === null) { + throw new \InvalidArgumentException('ValueMeta column cannot contain null entries.'); + } + + if ($index >= $start && $index < $end) { + $rows[] = [ + 'value' => $meta, + 'typeCode' => $meta & 0b1111, + 'length' => $meta >> 4, + 'acc' => $acc, + ]; + } + + $acc += $meta >> 4; + } + + return $rows; + } + + /** + * @param list $objectIds + * @return array{range:array{0:int,1:int},pos:int,values:list} + */ + public function opSet2ObjectIdSeek(array $objectIds, mixed $target): array + { + $objects = []; + $previous = null; + foreach ($objectIds as $objectId) { + $object = $this->opSet2NormalizeObjectId($objectId); + if ($previous !== null && $this->opSet2CompareObjectIds($previous, $object) > 0) { + throw new \InvalidArgumentException('Object id columns must be sorted for op_set2 seeking.'); + } + + $objects[] = $object; + $previous = $object; + } + + $targetObject = $this->opSet2NormalizeObjectId($target); + $low = 0; + $high = count($objects); + while ($low < $high) { + $mid = intdiv($low + $high, 2); + if ($this->opSet2CompareObjectIds($objects[$mid], $targetObject) < 0) { + $low = $mid + 1; + } else { + $high = $mid; + } + } + + $start = $low; + $end = $start; + while ($end < count($objects) && $this->opSet2CompareObjectIds($objects[$end], $targetObject) === 0) { + ++$end; + } + + return [ + 'range' => [$start, $end], + 'pos' => $start, + 'values' => array_slice($objects, $start, $end - $start), + ]; + } + + /** + * @param list $operationIds + * @return list + */ + public function opSet2OperationIdsInCounterRange(array $operationIds, int $start, int $end): array + { + $this->validateOpSet2CounterRange($start, $end); + + $matches = []; + foreach ($operationIds as $operationId) { + $id = $this->opSet2NormalizeOperationId($operationId); + if ($id['counter'] >= $start && $id['counter'] < $end) { + $matches[] = $id; + } + } + + return $matches; + } + + /** + * @param list $operationIds + * @param list> $successorsByIndex + * @return list + */ + public function opSet2OperationIdsWithSuccessorsInCounterRange(array $operationIds, array $successorsByIndex, int $start, int $end): array + { + $this->validateOpSet2CounterRange($start, $end); + if (count($operationIds) !== count($successorsByIndex)) { + throw new \InvalidArgumentException('Operation id and successor columns must have the same row count.'); + } + + $matches = []; + foreach (array_values($operationIds) as $index => $operationId) { + $hasSuccessorInRange = false; + foreach ($successorsByIndex[$index] as $successorId) { + $successor = $this->opSet2NormalizeOperationId($successorId); + if ($successor['counter'] >= $start && $successor['counter'] < $end) { + $hasSuccessorInRange = true; + break; + } + } + + if ($hasSuccessorInRange) { + $matches[] = $this->opSet2NormalizeOperationId($operationId); + } + } + + return $matches; + } + + /** + * @param list $operationIds + * @param list> $successorsByIndex + * @return list + */ + public function opSet2IterCounterRange(array $operationIds, array $successorsByIndex, int $start, int $end): array + { + $this->validateOpSet2CounterRange($start, $end); + if (count($operationIds) !== count($successorsByIndex)) { + throw new \InvalidArgumentException('Operation id and successor columns must have the same row count.'); + } + + $matches = []; + foreach (array_values($operationIds) as $index => $operationId) { + $id = $this->opSet2NormalizeOperationId($operationId); + $include = $id['counter'] >= $start && $id['counter'] < $end; + foreach ($successorsByIndex[$index] as $successorId) { + $successor = $this->opSet2NormalizeOperationId($successorId); + if ($successor['counter'] >= $start && $successor['counter'] < $end) { + $include = true; + break; + } + } + + if ($include) { + $matches[] = $id; + } + } + + return $matches; + } + + /** + * @param array{type:string,counter:int,actor:int} $value + */ + public function opSet2EncodeMarkIndexValue(array $value): int + { + $id = $this->opSet2NormalizeOperationId($value); + if (! in_array($value['type'] ?? null, ['start', 'end'], true)) { + throw new \InvalidArgumentException('MarkIndexValue entries must have type start or end.'); + } + + if ($id['counter'] > 0xffffffff) { + throw new \InvalidArgumentException('MarkIndexValue counters must fit in 32 bits.'); + } + + $packed = ($id['actor'] * 4294967296) + $id['counter']; + if ($packed > PHP_INT_MAX) { + throw new \InvalidArgumentException('MarkIndexValue actor/counter pair is too large for native PHP integers.'); + } + + if ($value['type'] === 'end' && $packed === 0) { + throw new \InvalidArgumentException('MarkIndexValue end entries cannot encode the zero operation id.'); + } + + return $value['type'] === 'end' ? -$packed : $packed; + } + + /** + * @return array{type:string,counter:int,actor:int} + */ + public function opSet2DecodeMarkIndexValue(int $value): array + { + $type = $value < 0 ? 'end' : 'start'; + $packed = abs($value); + + return [ + 'type' => $type, + 'counter' => $packed % 4294967296, + 'actor' => intdiv($packed, 4294967296), + ]; + } + + /** + * @param list $values + */ + public function opSet2EncodeMarkIndexColumn(array $values): string + { + $encoded = []; + foreach ($values as $value) { + $encoded[] = $value === null ? null : $this->opSet2EncodeMarkIndexValue($value); + } + + return $this->columnarEncodeRleInts($encoded); + } + + /** + * @return list + */ + public function opSet2DecodeMarkIndexColumn(string $bytes): array + { + return array_map( + fn (?int $value): ?array => $value === null ? null : $this->opSet2DecodeMarkIndexValue($value), + $this->columnarDecodeRleInts($bytes) + ); + } + + /** + * @return array{rowCount:int,pos:string,id:string,change:string,actor:string,seq:string,opIndex:string,action:string,key:string,insert:string,payload:string} + */ + public function opSet2EncodeOperationColumns(Document $document): array + { + $rows = $this->opSet2OperationRows($document); + + return [ + 'rowCount' => count($rows), + 'pos' => $this->columnarEncodeRleUints(array_column($rows, 'pos')), + 'id' => $this->columnarEncodeRleStrings(array_column($rows, 'id')), + 'change' => $this->columnarEncodeRleStrings(array_column($rows, 'change')), + 'actor' => $this->columnarEncodeRleStrings(array_column($rows, 'actor')), + 'seq' => $this->columnarEncodeRleUints(array_column($rows, 'seq')), + 'opIndex' => $this->columnarEncodeRleUints(array_column($rows, 'opIndex')), + 'action' => $this->columnarEncodeRleStrings(array_column($rows, 'action')), + 'key' => $this->columnarEncodeRleStrings(array_column($rows, 'key')), + 'insert' => $this->columnarEncodeBooleans(array_column($rows, 'insert')), + 'payload' => $this->columnarEncodeRleStrings(array_column($rows, 'payload')), + ]; + } + + /** + * @param array{rowCount:int,pos:string,id:string,change:string,actor:string,seq:string,opIndex:string,action:string,key:string,insert:string,payload:string} $columns + * @return list + */ + public function opSet2DecodeOperationColumns(array $columns): array + { + $rowCount = $columns['rowCount']; + if (! is_int($rowCount) || $rowCount < 0) { + throw new \InvalidArgumentException('Operation column data requires a non-negative row count.'); + } + + $positions = $this->columnarDecodeRleUints($columns['pos']); + $ids = $this->columnarDecodeRleStrings($columns['id']); + $changes = $this->columnarDecodeRleStrings($columns['change']); + $actors = $this->columnarDecodeRleStrings($columns['actor']); + $seqs = $this->columnarDecodeRleUints($columns['seq']); + $opIndexes = $this->columnarDecodeRleUints($columns['opIndex']); + $actions = $this->columnarDecodeRleStrings($columns['action']); + $keys = $this->columnarDecodeRleStrings($columns['key']); + $inserts = $this->columnarDecodeBooleans($columns['insert']); + $payloads = $this->columnarDecodeRleStrings($columns['payload']); + + foreach ([$positions, $ids, $changes, $actors, $seqs, $opIndexes, $actions, $keys, $inserts, $payloads] as $column) { + if (count($column) !== $rowCount) { + throw new \InvalidArgumentException('Operation column lengths must match the row count.'); + } + } + + $rows = []; + for ($index = 0; $index < $rowCount; ++$index) { + $rows[] = [ + 'pos' => $this->nonNullIntColumnValue($positions[$index], 'pos'), + 'id' => $this->nonNullStringColumnValue($ids[$index], 'id'), + 'change' => $this->nonNullStringColumnValue($changes[$index], 'change'), + 'actor' => $this->nonNullStringColumnValue($actors[$index], 'actor'), + 'seq' => $this->nonNullIntColumnValue($seqs[$index], 'seq'), + 'opIndex' => $this->nonNullIntColumnValue($opIndexes[$index], 'opIndex'), + 'action' => $this->nonNullStringColumnValue($actions[$index], 'action'), + 'key' => $this->nonNullStringColumnValue($keys[$index], 'key'), + 'insert' => $inserts[$index], + 'payload' => $this->nonNullStringColumnValue($payloads[$index], 'payload'), + ]; + } + + return $rows; + } + + /** + * @return list + */ + public function opSet2OperationRows(Document $document): array + { + $rows = []; + foreach ($document->getAllChanges() as $change) { + $actor = is_string($change['actor'] ?? null) ? $change['actor'] : ''; + $changeHash = is_string($change['hash'] ?? null) ? $change['hash'] : ''; + $seq = max(0, (int) ($change['seq'] ?? 0)); + $startOp = max(1, (int) ($change['startOp'] ?? 1)); + $ops = is_array($change['ops'] ?? null) ? array_values($change['ops']) : []; + + foreach ($ops as $opIndex => $op) { + if (! is_array($op)) { + continue; + } + + $rows[] = [ + 'pos' => count($rows), + 'id' => ($startOp + $opIndex) . '@' . $actor, + 'change' => $changeHash, + 'actor' => $actor, + 'seq' => $seq, + 'opIndex' => $opIndex, + 'action' => is_string($op['action'] ?? null) ? $op['action'] : '', + 'key' => $this->opSet2OperationKey($op), + 'insert' => (bool) ($op['insert'] ?? false), + 'payload' => json_encode($op, JSON_THROW_ON_ERROR), + ]; + } + } + + return $rows; + } + + /** + * @param list> $rows + * @return array{range:array{0:int,1:int},pos:int,rows:list>} + */ + public function opSet2OperationRowsForObject(array $rows, mixed $objectId): array + { + $target = $this->opSet2NormalizeObjectId($objectId); + $start = null; + $end = null; + $matchedRows = []; + $leftRange = false; + + foreach (array_values($rows) as $index => $row) { + if (! array_key_exists('obj', $row)) { + throw new \InvalidArgumentException('Operation rows must include an obj column for object range iteration.'); + } + + $object = $this->opSet2NormalizeObjectId($row['obj']); + $matches = $this->opSet2CompareObjectIds($object, $target) === 0; + if ($matches && $leftRange) { + throw new \InvalidArgumentException('Operation rows for an object must be stored contiguously.'); + } + + if ($matches) { + $start ??= $index; + $end = $index + 1; + $matchedRows[] = $row; + continue; + } + + if ($start !== null) { + $leftRange = true; + } + } + + $start ??= count($rows); + $end ??= $start; + + return [ + 'range' => [$start, $end], + 'pos' => $start, + 'rows' => $matchedRows, + ]; + } + + /** + * @param list> $rows + * @return array{range:array{0:int,1:int},pos:int,rows:list>} + */ + public function opSet2OperationRowsForProperty(array $rows, mixed $objectId, string|int $key): array + { + $target = $this->opSet2NormalizeObjectId($objectId); + $key = (string) $key; + $start = null; + $end = null; + $matchedRows = []; + $leftRange = false; + + foreach (array_values($rows) as $index => $row) { + if (! array_key_exists('obj', $row)) { + throw new \InvalidArgumentException('Operation rows must include an obj column for property range iteration.'); + } + + $object = $this->opSet2NormalizeObjectId($row['obj']); + $rowKey = array_key_exists('key', $row) && (is_string($row['key']) || is_int($row['key'])) ? (string) $row['key'] : ''; + $matches = $this->opSet2CompareObjectIds($object, $target) === 0 && $rowKey === $key; + if ($matches && $leftRange) { + throw new \InvalidArgumentException('Operation rows for an object property must be stored contiguously.'); + } + + if ($matches) { + $start ??= $index; + $end = $index + 1; + $matchedRows[] = $row; + continue; + } + + if ($start !== null) { + $leftRange = true; + } + } + + $start ??= count($rows); + $end ??= $start; + + return [ + 'range' => [$start, $end], + 'pos' => $start, + 'rows' => $matchedRows, + ]; + } + + /** + * @param list> $rows + * @return list>> + */ + public function opSet2OperationRowsGroupedByKey(array $rows, mixed $objectId, bool $visibleOnly = false): array + { + $groups = []; + foreach ($this->opSet2OperationRowsForObject($rows, $objectId)['rows'] as $row) { + if ($visibleOnly && ! $this->opSet2OperationRowIsVisible($row)) { + continue; + } + + $key = array_key_exists('key', $row) && (is_string($row['key']) || is_int($row['key'])) ? (string) $row['key'] : ''; + $lastIndex = count($groups) - 1; + $lastKey = $lastIndex >= 0 ? ($groups[$lastIndex][0]['key'] ?? null) : null; + $lastKey = is_string($lastKey) || is_int($lastKey) ? (string) $lastKey : null; + if ($lastKey === $key) { + $groups[$lastIndex][] = $row; + continue; + } + + $groups[] = [$row]; + } + + return $groups; + } + + /** + * @param list> $rows + * @return list> + */ + public function opSet2TopOperationRows(array $rows, mixed $objectId, bool $visibleOnly = false): array + { + $top = []; + foreach ($this->opSet2OperationRowsGroupedByKey($rows, $objectId, $visibleOnly) as $group) { + if ($group === []) { + continue; + } + + $top[] = $group[array_key_last($group)]; + } + + return $top; + } + + /** + * @param list> $rows + * @return list + */ + public function opSet2ParentPath(array $rows, mixed $objectId): array + { + $creators = []; + foreach ($rows as $row) { + if (! is_array($row['id'] ?? null) && ! is_string($row['id'] ?? null)) { + continue; + } + + if (! is_string($row['obj'] ?? null) && ! is_array($row['obj'] ?? null)) { + continue; + } + + $id = $this->opSet2StringOperationId($row['id']); + if (is_string($row['objectType'] ?? null)) { + $creators[$id] = $row; + } + } + + $path = []; + $current = $this->opSet2StringOperationId($objectId); + while (isset($creators[$current])) { + $row = $creators[$current]; + $parentObject = $row['obj']; + $parentObjectId = $this->opSet2StringObjectId($parentObject); + $path[] = [ + 'obj' => $parentObjectId === 'root' ? '_root' : $parentObjectId, + 'prop' => $this->opSet2ParentProp($row), + 'typ' => $parentObjectId === 'root' ? 'map' : (string) ($creators[$parentObjectId]['objectType'] ?? 'map'), + 'visible' => $this->opSet2OperationRowIsVisible($row), + ]; + + if ($parentObjectId === 'root') { + break; + } + + $current = $parentObjectId; + } + + return array_reverse($path); + } + + /** + * @param array $op + */ + private function opSet2OperationKey(array $op): string + { + if (array_key_exists('key', $op) && (is_string($op['key']) || is_int($op['key']))) { + return (string) $op['key']; + } + + if (array_key_exists('elemId', $op) && (is_string($op['elemId']) || is_int($op['elemId']))) { + return (string) $op['elemId']; + } + + if (is_array($op['path'] ?? null)) { + return json_encode(array_values($op['path']), JSON_THROW_ON_ERROR); + } + + return ''; + } + + /** + * @param array $row + */ + private function opSet2OperationRowIsVisible(array $row): bool + { + return ! is_array($row['succs'] ?? null) || count($row['succs']) === 0; + } + + private function opSet2StringOperationId(mixed $operationId): string + { + $id = $this->opSet2NormalizeOperationId($operationId); + + return $id['counter'] . '@' . $id['actor']; + } + + private function opSet2StringObjectId(mixed $objectId): string + { + $id = $this->opSet2NormalizeObjectId($objectId); + if ($id['type'] === 'root') { + return 'root'; + } + + return $id['counter'] . '@' . $id['actor']; + } + + /** + * @param array $row + * @return array{type:string,value:string|int} + */ + private function opSet2ParentProp(array $row): array + { + $key = $row['key'] ?? null; + if (($row['insert'] ?? false) === true || is_int($key)) { + return ['type' => 'seq', 'value' => is_int($key) ? $key : (int) $key]; + } + + return ['type' => 'map', 'value' => is_string($key) ? $key : (string) $key]; + } + + private function nonNullIntColumnValue(?int $value, string $column): int + { + if ($value === null) { + throw new \InvalidArgumentException('Operation column ' . $column . ' cannot contain null entries.'); + } + + return $value; + } + + private function nonNullStringColumnValue(?string $value, string $column): string + { + if ($value === null) { + throw new \InvalidArgumentException('Operation column ' . $column . ' cannot contain null entries.'); + } + + return $value; + } + + /** + * @param array{bytes:string,ranges:array{meta:array{0:int,1:int},raw:array{0:int,1:int}}} $encoded + * @param list> $replacement + * @return array{bytes:string,ranges:array{meta:array{0:int,1:int},raw:array{0:int,1:int}}} + */ + public function columnarSpliceScalarValues(array $encoded, int $start, int $deleteCount, array $replacement): array + { + $values = $this->columnarDecodeScalarValues($encoded); + $count = count($values); + if ($start < 0 || $deleteCount < 0 || $start > $count || $start + $deleteCount > $count) { + throw new \OutOfBoundsException('Scalar value splice range is out of bounds.'); + } + + $replacement = $this->columnarDecodeScalarValues($this->columnarEncodeScalarValues($replacement)); + array_splice($values, $start, $deleteCount, $replacement); + + return $this->columnarEncodeScalarValues($values); + } + + /** + * @return array{0:int,1:int}|null + */ + public function textDiffFindMiddleSnake(string $old, int $oldStart, int $oldEnd, string $new, int $newStart, int $newEnd): ?array + { + [$oldStart, $oldEnd] = $this->normalizeByteRange($old, $oldStart, $oldEnd); + [$newStart, $newEnd] = $this->normalizeByteRange($new, $newStart, $newEnd); + $n = max(0, $oldEnd - $oldStart); + $m = max(0, $newEnd - $newStart); + $delta = $n - $m; + $odd = ($delta & 1) === 1; + $vf = [1 => 0]; + $vb = [1 => 0]; + $dMax = $this->textDiffMaxD($n, $m); + + for ($d = 0; $d < $dMax; ++$d) { + for ($k = $d; $k >= -$d; $k -= 2) { + $x = $k === -$d || ($k !== $d && ($vf[$k - 1] ?? 0) < ($vf[$k + 1] ?? 0)) + ? ($vf[$k + 1] ?? 0) + : ($vf[$k - 1] ?? 0) + 1; + $y = $x - $k; + [$x0, $y0] = [$x, $y]; + if ($x < $n && $y >= 0 && $y < $m) { + $x += $this->textDiffCommonPrefixLen( + $old, + $oldStart + $x, + $oldEnd, + $new, + $newStart + $y, + $newEnd + ); + } + + $vf[$k] = $x; + if ($odd && abs($k - $delta) <= $d - 1 && $x + ($vb[-($k - $delta)] ?? 0) >= $n) { + return [$x0 + $oldStart, $y0 + $newStart]; + } + } + + for ($k = $d; $k >= -$d; $k -= 2) { + $x = $k === -$d || ($k !== $d && ($vb[$k - 1] ?? 0) < ($vb[$k + 1] ?? 0)) + ? ($vb[$k + 1] ?? 0) + : ($vb[$k - 1] ?? 0) + 1; + $y = $x - $k; + if ($x < $n && $y >= 0 && $y < $m) { + $advance = $this->textDiffCommonSuffixLen( + $old, + $oldStart, + $oldStart + $n - $x, + $new, + $newStart, + $newStart + $m - $y + ); + $x += $advance; + $y += $advance; + } + + $vb[$k] = $x; + if (! $odd && abs($k - $delta) <= $d && $x + ($vf[-($k - $delta)] ?? 0) >= $n) { + return [$n - $x + $oldStart, $m - $y + $newStart]; + } + } + } + + return null; + } + + /** + * @param list $path + */ + public function spliceTextEncoded(Document $document, array $path, int $index, int $deleteCount, string $insert, string $encoding): Document + { + $path = array_values($path); + $value = $this->readPath($document, $path); + if (! is_string($value)) { + throw new \InvalidArgumentException('Encoded text splice requires a text value.'); + } + + $rawValue = $this->readRawPath($document, $path); + if ($rawValue instanceof TextValue) { + $measure = $this->encodedTextMeasure($encoding); + [$clusterIndex, $encodedStart] = $rawValue->elementBoundaryAfterMeasuredIndex($index, $measure); + $clusterDeleteCount = 0; + if ($deleteCount > 0) { + [$clusterEnd] = $rawValue->elementBoundaryAfterMeasuredIndex($encodedStart + $deleteCount, $measure); + $clusterDeleteCount = max(0, $clusterEnd - $clusterIndex); + } + + return $this->spliceAtPath($document, $path, $clusterIndex, $clusterDeleteCount, $insert); + } + + [$clusterIndex, $encodedStart] = $this->clusterBoundaryAfterEncodedIndex($value, $index, $encoding); + $clusterDeleteCount = 0; + if ($deleteCount > 0) { + [$clusterEnd] = $this->clusterBoundaryAfterEncodedIndex($value, $encodedStart + $deleteCount, $encoding); + $clusterDeleteCount = max(0, $clusterEnd - $clusterIndex); + } + + return $this->spliceAtPath($document, $path, $clusterIndex, $clusterDeleteCount, $insert); + } + + /** + * @param list $path + */ + public function textAtEncodedIndex(Document $document, array $path, int $index, string $encoding): ?string + { + $path = array_values($path); + $value = $this->readPath($document, $path); + if (! is_string($value)) { + throw new \InvalidArgumentException('Encoded text get requires a text value.'); + } + + $clusters = $this->splitCharacters($value); + $clusterIndex = $this->clusterIndexFromEncodedIndex($value, $index, $encoding); + + return $clusters[$clusterIndex] ?? null; + } + + /** + * @param list $path + */ + public function putTextEncoded(Document $document, array $path, int $index, string $value, string $encoding): Document + { + $path = array_values($path); + $text = $this->readPath($document, $path); + if (! is_string($text)) { + throw new \InvalidArgumentException('Encoded text put requires a text value.'); + } + + $clusterIndex = $this->clusterIndexFromEncodedIndex($text, $index, $encoding); + if (count($path) === 1 && (is_string($path[0]) || is_int($path[0]))) { + $next = $this->mutableClone($document); + $next->putText((string) $path[0], $clusterIndex, $value); + + return $this->preserveFrozen($document, $next); + } + + return $this->spliceAtPath($document, $path, $clusterIndex, 1, $value); + } + + /** + * @param list $path + */ + public function insertTextEncoded(Document $document, array $path, int $index, string $value, string $encoding): Document + { + return $this->spliceTextEncoded($document, $path, $index, 0, $value, $encoding); + } + + /** + * @param list $path + */ + public function deleteTextEncoded(Document $document, array $path, int $index, string $encoding): Document + { + $path = array_values($path); + $text = $this->readPath($document, $path); + if (! is_string($text)) { + throw new \InvalidArgumentException('Encoded text delete requires a text value.'); + } + + return $this->spliceAtPath($document, $path, $this->clusterIndexFromEncodedIndex($text, $index, $encoding), 1, ''); + } + + /** + * @param list $path + */ + public function spliceAtPath(Document $document, array $path, int|string $index, int $deleteCount, string $insert = ''): Document + { + $path = array_values($path); + $this->assertTextOperationTarget($document, $path); + [$index, $deleteLength] = $this->resolveTextSpliceRange($document, $path, $index, $deleteCount); + $deleteIndex = $index; + + if ($deleteLength === 0 && $insert === '') { + return $document; + } + + if (count($path) === 1 && (is_string($path[0]) || is_int($path[0]))) { + $next = $this->mutableClone($document); + $next->spliceText((string) $path[0], $index, $deleteLength, $insert); + + return $this->preserveFrozen($document, $next); + } + + $patches = []; + if ($deleteLength > 0) { + $patches[] = ['action' => 'del', 'path' => array_merge($path, [$deleteIndex]), 'length' => $deleteLength]; + } + + if ($insert !== '') { + $patches[] = ['action' => 'splice', 'path' => array_merge($path, [$index]), 'value' => $insert]; + } + + return $patches === [] ? $document : $this->applyPatches($document, $patches); + } + + /** + * @param list $path + */ + public function updateTextAtPath(Document $document, array $path, string $newText): Document + { + return $this->writePath($document, array_values($path), $newText); + } + + /** + * @param list $path + */ + public function getCursor(Document $document, array $path, int|string $index, string $move = 'after'): string + { + $path = array_values($path); + $text = $this->cursorTextAtPath($document, $path); + $length = $this->utf16Length($text); + $kind = 'index'; + $clusterIndex = 0; + + if ($index === 'start' || (is_int($index) && $index < 0)) { + $kind = 'start'; + $index = 0; + } elseif ($index === 'end' || (is_int($index) && $index >= $length)) { + $kind = 'end'; + $index = $length; + } else { + $index = max(0, min((int) $index, $length)); + $clusterIndex = $this->clusterIndexFromUtf16Index($text, $index); + } + + return base64_encode(json_encode( + [ + 'path' => $path, + 'index' => $clusterIndex, + 'utf16Index' => $index, + 'kind' => $kind, + 'move' => $move === 'before' ? 'before' : 'after', + 'text' => $text, + ], + JSON_THROW_ON_ERROR + )); + } + + /** + * @param list $path + */ + public function getCursorEncoded(Document $document, array $path, int|string $index, string $encoding, string $move = 'after'): string + { + $path = array_values($path); + $text = $this->cursorTextAtPath($document, $path); + $clusters = $this->splitCharacters($text); + $length = $this->encodedIndexForClusterIndex($text, count($clusters), $encoding); + $kind = 'index'; + $clusterIndex = 0; + $encodedIndex = 0; + + if ($index === 'start' || (is_int($index) && $index < 0)) { + $kind = 'start'; + } elseif ($index === 'end' || (is_int($index) && $index >= $length)) { + $kind = 'end'; + $clusterIndex = count($clusters); + $encodedIndex = $length; + } else { + $encodedIndex = max(0, min((int) $index, $length)); + $clusterIndex = $this->clusterIndexFromEncodedIndex($text, $encodedIndex, $encoding); + } + + return base64_encode(json_encode( + [ + 'path' => $path, + 'index' => $clusterIndex, + 'encodedIndex' => $encodedIndex, + 'encoding' => $encoding, + 'kind' => $kind, + 'move' => $move === 'before' ? 'before' : 'after', + 'text' => $text, + ], + JSON_THROW_ON_ERROR + )); + } + + /** + * @param list $path + */ + public function getCursorPosition(Document $document, array $path, string $cursor): int + { + $decoded = json_decode(base64_decode($cursor, true) ?: '', true); + if (! is_array($decoded)) { + return 0; + } + + $path = array_values($path); + $clusterIndex = $this->cursorPositionFromPayload($document, $path, $decoded); + + return $this->utf16IndexForClusterIndex($this->cursorTextAtPath($document, $path), $clusterIndex); + } + + /** + * @param list $path + */ + public function getCursorPositionEncoded(Document $document, array $path, string $cursor, string $encoding): int + { + $decoded = json_decode(base64_decode($cursor, true) ?: '', true); + if (! is_array($decoded)) { + return 0; + } + + $path = array_values($path); + $clusterIndex = $this->cursorPositionFromPayload($document, $path, $decoded); + + return $this->encodedIndexForClusterIndex($this->cursorTextAtPath($document, $path), $clusterIndex, $encoding); + } + + /** + * @param list $path + */ + public function markTextEncoded(Document $document, array $path, int $start, int $end, string $name, mixed $value, string $encoding, string $expand = 'none'): Document + { + $path = array_values($path); + $text = $this->readPath($document, $path); + if (! is_string($text)) { + throw new \InvalidArgumentException('Encoded text mark requires a text value.'); + } + + return $this->mark( + $document, + $path, + $this->clusterIndexFromEncodedIndex($text, $start, $encoding), + $this->clusterIndexFromEncodedIndex($text, $end, $encoding), + $name, + $value, + $expand + ); + } + + /** + * @param list $path + */ + public function unmarkTextEncoded(Document $document, array $path, int $start, int $end, string $name, string $encoding): Document + { + $path = array_values($path); + $text = $this->readPath($document, $path); + if (! is_string($text)) { + throw new \InvalidArgumentException('Encoded text unmark requires a text value.'); + } + + return $this->unmark( + $document, + $path, + $this->clusterIndexFromEncodedIndex($text, $start, $encoding), + $this->clusterIndexFromEncodedIndex($text, $end, $encoding), + $name + ); + } + + /** + * @param list $path + * @return list + */ + public function marksEncoded(Document $document, array $path, string $encoding): array + { + $path = array_values($path); + $text = $this->readPath($document, $path); + if (! is_string($text)) { + throw new \InvalidArgumentException('Encoded text marks require a text value.'); + } + + return array_map( + function (array $mark) use ($text, $encoding): array { + $mark['start'] = $this->encodedIndexForClusterIndex($text, $mark['start'], $encoding); + $mark['end'] = $this->encodedIndexForClusterIndex($text, $mark['end'], $encoding); + + return $mark; + }, + $this->marks($document, $path) + ); + } + + /** + * @param list $path + */ + public function mark(Document $document, array $path, int $start, int $end, string $name, mixed $value, string $expand = 'none'): Document + { + if ($value === null) { + return $this->unmark($document, $path, $start, $end, $name); + } + + $expand = $this->normalizeExpandMode($expand, 'none'); + $next = $this->mutableClone($document); + $next->markText($path, [['name' => $name, 'value' => $value, 'start' => $start, 'end' => $end, 'expand' => $expand]]); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [ + ['action' => 'mark', 'path' => array_values($path), 'marks' => [['name' => $name, 'value' => $value, 'start' => $start, 'end' => $end]]], + ]); + + return $next; + } + + /** + * @param list $path + */ + public function unmark(Document $document, array $path, int $start, int $end, string $name): Document + { + $next = $this->mutableClone($document); + $next->unmarkText($path, $name, $start, $end); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [ + ['action' => 'mark', 'path' => array_values($path), 'marks' => [['name' => $name, 'value' => null, 'start' => $start, 'end' => $end]]], + ]); + + return $next; + } + + /** + * @param list $path + * @param array{parents?:list,type?:string|ImmutableString,attrs?:array} $block + */ + public function splitBlock(Document $document, array $path, int $index, array $block): Document + { + $path = array_values($path); + if (count($path) !== 1 || (! is_string($path[0]) && ! is_int($path[0]))) { + throw new \InvalidArgumentException('splitBlock currently supports a root text path.'); + } + + $index = max(0, $index); + $next = $this->mutableClone($document); + $next->spliceText((string) $path[0], $index, 0, self::BLOCK_CHARACTER); + $next->markText($path, [[ + 'name' => self::BLOCK_MARK_NAME, + 'value' => $this->normalizeBlock($block), + 'start' => $index, + 'end' => $index + 1, + 'expand' => 'none', + ]]); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [[ + 'action' => 'insert', + 'path' => array_merge($path, [$index]), + 'values' => [[]], + ]]); + + return $next; + } + + /** + * @param list $path + * @param array{parents?:list,type?:string|ImmutableString,attrs?:array} $block + */ + public function splitBlockEncoded(Document $document, array $path, int $index, string $encoding, array $block = []): Document + { + $path = array_values($path); + $text = $this->readPath($document, $path); + if (! is_string($text)) { + throw new \InvalidArgumentException('Encoded splitBlock requires a text value.'); + } + + return $this->splitBlock($document, $path, $this->clusterIndexFromEncodedIndex($text, $index, $encoding), $block); + } + + /** + * @param list $path + */ + public function joinBlock(Document $document, array $path, int $index): Document + { + $path = array_values($path); + if (count($path) !== 1 || (! is_string($path[0]) && ! is_int($path[0]))) { + throw new \InvalidArgumentException('joinBlock currently supports a root text path.'); + } + + if ($this->block($document, $path, $index) === null) { + return $document; + } + + $next = $this->mutableClone($document); + $next->spliceText((string) $path[0], max(0, $index), 1, ''); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, [[ + 'action' => 'del', + 'path' => array_merge($path, [max(0, $index)]), + 'length' => 1, + ]]); + + return $next; + } + + /** + * @param list $path + * @return array{parents:list,type:string|ImmutableString,attrs:array}|null + */ + public function block(Document $document, array $path, int $index): ?array + { + foreach ($this->blockMarks($document, $path) as $mark) { + if ($index >= $mark['start'] && $index < $mark['end'] && is_array($mark['value'])) { + return $this->normalizeBlock($mark['value']); + } + } + + return null; + } + + /** + * @param list $path + * @return list}> + */ + public function spans(Document $document, array $path): array + { + $text = $this->readPath($document, array_values($path)); + if (! is_string($text) || $text === '') { + return []; + } + + $blocksByIndex = []; + foreach ($this->blockMarks($document, $path) as $mark) { + if (is_array($mark['value'])) { + $blocksByIndex[(int) $mark['start']] = $this->normalizeBlock($mark['value']); + } + } + + $spans = []; + $buffer = ''; + $bufferMarks = []; + foreach ($this->splitCharacters($text) as $index => $character) { + if ($character === self::BLOCK_CHARACTER && isset($blocksByIndex[$index])) { + if ($buffer !== '') { + $span = ['type' => 'text', 'value' => $buffer]; + if ($bufferMarks !== []) { + $span['marks'] = $bufferMarks; + } + $spans[] = $span; + $buffer = ''; + $bufferMarks = []; + } + + $spans[] = ['type' => 'block', 'value' => $blocksByIndex[$index]]; + continue; + } + + $marks = $this->marksAt($document, $path, $index); + if ($buffer !== '' && $marks !== $bufferMarks) { + $span = ['type' => 'text', 'value' => $buffer]; + if ($bufferMarks !== []) { + $span['marks'] = $bufferMarks; + } + $spans[] = $span; + $buffer = ''; + } + + $buffer .= $character; + $bufferMarks = $marks; + } + + if ($buffer !== '') { + $span = ['type' => 'text', 'value' => $buffer]; + if ($bufferMarks !== []) { + $span['marks'] = $bufferMarks; + } + $spans[] = $span; + } + + return $spans; + } + + /** + * @param list $path + * @param list}> $spans + * @param array{defaultExpand?:string,perMarkExpand?:array} $options + */ + public function updateSpans(Document $document, array $path, array $spans, array $options = []): Document + { + $path = array_values($path); + if (count($path) !== 1 || (! is_string($path[0]) && ! is_int($path[0]))) { + throw new \InvalidArgumentException('updateSpans currently supports a root text path.'); + } + + $text = ''; + $marks = []; + $index = 0; + $beforeBlocks = $this->blockValuesByIndex($document, $path); + $desiredSpans = []; + foreach ($spans as $span) { + if (($span['type'] ?? null) === 'block' && is_array($span['value'] ?? null)) { + $block = $this->normalizeBlock($span['value']); + $text .= self::BLOCK_CHARACTER; + $marks[] = [ + 'name' => self::BLOCK_MARK_NAME, + 'value' => $block, + 'start' => $index, + 'end' => $index + 1, + 'expand' => 'none', + ]; + $desiredSpans[] = ['type' => 'block', 'value' => $block]; + ++$index; + continue; + } + + if (($span['type'] ?? null) !== 'text') { + continue; + } + + $value = is_string($span['value'] ?? null) ? $span['value'] : ''; + $start = $index; + $text .= $value; + $index += count($this->splitCharacters($value)); + $spanMarks = is_array($span['marks'] ?? null) ? $span['marks'] : []; + $desiredSpan = ['type' => 'text', 'value' => $value]; + if ($spanMarks !== []) { + $desiredSpan['marks'] = $spanMarks; + } + $desiredSpans[] = $desiredSpan; + + if ($spanMarks === []) { + continue; + } + + foreach ($spanMarks as $name => $markValue) { + if (! is_string($name) || $start === $index) { + continue; + } + + $marks[] = [ + 'name' => $name, + 'value' => $markValue, + 'start' => $start, + 'end' => $index, + 'expand' => $this->expandForMark($name, $options), + ]; + } + } + + if ($this->spans($document, $path) === $desiredSpans) { + return $document; + } + + $next = $this->mutableClone($document); + $next->replaceTextAndMarks((string) $path[0], $text, $marks); + $next = $this->preserveFrozen($document, $next); + $this->emitPatches($document, $next, $this->blockUpdateSpanPatches($path, $beforeBlocks, $this->blockValuesByIndex($next, $path))); + + return $next; + } + + /** + * @param list $path + * @return array + */ + public function marksAt(Document $document, array $path, int $index): array + { + $active = []; + foreach ($this->marks($document, $path) as $mark) { + if ($mark['start'] <= $index && $index < $mark['end']) { + $active[$mark['name']] = $mark['value']; + } + } + + ksort($active, SORT_STRING); + + return $active; + } + + /** + * @param list $path + * @param list $heads + * @return array + */ + public function marksAtHeads(Document $document, array $path, int $index, array $heads): array + { + $this->validateDiffHeads($document, $heads, 'marksAtHeads'); + + return $this->marksAt($document->view(array_values($heads)), $path, $index); + } + + public function mergeDocuments(Document $left, Document $right): Document + { + $merged = $left->merge($right); + $this->emitDocumentPatchCallback($left, $merged, 'merge'); + + return $merged; + } + + /** + * @param list $heads + */ + public function setAtHeads(Document $document, array $heads, string $key, mixed $value): Document + { + $this->validateDiffHeads($document, $heads, 'changeAt'); + + $next = $this->mutableClone($document); + $next->setAtHeads($heads, $key, $value); + $next = $this->preserveFrozen($document, $next); + $this->emitDocumentPatchCallback($document, $next, 'changeAt'); + + return $next; + } + + /** + * @param list $heads + */ + public function spliceAtHeads(Document $document, array $heads, string $key, int $index, int $deleteCount, string $insert = ''): Document + { + $this->validateDiffHeads($document, $heads, 'changeAt'); + + $historical = $document->view($heads)->withFrozen(false); + $historical->ensureSequenceAtLeast($document->stats()['sequence']); + $historical->spliceText($key, $index, $deleteCount, $insert); + + $next = $this->preserveFrozen($document, $document->merge($historical)); + $this->emitDocumentPatchCallback($document, $next, 'changeAt'); + + return $next; + } + + /** + * @param list $heads + */ + public function updateTextAtHeads(Document $document, array $heads, string $key, string $newText): Document + { + $this->validateDiffHeads($document, $heads, 'changeAt'); + + $historical = $document->view($heads)->withFrozen(false); + $historical->ensureSequenceAtLeast($document->stats()['sequence']); + $historical->updateText($key, $newText); + + $next = $this->preserveFrozen($document, $document->merge($historical)); + $this->emitDocumentPatchCallback($document, $next, 'changeAt'); + + return $next; + } + + /** + * @param list $heads + */ + public function emptyChangeAtHeads(Document $document, array $heads): Document + { + $this->validateDiffHeads($document, $heads, 'changeAt'); + + return $document; + } + + /** + * @param list> $patches + */ + public function applyPatches(Document $document, array $patches): Document + { + $next = $document; + foreach ($patches as $patch) { + if (! is_string($patch['action'] ?? null) || ! is_array($patch['path'] ?? null)) { + continue; + } + + $path = array_values($patch['path']); + if ($patch['action'] === 'put') { + $next = $this->writePath($next, $path, $patch['value'] ?? null); + continue; + } + + if ($patch['action'] === 'mark') { + $marks = is_array($patch['marks'] ?? null) ? array_values($patch['marks']) : []; + if ($marks !== []) { + $next = $this->mutableClone($next); + $addMarks = []; + foreach ($marks as $mark) { + if (! is_array($mark) || ! is_string($mark['name'] ?? null)) { + continue; + } + + if (array_key_exists('value', $mark) && $mark['value'] === null) { + $next->unmarkText( + $path, + $mark['name'], + (int) ($mark['start'] ?? 0), + (int) ($mark['end'] ?? $mark['start'] ?? 0) + ); + continue; + } + + $addMarks[] = $mark; + } + + if ($addMarks !== []) { + $next->markText($path, $addMarks); + } + $next = $this->preserveFrozen($document, $next); + } + + continue; + } + + if ($patch['action'] === 'unmark' && is_string($patch['name'] ?? null)) { + $next = $this->mutableClone($next); + $next->unmarkText( + $path, + $patch['name'], + (int) ($patch['start'] ?? 0), + (int) ($patch['end'] ?? $patch['start'] ?? 0) + ); + $next = $this->preserveFrozen($document, $next); + continue; + } + + if ($patch['action'] === 'inc') { + $current = $this->readPath($next, $path); + $amount = $patch['value'] ?? null; + if ($current instanceof Counter && is_int($amount)) { + $next = $this->incrementCounter($next, $path, $amount); + continue; + } + + if ((is_int($current) || is_float($current)) && (is_int($amount) || is_float($amount))) { + $next = $this->writePath($next, $path, $current + $amount); + } + + continue; + } + + if ( + ($patch['action'] === 'insert' || $patch['action'] === 'splice' || $patch['action'] === 'del') + && count($path) >= 2 + ) { + $index = array_pop($path); + if (! is_int($index)) { + continue; + } + + $value = $this->readPath($next, $path); + if (is_array($value)) { + if ($patch['action'] === 'splice') { + continue; + } + + if ($patch['action'] === 'insert') { + $values = is_array($patch['values'] ?? null) ? array_values($patch['values']) : []; + array_splice($value, $index, 0, $values); + } else { + $length = max(1, (int) ($patch['length'] ?? 1)); + array_splice($value, $index, $length); + } + + $next = $this->writePath($next, $path, array_values($value)); + continue; + } + + if (is_string($value) && ($patch['action'] === 'splice' || $patch['action'] === 'del')) { + $characters = $this->splitCharacters($value); + if ($patch['action'] === 'splice') { + $insert = is_string($patch['value'] ?? null) ? $this->splitCharacters($patch['value']) : []; + array_splice($characters, $index, 0, $insert); + } else { + $length = max(1, (int) ($patch['length'] ?? 1)); + array_splice($characters, $index, $length); + } + + $next = $this->writePath($next, $path, implode('', $characters)); + continue; + } + } + } + + return $next; + } + + /** + * @param array $document + * @param list> $patches + * @return array + */ + public function applyPatchesToArray(array $document, array $patches): array + { + $next = $document; + foreach ($patches as $patch) { + if (! is_string($patch['action'] ?? null) || ! is_array($patch['path'] ?? null)) { + continue; + } + + $path = array_values($patch['path']); + if ($patch['action'] === 'put') { + $next = $this->writeArrayPath($next, $path, $patch['value'] ?? null); + continue; + } + + if ($patch['action'] === 'inc') { + $current = $this->readArrayPath($next, $path); + $amount = $patch['value'] ?? null; + if ((is_int($current) || is_float($current)) && (is_int($amount) || is_float($amount))) { + $next = $this->writeArrayPath($next, $path, $current + $amount); + } + + continue; + } + + if ( + ($patch['action'] === 'insert' || $patch['action'] === 'splice' || $patch['action'] === 'del') + && count($path) >= 2 + ) { + $index = array_pop($path); + if (! is_int($index)) { + continue; + } + + $value = $this->readArrayPath($next, $path); + if (is_array($value)) { + if ($patch['action'] === 'splice') { + continue; + } + + if ($patch['action'] === 'insert') { + $values = is_array($patch['values'] ?? null) ? array_values($patch['values']) : []; + array_splice($value, $index, 0, $values); + } else { + $length = max(1, (int) ($patch['length'] ?? 1)); + array_splice($value, $index, $length); + } + + $next = $this->writeArrayPath($next, $path, array_values($value)); + continue; + } + + if (is_string($value) && ($patch['action'] === 'splice' || $patch['action'] === 'del')) { + $characters = $this->splitCharacters($value); + if ($patch['action'] === 'splice') { + $insert = is_string($patch['value'] ?? null) ? $this->splitCharacters($patch['value']) : []; + array_splice($characters, $index, 0, $insert); + } else { + $length = max(1, (int) ($patch['length'] ?? 1)); + array_splice($characters, $index, $length); + } + + $next = $this->writeArrayPath($next, $path, implode('', $characters)); + continue; + } + } + } + + return $next; + } + + /** + * @return list + */ + public function getHeads(Document $document): array + { + return $document->heads(); + } + + /** + * @param list $heads + */ + public function hasHeads(Document $document, array $heads): bool + { + return $document->hasHeads($heads); + } + + public function emptyChange(Document $document, ?string $message = null): Document + { + $next = $this->mutableClone($document); + $next->emptyChange($message); + + return $this->preserveFrozen($document, $next); + } + + public function emptyChangeWithTime(Document $document, int $time, ?string $message = null): Document + { + $next = $this->mutableClone($document); + $next->emptyChange($message, $time); + + return $this->preserveFrozen($document, $next); + } + + public function emptyChangeWithoutTime(Document $document, ?string $message = null): Document + { + return $this->emptyChangeWithTime($document, 0, $message); + } + + /** + * @return list> + */ + public function getAllChanges(Document $document): array + { + return $document->getAllChanges(); + } + + /** + * @param list $heads + * @return list> + */ + public function getChangesSince(Document $document, array $heads): array + { + return $document->getChangesSince($heads); + } + + /** + * @return list> + */ + public function getChanges(Document $before, Document $after): array + { + return $after->getChangesSince($before->heads()); + } + + /** + * @param list $heads + * @return list + */ + public function getMissingDeps(Document $document, array $heads = []): array + { + $known = array_fill_keys($this->normalizeHeads($heads), true); + foreach ($document->getAllChanges() as $change) { + if (is_string($change['hash'] ?? null)) { + $known[$change['hash']] = true; + } + } + + $missing = []; + foreach ($document->getAllChanges() as $change) { + foreach ($this->normalizeHeads($change['deps'] ?? []) as $dep) { + if (! isset($known[$dep]) && ! isset($missing[$dep])) { + $missing[$dep] = true; + } + } + } + + return array_keys($missing); + } + + /** + * @return list> + */ + public function diff(Document $document, mixed $beforeHeads, mixed $afterHeads): array + { + $beforeHeads = $this->validateDiffHeads($document, $beforeHeads, 'before'); + $afterHeads = $this->validateDiffHeads($document, $afterHeads, 'after'); + $beforeView = $document->view($beforeHeads); + $afterView = $document->view($afterHeads); + + return $this->diffValues( + $beforeView->toArray(), + $afterView->toArray(), + [], + $this->replacementContainerPaths($document, $beforeHeads, $afterHeads), + $this->marksByPathKey($afterView) + ); + } + + /** + * @return list + */ + public function diffCursor(Document $document): array + { + if (! $this->documentDiffCursors->contains($document)) { + return []; + } + + $cursor = $this->documentDiffCursors[$document]; + + return is_array($cursor) ? array_values($cursor) : []; + } + + public function updateDiffCursor(Document $document): void + { + $this->documentDiffCursors[$document] = $document->heads(); + } + + /** + * @return list> + */ + public function diffIncremental(Document $document): array + { + $heads = $document->heads(); + $patches = $this->diff($document, $this->diffCursor($document), $heads); + $this->updateDiffCursor($document); + + return $patches; + } + + /** + * @param list $textPath + * @return list> + */ + public function diffIncrementalEncoded(Document $document, array $textPath, string $encoding): array + { + $patches = $this->patchesForChanges($document, $document->getChangesSince($this->diffCursor($document))); + $this->updateDiffCursor($document); + + return $this->encodeTextPatchIndexes($document, $patches, array_values($textPath), $encoding); + } + + /** + * @param list $path + * @param array{recursive?:bool} $options + * @return list> + */ + public function diffPath(Document $document, array $path, mixed $beforeHeads, mixed $afterHeads, array $options = []): array + { + $beforeHeads = $this->validateDiffHeads($document, $beforeHeads, 'before'); + $afterHeads = $this->validateDiffHeads($document, $afterHeads, 'after'); + $path = array_values($path); + + $before = $this->readArrayPath($document->view($beforeHeads)->toArray(), $path); + $after = $this->readArrayPath($document->view($afterHeads)->toArray(), $path); + $replacementPaths = $this->replacementContainerPaths($document, $beforeHeads, $afterHeads); + $patches = $before === null && is_array($after) + ? $this->diffContainerContents($after, $path, $replacementPaths) + : $this->diffValues($before, $after, $path, $replacementPaths); + + if (($options['recursive'] ?? true) === false) { + $maxDepth = count($path) + 1; + $patches = array_values(array_filter( + $patches, + static fn (array $patch): bool => is_array($patch['path'] ?? null) && count($patch['path']) <= $maxDepth + )); + } + + return $patches; + } + + /** + * @return array|null + */ + public function getConflicts(mixed $object, string|int $key): ?array + { + if ($object instanceof Document) { + return $object->conflictsFor($key); + } + + return null; + } + + /** + * @return array|null + */ + public function getListElementConflicts(Document $document, string $key, int $index): ?array + { + return $document->listElementConflictsFor($key, $index); + } + + /** + * @param list $path + * @return list + */ + public function marks(Document $document, array $path): array + { + return array_values(array_filter( + $document->marksFor($path), + static fn (array $mark): bool => ($mark['name'] ?? null) !== self::BLOCK_MARK_NAME + )); + } + + /** + * @param array{readOnly?:bool,peerReadOnly?:bool,requestedHeads?:list,needHeads?:list,inFlightHashes?:list,theirCapabilities?:list|null} $options + * @return array{sentHeads:list|null,lastSentHeads:list|null,lastSentReadOnly:bool|null,receivedHeads:list|null,sharedHeads:list,requestedHeads:list,needHeads:list,inFlightHashes:list,readOnly:bool,peerReadOnly:bool,theirCapabilities:list|null} + */ + public function initSyncState(array $options = []): array + { + $theirCapabilities = array_key_exists('theirCapabilities', $options) + ? $this->normalizeSyncCapabilities($options['theirCapabilities']) + : ['syncReset']; + + return [ + 'sentHeads' => null, + 'lastSentHeads' => null, + 'lastSentReadOnly' => null, + 'receivedHeads' => null, + 'sharedHeads' => [], + 'requestedHeads' => $this->normalizeHeads($options['requestedHeads'] ?? []), + 'needHeads' => $this->normalizeHeads($options['needHeads'] ?? []), + 'inFlightHashes' => $this->normalizeHeads($options['inFlightHashes'] ?? []), + 'readOnly' => (bool) ($options['readOnly'] ?? false), + 'peerReadOnly' => (bool) ($options['peerReadOnly'] ?? false), + 'theirCapabilities' => $theirCapabilities, + ]; + } + + /** + * @param array $syncState + */ + public function encodeSyncState(array $syncState): string + { + return json_encode( + [ + 'format' => 'wordpress-de/automerge-php-native-sync-state-v1', + 'state' => $this->normalizeSyncState($syncState), + ], + JSON_THROW_ON_ERROR + ); + } + + /** + * @return array + */ + public function decodeSyncState(string $payload): array + { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + if ( + ! is_array($decoded) + || ($decoded['format'] ?? null) !== 'wordpress-de/automerge-php-native-sync-state-v1' + || ! is_array($decoded['state'] ?? null) + ) { + throw new \InvalidArgumentException('Unsupported native Automerge PHP sync-state payload.'); + } + + return $this->normalizeSyncState($decoded['state']); + } + + /** + * @param array|null $message + */ + public function encodeSyncMessage(?array $message): string + { + return json_encode( + [ + 'format' => 'wordpress-de/automerge-php-native-sync-message-v1', + 'message' => $this->decodeSyncMessage($message), + ], + JSON_THROW_ON_ERROR + ); + } + + /** + * @return array{heads:list,need:list,have:list,bloom:array{byteLength:int},hashes:list}>,changes:list>,readOnly:bool,syncReset:bool}|null + */ + public function decodeEncodedSyncMessage(string $payload): ?array + { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + if ( + ! is_array($decoded) + || ($decoded['format'] ?? null) !== 'wordpress-de/automerge-php-native-sync-message-v1' + || ! array_key_exists('message', $decoded) + ) { + throw new \InvalidArgumentException('Unsupported native Automerge PHP sync-message payload.'); + } + + $message = $decoded['message']; + if ($message !== null && ! is_array($message)) { + throw new \InvalidArgumentException('Unsupported native Automerge PHP sync-message payload.'); + } + + return $this->decodeSyncMessage($message); + } + + /** + * @param array $syncState + * @return array{0:array,1:array|null} + */ + public function generateSyncMessage(Document $document, array $syncState): array + { + $state = $this->normalizeSyncState($syncState); + $heads = $document->heads(); + $requestedChanges = $this->changesMatchingRequestedHeads($document, $state['requestedHeads']); + $needHeads = $state['needHeads']; + $readOnlyChanged = $state['lastSentReadOnly'] !== null + && $state['lastSentReadOnly'] !== $state['readOnly']; + $oldPeerReadWriteFallback = $state['lastSentReadOnly'] === true + && ! $state['readOnly'] + && $state['theirCapabilities'] === null; + $syncReset = $state['lastSentReadOnly'] === true + && ! $state['readOnly'] + && ! $oldPeerReadWriteFallback; + $messageHeads = $oldPeerReadWriteFallback ? [] : $heads; + + $outgoingChanges = $state['peerReadOnly'] + ? [] + : ($requestedChanges !== [] ? $requestedChanges : $this->changesWithDependenciesSince($document, $state['receivedHeads'] ?? [])); + $requestedChangesForPeer = $state['peerReadOnly'] ? [] : $requestedChanges; + if ($requestedChangesForPeer === [] && $state['inFlightHashes'] !== []) { + $inFlightHashes = array_fill_keys($state['inFlightHashes'], true); + $outgoingChanges = array_values(array_filter( + $outgoingChanges, + static fn (array $change): bool => ! is_string($change['hash'] ?? null) || ! isset($inFlightHashes[$change['hash']]) + )); + } + $outgoingHashes = array_values(array_filter( + array_map(static fn (array $change): mixed => $change['hash'] ?? null, $outgoingChanges), + static fn (mixed $hash): bool => is_string($hash) + )); + + if ( + $requestedChangesForPeer === [] + && $needHeads === [] + && ! $readOnlyChanged + && $state['sentHeads'] === $heads + && ( + $state['readOnly'] + || $state['peerReadOnly'] + || $state['receivedHeads'] === null + || $state['receivedHeads'] === $heads + ) + ) { + $state['sentHeads'] = $heads; + $state['lastSentHeads'] = $heads; + $state['lastSentReadOnly'] = $state['readOnly']; + return [$state, null]; + } + + $message = [ + 'heads' => $messageHeads, + 'need' => $needHeads, + 'have' => [ + [ + 'lastSync' => $state['sharedHeads'], + 'bloom' => ['byteLength' => 0], + 'hashes' => $outgoingHashes, + ], + ], + 'changes' => $outgoingChanges, + 'readOnly' => $state['readOnly'], + 'syncReset' => $syncReset, + ]; + $state['sentHeads'] = $heads; + $state['lastSentHeads'] = $heads; + $state['lastSentReadOnly'] = $state['readOnly']; + $state['requestedHeads'] = []; + $state['inFlightHashes'] = []; + + return [$state, $message]; + } + + /** + * @param array $syncState + * @return array{0:array,1:array|null} + */ + public function generateSyncMessageV1(Document $document, array $syncState): array + { + [$state, $message] = $this->generateSyncMessage($document, $syncState); + + return [$state, $this->syncMessageToV1($message)]; + } + + /** + * @param array $syncState + * @param array|null $message + * @return array{0:Document,1:array} + */ + public function receiveSyncMessage(Document $document, array $syncState, ?array $message, ?callable $patchCallback = null): array + { + $state = $this->normalizeSyncState($syncState); + $decoded = $this->decodeSyncMessage($message); + if ($decoded === null) { + return [$document, $state]; + } + + $heads = $this->normalizeHeads($decoded['heads'] ?? []); + $need = $this->normalizeHeads($decoded['need'] ?? []); + $alreadyHadHeads = $document->heads() === $heads; + $changes = is_array($decoded['changes'] ?? null) ? array_values($decoded['changes']) : []; + $next = $state['readOnly'] + ? $document + : ($changes === [] ? $document->clone() : Document::applyChanges($document, $changes)); + $missingHeads = $state['readOnly'] ? [] : $this->missingHeads($next, $heads); + $missingDeps = $state['readOnly'] ? [] : $this->getMissingDeps($next); + $needHeads = $this->normalizeHeads(array_merge($missingHeads, $missingDeps)); + $sharedHeads = $needHeads === [] ? $this->minimalKnownHeads($next, $heads) : $next->heads(); + $state['receivedHeads'] = $sharedHeads; + $state['sharedHeads'] = $sharedHeads; + $state['requestedHeads'] = $need; + $state['needHeads'] = $needHeads; + $state['inFlightHashes'] = $this->normalizeHeads(array_merge( + $state['inFlightHashes'], + $this->hashesFromSyncHave($decoded['have'] ?? []) + )); + $state['peerReadOnly'] = (bool) ($decoded['readOnly'] ?? false); + if ((bool) ($decoded['syncReset'] ?? false)) { + $state['sentHeads'] = null; + $state['lastSentHeads'] = null; + $state['lastSentReadOnly'] = null; + } + if ($alreadyHadHeads && $state['lastSentHeads'] !== null) { + $state['sentHeads'] = $heads; + $state['lastSentHeads'] = $heads; + } + if ($patchCallback !== null) { + $patches = $this->patchesBetweenDocuments($document, $next); + if ($patches !== []) { + $patchCallback($patches, ['before' => $document, 'after' => $next, 'source' => 'receiveSyncMessage']); + } + $this->documentPatchCallbacks[$next] = $patchCallback; + } else { + $this->emitDocumentPatchCallback($document, $next, 'receiveSyncMessage'); + } + + return [$next, $state]; + } + + /** + * @param array $syncState + * @param array|null $message + * @return array{0:Document,1:array} + */ + public function receiveSyncMessageV1(Document $document, array $syncState, ?array $message): array + { + return $this->receiveSyncMessage($document, $syncState, $message === null ? null : $this->decodeSyncMessage($message)); + } + + /** + * @param array $syncState + * @param array|null $message + * @return array{0:Document,1:array,2:list>} + */ + public function receiveSyncMessageLogPatches(Document $document, array $syncState, ?array $message): array + { + $patches = []; + [$next, $state] = $this->receiveSyncMessage( + $document, + $syncState, + $message, + static function (array $receivedPatches) use (&$patches): void { + foreach ($receivedPatches as $patch) { + if (is_array($patch)) { + $patches[] = $patch; + } + } + } + ); + + return [$next, $state, $patches]; + } + + /** + * @return array{active:bool,documentHeads:list|null,patches:list>} + */ + public function initPatchLog(): array + { + return ['active' => true, 'documentHeads' => null, 'patches' => []]; + } + + /** + * @param list> $changes + * @param array $patchLog + * @return array{0:Document,1:array{active:bool,documentHeads:list|null,patches:list>},2:list>} + */ + public function applyChangesLogPatches(Document $document, array $changes, array $patchLog): array + { + $log = $this->normalizePatchLog($patchLog); + $this->assertPatchLogMatchesDocument($document, $log); + + $next = Document::applyChanges($document, $changes); + $patches = $this->patchesBetweenDocuments($document, $next); + array_push($log['patches'], ...$patches); + $log['documentHeads'] = $next->heads(); + + return [$next, $log, $patches]; + } + + /** + * @param array $patchLog + * @return list> + */ + public function makePatchesFromLog(array $patchLog): array + { + return $this->compactAdjacentListInsertPatches($this->normalizePatchLog($patchLog)['patches']); + } + + /** + * @param array $patchLog + */ + public function transactionLogPatches(Document $document, array $patchLog): Transaction + { + $this->assertPatchLogMatchesDocument($document, $this->normalizePatchLog($patchLog)); + + return new Transaction($document, $this); + } + + /** + * @param array $patchLog + * @param list $heads + */ + public function transactionAtLogPatches(Document $document, array $patchLog, array $heads): Transaction + { + $this->assertPatchLogMatchesDocument($document, $this->normalizePatchLog($patchLog)); + + return $this->transactionAt($document, $heads); + } + + /** + * @param array $patchLog + */ + public function intoTransactionLogPatches(Document $document, array $patchLog): Transaction + { + $this->assertPatchLogMatchesDocument($document, $this->normalizePatchLog($patchLog)); + + return new Transaction($document, $this); + } + + /** + * @param array $syncState + */ + public function hasOurChanges(Document $document, array $syncState): bool + { + $state = $this->normalizeSyncState($syncState); + $heads = $document->heads(); + + return $state['sentHeads'] === $heads + || $state['receivedHeads'] === $heads + || $state['sharedHeads'] === $heads; + } + + /** + * @param array|null $message + * @return array{heads:list,need:list,have:list,bloom:array{byteLength:int},hashes:list}>,changes:list>,readOnly:bool,syncReset:bool}|null + */ + public function decodeSyncMessage(?array $message): ?array + { + if ($message === null) { + return null; + } + + $have = []; + foreach (is_array($message['have'] ?? null) ? $message['have'] : [] as $entry) { + if (! is_array($entry)) { + continue; + } + + $byteLength = 0; + if (is_array($entry['bloom'] ?? null)) { + $byteLength = (int) ($entry['bloom']['byteLength'] ?? 0); + } + + $have[] = [ + 'lastSync' => $this->normalizeHeads($entry['lastSync'] ?? []), + 'bloom' => ['byteLength' => $byteLength], + 'hashes' => $this->normalizeHeads($entry['hashes'] ?? []), + ]; + } + + return [ + 'heads' => $this->normalizeHeads($message['heads'] ?? []), + 'need' => $this->normalizeHeads($message['need'] ?? []), + 'have' => $have, + 'changes' => is_array($message['changes'] ?? null) ? array_values($message['changes']) : [], + 'readOnly' => (bool) ($message['readOnly'] ?? false), + 'syncReset' => (bool) ($message['syncReset'] ?? false), + ]; + } + + /** + * @param array|null $message + * @return array{heads:list,need:list,have:list,bloom:array{byteLength:int}}>,changes:list>}|null + */ + public function syncMessageToV1(?array $message): ?array + { + $decoded = $this->decodeSyncMessage($message); + if ($decoded === null) { + return null; + } + + $have = []; + foreach ($decoded['have'] as $entry) { + $have[] = [ + 'lastSync' => $entry['lastSync'], + 'bloom' => $entry['bloom'], + ]; + } + + return [ + 'heads' => $decoded['heads'], + 'need' => $decoded['need'], + 'have' => $have, + 'changes' => $decoded['changes'], + ]; + } + + /** + * @param list $heads + * @return list> + */ + public function getChangesMetaSince(Document $document, array $heads): array + { + return $document->getChangesMetaSince($heads); + } + + /** + * @return list,snapshot:Document}> + */ + public function getHistory(Document $document): array + { + return $document->getHistory(); + } + + /** + * @return list + */ + public function topoHistoryTraversal(Document $document): array + { + $hashes = []; + foreach ($document->getAllChanges() as $change) { + if (is_string($change['hash'] ?? null)) { + $hashes[] = $change['hash']; + } + } + + return $hashes; + } + + /** + * @return array + */ + public function decodeChange(array $change): array + { + return $change; + } + + /** + * @return array|null + */ + public function getLastLocalChange(Document $document): ?array + { + return $document->getLastLocalChange(); + } + + /** + * @return array|null + */ + public function inspectChange(Document $document, string $hash): ?array + { + return $document->inspectChange($hash); + } + + public function save(Document $document): string + { + return $document->save(); + } + + /** + * @param array{retainOrphans?:bool} $options + */ + public function saveWithOptions(Document $document, array $options = []): string + { + if (($options['retainOrphans'] ?? true) === false) { + return $document->saveWithoutOrphanedChanges(); + } + + return $this->save($document); + } + + public function saveIncremental(Document $document): string + { + return $document->saveIncremental(); + } + + /** + * @param list $heads + */ + public function saveSince(Document $document, array $heads): string + { + return $document->saveSince($heads); + } + + /** + * @param list $changeHashes + */ + public function saveBundle(Document $document, array $changeHashes): string + { + $changesByHash = []; + foreach ($document->getAllChanges() as $change) { + if (is_string($change['hash'] ?? null)) { + $changesByHash[$change['hash']] = $change; + } + } + + $selected = []; + $rawChanges = []; + $inspectableChanges = []; + $deps = []; + foreach ($changeHashes as $hash) { + if (! is_string($hash)) { + throw new \InvalidArgumentException('Bundle change hashes must be strings.'); + } + + if (isset($selected[$hash])) { + continue; + } + + if (! isset($changesByHash[$hash])) { + throw new \InvalidArgumentException('Cannot save bundle for unknown change hash.'); + } + + $selected[$hash] = true; + $change = $changesByHash[$hash]; + $rawChanges[] = $change; + $inspectableChanges[] = $document->inspectChange($hash) ?? $change; + } + + foreach ($rawChanges as $change) { + foreach (is_array($change['deps'] ?? null) ? $change['deps'] : [] as $dep) { + if (is_string($dep) && ! isset($selected[$dep])) { + $deps[$dep] = true; + } + } + } + + $depList = array_keys($deps); + sort($depList, SORT_STRING); + + return json_encode( + [ + 'format' => 'wordpress-de/automerge-php-native-bundle-v1', + 'deps' => $depList, + 'changes' => $inspectableChanges, + 'rawChanges' => $rawChanges, + ], + JSON_THROW_ON_ERROR + ); + } + + /** + * @return array{changes:list>,deps:list,rawChanges:list>} + */ + public function readBundle(string $payload): array + { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + if (! is_array($decoded) || ($decoded['format'] ?? null) !== 'wordpress-de/automerge-php-native-bundle-v1') { + throw new \InvalidArgumentException('Unsupported native Automerge PHP bundle payload.'); + } + + return [ + 'changes' => is_array($decoded['changes'] ?? null) ? array_values($decoded['changes']) : [], + 'deps' => $this->normalizeHeads($decoded['deps'] ?? []), + 'rawChanges' => is_array($decoded['rawChanges'] ?? null) ? array_values($decoded['rawChanges']) : [], + ]; + } + + public function loadIncremental(Document $document, string $payload): Document + { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + if (! is_array($decoded) || ! is_string($decoded['format'] ?? null)) { + throw new \InvalidArgumentException('Unsupported native Automerge PHP incremental payload.'); + } + + if ($decoded['format'] === 'wordpress-de/automerge-php-native-v1') { + $after = Document::applyChanges($document, Document::load($payload)->getAllChanges()); + $this->emitDocumentPatchCallback($document, $after, 'loadIncremental'); + + return $after; + } + + if ($decoded['format'] === 'wordpress-de/automerge-php-native-bundle-v1') { + $changes = is_array($decoded['rawChanges'] ?? null) ? array_values($decoded['rawChanges']) : []; + + $after = Document::applyChanges($document, $changes); + $this->emitDocumentPatchCallback($document, $after, 'loadIncremental'); + + return $after; + } + + if ($decoded['format'] === 'wordpress-de/automerge-php-native-incremental-v1') { + $changes = is_array($decoded['changes'] ?? null) ? array_values($decoded['changes']) : []; + + $after = Document::applyChanges($document, $changes); + $this->emitDocumentPatchCallback($document, $after, 'loadIncremental'); + + return $after; + } + + throw new \InvalidArgumentException('Unsupported native Automerge PHP incremental payload.'); + } + + /** + * @return array{document:Document,loadedChanges:int,bytesConsumed:int,trailingBytes:string} + */ + public function loadIncrementalPrefix(Document $document, string $payload): array + { + $prefixLength = $this->jsonObjectPrefixLength($payload); + $prefix = substr($payload, 0, $prefixLength); + $beforeChanges = count($document->getAllChanges()); + $after = $this->loadIncremental($document, $prefix); + + return [ + 'document' => $after, + 'loadedChanges' => max(0, count($after->getAllChanges()) - $beforeChanges), + 'bytesConsumed' => $prefixLength, + 'trailingBytes' => substr($payload, $prefixLength), + ]; + } + + public function load(string $payload, ?string $actorId = null): Document + { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + if (is_array($decoded) && ($decoded['format'] ?? null) === 'wordpress-de/automerge-php-native-incremental-v1') { + $changes = is_array($decoded['changes'] ?? null) ? array_values($decoded['changes']) : []; + foreach ($changes as $change) { + if (is_array($change) && $this->normalizeHeads($change['deps'] ?? []) !== []) { + throw new \InvalidArgumentException('Cannot load incremental change with missing dependencies.'); + } + } + + return Document::applyChanges($this->init($actorId), $changes); + } + + return Document::load($payload)->clone($actorId ?? bin2hex(random_bytes(4))); + } + + public function loadFrozen(string $payload, ?string $actorId = null): Document + { + return $this->load($payload, $actorId)->withFrozen(true); + } + + public function loadMigratingStringsToText(string $payload, ?string $actorId = null): Document + { + return Document::loadWithStringMigration($payload)->clone($actorId ?? bin2hex(random_bytes(4))); + } + + public function loadWithPatchCallback(string $payload, callable $patchCallback, ?string $actorId = null): Document + { + $loaded = $this->load($payload, $actorId); + $before = $this->init($loaded->actorId()); + $patches = $this->patchesBetweenDocuments($before, $loaded); + if ($patches !== []) { + $patchCallback($patches, ['before' => $before, 'after' => $loaded, 'source' => 'load']); + } + + return $loaded; + } + + /** + * @param array $change + */ + public function loadChange(array $change, bool $allowMissingChanges = false, ?string $actorId = null): Document + { + $deps = $this->normalizeHeads($change['deps'] ?? []); + if ($deps !== [] && ! $allowMissingChanges) { + throw new \InvalidArgumentException('Cannot load change with missing dependencies.'); + } + + return Document::applyChanges($this->init($actorId), [$change]); + } + + /** + * @param list> $changes + */ + public function applyChanges(Document $document, array $changes): Document + { + $after = Document::applyChanges($document, $changes); + $this->emitDocumentPatchCallback($document, $after, 'applyChanges'); + + return $after; + } + + /** + * @param list> $changes + */ + public function applyChangesBatch(Document $document, array $changes): Document + { + return $this->applyChanges($document, $changes); + } + + /** + * @param list> $changes + */ + public function applyChangesWithPatchCallback(Document $document, array $changes, callable $patchCallback): Document + { + $after = Document::applyChanges($document, $changes); + $patches = $this->patchesBetweenDocuments($document, $after); + if ($patches !== []) { + $patchCallback($patches, ['before' => $document, 'after' => $after, 'source' => 'applyChanges']); + } + + return $after; + } + + /** + * @return array{postContent:string, metadata:array, automerge:Document} + */ + public function createDocument(string $basePostContent, array $baseMetadata = []): array + { + $document = $this->from( + [ + 'postContent' => $basePostContent, + 'metadata' => $baseMetadata, + ], + $baseMetadata['actorId'] ?? null + ); + + return [ + 'postContent' => $basePostContent, + 'basePostContent' => $basePostContent, + 'metadata' => $baseMetadata, + 'automerge' => $document, + 'lastPostContentEdit' => null, + ]; + } + + /** + * @param array{postContent:string, basePostContent?:string, metadata:array, automerge?:Document, lastPostContentEdit?:array}|Document $document + * @param array{postContent?:string, actorId?:string} $edit + * @return array{postContent:string, basePostContent:string, metadata:array, automerge:Document, lastPostContentEdit:?array}|Document + */ + public function applyLocalEdit(array|Document $document, array $edit): array|Document + { + if (! is_string($edit['postContent'] ?? null)) { + throw new \InvalidArgumentException('applyLocalEdit requires a postContent string.'); + } + + if ($document instanceof Document) { + return $this->updateText($document, 'postContent', $edit['postContent']); + } + + $baseDocument = $document['automerge'] ?? null; + if (! $baseDocument instanceof Document) { + $baseDocument = $this->from(['postContent' => $document['postContent']], $edit['actorId'] ?? null); + } + + $actorId = is_string($edit['actorId'] ?? null) ? $edit['actorId'] : null; + $workingDocument = $actorId === null ? $baseDocument->clone() : $baseDocument->clone($actorId); + $oldPostContent = $this->postContentFromDocument($workingDocument); + $nextDocument = $this->updateText($workingDocument, 'postContent', $edit['postContent']); + + return [ + 'postContent' => $edit['postContent'], + 'basePostContent' => is_string($document['basePostContent'] ?? null) ? $document['basePostContent'] : $oldPostContent, + 'metadata' => is_array($document['metadata'] ?? null) ? $document['metadata'] : [], + 'automerge' => $nextDocument, + 'lastPostContentEdit' => $this->textEdit($oldPostContent, $edit['postContent']), + ]; + } + + /** + * @param array{postContent:string, basePostContent?:string, metadata:array, automerge?:Document, lastPostContentEdit?:array}|Document $document + * @return array{postContent:string, basePostContent:string, metadata:array, automerge:Document, lastPostContentEdit:?array}|Document + */ + public function applyServerPostUpdate(array|Document $document, string $postContent, string $actorId = 'server'): array|Document + { + return $this->applyLocalEdit($document, ['actorId' => $actorId, 'postContent' => $postContent]); + } + + /** + * @param array{postContent:string, basePostContent?:string, metadata:array, automerge?:Document, lastPostContentEdit?:array}|Document $document + * @return array{postContent:string, basePostContent:string, metadata:array, document:Document, changes:list>, lastPostContentEdit:?array} + */ + public function encodeUpdate(array|Document $document): array + { + if ($document instanceof Document) { + return [ + 'postContent' => $this->postContentFromDocument($document), + 'basePostContent' => '', + 'metadata' => [], + 'document' => $document, + 'changes' => $document->getAllChanges(), + 'lastPostContentEdit' => null, + ]; + } + + $automerge = $document['automerge'] ?? null; + if (! $automerge instanceof Document) { + $automerge = $this->from(['postContent' => $document['postContent']]); + } + + return [ + 'postContent' => $this->postContentFromDocument($automerge), + 'basePostContent' => is_string($document['basePostContent'] ?? null) ? $document['basePostContent'] : '', + 'metadata' => is_array($document['metadata'] ?? null) ? $document['metadata'] : [], + 'document' => $automerge, + 'changes' => $automerge->getAllChanges(), + 'lastPostContentEdit' => is_array($document['lastPostContentEdit'] ?? null) ? $document['lastPostContentEdit'] : null, + ]; + } + + /** + * @param array{postContent:string, metadata:array, automerge?:Document}|Document $document + */ + public function materialize(array|Document $document): string|array + { + if ($document instanceof Document) { + return $document->toArray(); + } + + if (isset($document['automerge']) && $document['automerge'] instanceof Document) { + $root = $document['automerge']->toArray(); + return is_string($root['postContent'] ?? null) ? $root['postContent'] : ''; + } + + return $document['postContent']; + } + + /** + * @param list $path + */ + public function hydrate(Document $document, array $path = []): mixed + { + $path = array_values($path); + + return $path === [] ? $document->toArray() : $this->readPath($document, $path); + } + + /** + * @param array $hydrated + * @param list> $patches + * @return array + */ + public function applyHydratedPatches(array $hydrated, array $patches): array + { + return $this->applyPatchesToArray($hydrated, $patches); + } + + /** + * @return list,kind:string,value:mixed}> + */ + public function iterDocument(Document $document): array + { + $entries = []; + $queue = [ + [ + 'path' => [], + 'value' => $document->rootValues(), + ], + ]; + + while ($queue !== []) { + $container = array_shift($queue); + $containerPath = is_array($container['path']) ? array_values($container['path']) : []; + foreach ($this->iterDocumentChildren($container['value']) as $child) { + $path = array_merge($containerPath, [$child['pathKey']]); + $entry = $this->iterDocumentEntry($child['key'], $path, $child['value']); + $entries[] = $entry; + if ($entry['kind'] !== 'scalar') { + $queue[] = [ + 'path' => $path, + 'value' => $child['value'], + ]; + } + } + } + + return $entries; + } + + /** + * @param array{postContent:string, metadata:array, automerge?:Document}|Document $document + */ + public function getMetadataStats(array|Document $document): array + { + if ($document instanceof Document) { + $stats = $document->stats(); + return [ + 'bytes' => $stats['bytes'], + 'operationCount' => $stats['sequence'], + 'historySize' => count($stats['heads']), + 'textElements' => $stats['textElements'], + ]; + } + + if (isset($document['automerge']) && $document['automerge'] instanceof Document) { + return $this->getMetadataStats($document['automerge']); + } + + return [ + 'bytes' => strlen(json_encode($document['metadata'], JSON_THROW_ON_ERROR)), + 'operationCount' => 0, + 'historySize' => 0, + 'textElements' => 0, + ]; + } + + /** + * @return array{numChanges:int,numOps:int,cargoPackageName:string,cargoPackageVersion:string,rustcVersion:string} + */ + public function stats(Document $document): array + { + $changes = $document->getAllChanges(); + $numOps = 0; + foreach ($changes as $change) { + $numOps += count(is_array($change['ops'] ?? null) ? $change['ops'] : []); + } + + return [ + 'numChanges' => count($changes), + 'numOps' => $numOps, + 'cargoPackageName' => 'wordpress-de/automerge-php-native', + 'cargoPackageVersion' => '0.1.0', + 'rustcVersion' => 'native-php', + ]; + } + + public function merge(string $basePostContent, mixed $updateA, mixed $updateB): array + { + if (is_array($updateA) && is_array($updateB) && isset($updateA['document'], $updateB['document'])) { + if (is_array($updateA['lastPostContentEdit'] ?? null) && is_array($updateB['lastPostContentEdit'] ?? null)) { + $mergedPostContent = $this->mergeTextEdits( + $basePostContent, + $updateA['lastPostContentEdit'], + $updateB['lastPostContentEdit'] + ); + + if ($mergedPostContent === null) { + return [ + 'ok' => false, + 'conflict' => [ + 'reason' => 'overlapping-post-content-edits', + 'message' => 'The native PHP port cannot yet merge overlapping post content replacement spans.', + ], + ]; + } + + return [ + 'ok' => true, + 'postContent' => $mergedPostContent, + 'metadata' => is_array($updateA['metadata'] ?? null) ? $updateA['metadata'] : [], + ]; + } + + if ($updateA['document'] instanceof Document && $updateB['document'] instanceof Document) { + return $this->merge($basePostContent, $updateA['document'], $updateB['document']); + } + } + + if ($updateA instanceof Document && $updateB instanceof Document) { + $merged = $this->mergeDocuments($updateA, $updateB); + $root = $merged->toArray(); + + return [ + 'ok' => true, + 'postContent' => is_string($root['postContent'] ?? null) ? $root['postContent'] : $basePostContent, + 'document' => $merged, + ]; + } + + return [ + 'ok' => false, + 'conflict' => [ + 'reason' => 'unsupported-update-shape', + 'message' => 'Native PHP merge currently accepts only Document instances from this port.', + ], + ]; + } + + private function postContentFromDocument(Document $document): string + { + $root = $document->toArray(); + + return is_string($root['postContent'] ?? null) ? $root['postContent'] : ''; + } + + /** + * @return array{index:int, deleteCount:int, insert:string} + */ + private function textEdit(string $oldText, string $newText): array + { + $old = $this->splitCharacters($oldText); + $new = $this->splitCharacters($newText); + $oldLength = count($old); + $newLength = count($new); + $prefix = 0; + + while ($prefix < $oldLength && $prefix < $newLength && $old[$prefix] === $new[$prefix]) { + ++$prefix; + } + + $suffix = 0; + while ( + $suffix < ($oldLength - $prefix) + && $suffix < ($newLength - $prefix) + && $old[$oldLength - 1 - $suffix] === $new[$newLength - 1 - $suffix] + ) { + ++$suffix; + } + + return [ + 'index' => $prefix, + 'deleteCount' => $oldLength - $prefix - $suffix, + 'insert' => implode('', array_slice($new, $prefix, $newLength - $prefix - $suffix)), + ]; + } + + /** + * @param array{index:int,deleteCount:int,insert:string} $editA + * @param array{index:int,deleteCount:int,insert:string} $editB + */ + private function mergeTextEdits(string $baseText, array $editA, array $editB): ?string + { + if ($editA === $editB) { + return $this->applyTextEdit($baseText, $editA); + } + + $aStart = $editA['index']; + $aEnd = $aStart + $editA['deleteCount']; + $bStart = $editB['index']; + $bEnd = $bStart + $editB['deleteCount']; + + if ($aEnd <= $bStart) { + $afterA = $this->applyTextEdit($baseText, $editA); + $adjustedB = $editB; + $adjustedB['index'] += count($this->splitCharacters($editA['insert'])) - $editA['deleteCount']; + + return $this->applyTextEdit($afterA, $adjustedB); + } + + if ($bEnd <= $aStart) { + $afterB = $this->applyTextEdit($baseText, $editB); + $adjustedA = $editA; + $adjustedA['index'] += count($this->splitCharacters($editB['insert'])) - $editB['deleteCount']; + + return $this->applyTextEdit($afterB, $adjustedA); + } + + return null; + } + + /** + * @param array{index:int,deleteCount:int,insert:string} $edit + */ + private function applyTextEdit(string $text, array $edit): string + { + $characters = $this->splitCharacters($text); + array_splice( + $characters, + $edit['index'], + $edit['deleteCount'], + $this->splitCharacters($edit['insert']) + ); + + return implode('', $characters); + } + + /** + * @return list> + */ + private function patchesBetweenDocuments(Document $before, Document $after): array + { + $beforeRoot = $before->toArray(); + $afterRoot = $after->toArray(); + if ($beforeRoot === []) { + $patches = []; + foreach ($afterRoot as $key => $value) { + $patches = array_merge($patches, $this->assignmentPatchesForDocumentValue($after, (string) $key, $value)); + } + + return $patches; + } + + return array_values(array_merge( + $this->diffValues($beforeRoot, $afterRoot, []), + $this->markPatchesBetweenDocuments($before, $after) + )); + } + + /** + * @return list + */ + private function splitCharacters(string $text): array + { + if (! preg_match_all('/\X/u', $text, $matches)) { + if ($text === '') { + return []; + } + + throw new \InvalidArgumentException('Text must be valid UTF-8.'); + } + + return $matches[0]; + } + + /** + * @return array{0:int,1:int} + */ + private function normalizeByteRange(string $text, int $start, int $end): array + { + $length = strlen($text); + $start = max(0, min($start, $length)); + $end = max(0, min($end, $length)); + + return [$start, $end]; + } + + private function textDiffMaxD(int $oldLength, int $newLength): int + { + return intdiv(max(0, $oldLength) + max(0, $newLength) + 1, 2) + 1; + } + + /** + * @param array $clock + * @return list + */ + private function normalizeClock(array $clock): array + { + return array_map( + static fn (int $counter): int => max(0, $counter), + array_values($clock) + ); + } + + /** + * @param list}> $changes + * @return array{nodes:array}>,actorCount:int} + */ + private function normalizeChangeGraph(array $changes): array + { + $nodes = []; + $actorCount = 0; + foreach ($changes as $change) { + if (! isset($change['hash']) || ! is_string($change['hash']) || $change['hash'] === '') { + throw new \InvalidArgumentException('Change graph nodes require a non-empty hash.'); + } + + if (isset($nodes[$change['hash']])) { + throw new \InvalidArgumentException('Change graph hashes must be unique.'); + } + + $actor = $change['actor'] ?? null; + if (! is_int($actor) || $actor < 0) { + throw new \InvalidArgumentException('Change graph nodes require a non-negative actor index.'); + } + + $seq = $change['seq'] ?? null; + if ($seq !== null && (! is_int($seq) || $seq <= 0)) { + throw new \InvalidArgumentException('Change graph sequence numbers must be positive.'); + } + + $deps = $change['deps'] ?? []; + if (! is_array($deps) || array_values($deps) !== $deps) { + throw new \InvalidArgumentException('Change graph dependencies must be a list.'); + } + + foreach ($deps as $dep) { + if (! is_string($dep) || $dep === '') { + throw new \InvalidArgumentException('Change graph dependencies must be non-empty hashes.'); + } + } + + $nodes[$change['hash']] = [ + 'hash' => $change['hash'], + 'actor' => $actor, + 'seq' => $seq, + 'deps' => $deps, + ]; + $actorCount = max($actorCount, $actor + 1); + } + + return [ + 'nodes' => $nodes, + 'actorCount' => $actorCount, + ]; + } + + /** + * @param array}> $nodes + * @param list $heads + * @return list + */ + private function changeGraphAncestors(array $nodes, array $heads): array + { + $visited = []; + $ordered = []; + $toVisit = array_reverse(array_values($heads)); + + while ($toVisit !== []) { + $hash = array_pop($toVisit); + if (! is_string($hash) || isset($visited[$hash]) || ! isset($nodes[$hash])) { + continue; + } + + $visited[$hash] = true; + $ordered[] = $hash; + foreach ($nodes[$hash]['deps'] as $dep) { + if (! isset($visited[$dep])) { + $toVisit[] = $dep; + } + } + } + + return $ordered; + } + + private function normalizeUnsignedDecimal(int|string $value, string $max): string + { + $decimal = $this->normalizeDecimalString($value); + if (str_starts_with($decimal, '-')) { + throw new \InvalidArgumentException('Unsigned LEB128 size requires a non-negative integer.'); + } + + if ($this->compareDecimalStrings($decimal, $max) > 0) { + throw new \InvalidArgumentException('Unsigned LEB128 size is limited to u64 values.'); + } + + return $decimal; + } + + private function normalizeSignedDecimal(int|string $value): string + { + $decimal = $this->normalizeDecimalString($value); + if (str_starts_with($decimal, '-')) { + if ($this->compareDecimalStrings(substr($decimal, 1), '9223372036854775808') > 0) { + throw new \InvalidArgumentException('Signed LEB128 size is limited to i64 values.'); + } + } elseif ($this->compareDecimalStrings($decimal, '9223372036854775807') > 0) { + throw new \InvalidArgumentException('Signed LEB128 size is limited to i64 values.'); + } + + return $decimal; + } + + private function normalizeDecimalString(int|string $value): string + { + $decimal = is_int($value) ? (string) $value : trim($value); + if ($decimal === '') { + throw new \InvalidArgumentException('Decimal integer cannot be empty.'); + } + + $negative = str_starts_with($decimal, '-'); + if ($negative || str_starts_with($decimal, '+')) { + $decimal = substr($decimal, 1); + } + + if ($decimal === '' || ! ctype_digit($decimal)) { + throw new \InvalidArgumentException('Decimal integer must contain only digits.'); + } + + $decimal = ltrim($decimal, '0'); + if ($decimal === '') { + return '0'; + } + + return $negative ? '-' . $decimal : $decimal; + } + + private function compareDecimalStrings(string $left, string $right): int + { + $left = ltrim($left, '0'); + $right = ltrim($right, '0'); + $left = $left === '' ? '0' : $left; + $right = $right === '' ? '0' : $right; + + if (strlen($left) !== strlen($right)) { + return strlen($left) < strlen($right) ? -1 : 1; + } + + return $left <=> $right; + } + + private function decimalBitLength(string $decimal): int + { + $decimal = ltrim($decimal, '0'); + if ($decimal === '') { + return 0; + } + + $bits = 0; + while ($decimal !== '0') { + $decimal = $this->divideDecimalStringByTwo($decimal); + ++$bits; + } + + return $bits; + } + + private function divideDecimalStringByTwo(string $decimal): string + { + $carry = 0; + $quotient = ''; + $length = strlen($decimal); + for ($index = 0; $index < $length; ++$index) { + $digit = $carry * 10 + (ord($decimal[$index]) - 48); + $quotient .= (string) intdiv($digit, 2); + $carry = $digit % 2; + } + + $quotient = ltrim($quotient, '0'); + + return $quotient === '' ? '0' : $quotient; + } + + /** + * @return array{quotient:string,remainder:int} + */ + private function divideDecimalStringByInt(string $decimal, int $divisor): array + { + if ($divisor <= 0) { + throw new \InvalidArgumentException('Decimal divisor must be positive.'); + } + + $decimal = ltrim($decimal, '0'); + if ($decimal === '') { + return ['quotient' => '0', 'remainder' => 0]; + } + + $carry = 0; + $quotient = ''; + $length = strlen($decimal); + for ($index = 0; $index < $length; ++$index) { + $value = ($carry * 10) + (ord($decimal[$index]) - 48); + $quotient .= (string) intdiv($value, $divisor); + $carry = $value % $divisor; + } + + $quotient = ltrim($quotient, '0'); + + return [ + 'quotient' => $quotient === '' ? '0' : $quotient, + 'remainder' => $carry, + ]; + } + + private function decrementDecimalString(string $decimal): string + { + $decimal = ltrim($decimal, '0'); + if ($decimal === '' || $decimal === '0') { + return '0'; + } + + $digits = str_split($decimal); + for ($index = count($digits) - 1; $index >= 0; --$index) { + if ($digits[$index] !== '0') { + $digits[$index] = (string) ((int) $digits[$index] - 1); + break; + } + + $digits[$index] = '9'; + } + + $result = ltrim(implode('', $digits), '0'); + + return $result === '' ? '0' : $result; + } + + private function decimalStringToPhpInt(string $decimal, string $label): int + { + $decimal = $this->normalizeDecimalString($decimal); + if (str_starts_with($decimal, '-') || $this->compareDecimalStrings($decimal, (string) PHP_INT_MAX) > 0) { + throw new \InvalidArgumentException($label . ' is too large for this PHP runtime.'); + } + + return (int) $decimal; + } + + private function jsonObjectPrefixLength(string $payload): int + { + $length = strlen($payload); + $offset = 0; + while ($offset < $length && str_contains(" \t\r\n", $payload[$offset])) { + ++$offset; + } + + if ($offset >= $length || $payload[$offset] !== '{') { + throw new \InvalidArgumentException('Native incremental payload does not start with a JSON object.'); + } + + $depth = 0; + $inString = false; + $escaped = false; + for ($index = $offset; $index < $length; ++$index) { + $char = $payload[$index]; + if ($inString) { + if ($escaped) { + $escaped = false; + continue; + } + if ($char === '\\') { + $escaped = true; + continue; + } + if ($char === '"') { + $inString = false; + } + continue; + } + + if ($char === '"') { + $inString = true; + continue; + } + if ($char === '{' || $char === '[') { + ++$depth; + continue; + } + if ($char === '}' || $char === ']') { + --$depth; + if ($depth === 0) { + return $index + 1; + } + if ($depth < 0) { + throw new \InvalidArgumentException('Native incremental payload has mismatched JSON delimiters.'); + } + } + } + + throw new \InvalidArgumentException('Native incremental payload has no complete JSON object prefix.'); + } + + private function addDecimalStrings(string $left, string $right): string + { + $left = strrev(ltrim($left, '0') === '' ? '0' : ltrim($left, '0')); + $right = strrev(ltrim($right, '0') === '' ? '0' : ltrim($right, '0')); + $length = max(strlen($left), strlen($right)); + $carry = 0; + $sum = ''; + + for ($index = 0; $index < $length; ++$index) { + $digit = $carry; + if ($index < strlen($left)) { + $digit += ord($left[$index]) - 48; + } + if ($index < strlen($right)) { + $digit += ord($right[$index]) - 48; + } + + $sum .= (string) ($digit % 10); + $carry = intdiv($digit, 10); + } + + if ($carry > 0) { + $sum .= (string) $carry; + } + + $result = ltrim(strrev($sum), '0'); + + return $result === '' ? '0' : $result; + } + + private function subtractDecimalStrings(string $left, string $right): string + { + $left = ltrim($left, '0') === '' ? '0' : ltrim($left, '0'); + $right = ltrim($right, '0') === '' ? '0' : ltrim($right, '0'); + if ($this->compareDecimalStrings($left, $right) < 0) { + throw new \InvalidArgumentException('Decimal subtraction requires a non-negative result.'); + } + + $left = strrev($left); + $right = strrev($right); + $borrow = 0; + $result = ''; + + for ($index = 0; $index < strlen($left); ++$index) { + $digit = (ord($left[$index]) - 48) - $borrow; + $other = $index < strlen($right) ? ord($right[$index]) - 48 : 0; + if ($digit < $other) { + $digit += 10; + $borrow = 1; + } else { + $borrow = 0; + } + + $result .= (string) ($digit - $other); + } + + $result = ltrim(strrev($result), '0'); + + return $result === '' ? '0' : $result; + } + + private function multiplyDecimalStringByTwo(string $decimal): string + { + $decimal = ltrim($decimal, '0'); + if ($decimal === '') { + return '0'; + } + + $carry = 0; + $result = ''; + for ($index = strlen($decimal) - 1; $index >= 0; --$index) { + $value = ((ord($decimal[$index]) - 48) * 2) + $carry; + $result .= (string) ($value % 10); + $carry = intdiv($value, 10); + } + + if ($carry > 0) { + $result .= (string) $carry; + } + + return strrev($result); + } + + private function decimalPowerOfTwo(int $shift): string + { + if ($shift < 0) { + throw new \InvalidArgumentException('Decimal power shift must be non-negative.'); + } + + $value = '1'; + for ($index = 0; $index < $shift; ++$index) { + $value = $this->multiplyDecimalStringByTwo($value); + } + + return $value; + } + + private function shiftedLebPayloadDecimal(int $payload, int $shift): string + { + if ($payload === 0) { + return '0'; + } + + $value = (string) $payload; + for ($index = 0; $index < $shift; ++$index) { + $value = $this->multiplyDecimalStringByTwo($value); + } + + return $value; + } + + /** + * @return array{0:string,1:int} + */ + private function parseStorageUnsignedLeb128(string $bytes): array + { + $value = '0'; + $shift = 0; + $offset = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $byte = ord($bytes[$offset]); + ++$offset; + $payload = $byte & 0x7f; + $value = $this->addDecimalStrings($value, $this->shiftedLebPayloadDecimal($payload, $shift)); + $shift += 7; + + if (($byte & 0x80) === 0) { + if ($shift > 64 && $byte > 1) { + throw new \InvalidArgumentException('LEB128 value is too large for u64.'); + } + if ($shift > 7 && $byte === 0) { + throw new \InvalidArgumentException('LEB128 value uses an overlong encoding.'); + } + if ($this->compareDecimalStrings($value, '18446744073709551615') > 0) { + throw new \InvalidArgumentException('LEB128 value is too large for u64.'); + } + + return [$value, $offset]; + } + + if ($shift > 64) { + throw new \InvalidArgumentException('LEB128 value is too large for u64.'); + } + } + + throw new \InvalidArgumentException('Truncated LEB128 value.'); + } + + /** + * @return array{0:string,1:int} + */ + private function parseStorageSignedLeb128(string $bytes): array + { + $raw = '0'; + $shift = 0; + $offset = 0; + $previousByte = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $byte = ord($bytes[$offset]); + ++$offset; + $payload = $byte & 0x7f; + $bitsRemaining = max(0, min(7, 64 - $shift)); + if ($bitsRemaining > 0) { + $mask = (1 << $bitsRemaining) - 1; + $raw = $this->addDecimalStrings( + $raw, + $this->shiftedLebPayloadDecimal($payload & $mask, $shift) + ); + } + $shift += 7; + + if (($byte & 0x80) === 0) { + if ($shift > 64 && $byte !== 0 && $byte !== 0x7f) { + throw new \InvalidArgumentException('LEB128 value is too large for i64.'); + } + if ( + $shift > 7 + && ( + ($byte === 0 && ($previousByte & 0x40) === 0) + || ($byte === 0x7f && ($previousByte & 0x40) !== 0) + ) + ) { + throw new \InvalidArgumentException('LEB128 value uses an overlong encoding.'); + } + + if ($shift < 64 && ($byte & 0x40) !== 0) { + return ['-' . $this->subtractDecimalStrings($this->decimalPowerOfTwo($shift), $raw), $offset]; + } + if ($this->compareDecimalStrings($raw, '9223372036854775808') >= 0) { + return ['-' . $this->subtractDecimalStrings('18446744073709551616', $raw), $offset]; + } + + return [$raw, $offset]; + } + + if ($shift > 64) { + throw new \InvalidArgumentException('LEB128 value is too large for i64.'); + } + + $previousByte = $byte; + } + + throw new \InvalidArgumentException('Truncated LEB128 value.'); + } + + private function storageChunkBodyOffset(string $raw): int + { + $offset = 9; + $this->decodeUnsignedLeb128Int($raw, $offset); + + return $offset; + } + + /** + * @return list> + */ + private function storageChangesFromPayload(string $payload): array + { + $changes = []; + $offset = 0; + $length = strlen($payload); + while ($offset < $length) { + if ($offset + 10 > $length) { + throw new \InvalidArgumentException('Automerge storage payload contains a truncated chunk.'); + } + if (substr($payload, $offset, 4) !== "\x85\x6f\x4a\x83") { + throw new \InvalidArgumentException('Invalid Automerge storage magic bytes.'); + } + + $chunkStart = $offset; + $offset += 8; + ++$offset; + $dataLength = $this->decodeUnsignedLeb128Int($payload, $offset); + $chunkEnd = $offset + $dataLength; + if ($chunkEnd > $length) { + throw new \InvalidArgumentException('Automerge storage chunk length exceeds payload bytes.'); + } + + $changes[] = $this->storageChangeFromBytes($this->columnarBytesToList(substr($payload, $chunkStart, $chunkEnd - $chunkStart))); + $offset = $chunkEnd; + } + + return $this->storageTopologicalChanges($changes); + } + + /** + * @param list> $changes + * @return list> + */ + private function storageTopologicalChanges(array $changes): array + { + $byHash = []; + foreach ($changes as $change) { + if (is_string($change['hash'] ?? null)) { + $byHash[$change['hash']] = $change; + } + } + + $ordered = []; + $applied = []; + while ($changes !== []) { + $progress = false; + foreach ($changes as $index => $change) { + $deps = $this->normalizeHeads($change['deps'] ?? []); + $missing = array_values(array_filter( + $deps, + static fn (string $dep): bool => isset($byHash[$dep]) && ! isset($applied[$dep]) + )); + if ($missing !== []) { + continue; + } + + $ordered[] = $change; + if (is_string($change['hash'] ?? null)) { + $applied[$change['hash']] = true; + } + unset($changes[$index]); + $progress = true; + } + + if (! $progress) { + throw new \InvalidArgumentException('Automerge storage changes contain unsatisfied dependencies.'); + } + + $changes = array_values($changes); + } + + return $ordered; + } + + /** + * @param array> $objectPaths + * @param array $change + * @return array{0:Document,1:array>} + */ + private function storageApplyLoadedChange(Document $document, array $objectPaths, array $change): array + { + $key = $this->storageChangeSingleStringColumn($change, 1); + $action = $this->storageChangeSingleIntegerColumn($change, 4); + if ($key === null || $action === null) { + throw new \InvalidArgumentException('Unsupported Automerge storage fixture change columns.'); + } + + if ($action === 0) { + $document = $this->batchCreateObject($document, $key, []); + $objectPaths[$this->storageChangeObjectId($change)] = [$key]; + + return [$document, $objectPaths]; + } + + if ($action === 1) { + $objectPath = $objectPaths[$this->storageChangeObjectKey($change)] ?? null; + $value = $this->storageChangeSingleValueColumn($change, 5); + if ($objectPath === null || $value === null) { + throw new \InvalidArgumentException('Unsupported Automerge storage fixture set operation.'); + } + + return [$this->setNested($document, array_merge($objectPath, [$key]), $value), $objectPaths]; + } + + throw new \InvalidArgumentException('Unsupported Automerge storage fixture action.'); + } + + /** + * @param array $change + */ + private function storageChangeObjectId(array $change): string + { + return ((int) ($change['startOp'] ?? 0)) . '@' . (string) ($change['actor'] ?? ''); + } + + /** + * @param array $change + */ + private function storageChangeObjectKey(array $change): string + { + $counter = $this->storageChangeSingleIntegerColumn($change, 0); + $actor = (string) ($change['actor'] ?? ''); + + return $counter . '@' . $actor; + } + + /** + * @param array $change + */ + private function storageChangeSingleStringColumn(array $change, int $columnId): ?string + { + $bytes = $this->storageChangeColumnBytes($change, $columnId, 'String'); + if (count($bytes) < 2 || $bytes[0] !== 0x7f) { + return null; + } + + $length = $bytes[1]; + if ($length < 0 || count($bytes) !== $length + 2) { + return null; + } + + return $this->columnarByteListToString(array_slice($bytes, 2)); + } + + /** + * @param array $change + */ + private function storageChangeSingleIntegerColumn(array $change, int $columnId): ?int + { + $bytes = $this->storageChangeColumnBytes($change, $columnId, 'Integer'); + if (count($bytes) !== 2 || $bytes[0] !== 0x7f) { + return null; + } + + return $bytes[1]; + } + + /** + * @param array $change + */ + private function storageChangeSingleValueColumn(array $change, int $columnId): mixed + { + $bytes = $this->storageChangeColumnBytes($change, $columnId, 'Value'); + if ($bytes === []) { + return null; + } + + return $this->columnarByteListToString($bytes); + } + + /** + * @param array $change + * @return list + */ + private function storageChangeColumnBytes(array $change, int $columnId, string $columnType): array + { + $opsData = is_array($change['opsData'] ?? null) ? array_values($change['opsData']) : []; + foreach (is_array($change['rawColumns'] ?? null) ? $change['rawColumns'] : [] as $column) { + if ( + is_array($column) + && ($column['id'] ?? null) === $columnId + && ($column['type'] ?? null) === $columnType + && is_array($column['range'] ?? null) + ) { + $start = (int) ($column['range'][0] ?? 0); + $end = (int) ($column['range'][1] ?? $start); + + return array_slice($opsData, $start, max(0, $end - $start)); + } + } + + return []; + } + + /** + * @return list + */ + private function storageChangeReadHashList(string $data, int &$offset): array + { + $count = $this->decodeUnsignedLeb128Int($data, $offset); + $hashes = []; + for ($index = 0; $index < $count; ++$index) { + if ($offset + 32 > strlen($data)) { + throw new \InvalidArgumentException('Automerge change dependency hash is truncated.'); + } + + $hashes[] = bin2hex(substr($data, $offset, 32)); + $offset += 32; + } + + return $hashes; + } + + private function storageChangeReadActorId(string $data, int &$offset): string + { + $length = $this->decodeUnsignedLeb128Int($data, $offset); + if ($offset + $length > strlen($data)) { + throw new \InvalidArgumentException('Automerge change actor ID is truncated.'); + } + + $actor = bin2hex(substr($data, $offset, $length)); + $offset += $length; + + return $actor; + } + + /** + * @return list + */ + private function storageChangeReadActorList(string $data, int &$offset): array + { + $count = $this->decodeUnsignedLeb128Int($data, $offset); + $actors = []; + for ($index = 0; $index < $count; ++$index) { + $actors[] = $this->storageChangeReadActorId($data, $offset); + } + + return $actors; + } + + /** + * @return list + */ + private function storageChangeReadRawColumns(string $data, int &$offset): array + { + $count = $this->decodeUnsignedLeb128Int($data, $offset); + $columns = []; + $dataOffset = 0; + $lastNormalized = null; + + for ($index = 0; $index < $count; ++$index) { + $spec = $this->decodeUnsignedLeb128Int($data, $offset); + $length = $this->decodeUnsignedLeb128Int($data, $offset); + $normalized = $spec & 0xfffffff7; + if ($lastNormalized !== null && $normalized < $lastNormalized) { + throw new \InvalidArgumentException('Automerge change columns are not in normalized order.'); + } + + $columns[] = [ + 'spec' => $spec, + 'normalized' => $normalized, + 'id' => $spec >> 4, + 'type' => $this->columnTypeName($spec), + 'deflate' => ($spec & 0x08) !== 0, + 'length' => $length, + 'range' => [$dataOffset, $dataOffset + $length], + ]; + $dataOffset += $length; + $lastNormalized = $normalized; + } + + return $columns; + } + + /** + * @param list $columns + * @return list + */ + private function storageNormalizeRawColumns(array $columns, int $dataLength): array + { + if (! array_is_list($columns)) { + throw new \InvalidArgumentException('Automerge columns must be a list.'); + } + + $normalizedColumns = []; + $dataOffset = 0; + $lastNormalized = null; + + foreach ($columns as $column) { + if (! is_array($column)) { + throw new \InvalidArgumentException('Automerge column metadata must be an array.'); + } + + $spec = $column['spec'] ?? null; + if (! is_int($spec)) { + throw new \InvalidArgumentException('Automerge column spec must be an integer.'); + } + + $range = $column['range'] ?? null; + $length = $column['length'] ?? null; + if ($range !== null) { + if (! is_array($range) || count($range) !== 2 || ! is_int($range[0] ?? null) || ! is_int($range[1] ?? null)) { + throw new \InvalidArgumentException('Automerge column range must contain two integer offsets.'); + } + + if ($range[0] < 0 || $range[1] < $range[0] || $range[1] > $dataLength) { + throw new \InvalidArgumentException('Automerge column range is outside the column data.'); + } + + $rangeLength = $range[1] - $range[0]; + if ($length !== null && (! is_int($length) || $length !== $rangeLength)) { + throw new \InvalidArgumentException('Automerge column length does not match its range.'); + } + + $length = $rangeLength; + } elseif (is_int($length) && $length >= 0) { + $range = [$dataOffset, $dataOffset + $length]; + } else { + throw new \InvalidArgumentException('Automerge column metadata must include a non-negative length.'); + } + + if ($range[0] !== $dataOffset) { + throw new \InvalidArgumentException('Automerge column ranges must be contiguous in storage order.'); + } + + $descriptor = $this->storageRawColumnDescriptor($spec, $length, $range[0]); + if ($lastNormalized !== null && $descriptor['normalized'] < $lastNormalized) { + throw new \InvalidArgumentException('Automerge columns are not in normalized order.'); + } + + $normalizedColumns[] = $descriptor; + $dataOffset = $range[1]; + $lastNormalized = $descriptor['normalized']; + } + + if ($dataOffset !== $dataLength) { + throw new \InvalidArgumentException('Automerge column lengths do not cover the column data.'); + } + + return $normalizedColumns; + } + + /** + * @return array{spec:int,normalized:int,id:int,type:string,deflate:bool,length:int,range:array{0:int,1:int}} + */ + private function storageRawColumnDescriptor(int $spec, int $length, int $start): array + { + if ($length < 0 || $start < 0) { + throw new \InvalidArgumentException('Automerge column range must be non-negative.'); + } + + $decoded = $this->columnSpecDecode($spec); + + return [ + 'spec' => $spec, + 'normalized' => $decoded['normalized'], + 'id' => $decoded['id'], + 'type' => $decoded['type'], + 'deflate' => $decoded['deflate'], + 'length' => $length, + 'range' => [$start, $start + $length], + ]; + } + + private function encodeUnsignedLeb128Decimal(int|string $value): string + { + $decimal = $this->normalizeUnsignedDecimal($value, '18446744073709551615'); + $bytes = ''; + + do { + $division = $this->divideDecimalStringByInt($decimal, 128); + $byte = $division['remainder']; + $decimal = $division['quotient']; + if ($decimal !== '0') { + $byte |= 0x80; + } + $bytes .= chr($byte); + } while ($decimal !== '0'); + + return $bytes; + } + + private function columnTypeCode(string $type): int + { + return match ($type) { + 'Group' => 0, + 'Actor' => 1, + 'Integer' => 2, + 'DeltaInteger' => 3, + 'Boolean' => 4, + 'String' => 5, + 'ValueMetadata' => 6, + 'Value' => 7, + default => throw new \InvalidArgumentException('Unknown column spec type.'), + }; + } + + private function columnTypeName(int $code): string + { + return match ($code & 0x07) { + 0 => 'Group', + 1 => 'Actor', + 2 => 'Integer', + 3 => 'DeltaInteger', + 4 => 'Boolean', + 5 => 'String', + 6 => 'ValueMetadata', + 7 => 'Value', + }; + } + + private function assertColumnSpecRaw(int $raw): void + { + if ($raw < 0 || $raw > 0xffffffff) { + throw new \InvalidArgumentException('Column spec raw value must fit in u32.'); + } + } + + /** + * @param array{values?:list} $tree + * @return list + */ + private function sequenceTreeValues(array $tree): array + { + $values = $tree['values'] ?? []; + if (! is_array($values) || ! array_is_list($values)) { + throw new \InvalidArgumentException('SequenceTree must contain a list of values.'); + } + + return array_values($values); + } + + private function encodeUnsignedLeb128Int(int $value): string + { + if ($value < 0) { + throw new \InvalidArgumentException('Unsigned LEB128 encoding requires a non-negative integer.'); + } + + $bytes = ''; + do { + $byte = $value & 0x7f; + $value = intdiv($value, 128); + if ($value !== 0) { + $byte |= 0x80; + } + $bytes .= chr($byte); + } while ($value !== 0); + + return $bytes; + } + + private function decodeUnsignedLeb128Int(string $bytes, int &$offset): int + { + $result = 0; + $shift = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $byte = ord($bytes[$offset]); + ++$offset; + $result += ($byte & 0x7f) << $shift; + if (($byte & 0x80) === 0) { + return $result; + } + + $shift += 7; + if ($shift >= PHP_INT_SIZE * 8 - 1) { + throw new \InvalidArgumentException('Unsigned LEB128 integer is too large for PHP int.'); + } + } + + throw new \InvalidArgumentException('Truncated unsigned LEB128 integer.'); + } + + /** + * @param array $state + * @return array + */ + private function appendRleNull(array $state, string &$bytes): array + { + return match ($state['type'] ?? 'empty') { + 'empty' => ['type' => 'initialNullRun', 'len' => 1], + 'initialNullRun' => ['type' => 'initialNullRun', 'len' => ((int) $state['len']) + 1], + 'nullRun' => ['type' => 'nullRun', 'len' => ((int) $state['len']) + 1], + 'loneVal' => $this->afterFlushingLiteralRun([(int) $state['value']], $bytes, 'nullRun'), + 'run' => $this->afterFlushingRun((int) $state['value'], (int) $state['len'], $bytes, 'nullRun'), + 'literalRun' => $this->afterFlushingLiteralRun( + array_merge($state['run'], [(int) $state['last']]), + $bytes, + 'nullRun' + ), + default => throw new \InvalidArgumentException('Invalid RLE encoder state.'), + }; + } + + /** + * @param array $state + * @return array + */ + private function appendRleIntValue(array $state, int $value, string &$bytes): array + { + return match ($state['type'] ?? 'empty') { + 'empty' => ['type' => 'loneVal', 'value' => $value], + 'loneVal' => ((int) $state['value']) === $value + ? ['type' => 'run', 'value' => $value, 'len' => 2] + : ['type' => 'literalRun', 'last' => $value, 'run' => [(int) $state['value']]], + 'run' => ((int) $state['value']) === $value + ? ['type' => 'run', 'value' => $value, 'len' => ((int) $state['len']) + 1] + : $this->afterFlushingRun((int) $state['value'], (int) $state['len'], $bytes, 'loneVal', $value), + 'literalRun' => ((int) $state['last']) === $value + ? $this->afterFlushingLiteralRun($state['run'], $bytes, 'run', $value) + : ['type' => 'literalRun', 'last' => $value, 'run' => array_merge($state['run'], [(int) $state['last']])], + 'nullRun', 'initialNullRun' => $this->afterFlushingNullRun((int) $state['len'], $bytes, $value), + default => throw new \InvalidArgumentException('Invalid RLE encoder state.'), + }; + } + + /** + * @param array $state + */ + private function flushRleIntState(array $state, string &$bytes): void + { + match ($state['type'] ?? 'empty') { + 'empty', 'initialNullRun' => null, + 'nullRun' => $this->flushRleNullRun((int) $state['len'], $bytes), + 'loneVal' => $this->flushRleLiteralRun([(int) $state['value']], $bytes), + 'run' => $this->flushRleRun((int) $state['value'], (int) $state['len'], $bytes), + 'literalRun' => $this->flushRleLiteralRun(array_merge($state['run'], [(int) $state['last']]), $bytes), + default => throw new \InvalidArgumentException('Invalid RLE encoder state.'), + }; + } + + /** + * @param list $run + * @return array + */ + private function afterFlushingLiteralRun(array $run, string &$bytes, string $nextType, ?int $value = null): array + { + $this->flushRleLiteralRun($run, $bytes); + + return $nextType === 'run' + ? ['type' => 'run', 'value' => $value, 'len' => 2] + : ['type' => $nextType, 'len' => 1]; + } + + /** + * @return array + */ + private function afterFlushingRun(int $value, int $len, string &$bytes, string $nextType, ?int $nextValue = null): array + { + $this->flushRleRun($value, $len, $bytes); + + return $nextType === 'loneVal' + ? ['type' => 'loneVal', 'value' => $nextValue] + : ['type' => $nextType, 'len' => 1]; + } + + /** + * @return array + */ + private function afterFlushingNullRun(int $len, string &$bytes, int $value): array + { + $this->flushRleNullRun($len, $bytes); + + return ['type' => 'loneVal', 'value' => $value]; + } + + private function flushRleRun(int $value, int $len, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int($len); + $bytes .= $this->encodeSignedLeb128Int($value); + } + + private function flushRleNullRun(int $len, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int(0); + $bytes .= $this->encodeUnsignedLeb128Int($len); + } + + /** + * @param list $run + */ + private function flushRleLiteralRun(array $run, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int(-count($run)); + foreach ($run as $value) { + $bytes .= $this->encodeSignedLeb128Int($value); + } + } + + /** + * @param array $state + * @return array + */ + private function appendRleStringNull(array $state, string &$bytes): array + { + return match ($state['type'] ?? 'empty') { + 'empty' => ['type' => 'initialNullRun', 'len' => 1], + 'initialNullRun' => ['type' => 'initialNullRun', 'len' => ((int) $state['len']) + 1], + 'nullRun' => ['type' => 'nullRun', 'len' => ((int) $state['len']) + 1], + 'loneVal' => $this->afterFlushingStringLiteralRun([(string) $state['value']], $bytes, 'nullRun'), + 'run' => $this->afterFlushingStringRun((string) $state['value'], (int) $state['len'], $bytes, 'nullRun'), + 'literalRun' => $this->afterFlushingStringLiteralRun( + array_merge($state['run'], [(string) $state['last']]), + $bytes, + 'nullRun' + ), + default => throw new \InvalidArgumentException('Invalid string RLE encoder state.'), + }; + } + + /** + * @param array $state + * @return array + */ + private function appendRleStringValue(array $state, string $value, string &$bytes): array + { + return match ($state['type'] ?? 'empty') { + 'empty' => ['type' => 'loneVal', 'value' => $value], + 'loneVal' => ((string) $state['value']) === $value + ? ['type' => 'run', 'value' => $value, 'len' => 2] + : ['type' => 'literalRun', 'last' => $value, 'run' => [(string) $state['value']]], + 'run' => ((string) $state['value']) === $value + ? ['type' => 'run', 'value' => $value, 'len' => ((int) $state['len']) + 1] + : $this->afterFlushingStringRun((string) $state['value'], (int) $state['len'], $bytes, 'loneVal', $value), + 'literalRun' => ((string) $state['last']) === $value + ? $this->afterFlushingStringLiteralRun($state['run'], $bytes, 'run', $value) + : ['type' => 'literalRun', 'last' => $value, 'run' => array_merge($state['run'], [(string) $state['last']])], + 'nullRun', 'initialNullRun' => $this->afterFlushingStringNullRun((int) $state['len'], $bytes, $value), + default => throw new \InvalidArgumentException('Invalid string RLE encoder state.'), + }; + } + + /** + * @param array $state + */ + private function flushRleStringState(array $state, string &$bytes): void + { + match ($state['type'] ?? 'empty') { + 'empty', 'initialNullRun' => null, + 'nullRun' => $this->flushRleNullRun((int) $state['len'], $bytes), + 'loneVal' => $this->flushRleStringLiteralRun([(string) $state['value']], $bytes), + 'run' => $this->flushRleStringRun((string) $state['value'], (int) $state['len'], $bytes), + 'literalRun' => $this->flushRleStringLiteralRun(array_merge($state['run'], [(string) $state['last']]), $bytes), + default => throw new \InvalidArgumentException('Invalid string RLE encoder state.'), + }; + } + + /** + * @param list $run + * @return array + */ + private function afterFlushingStringLiteralRun(array $run, string &$bytes, string $nextType, ?string $value = null): array + { + $this->flushRleStringLiteralRun($run, $bytes); + + return $nextType === 'run' + ? ['type' => 'run', 'value' => $value, 'len' => 2] + : ['type' => $nextType, 'len' => 1]; + } + + /** + * @return array + */ + private function afterFlushingStringRun(string $value, int $len, string &$bytes, string $nextType, ?string $nextValue = null): array + { + $this->flushRleStringRun($value, $len, $bytes); + + return $nextType === 'loneVal' + ? ['type' => 'loneVal', 'value' => $nextValue] + : ['type' => $nextType, 'len' => 1]; + } + + /** + * @return array + */ + private function afterFlushingStringNullRun(int $len, string &$bytes, string $value): array + { + $this->flushRleNullRun($len, $bytes); + + return ['type' => 'loneVal', 'value' => $value]; + } + + private function flushRleStringRun(string $value, int $len, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int($len); + $bytes .= $this->encodeColumnarString($value); + } + + /** + * @param list $run + */ + private function flushRleStringLiteralRun(array $run, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int(-count($run)); + foreach ($run as $value) { + $bytes .= $this->encodeColumnarString($value); + } + } + + private function encodeColumnarString(string $value): string + { + return $this->encodeUnsignedLeb128Int(strlen($value)) . $value; + } + + private function decodeColumnarString(string $bytes, int &$offset): string + { + $length = $this->decodeUnsignedLeb128Int($bytes, $offset); + if ($offset + $length > strlen($bytes)) { + throw new \InvalidArgumentException('Truncated columnar string value.'); + } + + $value = substr($bytes, $offset, $length); + $offset += $length; + + return $value; + } + + /** + * @param array $value + * @return array{0:int,1:string} + */ + private function columnarEncodeScalarValue(array $value): array + { + $type = $value['type'] ?? null; + $rawValue = $value['value'] ?? null; + + return match ($type) { + 'null' => [0, ''], + 'boolean' => is_bool($rawValue) + ? [$rawValue ? 2 : 1, ''] + : throw new \InvalidArgumentException('Boolean scalar values must contain a boolean.'), + 'uint' => $this->columnarEncodeUintScalar($rawValue), + 'int' => $this->columnarEncodeSignedScalar($rawValue, 4, 'Int'), + 'float64' => is_float($rawValue) || is_int($rawValue) + ? [(8 << 4) | 5, pack('e', (float) $rawValue)] + : throw new \InvalidArgumentException('Float scalar values must contain a number.'), + 'string' => is_string($rawValue) + ? [(strlen($rawValue) << 4) | 6, $rawValue] + : throw new \InvalidArgumentException('String scalar values must contain a string.'), + 'bytes' => $this->columnarEncodeBytesScalar($rawValue, 7), + 'counter' => $this->columnarEncodeSignedScalar($rawValue, 8, 'Counter'), + 'timestamp' => $this->columnarEncodeSignedScalar($rawValue, 9, 'Timestamp'), + 'unknown' => $this->columnarEncodeUnknownScalar($value), + default => throw new \InvalidArgumentException('Unsupported scalar value column type.'), + }; + } + + /** + * @return array + */ + private function columnarDecodeScalarValue(int $meta, string $raw, int &$offset): array + { + $typeCode = $meta & 0x0f; + $length = $meta >> 4; + if ($offset + $length > strlen($raw)) { + throw new \InvalidArgumentException('Scalar value raw column is shorter than its metadata length.'); + } + + $rawSlice = substr($raw, $offset, $length); + $offset += $length; + + return match ($typeCode) { + 0 => $this->decodeFixedLengthScalar($rawSlice, 0, ['type' => 'null', 'value' => null]), + 1 => $this->decodeFixedLengthScalar($rawSlice, 0, ['type' => 'boolean', 'value' => false]), + 2 => $this->decodeFixedLengthScalar($rawSlice, 0, ['type' => 'boolean', 'value' => true]), + 3 => ['type' => 'uint', 'value' => $this->decodeUnsignedScalarRaw($rawSlice, 'Uint')], + 4 => ['type' => 'int', 'value' => $this->decodeSignedScalarRaw($rawSlice, 'Int')], + 5 => ['type' => 'float64', 'value' => $this->decodeFloatScalarRaw($rawSlice)], + 6 => ['type' => 'string', 'value' => $rawSlice], + 7 => ['type' => 'bytes', 'value' => $this->columnarBytesToList($rawSlice)], + 8 => ['type' => 'counter', 'value' => $this->decodeSignedScalarRaw($rawSlice, 'Counter')], + 9 => ['type' => 'timestamp', 'value' => $this->decodeSignedScalarRaw($rawSlice, 'Timestamp')], + default => ['type' => 'unknown', 'code' => $typeCode, 'value' => $this->columnarBytesToList($rawSlice)], + }; + } + + /** + * @return array{0:int,1:string} + */ + private function columnarEncodeUintScalar(mixed $value): array + { + if (! is_int($value) || $value < 0) { + throw new \InvalidArgumentException('Uint scalar values must contain a non-negative integer.'); + } + + $raw = $this->encodeUnsignedLeb128Int($value); + + return [(strlen($raw) << 4) | 3, $raw]; + } + + /** + * @return array{0:int,1:string} + */ + private function columnarEncodeSignedScalar(mixed $value, int $typeCode, string $label): array + { + if (! is_int($value)) { + throw new \InvalidArgumentException($label . ' scalar values must contain an integer.'); + } + + $raw = $this->encodeSignedLeb128Int($value); + + return [(strlen($raw) << 4) | $typeCode, $raw]; + } + + /** + * @return array{0:int,1:string} + */ + private function columnarEncodeBytesScalar(mixed $value, int $typeCode): array + { + $raw = $this->columnarByteListToString($value); + + return [(strlen($raw) << 4) | $typeCode, $raw]; + } + + /** + * @param array $value + * @return array{0:int,1:string} + */ + private function columnarEncodeUnknownScalar(array $value): array + { + $code = $value['code'] ?? null; + if (! is_int($code) || $code < 10 || $code > 15) { + throw new \InvalidArgumentException('Unknown scalar values must use a type code from 10 through 15.'); + } + + return $this->columnarEncodeBytesScalar($value['value'] ?? null, $code); + } + + private function decodeUnsignedScalarRaw(string $raw, string $label): int + { + $offset = 0; + $value = $this->decodeUnsignedLeb128Int($raw, $offset); + if ($offset !== strlen($raw)) { + throw new \InvalidArgumentException($label . ' scalar value contains extra bytes.'); + } + + return $value; + } + + private function decodeSignedScalarRaw(string $raw, string $label): int + { + $offset = 0; + $value = $this->decodeSignedLeb128Int($raw, $offset); + if ($offset !== strlen($raw)) { + throw new \InvalidArgumentException($label . ' scalar value contains extra bytes.'); + } + + return $value; + } + + private function decodeFloatScalarRaw(string $raw): float + { + if (strlen($raw) !== 8) { + throw new \InvalidArgumentException('Float scalar value must be exactly 8 bytes.'); + } + + $decoded = unpack('evalue', $raw); + + return (float) $decoded['value']; + } + + /** + * @param array $decoded + * @return array + */ + private function decodeFixedLengthScalar(string $raw, int $expectedLength, array $decoded): array + { + if (strlen($raw) !== $expectedLength) { + throw new \InvalidArgumentException('Scalar metadata length does not match type payload.'); + } + + return $decoded; + } + + private function columnarByteListToString(mixed $bytes): string + { + if (! is_array($bytes)) { + throw new \InvalidArgumentException('Byte scalar values must contain a byte array.'); + } + + $raw = ''; + foreach (array_values($bytes) as $byte) { + if (! is_int($byte) || $byte < 0 || $byte > 255) { + throw new \InvalidArgumentException('Byte scalar arrays must contain byte integers.'); + } + + $raw .= chr($byte); + } + + return $raw; + } + + /** + * @return list + */ + private function columnarBytesToList(string $bytes): array + { + if ($bytes === '') { + return []; + } + + return array_values(unpack('C*', $bytes)); + } + + /** + * @param array $state + * @return array + */ + private function appendRleUintValue(array $state, int $value, string &$bytes): array + { + return match ($state['type'] ?? 'empty') { + 'empty' => ['type' => 'loneVal', 'value' => $value], + 'loneVal' => ((int) $state['value']) === $value + ? ['type' => 'run', 'value' => $value, 'len' => 2] + : ['type' => 'literalRun', 'last' => $value, 'run' => [(int) $state['value']]], + 'run' => ((int) $state['value']) === $value + ? ['type' => 'run', 'value' => $value, 'len' => ((int) $state['len']) + 1] + : $this->afterFlushingUintRun((int) $state['value'], (int) $state['len'], $bytes, $value), + 'literalRun' => ((int) $state['last']) === $value + ? $this->afterFlushingUintLiteralRun($state['run'], $bytes, $value) + : ['type' => 'literalRun', 'last' => $value, 'run' => array_merge($state['run'], [(int) $state['last']])], + default => throw new \InvalidArgumentException('Invalid unsigned RLE encoder state.'), + }; + } + + /** + * @param array $state + */ + private function flushRleUintState(array $state, string &$bytes): void + { + match ($state['type'] ?? 'empty') { + 'empty' => null, + 'loneVal' => $this->flushRleUintLiteralRun([(int) $state['value']], $bytes), + 'run' => $this->flushRleUintRun((int) $state['value'], (int) $state['len'], $bytes), + 'literalRun' => $this->flushRleUintLiteralRun(array_merge($state['run'], [(int) $state['last']]), $bytes), + default => throw new \InvalidArgumentException('Invalid unsigned RLE encoder state.'), + }; + } + + /** + * @param list $run + * @return array + */ + private function afterFlushingUintLiteralRun(array $run, string &$bytes, int $value): array + { + $this->flushRleUintLiteralRun($run, $bytes); + + return ['type' => 'run', 'value' => $value, 'len' => 2]; + } + + /** + * @return array + */ + private function afterFlushingUintRun(int $value, int $len, string &$bytes, int $nextValue): array + { + $this->flushRleUintRun($value, $len, $bytes); + + return ['type' => 'loneVal', 'value' => $nextValue]; + } + + private function flushRleUintRun(int $value, int $len, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int($len); + $bytes .= $this->encodeUnsignedLeb128Int($value); + } + + /** + * @param list $run + */ + private function flushRleUintLiteralRun(array $run, string &$bytes): void + { + $bytes .= $this->encodeSignedLeb128Int(-count($run)); + foreach ($run as $value) { + $bytes .= $this->encodeUnsignedLeb128Int($value); + } + } + + /** + * @param mixed $range + */ + private function columnarRangeSlice(string $bytes, mixed $range, string $column): string + { + if ( + ! is_array($range) + || ! array_key_exists(0, $range) + || ! array_key_exists(1, $range) + || ! is_int($range[0]) + || ! is_int($range[1]) + || $range[0] < 0 + || $range[1] < $range[0] + || $range[1] > strlen($bytes) + ) { + throw new \InvalidArgumentException('Invalid columnar ' . $column . ' range.'); + } + + return substr($bytes, $range[0], $range[1] - $range[0]); + } + + private function encodeSignedLeb128Int(int $value): string + { + $bytes = ''; + do { + $byte = $value & 0x7f; + $value >>= 7; + $done = ($value === 0 && ($byte & 0x40) === 0) || ($value === -1 && ($byte & 0x40) !== 0); + if (! $done) { + $byte |= 0x80; + } + $bytes .= chr($byte); + } while (! $done); + + return $bytes; + } + + private function decodeSignedLeb128Int(string $bytes, int &$offset): int + { + $result = 0; + $shift = 0; + $length = strlen($bytes); + + while ($offset < $length) { + $byte = ord($bytes[$offset]); + ++$offset; + $result |= ($byte & 0x7f) << $shift; + $shift += 7; + if (($byte & 0x80) === 0) { + if ($shift < PHP_INT_SIZE * 8 && ($byte & 0x40) !== 0) { + $result |= -(1 << $shift); + } + + return $result; + } + + if ($shift >= PHP_INT_SIZE * 8 - 1) { + throw new \InvalidArgumentException('Signed LEB128 integer is too large for PHP int.'); + } + } + + throw new \InvalidArgumentException('Truncated signed LEB128 integer.'); + } + + private function utf16Length(string $text): int + { + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } + + private function clusterIndexFromUtf16Index(string $text, int $utf16Index): int + { + $utf16Index = max(0, $utf16Index); + $offset = 0; + foreach ($this->splitCharacters($text) as $clusterIndex => $cluster) { + $nextOffset = $offset + $this->utf16Length($cluster); + if ($utf16Index < $nextOffset) { + return $clusterIndex; + } + + if ($utf16Index === $nextOffset) { + return $clusterIndex + 1; + } + + $offset = $nextOffset; + } + + return count($this->splitCharacters($text)); + } + + private function clusterIndexFromEncodedIndex(string $text, int $index, string $encoding): int + { + return match ($encoding) { + 'UnicodeCodePoint', 'codepoint', 'code_point', 'unicode-code-point' => $this->clusterIndexFromMeasuredIndex( + $text, + max(0, $index), + static fn (string $cluster): int => mb_strlen($cluster, 'UTF-8') + ), + 'Utf8CodeUnit', 'utf8', 'utf8-code-unit' => $this->clusterIndexFromMeasuredIndex( + $text, + max(0, $index), + static fn (string $cluster): int => strlen($cluster) + ), + 'Utf16CodeUnit', 'utf16', 'utf16-code-unit' => $this->clusterIndexFromUtf16Index($text, $index), + 'GraphemeCluster', 'grapheme', 'grapheme-cluster' => min(max(0, $index), count($this->splitCharacters($text))), + default => throw new \InvalidArgumentException('Unsupported text index encoding.'), + }; + } + + /** + * @return array{0:int,1:int} + */ + private function clusterBoundaryAfterEncodedIndex(string $text, int $index, string $encoding): array + { + $index = max(0, $index); + + if (in_array($encoding, ['GraphemeCluster', 'grapheme', 'grapheme-cluster'], true)) { + $clusterIndex = min($index, count($this->splitCharacters($text))); + + return [$clusterIndex, $clusterIndex]; + } + + $measure = $this->encodedTextMeasure($encoding); + + $offset = 0; + foreach ($this->splitCharacters($text) as $clusterIndex => $cluster) { + if ($index <= $offset) { + return [$clusterIndex, $offset]; + } + + $nextOffset = $offset + $measure($cluster); + if ($index <= $nextOffset) { + return [$clusterIndex + 1, $nextOffset]; + } + + $offset = $nextOffset; + } + + return [count($this->splitCharacters($text)), $offset]; + } + + /** + * @return callable(string):int + */ + private function encodedTextMeasure(string $encoding): callable + { + return match ($encoding) { + 'GraphemeCluster', 'grapheme', 'grapheme-cluster' => fn (string $cluster): int => count($this->splitCharacters($cluster)), + 'UnicodeCodePoint', 'codepoint', 'code_point', 'unicode-code-point' => static fn (string $cluster): int => mb_strlen($cluster, 'UTF-8'), + 'Utf8CodeUnit', 'utf8', 'utf8-code-unit' => static fn (string $cluster): int => strlen($cluster), + 'Utf16CodeUnit', 'utf16', 'utf16-code-unit' => fn (string $cluster): int => $this->utf16Length($cluster), + default => throw new \InvalidArgumentException('Unsupported text index encoding.'), + }; + } + + /** + * @param callable(string):int $measure + */ + private function clusterIndexFromMeasuredIndex(string $text, int $targetIndex, callable $measure): int + { + $offset = 0; + foreach ($this->splitCharacters($text) as $clusterIndex => $cluster) { + $nextOffset = $offset + $measure($cluster); + if ($targetIndex < $nextOffset) { + return $clusterIndex; + } + + if ($targetIndex === $nextOffset) { + return $clusterIndex + 1; + } + + $offset = $nextOffset; + } + + return count($this->splitCharacters($text)); + } + + private function encodedIndexForClusterIndex(string $text, int $clusterIndex, string $encoding): int + { + return match ($encoding) { + 'UnicodeCodePoint', 'codepoint', 'code_point', 'unicode-code-point' => $this->measuredIndexForClusterIndex( + $text, + max(0, $clusterIndex), + static fn (string $cluster): int => mb_strlen($cluster, 'UTF-8') + ), + 'Utf8CodeUnit', 'utf8', 'utf8-code-unit' => $this->measuredIndexForClusterIndex( + $text, + max(0, $clusterIndex), + static fn (string $cluster): int => strlen($cluster) + ), + 'Utf16CodeUnit', 'utf16', 'utf16-code-unit' => $this->utf16IndexForClusterIndex($text, $clusterIndex), + 'GraphemeCluster', 'grapheme', 'grapheme-cluster' => min(max(0, $clusterIndex), count($this->splitCharacters($text))), + default => throw new \InvalidArgumentException('Unsupported text index encoding.'), + }; + } + + /** + * @param callable(string):int $measure + */ + private function measuredIndexForClusterIndex(string $text, int $clusterIndex, callable $measure): int + { + $offset = 0; + foreach ($this->splitCharacters($text) as $index => $cluster) { + if ($index >= $clusterIndex) { + break; + } + + $offset += $measure($cluster); + } + + return $offset; + } + + private function utf16IndexForClusterIndex(string $text, int $clusterIndex): int + { + $clusterIndex = max(0, $clusterIndex); + $offset = 0; + foreach ($this->splitCharacters($text) as $index => $cluster) { + if ($index >= $clusterIndex) { + break; + } + + $offset += $this->utf16Length($cluster); + } + + return $offset; + } + + /** + * @return array{0:int,1:int} + */ + private function clusterRangeForUtf16Range(string $text, int $start, int $end): array + { + $start = max(0, $start); + $end = max($start, $end); + $clusters = $this->splitCharacters($text); + if ($start === $end) { + return [$this->clusterIndexFromUtf16Index($text, $start), 0]; + } + + $offset = 0; + $first = null; + $last = null; + foreach ($clusters as $index => $cluster) { + $nextOffset = $offset + $this->utf16Length($cluster); + if ($nextOffset <= $start) { + $offset = $nextOffset; + continue; + } + + if ($offset >= $end) { + break; + } + + $first ??= $index; + $last = $index; + $offset = $nextOffset; + } + + if ($first === null || $last === null) { + return [$this->clusterIndexFromUtf16Index($text, $start), 0]; + } + + return [$first, $last - $first + 1]; + } + + /** + * @return list> + */ + private function assignmentPatches(string $key, mixed $value): array + { + if (is_string($value)) { + return [ + ['action' => 'put', 'path' => [$key], 'value' => ''], + ['action' => 'splice', 'path' => [$key, 0], 'value' => $value], + ]; + } + + if (is_array($value) && array_is_list($value)) { + $patches = [['action' => 'put', 'path' => [$key], 'value' => []]]; + if ($value !== [] && array_reduce($value, static fn (bool $allStrings, mixed $item): bool => $allStrings && is_string($item), true)) { + $patches[] = ['action' => 'insert', 'path' => [$key, 0], 'values' => array_fill(0, count($value), '')]; + foreach ($value as $index => $item) { + $patches[] = ['action' => 'splice', 'path' => [$key, $index, 0], 'value' => $item]; + } + + return $patches; + } + + foreach ($value as $index => $item) { + if (is_string($item)) { + $patches[] = ['action' => 'insert', 'path' => [$key, $index], 'values' => ['']]; + $patches[] = ['action' => 'splice', 'path' => [$key, $index, 0], 'value' => $item]; + continue; + } + + $patches[] = ['action' => 'insert', 'path' => [$key, $index], 'values' => [$item]]; + } + + return $patches; + } + + return [ + ['action' => 'put', 'path' => [$key], 'value' => $value], + ]; + } + + /** + * @return list> + */ + private function assignmentPatchesForDocumentValue(Document $document, string $key, mixed $value): array + { + if (! is_string($value)) { + return $this->assignmentPatches($key, $value); + } + + $marks = $document->marksFor([$key]); + if ($marks === []) { + return $this->assignmentPatches($key, $value); + } + + return $this->markedStringAssignmentPatches([$key], $value, $marks); + } + + /** + * @param list $path + * @param list $marks + * @return list> + */ + private function markedStringAssignmentPatches(array $path, string $value, array $marks): array + { + $patches = [['action' => 'put', 'path' => $path, 'value' => '']]; + $characters = $this->splitCharacters($value); + if ($characters === []) { + return $patches; + } + + $segmentStart = 0; + $segmentMarks = $this->activeMarksAtOffset($marks, 0); + $length = count($characters); + for ($index = 1; $index < $length; ++$index) { + $active = $this->activeMarksAtOffset($marks, $index); + if ($active === $segmentMarks) { + continue; + } + + $patch = [ + 'action' => 'splice', + 'path' => array_merge($path, [$segmentStart]), + 'value' => implode('', array_slice($characters, $segmentStart, $index - $segmentStart)), + ]; + if ($segmentMarks !== []) { + $patch['marks'] = $segmentMarks; + } + $patches[] = $patch; + $segmentStart = $index; + $segmentMarks = $active; + } + + $patch = [ + 'action' => 'splice', + 'path' => array_merge($path, [$segmentStart]), + 'value' => implode('', array_slice($characters, $segmentStart)), + ]; + if ($segmentMarks !== []) { + $patch['marks'] = $segmentMarks; + } + $patches[] = $patch; + + return $patches; + } + + /** + * @param list $path + * @param list $marks + * @return list> + */ + private function markedStringDiffPatches(string $before, string $after, array $path, array $marks): array + { + $beforeCharacters = $this->splitCharacters($before); + $afterCharacters = $this->splitCharacters($after); + $beforeLength = count($beforeCharacters); + $afterLength = count($afterCharacters); + $prefix = 0; + + while ($prefix < $beforeLength && $prefix < $afterLength && $beforeCharacters[$prefix] === $afterCharacters[$prefix]) { + ++$prefix; + } + + $suffix = 0; + while ( + $suffix < ($beforeLength - $prefix) + && $suffix < ($afterLength - $prefix) + && $beforeCharacters[$beforeLength - 1 - $suffix] === $afterCharacters[$afterLength - 1 - $suffix] + ) { + ++$suffix; + } + + $deleted = $beforeLength - $prefix - $suffix; + $inserted = array_slice($afterCharacters, $prefix, $afterLength - $prefix - $suffix); + if ($deleted !== 0 || $inserted === []) { + return $this->markedStringAssignmentPatches($path, $after, $marks); + } + + $patches = []; + $segmentStart = 0; + $segmentMarks = $this->activeMarksAtOffset($marks, $prefix); + $insertedLength = count($inserted); + for ($index = 1; $index < $insertedLength; ++$index) { + $active = $this->activeMarksAtOffset($marks, $prefix + $index); + if ($active === $segmentMarks) { + continue; + } + + $patch = [ + 'action' => 'splice', + 'path' => array_merge($path, [$prefix + $segmentStart]), + 'value' => implode('', array_slice($inserted, $segmentStart, $index - $segmentStart)), + ]; + if ($segmentMarks !== []) { + $patch['marks'] = $segmentMarks; + } + $patches[] = $patch; + $segmentStart = $index; + $segmentMarks = $active; + } + + $patch = [ + 'action' => 'splice', + 'path' => array_merge($path, [$prefix + $segmentStart]), + 'value' => implode('', array_slice($inserted, $segmentStart)), + ]; + if ($segmentMarks !== []) { + $patch['marks'] = $segmentMarks; + } + $patches[] = $patch; + + return $patches; + } + + /** + * @param list $marks + * @return array + */ + private function activeMarksAtOffset(array $marks, int $offset): array + { + $active = []; + foreach ($marks as $mark) { + if ($mark['start'] <= $offset && $offset < $mark['end']) { + $active[$mark['name']] = $mark['value']; + } + } + + ksort($active, SORT_STRING); + + return $active; + } + + /** + * @param list> $changes + * @return list> + */ + private function patchesForChanges(Document $after, array $changes): array + { + $patches = []; + foreach ($changes as $change) { + $ops = is_array($change['ops'] ?? null) ? array_values($change['ops']) : []; + foreach ($ops as $op) { + if (! is_array($op) || ! is_string($op['action'] ?? null)) { + continue; + } + + if ($op['action'] === 'set' && is_string($op['key'] ?? null)) { + $patches = array_merge($patches, $this->assignmentPatchesForDocumentValue($after, $op['key'], $after->toArray()[$op['key']] ?? null)); + continue; + } + + if ($op['action'] === 'delete' && is_string($op['key'] ?? null)) { + $patches[] = ['action' => 'del', 'path' => [$op['key']]]; + continue; + } + + if ($op['action'] === 'setNested' && is_array($op['path'] ?? null)) { + $path = array_values($op['path']); + $patches[] = ['action' => 'put', 'path' => $path, 'value' => $this->readPath($after, $path)]; + continue; + } + + if ($op['action'] === 'deleteNested' && is_array($op['path'] ?? null)) { + $patches[] = ['action' => 'del', 'path' => array_values($op['path'])]; + continue; + } + + if ($op['action'] === 'mark' && is_array($op['path'] ?? null) && is_array($op['marks'] ?? null)) { + $marks = $this->publicPatchMarks(array_values($op['marks'])); + if ($marks !== []) { + $patches[] = ['action' => 'mark', 'path' => array_values($op['path']), 'marks' => $marks]; + } + continue; + } + + if ($op['action'] === 'unmark' && is_array($op['path'] ?? null) && is_string($op['name'] ?? null)) { + $patches[] = [ + 'action' => 'mark', + 'path' => array_values($op['path']), + 'marks' => [[ + 'name' => $op['name'], + 'value' => null, + 'start' => max(0, (int) ($op['start'] ?? 0)), + 'end' => max(0, (int) ($op['end'] ?? $op['start'] ?? 0)), + ]], + ]; + continue; + } + + if ($op['action'] === 'putText' && is_string($op['key'] ?? null)) { + $patches[] = [ + 'action' => 'putSeq', + 'path' => [$op['key'], max(0, (int) ($op['index'] ?? 0))], + 'value' => is_string($op['value'] ?? null) ? $op['value'] : '', + ]; + continue; + } + + if ($op['action'] === 'splice' && is_string($op['key'] ?? null)) { + $index = max(0, (int) ($op['index'] ?? 0)); + $deleteCount = max(0, (int) ($op['deleteCount'] ?? 0)); + $insert = is_string($op['insert'] ?? null) ? $op['insert'] : ''; + if ($deleteCount > 0) { + $patches[] = ['action' => 'del', 'path' => [$op['key'], $index], 'length' => $deleteCount]; + } + + if ($insert !== '') { + $patch = ['action' => 'splice', 'path' => [$op['key'], $index], 'value' => $insert]; + $marks = $this->marksAt($after, [$op['key']], $index); + if ($marks !== []) { + $patch['marks'] = $marks; + } + $patches[] = $patch; + } + } + } + } + + return $patches; + } + + /** + * @param list> $marks + * @return list + */ + private function publicPatchMarks(array $marks): array + { + $public = []; + foreach ($marks as $mark) { + if (! is_array($mark) || ! is_string($mark['name'] ?? null)) { + continue; + } + + $start = max(0, (int) ($mark['start'] ?? 0)); + $end = max($start, (int) ($mark['end'] ?? $start)); + $public[] = [ + 'name' => $mark['name'], + 'value' => $mark['value'] ?? true, + 'start' => $start, + 'end' => $end, + ]; + } + + return $public; + } + + /** + * @return list> + */ + private function markPatchesBetweenDocuments(Document $before, Document $after): array + { + $beforeMarks = $this->marksByPathKey($before); + $afterMarks = $this->marksByPathKey($after); + $pathKeys = array_values(array_unique(array_merge(array_keys($beforeMarks), array_keys($afterMarks)))); + sort($pathKeys, SORT_STRING); + + $patches = []; + foreach ($pathKeys as $pathKey) { + $path = $afterMarks[$pathKey]['path'] ?? $beforeMarks[$pathKey]['path'] ?? null; + if (! is_array($path)) { + continue; + } + + $marks = array_merge( + $this->addedMarkSegments($beforeMarks[$pathKey]['marks'] ?? [], $afterMarks[$pathKey]['marks'] ?? []), + $this->removedMarkSegments($beforeMarks[$pathKey]['marks'] ?? [], $afterMarks[$pathKey]['marks'] ?? []) + ); + if ($marks === []) { + continue; + } + + usort( + $marks, + static fn (array $left, array $right): int => [$left['start'], $left['end'], $left['name']] <=> [$right['start'], $right['end'], $right['name']] + ); + $patches[] = ['action' => 'mark', 'path' => array_values($path), 'marks' => $marks]; + } + + return $patches; + } + + /** + * @return array,marks:list}> + */ + private function marksByPathKey(Document $document): array + { + $marks = []; + foreach ($document->allMarks() as $entry) { + $marks[$this->pathKey($entry['path'])] = [ + 'path' => $entry['path'], + 'marks' => $entry['marks'], + ]; + } + + return $marks; + } + + /** + * @param list $before + * @param list $after + * @return list + */ + private function addedMarkSegments(array $before, array $after): array + { + $added = []; + foreach ($after as $mark) { + foreach ($this->subtractMarkIntervals($mark, $this->matchingMarkIntervals($before, $mark)) as $segment) { + $added[] = [ + 'name' => $mark['name'], + 'value' => $mark['value'], + 'start' => $segment[0], + 'end' => $segment[1], + ]; + } + } + + return $added; + } + + /** + * @param list $before + * @param list $after + * @return list + */ + private function removedMarkSegments(array $before, array $after): array + { + $removed = []; + foreach ($before as $mark) { + foreach ($this->subtractMarkIntervals($mark, $this->matchingMarkIntervals($after, $mark)) as $segment) { + $removed[] = [ + 'name' => $mark['name'], + 'value' => null, + 'start' => $segment[0], + 'end' => $segment[1], + ]; + } + } + + return $removed; + } + + /** + * @param list $marks + * @return list + */ + private function matchingMarkIntervals(array $marks, array $target): array + { + $matching = []; + foreach ($marks as $mark) { + if ($mark['name'] === $target['name'] && $mark['value'] === $target['value']) { + $matching[] = ['start' => $mark['start'], 'end' => $mark['end']]; + } + } + + usort($matching, static fn (array $left, array $right): int => [$left['start'], $left['end']] <=> [$right['start'], $right['end']]); + + return $matching; + } + + /** + * @param array{name:string,value:mixed,start:int,end:int} $mark + * @param list $coverage + * @return list + */ + private function subtractMarkIntervals(array $mark, array $coverage): array + { + $segments = [[$mark['start'], $mark['end']]]; + foreach ($coverage as $cover) { + $next = []; + foreach ($segments as $segment) { + [$start, $end] = $segment; + $coverStart = max($start, $cover['start']); + $coverEnd = min($end, $cover['end']); + if ($coverStart >= $coverEnd) { + $next[] = [$start, $end]; + continue; + } + + if ($start < $coverStart) { + $next[] = [$start, $coverStart]; + } + + if ($coverEnd < $end) { + $next[] = [$coverEnd, $end]; + } + } + + $segments = $next; + if ($segments === []) { + break; + } + } + + return array_values(array_filter($segments, static fn (array $segment): bool => $segment[0] < $segment[1])); + } + + /** + * @param list> $patches + */ + private function emitPatches(Document $before, Document $after, array $patches): void + { + if ($this->patchCallback === null || $patches === []) { + return; + } + + ($this->patchCallback)($patches, ['before' => $before, 'after' => $after]); + } + + private function emitDocumentPatchCallback(Document $before, Document $after, string $source, ?array $patches = null): void + { + if (! $this->documentPatchCallbacks->contains($before)) { + return; + } + + $patches ??= $this->patchesBetweenDocuments($before, $after); + if ($patches === []) { + $this->documentPatchCallbacks[$after] = $this->documentPatchCallbacks[$before]; + return; + } + + $patchCallback = $this->documentPatchCallbacks[$before]; + $this->documentPatchCallbacks[$after] = $patchCallback; + $patchCallback($patches, ['before' => $before, 'after' => $after, 'source' => $source]); + } + + private function mutableClone(Document $document): Document + { + return $document->clone(null, false); + } + + private function preserveFrozen(Document $source, Document $next): Document + { + $result = $source->isFrozen() ? $next->withFrozen(true) : $next; + if ($this->documentDiffCursors->contains($source)) { + $this->documentDiffCursors[$result] = $this->documentDiffCursors[$source]; + } + + return $result; + } + + private function isolationEditDocument(IsolatedDocument $document): Document + { + $visibleDocument = $document->visibleDocument()->withFrozen(false); + $visibleDocument->ensureSequenceAtLeast($document->hiddenDocument()->stats()['sequence']); + + return $visibleDocument; + } + + /** + * @param array $block + * @return array{parents:list,type:string|ImmutableString,attrs:array} + */ + private function normalizeBlock(array $block): array + { + $parents = []; + if (is_array($block['parents'] ?? null)) { + foreach ($block['parents'] as $parent) { + if (is_string($parent) || $parent instanceof ImmutableString) { + $parents[] = $parent; + } + } + } + $type = $block['type'] ?? ''; + $normalized = []; + foreach ($block as $key => $value) { + if (! is_string($key) || in_array($key, ['parents', 'type', 'attrs'], true)) { + continue; + } + + $normalized[$key] = $value; + } + + return array_merge([ + 'parents' => $parents, + 'type' => is_string($type) || $type instanceof ImmutableString ? $type : '', + 'attrs' => is_array($block['attrs'] ?? null) ? $block['attrs'] : [], + ], $normalized); + } + + /** + * @param list $path + * @return list + */ + private function blockMarks(Document $document, array $path): array + { + return array_values(array_filter( + $document->marksFor(array_values($path)), + static fn (array $mark): bool => ($mark['name'] ?? null) === self::BLOCK_MARK_NAME + )); + } + + /** + * @param list $path + * @return array,type:string|ImmutableString,attrs:array}> + */ + private function blockValuesByIndex(Document $document, array $path): array + { + $blocks = []; + foreach ($this->blockMarks($document, $path) as $mark) { + if (is_array($mark['value'] ?? null)) { + $blocks[(int) $mark['start']] = $this->normalizeBlock($mark['value']); + } + } + ksort($blocks); + + return $blocks; + } + + /** + * @param list $path + * @param array,type:string|ImmutableString,attrs:array}> $beforeBlocks + * @param array,type:string|ImmutableString,attrs:array}> $afterBlocks + * @return list> + */ + private function blockUpdateSpanPatches(array $path, array $beforeBlocks, array $afterBlocks): array + { + $patches = []; + foreach ($afterBlocks as $index => $afterBlock) { + $beforeBlock = $beforeBlocks[$index] ?? null; + if ($beforeBlock === null) { + continue; + } + + $beforeParents = $beforeBlock['parents']; + $afterParents = $afterBlock['parents']; + if (array_slice($afterParents, 0, count($beforeParents)) !== $beforeParents) { + continue; + } + + foreach (array_slice($afterParents, count($beforeParents)) as $offset => $parent) { + $patches[] = [ + 'action' => 'insert', + 'path' => array_merge($path, [$index, 'parents', count($beforeParents) + $offset]), + 'values' => [$parent], + ]; + } + } + + return $patches; + } + + /** + * @param array{defaultExpand?:string,perMarkExpand?:array} $options + */ + private function expandForMark(string $name, array $options): string + { + $perMark = is_array($options['perMarkExpand'] ?? null) ? $options['perMarkExpand'] : []; + $expand = is_string($perMark[$name] ?? null) ? $perMark[$name] : ($options['defaultExpand'] ?? 'both'); + + return $this->normalizeExpandMode($expand, 'both'); + } + + private function normalizeExpandMode(mixed $expand, string $default): string + { + if (! is_string($expand)) { + return $default; + } + + $expand = match ($expand) { + 'before' => 'start', + 'after' => 'end', + default => $expand, + }; + + return in_array($expand, ['none', 'start', 'end', 'both'], true) ? $expand : $default; + } + + /** + * @param list $path + */ + private function readPath(Document $document, array $path): mixed + { + $value = $document->toArray(); + foreach ($path as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + return null; + } + + $value = $value[$key]; + } + + return $value; + } + + /** + * @param list $path + */ + private function readRawPath(Document $document, array $path): mixed + { + $value = $document->rootValues(); + foreach ($path as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + return null; + } + + $value = $value[$key]; + } + + return $value; + } + + /** + * @return list + */ + private function iterDocumentChildren(mixed $value): array + { + if ($value instanceof TextValue) { + return [[ + 'key' => '', + 'pathKey' => 0, + 'value' => $value->toString(), + ]]; + } + + if (! is_array($value)) { + return []; + } + + $children = []; + foreach ($value as $key => $childValue) { + $children[] = [ + 'key' => is_int($key) ? '' : (string) $key, + 'pathKey' => $key, + 'value' => $childValue, + ]; + } + + return $children; + } + + /** + * @param list $path + * @return array{key:string,path:list,kind:string,value:mixed} + */ + private function iterDocumentEntry(string $key, array $path, mixed $value): array + { + if ($value instanceof TextValue) { + return [ + 'key' => $key, + 'path' => $path, + 'kind' => 'text', + 'value' => $value->toString(), + ]; + } + + if (is_array($value)) { + return [ + 'key' => $key, + 'path' => $path, + 'kind' => array_is_list($value) ? 'list' : 'map', + 'value' => $this->materializeIteratorValue($value), + ]; + } + + return [ + 'key' => $key, + 'path' => $path, + 'kind' => 'scalar', + 'value' => $this->materializeIteratorValue($value), + ]; + } + + private function materializeIteratorValue(mixed $value): mixed + { + if ($value instanceof TextValue) { + return $value->toString(); + } + + if ($value instanceof Counter) { + return $value->copy(); + } + + if ($value instanceof BytesValue) { + return $value->copy(); + } + + if ($value instanceof ImmutableString) { + return new ImmutableString($value->toString()); + } + + if ($value instanceof \DateTimeInterface) { + return \DateTimeImmutable::createFromInterface($value); + } + + if (is_array($value)) { + $materialized = []; + foreach ($value as $key => $item) { + $materialized[$key] = $this->materializeIteratorValue($item); + } + + return $materialized; + } + + return $value; + } + + /** + * @param list $path + */ + private function assertTextOperationTarget(Document $document, array $path): void + { + $value = $this->readPath($document, $path); + if ($value === null || is_string($value)) { + return; + } + + throw new \InvalidArgumentException('Invalid text operation on non-text value.'); + } + + /** + * @param list $path + */ + private function assertPathContainerCompatibility(Document $document, array $path): void + { + if ($path === []) { + return; + } + + $value = $document->toArray(); + $lastIndex = count($path) - 1; + foreach ($path as $offset => $key) { + if (! is_array($value)) { + throw new \InvalidArgumentException('Cannot modify a scalar value as a container.'); + } + + if ($offset === 0) { + if (is_int($key)) { + throw new \InvalidArgumentException('Cannot use a list index on a map value.'); + } + } elseif ($value !== []) { + $containerIsList = array_is_list($value); + if ($containerIsList && ! is_int($key)) { + throw new \InvalidArgumentException('Cannot use a map key on a list value.'); + } + + if (! $containerIsList && is_int($key)) { + throw new \InvalidArgumentException('Cannot use a list index on a map value.'); + } + } + + if ($offset === $lastIndex || ! array_key_exists($key, $value)) { + return; + } + + $value = $value[$key]; + } + } + + /** + * @param list $path + */ + private function resolveCursorIndex(Document $document, array $path, int|string $index): int + { + if (is_int($index)) { + return max(0, $index); + } + + if ($index === 'start') { + return 0; + } + + if ($index === 'end') { + return count($this->splitCharacters($this->cursorTextAtPath($document, $path))); + } + + $decoded = $this->decodeCursorPayload($index); + if (is_array($decoded)) { + return $this->cursorPositionFromPayload($document, array_values($path), $decoded); + } + + return is_numeric($index) ? max(0, (int) $index) : 0; + } + + /** + * @param list $path + * @return array{0:int,1:int} + */ + private function resolveTextSpliceRange(Document $document, array $path, int|string $index, int $deleteCount): array + { + $path = array_values($path); + $cursorPayload = is_string($index) ? $this->decodeCursorPayload($index) : null; + $resolvedIndex = $this->resolveCursorIndex($document, $path, $index); + + if (! is_array($cursorPayload)) { + if ($deleteCount < 0) { + $deleteLength = abs($deleteCount); + + return [max(0, $resolvedIndex - $deleteLength), $deleteLength]; + } + + return [$resolvedIndex, $deleteCount]; + } + + if ($deleteCount === 0) { + return [$resolvedIndex, 0]; + } + + $text = $this->cursorTextAtPath($document, $path); + $utf16Index = $this->utf16IndexForClusterIndex($text, $resolvedIndex); + if ($deleteCount < 0) { + $start = max(0, $utf16Index + $deleteCount); + $end = $utf16Index; + } else { + $start = $utf16Index; + $end = $utf16Index + $deleteCount; + } + + [$deleteIndex, $deleteLength] = $this->clusterRangeForUtf16Range($text, $start, $end); + if ($deleteLength === 0) { + return [$resolvedIndex, 0]; + } + + return [$deleteIndex, $deleteLength]; + } + + /** + * @return array|null + */ + private function decodeCursorPayload(string $cursor): ?array + { + $raw = base64_decode($cursor, true); + if (! is_string($raw)) { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param list $path + * @param array $payload + */ + private function cursorPositionFromPayload(Document $document, array $path, array $payload): int + { + $currentText = $this->cursorTextAtPath($document, $path); + $currentLength = count($this->splitCharacters($currentText)); + $kind = is_string($payload['kind'] ?? null) ? $payload['kind'] : 'index'; + if ($kind === 'start') { + return 0; + } + + if ($kind === 'end') { + return $currentLength; + } + + $originalText = is_string($payload['text'] ?? null) ? $payload['text'] : ''; + $originalLength = count($this->splitCharacters($originalText)); + $index = max(0, min((int) ($payload['index'] ?? 0), $originalLength)); + $move = is_string($payload['move'] ?? null) ? $payload['move'] : 'after'; + + return $this->translateCursorIndex($originalText, $currentText, $index, $move); + } + + /** + * @param list> $patches + * @param list $textPath + * @return list> + */ + private function encodeTextPatchIndexes(Document $document, array $patches, array $textPath, string $encoding): array + { + $text = $this->cursorTextAtPath($document, $textPath); + $pathLength = count($textPath); + + return array_map( + function (array $patch) use ($text, $textPath, $pathLength, $encoding): array { + $path = is_array($patch['path'] ?? null) ? array_values($patch['path']) : []; + if (array_slice($path, 0, $pathLength) === $textPath && isset($path[$pathLength]) && is_int($path[$pathLength])) { + $path[$pathLength] = $this->encodedIndexForClusterIndex($text, $path[$pathLength], $encoding); + $patch['path'] = $path; + } + + if (array_slice($path, 0, $pathLength) === $textPath && is_array($patch['marks'] ?? null)) { + $patch['marks'] = array_map( + function (array $mark) use ($text, $encoding): array { + $mark['start'] = $this->encodedIndexForClusterIndex($text, (int) ($mark['start'] ?? 0), $encoding); + $mark['end'] = $this->encodedIndexForClusterIndex($text, (int) ($mark['end'] ?? $mark['start'] ?? 0), $encoding); + + return $mark; + }, + array_values($patch['marks']) + ); + } + + return $patch; + }, + $patches + ); + } + + /** + * @param list $path + */ + private function cursorTextAtPath(Document $document, array $path): string + { + $value = $this->readPath($document, array_values($path)); + + return is_string($value) ? $value : ''; + } + + private function translateCursorIndex(string $originalText, string $currentText, int $index, string $move): int + { + $original = $this->splitCharacters($originalText); + $current = $this->splitCharacters($currentText); + $originalLength = count($original); + $currentLength = count($current); + $index = max(0, min($index, $originalLength)); + + if ($original === $current) { + return min($index, $currentLength); + } + + $prefix = 0; + while ( + $prefix < $originalLength + && $prefix < $currentLength + && $original[$prefix] === $current[$prefix] + ) { + ++$prefix; + } + + $suffix = 0; + while ( + $suffix < ($originalLength - $prefix) + && $suffix < ($currentLength - $prefix) + && $original[$originalLength - 1 - $suffix] === $current[$currentLength - 1 - $suffix] + ) { + ++$suffix; + } + + $deleted = $originalLength - $prefix - $suffix; + $inserted = $currentLength - $prefix - $suffix; + if ($index < $prefix) { + return $index; + } + + if ($index > $prefix + $deleted || ($deleted === 0 && $index >= $prefix)) { + return max(0, min($currentLength, $index + $inserted - $deleted)); + } + + if ($move === 'before') { + return max(0, $prefix - 1); + } + + return min($currentLength, $prefix + $inserted); + } + + /** + * @param list $path + */ + private function writePath(Document $document, array $path, mixed $value): Document + { + if ($path === []) { + return $document; + } + + if (count($path) === 1 && (is_string($path[0]) || is_int($path[0]))) { + return $this->set($document, (string) $path[0], $value); + } + + return $this->setNested($document, $path, $value); + } + + /** + * @param array $document + * @param list $path + */ + private function readArrayPath(array $document, array $path): mixed + { + $value = $document; + foreach ($path as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + return null; + } + + $value = $value[$key]; + } + + return $value; + } + + /** + * @param array $document + * @param list $path + * @return array + */ + private function writeArrayPath(array $document, array $path, mixed $value): array + { + if ($path === []) { + return $document; + } + + $key = array_shift($path); + if ($path === []) { + $document[$key] = $value; + return $document; + } + + $child = is_array($document[$key] ?? null) ? $document[$key] : []; + $document[$key] = $this->writeArrayPath($child, $path, $value); + + return $document; + } + + /** + * @param array $state + * @return array{sentHeads:list|null,lastSentHeads:list|null,lastSentReadOnly:bool|null,receivedHeads:list|null,sharedHeads:list,requestedHeads:list,needHeads:list,inFlightHashes:list,readOnly:bool,peerReadOnly:bool,theirCapabilities:list|null} + */ + private function normalizeSyncState(array $state): array + { + $sentHeads = $this->normalizeOptionalHeads($state['sentHeads'] ?? $state['lastSentHeads'] ?? null); + $lastSentReadOnly = array_key_exists('lastSentReadOnly', $state) + ? (is_bool($state['lastSentReadOnly']) ? $state['lastSentReadOnly'] : null) + : ($sentHeads === null ? null : (bool) ($state['readOnly'] ?? false)); + $theirCapabilities = array_key_exists('theirCapabilities', $state) + ? $this->normalizeSyncCapabilities($state['theirCapabilities']) + : ['syncReset']; + + return [ + 'sentHeads' => $sentHeads, + 'lastSentHeads' => $sentHeads, + 'lastSentReadOnly' => $lastSentReadOnly, + 'receivedHeads' => $this->normalizeOptionalHeads($state['receivedHeads'] ?? null), + 'sharedHeads' => $this->normalizeHeads($state['sharedHeads'] ?? []), + 'requestedHeads' => $this->normalizeHeads($state['requestedHeads'] ?? []), + 'needHeads' => $this->normalizeHeads($state['needHeads'] ?? []), + 'inFlightHashes' => $this->normalizeHeads($state['inFlightHashes'] ?? []), + 'readOnly' => (bool) ($state['readOnly'] ?? false), + 'peerReadOnly' => (bool) ($state['peerReadOnly'] ?? false), + 'theirCapabilities' => $theirCapabilities, + ]; + } + + /** + * @return list|null + */ + private function normalizeSyncCapabilities(mixed $capabilities): ?array + { + if ($capabilities === null) { + return null; + } + + if (! is_array($capabilities)) { + throw new \InvalidArgumentException('Sync capabilities must be a string list or null.'); + } + + $normalized = []; + foreach (array_values($capabilities) as $capability) { + if (! is_string($capability) || $capability === '') { + throw new \InvalidArgumentException('Sync capabilities must contain non-empty strings.'); + } + + $normalized[$capability] = $capability; + } + + $normalized = array_values($normalized); + sort($normalized, SORT_STRING); + + return $normalized; + } + + /** + * @param array $patchLog + * @return array{active:bool,documentHeads:list|null,patches:list>} + */ + private function normalizePatchLog(array $patchLog): array + { + $patches = []; + foreach (is_array($patchLog['patches'] ?? null) ? $patchLog['patches'] : [] as $patch) { + if (is_array($patch)) { + $patches[] = $patch; + } + } + + return [ + 'active' => (bool) ($patchLog['active'] ?? true), + 'documentHeads' => $this->normalizeOptionalHeads($patchLog['documentHeads'] ?? null), + 'patches' => $patches, + ]; + } + + /** + * @param array{active:bool,documentHeads:list|null,patches:list>} $patchLog + */ + private function assertPatchLogMatchesDocument(Document $document, array $patchLog): void + { + if ($patchLog['documentHeads'] !== null && $patchLog['documentHeads'] !== $document->heads()) { + throw new \RuntimeException('PatchLogMismatch: patch log belongs to another document.'); + } + } + + /** + * @return list|null + */ + private function normalizeOptionalHeads(mixed $heads): ?array + { + if ($heads === null) { + return null; + } + + return $this->normalizeHeads($heads); + } + + /** + * @return list + */ + private function normalizeHeads(mixed $heads): array + { + if (! is_array($heads)) { + return []; + } + + $normalized = []; + foreach ($heads as $head) { + if (is_string($head)) { + $normalized[] = $head; + } + } + + return array_values($normalized); + } + + /** + * @param mixed $have + * @return list + */ + private function hashesFromSyncHave(mixed $have): array + { + if (! is_array($have)) { + return []; + } + + $hashes = []; + foreach ($have as $entry) { + if (! is_array($entry)) { + continue; + } + + foreach ($this->normalizeHeads($entry['hashes'] ?? []) as $hash) { + $hashes[] = $hash; + } + } + + return array_values(array_unique($hashes)); + } + + /** + * @param list $heads + * @return list> + */ + private function changesMatchingRequestedHeads(Document $document, array $heads): array + { + if ($heads === []) { + return []; + } + + $changesByHash = []; + foreach ($document->getAllChanges() as $change) { + if (is_string($change['hash'] ?? null)) { + $changesByHash[$change['hash']] = $change; + } + } + + $changes = []; + $added = []; + $appendChangeWithDependencies = function (string $hash) use (&$appendChangeWithDependencies, &$changes, &$added, $changesByHash): void { + if (isset($added[$hash]) || ! isset($changesByHash[$hash])) { + return; + } + + $change = $changesByHash[$hash]; + $changes[] = $change; + $added[$hash] = true; + foreach (is_array($change['deps'] ?? null) ? $change['deps'] : [] as $dep) { + if (is_string($dep)) { + $appendChangeWithDependencies($dep); + } + } + }; + + foreach ($heads as $head) { + $appendChangeWithDependencies($head); + } + + return $changes; + } + + /** + * @param list $heads + * @return list + */ + private function missingHeads(Document $document, array $heads): array + { + return array_values(array_filter( + $this->normalizeHeads($heads), + static fn (string $head): bool => ! $document->hasHeads([$head]) + )); + } + + /** + * @param list $heads + * @return list + */ + private function minimalKnownHeads(Document $document, array $heads): array + { + $heads = $this->normalizeHeads($heads); + if (count($heads) < 2) { + return $heads; + } + + $depsByHash = []; + foreach ($document->getAllChanges() as $change) { + if (is_string($change['hash'] ?? null)) { + $depsByHash[$change['hash']] = $this->normalizeHeads($change['deps'] ?? []); + } + } + + $ancestorOfAnotherHead = []; + foreach ($heads as $head) { + $stack = $depsByHash[$head] ?? []; + while ($stack !== []) { + $hash = array_pop($stack); + if (! is_string($hash) || isset($ancestorOfAnotherHead[$hash])) { + continue; + } + + $ancestorOfAnotherHead[$hash] = true; + foreach ($depsByHash[$hash] ?? [] as $dep) { + $stack[] = $dep; + } + } + } + + $minimal = array_values(array_filter( + $heads, + static fn (string $head): bool => ! isset($ancestorOfAnotherHead[$head]) + )); + sort($minimal, SORT_STRING); + + return $minimal; + } + + /** + * @param list $heads + * @return list> + */ + private function changesWithDependenciesSince(Document $document, array $heads): array + { + $selected = $document->getChangesSince($heads); + if ($selected === [] || $heads === []) { + return $selected; + } + + $changesByHash = []; + foreach ($document->getAllChanges() as $change) { + if (is_string($change['hash'] ?? null)) { + $changesByHash[$change['hash']] = $change; + } + } + + $needed = []; + $stack = []; + foreach ($selected as $change) { + if (is_string($change['hash'] ?? null)) { + $stack[] = $change['hash']; + } + } + + while ($stack !== []) { + $hash = array_pop($stack); + if (! is_string($hash) || isset($needed[$hash])) { + continue; + } + + $change = $changesByHash[$hash] ?? null; + if (! is_array($change)) { + continue; + } + + $needed[$hash] = true; + $deps = is_array($change['deps'] ?? null) ? $change['deps'] : []; + foreach ($deps as $dep) { + if (is_string($dep) && ! isset($needed[$dep])) { + $stack[] = $dep; + } + } + } + + return array_values(array_filter( + $document->getAllChanges(), + static fn (array $change): bool => is_string($change['hash'] ?? null) && isset($needed[$change['hash']]) + )); + } + + /** + * @return list + */ + private function validateDiffHeads(Document $document, mixed $heads, string $label): array + { + if (! is_array($heads) || ! array_is_list($heads)) { + throw new \InvalidArgumentException('invalid ' . $label . ' heads'); + } + + foreach ($heads as $head) { + if (! is_string($head)) { + throw new \InvalidArgumentException('invalid ' . $label . ' heads'); + } + } + + if ($heads !== [] && ! $document->hasHeads($heads)) { + throw new \InvalidArgumentException('invalid ' . $label . ' heads'); + } + + return array_values($heads); + } + + /** + * @param list $beforeHeads + * @param list $afterHeads + * @return array + */ + private function replacementContainerPaths(Document $document, array $beforeHeads, array $afterHeads): array + { + $afterView = $document->view($afterHeads); + if ($beforeHeads !== [] && ! $afterView->hasHeads($beforeHeads)) { + return []; + } + + $paths = []; + foreach ($afterView->getChangesSince($beforeHeads) as $change) { + foreach (is_array($change['ops'] ?? null) ? $change['ops'] : [] as $op) { + if ( + is_array($op) + && ($op['action'] ?? null) === 'setNested' + && is_array($op['path'] ?? null) + && $this->encodedValueIsContainer($op['value'] ?? null) + ) { + $paths[$this->pathKey(array_values($op['path']))] = true; + } + } + } + + return $paths; + } + + private function encodedValueIsContainer(mixed $encoded): bool + { + return is_array($encoded) && ($encoded['type'] ?? null) === 'array' && is_array($encoded['value'] ?? null); + } + + /** + * @param list $path + */ + private function pathKey(array $path): string + { + return json_encode(array_values($path), JSON_THROW_ON_ERROR); + } + + /** + * @param list $path + * @return list> + */ + private function diffValues(mixed $before, mixed $after, array $path, array $replacementPaths = [], array $marksByPathKey = []): array + { + if ($before === $after) { + return []; + } + + if (! is_array($before) && is_array($after)) { + $container = $path === [] ? [] : [['action' => 'put', 'path' => $path, 'value' => []]]; + + return array_merge($container, $this->diffContainerContents($after, $path, $replacementPaths, $marksByPathKey)); + } + + if ($path === [] && $before === [] && is_array($after) && ! array_is_list($after)) { + return $this->diffMapValues([], $after, $path, $replacementPaths, $marksByPathKey); + } + + if (is_string($before) && is_string($after)) { + $marks = $marksByPathKey[$this->pathKey($path)]['marks'] ?? []; + if ($marks !== []) { + return $this->markedStringDiffPatches($before, $after, $path, $marks); + } + } + + if (is_string($after)) { + $marks = $marksByPathKey[$this->pathKey($path)]['marks'] ?? []; + if ($marks !== []) { + return $this->markedStringAssignmentPatches($path, $after, $marks); + } + + $patches = [['action' => 'put', 'path' => $path, 'value' => '']]; + if ($after !== '') { + $patches[] = ['action' => 'splice', 'path' => array_merge($path, [0]), 'value' => $after]; + } + + return $patches; + } + + if (is_array($before) && is_array($after) && array_is_list($before) && array_is_list($after)) { + return $this->diffListValues($before, $after, $path, $marksByPathKey); + } + + if (is_array($before) && is_array($after) && ! array_is_list($before) && ! array_is_list($after)) { + return $this->diffMapValues($before, $after, $path, $replacementPaths, $marksByPathKey); + } + + return [['action' => 'put', 'path' => $path, 'value' => $after]]; + } + + /** + * @param array $before + * @param array $after + * @param list $path + * @return list> + */ + private function diffMapValues(array $before, array $after, array $path, array $replacementPaths = [], array $marksByPathKey = []): array + { + $containerPuts = []; + $existingPatches = []; + $newContainerChildren = []; + + foreach ($after as $key => $value) { + $childPath = array_merge($path, [$key]); + if (is_array($value) && isset($replacementPaths[$this->pathKey($childPath)])) { + $containerPuts[] = ['action' => 'put', 'path' => $childPath, 'value' => []]; + $newContainerChildren = array_merge($newContainerChildren, $this->diffContainerContents($value, $childPath, $replacementPaths, $marksByPathKey)); + continue; + } + + if (! array_key_exists($key, $before)) { + if (is_array($value)) { + $containerPuts[] = ['action' => 'put', 'path' => $childPath, 'value' => []]; + $newContainerChildren = array_merge($newContainerChildren, $this->diffContainerContents($value, $childPath, $replacementPaths, $marksByPathKey)); + continue; + } + + $marks = $marksByPathKey[$this->pathKey($childPath)]['marks'] ?? []; + if (is_string($value) && $marks !== []) { + $existingPatches = array_merge($existingPatches, $this->markedStringAssignmentPatches($childPath, $value, $marks)); + continue; + } + + if ($path !== [] && is_string($value)) { + $existingPatches[] = ['action' => 'put', 'path' => $childPath, 'value' => '']; + if ($value !== '') { + $existingPatches[] = ['action' => 'splice', 'path' => array_merge($childPath, [0]), 'value' => $value]; + } + continue; + } + + $existingPatches[] = ['action' => 'put', 'path' => $childPath, 'value' => $value]; + continue; + } + + $existingPatches = array_merge($existingPatches, $this->diffValues($before[$key], $value, $childPath, $replacementPaths, $marksByPathKey)); + } + + foreach ($before as $key => $_value) { + if (! array_key_exists($key, $after)) { + $existingPatches[] = ['action' => 'del', 'path' => array_merge($path, [$key])]; + } + } + + return array_values(array_merge($containerPuts, $existingPatches, $newContainerChildren)); + } + + /** + * @param list $before + * @param list $after + * @param list $path + * @return list> + */ + private function diffListValues(array $before, array $after, array $path, array $marksByPathKey = []): array + { + $beforeLength = count($before); + $afterLength = count($after); + $prefix = 0; + while ($prefix < $beforeLength && $prefix < $afterLength && $before[$prefix] === $after[$prefix]) { + ++$prefix; + } + + $suffix = 0; + while ( + $suffix < ($beforeLength - $prefix) + && $suffix < ($afterLength - $prefix) + && $before[$beforeLength - 1 - $suffix] === $after[$afterLength - 1 - $suffix] + ) { + ++$suffix; + } + + $deleted = $beforeLength - $prefix - $suffix; + $inserted = array_slice($after, $prefix, $afterLength - $prefix - $suffix); + if ($deleted > 0 && $inserted === []) { + return [['action' => 'del', 'path' => array_merge($path, [$prefix]), 'length' => $deleted]]; + } + + if ($deleted === 0 && $inserted !== []) { + $patches = []; + foreach ($inserted as $offset => $value) { + $patches = array_merge($patches, $this->insertListValuePatches($path, $prefix + $offset, $value)); + } + + return $patches; + } + + return [['action' => 'put', 'path' => $path, 'value' => $after]]; + } + + /** + * @param array $value + * @param list $path + * @return list> + */ + private function diffContainerContents(array $value, array $path, array $replacementPaths = [], array $marksByPathKey = []): array + { + if (array_is_list($value)) { + $patches = []; + foreach ($value as $index => $item) { + $patches = array_merge($patches, $this->insertListValuePatches($path, $index, $item)); + } + + return $patches; + } + + $containerPuts = []; + $existingPatches = []; + $newContainerChildren = []; + foreach ($value as $key => $item) { + $childPath = array_merge($path, [$key]); + if (is_array($item)) { + $containerPuts[] = ['action' => 'put', 'path' => $childPath, 'value' => []]; + $newContainerChildren = array_merge($newContainerChildren, $this->diffContainerContents($item, $childPath, $replacementPaths, $marksByPathKey)); + continue; + } + + if (is_string($item)) { + $marks = $marksByPathKey[$this->pathKey($childPath)]['marks'] ?? []; + $existingPatches = array_merge( + $existingPatches, + $marks === [] + ? $this->assignmentPatchesForStringPath($childPath, $item) + : $this->markedStringAssignmentPatches($childPath, $item, $marks) + ); + continue; + } + + $existingPatches[] = ['action' => 'put', 'path' => $childPath, 'value' => $item]; + } + + return array_values(array_merge($containerPuts, $existingPatches, $newContainerChildren)); + } + + /** + * @param list $path + * @return list> + */ + private function insertListValuePatches(array $path, int $index, mixed $value): array + { + if (is_string($value)) { + return [ + ['action' => 'insert', 'path' => array_merge($path, [$index]), 'values' => ['']], + ['action' => 'splice', 'path' => array_merge($path, [$index, 0]), 'value' => $value], + ]; + } + + return [['action' => 'insert', 'path' => array_merge($path, [$index]), 'values' => [$value]]]; + } + + /** + * @param list $path + * @return list> + */ + private function assignmentPatchesForStringPath(array $path, string $value): array + { + $patches = [['action' => 'put', 'path' => $path, 'value' => '']]; + if ($value !== '') { + $patches[] = ['action' => 'splice', 'path' => array_merge($path, [0]), 'value' => $value]; + } + + return $patches; + } + + /** + * @param list> $patches + * @return list> + */ + private function compactAdjacentListInsertPatches(array $patches): array + { + $compacted = []; + $pending = null; + + $flushPending = static function () use (&$compacted, &$pending): void { + if ($pending !== null) { + $compacted[] = $pending; + $pending = null; + } + }; + + foreach ($patches as $patch) { + if (! $this->isCompactableListInsertPatch($patch)) { + $flushPending(); + $compacted[] = $patch; + continue; + } + + $path = $patch['path']; + $values = array_values($patch['values']); + $index = array_pop($path); + + if ($pending !== null && $this->isCompactableListInsertPatch($pending)) { + $pendingPath = $pending['path']; + $pendingIndex = (int) end($pendingPath); + if (array_slice($pendingPath, 0, -1) === $path && $pendingIndex + count($pending['values']) === $index) { + $pending['values'] = array_merge($pending['values'], $values); + continue; + } + } + + $flushPending(); + $pending = $patch; + } + + $flushPending(); + + return $compacted; + } + + /** + * @param array $patch + */ + private function isCompactableListInsertPatch(array $patch): bool + { + if (($patch['action'] ?? null) !== 'insert' || ! is_array($patch['path'] ?? null) || ! is_array($patch['values'] ?? null)) { + return false; + } + + $path = $patch['path']; + if ($path === [] || ! is_int($path[array_key_last($path)])) { + return false; + } + + return array_is_list($patch['values']); + } + + private function stringifyListValue(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return json_encode($value, JSON_THROW_ON_ERROR); + } + + /** + * @param list $values + */ + private function assertListInputHasNoUndefined(string $key, array $values): void + { + foreach ($values as $index => $value) { + if ($value instanceof UndefinedValue) { + throw new \InvalidArgumentException('Cannot assign undefined value at /' . $key . ' at index ' . $index . ' in the input'); + } + } + } + + /** + * @return array + */ + private function initialRootMap(mixed $value): array + { + if ($value instanceof Document) { + return $value->toArray(); + } + + if (is_array($value)) { + return $value; + } + + if ($value instanceof \stdClass) { + return get_object_vars($value); + } + + if (is_string($value)) { + $characters = preg_split('//u', $value, -1, PREG_SPLIT_NO_EMPTY); + if ($characters === false) { + throw new \InvalidArgumentException('Initial string value must be valid UTF-8.'); + } + + return $characters; + } + + return []; + } +} diff --git a/src/wp-includes/automerge-php/src/RawString.php b/src/wp-includes/automerge-php/src/RawString.php new file mode 100644 index 0000000000000..dcdce85ebf8b8 --- /dev/null +++ b/src/wp-includes/automerge-php/src/RawString.php @@ -0,0 +1,9 @@ + */ + private array $elements; + private ?string $compactText; + private ?string $compactActor; + private int $compactStartSeq; + private ?int $cachedLength; + private ?string $cachedString; + + /** + * @param array $elements + */ + public function __construct( + array $elements = [], + ?string $compactText = null, + ?string $compactActor = null, + int $compactStartSeq = 1, + ?int $compactLength = null + ) { + $this->elements = array_map( + static fn (array $element): array => $element + ['after' => null, 'inserted' => false], + array_values($elements) + ); + $this->compactText = $compactText; + $this->compactActor = $compactText === null ? null : ($compactActor ?? ''); + $this->compactStartSeq = max(1, $compactStartSeq); + $this->cachedLength = $compactText === null ? null : ($compactLength ?? self::countCharacters($compactText)); + $this->cachedString = $compactText; + } + + public static function fromString(string $text, string $actor, int &$sequence): self + { + $length = self::countCharacters($text); + $startSeq = $sequence + 1; + $sequence += $length; + + return new self([], $text, $actor, $startSeq, $length); + } + + public static function fromCompactString(string $text, string $actor, int $startSeq, ?int $elementCount = null): self + { + $length = $elementCount ?? self::countCharacters($text); + + return new self([], $text, $actor, $startSeq, $length); + } + + public function copy(): self + { + if ($this->compactText !== null) { + return new self([], $this->compactText, $this->compactActor, $this->compactStartSeq, $this->cachedLength); + } + + return new self($this->elements); + } + + public function length(): int + { + if ($this->cachedLength !== null) { + return $this->cachedLength; + } + + $length = 0; + foreach ($this->elements as $element) { + if ($element['visible']) { + ++$length; + } + } + + $this->cachedLength = $length; + + return $length; + } + + public function charAt(int $index): string + { + $visibleIndex = 0; + foreach ($this->orderedElements() as $element) { + if (! $element['visible']) { + continue; + } + + if ($visibleIndex === $index) { + return $element['char']; + } + + ++$visibleIndex; + } + + throw new OutOfBoundsException('Text index is outside the visible text.'); + } + + public function splice(int $index, int $deleteCount, string $insert, string $actor, int &$sequence): void + { + if ($index < 0 || $deleteCount < 0 || $index > $this->length()) { + throw new OutOfBoundsException('Text splice range is outside the visible text.'); + } + + $this->materializeElements(); + $this->invalidateCache(); + + $after = $insert === '' ? null : $this->anchorForVisibleIndex($index); + + if ($deleteCount > 0) { + $remaining = $deleteCount; + $visibleIndex = 0; + foreach ($this->orderedElements() as $element) { + if (! $element['visible']) { + continue; + } + + if ($visibleIndex >= $index && $remaining > 0) { + $this->elements[$element['offset']]['visible'] = false; + --$remaining; + } + + ++$visibleIndex; + } + } + + if ($insert === '') { + return; + } + + $newElements = []; + foreach ($this->splitCharacters($insert) as $char) { + ++$sequence; + $id = $sequence . '@' . $actor; + $newElements[] = [ + 'id' => $id, + 'actor' => $actor, + 'seq' => $sequence, + 'char' => $char, + 'visible' => true, + 'after' => $after, + 'inserted' => true, + ]; + $after = $id; + } + + array_push($this->elements, ...$newElements); + } + + public function merge(self $other): self + { + /** @var array $byId */ + $byId = []; + $position = 0; + + foreach ([$this->elements(), $other->elements()] as $source) { + foreach ($source as $element) { + if (! isset($byId[$element['id']])) { + $byId[$element['id']] = ($element + ['after' => null, 'inserted' => false]) + ['first' => $position]; + ++$position; + continue; + } + + $byId[$element['id']]['visible'] = $byId[$element['id']]['visible'] && $element['visible']; + } + } + + $merged = array_values($byId); + usort( + $merged, + static function (array $left, array $right): int { + $actor = $left['actor'] <=> $right['actor']; + if ($actor !== 0) { + return $actor; + } + + $sequence = $left['seq'] <=> $right['seq']; + if ($sequence !== 0) { + return $sequence; + } + + return $left['first'] <=> $right['first']; + } + ); + + return new self( + array_map( + static fn (array $element): array => [ + 'id' => $element['id'], + 'actor' => $element['actor'], + 'seq' => $element['seq'], + 'char' => $element['char'], + 'visible' => $element['visible'], + 'after' => $element['after'], + 'inserted' => $element['inserted'], + ], + $merged + ) + ); + } + + public function toString(): string + { + if ($this->cachedString !== null) { + return $this->cachedString; + } + + $text = ''; + foreach ($this->orderedElements() as $element) { + if ($element['visible']) { + $text .= $element['char']; + } + } + + $this->cachedString = $text; + + return $text; + } + + public function jsonSerialize(): string + { + return $this->toString(); + } + + /** + * @return array + */ + public function elements(): array + { + $this->materializeElements(); + + return $this->elements; + } + + /** + * @return array{type:string,elements?:array,value?:string,actor?:string,startSeq?:int,elementCount?:int} + */ + public function encodedValue(): array + { + if ($this->compactText !== null) { + return [ + 'type' => 'text', + 'value' => $this->compactText, + 'actor' => $this->compactActor ?? '', + 'startSeq' => $this->compactStartSeq, + 'elementCount' => $this->length(), + ]; + } + + return [ + 'type' => 'text', + 'elements' => $this->elements(), + ]; + } + + public function elementCount(): int + { + if ($this->compactText !== null) { + return $this->length(); + } + + return count($this->elements); + } + + /** + * @param callable(string):int $measure + * @return array{0:int,1:int} + */ + public function elementBoundaryAfterMeasuredIndex(int $index, callable $measure): array + { + $index = max(0, $index); + $offset = 0; + $visibleIndex = 0; + foreach ($this->orderedElements() as $element) { + if (! $element['visible']) { + continue; + } + + if ($index <= $offset) { + return [$visibleIndex, $offset]; + } + + $nextOffset = $offset + $measure($element['char']); + if ($index <= $nextOffset) { + return [$visibleIndex + 1, $nextOffset]; + } + + $offset = $nextOffset; + ++$visibleIndex; + } + + return [$visibleIndex, $offset]; + } + + private function anchorForVisibleIndex(int $index): ?string + { + if ($index === 0) { + return null; + } + + $visibleIndex = 0; + foreach ($this->orderedElements() as $element) { + if (! $element['visible']) { + continue; + } + + ++$visibleIndex; + if ($visibleIndex === $index) { + return $element['id']; + } + } + + return null; + } + + /** + * @return list + */ + private function orderedElements(): array + { + $this->materializeElements(); + + $byParent = []; + foreach ($this->elements as $offset => $element) { + $element = ($element + ['after' => null, 'inserted' => false]) + ['offset' => $offset]; + $parent = $element['after'] ?? ''; + $byParent[$parent][] = $element; + } + + foreach ($byParent as $parent => $children) { + usort( + $children, + static function (array $left, array $right): int { + if ($left['inserted'] !== $right['inserted']) { + return $left['inserted'] ? -1 : 1; + } + + $actor = $right['actor'] <=> $left['actor']; + if ($actor !== 0) { + return $actor; + } + + return $right['seq'] <=> $left['seq']; + } + ); + $byParent[$parent] = $children; + } + + $ordered = []; + $visit = static function (?string $parent) use (&$visit, &$ordered, $byParent): void { + $key = $parent ?? ''; + foreach ($byParent[$key] ?? [] as $child) { + $ordered[] = $child; + $visit($child['id']); + } + }; + $visit(null); + + return $ordered; + } + + /** + * @return list + */ + private function splitCharacters(string $text): array + { + return self::characters($text); + } + + private function materializeElements(): void + { + if ($this->compactText === null) { + return; + } + + $text = $this->compactText; + $actor = $this->compactActor ?? ''; + $sequence = $this->compactStartSeq - 1; + $after = null; + $elements = []; + foreach (self::characters($text) as $char) { + ++$sequence; + $id = $sequence . '@' . $actor; + $elements[] = [ + 'id' => $id, + 'actor' => $actor, + 'seq' => $sequence, + 'char' => $char, + 'visible' => true, + 'after' => $after, + 'inserted' => false, + ]; + $after = $id; + } + + $this->elements = $elements; + $this->compactText = null; + $this->compactActor = null; + $this->cachedString = $text; + $this->cachedLength ??= count($elements); + } + + private function invalidateCache(): void + { + $this->cachedLength = null; + $this->cachedString = null; + } + + private static function countCharacters(string $text): int + { + if ($text === '') { + return 0; + } + + if (! preg_match('//u', $text)) { + throw new \InvalidArgumentException('Text must be valid UTF-8.'); + } + + if (! preg_match('/[^\x00-\x7F]/', $text)) { + return strlen($text); + } + + return count(self::characters($text)); + } + + /** + * @return list + */ + private static function characters(string $text): array + { + if (! preg_match_all('/\X/u', $text, $matches)) { + if ($text === '') { + return []; + } + + throw new \InvalidArgumentException('Text must be valid UTF-8.'); + } + + return $matches[0]; + } +} diff --git a/src/wp-includes/automerge-php/src/Transaction.php b/src/wp-includes/automerge-php/src/Transaction.php new file mode 100644 index 0000000000000..5e1a65d7c0d78 --- /dev/null +++ b/src/wp-includes/automerge-php/src/Transaction.php @@ -0,0 +1,178 @@ +|null */ + private ?array $historicalHeads; + + private ?Document $readView; + + /** + * @param list|null $historicalHeads + */ + public function __construct(Document $document, NativePort $port, ?array $historicalHeads = null) + { + $this->base = $document; + $this->working = $document->clone(); + $this->port = $port; + $this->historicalHeads = $historicalHeads === null ? null : array_values($historicalHeads); + $this->readView = $historicalHeads === null ? null : $port->view($document, $this->historicalHeads); + } + + public function batchCreateObject(string $key, mixed $value): void + { + $this->assertOpen(); + $this->working = $this->port->batchCreateObject($this->working, $key, $value); + } + + public function set(string $key, mixed $value): void + { + $this->assertOpen(); + $this->working = $this->historicalHeads === null + ? $this->port->set($this->working, $key, $value) + : $this->port->setAtHeads($this->working, $this->historicalHeads, $key, $value); + $this->readView = null; + } + + /** + * @param list $path + */ + public function setNested(array $path, mixed $value): void + { + $this->assertOpen(); + $this->working = $this->port->setNested($this->working, $path, $value); + } + + /** + * @param list $values + */ + public function insertListElements(string $key, int $index, array $values): void + { + $this->assertOpen(); + $this->working = $this->port->insertListElements($this->working, $key, $index, $values); + } + + public function splice(string $key, int $index, int $deleteCount, string $insert = ''): void + { + $this->assertOpen(); + $this->working = $this->port->splice($this->working, $key, $index, $deleteCount, $insert); + $this->readView = null; + } + + public function document(): Document + { + return $this->readView ?? $this->working; + } + + /** + * @return list + */ + public function getHeads(): array + { + return $this->base->heads(); + } + + public function commit(): Document + { + $this->assertOpen(); + $this->closed = true; + + return $this->working; + } + + /** + * @return array{0:Document,1:?string} + */ + public function commitWithHash(): array + { + $this->assertOpen(); + $this->closed = true; + + $baseChangeCount = count($this->base->getAllChanges()); + $newChanges = array_slice($this->working->getAllChanges(), $baseChangeCount); + $lastChange = $newChanges === [] ? null : $newChanges[array_key_last($newChanges)]; + $hash = is_array($lastChange) && is_string($lastChange['hash'] ?? null) ? $lastChange['hash'] : null; + + return [$this->working, $hash]; + } + + /** + * @return array{0:Document,1:list>} + */ + public function commitWithPatches(): array + { + $this->assertOpen(); + $this->closed = true; + + return [ + $this->working, + $this->port->diff($this->working, $this->base->heads(), $this->working->heads()), + ]; + } + + public function pendingOps(): int + { + $this->assertOpen(); + + return $this->countPendingOps(); + } + + public function commitWith(?string $message = null, ?int $time = null): Document + { + $this->assertOpen(); + $this->closed = true; + $this->working->amendLastLocalChange($message, $time); + + return $this->working; + } + + public function rollback(): Document + { + $this->assertOpen(); + $this->closed = true; + + return $this->base; + } + + /** + * @return array{0:Document,1:int} + */ + public function rollbackWithCancelled(): array + { + $this->assertOpen(); + $cancelled = $this->countPendingOps(); + $this->closed = true; + + return [$this->base, $cancelled]; + } + + private function assertOpen(): void + { + if ($this->closed) { + throw new \RuntimeException('Transaction is already closed.'); + } + } + + private function countPendingOps(): int + { + $baseChangeCount = count($this->base->getAllChanges()); + $pending = 0; + foreach (array_slice($this->working->getAllChanges(), $baseChangeCount) as $change) { + $pending += count(is_array($change['ops'] ?? null) ? $change['ops'] : []); + } + + return $pending; + } +} diff --git a/src/wp-includes/automerge-php/src/UndefinedValue.php b/src/wp-includes/automerge-php/src/UndefinedValue.php new file mode 100644 index 0000000000000..3f63936bce0a6 --- /dev/null +++ b/src/wp-includes/automerge-php/src/UndefinedValue.php @@ -0,0 +1,9 @@ + $left + * @param array $right + */ +function sameArray(array $left, array $right, string $message): void +{ + if ($left !== $right) { + throw new RuntimeException($message . ' Expected ' . json_encode($right) . ', got ' . json_encode($left)); + } +} + +function same(mixed $left, mixed $right, string $message): void +{ + if ($left !== $right) { + throw new RuntimeException($message . ' Expected ' . json_encode($right) . ', got ' . json_encode($left)); + } +} + +function truthy(bool $condition, string $message): void +{ + if (! $condition) { + throw new RuntimeException($message); + } +} + +function throwsLike(callable $callback, string $needle, string $message): void +{ + try { + $callback(); + } catch (Throwable $throwable) { + if (str_contains($throwable->getMessage(), $needle)) { + return; + } + + throw new RuntimeException($message . ' Unexpected error: ' . $throwable->getMessage()); + } + + throw new RuntimeException($message . ' No exception was thrown.'); +} + +/** + * @param list $options + */ +function oneOf(mixed $actual, array $options, string $message): void +{ + foreach ($options as $option) { + if ($actual === $option) { + return; + } + } + + throw new RuntimeException($message . ' Got ' . json_encode($actual)); +} + +function dateMillis(DateTimeInterface $value): int +{ + return ((int) $value->format('U')) * 1000 + intdiv((int) $value->format('u'), 1000); +} + +/** + * @param array|null $leftSync + * @param array|null $rightSync + * @return array{0:Document,1:Document,2:array,3:array} + */ +function syncDocuments(NativePort $port, Document $left, Document $right, ?array $leftSync = null, ?array $rightSync = null): array +{ + $leftSync ??= $port->initSyncState(); + $rightSync ??= $port->initSyncState(); + $iterations = 0; + + do { + [$leftSync, $leftToRight] = $port->generateSyncMessage($left, $leftSync); + [$rightSync, $rightToLeft] = $port->generateSyncMessage($right, $rightSync); + + if ($leftToRight !== null) { + [$right, $rightSync] = $port->receiveSyncMessage($right, $rightSync, $leftToRight); + } + + if ($rightToLeft !== null) { + [$left, $leftSync] = $port->receiveSyncMessage($left, $leftSync, $rightToLeft); + } + + if (++$iterations > 10) { + throw new RuntimeException('Native PHP sync helper did not converge within 10 iterations.'); + } + } while ($leftToRight !== null || $rightToLeft !== null); + + return [$left, $right, $leftSync, $rightSync]; +} + +$port = new NativePort(); +$tests = []; +$wordpressScenarios = []; + +$mapped = static function (string $name, string $upstreamFile, int $upstreamLine, string $upstreamTest, callable $run) use (&$tests): void { + try { + $run(); + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'file' => $upstreamFile, + 'line' => $upstreamLine, + 'test' => $upstreamTest, + ], + 'passed' => true, + ]; + } catch (Throwable $throwable) { + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'file' => $upstreamFile, + 'line' => $upstreamLine, + 'test' => $upstreamTest, + ], + 'passed' => false, + 'failure' => $throwable->getMessage(), + ]; + } +}; + +$pendingMapped = static function (string $name, string $upstreamFile, int $upstreamLine, string $upstreamTest, callable $run) use (&$tests): void { + try { + $run(); + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'file' => $upstreamFile, + 'line' => $upstreamLine, + 'test' => $upstreamTest, + 'upstreamStatus' => 'pending', + ], + 'passed' => true, + ]; + } catch (Throwable $throwable) { + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'file' => $upstreamFile, + 'line' => $upstreamLine, + 'test' => $upstreamTest, + 'upstreamStatus' => 'pending', + ], + 'passed' => false, + 'failure' => $throwable->getMessage(), + ]; + } +}; + +$ignoredMapped = static function (string $name, string $manifestId, string $upstreamTest, callable $run, string $upstreamFile) use (&$tests): void { + try { + $run(); + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'id' => $manifestId, + 'file' => $upstreamFile, + 'test' => $upstreamTest, + 'upstreamStatus' => 'ignored', + ], + 'passed' => true, + ]; + } catch (Throwable $throwable) { + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'id' => $manifestId, + 'file' => $upstreamFile, + 'test' => $upstreamTest, + 'upstreamStatus' => 'ignored', + ], + 'passed' => false, + 'failure' => $throwable->getMessage(), + ]; + } +}; + +$rustMapped = static function (string $name, string $manifestId, string $upstreamTest, callable $run, string $upstreamFile = 'rust/automerge/tests/batch_insert.rs') use (&$tests): void { + try { + $run(); + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'id' => $manifestId, + 'file' => $upstreamFile, + 'test' => $upstreamTest, + ], + 'passed' => true, + ]; + } catch (Throwable $throwable) { + $tests[] = [ + 'name' => $name, + 'mappedFrom' => [ + 'id' => $manifestId, + 'file' => $upstreamFile, + 'test' => $upstreamTest, + ], + 'passed' => false, + 'failure' => $throwable->getMessage(), + ]; + } +}; + +$wordpress = static function (string $name, callable $run) use (&$wordpressScenarios): void { + try { + $run(); + $wordpressScenarios[] = [ + 'name' => $name, + 'passed' => true, + ]; + } catch (Throwable $throwable) { + $wordpressScenarios[] = [ + 'name' => $name, + 'passed' => false, + 'failure' => $throwable->getMessage(), + ]; + } +}; + +$mapped( + 'init clone and free creates independent PHP document values', + 'javascript/test/basic_test.ts', + 12, + 'should init clone and free', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->clone($doc1); + + truthy($doc1 !== $doc2, 'clone must create a distinct object'); + sameArray($doc1->toArray(), [], 'init should create an empty root object'); + sameArray($doc2->toArray(), [], 'clone should preserve empty root object'); + } +); + +$mapped( + 'basic view materializes a document at specific heads', + 'javascript/test/basic_test.ts', + 21, + 'should be able to make a view with specifc heads', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->set($doc1, 'value', 1); + $heads2 = $port->getHeads($doc2); + $doc3 = $port->set($doc2, 'value', 2); + $doc2View = $port->view($doc3, $heads2); + $doc2Clone = $port->clone($doc2View, 'ddeeff'); + + sameArray($doc2View->toArray(), $doc2->toArray(), 'view should materialize the document state at the requested heads'); + sameArray($port->getHeads($doc2View), $heads2, 'view should expose the requested heads'); + sameArray($doc2Clone->toArray(), $doc2->toArray(), 'clone of a view should preserve the view materialization'); + same($port->getActorId($doc2Clone), 'ddeeff', 'clone of a view should accept a new actor id'); + } +); + +$mapped( + 'basic clone of a view can be changed independently', + 'javascript/test/basic_test.ts', + 33, + 'should allow you to change a clone of a view', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'key', 'value'); + $heads = $port->getHeads($doc1); + $doc1 = $port->set($doc1, 'key', 'value2'); + $fork = $port->clone($port->view($doc1, $heads), 'ddeeff'); + + sameArray($fork->toArray(), ['key' => 'value'], 'clone of a view should preserve the older visible state'); + $fork = $port->set($fork, 'key', 'value3'); + sameArray($fork->toArray(), ['key' => 'value3'], 'clone of a view should accept later native changes'); + sameArray($doc1->toArray(), ['key' => 'value2'], 'changing the view clone should not mutate the source document'); + } +); + +$mapped( + 'legacy initialization starts as an empty map', + 'javascript/test/legacy_tests.ts', + 23, + 'should initially be an empty map', + function () use ($port): void { + sameArray($port->init('aaaaaa')->toArray(), [], 'init should materialize an empty map'); + } +); + +$mapped( + 'legacy initialization accepts an existing object', + 'javascript/test/legacy_tests.ts', + 28, + 'should allow instantiating from an existing object', + function () use ($port): void { + $initialState = ['birds' => ['wrens' => 3, 'magpies' => 4]]; + sameArray($port->from($initialState, 'aaaaaa')->toArray(), $initialState, 'from should materialize the supplied root map'); + } +); + +$mapped( + 'legacy initialization merges an object initialized with from', + 'javascript/test/legacy_tests.ts', + 34, + 'should allow merging of an object initialized with `from`', + function () use ($port): void { + $doc1 = $port->from(['cards' => []], 'aaaaaa'); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + + sameArray($doc2->toArray(), ['cards' => []], 'merge should preserve the from-created object root'); + } +); + +$mapped( + 'legacy initialization preserves actor id from from', + 'javascript/test/legacy_tests.ts', + 40, + 'should allow passing an actorId when instantiating from an existing object', + function () use ($port): void { + $doc = $port->from(['foo' => 1], '1234'); + + same($port->getActorId($doc), '1234', 'from should preserve the explicit actor id'); + } +); + +$mapped( + 'legacy initialization accepts an empty object', + 'javascript/test/legacy_tests.ts', + 46, + 'accepts an empty object as initial state', + function () use ($port): void { + sameArray($port->from([], 'aaaaaa')->toArray(), [], 'from should accept an empty initial map'); + } +); + +$mapped( + 'legacy initialization converts array input to root map entries', + 'javascript/test/legacy_tests.ts', + 51, + 'accepts an array as initial state, but converts it to an object', + function () use ($port): void { + sameArray($port->from(['a', 'b', 'c'], 'aaaaaa')->toArray(), ['a', 'b', 'c'], 'from should keep array values under numeric root keys'); + } +); + +$mapped( + 'legacy initialization converts string input to character entries', + 'javascript/test/legacy_tests.ts', + 57, + 'accepts strings as initial values, but treats them as an array of characters', + function () use ($port): void { + sameArray($port->from('abc', 'aaaaaa')->toArray(), ['a', 'b', 'c'], 'from should split string initial state into character entries'); + } +); + +$mapped( + 'legacy initialization ignores numeric initial values', + 'javascript/test/legacy_tests.ts', + 63, + 'ignores numbers provided as initial values', + function () use ($port): void { + sameArray($port->from(123, 'aaaaaa')->toArray(), [], 'from should ignore numeric initial state'); + } +); + +$mapped( + 'legacy initialization ignores boolean initial values', + 'javascript/test/legacy_tests.ts', + 69, + 'ignores booleans provided as initial values', + function () use ($port): void { + sameArray($port->from(false, 'aaaaaa')->toArray(), [], 'from should ignore false initial state'); + sameArray($port->from(true, 'bbbbbb')->toArray(), [], 'from should ignore true initial state'); + } +); + +$mapped( + 'root map set/read materializes PHP array in insertion order', + 'javascript/test/basic_test.ts', + 44, + 'handle basic set and read on root object', + function () use ($port): void { + $doc = $port->init('aabbcc'); + $doc = $port->set($doc, 'hello', 'world'); + $doc = $port->set($doc, 'big', 'little'); + $doc = $port->set($doc, 'zip', 'zop'); + $doc = $port->set($doc, 'app', 'dap'); + + sameArray( + $doc->toArray(), + [ + 'hello' => 'world', + 'big' => 'little', + 'zip' => 'zop', + 'app' => 'dap', + ], + 'root map values should be readable after set' + ); + } +); + +$mapped( + 'legacy sequential changes do not mutate the input document', + 'javascript/test/legacy_tests.ts', + 85, + 'should not mutate objects', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->set($doc1, 'foo', 'bar'); + + sameArray($doc1->toArray(), [], 'set should leave the input document unchanged'); + sameArray($doc2->toArray(), ['foo' => 'bar'], 'set should materialize the change on the returned document'); + } +); + +$mapped( + 'legacy changes expose the last local change', + 'javascript/test/legacy_tests.ts', + 91, + 'changes should be retrievable', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $change1 = $port->getLastLocalChange($doc1); + $doc2 = $port->set($doc1, 'foo', 'bar'); + $change2 = $port->decodeChange($port->getLastLocalChange($doc2) ?? []); + + same($change1, null, 'empty document should not have a last local change'); + same($change2['actor'], 'aabbcc', 'last local change should expose its actor'); + same($change2['seq'], 1, 'last local change should expose its sequence'); + same($change2['startOp'], 1, 'last local change should expose its start operation'); + same($change2['message'], null, 'last local change should expose a null message when none was supplied'); + sameArray($change2['deps'], [], 'first local change should have no dependencies'); + truthy(is_string($change2['hash'] ?? null) && $change2['hash'] !== '', 'last local change should expose a hash'); + sameArray( + $change2['ops'], + [ + [ + 'action' => 'set', + 'key' => 'foo', + 'value' => ['type' => 'scalar', 'value' => 'bar'], + ], + ], + 'last local change should expose the native root set operation' + ); + } +); + +$mapped( + 'legacy sequential repeated assignment records no conflicts', + 'javascript/test/legacy_tests.ts', + 135, + 'should not register any conflicts on repeated assignment', + function () use ($port): void { + $doc = $port->init('aabbcc'); + same($port->getConflicts($doc, 'foo'), null, 'empty document should report no conflict for an absent key'); + $doc = $port->set($doc, 'foo', 'one'); + same($port->getConflicts($doc, 'foo'), null, 'first assignment should report no conflict'); + $doc = $port->set($doc, 'foo', 'two'); + same($port->getConflicts($doc, 'foo'), null, 'sequential overwrite should report no conflict'); + } +); + +$mapped( + 'legacy changes group multiple root assignments', + 'javascript/test/legacy_tests.ts', + 144, + 'should group several changes', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->setMany($doc1, ['first' => 'one', 'second' => 'two'], 'change message'); + $history = $port->getHistory($doc2); + + sameArray($doc1->toArray(), [], 'grouped change should not mutate the input document'); + sameArray($doc2->toArray(), ['first' => 'one', 'second' => 'two'], 'grouped change should materialize all assignments'); + same(count($history), 1, 'grouped assignments should record one native change'); + same($history[0]['change']['message'], 'change message', 'grouped change should preserve its message'); + same(count($history[0]['change']['ops']), 2, 'grouped change should record both root assignment operations'); + } +); + +$mapped( + 'legacy sequential repeated writes keep the final value', + 'javascript/test/legacy_tests.ts', + 189, + 'should allow repeated reading and writing of values', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->set($doc1, 'value', 'a'); + same($doc2->toArray()['value'], 'a', 'first write should be readable'); + $doc2 = $port->set($doc2, 'value', 'b'); + $doc2 = $port->set($doc2, 'value', 'c'); + + sameArray($doc1->toArray(), [], 'repeated writes should not mutate the input document'); + sameArray($doc2->toArray(), ['value' => 'c'], 'repeated writes should keep the final value'); + } +); + +$mapped( + 'legacy sequential same-field writes have no conflicts', + 'javascript/test/legacy_tests.ts', + 201, + 'should not record conflicts when writing the same field several times within one change', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'value', 'a'); + $doc = $port->set($doc, 'value', 'b'); + $doc = $port->set($doc, 'value', 'c'); + + same($doc->toArray()['value'], 'c', 'sequential same-field writes should materialize the final value'); + same($port->getConflicts($doc, 'value'), null, 'sequential same-field writes should not produce conflicts'); + } +); + +$mapped( + 'legacy sequential no-op change returns the same document', + 'javascript/test/legacy_tests.ts', + 211, + 'should return the unchanged state object if nothing changed', + function () use ($port): void { + $doc = $port->init('aabbcc'); + + truthy($port->changeNoop($doc) === $doc, 'no-op change should preserve document identity'); + } +); + +$mapped( + 'legacy sequential existing-value updates are ignored', + 'javascript/test/legacy_tests.ts', + 216, + 'should ignore field updates that write the existing value', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'field', 123); + $doc2 = $port->set($doc1, 'field', 123); + + truthy($doc2 === $doc1, 'setting an existing identical value should return the same document'); + same(count($port->getAllChanges($doc2)), 1, 'existing-value update should not append a native change'); + } +); + +$mapped( + 'legacy root existing-value update resolves conflicts', + 'javascript/test/legacy_tests.ts', + 222, + 'should not ignore field updates that resolve a conflict', + function () use ($port): void { + $doc1 = $port->init('bbbbbb'); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + $doc1 = $port->set($doc1, 'field', 123); + $doc2 = $port->set($doc2, 'field', 321); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $winner = $doc1->toArray()['field']; + $changeCount = count($port->getAllChanges($doc1)); + + same(count($port->getConflicts($doc1, 'field') ?? []), 2, 'concurrent root updates should create two conflict values'); + $resolved = $port->set($doc1, 'field', $winner); + + truthy($resolved !== $doc1, 'writing the visible winner should create a conflict-resolution document'); + sameArray($resolved->toArray(), ['field' => $winner], 'conflict resolution should keep the visible root value'); + same($port->getConflicts($resolved, 'field'), null, 'conflict resolution should clear root conflicts'); + same(count($port->getAllChanges($resolved)), $changeCount + 1, 'conflict resolution should append a native change'); + } +); + +$mapped( + 'legacy sequential list existing-value updates are ignored', + 'javascript/test/legacy_tests.ts', + 237, + 'should ignore list element updates that write the existing value', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'list', [123]); + $doc2 = $port->setListElement($doc1, 'list', 0, 123); + + truthy($doc2 === $doc1, 'setting an existing identical list element should return the same document'); + same(count($port->getAllChanges($doc2)), 1, 'existing-value list element update should not append a native change'); + } +); + +$mapped( + 'legacy list existing-value update resolves conflicts', + 'javascript/test/legacy_tests.ts', + 243, + 'should not ignore list element updates that resolve a conflict', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'list', [1]); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + $doc1 = $port->setListElement($doc1, 'list', 0, 123); + $doc2 = $port->setListElement($doc2, 'list', 0, 321); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $winner = $doc1->toArray()['list'][0]; + $changeCount = count($port->getAllChanges($doc1)); + + sameArray( + $port->getListElementConflicts($doc1, 'list', 0) ?? [], + [ + '2@aaaaaa' => 123, + '2@bbbbbb' => 321, + ], + 'concurrent list element updates should create two conflict values' + ); + + $resolved = $port->setListElement($doc1, 'list', 0, $winner); + + truthy($resolved !== $doc1, 'writing the visible list winner should create a conflict-resolution document'); + sameArray($resolved->toArray(), $doc1->toArray(), 'list conflict resolution should keep the visible list value'); + same($port->getListElementConflicts($resolved, 'list', 0), null, 'list conflict resolution should clear element conflicts'); + same($port->getListElementConflicts($port->load($port->save($resolved)), 'list', 0), null, 'list conflict resolution should survive save/load'); + same(count($port->getAllChanges($resolved)), $changeCount + 1, 'list conflict resolution should append a native change'); + } +); + +$mapped( + 'legacy concurrent updates of the same list element expose conflicts', + 'javascript/test/legacy_tests.ts', + 1134, + 'should detect concurrent updates of the same list element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['finch']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setListElement($doc1, 'birds', 0, 'greenfinch'); + $doc2 = $port->setListElement($doc2, 'birds', 0, 'goldfinch_'); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['birds' => ['greenfinch']], 'larger actor id should win the visible list element value'); + sameArray( + $port->getListElementConflicts($merged, 'birds', 0) ?? [], + [ + '2@aaaaaa' => 'goldfinch_', + '2@bbbbbb' => 'greenfinch', + ], + 'same-list-element concurrent updates should expose both conflict values' + ); + sameArray( + $port->getListElementConflicts($port->load($port->save($merged)), 'birds', 0) ?? [], + [ + '2@aaaaaa' => 'goldfinch_', + '2@bbbbbb' => 'greenfinch', + ], + 'same-list-element conflict values should survive save/load' + ); + } +); + +$mapped( + 'legacy conflicting list element maps retain nested changes', + 'javascript/test/legacy_tests.ts', + 1176, + 'should handle changes within a conflicting list element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['hello']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setListElement($doc1, 'list', 0, ['map1' => true]); + $doc1 = $port->setNested($doc1, ['list', 0, 'key'], 1); + $doc2 = $port->setListElement($doc2, 'list', 0, ['map2' => true]); + $doc2 = $port->setNested($doc2, ['list', 0, 'key'], 2); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['list' => [['map1' => true, 'key' => 1]]], 'larger actor id should win the visible nested list map value'); + $expectedConflicts = [ + '2@aaaaaa' => ['map2' => true, 'key' => 2], + '2@bbbbbb' => ['map1' => true, 'key' => 1], + ]; + sameArray( + $port->getListElementConflicts($merged, 'list', 0) ?? [], + $expectedConflicts, + 'changes inside conflicting list element maps should be retained in conflict values' + ); + sameArray( + $port->getListElementConflicts($port->load($port->save($merged)), 'list', 0) ?? [], + $expectedConflicts, + 'conflicting list element map changes should survive save/load' + ); + } +); + +$mapped( + 'legacy concurrent insertions at different list positions merge cleanly', + 'javascript/test/legacy_tests.ts', + 1222, + 'should handle concurrent insertions at different list positions', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['one', 'three']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->insertListElements($doc1, 'list', 1, ['two']); + $doc2 = $port->pushList($doc2, 'list', ['four']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['list' => ['one', 'two', 'three', 'four']], 'concurrent insertions at different list positions should merge against the shared base'); + same($port->getConflicts($merged, 'list'), null, 'different-position concurrent list insertions should not create a root conflict'); + sameArray($port->load($port->save($merged))->toArray(), ['list' => ['one', 'two', 'three', 'four']], 'merged different-position list insertions should survive save/load'); + } +); + +$mapped( + 'legacy concurrent insertions at the same list position merge cleanly', + 'javascript/test/legacy_tests.ts', + 1232, + 'should handle concurrent insertions at the same list position', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['parakeet']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->pushList($doc1, 'birds', ['starling']); + $doc2 = $port->pushList($doc2, 'birds', ['chaffinch']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['birds' => ['parakeet', 'starling', 'chaffinch']], 'same-position concurrent insertions should keep both inserted values in deterministic order'); + same($port->getConflicts($merged, 'birds'), null, 'same-position concurrent list insertions should not create a root conflict'); + sameArray($port->mergeDocuments($doc2, $merged)->toArray(), $merged->toArray(), 'merging the resolved same-position insertion state back should converge'); + sameArray($port->load($port->save($merged))->toArray(), ['birds' => ['parakeet', 'starling', 'chaffinch']], 'same-position list insertions should survive save/load'); + } +); + +$mapped( + 'legacy concurrent assignment and deletion of a map entry is add-wins', + 'javascript/test/legacy_tests.ts', + 1247, + 'should handle concurrent assignment and deletion of a map entry', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'bestBird', 'robin'); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->delete($doc1, 'bestBird'); + $doc2 = $port->set($doc2, 'bestBird', 'magpie'); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($doc1->toArray(), [], 'deleted branch should not retain the removed map entry'); + sameArray($doc2->toArray(), ['bestBird' => 'magpie'], 'assignment branch should materialize the replacement map entry'); + sameArray($merged->toArray(), ['bestBird' => 'magpie'], 'concurrent assignment should win over deletion for map entries'); + same($port->getConflicts($merged, 'bestBird'), null, 'concurrent assignment and deletion should not create a conflict'); + sameArray($port->load($port->save($merged))->toArray(), ['bestBird' => 'magpie'], 'add-wins map entry result should survive save/load'); + } +); + +$mapped( + 'legacy concurrent assignment and deletion of a list element is add-wins', + 'javascript/test/legacy_tests.ts', + 1260, + 'should handle concurrent assignment and deletion of a list element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['blackbird', 'thrush', 'goldfinch']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setListElement($doc1, 'birds', 1, 'starling'); + $doc2 = $port->deleteListElements($doc2, 'birds', 1); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($doc1->toArray()['birds'], ['blackbird', 'starling', 'goldfinch'], 'assignment branch should replace the middle list element'); + sameArray($doc2->toArray()['birds'], ['blackbird', 'goldfinch'], 'delete branch should remove the middle list element'); + sameArray($merged->toArray()['birds'], ['blackbird', 'starling', 'goldfinch'], 'concurrent assignment should resurrect the deleted list element'); + same($port->getConflicts($merged, 'birds'), null, 'concurrent list assignment and deletion should not create a root conflict'); + same($port->getListElementConflicts($merged, 'birds', 1), null, 'concurrent list assignment and deletion should not create element conflicts'); + sameArray($port->load($port->save($merged))->toArray()['birds'], ['blackbird', 'starling', 'goldfinch'], 'add-wins list element result should survive save/load'); + } +); + +$mapped( + 'legacy insertion after a concurrently deleted list element survives', + 'javascript/test/legacy_tests.ts', + 1278, + 'should handle insertion after a deleted list element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['blackbird', 'thrush', 'goldfinch']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteListElements($doc1, 'birds', 1, 2); + $doc2 = $port->insertListElements($doc2, 'birds', 2, ['starling']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['birds' => ['blackbird', 'starling']], 'insertion after deleted list elements should survive without resurrecting deleted base values'); + sameArray($port->mergeDocuments($doc2, $merged)->toArray(), ['birds' => ['blackbird', 'starling']], 'merging the insertion branch with the merged document should remain stable'); + same($port->getConflicts($merged, 'birds'), null, 'insertion after deleted list elements should not create a root conflict'); + } +); + +$mapped( + 'legacy concurrent deletion of the same list element is idempotent', + 'javascript/test/legacy_tests.ts', + 1293, + 'should handle concurrent deletion of the same element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['albatross', 'buzzard', 'cormorant']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteListElements($doc1, 'birds', 1); + $doc2 = $port->deleteListElements($doc2, 'birds', 1); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['birds'], ['albatross', 'cormorant'], 'concurrent deletion of the same original element should delete it once'); + same($port->getConflicts($merged, 'birds'), null, 'concurrent deletion of the same list element should not create a root conflict'); + } +); + +$mapped( + 'legacy concurrent deletion of different list elements removes both originals', + 'javascript/test/legacy_tests.ts', + 1305, + 'should handle concurrent deletion of different elements', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['albatross', 'buzzard', 'cormorant']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteListElements($doc1, 'birds', 0); + $doc2 = $port->deleteListElements($doc2, 'birds', 1); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['birds'], ['cormorant'], 'concurrent deletion of different original elements should remove both'); + same($port->getConflicts($merged, 'birds'), null, 'concurrent deletion of different list elements should not create a root conflict'); + } +); + +$mapped( + 'legacy concurrent sequence insertions at the same position stay grouped', + 'javascript/test/legacy_tests.ts', + 1355, + 'should not interleave sequence insertions at the same position', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'wisdom', []); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->insertListElements($doc1, 'wisdom', 0, ['to', 'be', 'is', 'to', 'do']); + $doc2 = $port->insertListElements($doc2, 'wisdom', 0, ['to', 'do', 'is', 'to', 'be']); + $merged = $port->mergeDocuments($doc1, $doc2); + $wisdom = $merged->toArray()['wisdom']; + + $leftFirst = ['to', 'be', 'is', 'to', 'do', 'to', 'do', 'is', 'to', 'be']; + $rightFirst = ['to', 'do', 'is', 'to', 'be', 'to', 'be', 'is', 'to', 'do']; + truthy($wisdom === $leftFirst || $wisdom === $rightFirst, 'concurrent insertion groups at the same position should not interleave'); + same($port->getConflicts($merged, 'wisdom'), null, 'grouped concurrent insertions should not create a root conflict'); + } +); + +$mapped( + 'legacy list insertion works when the inserting actor id is greater', + 'javascript/test/legacy_tests.ts', + 1374, + 'should handle insertion by greater actor ID', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaa'), 'list', ['two']); + $doc2 = $port->mergeDocuments($port->init('bbbb'), $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two'], 'greater actor id insertion should appear before the existing element'); + } +); + +$mapped( + 'legacy list insertion works when the inserting actor id is lesser', + 'javascript/test/legacy_tests.ts', + 1383, + 'should handle insertion by lesser actor ID', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbb'), 'list', ['two']); + $doc2 = $port->mergeDocuments($port->init('aaaa'), $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two'], 'lesser actor id insertion should appear before the existing element'); + } +); + +$mapped( + 'legacy list insertion before an existing element is actor-id independent', + 'javascript/test/legacy_tests.ts', + 1392, + 'should handle insertion regardless of actor ID', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['two']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two'], 'list insertion before an existing element should not depend on actor id'); + } +); + +$mapped( + 'legacy causal list prepends maintain insertion order', + 'javascript/test/legacy_tests.ts', + 1399, + 'should make insertion order consistent with causality', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['four']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc2 = $port->insertListElements($doc2, 'list', 0, ['three']); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $doc1 = $port->insertListElements($doc1, 'list', 0, ['two']); + $doc2 = $port->mergeDocuments($doc2, $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two', 'three', 'four'], 'causally ordered list prepends should materialize in causal order'); + } +); + +$mapped( + 'legacy delete higher in a tree wins over a concurrent subtree update', + 'javascript/test/legacy_tests.ts', + 1317, + 'should handle concurrent updates at different levels of the tree', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'animals', [ + 'birds' => ['pink' => 'flamingo', 'black' => 'starling'], + 'mammals' => ['badger'], + ]); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setNested($doc1, ['animals', 'birds', 'brown'], 'sparrow'); + $doc2 = $port->deleteNested($doc2, ['animals', 'birds']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray( + $doc1->toArray()['animals'], + ['birds' => ['pink' => 'flamingo', 'black' => 'starling', 'brown' => 'sparrow'], 'mammals' => ['badger']], + 'subtree update branch should retain the added nested bird' + ); + sameArray($doc2->toArray()['animals'], ['mammals' => ['badger']], 'delete branch should remove the nested birds map'); + sameArray($merged->toArray()['animals'], ['mammals' => ['badger']], 'delete of a higher tree level should win over concurrent subtree update'); + same($port->getConflicts($merged, 'animals'), null, 'higher-level delete should not create a root conflict'); + } +); + +$mapped( + 'legacy updates inside concurrently deleted objects do not resurrect the object', + 'javascript/test/legacy_tests.ts', + 1343, + 'should handle updates of concurrently deleted objects', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['blackbird' => ['feathers' => 'black']]); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteNested($doc1, ['birds', 'blackbird']); + $doc2 = $port->setNested($doc2, ['birds', 'blackbird', 'beak'], 'orange'); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($doc1->toArray(), ['birds' => []], 'delete branch should remove the nested object'); + sameArray($merged->toArray(), ['birds' => []], 'concurrent update under a deleted object should not resurrect it'); + same($port->getConflicts($merged, 'birds'), null, 'concurrently deleted object should not create a root conflict'); + } +); + +$mapped( + 'legacy change API sanity-checks document root arguments', + 'javascript/test/legacy_tests.ts', + 259, + 'should sanity-check arguments', + function () use ($port): void { + $doc = $port->change( + $port->init('aabbcc'), + static function (Document $draft): void { + $draft->set('nested', []); + } + ); + $nested = $doc->toArray()['nested']; + + throwsLike( + static fn () => $port->change([], static fn (Document $draft): Document => $draft), + 'must be the document root', + 'change should reject a plain PHP array as a root argument' + ); + throwsLike( + static fn () => $port->change($nested, static fn (Document $draft): Document => $draft), + 'must be the document root', + 'change should reject a nested materialized object as a root argument' + ); + } +); + +$mapped( + 'legacy change API rejects nested and outdated change attempts', + 'javascript/test/legacy_tests.ts', + 271, + 'should not allow nested change blocks', + function () use ($port): void { + $doc = $port->init('aabbcc'); + + throwsLike( + static function () use ($port, $doc): void { + $port->change( + $doc, + static function (Document $draft) use ($port): void { + $port->change( + $draft, + static function (Document $nestedDraft): void { + $nestedDraft->set('foo', 'bar'); + } + ); + } + ); + }, + 'Calls to Automerge.change cannot be nested', + 'change should reject nested change calls on the active draft' + ); + + throwsLike( + static function () use ($port, $doc): void { + $port->change( + $doc, + static function (Document $draft) use ($port, $doc): void { + $port->change( + $doc, + static function (Document $otherDraft): void { + $otherDraft->set('two', 2); + } + ); + $draft->set('one', 1); + } + ); + }, + 'Attempting to change an outdated document', + 'change should reject reusing the base document while a draft is active' + ); + } +); + +$mapped( + 'legacy change API rejects reusing the same base document', + 'javascript/test/legacy_tests.ts', + 288, + 'should not allow the same base document to be used for multiple changes', + function () use ($port): void { + $doc = $port->init('aabbcc'); + $changed = $port->change( + $doc, + static function (Document $draft): void { + $draft->set('one', 1); + } + ); + + same($changed->toArray(), ['one' => 1], 'first change should materialize the base edit'); + throwsLike( + static fn () => $port->change( + $doc, + static function (Document $draft): void { + $draft->set('two', 2); + } + ), + 'Attempting to change an outdated document', + 'change should reject reusing a previously changed base document' + ); + } +); + +$mapped( + 'error handling throws an exception object for invalid list assignment', + 'javascript/test/error.ts', + 5, + 'proxy handler throws an error, not a string', + function () use ($port): void { + $doc = $port->from(['d' => ['test']], 'aabbcc'); + try { + $port->setListElement($doc, 'd', 2, 'oops'); + } catch (Throwable $throwable) { + truthy($throwable instanceof OutOfBoundsException, 'invalid list assignment should throw a native exception object'); + same($throwable->getMessage(), 'List assignment index is out of bounds.', 'invalid list assignment should explain the bounds failure'); + return; + } + + throw new RuntimeException('invalid list assignment did not throw'); + } +); + +$mapped( + 'legacy sequential clone can diverge independently', + 'javascript/test/legacy_tests.ts', + 295, + 'should allow a document to be cloned', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'zero', 0); + $doc2 = $port->clone($doc1, 'ddeeff'); + $doc1 = $port->set($doc1, 'one', 1); + $doc2 = $port->set($doc2, 'two', 2); + + sameArray($doc1->toArray(), ['zero' => 0, 'one' => 1], 'original branch should keep its own later write'); + sameArray($doc2->toArray(), ['zero' => 0, 'two' => 2], 'cloned branch should keep its own later write'); + } +); + +$mapped( + 'legacy sequential object assign style replacement works', + 'javascript/test/legacy_tests.ts', + 306, + 'should work with Object.assign merges', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'stuff', ['foo' => 'bar', 'baz' => 'blur']); + $stuff = $doc->toArray()['stuff']; + $stuff['baz'] = 'updated!'; + $doc = $port->set($doc, 'stuff', $stuff); + + sameArray($doc->toArray(), ['stuff' => ['foo' => 'bar', 'baz' => 'updated!']], 'object replacement should preserve untouched keys and update changed keys'); + } +); + +$mapped( + 'legacy sequential supports Date objects in maps', + 'javascript/test/legacy_tests.ts', + 316, + 'should support Date objects in maps', + function () use ($port): void { + $now = new DateTimeImmutable('2026-05-22T01:02:03.456Z'); + $doc = $port->set($port->init('aabbcc'), 'now', $now); + $applied = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc)); + $materialized = $applied->toArray()['now']; + + truthy($materialized instanceof DateTimeInterface, 'replayed map date should materialize as a DateTime value'); + same(dateMillis($materialized), dateMillis($now), 'replayed map date should preserve milliseconds'); + } +); + +$mapped( + 'legacy sequential supports Date objects in lists', + 'javascript/test/legacy_tests.ts', + 325, + 'should support Date objects in lists', + function () use ($port): void { + $now = new DateTimeImmutable('2026-05-22T01:02:03.789Z'); + $doc = $port->set($port->init('aabbcc'), 'list', [$now]); + $applied = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc)); + $materialized = $applied->toArray()['list'][0]; + + truthy($materialized instanceof DateTimeInterface, 'replayed list date should materialize as a DateTime value'); + same(dateMillis($materialized), dateMillis($now), 'replayed list date should preserve milliseconds'); + } +); + +$mapped( + 'legacy patch callback receives list assignment patches', + 'javascript/test/legacy_tests.ts', + 334, + 'should call patchCallback if supplied', + function () use ($port): void { + $callbacks = []; + $doc1 = $port->init('aabbcc'); + $doc2 = $port->setWithPatchCallback( + $doc1, + 'birds', + ['Goldfinch'], + static function (array $patches, array $info) use (&$callbacks): void { + $callbacks[] = [ + 'patches' => $patches, + 'before' => $info['before'], + 'after' => $info['after'], + ]; + } + ); + + same(count($callbacks), 1, 'explicit patch callback should be called exactly once'); + sameArray( + $callbacks[0]['patches'], + [ + ['action' => 'put', 'path' => ['birds'], 'value' => []], + ['action' => 'insert', 'path' => ['birds', 0], 'values' => ['']], + ['action' => 'splice', 'path' => ['birds', 0, 0], 'value' => 'Goldfinch'], + ], + 'list assignment should emit the expected put/insert/splice patches' + ); + sameArray($callbacks[0]['before']->toArray(), [], 'patch callback should receive the pre-change document'); + sameArray($callbacks[0]['after']->toArray(), $doc2->toArray(), 'patch callback should receive the post-change document'); + sameArray($doc2->toArray(), ['birds' => ['Goldfinch']], 'list assignment should still materialize the assigned value'); + } +); + +$mapped( + 'legacy initialization-level patch callback receives string assignment patches', + 'javascript/test/legacy_tests.ts', + 374, + 'should call a patchCallback set up on document initialisation', + function () use ($port): void { + $callbacks = []; + $callbackPort = $port->withPatchCallback( + static function (array $patches, array $info) use (&$callbacks): void { + $callbacks[] = [ + 'patches' => $patches, + 'before' => $info['before'], + 'after' => $info['after'], + ]; + } + ); + + $doc1 = $callbackPort->init('aabbcc'); + $doc2 = $callbackPort->set($doc1, 'bird', 'Goldfinch'); + + same(count($callbacks), 1, 'initialization-level patch callback should be called once'); + sameArray( + $callbacks[0]['patches'], + [ + ['action' => 'put', 'path' => ['bird'], 'value' => ''], + ['action' => 'splice', 'path' => ['bird', 0], 'value' => 'Goldfinch'], + ], + 'string assignment should emit the expected put/splice patches' + ); + sameArray($callbacks[0]['before']->toArray(), [], 'initial patch callback should receive the pre-change document'); + sameArray($callbacks[0]['after']->toArray(), $doc2->toArray(), 'initial patch callback should receive the post-change document'); + sameArray($doc2->toArray(), ['bird' => 'Goldfinch'], 'string assignment should still materialize the assigned value'); + } +); + +$mapped( + 'legacy load invokes a supplied patch callback', + 'javascript/test/legacy_tests.ts', + 1570, + 'should call patchCallback if supplied to load', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'birds', ['Goldfinch']); + $doc = $port->pushList($doc, 'birds', ['Chaffinch']); + $callbacks = []; + + $loaded = $port->loadWithPatchCallback( + $port->save($doc), + static function (array $patches, array $info) use (&$callbacks): void { + $callbacks[] = [ + 'patches' => $patches, + 'before' => $info['before'], + 'after' => $info['after'], + 'source' => $info['source'] ?? null, + ]; + }, + 'cccccc' + ); + + same(count($callbacks), 1, 'load patch callback should be called once'); + sameArray( + $callbacks[0]['patches'], + [ + ['action' => 'put', 'path' => ['birds'], 'value' => []], + ['action' => 'insert', 'path' => ['birds', 0], 'values' => ['', '']], + ['action' => 'splice', 'path' => ['birds', 0, 0], 'value' => 'Goldfinch'], + ['action' => 'splice', 'path' => ['birds', 1, 0], 'value' => 'Chaffinch'], + ], + 'load callback should receive patches needed to construct the loaded document' + ); + sameArray($callbacks[0]['before']->toArray(), [], 'load callback before document should be empty'); + sameArray($callbacks[0]['after']->toArray(), $loaded->toArray(), 'load callback after document should be the loaded document'); + same($callbacks[0]['source'], 'load', 'load callback should identify the patch source'); + sameArray($loaded->toArray(), ['birds' => ['Goldfinch', 'Chaffinch']], 'loaded document should materialize both list entries'); + } +); + +$mapped( + 'legacy applyChanges invokes a supplied patch callback', + 'javascript/test/legacy_tests.ts', + 1780, + 'should call patchCallback if supplied when applying changes', + function () use ($port): void { + $source = $port->set($port->init('aabbcc'), 'birds', ['Goldfinch']); + $callbacks = []; + + $after = $port->applyChangesWithPatchCallback( + $port->init('bbbbbb'), + $port->getAllChanges($source), + static function (array $patches, array $info) use (&$callbacks): void { + $callbacks[] = [ + 'patches' => $patches, + 'before' => $info['before'], + 'after' => $info['after'], + 'source' => $info['source'] ?? null, + ]; + } + ); + + same(count($callbacks), 1, 'applyChanges patch callback should be called once'); + sameArray( + $callbacks[0]['patches'], + [ + ['action' => 'put', 'path' => ['birds'], 'value' => []], + ['action' => 'insert', 'path' => ['birds', 0], 'values' => ['']], + ['action' => 'splice', 'path' => ['birds', 0, 0], 'value' => 'Goldfinch'], + ], + 'applyChanges callback should receive patches needed to apply the incoming change' + ); + sameArray($callbacks[0]['before']->toArray(), [], 'applyChanges callback before document should be unchanged'); + sameArray($callbacks[0]['after']->toArray(), $after->toArray(), 'applyChanges callback after document should be the applied document'); + same($callbacks[0]['source'], 'applyChanges', 'applyChanges callback should identify the patch source'); + sameArray($after->toArray(), ['birds' => ['Goldfinch']], 'applied document should materialize the incoming list value'); + } +); + +$mapped( + 'legacy applyChanges merges multiple string-list changes into one patch batch', + 'javascript/test/legacy_tests.ts', + 1820, + 'should merge multiple applied changes into one patch', + function () use ($port): void { + $source = $port->set($port->init('aabbcc'), 'birds', ['Goldfinch']); + $source = $port->pushList($source, 'birds', ['Chaffinch']); + $patches = []; + + $port->applyChangesWithPatchCallback( + $port->init('bbbbbb'), + $port->getAllChanges($source), + static function (array $patchBatch) use (&$patches): void { + array_push($patches, ...$patchBatch); + } + ); + + sameArray( + $patches, + [ + ['action' => 'put', 'path' => ['birds'], 'value' => []], + ['action' => 'insert', 'path' => ['birds', 0], 'values' => ['', '']], + ['action' => 'splice', 'path' => ['birds', 0, 0], 'value' => 'Goldfinch'], + ['action' => 'splice', 'path' => ['birds', 1, 0], 'value' => 'Chaffinch'], + ], + 'applyChanges should report one coalesced string-list patch batch' + ); + } +); + +$mapped( + 'legacy applyChanges invokes patch callback registered on document initialization', + 'javascript/test/legacy_tests.ts', + 1838, + 'should call a patchCallback registered on doc initialisation', + function () use ($port): void { + $source = $port->set($port->init('aabbcc'), 'bird', 'Goldfinch'); + $patches = []; + $before = $port->initWithPatchCallback( + static function (array $patchBatch) use (&$patches): void { + array_push($patches, ...$patchBatch); + }, + 'bbbbbb' + ); + + $after = $port->applyChanges($before, $port->getAllChanges($source)); + + sameArray( + $patches, + [ + ['action' => 'put', 'path' => ['bird'], 'value' => ''], + ['action' => 'splice', 'path' => ['bird', 0], 'value' => 'Goldfinch'], + ], + 'document initialization patch callback should observe applied changes' + ); + sameArray($after->toArray(), ['bird' => 'Goldfinch'], 'applyChanges should still materialize the applied scalar string'); + } +); + +$mapped( + 'root map delete keeps only the live property across repeated changes', + 'javascript/test/basic_test.ts', + 66, + 'should be able to insert and delete a large number of properties', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'k1', true); + + for ($idx = 1; $idx <= 200; ++$idx) { + $doc = $port->delete($doc, 'k' . $idx); + $doc = $port->set($doc, 'k' . ($idx + 1), true); + same(count($doc->toArray()), 1, 'only one property should remain live after each delete/set'); + } + + sameArray($doc->toArray(), ['k201' => true], 'last live property should be k201'); + } +); + +$mapped( + 'basic isAutomerge detects only native documents', + 'javascript/test/basic_test.ts', + 82, + 'can detect an automerge doc with isAutomerge()', + function () use ($port): void { + $doc = $port->from(['sub' => ['object' => true]], 'aaaaaa'); + $jsObject = $port->toJS($doc); + + truthy($port->isAutomerge($doc), 'native Document should be reported as Automerge data'); + truthy(! $port->isAutomerge($doc->toArray()['sub']), 'materialized sub-objects should not be reported as Automerge documents'); + truthy(! $port->isAutomerge('String'), 'strings should not be reported as Automerge documents'); + truthy(! $port->isAutomerge(['sub' => ['object' => true]]), 'plain arrays should not be reported as Automerge documents'); + truthy(! $port->isAutomerge(null), 'null should not be reported as Automerge data'); + truthy(! $port->isAutomerge($jsObject), 'materialized arrays should not be reported as Automerge documents'); + sameArray($jsObject, $doc->toArray(), 'toJS should materialize the document as plain PHP arrays and scalars'); + } +); + +$mapped( + 'basic freeze option recursively marks document materialization frozen', + 'javascript/test/basic_test.ts', + 94, + 'it should recursively freeze the document if requested', + function () use ($port): void { + $doc1 = $port->initFrozen('aabbcc'); + $doc2 = $port->init('ddeeff'); + + truthy($doc1->isFrozen(), 'init with freeze should mark the document frozen'); + truthy(! $doc2->isFrozen(), 'plain init should not mark the document frozen'); + + $doc1 = $port->change( + $doc1, + static function (Document $draft): void { + $draft->set('book', ['title' => 'how to win friends']); + } + ); + $doc2 = $port->mergeDocuments($doc2, $doc1); + + truthy($doc1->isFrozen(), 'change should preserve the frozen flag on the returned document'); + truthy($doc1->isFrozenPath(['book']), 'freeze should cover nested materialized map values'); + truthy(! $doc2->isFrozen(), 'merge into a non-frozen document should keep the destination non-frozen'); + truthy(! $doc2->isFrozenPath(['book']), 'merge into a non-frozen document should not freeze nested values'); + + $doc3 = $port->fromFrozen(['sub' => ['obj' => 'inner']], '112233'); + truthy($doc3->isFrozen(), 'from with freeze should mark the document frozen'); + truthy($doc3->isFrozenPath(['sub']), 'from with freeze should mark nested maps frozen'); + + $doc4 = $port->loadFrozen($port->save($doc3), '445566'); + truthy($doc4->isFrozen(), 'load with freeze should mark the document frozen'); + truthy($doc4->isFrozenPath(['sub']), 'load with freeze should mark nested maps frozen'); + + $doc5 = $port->clone($doc4, '778899'); + truthy($doc5->isFrozen(), 'clone should preserve the frozen flag'); + truthy($doc5->isFrozenPath(['sub']), 'clone should preserve recursive frozen state'); + + $jsObject = $port->toJS($doc5); + truthy(is_array($jsObject), 'toJS should return a plain PHP array'); + truthy(! $port->isAutomerge($jsObject), 'toJS output should not be an Automerge document'); + sameArray($jsObject, ['sub' => ['obj' => 'inner']], 'toJS should preserve frozen document content'); + } +); + +$mapped( + 'legacy freeze rejects direct document mutation outside change', + 'javascript/test/legacy_tests.ts', + 158, + 'should freeze objects if desired', + function () use ($port): void { + $doc1 = $port->initFrozen('aabbcc'); + $doc2 = $port->change( + $doc1, + static function (Document $draft): void { + $draft->set('foo', 'bar'); + } + ); + + throwsLike( + static fn () => $doc2->set('foo', 'lemon'), + 'frozen document cannot be modified directly', + 'direct root assignment on a frozen document should be rejected' + ); + same($doc2->toArray()['foo'], 'bar', 'failed direct assignment should leave the frozen value unchanged'); + + throwsLike( + static fn () => $doc2->delete('foo'), + 'frozen document cannot be modified directly', + 'direct root deletion on a frozen document should be rejected' + ); + same($doc2->toArray()['foo'], 'bar', 'failed direct deletion should leave the frozen value unchanged'); + + $port->change( + $doc2, + static function (Document $draft) use ($doc2): void { + throwsLike( + static fn () => $doc2->set('foo', 'lemon'), + 'frozen document cannot be modified directly', + 'direct mutation of the frozen base inside change should still be rejected' + ); + same($draft->toArray()['foo'], 'bar', 'change draft should be a mutable copy of the frozen base'); + } + ); + + throwsLike( + static fn () => $doc2->setMany(['x' => 4]), + 'frozen document cannot be modified directly', + 'bulk direct assignment on a frozen document should be rejected' + ); + truthy(! array_key_exists('x', $doc2->toArray()), 'failed bulk assignment should not add a root key'); + } +); + +$mapped( + 'basic root sets over many changes preserve scalar and typed values', + 'javascript/test/basic_test.ts', + 132, + 'handle basic sets over many changes', + function () use ($port): void { + $timestamp = new DateTimeImmutable('2026-05-22T03:51:00.123Z'); + $counter = new Counter(100); + $bytes = new BytesValue([10, 11, 12]); + $doc = $port->init('aabbcc'); + $doc = $port->set($doc, 'hello', 'world'); + $doc = $port->set($doc, 'counter1', $counter); + $doc = $port->set($doc, 'timestamp1', $timestamp); + $doc = $port->set($doc, 'app', null); + $doc = $port->set($doc, 'bytes1', $bytes); + $doc = $port->setMany($doc, [ + 'uint' => 1, + 'int' => -1, + 'float64' => 5.5, + 'number1' => 100, + 'number2' => -45.67, + 'true' => true, + 'false' => false, + ]); + $materialized = $doc->toArray(); + + same($materialized['hello'], 'world', 'string root value should survive many changes'); + truthy($materialized['counter1'] instanceof Counter, 'counter root value should remain a native Counter'); + same($materialized['counter1']->value(), 100, 'counter root value should preserve its value'); + truthy($materialized['timestamp1'] instanceof DateTimeInterface, 'timestamp root value should remain a DateTime value'); + same(dateMillis($materialized['timestamp1']), dateMillis($timestamp), 'timestamp root value should preserve milliseconds'); + same($materialized['app'], null, 'null root value should survive many changes'); + truthy($materialized['bytes1'] instanceof BytesValue, 'byte-array root value should stay a native BytesValue'); + sameArray($materialized['bytes1']->bytes(), $bytes->bytes(), 'byte-array root value should survive many changes'); + same($materialized['uint'], 1, 'uint-like root value should be readable'); + same($materialized['int'], -1, 'int-like root value should be readable'); + same($materialized['float64'], 5.5, 'float-like root value should be readable'); + same($materialized['number1'], 100, 'positive number root value should be readable'); + same($materialized['number2'], -45.67, 'negative float root value should be readable'); + same($materialized['true'], true, 'true root value should be readable'); + same($materialized['false'], false, 'false root value should be readable'); + } +); + +$mapped( + 'basic object ids return null for scalar-like values', + 'javascript/test/basic_test.ts', + 558, + 'should return null for scalar values', + function () use ($port): void { + $doc = $port->from([ + 'string' => 'string', + 'number' => 1, + 'null' => null, + 'date' => new DateTimeImmutable('2026-05-22T03:52:00Z'), + 'counter' => new Counter(), + 'bytes' => new BytesValue(array_fill(0, 10, 0)), + ], 'aaaaaa'); + $root = $doc->toArray(); + + same($port->getObjectId($root['string']), null, 'string values should not have object ids'); + same($port->getObjectId($root['number']), null, 'number values should not have object ids'); + same($port->getObjectId($root['null']), null, 'null values should not have object ids'); + same($port->getObjectId($root['date']), null, 'date values should not have object ids'); + same($port->getObjectId($root['counter']), null, 'counter values should not have object ids'); + same($port->getObjectId($root['bytes']), null, 'bytes values should not have object ids'); + } +); + +$mapped( + 'basic object id returns root for a native document', + 'javascript/test/basic_test.ts', + 567, + 'should return _root for the root object', + function () use ($port): void { + $doc = $port->from(['map' => []], 'aaaaaa'); + + same($port->getObjectId($doc), '_root', 'native document root should expose the root object id'); + } +); + +$mapped( + 'basic object ids distinguish containers from text scalars', + 'javascript/test/basic_test.ts', + 571, + 'should return non-null for map, list, text, and objects', + function () use ($port): void { + $doc = $port->from([ + 'text' => '', + 'list' => [], + 'map' => ['nested' => true], + ], 'aaaaaa'); + $root = $doc->toArray(); + + same($port->getObjectId($root['text']), null, 'materialized text strings should not expose object ids in the native PHP API'); + truthy($port->getObjectId($root['list']) !== null, 'list containers should expose a native container object id'); + truthy($port->getObjectId($root['map']) !== null, 'map containers should expose a native container object id'); + } +); + +$mapped( + 'block split inserts a block boundary and exposes spans', + 'javascript/test/block_test.ts', + 18, + 'can split a block', + function () use ($port): void { + $block = ['parents' => ['div'], 'type' => 'p', 'attrs' => []]; + $callbacks = []; + $callbackPort = $port->withPatchCallback(static function (array $patches) use (&$callbacks): void { + $callbacks[] = $patches; + }); + + $doc = $port->from(['text' => 'aaabbbccc']); + $doc = $callbackPort->splitBlock($doc, ['text'], 3, $block); + $loaded = $port->load($port->save($doc)); + + same($doc->toArray()['text'], "aaa\u{FFFC}bbbccc", 'splitBlock should insert the object replacement character'); + sameArray($loaded->toArray(), $doc->toArray(), 'split block should round-trip through save/load'); + sameArray($port->block($loaded, ['text'], 3) ?? [], $block, 'block metadata should survive save/load'); + sameArray($callbacks[0] ?? [], [[ + 'action' => 'insert', + 'path' => ['text', 3], + 'values' => [[]], + ]], 'splitBlock should emit the upstream insert patch shape'); + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'aaa'], + ['type' => 'block', 'value' => $block], + ['type' => 'text', 'value' => 'bbbccc'], + ], 'spans should expose text and block segments'); + + $doc = $port->splice($doc, 'text', 7, 0, 'ADD'); + $doc = $port->splice($doc, 'text', 0, 7, 'REMOVE'); + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'REMOVEADDccc'], + ], 'splicing across the block should remove the block span'); + } +); + +$rustMapped( + 'rust marks in spans cross block markers', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:marks-in-spans-cross-block-markers', + 'marks_in_spans_cross_block_markers', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'lix'); + $doc = $port->mark($doc, ['text'], 0, 3, 'bold', true, 'after'); + $doc = $port->splitBlock($doc, ['text'], 1, []); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'l', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'text', 'value' => 'ix', 'marks' => ['bold' => true]], + ], 'mark coverage should continue across a block marker in spans'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust block diff emits block insertion updates', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:diff-emits-block-updates', + 'diff_emits_block_updates', + function () use ($port): void { + $block = ['parents' => [], 'type' => '', 'attrs' => []]; + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, $block); + $heads = $port->getHeads($doc); + + $expected = [ + ['action' => 'put', 'path' => ['text'], 'value' => ''], + [ + 'action' => 'splice', + 'path' => ['text', 0], + 'value' => "\u{FFFC}", + 'marks' => ['__automerge_block' => $block], + ], + ]; + + sameArray($port->diff($doc, [], $heads), $expected, 'diff from empty heads should emit text creation and block insertion'); + + $advanced = $port->splice($doc, 'text', 0, 0, 'hello world'); + sameArray($port->diff($advanced, [], $heads), $expected, 'diff to historical block heads should ignore later text changes'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust merge produces block insertion diffs', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:merge-produces-block-insertion-diffs', + 'merge_produces_block_insertion_diffs', + function () use ($port): void { + $block = ['parents' => [], 'type' => '', 'attrs' => []]; + $doc = $port->from(['text' => ''], 'aaaaaa'); + $peer = $port->clone($doc, 'bbbbbb'); + $doc = $port->splitBlock($doc, ['text'], 0, $block); + $headsBefore = $port->getHeads($peer); + + $merged = $port->mergeDocuments($peer, $doc); + $patches = $port->diff($merged, $headsBefore, $port->getHeads($merged)); + + $blockInsertion = array_values(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'splice' + && ($patch['path'] ?? null) === ['text', 0] + && ($patch['value'] ?? null) === "\u{FFFC}" + && ($patch['marks']['__automerge_block'] ?? null) === $block + )); + + same(count($blockInsertion), 1, 'merge diff should expose the incoming block insertion exactly once'); + sameArray($port->spans($merged, ['text']), [ + ['type' => 'block', 'value' => $block], + ], 'merged document should materialize the incoming block span'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$mapped( + 'block join removes a block boundary', + 'javascript/test/block_test.ts', + 61, + 'can join a block', + function () use ($port): void { + $block = ['parents' => ['div'], 'type' => 'p', 'attrs' => []]; + + $doc = $port->from(['text' => 'aaabbbccc']); + $doc = $port->splitBlock($doc, ['text'], 3, $block); + $doc = $port->joinBlock($doc, ['text'], 3); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'aaabbbccc'], + ], 'joinBlock should remove the block boundary and restore plain text spans'); + same($port->block($doc, ['text'], 3), null, 'joinBlock should remove block metadata at the joined offset'); + } +); + +$mapped( + 'block updateSpans replaces text and all block spans', + 'javascript/test/block_test.ts', + 81, + 'allows updating all blocks at once', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'parents' => [], + 'type' => 'ordered-list-item', + 'attrs' => [], + ]); + $doc = $port->splice($doc, 'text', 1, 0, 'first thing'); + $doc = $port->splitBlock($doc, ['text'], 7, [ + 'parents' => [], + 'type' => 'ordered-list-item', + 'attrs' => [], + ]); + $doc = $port->splice($doc, 'text', 8, 0, 'second thing'); + + $paragraph = ['parents' => [], 'type' => 'paragraph', 'attrs' => []]; + $nestedList = [ + 'parents' => ['ordered-list-item'], + 'type' => 'unordered-list-item', + 'attrs' => [], + ]; + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'paragraph', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'the first thing'], + ['type' => 'block', 'value' => [ + 'type' => 'unordered-list-item', + 'parents' => ['ordered-list-item'], + 'attrs' => [], + ]], + ['type' => 'text', 'value' => 'the second thing'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => $paragraph], + ['type' => 'text', 'value' => 'the first thing'], + ['type' => 'block', 'value' => $nestedList], + ['type' => 'text', 'value' => 'the second thing'], + ], 'updateSpans should replace both text runs and block metadata'); + sameArray($port->spans($port->load($port->save($doc)), ['text']), $port->spans($doc, ['text']), 'updated spans should round-trip through save/load'); + } +); + +$rustMapped( + 'rust block updateSpans noop leaves diff cursor quiet', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-noop', + 'update_blocks_noop', + function () use ($port): void { + $block = [ + 'parents' => [], + 'type' => 'ordered-list-item', + 'attrs' => [], + ]; + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, $block); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'item 1'); + $headsBefore = $port->getHeads($doc); + $port->updateDiffCursor($doc); + + $updated = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => $block], + ['type' => 'text', 'value' => 'item 1'], + ]); + + truthy($updated === $doc, 'no-op updateSpans should preserve the same native document instance'); + sameArray($port->getHeads($updated), $headsBefore, 'no-op updateSpans should not append a change'); + sameArray($port->diffIncremental($updated), [], 'no-op updateSpans should not emit incremental patches'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust block updateSpans changes block properties', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-change-block-properties', + 'update_blocks_change_block_properties', + function () use ($port): void { + $ordered = ['parents' => [], 'type' => 'ordered-list-item', 'attrs' => []]; + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, $ordered); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'item 1'); + $doc = $port->splitBlock($doc, ['text'], 7, $ordered); + $doc = $port->spliceAtPath($doc, ['text'], 8, 0, 'item 2'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'paragraph', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'item 1'], + ['type' => 'block', 'value' => [ + 'type' => 'unordered-list-item', + 'parents' => ['ordered-list-item'], + 'attrs' => ['key' => 1], + ]], + ['type' => 'text', 'value' => 'item 2'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'paragraph', 'attrs' => []]], + ['type' => 'text', 'value' => 'item 1'], + ['type' => 'block', 'value' => [ + 'parents' => ['ordered-list-item'], + 'type' => 'unordered-list-item', + 'attrs' => ['key' => 1], + ]], + ['type' => 'text', 'value' => 'item 2'], + ], 'updateSpans should change block metadata while preserving block order'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust block updateSpans updates text', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-text', + 'update_blocks_updates_text', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, []); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'first thing'); + $doc = $port->splitBlock($doc, ['text'], 12, []); + $doc = $port->spliceAtPath($doc, ['text'], 13, 0, 'second thing'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'ordered-list-item', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'the first thing'], + ['type' => 'block', 'value' => ['type' => 'paragraph', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'the things are done'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'ordered-list-item', 'attrs' => []]], + ['type' => 'text', 'value' => 'the first thing'], + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'paragraph', 'attrs' => []]], + ['type' => 'text', 'value' => 'the things are done'], + ], 'updateSpans should replace text between existing block markers'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust block updateSpans updates marks', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-marks', + 'update_blocks_updates_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'onetwo'); + $doc = $port->splitBlock($doc, ['text'], 6, []); + $doc = $port->spliceAtPath($doc, ['text'], 7, 0, 'threefour'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'one'], + ['type' => 'text', 'value' => 'two', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => []], + ['type' => 'text', 'value' => 'three', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'four'], + ['type' => 'block', 'value' => []], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'one'], + ['type' => 'text', 'value' => 'two', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'text', 'value' => 'three', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'four'], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ], 'updateSpans should apply marks across text and block spans'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust block updateSpans updates text and blocks together', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-blocks-updates-text-and-blocks-at-once', + 'update_blocks_updates_text_and_blocks_at_once', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, ['type' => 'paragraph', 'parents' => [], 'attrs' => []]); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'hello world'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'unordered-list-item', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'goodbye world'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'unordered-list-item', 'attrs' => []]], + ['type' => 'text', 'value' => 'goodbye world'], + ], 'updateSpans should update text content and block metadata in one pass'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust block metadata supports complex text-like properties', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:text-complex-block-properties', + 'text_complex_block_properties', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'type' => new ImmutableString('ordered-list-item'), + 'parents' => [new ImmutableString('div')], + 'attrs' => [], + ]); + $block = $port->block($doc, ['text'], 0) ?? []; + + truthy(($block['type'] ?? null) instanceof ImmutableString, 'block type should preserve ImmutableString metadata'); + same((string) $block['type'], 'ordered-list-item', 'block type string value should survive'); + truthy(($block['parents'][0] ?? null) instanceof ImmutableString, 'block parent should preserve ImmutableString metadata'); + same((string) $block['parents'][0], 'div', 'block parent string value should survive'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$mapped( + 'block updateSpans emits ImmutableString parent insert patches', + 'javascript/test/block_test.ts', + 135, + 'emits insert patches with ImmutableString for attribute updatese', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'parents' => [], + 'type' => 'paragraph', + 'attrs' => [], + ]); + + $patches = []; + $callbackPort = $port->withPatchCallback(static function (array $patchBatch) use (&$patches): void { + array_push($patches, ...$patchBatch); + }); + $doc = $callbackPort->updateSpans($doc, ['text'], [[ + 'type' => 'block', + 'value' => [ + 'type' => 'paragraph', + 'parents' => [new ImmutableString('someparent')], + 'attrs' => [], + ], + ]]); + + same(count($patches), 1, 'updateSpans should emit one parent insertion patch'); + same($patches[0]['action'] ?? null, 'insert', 'parent update patch should be an insert'); + sameArray($patches[0]['path'] ?? [], ['text', 0, 'parents', 0], 'parent update patch should target the block parents list'); + same(count($patches[0]['values'] ?? []), 1, 'parent update patch should insert one value'); + truthy(($patches[0]['values'][0] ?? null) instanceof ImmutableString, 'parent update patch should preserve ImmutableString values'); + same((string) $patches[0]['values'][0], 'someparent', 'parent update patch should preserve the ImmutableString text'); + $block = $port->block($doc, ['text'], 0); + truthy(($block['parents'][0] ?? null) instanceof ImmutableString, 'block metadata should retain the ImmutableString parent before serialization'); + } +); + +$rustMapped( + 'rust updateSpans deletes a block attribute list entry', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-delete-attribute', + 'update_spans_delete_attribute', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'type' => 'ordered-list-item', + 'parents' => ['div'], + 'attrs' => [], + ]); + $doc = $port->updateSpans($doc, ['text'], [[ + 'type' => 'block', + 'value' => [ + 'type' => 'ordered-list-item', + 'parents' => [], + 'attrs' => [], + ], + ]]); + + sameArray($port->spans($doc, ['text']), [[ + 'type' => 'block', + 'value' => [ + 'parents' => [], + 'type' => 'ordered-list-item', + 'attrs' => [], + ], + ]], 'updateSpans should remove the previous block parent entry'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$mapped( + 'block updateSpans materializes marks in spans', + 'javascript/test/block_test.ts', + 179, + 'should update marks', + function () use ($port): void { + $doc = $port->from(['text' => 'hello world']); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => ' world', 'marks' => ['italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => ' world', 'marks' => ['italic' => true]], + ], 'updateSpans should preserve active mark maps when materializing spans'); + } +); + +$rustMapped( + 'rust updateSpans diffs existing and desired marks', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-diffs-marks', + 'update_spans_diffs_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 5, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['italic' => true]], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'world', 'marks' => ['bold' => true, 'italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['italic' => true]], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'world', 'marks' => ['bold' => true, 'italic' => true]], + ], 'updateSpans should replace existing mark coverage with the desired mark sets'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustDiffMarksSuite = 'rust:tests-diff-marks-rs-target-debug-deps-diff-marks-faa6fc17c830fc19:'; + +$rustMapped( + 'rust diff marks expands an existing mark range', + $rustDiffMarksSuite . 'mark-expands', + 'mark_expands', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text'); + $doc = $port->mark($doc, ['text'], 0, 4, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold text', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold text', 'marks' => ['bold' => true]], + ], 'updateSpans should expand an existing mark to cover the desired text range'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks contracts an existing mark range', + $rustDiffMarksSuite . 'mark-contracts', + 'mark_contracts', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text'); + $doc = $port->mark($doc, ['text'], 0, 9, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text'], + ], 'updateSpans should contract an existing mark to the desired range'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks shifts a mark position', + $rustDiffMarksSuite . 'mark-shifts-position', + 'mark_shifts_position', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text'); + $doc = $port->mark($doc, ['text'], 0, 4, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text '], + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'text '], + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ], 'updateSpans should move a mark to the desired span'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks splits one mark into two ranges', + $rustDiffMarksSuite . 'mark-splits', + 'mark_splits', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text here'); + $doc = $port->mark($doc, ['text'], 0, 14, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text '], + ['type' => 'text', 'value' => 'here', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text '], + ['type' => 'text', 'value' => 'here', 'marks' => ['bold' => true]], + ], 'updateSpans should split one mark into two separated ranges'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks merges adjacent same-valued marks', + $rustDiffMarksSuite . 'adjacent-marks-merge', + 'adjacent_marks_merge', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text'); + $doc = $port->mark($doc, ['text'], 0, 4, 'bold', true, 'both'); + $doc = $port->mark($doc, ['text'], 5, 9, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold text', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold text', 'marks' => ['bold' => true]], + ], 'adjacent desired ranges with the same mark should materialize as one marked span'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks keeps separated same-valued marks apart', + $rustDiffMarksSuite . 'adjacent-marks-stay-separate', + 'adjacent_marks_stay_separate', + function () use ($port): void { + $spans = [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'text', 'marks' => ['bold' => true]], + ]; + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text'); + $doc = $port->updateSpans($doc, ['text'], $spans); + $doc = $port->updateSpans($doc, ['text'], $spans); + + sameArray($port->spans($doc, ['text']), $spans, 'same-valued marks separated by unmarked text should stay separated'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks keeps different adjacent marks separate', + $rustDiffMarksSuite . 'different-adjacent-marks', + 'different_adjacent_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bolditalic'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'italic', 'marks' => ['italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'italic', 'marks' => ['italic' => true]], + ], 'different adjacent marks should materialize as separate spans'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks removes empty spans between equal marks', + $rustDiffMarksSuite . 'empty-spans-between-marks', + 'empty_spans_between_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'a', 'marks' => ['mark' => true]], + ['type' => 'text', 'value' => ''], + ['type' => 'text', 'value' => 'b', 'marks' => ['mark' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'ab', 'marks' => ['mark' => true]], + ], 'empty spans should not break adjacent equal mark ranges'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks updateSpans is idempotent', + $rustDiffMarksSuite . 'idempotent-update-spans', + 'idempotent_update_spans', + function () use ($port): void { + $spans = [ + ['type' => 'text', 'value' => 'hello ', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'world', 'marks' => ['italic' => true]], + ]; + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], $spans); + $heads = $port->getHeads($doc); + $doc2 = $port->updateSpans($doc, ['text'], $spans); + $doc3 = $port->updateSpans($doc2, ['text'], $spans); + + sameArray($port->spans($doc3, ['text']), $spans, 'idempotent updateSpans should preserve the desired spans'); + sameArray($port->getHeads($doc2), $heads, 'second identical updateSpans should not append a change'); + sameArray($port->getHeads($doc3), $heads, 'third identical updateSpans should not append a change'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks preserves many marks on the same text', + $rustDiffMarksSuite . 'many-marks-on-same-text', + 'many_marks_on_same_text', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'formatted'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'formatted', 'marks' => [ + 'bold' => true, + 'italic' => true, + 'link' => 'https://example.com', + 'underline' => true, + ]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'formatted', 'marks' => [ + 'bold' => true, + 'italic' => true, + 'link' => 'https://example.com', + 'underline' => true, + ]], + ], 'multiple marks with different names should survive on one text span'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks ignores marks on empty strings', + $rustDiffMarksSuite . 'mark-on-empty-string', + 'mark_on_empty_string', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => '', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [], 'marking an empty string should produce no visible spans'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks applies marks to whitespace text', + $rustDiffMarksSuite . 'mark-on-whitespace', + 'mark_on_whitespace', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => ' ', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => "\n", 'marks' => ['italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => ' ', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => "\n", 'marks' => ['italic' => true]], + ], 'marks should apply to whitespace text spans'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks changes a color mark value', + $rustDiffMarksSuite . 'mark-value-changes-color', + 'mark_value_changes_color', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'colored'); + $doc = $port->mark($doc, ['text'], 0, 7, 'color', 'red', 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'colored', 'marks' => ['color' => 'blue']], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'colored', 'marks' => ['color' => 'blue']], + ], 'updateSpans should replace a color mark value'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks changes a link mark value', + $rustDiffMarksSuite . 'mark-value-changes-link-url', + 'mark_value_changes_link_url', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'click here'); + $doc = $port->mark($doc, ['text'], 0, 10, 'link', 'https://old.com', 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'click here', 'marks' => ['link' => 'https://new.com']], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'click here', 'marks' => ['link' => 'https://new.com']], + ], 'updateSpans should replace a string mark value'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks changes a mark value type', + $rustDiffMarksSuite . 'mark-value-type-changes', + 'mark_value_type_changes', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'text'); + $doc = $port->mark($doc, ['text'], 0, 4, 'custom', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text', 'marks' => ['custom' => 'value']], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'text', 'marks' => ['custom' => 'value']], + ], 'updateSpans should replace a boolean mark value with a string value'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks alternates mark changes', + $rustDiffMarksSuite . 'alternating-mark-changes', + 'alternating_mark_changes', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'text'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text', 'marks' => ['bold' => true]], + ]); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text'], + ]); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text', 'marks' => ['italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'text', 'marks' => ['italic' => true]], + ], 'alternating mark updates should leave only the final desired mark'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks handles complex unicode text', + $rustDiffMarksSuite . 'complex-unicode-text', + 'complex_unicode_text', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'Hello '], + ['type' => 'text', 'value' => '😊', 'marks' => ['emoji' => true]], + ['type' => 'text', 'value' => ' 世界 ', 'marks' => ['chinese' => true]], + ['type' => 'text', 'value' => '🌍', 'marks' => ['emoji' => true]], + ['type' => 'text', 'value' => ' مرحبا', 'marks' => ['arabic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'Hello '], + ['type' => 'text', 'value' => '😊', 'marks' => ['emoji' => true]], + ['type' => 'text', 'value' => ' 世界 ', 'marks' => ['chinese' => true]], + ['type' => 'text', 'value' => '🌍', 'marks' => ['emoji' => true]], + ['type' => 'text', 'value' => ' مرحبا', 'marks' => ['arabic' => true]], + ], 'unicode text spans should preserve their mark boundaries'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks applies marks to emoji graphemes', + $rustDiffMarksSuite . 'marks-on-emoji', + 'marks_on_emoji', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'Hello 👨‍👩‍👧‍👦 world'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'Hello '], + ['type' => 'text', 'value' => '👨‍👩‍👧‍👦', 'marks' => ['emoji' => true]], + ['type' => 'text', 'value' => ' world'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'Hello '], + ['type' => 'text', 'value' => '👨‍👩‍👧‍👦', 'marks' => ['emoji' => true]], + ['type' => 'text', 'value' => ' world'], + ], 'emoji grapheme spans should preserve marks'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks applies marks to accented text', + $rustDiffMarksSuite . 'marks-on-combining-characters', + 'marks_on_combining_characters', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'café'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'café', 'marks' => ['accented' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'café', 'marks' => ['accented' => true]], + ], 'accented text should preserve marks and content'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks spans a mark across a block marker', + $rustDiffMarksSuite . 'mark-spans-across-block', + 'mark_spans_across_block', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold'); + $doc = $port->splitBlock($doc, ['text'], 4, []); + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, 'text'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => []], + ['type' => 'text', 'value' => 'text', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'text', 'value' => 'text', 'marks' => ['bold' => true]], + ], 'marks should materialize on both text spans around a block marker'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks ends a mark at a block boundary', + $rustDiffMarksSuite . 'mark-ends-at-block-boundary', + 'mark_ends_at_block_boundary', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold'); + $doc = $port->splitBlock($doc, ['text'], 4, []); + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, 'text'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => []], + ['type' => 'text', 'value' => 'text'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'text', 'value' => 'text'], + ], 'a mark ending before a block marker should not apply after the marker'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks changes block properties while preserving marks', + $rustDiffMarksSuite . 'block-properties-change-with-marks', + 'block_properties_change_with_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splitBlock($doc, ['text'], 0, []); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'marked text'); + $doc = $port->mark($doc, ['text'], 1, 7, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'paragraph', 'level' => 1]], + ['type' => 'text', 'value' => 'marked', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'paragraph', 'attrs' => [], 'level' => 1]], + ['type' => 'text', 'value' => 'marked', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text'], + ], 'block property changes should preserve adjacent text marks'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks survive block updates', + $rustDiffMarksSuite . 'marks-survive-block-updates', + 'marks_survive_block_updates', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'p']], + ['type' => 'text', 'value' => 'marked', 'marks' => ['bold' => true]], + ]); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'h1', 'level' => 1]], + ['type' => 'text', 'value' => 'marked', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'h1', 'attrs' => [], 'level' => 1]], + ['type' => 'text', 'value' => 'marked', 'marks' => ['bold' => true]], + ], 'updating block properties should leave following text marks intact'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks boundary insert expands existing mark', + $rustDiffMarksSuite . 'update-spans-which-inserts-at-the-end-of-expand-mark-doesnt-generate-mark-changes', + 'update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 6, 11, 'bold', true, 'both'); + + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello '], + ['type' => 'text', 'value' => 'wworldd', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello '], + ['type' => 'text', 'value' => 'wworldd', 'marks' => ['bold' => true]], + ], 'updateSpans should extend text at an expand-both mark boundary without an extra mark segment'); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + same(count($decoded['ops'] ?? []), 2, 'native updateSpans boundary insertion should remain a compact two-operation change'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks removes all text from a marked span', + $rustDiffMarksSuite . 'removing-all-text-from-marked-span', + 'removing_all_text_from_marked_span', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 5, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => ' world'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => ' world'], + ], 'removing marked text should clear the removed mark span'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks preserves nested marks', + $rustDiffMarksSuite . 'nested-marks', + 'nested_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'italic bold and italic just italic'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'italic ', 'marks' => ['italic' => true]], + ['type' => 'text', 'value' => 'bold and italic', 'marks' => ['italic' => true, 'bold' => true]], + ['type' => 'text', 'value' => ' just italic', 'marks' => ['italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'italic ', 'marks' => ['italic' => true]], + ['type' => 'text', 'value' => 'bold and italic', 'marks' => ['bold' => true, 'italic' => true]], + ['type' => 'text', 'value' => ' just italic', 'marks' => ['italic' => true]], + ], 'nested marks should materialize as adjacent span runs'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks unmarks part of a range', + $rustDiffMarksSuite . 'unmark-part-of-range', + 'unmark_part_of_range', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'bold text here'); + $doc = $port->mark($doc, ['text'], 0, 14, 'bold', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text '], + ['type' => 'text', 'value' => 'here', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'bold', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' text '], + ['type' => 'text', 'value' => 'here', 'marks' => ['bold' => true]], + ], 'unmarking the middle of a range should split the marked run'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks creates gaps when unmarking', + $rustDiffMarksSuite . 'unmark-creates-gaps', + 'unmark_creates_gaps', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'a b c d e'); + $doc = $port->mark($doc, ['text'], 0, 9, 'mark', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'a', 'marks' => ['mark' => true]], + ['type' => 'text', 'value' => ' b '], + ['type' => 'text', 'value' => 'c', 'marks' => ['mark' => true]], + ['type' => 'text', 'value' => ' d '], + ['type' => 'text', 'value' => 'e', 'marks' => ['mark' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'a', 'marks' => ['mark' => true]], + ['type' => 'text', 'value' => ' b '], + ['type' => 'text', 'value' => 'c', 'marks' => ['mark' => true]], + ['type' => 'text', 'value' => ' d '], + ['type' => 'text', 'value' => 'e', 'marks' => ['mark' => true]], + ], 'unmarking multiple gaps should preserve alternating marked runs'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks combines different expand behaviors', + $rustDiffMarksSuite . 'multiple-marks-different-expand-behaviors', + 'multiple_marks_different_expand_behaviors', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text', 'marks' => ['before' => true, 'after' => true, 'none' => true]], + ], [ + 'perMarkExpand' => ['before' => 'before', 'after' => 'after', 'none' => 'none'], + ]); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'a'); + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, 'b'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'a', 'marks' => ['before' => true]], + ['type' => 'text', 'value' => 'text', 'marks' => ['after' => true, 'before' => true, 'none' => true]], + ['type' => 'text', 'value' => 'b', 'marks' => ['after' => true]], + ], 'per-mark expand settings should affect only their own boundary insertions'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks respects expand none at boundaries', + $rustDiffMarksSuite . 'marks-with-expand-none-at-boundaries', + 'marks_with_expand_none_at_boundaries', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'text', 'marks' => ['mark' => true]], + ], ['defaultExpand' => 'none']); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'before '); + $doc = $port->spliceAtPath($doc, ['text'], 11, 0, ' after'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'before '], + ['type' => 'text', 'value' => 'text', 'marks' => ['mark' => true]], + ['type' => 'text', 'value' => ' after'], + ], 'expand none should keep boundary insertions outside the mark'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks supports marked block content', + $rustDiffMarksSuite . 'block-with-marked-content', + 'block_with_marked_content', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'heading', 'level' => 1]], + ['type' => 'text', 'value' => 'Chapter '], + ['type' => 'text', 'value' => 'One', 'marks' => ['emphasis' => true]], + ['type' => 'block', 'value' => ['type' => 'paragraph']], + ['type' => 'text', 'value' => 'This is the '], + ['type' => 'text', 'value' => 'first', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' chapter.'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'heading', 'attrs' => [], 'level' => 1]], + ['type' => 'text', 'value' => 'Chapter '], + ['type' => 'text', 'value' => 'One', 'marks' => ['emphasis' => true]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'paragraph', 'attrs' => []]], + ['type' => 'text', 'value' => 'This is the '], + ['type' => 'text', 'value' => 'first', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' chapter.'], + ], 'block spans should preserve marked text content'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks preserves same-name different values', + $rustDiffMarksSuite . 'marks-with-different-values-same-name', + 'marks_with_different_values_same_name', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'red blue green'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'red', 'marks' => ['color' => 'red']], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'blue', 'marks' => ['color' => 'blue']], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'green', 'marks' => ['color' => 'green']], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'red', 'marks' => ['color' => 'red']], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'blue', 'marks' => ['color' => 'blue']], + ['type' => 'text', 'value' => ' '], + ['type' => 'text', 'value' => 'green', 'marks' => ['color' => 'green']], + ], 'same-name marks with different values should remain separate'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks updates spans to only blocks', + $rustDiffMarksSuite . 'update-spans-with-only-blocks', + 'update_spans_with_only_blocks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'text'); + $doc = $port->splitBlock($doc, ['text'], 4, []); + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, 'more'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => []], + ['type' => 'block', 'value' => []], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ], 'updateSpans should be able to replace all text with block markers'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks removes one overlapping mark and keeps another', + $rustDiffMarksSuite . 'overlapping-marks-remove-one-keep-other', + 'overlapping_marks_remove_one_keep_other', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 6, 11, 'bold', true, 'both'); + $doc = $port->mark($doc, ['text'], 6, 11, 'italic', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello '], + ['type' => 'text', 'value' => 'world', 'marks' => ['bold' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello '], + ['type' => 'text', 'value' => 'world', 'marks' => ['bold' => true]], + ], 'updateSpans should remove the italic overlap while keeping bold'); + sameArray($port->marksAt($doc, ['text'], 6), ['bold' => true], 'only bold should remain active on world'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks changes overlapping mark boundaries', + $rustDiffMarksSuite . 'overlapping-marks-change-boundaries', + 'overlapping_marks_change_boundaries', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello beautiful world'); + $doc = $port->mark($doc, ['text'], 0, 15, 'bold', true, 'both'); + $doc = $port->mark($doc, ['text'], 6, 21, 'italic', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' beautiful '], + ['type' => 'text', 'value' => 'world', 'marks' => ['italic' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' beautiful '], + ['type' => 'text', 'value' => 'world', 'marks' => ['italic' => true]], + ], 'updateSpans should move overlapping mark boundaries independently'); + sameArray($port->marksAt($doc, ['text'], 0), ['bold' => true], 'hello should keep bold'); + sameArray($port->marksAt($doc, ['text'], 6), [], 'middle text should be unmarked'); + sameArray($port->marksAt($doc, ['text'], 16), ['italic' => true], 'world should keep italic'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$rustMapped( + 'rust diff marks adds a third overlapping mark', + $rustDiffMarksSuite . 'overlapping-marks-add-third-mark', + 'overlapping_marks_add_third_mark', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 11, 'bold', true, 'both'); + $doc = $port->mark($doc, ['text'], 6, 11, 'italic', true, 'both'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hel', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'lo wo', 'marks' => ['bold' => true, 'underline' => true]], + ['type' => 'text', 'value' => 'rld', 'marks' => ['bold' => true, 'italic' => true, 'underline' => true]], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hel', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'lo wo', 'marks' => ['bold' => true, 'underline' => true]], + ['type' => 'text', 'value' => 'rld', 'marks' => ['bold' => true, 'italic' => true, 'underline' => true]], + ], 'updateSpans should add a third mark over existing overlaps'); + sameArray($doc->toArray(), ['text' => 'hello world'], 'text content should be preserved while adding the third overlap'); + }, + 'rust/automerge/tests/diff_marks.rs' +); + +$mapped( + 'block updateSpans honors default mark expand none', + 'javascript/test/block_test.ts', + 200, + 'allows configuring the default expand value of created marks', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], ['defaultExpand' => 'none']); + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, '!'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => '! world'], + ], 'defaultExpand=none should keep end-boundary insertions outside the mark'); + } +); + +$mapped( + 'block updateSpans honors per mark expand override', + 'javascript/test/block_test.ts', + 225, + 'should allow overriding the default expand on a per mark basis', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], ['defaultExpand' => 'none', 'perMarkExpand' => ['bold' => 'both']]); + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, '!'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello!', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], 'perMarkExpand should override defaultExpand for matching mark names'); + } +); + +$rustMapped( + 'rust block updateSpans accepts upstream after expand config', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:update-spans-uses-expand-config', + 'update_spans_uses_expand_config', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], [ + 'defaultExpand' => 'none', + 'perMarkExpand' => ['bold' => 'after'], + ]); + + $doc = $port->spliceAtPath($doc, ['text'], 5, 0, '!'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'Oh '); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'Oh '], + ['type' => 'text', 'value' => 'hello!', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], 'upstream after expand should include end-boundary insertions and exclude start-boundary insertions'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust marked splice replacement keeps only expanding marks', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:test-splice-with-mark', + 'test_splice_with_mark', + function () use ($port): void { + $doc = $port->from(['txt' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['txt'], 0, 0, 'abc'); + $doc = $port->mark($doc, ['txt'], 1, 2, 'some_nonexpanding_mark_type', 'marked', 'none'); + $doc = $port->mark($doc, ['txt'], 1, 2, 'some_expanding_mark_type', 'marked', 'both'); + + sameArray($port->spans($doc, ['txt']), [ + ['type' => 'text', 'value' => 'a'], + ['type' => 'text', 'value' => 'b', 'marks' => [ + 'some_expanding_mark_type' => 'marked', + 'some_nonexpanding_mark_type' => 'marked', + ]], + ['type' => 'text', 'value' => 'c'], + ], 'setup should expose both marks on the replaced character'); + + $doc = $port->spliceAtPath($doc, ['txt'], 1, 1, 'd'); + + sameArray($port->spans($doc, ['txt']), [ + ['type' => 'text', 'value' => 'a'], + ['type' => 'text', 'value' => 'd', 'marks' => ['some_expanding_mark_type' => 'marked']], + ['type' => 'text', 'value' => 'c'], + ], 'replacement text should keep the expanding mark and drop the non-expanding mark'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust deleted marked text does not mark a later insertion at the same index', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:test-mark-behavior-on-delete-insert', + 'test_mark_behavior_on_delete_insert', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello'); + $doc = $port->mark($doc, ['text'], 0, 5, 'bold', true, 'both'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 5, ''); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hi'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hi'], + ], 'new text inserted after deleting a marked range should not inherit the deleted mark'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust spans consolidate deleted mark gaps', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-which-are-empty-due-to-deleted-marks', + 'spans_consolidates_marks_which_are_empty_due_to_deleted_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello middle world'); + $doc = $port->mark($doc, ['text'], 0, 9, 'bold', true, 'none'); + $doc = $port->mark($doc, ['text'], 9, 18, 'italic', true, 'none'); + $doc = $port->mark($doc, ['text'], 6, 9, 'bold', null, 'none'); + $doc = $port->mark($doc, ['text'], 9, 12, 'italic', null, 'none'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello ', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => 'middle'], + ['type' => 'text', 'value' => ' world', 'marks' => ['italic' => true]], + ], 'null-valued mark operations should delete mark intervals and consolidate unmarked gaps'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust spans consolidate a fully deleted mark interval', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-with-deleted-marks-followed-by-empty-marks', + 'spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 6, 'bold', true, 'none'); + $doc = $port->mark($doc, ['text'], 0, 6, 'bold', null, 'none'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello world'], + ], 'null-valued mark covering the whole interval should leave a single unmarked span'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$rustMapped( + 'rust spans consolidate a fully deleted trailing mark interval', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:spans-consolidates-marks-with-empty-marks-followed-by-deleted-marks', + 'spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 6, 11, 'bold', true, 'none'); + $doc = $port->mark($doc, ['text'], 6, 11, 'bold', null, 'none'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'hello world'], + ], 'null-valued trailing mark should leave one unmarked span'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$mapped( + 'block updateSpans accepts partial or omitted mark config', + 'javascript/test/block_test.ts', + 250, + 'should allow omitting any part of the update spans config', + function () use ($port): void { + $expected = [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ]; + $doc = $port->from(['text' => '']); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], ['defaultExpand' => 'none']); + sameArray($port->spans($doc, ['text']), $expected, 'updateSpans should accept defaultExpand without perMarkExpand'); + + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], ['perMarkExpand' => ['bold' => 'none']]); + sameArray($port->spans($doc, ['text']), $expected, 'updateSpans should accept perMarkExpand without defaultExpand'); + + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ]); + sameArray($port->spans($doc, ['text']), $expected, 'updateSpans should accept omitted mark config'); + } +); + +$mapped( + 'block attributes preserve ImmutableString attrs when loading blocks', + 'javascript/test/block_test.ts', + 291, + 'when loading blocks', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'parents' => [], + 'type' => new ImmutableString('ordered-list-item'), + 'attrs' => ['data-foo' => new ImmutableString('someval')], + ]); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'first thing'); + + $block = $port->block($doc, ['text'], 0); + truthy(is_array($block), 'block should be available at the split position'); + truthy(($block['attrs']['data-foo'] ?? null) instanceof ImmutableString, 'block attrs should preserve ImmutableString values'); + same((string) $block['attrs']['data-foo'], 'someval', 'block attr ImmutableString should preserve its text'); + } +); + +$mapped( + 'block attributes preserve ImmutableString metadata in spans', + 'javascript/test/block_test.ts', + 308, + 'when loading spans', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'parents' => [new ImmutableString('div')], + 'type' => new ImmutableString('ordered-list-item'), + 'attrs' => ['data-foo' => new ImmutableString('someval')], + ]); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'first thing'); + + $spans = $port->spans($doc, ['text']); + same($spans[0]['type'] ?? null, 'block', 'first span should be the inserted block'); + $block = $spans[0]['value'] ?? null; + truthy(is_array($block), 'block span should carry block metadata'); + truthy(($block['parents'][0] ?? null) instanceof ImmutableString, 'block span parents should preserve ImmutableString values'); + same((string) $block['parents'][0], 'div', 'block span parent ImmutableString should preserve its text'); + truthy(($block['attrs']['data-foo'] ?? null) instanceof ImmutableString, 'block span attrs should preserve ImmutableString values'); + same((string) $block['attrs']['data-foo'], 'someval', 'block span attr ImmutableString should preserve its text'); + truthy(($block['type'] ?? null) instanceof ImmutableString, 'block span type should preserve ImmutableString values'); + same((string) $block['type'], 'ordered-list-item', 'block span type ImmutableString should preserve its text'); + } +); + +$mapped( + 'block updateSpans metadata-only changes remain editable', + 'javascript/test/block_test.ts', + 331, + 'updates the document even if the only change was to a block attribute', + function () use ($port): void { + $doc = $port->from(['text' => '']); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'parents' => [], + 'type' => 'paragraph', + 'attrs' => [], + ]); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'item'); + + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => [ + 'type' => 'paragraph', + 'parents' => ['ordered-list-item'], + 'attrs' => [], + ]], + ['type' => 'text', 'value' => 'item'], + ]); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => [ + 'parents' => ['ordered-list-item'], + 'type' => 'paragraph', + 'attrs' => [], + ]], + ['type' => 'text', 'value' => 'item'], + ], 'metadata-only updateSpans should update block metadata'); + + $doc = $port->spliceAtPath($doc, ['text'], 0, 1, 'A'); + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'Aitem'], + ], 'document should remain editable after a metadata-only updateSpans change'); + } +); + +$mapped( + 'block view shows historical marks', + 'javascript/test/block_test.ts', + 371, + 'should show historical marks', + function () use ($port): void { + $doc = $port->from(['text' => 'hello world']); + $doc = $port->mark($doc, ['text'], 0, 5, 'bold', true); + $headsBefore = $port->getHeads($doc); + $doc = $port->mark($doc, ['text'], 5, 11, 'italic', true); + + sameArray($port->spans($port->view($doc, $headsBefore), ['text']), [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], 'view at earlier heads should include earlier marks and exclude later marks'); + } +); + +$rustMapped( + 'rust spans at heads respect historical marks', + 'rust:tests-block-tests-rs-target-debug-deps-block-tests-405aaf2cd395742f:marks-on-spans-respect-heads', + 'marks_on_spans_respect_heads', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 5, 'bold', true, 'after'); + $headsBefore = $port->getHeads($doc); + $doc = $port->mark($doc, ['text'], 5, 11, 'italic', true, 'after'); + + sameArray($port->spans($port->view($doc, $headsBefore), ['text']), [ + ['type' => 'text', 'value' => 'hello', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], 'spans at saved heads should exclude marks added by later changes'); + }, + 'rust/automerge/tests/block_tests.rs' +); + +$mapped( + 'block attributes preserve very small floating point values', + 'javascript/test/block_test.ts', + 388, + 'can allow small values in block attributes', + function () use ($port): void { + $smallnum = 1.401298464324817e-45; + $doc = $port->from(['text' => '']); + $doc = $port->splitBlock($doc, ['text'], 0, ['smallnum' => $smallnum]); + $block = $port->block($doc, ['text'], 0); + + truthy(is_array($block), 'block should be available at the split position'); + same($block['smallnum'] ?? null, $smallnum, 'small block metadata floats should not be coerced to zero'); + } +); + +$mapped( + 'legacy root handles single-property assignment', + 'javascript/test/legacy_tests.ts', + 429, + 'should handle single-property assignment', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'foo', 'bar'); + $doc = $port->set($doc, 'zip', 'zap'); + + same($doc->toArray()['foo'], 'bar', 'first root property should be readable'); + same($doc->toArray()['zip'], 'zap', 'second root property should be readable'); + sameArray($doc->toArray(), ['foo' => 'bar', 'zip' => 'zap'], 'root map should contain both assigned properties'); + } +); + +$mapped( + 'legacy root allows floating point values', + 'javascript/test/legacy_tests.ts', + 437, + 'should allow floating-point values', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'number', 1589032171.1); + + same($doc->toArray()['number'], 1589032171.1, 'floating point root value should materialize exactly'); + } +); + +$mapped( + 'legacy root handles multi-property assignment', + 'javascript/test/legacy_tests.ts', + 442, + 'should handle multi-property assignment', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar', 'answer' => 42], 'aabbcc'); + + same($doc->toArray()['foo'], 'bar', 'first multi-assigned property should be readable'); + same($doc->toArray()['answer'], 42, 'second multi-assigned property should be readable'); + sameArray($doc->toArray(), ['foo' => 'bar', 'answer' => 42], 'root map should contain all multi-assigned properties'); + } +); + +$mapped( + 'legacy root handles property deletion', + 'javascript/test/legacy_tests.ts', + 451, + 'should handle root property deletion', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'foo', 'bar'); + $doc = $port->set($doc, 'something', null); + $doc = $port->delete($doc, 'foo'); + + truthy(! array_key_exists('foo', $doc->toArray()), 'deleted root property should be absent'); + sameArray($doc->toArray(), ['something' => null], 'deleting one root property should preserve unrelated null value'); + } +); + +$mapped( + 'legacy root follows JavaScript delete behavior', + 'javascript/test/legacy_tests.ts', + 464, + 'should follow JS delete behavior', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'foo', 'bar'); + [$doc, $deleted] = $port->deleteWithResult($doc, 'foo'); + same($deleted, true, 'deleting an existing root property should report true'); + [$doc, $deletedMissing] = $port->deleteWithResult($doc, 'baz'); + same($deletedMissing, true, 'deleting a missing root property should report true'); + sameArray($doc->toArray(), [], 'deleting a missing root property should leave state unchanged'); + } +); + +$mapped( + 'legacy root allows property type changes', + 'javascript/test/legacy_tests.ts', + 482, + 'should allow the type of a property to be changed', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'prop', 123); + same($doc->toArray()['prop'], 123, 'numeric root property should materialize'); + $doc = $port->set($doc, 'prop', '123'); + same($doc->toArray()['prop'], '123', 'string replacement should materialize'); + $doc = $port->set($doc, 'prop', null); + same($doc->toArray()['prop'], null, 'null replacement should materialize'); + $doc = $port->set($doc, 'prop', true); + same($doc->toArray()['prop'], true, 'boolean replacement should materialize'); + } +); + +$mapped( + 'legacy root allows empty string keys', + 'javascript/test/legacy_tests.ts', + 493, + 'should not error on empty string keys', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), '', 'x'); + + same($doc->toArray()[''], 'x', 'empty string root key should be readable'); + sameArray($doc->toArray(), ['' => 'x'], 'empty string root key should materialize'); + } +); + +$mapped( + 'legacy root rejects unsupported assignment datatypes', + 'javascript/test/legacy_tests.ts', + 502, + 'should not allow assignment of unsupported datatypes', + function () use ($port): void { + $doc = $port->init('aabbcc'); + + throwsLike( + static fn () => $port->set($doc, 'foo', static fn (): int => 1), + 'Cannot assign function value at /foo', + 'function assignment should be rejected at the root path' + ); + throwsLike( + static fn () => $port->set($doc, 'foo', ['prop' => static fn (): int => 1]), + 'Cannot assign function value at /foo/prop', + 'nested unsupported assignment should include the nested path' + ); + + $resource = fopen('php://memory', 'rb'); + truthy($resource !== false, 'memory resource should open for unsupported datatype coverage'); + try { + throwsLike( + static fn () => $port->set($doc, 'foo', $resource), + 'Cannot assign resource value at /foo', + 'resource assignment should be rejected at the root path' + ); + } finally { + fclose($resource); + } + + same($port->getAllChanges($doc), [], 'rejected assignments should not append changes'); + } +); + +$mapped( + 'legacy nested maps expose Automerge-shaped object ids', + 'javascript/test/legacy_tests.ts', + 521, + 'should assign an objectId to nested maps', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'nested', []); + $nested = $doc->toArray()['nested']; + $objectId = $port->getObjectId($nested); + + truthy(is_string($objectId), 'nested map should expose an object id'); + truthy((bool) preg_match('/^[0-9]+@([0-9a-f][0-9a-f])*$/', $objectId), 'nested map object id should match upstream op-id shape'); + truthy($objectId !== '_root', 'nested map object id should not be the root id'); + } +); + +$mapped( + 'legacy nested maps handle assignment of a nested property', + 'javascript/test/legacy_tests.ts', + 533, + 'should handle assignment of a nested property', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'nested', []); + $doc = $port->setNested($doc, ['nested', 'foo'], 'bar'); + $doc = $port->setNested($doc, ['nested', 'one'], 1); + + same($doc->toArray()['nested']['foo'], 'bar', 'nested string property should materialize'); + same($doc->toArray()['nested']['one'], 1, 'nested numeric property should materialize'); + sameArray($doc->toArray(), ['nested' => ['foo' => 'bar', 'one' => 1]], 'nested map should include both assigned properties'); + + $replayed = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc)); + sameArray($replayed->toArray(), $doc->toArray(), 'nested map assignment should replay from changes'); + } +); + +$mapped( + 'legacy nested maps handle assignment of an object literal', + 'javascript/test/legacy_tests.ts', + 547, + 'should handle assignment of an object literal', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'textStyle', ['bold' => false, 'fontSize' => 12]); + + same($doc->toArray()['textStyle']['bold'], false, 'nested object literal boolean should materialize'); + same($doc->toArray()['textStyle']['fontSize'], 12, 'nested object literal number should materialize'); + sameArray($doc->toArray(), ['textStyle' => ['bold' => false, 'fontSize' => 12]], 'object literal should materialize as a nested map'); + } +); + +$mapped( + 'legacy nested maps handle multiple nested property assignment', + 'javascript/test/legacy_tests.ts', + 559, + 'should handle assignment of multiple nested properties', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'textStyle', ['bold' => false, 'fontSize' => 12]); + $doc = $port->setNested($doc, ['textStyle', 'typeface'], 'Optima'); + $doc = $port->setNested($doc, ['textStyle', 'fontSize'], 14); + $style = $doc->toArray()['textStyle']; + + same($style['typeface'], 'Optima', 'new nested property should materialize'); + same($style['bold'], false, 'existing nested boolean should be preserved'); + same($style['fontSize'], 14, 'existing nested numeric property should update'); + } +); + +$mapped( + 'legacy nested maps handle arbitrary-depth nesting', + 'javascript/test/legacy_tests.ts', + 574, + 'should handle arbitrary-depth nesting', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'a', ['b' => ['c' => ['d' => ['e' => ['f' => ['g' => 'h']]]]]]); + $doc = $port->setNested($doc, ['a', 'b', 'c', 'd', 'e', 'f', 'i'], 'j'); + $root = $doc->toArray(); + + same($root['a']['b']['c']['d']['e']['f']['g'], 'h', 'existing deep nested property should remain'); + same($root['a']['b']['c']['d']['e']['f']['i'], 'j', 'new deep nested property should materialize'); + sameArray($root, ['a' => ['b' => ['c' => ['d' => ['e' => ['f' => ['g' => 'h', 'i' => 'j']]]]]]], 'deep nested map should materialize'); + } +); + +$mapped( + 'legacy nested maps allow object replacement', + 'javascript/test/legacy_tests.ts', + 588, + 'should allow an old object to be replaced with a new one', + function () use ($port): void { + $doc1 = $port->set( + $port->init('aabbcc'), + 'myPet', + ['species' => 'dog', 'legs' => 4, 'breed' => 'dachshund'] + ); + $doc2 = $port->set( + $doc1, + 'myPet', + [ + 'species' => 'koi', + 'variety' => "\u{7d05}\u{767d}", + 'colors' => ['red' => true, 'white' => true, 'black' => false], + ] + ); + + same($doc1->toArray()['myPet']['breed'], 'dachshund', 'old nested object should remain immutable'); + truthy(! array_key_exists('breed', $doc2->toArray()['myPet']), 'replacement object should remove old fields'); + same($doc2->toArray()['myPet']['variety'], "\u{7d05}\u{767d}", 'replacement object should keep unicode scalar values'); + sameArray( + $doc2->toArray()['myPet']['colors'], + ['red' => true, 'white' => true, 'black' => false], + 'replacement object should preserve nested map fields' + ); + } +); + +$mapped( + 'legacy nested maps allow primitive map type changes', + 'javascript/test/legacy_tests.ts', + 615, + 'should allow fields to be changed between primitive and nested map', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'color', '#ff7f00'); + same($doc->toArray()['color'], '#ff7f00', 'primitive color should materialize'); + + $doc = $port->set($doc, 'color', ['red' => 255, 'green' => 127, 'blue' => 0]); + sameArray($doc->toArray()['color'], ['red' => 255, 'green' => 127, 'blue' => 0], 'nested map replacement should materialize'); + + $doc = $port->set($doc, 'color', '#ff7f00'); + same($doc->toArray()['color'], '#ff7f00', 'primitive replacement should materialize again'); + } +); + +$mapped( + 'legacy nested maps reject references to existing document objects', + 'javascript/test/legacy_tests.ts', + 627, + 'should not allow several references to the same map object', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'object', ['species' => 'dog']); + $existing = Document::fromArray(['species' => 'koi'], 'ddeeff'); + + throwsLike( + static fn (): Document => $port->set($doc, 'x', $doc), + 'Cannot create a reference to an existing document object at /x', + 'assigning the current document object should be rejected' + ); + throwsLike( + static fn (): Document => $port->set($doc, 'x', $existing), + 'Cannot create a reference to an existing document object at /x', + 'assigning an existing document object should be rejected' + ); + throwsLike( + static fn (): Document => $port->set($doc, 'x', ['nested' => $existing]), + 'Cannot create a reference to an existing document object at /x/nested', + 'assigning an existing document object through a nested map should be rejected' + ); + sameArray($doc->toArray(), ['object' => ['species' => 'dog']], 'rejected object references should not alter the source document'); + } +); + +$mapped( + 'legacy nested maps reject object-copying idioms', + 'javascript/test/legacy_tests.ts', + 647, + 'should not allow object-copying idioms', + function () use ($port): void { + $doc = $port->set( + $port->init('aabbcc'), + 'items', + [ + ['id' => 'id1', 'name' => 'one'], + ['id' => 'id2', 'name' => 'two'], + ] + ); + + throwsLike( + static fn (): Document => $port->set( + $doc, + 'items', + [ + $port->objectReference($doc, ['items', 0]), + $port->objectReference($doc, ['items', 1]), + ['id' => 'id3', 'name' => 'three'], + ] + ), + 'Cannot create a reference to an existing document object at /items/0', + 'copying existing list element objects into a replacement list should be rejected' + ); + sameArray( + $doc->toArray(), + [ + 'items' => [ + ['id' => 'id1', 'name' => 'one'], + ['id' => 'id2', 'name' => 'two'], + ], + ], + 'rejected object-copying idiom should not alter the source list' + ); + } +); + +$mapped( + 'legacy nested maps handle deletion of properties within a map', + 'javascript/test/legacy_tests.ts', + 664, + 'should handle deletion of properties within a map', + function () use ($port): void { + $doc = $port->set( + $port->init('aabbcc'), + 'textStyle', + ['typeface' => 'Optima', 'bold' => false, 'fontSize' => 12] + ); + $doc = $port->deleteNested($doc, ['textStyle', 'bold']); + $style = $doc->toArray()['textStyle']; + + truthy(! array_key_exists('bold', $style), 'deleted nested map property should be absent'); + sameArray($style, ['typeface' => 'Optima', 'fontSize' => 12], 'nested map deletion should preserve sibling properties'); + + $replayed = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc)); + sameArray($replayed->toArray(), $doc->toArray(), 'nested map deletion should replay from changes'); + } +); + +$mapped( + 'legacy nested maps handle deletion of references to a map', + 'javascript/test/legacy_tests.ts', + 676, + 'should handle deletion of references to a map', + function () use ($port): void { + $doc = $port->set( + $port->init('aabbcc'), + 'title', + 'Hello' + ); + $doc = $port->set($doc, 'textStyle', ['typeface' => 'Optima', 'fontSize' => 12]); + $doc = $port->delete($doc, 'textStyle'); + + truthy(! array_key_exists('textStyle', $doc->toArray()), 'deleted root map reference should be absent'); + sameArray($doc->toArray(), ['title' => 'Hello'], 'root map deletion should preserve unrelated fields'); + } +); + +$mapped( + 'legacy lists allow elements to be inserted', + 'javascript/test/legacy_tests.ts', + 690, + 'should allow elements to be inserted', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', []); + $doc = $port->insertListElements($doc, 'noodles', 0, ['udon', 'soba']); + $doc = $port->insertListElements($doc, 'noodles', 1, ['ramen']); + + sameArray($doc->toArray(), ['noodles' => ['udon', 'ramen', 'soba']], 'list insertions should preserve order'); + same($doc->toArray()['noodles'][0], 'udon', 'first inserted list element should be readable'); + same($doc->toArray()['noodles'][1], 'ramen', 'middle inserted list element should be readable'); + same($doc->toArray()['noodles'][2], 'soba', 'last inserted list element should be readable'); + } +); + +$mapped( + 'legacy lists handle assignment of a list literal', + 'javascript/test/legacy_tests.ts', + 704, + 'should handle assignment of a list literal', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', ['udon', 'ramen', 'soba']); + + sameArray($doc->toArray(), ['noodles' => ['udon', 'ramen', 'soba']], 'list literal should materialize'); + truthy(! array_key_exists(3, $doc->toArray()['noodles']), 'list literal should not expose a fourth element'); + } +); + +$mapped( + 'legacy lists handle deletion of list elements', + 'javascript/test/legacy_tests.ts', + 738, + 'should handle deletion of list elements', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', ['udon', 'ramen', 'soba']); + $doc = $port->deleteListElements($doc, 'noodles', 1); + sameArray($doc->toArray()['noodles'], ['udon', 'soba'], 'first list deletion should compact the list'); + + $doc = $port->deleteListElements($doc, 'noodles', 1); + sameArray($doc->toArray()['noodles'], ['udon'], 'second list deletion should leave only the first element'); + truthy(! array_key_exists(1, $doc->toArray()['noodles']), 'deleted list index should be absent after compaction'); + } +); + +$mapped( + 'legacy lists accept only numeric indexes', + 'javascript/test/legacy_tests.ts', + 718, + 'should only allow numeric indexes', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', ['udon', 'ramen', 'soba']); + $doc = $port->setListKey($doc, 'noodles', 1, 'Ramen!'); + same($doc->toArray()['noodles'][1], 'Ramen!', 'integer list index should update the requested element'); + + $doc = $port->setListKey($doc, 'noodles', '1', 'RAMEN!!!'); + same($doc->toArray()['noodles'][1], 'RAMEN!!!', 'numeric string list index should update the requested element'); + + foreach (['favourite', '', '1e6'] as $invalidIndex) { + throwsLike( + static fn (): Document => $port->setListKey($doc, 'noodles', $invalidIndex, 'udon'), + 'list index must be a number', + 'non-numeric list index should be rejected' + ); + } + } +); + +$mapped( + 'legacy lists handle assignment of individual list indexes', + 'javascript/test/legacy_tests.ts', + 753, + 'should handle assignment of individual list indexes', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'japaneseFood', ['udon', 'ramen', 'soba']); + $doc = $port->setListElement($doc, 'japaneseFood', 1, 'sushi'); + + sameArray($doc->toArray()['japaneseFood'], ['udon', 'sushi', 'soba'], 'list index assignment should replace the requested element'); + } +); + +$mapped( + 'legacy concurrent list insertions with equal counters use reverse actor order', + 'javascript/test/legacy_tests.ts', + 767, + 'concurrent edits insert in reverse actorid order if counters equal', + function () use ($port): void { + $doc1 = $port->init('aaaa'); + $doc2 = $port->init('bbbb'); + $doc1 = $port->set($doc1, 'list', []); + $doc2 = $port->mergeDocuments($doc2, $doc1); + + $doc1 = $port->spliceList($doc1, 'list', 0, 0, ['2@aaaa']); + $doc2 = $port->spliceList($doc2, 'list', 0, 0, ['2@bbbb']); + $merged = $port->mergeDocuments($doc2, $doc1); + + sameArray($merged->toArray()['list'], ['2@bbbb', '2@aaaa'], 'same-position concurrent list inserts should use reverse actor id order when counters match'); + same($port->getConflicts($merged, 'list'), null, 'concurrent list inserts should not become a root list conflict'); + } +); + +$mapped( + 'legacy concurrent list insertions with different counters use reverse counter order', + 'javascript/test/legacy_tests.ts', + 778, + 'concurrent edits insert in reverse counter order if different', + function () use ($port): void { + $doc1 = $port->init('aaaa'); + $doc2 = $port->init('bbbb'); + $doc1 = $port->set($doc1, 'list', []); + $doc2 = $port->mergeDocuments($doc2, $doc1); + + $doc1 = $port->spliceList($doc1, 'list', 0, 0, ['2@aaaa']); + $doc2 = $port->set($doc2, 'foo', '2@bbbb'); + $doc2 = $port->spliceList($doc2, 'list', 0, 0, ['3@bbbb']); + $merged = $port->mergeDocuments($doc2, $doc1); + + sameArray($merged->toArray()['list'], ['3@bbbb', '2@aaaa'], 'same-position concurrent list inserts should use reverse operation counter order'); + same($port->getConflicts($merged, 'list'), null, 'different-counter concurrent list inserts should not become a root list conflict'); + } +); + +$mapped( + 'legacy lists treat out-by-one assignment as insertion', + 'javascript/test/legacy_tests.ts', + 790, + 'should treat out-by-one assignment as insertion', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'japaneseFood', ['udon']); + $doc = $port->setListElement($doc, 'japaneseFood', 1, 'sushi'); + + sameArray($doc->toArray()['japaneseFood'], ['udon', 'sushi'], 'one-past-end list assignment should append'); + } +); + +$mapped( + 'legacy lists reject out-of-range assignment', + 'javascript/test/legacy_tests.ts', + 800, + 'should not allow out-of-range assignment', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'japaneseFood', ['udon']); + + throwsLike( + static fn (): Document => $port->setListElement($doc, 'japaneseFood', 4, 'ramen'), + 'out of bounds', + 'assignment beyond one-past-end should be rejected' + ); + } +); + +$mapped( + 'legacy lists allow bulk assignment of multiple list indexes', + 'javascript/test/legacy_tests.ts', + 807, + 'should allow bulk assignment of multiple list indexes', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', ['udon', 'ramen', 'soba']); + $doc = $port->setListElement($doc, 'noodles', 0, "\u{3046}\u{3069}\u{3093}"); + $doc = $port->setListElement($doc, 'noodles', 2, "\u{305d}\u{3070}"); + + sameArray($doc->toArray()['noodles'], ["\u{3046}\u{3069}\u{3093}", 'ramen', "\u{305d}\u{3070}"], 'bulk-style list assignment should update multiple indexes'); + } +); + +$mapped( + 'legacy lists handle nested objects', + 'javascript/test/legacy_tests.ts', + 822, + 'should handle nested objects', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', [['type' => 'ramen', 'dishes' => ['tonkotsu', 'shoyu']]]); + $doc = $port->insertListElements($doc, 'noodles', 1, [['type' => 'udon', 'dishes' => ['tempura udon']]]); + $doc = $port->applyPatches($doc, [['action' => 'insert', 'path' => ['noodles', 0, 'dishes', 2], 'values' => ['miso']]]); + + sameArray( + $doc->toArray()['noodles'], + [ + ['type' => 'ramen', 'dishes' => ['tonkotsu', 'shoyu', 'miso']], + ['type' => 'udon', 'dishes' => ['tempura udon']], + ], + 'nested object list updates should materialize' + ); + } +); + +$mapped( + 'legacy lists handle nested lists', + 'javascript/test/legacy_tests.ts', + 848, + 'should handle nested lists', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodleMatrix', [['ramen', 'tonkotsu', 'shoyu']]); + $doc = $port->insertListElements($doc, 'noodleMatrix', 1, [['udon', 'tempura udon']]); + $doc = $port->applyPatches($doc, [['action' => 'insert', 'path' => ['noodleMatrix', 0, 3], 'values' => ['miso']]]); + + sameArray( + $doc->toArray()['noodleMatrix'], + [ + ['ramen', 'tonkotsu', 'shoyu', 'miso'], + ['udon', 'tempura udon'], + ], + 'nested list updates should materialize' + ); + } +); + +$mapped( + 'legacy lists handle replacement of the entire list', + 'javascript/test/legacy_tests.ts', + 911, + 'should handle replacement of the entire list', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', ['udon', 'soba', 'ramen']); + $doc = $port->set($doc, 'japaneseNoodles', $doc->toArray()['noodles']); + $doc = $port->set($doc, 'noodles', ['wonton', 'pho']); + + sameArray( + $doc->toArray(), + ['noodles' => ['wonton', 'pho'], 'japaneseNoodles' => ['udon', 'soba', 'ramen']], + 'whole-list replacement should not mutate the copied list' + ); + truthy(! array_key_exists(2, $doc->toArray()['noodles']), 'replacement list should expose only the replacement elements'); + } +); + +$mapped( + 'legacy lists allow assignment to change the type of a list element', + 'javascript/test/legacy_tests.ts', + 932, + 'should allow assignment to change the type of a list element', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', ['udon', 'soba', 'ramen']); + $doc = $port->setListElement($doc, 'noodles', 1, ['type' => 'soba', 'options' => ['hot', 'cold']]); + sameArray($doc->toArray()['noodles'], ['udon', ['type' => 'soba', 'options' => ['hot', 'cold']], 'ramen'], 'list element should change from scalar to map'); + + $doc = $port->setListElement($doc, 'noodles', 1, ['hot soba', 'cold soba']); + sameArray($doc->toArray()['noodles'], ['udon', ['hot soba', 'cold soba'], 'ramen'], 'list element should change from map to list'); + + $doc = $port->setListElement($doc, 'noodles', 1, 'soba is the best'); + sameArray($doc->toArray()['noodles'], ['udon', 'soba is the best', 'ramen'], 'list element should change from list to scalar'); + } +); + +$mapped( + 'legacy lists allow list creation and assignment in one logical callback', + 'javascript/test/legacy_tests.ts', + 964, + 'should allow list creation and assignment in the same change callback', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'letters', ['a', 'b', 'c']); + $doc = $port->setListElement($doc, 'letters', 1, 'd'); + + same($doc->toArray()['letters'][1], 'd', 'list assignment after creation should update the target index'); + } +); + +$mapped( + 'legacy lists allow adding and removing list elements in one logical callback', + 'javascript/test/legacy_tests.ts', + 972, + 'should allow adding and removing list elements in the same change callback', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'noodles', []); + $doc = $port->insertListElements($doc, 'noodles', 0, ['udon']); + $doc = $port->deleteListElements($doc, 'noodles', 0); + sameArray($doc->toArray(), ['noodles' => []], 'first add/remove list cycle should leave an empty list'); + + $doc = $port->insertListElements($doc, 'noodles', 0, ['soba']); + $doc = $port->deleteListElements($doc, 'noodles', 0); + sameArray($doc->toArray(), ['noodles' => []], 'second add/remove list cycle should leave an empty list'); + } +); + +$mapped( + 'legacy lists handle arbitrary-depth nesting', + 'javascript/test/legacy_tests.ts', + 994, + 'should handle arbitrary-depth nesting', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'maze', [[[[[[[['noodles', ['here']]]]]]]]]); + $doc = $port->applyPatches($doc, [['action' => 'insert', 'path' => ['maze', 0, 0, 0, 0, 0, 0, 0, 1, 0], 'values' => ['found']]]); + + sameArray( + $doc->toArray()['maze'], + [[[[[[[['noodles', ['found', 'here']]]]]]]]], + 'deep nested list insertion should materialize at the targeted depth' + ); + sameArray($port->load($port->save($doc))->toArray(), $doc->toArray(), 'deep nested list should round trip through native save/load'); + } +); + +$mapped( + 'legacy lists reject references to existing document objects', + 'javascript/test/legacy_tests.ts', + 1010, + 'should not allow several references to the same list object', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'list', []); + + throwsLike( + static fn (): Document => $port->set($doc, 'x', $port->objectReference($doc, ['list'])), + 'Cannot create a reference to an existing document object at /x', + 'assigning an existing list object to another root key should be rejected' + ); + throwsLike( + static fn (): Document => $port->set($doc, 'x', ['copy' => $port->objectReference($doc, ['list'])]), + 'Cannot create a reference to an existing document object at /x/copy', + 'assigning an existing list object through a nested object should be rejected' + ); + sameArray($doc->toArray(), ['list' => []], 'rejected list references should not alter the source document'); + } +); + +$mapped( + 'legacy nested maps and lists handle deep mixed mutations', + 'javascript/test/legacy_tests.ts', + 870, + 'should handle deep nesting', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'nesting', [ + 'maps' => ['m1' => ['m2' => ['foo' => 'bar', 'baz' => []], 'm2a' => []]], + 'lists' => [[1, 2, 3], [[3, 4, 5, [6]], 7]], + 'mapsinlists' => [['foo' => 'bar'], [['bar' => 'baz']]], + 'listsinmaps' => ['foo' => [1, 2, 3], 'bar' => [[['baz' => '123']]]], + ]); + + $doc = $port->setNested($doc, ['nesting', 'maps', 'm1a'], '123'); + $doc = $port->setNested($doc, ['nesting', 'maps', 'm1', 'm2', 'baz', 'xxx'], '123'); + $doc = $port->deleteNested($doc, ['nesting', 'maps', 'm1', 'm2a']); + $doc = $port->applyPatches($doc, [['action' => 'del', 'path' => ['nesting', 'lists', 0]]]); + $doc = $port->applyPatches($doc, [['action' => 'del', 'path' => ['nesting', 'lists', 0, 0, 3]]]); + $doc = $port->applyPatches($doc, [['action' => 'insert', 'path' => ['nesting', 'lists', 0, 0, 3], 'values' => [100]]]); + $doc = $port->setNested($doc, ['nesting', 'mapsinlists', 0, 'foo'], 'baz'); + $doc = $port->setNested($doc, ['nesting', 'mapsinlists', 1, 0, 'foo'], 'bar'); + $doc = $port->applyPatches($doc, [['action' => 'del', 'path' => ['nesting', 'mapsinlists', 1]]]); + $doc = $port->applyPatches($doc, [['action' => 'insert', 'path' => ['nesting', 'listsinmaps', 'foo', 3], 'values' => [4]]]); + $doc = $port->setNested($doc, ['nesting', 'listsinmaps', 'bar', 0, 0, 'baz'], '456'); + $doc = $port->deleteNested($doc, ['nesting', 'listsinmaps', 'bar']); + + sameArray( + $doc->toArray(), + [ + 'nesting' => [ + 'maps' => [ + 'm1' => ['m2' => ['foo' => 'bar', 'baz' => ['xxx' => '123']]], + 'm1a' => '123', + ], + 'lists' => [[[3, 4, 5, 100], 7]], + 'mapsinlists' => [['foo' => 'baz']], + 'listsinmaps' => ['foo' => [1, 2, 3, 4]], + ], + ], + 'deep mixed map/list mutations should materialize like the upstream proxy update sequence' + ); + } +); + +$mapped( + 'legacy save/load allows a reloaded list to be mutated', + 'javascript/test/legacy_tests.ts', + 1547, + 'should allow a reloaded list to be mutated', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'foo', []); + $doc = $port->load($port->save($doc)); + $doc = $port->pushList($doc, 'foo', [1]); + $doc = $port->load($port->save($doc)); + + sameArray($doc->toArray()['foo'], [1], 'reloaded lists should remain mutable and survive another save/load round trip'); + } +); + +$mapped( + 'legacy save/load reloads a large inserted list', + 'javascript/test/legacy_tests.ts', + 1555, + 'should reload a document containing deflated columns', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'list', []); + for ($i = 0; $i < 200; ++$i) { + $index = $i === 0 ? 0 : ($i * 37) % ($i + 1); + $doc = $port->insertListElements($doc, 'list', $index, ['a']); + } + + $loaded = $port->load($port->save($doc)); + + same(count($loaded->toArray()['list']), 200, 'large reloaded list should preserve its length'); + sameArray($loaded->toArray()['list'], array_fill(0, 200, 'a'), 'large reloaded list should preserve every inserted value'); + } +); + +$mapped( + 'legacy counters can be incremented and deleted from nested maps', + 'javascript/test/legacy_tests.ts', + 1033, + 'should allow deleting counters from maps', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'birds', ['wrens' => new Counter(1)]); + $doc2 = $port->incrementCounter($doc1, ['birds', 'wrens'], 2); + $doc3 = $port->deleteNested($doc2, ['birds', 'wrens']); + $wrens = $doc2->toArray()['birds']['wrens']; + + truthy($wrens instanceof Counter, 'nested counter should materialize as a native Counter'); + same($wrens->value(), 3, 'nested counter increment should add to the original value'); + sameArray($doc3->toArray(), ['birds' => []], 'deleting a nested counter should leave the containing map'); + } +); + +$mapped( + 'legacy concurrent merge preserves updates of different root properties', + 'javascript/test/legacy_tests.ts', + 1070, + 'should merge concurrent updates of different properties', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'foo', 'bar'); + $doc2 = $port->set($port->init('ddeeff'), 'hello', 'world'); + $doc3 = $port->mergeDocuments($doc1, $doc2); + + sameArray($doc3->toArray(), ['foo' => 'bar', 'hello' => 'world'], 'different-property concurrent merge should preserve both values'); + same($port->getConflicts($doc3, 'foo'), null, 'different-property merge should report no foo conflict'); + same($port->getConflicts($doc3, 'hello'), null, 'different-property merge should report no hello conflict'); + sameArray($port->load($port->save($doc3))->toArray(), $doc3->toArray(), 'different-property merge should round trip through save/load'); + } +); + +$mapped( + 'legacy counters add concurrent increments of the same property', + 'javascript/test/legacy_tests.ts', + 1083, + 'should add concurrent increments of the same property', + function () use ($port): void { + $doc1 = $port->set($port->init('aabbcc'), 'counter', new Counter()); + $doc2 = $port->mergeDocuments($port->init('ddeeff'), $doc1); + $doc1 = $port->incrementCounter($doc1, ['counter']); + $doc2 = $port->incrementCounter($doc2, ['counter'], 2); + $doc3 = $port->mergeDocuments($doc1, $doc2); + + $counter1 = $doc1->toArray()['counter']; + $counter2 = $doc2->toArray()['counter']; + $counter3 = $doc3->toArray()['counter']; + truthy($counter1 instanceof Counter && $counter2 instanceof Counter && $counter3 instanceof Counter, 'counter values should materialize as native counters'); + same($counter1->value(), 1, 'first branch counter should include its local increment'); + same($counter2->value(), 2, 'second branch counter should include its local increment'); + same($counter3->value(), 3, 'merged counter should add concurrent increments with the same counter identity'); + same($port->getConflicts($doc3, 'counter'), null, 'same-counter increments should not create a conflict'); + same($port->load($port->save($doc3))->toArray()['counter']->value(), 3, 'merged counter should round trip through save/load'); + } +); + +$mapped( + 'legacy counters add increments only to the values they precede', + 'javascript/test/legacy_tests.ts', + 1097, + 'should add increments only to the values they precede', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'counter', new Counter(0)); + $doc1 = $port->incrementCounter($doc1, ['counter']); + $doc2 = $port->set($port->init('bbbbbb'), 'counter', new Counter(100)); + $doc2 = $port->incrementCounter($doc2, ['counter'], 3); + $merged = $port->mergeDocuments($doc1, $doc2); + + $winner = $merged->toArray()['counter']; + truthy($winner instanceof Counter, 'different-counter conflict should materialize a counter winner'); + same($winner->value(), 103, 'higher actor counter assignment should materialize with only its own increment'); + + $conflicts = $port->getConflicts($merged, 'counter') ?? []; + truthy($conflicts['1@aaaaaa'] instanceof Counter, 'first counter assignment should remain conflicted under its assignment op'); + truthy($conflicts['1@bbbbbb'] instanceof Counter, 'second counter assignment should remain conflicted under its assignment op'); + same($conflicts['1@aaaaaa']->value(), 1, 'first counter conflict should include only the increment that follows it'); + same($conflicts['1@bbbbbb']->value(), 103, 'second counter conflict should include only the increment that follows it'); + + $loadedConflicts = $port->getConflicts($port->load($port->save($merged)), 'counter') ?? []; + same($loadedConflicts['1@aaaaaa']->value(), 1, 'first counter conflict should survive save/load'); + same($loadedConflicts['1@bbbbbb']->value(), 103, 'second counter conflict should survive save/load'); + } +); + +$mapped( + 'legacy concurrent same-field updates retain conflict values', + 'javascript/test/legacy_tests.ts', + 1119, + 'should detect concurrent updates of the same field', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'field', 'one'); + $doc2 = $port->set($port->init('aaaaaa'), 'field', 'two'); + $doc3 = $port->mergeDocuments($doc1, $doc2); + + sameArray($doc3->toArray(), ['field' => 'one'], 'same-field merge should materialize the deterministic winner'); + sameArray( + $port->getConflicts($doc3, 'field') ?? [], + [ + '1@aaaaaa' => 'two', + '1@bbbbbb' => 'one', + ], + 'same-field merge should report both conflicting assignments' + ); + sameArray( + $port->getConflicts($port->load($port->save($doc3)), 'field') ?? [], + $port->getConflicts($doc3, 'field') ?? [], + 'same-field conflicts should round trip through save/load' + ); + } +); + +$mapped( + 'legacy concurrent same-field assignments retain different-type conflicts', + 'javascript/test/legacy_tests.ts', + 1151, + 'should handle assignment conflicts of different types', + function () use ($port): void { + $doc1 = $port->set($port->init('cccccc'), 'field', 'string'); + $doc2 = $port->set($port->init('bbbbbb'), 'field', ['list']); + $doc3 = $port->set($port->init('aaaaaa'), 'field', ['thing' => 'map']); + $merged = $port->mergeDocuments($port->mergeDocuments($doc1, $doc2), $doc3); + + same($merged->toArray()['field'], 'string', 'different-type conflict should materialize the deterministic winner'); + sameArray( + $port->getConflicts($merged, 'field') ?? [], + [ + '1@aaaaaa' => ['thing' => 'map'], + '1@bbbbbb' => ['list'], + '1@cccccc' => 'string', + ], + 'different-type conflict should keep each branch value' + ); + } +); + +$mapped( + 'legacy concurrent root conflicts keep nested map changes on the assigned object id', + 'javascript/test/legacy_tests.ts', + 1164, + 'should handle changes within a conflicting map field', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'field', 'string'); + $doc2 = $port->set($port->init('aaaaaa'), 'field', []); + $doc2 = $port->setNested($doc2, ['field', 'innerKey'], 42); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['field' => 'string'], 'root conflict should materialize the deterministic winner'); + sameArray( + $port->getConflicts($merged, 'field') ?? [], + [ + '1@aaaaaa' => ['innerKey' => 42], + '1@bbbbbb' => 'string', + ], + 'nested changes inside a conflicted map should remain attached to the original root assignment op' + ); + } +); + +$mapped( + 'basic conflict values are stable regardless of merge order', + 'javascript/test/basic_test.ts', + 526, + 'should obtain the same conflicts, regardless of merge order', + function () use ($port): void { + $doc1 = $port->setMany($port->init('aaaaaa'), ['x' => 1, 'y' => 2]); + $doc2 = $port->setMany($port->init('bbbbbb'), ['x' => 3, 'y' => 4]); + $merge12 = $port->mergeDocuments($port->clone($doc1), $port->clone($doc2)); + $merge21 = $port->mergeDocuments($port->clone($doc2), $port->clone($doc1)); + + sameArray( + $port->getConflicts($merge12, 'x') ?? [], + $port->getConflicts($merge21, 'x') ?? [], + 'root conflicts should expose the same values independent of merge order' + ); + } +); + +$mapped( + 'basic merge conflict materialization matches after save and load', + 'javascript/test/basic_test.ts', + 342, + 'it should handle conflicts the same in merges as with loads', + function () use ($port): void { + $doc1 = $port->from(['sub' => ['x' => 0, 'y' => 0]], 'aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + $doc3 = $port->clone($doc1, 'cccccc'); + $doc4 = $port->clone($doc1, 'dddddd'); + + $doc1 = $port->setNested($doc1, ['sub', 'x'], 1); + $doc2 = $port->setNested($doc2, ['sub', 'x'], 2); + $doc3 = $port->setNested($doc3, ['sub', 'x'], 3); + $doc4 = $port->setNested($doc4, ['sub', 'x'], 4); + + $doc1 = $port->setNested($doc1, ['sub', 'y'], 1); + + $doc2 = $port->setNested($doc2, ['sub', 'y'], 2); + $doc2 = $port->setNested($doc2, ['sub', 'y'], 3); + + $doc3 = $port->setNested($doc3, ['sub', 'y'], 4); + $doc3 = $port->setNested($doc3, ['sub', 'y'], 5); + $doc3 = $port->setNested($doc3, ['sub', 'y'], 6); + + $doc4 = $port->setNested($doc4, ['sub', 'y'], 7); + $doc4 = $port->setNested($doc4, ['sub', 'y'], 8); + $doc4 = $port->setNested($doc4, ['sub', 'y'], 9); + $doc4 = $port->setNested($doc4, ['sub', 'y'], 10); + + $merged = $port->init('eeeeee'); + $merged = $port->mergeDocuments($merged, $doc1); + $merged = $port->mergeDocuments($merged, $doc2); + $merged = $port->mergeDocuments($merged, $doc3); + $merged = $port->mergeDocuments($merged, $doc4); + + $loaded = $port->load($port->save($merged)); + + sameArray($merged->toArray()['sub'], $loaded->toArray()['sub'], 'loaded conflict winner should match merged conflict winner'); + } +); + +$mapped( + 'conflict inspection returns detached map values', + 'javascript/test/conflicts.ts', + 5, + 'should not allow updating values inside a conflict outside of the change callback', + function () use ($port): void { + $doc1 = $port->from(['user' => ['name' => 'alice']], 'aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + + $doc1 = $port->set($doc1, 'user', ['name' => 'bob']); + $doc2 = $port->set($doc2, 'user', ['name' => 'charlie']); + $merged = $port->mergeDocuments($doc1, $doc2); + + $inspected = $port->getConflicts($merged, 'user') ?? []; + foreach ($inspected as $operationId => $value) { + if (is_array($value) && ($value['name'] ?? null) === 'bob') { + $inspected[$operationId]['name'] = 'Attila'; + } + } + + $conflicts = $port->getConflicts($merged, 'user') ?? []; + $names = array_map( + static fn (mixed $value): mixed => is_array($value) ? ($value['name'] ?? null) : null, + array_values($conflicts) + ); + sort($names); + + sameArray($names, ['bob', 'charlie'], 'mutating inspected conflict maps should not mutate document conflicts'); + } +); + +$mapped( + 'conflicted map values can be updated together', + 'javascript/test/conflicts.ts', + 56, + 'should allow updating values inside a conflicted map', + function () use ($port): void { + $doc1 = $port->from(['user' => []], 'aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + $doc3 = $port->clone($doc1, 'cccccc'); + + $doc2 = $port->set($doc2, 'user', ['name' => 'alice']); + $doc3 = $port->set($doc3, 'user', ['name' => 'charlie']); + $doc1 = $port->set($doc1, 'user', ['name' => 'bob']); + + $merged = $port->mergeDocuments($port->mergeDocuments($doc1, $doc2), $doc3); + sameArray( + $port->getConflicts($merged, 'user') ?? [], + [ + '2@aaaaaa' => ['name' => 'bob'], + '2@bbbbbb' => ['name' => 'alice'], + '2@cccccc' => ['name' => 'charlie'], + ], + 'root user conflict should expose all concurrently assigned maps before nested update' + ); + + $updated = $port->setRootConflictMapValue($merged, 'user', 'name', 'Attila'); + $expected = [ + '2@aaaaaa' => ['name' => 'Attila'], + '2@bbbbbb' => ['name' => 'Attila'], + '2@cccccc' => ['name' => 'Attila'], + ]; + + sameArray($port->getConflicts($updated, 'user') ?? [], $expected, 'nested update should rewrite each conflicted map value'); + sameArray( + $port->getConflicts($port->load($port->save($updated)), 'user') ?? [], + $expected, + 'updated conflicted map values should survive save/load' + ); + } +); + +$mapped( + 'conflicted list element map values can be updated together', + 'javascript/test/conflicts.ts', + 100, + 'should allow updating values inside a conflicted list', + function () use ($port): void { + $doc1 = $port->from(['users' => [['name' => 'ignored']]], 'aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + $doc3 = $port->clone($doc1, 'cccccc'); + + $doc2 = $port->setListElement($doc2, 'users', 0, ['name' => 'alice']); + $doc3 = $port->setListElement($doc3, 'users', 0, ['name' => 'charlie']); + $doc1 = $port->setListElement($doc1, 'users', 0, ['name' => 'bob']); + + $merged = $port->mergeDocuments($port->mergeDocuments($doc1, $doc2), $doc3); + sameArray( + $port->getListElementConflicts($merged, 'users', 0) ?? [], + [ + '2@aaaaaa' => ['name' => 'bob'], + '2@bbbbbb' => ['name' => 'alice'], + '2@cccccc' => ['name' => 'charlie'], + ], + 'users[0] conflict should expose all concurrently assigned maps before nested update' + ); + + $updated = $port->setRootConflictListElementMapValue($merged, 'users', 0, 'name', 'Attila'); + $expected = [ + '2@aaaaaa' => ['name' => 'Attila'], + '2@bbbbbb' => ['name' => 'Attila'], + '2@cccccc' => ['name' => 'Attila'], + ]; + + sameArray($port->getListElementConflicts($updated, 'users', 0) ?? [], $expected, 'nested list-element update should rewrite each conflicted map value'); + sameArray( + $port->getListElementConflicts($port->load($port->save($updated)), 'users', 0) ?? [], + $expected, + 'updated conflicted list-element map values should survive save/load' + ); + } +); + +$mapped( + 'legacy concurrent nested map assignments stay conflicted at the root', + 'javascript/test/legacy_tests.ts', + 1195, + 'should not merge concurrently assigned nested maps', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'config', ['background' => 'blue']); + $doc2 = $port->set($port->init('aaaaaa'), 'config', ['logo_url' => 'logo.png']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['config'], ['background' => 'blue'], 'concurrent nested map assignment should keep the deterministic root winner'); + sameArray( + $port->getConflicts($merged, 'config') ?? [], + [ + '1@aaaaaa' => ['logo_url' => 'logo.png'], + '1@bbbbbb' => ['background' => 'blue'], + ], + 'concurrent nested map assignment should report root-level map conflicts' + ); + } +); + +$mapped( + 'legacy root assignment clears prior same-field conflicts', + 'javascript/test/legacy_tests.ts', + 1210, + 'should clear conflicts after assigning a new value', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'field', 'one'); + $doc2 = $port->set($port->init('aaaaaa'), 'field', 'two'); + $doc3 = $port->mergeDocuments($doc1, $doc2); + $doc3 = $port->set($doc3, 'field', 'three'); + + sameArray($doc3->toArray(), ['field' => 'three'], 'new assignment after conflict should materialize the replacement value'); + same($port->getConflicts($doc3, 'field'), null, 'new assignment should clear local conflict metadata'); + + $doc2 = $port->mergeDocuments($doc2, $doc3); + sameArray($doc2->toArray(), ['field' => 'three'], 'causally newer assignment should win during later merge'); + same($port->getConflicts($doc2, 'field'), null, 'causally newer assignment should not recreate the old conflict'); + } +); + +$mapped( + 'root map overwrites retain the last scalar value', + 'javascript/test/basic_test.ts', + 183, + 'handle overwrites to values', + function () use ($port): void { + $doc = $port->init('aabbcc'); + foreach (['world1', 'world2', 'world3', 'world4'] as $value) { + $doc = $port->set($doc, 'hello', $value); + } + + sameArray($doc->toArray(), ['hello' => 'world4'], 'last scalar write should win in a linear document'); + } +); + +$mapped( + 'object values materialize through root map set', + 'javascript/test/basic_test.ts', + 200, + 'handle set with object value', + function () use ($port): void { + $doc = $port->set( + $port->init('aabbcc'), + 'subobj', + ['hello' => 'world', 'subsubobj' => ['zip' => 'zop']] + ); + + sameArray( + $doc->toArray(), + ['subobj' => ['hello' => 'world', 'subsubobj' => ['zip' => 'zop']]], + 'nested object value should materialize deterministically' + ); + } +); + +$mapped( + 'list creation materializes an empty PHP list', + 'javascript/test/basic_test.ts', + 210, + 'handle simple list creation', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'list', []); + + sameArray($doc->toArray(), ['list' => []], 'empty list should materialize as an empty PHP array'); + } +); + +$mapped( + 'simple list values can be read and replaced', + 'javascript/test/basic_test.ts', + 216, + 'handle simple lists', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'list', [1, 2, 3]); + same(count($doc->toArray()['list']), 3, 'list should have three values'); + same($doc->toArray()['list'][1], 2, 'list item should be readable by index'); + + $nextList = $doc->toArray()['list']; + $nextList[1] = 'a'; + $doc = $port->set($doc, 'list', $nextList); + sameArray($doc->toArray(), ['list' => [1, 'a', 3]], 'list replacement should materialize'); + } +); + +$mapped( + 'basic getChanges/applyChanges replays simple list assignment', + 'javascript/test/basic_test.ts', + 238, + 'handle simple lists', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->set($doc1, 'list', [1, 2, 3]); + $changes = $port->getChanges($doc1, $doc2); + $docB2 = $port->applyChanges($port->init('bbbbbb'), $changes); + + sameArray($docB2->toArray(), $doc2->toArray(), 'simple list assignment should replay from changes'); + } +); + +$mapped( + 'basic getChanges/applyChanges replays text splice at arbitrary root key', + 'javascript/test/basic_test.ts', + 248, + 'handle text', + function () use ($port): void { + $doc1 = $port->init('aabbcc'); + $doc2 = $port->set($doc1, 'list', 'hello'); + $doc2 = $port->splice($doc2, 'list', 2, 0, 'Z'); + $changes = $port->getChanges($doc1, $doc2); + $docB2 = $port->applyChanges($port->init('bbbbbb'), $changes); + + same($docB2->toArray()['list'], 'heZllo', 'root-key text splice should replay visible text'); + sameArray($docB2->toArray(), $doc2->toArray(), 'text splice change set should replay from changes'); + } +); + +$mapped( + 'text insertion exposes length, index access, and string materialization', + 'javascript/test/text_test.ts', + 17, + 'should support insertion', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'a'); + $text = $doc->text('text'); + + same($text->length(), 1, 'inserted text should have length one'); + same($text->charAt(0), 'a', 'inserted text should be readable by index'); + same($text->toString(), 'a', 'inserted text should materialize to a string'); + } +); + +$rustMapped( + 'rust text encoding length counts code points code units and graphemes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:length', + 'length', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, "hello👩‍👩‍👧‍👦"); + + same($port->textLength($doc, ['text'], 'UnicodeCodePoint'), 12, 'unicode code point length should match upstream text encoding length'); + same($port->textLength($doc, ['text'], 'Utf8CodeUnit'), 30, 'UTF-8 code unit length should match upstream text encoding length'); + same($port->textLength($doc, ['text'], 'Utf16CodeUnit'), 16, 'UTF-16 code unit length should match upstream text encoding length'); + same($port->textLength($doc, ['text'], 'GraphemeCluster'), 6, 'grapheme cluster length should match upstream text encoding length'); + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding splice maps code point code unit and grapheme indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:splice-text', + 'splice_text', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "hello 👩‍👩‍👧‍👦 world"); + $cases = [ + ['UnicodeCodePoint', 14], + ['Utf8CodeUnit', 32], + ['Utf16CodeUnit', 18], + ['GraphemeCluster', 8], + ]; + + foreach ($cases as [$encoding, $index]) { + $doc = $port->spliceTextEncoded($base, ['text'], $index, 0, 'beautiful ', $encoding); + same( + $doc->toArray()['text'], + "hello 👩‍👩‍👧‍👦 beautiful world", + $encoding . ' splice index should insert at the upstream text boundary' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding get maps code point code unit and grapheme indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:get', + 'get', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, "he👩‍👩‍👧‍👦lo"); + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + same( + $port->textAtEncodedIndex($doc, ['text'], $index, $encoding), + 'l', + $encoding . ' get index should read the upstream text element' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding put maps code point code unit and grapheme indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:put', + 'put', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $doc = $port->putTextEncoded($base, ['text'], $index, 'L', $encoding); + same( + $doc->toArray()['text'], + "he👩‍👩‍👧‍👦Llo", + $encoding . ' put index should replace the upstream text element' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding insert maps code point code unit and grapheme indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:insert', + 'insert', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $doc = $port->insertTextEncoded($base, ['text'], $index, 'L', $encoding); + same( + $doc->toArray()['text'], + "he👩‍👩‍👧‍👦Lllo", + $encoding . ' insert index should insert at the upstream text boundary' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding delete maps code point code unit and grapheme indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:delete', + 'delete', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $doc = $port->deleteTextEncoded($base, ['text'], $index, $encoding); + same( + $doc->toArray()['text'], + "he👩‍👩‍👧‍👦lo", + $encoding . ' delete index should remove the upstream text element' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding mark maps code point code unit and grapheme ranges', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:mark', + 'mark', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 11, [[1, 11]]], + ['Utf8CodeUnit', 27, [[1, 27]]], + ['Utf16CodeUnit', 13, [[1, 13]]], + ['GraphemeCluster', 4, [[1, 4]]], + ]; + + foreach ($cases as [$encoding, $endIndex, $expected]) { + $doc = $port->markTextEncoded($base, ['text'], 1, $endIndex, 'bold', true, $encoding, 'both'); + $ranges = array_map( + static fn (array $mark): array => [$mark['start'], $mark['end']], + $port->marksEncoded($doc, ['text'], $encoding) + ); + sameArray($ranges, $expected, $encoding . ' mark range should round trip through encoded indexes'); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding unmark maps code point code unit and grapheme ranges', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:unmark', + 'unmark', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 11], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 4], + ]; + + foreach ($cases as [$encoding, $endIndex]) { + $doc = $port->markTextEncoded($base, ['text'], 1, $endIndex, 'bold', true, $encoding, 'both'); + $doc = $port->unmarkTextEncoded($doc, ['text'], 1, $endIndex, 'bold', $encoding); + sameArray($port->marksEncoded($doc, ['text'], $encoding), [], $encoding . ' unmark range should remove the encoded mark'); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding splitBlock maps code point code unit and grapheme indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:split-block', + 'split_block', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $doc = $port->splitBlockEncoded($base, ['text'], $index, $encoding); + $textSpans = array_values(array_map( + static fn (array $span): string => $span['value'], + array_filter($port->spans($doc, ['text']), static fn (array $span): bool => ($span['type'] ?? null) === 'text') + )); + sameArray($textSpans, ["he👩‍👩‍👧‍👦", 'llo'], $encoding . ' splitBlock index should split at the upstream text boundary'); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding cursors report encoded positions after edits', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:cursors', + 'cursors', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $cases = [ + ['UnicodeCodePoint', 9, 16], + ['Utf8CodeUnit', 27, 52], + ['Utf16CodeUnit', 13, 24], + ['GraphemeCluster', 3, 4], + ]; + + foreach ($cases as [$encoding, $cursorIndex, $expectedPosition]) { + $cursor = $port->getCursorEncoded($base, ['text'], $cursorIndex, $encoding); + $doc = $port->spliceTextEncoded($base, ['text'], 2, 0, "👩‍👩‍👧‍👦", $encoding); + same( + $port->getCursorPositionEncoded($doc, ['text'], $cursor, $encoding), + $expectedPosition, + $encoding . ' cursor position should track through an inserted emoji' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding putSeq patches report encoded indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-put-seq', + 'patch_put_seq', + function () use ($port): void { + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $port->updateDiffCursor($base); + $doc = $port->putTextEncoded($base, ['text'], $index, 'L', $encoding); + sameArray( + $port->diffIncrementalEncoded($doc, ['text'], $encoding), + [['action' => 'putSeq', 'path' => ['text', $index], 'value' => 'L']], + $encoding . ' putSeq patch should report the upstream encoded index' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding insert patches report encoded indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-insert', + 'patch_insert', + function () use ($port): void { + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $port->updateDiffCursor($base); + $doc = $port->insertTextEncoded($base, ['text'], $index, 'L', $encoding); + sameArray( + $port->diffIncrementalEncoded($doc, ['text'], $encoding), + [['action' => 'splice', 'path' => ['text', $index], 'value' => 'L']], + $encoding . ' insert patch should report the upstream encoded index' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding splice patches report encoded indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-splice-text', + 'patch_splice_text', + function () use ($port): void { + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $port->updateDiffCursor($base); + $doc = $port->spliceTextEncoded($base, ['text'], $index, 0, 'L', $encoding); + sameArray( + $port->diffIncrementalEncoded($doc, ['text'], $encoding), + [['action' => 'splice', 'path' => ['text', $index], 'value' => 'L']], + $encoding . ' splice patch should report the upstream encoded index' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding delete patches report encoded indexes', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-delete', + 'patch_delete', + function () use ($port): void { + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $index]) { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $port->updateDiffCursor($base); + $doc = $port->deleteTextEncoded($base, ['text'], $index, $encoding); + sameArray( + $port->diffIncrementalEncoded($doc, ['text'], $encoding), + [['action' => 'del', 'path' => ['text', $index], 'length' => 1]], + $encoding . ' delete patch should report the upstream encoded index' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$rustMapped( + 'rust text encoding mark patches report encoded ranges', + 'rust:tests-text-encoding-rs-target-debug-deps-text-encoding-3bef81c1b0c759ee:patch-mark', + 'patch_mark', + function () use ($port): void { + $cases = [ + ['UnicodeCodePoint', 9], + ['Utf8CodeUnit', 27], + ['Utf16CodeUnit', 13], + ['GraphemeCluster', 3], + ]; + + foreach ($cases as [$encoding, $endIndex]) { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $base = $port->splice($base, 'text', 0, 0, "he👩‍👩‍👧‍👦llo"); + $port->updateDiffCursor($base); + $doc = $port->markTextEncoded($base, ['text'], 1, $endIndex, 'bold', true, $encoding, 'both'); + sameArray( + $port->diffIncrementalEncoded($doc, ['text'], $encoding), + [[ + 'action' => 'mark', + 'path' => ['text'], + 'marks' => [['name' => 'bold', 'value' => true, 'start' => 1, 'end' => $endIndex]], + ]], + $encoding . ' mark patch should report the upstream encoded range' + ); + } + }, + 'rust/automerge/tests/text_encoding.rs' +); + +$mapped( + 'text deletion removes the visible character at the splice range', + 'javascript/test/text_test.ts', + 25, + 'should support deletion', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'abc'); + $doc = $port->splice($doc, 'text', 1, 1); + $text = $doc->text('text'); + + same($text->length(), 2, 'deleted text should have two visible characters'); + same($text->charAt(0), 'a', 'first visible char should remain'); + same($text->charAt(1), 'c', 'third inserted char should shift into second position'); + same($text->toString(), 'ac', 'deleted text should materialize without tombstoned char'); + } +); + +$mapped( + 'text zero-length splice after deletion is a no-op', + 'javascript/test/text_test.ts', + 36, + 'should support implicit and explicit deletion', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'abc'); + $doc = $port->splice($doc, 'text', 1, 1); + $doc = $port->splice($doc, 'text', 1, 0); + + same($doc->text('text')->toString(), 'ac', 'zero-length splice should not change visible text'); + } +); + +$rustMapped( + 'rust UTF-16 splice deletion inside multibyte characters snaps after the character', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:deleting-in-middle-of-multibyte-char-moves-the-cursor-to-after-the-character', + 'deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->spliceTextEncoded($base, ['text'], 0, 0, '🐻🐻🐻🐻🐻🐻', 'Utf16CodeUnit'); + same($doc->toArray()['text'], '🐻🐻🐻🐻🐻🐻', 'UTF-16 setup should preserve bear emoji text'); + + $doc = $port->spliceTextEncoded($doc, ['text'], 2, 2, 'A🐻A', 'Utf16CodeUnit'); + same($doc->toArray()['text'], '🐻A🐻A🐻🐻🐻🐻', 'UTF-16 boundary splice should replace one full emoji'); + + $replaceOne = $port->spliceTextEncoded($doc, ['text'], 4, 1, 'X', 'Utf16CodeUnit'); + same($replaceOne->toArray()['text'], '🐻A🐻X🐻🐻🐻🐻', 'UTF-16 splice inside an emoji should delete the following character'); + + $replaceTwo = $port->spliceTextEncoded($doc, ['text'], 4, 2, 'Y', 'Utf16CodeUnit'); + same($replaceTwo->toArray()['text'], '🐻A🐻Y🐻🐻🐻', 'UTF-16 splice inside an emoji should measure deletion after the snapped boundary'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust legacy multi-character text op splices on op boundaries', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:splicing-into-multibyte-characters', + 'splicing_into_multibyte_characters', + function () use ($port): void { + $legacyText = new TextValue([ + ['id' => '1@aaaaaa', 'actor' => 'aaaaaa', 'seq' => 1, 'char' => 'A', 'visible' => true, 'after' => null, 'inserted' => false], + ['id' => '2@bbbbbb', 'actor' => 'bbbbbb', 'seq' => 2, 'char' => 'BBBBB', 'visible' => true, 'after' => '1@aaaaaa', 'inserted' => false], + ['id' => '3@bbbbbb', 'actor' => 'bbbbbb', 'seq' => 3, 'char' => "\u{fffc}", 'visible' => true, 'after' => '2@bbbbbb', 'inserted' => false], + ['id' => '4@bbbbbb', 'actor' => 'bbbbbb', 'seq' => 4, 'char' => 'C', 'visible' => true, 'after' => '3@bbbbbb', 'inserted' => false], + ]); + $doc = $port->set($port->init('aaaaaa'), 'text', $legacyText); + same($doc->text('text')->toString(), "ABBBBB\u{fffc}C", 'legacy setup should materialize the multi-character text op payload'); + + $deleteThroughTail = $port->spliceTextEncoded($doc, ['text'], 3, 4, 'X', 'Utf16CodeUnit'); + same($deleteThroughTail->text('text')->toString(), 'ABBBBBX', 'deleting from the middle of a legacy multi-character op should snap after that op'); + + $insertInside = $port->spliceTextEncoded($doc, ['text'], 3, 0, 'X', 'Utf16CodeUnit'); + same($insertInside->text('text')->toString(), "ABBBBBX\u{fffc}C", 'inserting inside a legacy multi-character op should insert after that op'); + + $deleteObject = $port->spliceTextEncoded($doc, ['text'], 3, 1, '', 'Utf16CodeUnit'); + same($deleteObject->text('text')->toString(), 'ABBBBBC', 'deleting inside a legacy multi-character op should delete the following object marker'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text diff common prefix length matches byte ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-utils-test-common-prefix-len', + 'text_diff::utils::test_common_prefix_len', + function () use ($port): void { + same($port->textDiffCommonPrefixLen('', 0, 0, '', 0, 0), 0, 'empty ranges should have no common prefix'); + same($port->textDiffCommonPrefixLen('foobarbaz', 0, 9, 'foobarblah', 0, 10), 7, 'common prefix should count equal bytes from range starts'); + same($port->textDiffCommonPrefixLen('foobarbaz', 0, 9, 'blablabla', 0, 9), 0, 'different range starts should have no common prefix'); + same($port->textDiffCommonPrefixLen('foobarbaz', 3, 9, 'foobarblah', 3, 10), 4, 'offset ranges should count the shared byte prefix'); + }, + 'rust/automerge/src/text_diff/utils.rs' +); + +$rustMapped( + 'rust text diff common suffix length matches byte ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-utils-test-common-suffix-len', + 'text_diff::utils::test_common_suffix_len', + function () use ($port): void { + same($port->textDiffCommonSuffixLen('', 0, 0, '', 0, 0), 0, 'empty ranges should have no common suffix'); + same($port->textDiffCommonSuffixLen('1234', 0, 4, 'X0001234', 0, 8), 4, 'common suffix should count equal bytes from range ends'); + same($port->textDiffCommonSuffixLen('1234', 0, 4, 'Xxxx', 0, 4), 0, 'different range ends should have no common suffix'); + same($port->textDiffCommonSuffixLen('1234', 2, 4, '01234', 2, 5), 2, 'offset ranges should count the shared byte suffix'); + }, + 'rust/automerge/src/text_diff/utils.rs' +); + +$rustMapped( + 'rust clock covers op ids by actor counter', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:clock-tests-covers', + 'clock::tests::covers', + function () use ($port): void { + $clock = $port->clock(4); + $clock = $port->clockInclude($clock, 1, 20); + $clock = $port->clockInclude($clock, 2, 10); + + truthy($port->clockCovers($clock, 10, 1), 'clock should cover earlier actor 1 counters'); + truthy($port->clockCovers($clock, 20, 1), 'clock should cover exact actor 1 counter'); + truthy(! $port->clockCovers($clock, 30, 1), 'clock should not cover later actor 1 counters'); + + truthy($port->clockCovers($clock, 5, 2), 'clock should cover earlier actor 2 counters'); + truthy($port->clockCovers($clock, 10, 2), 'clock should cover exact actor 2 counter'); + truthy(! $port->clockCovers($clock, 15, 2), 'clock should not cover later actor 2 counters'); + + truthy(! $port->clockCovers($clock, 1, 3), 'clock should not cover unseen actor 3 counters'); + truthy(! $port->clockCovers($clock, 100, 3), 'clock should not cover large unseen actor 3 counters'); + }, + 'rust/automerge/src/clock.rs' +); + +$rustMapped( + 'rust clock comparison handles ordering and concurrency', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:clock-tests-comparison', + 'clock::tests::comparison', + function () use ($port): void { + $baseClock = $port->clock(4); + $baseClock = $port->clockInclude($baseClock, 1, 1); + $baseClock = $port->clockInclude($baseClock, 2, 1); + + $afterClock = $port->clockInclude($baseClock, 1, 2); + + same($port->clockCompare($afterClock, $baseClock), 'greater', 'after clock should dominate the base clock'); + same($port->clockCompare($baseClock, $afterClock), 'less', 'base clock should predate the after clock'); + same($port->clockCompare($baseClock, $baseClock), 'equal', 'identical clocks should compare equal'); + + $newActorClock = $port->clockInclude($baseClock, 3, 1); + + same($port->clockCompare($baseClock, $newActorClock), 'less', 'adding a new actor should make that clock greater than base'); + same($port->clockCompare($newActorClock, $baseClock), 'greater', 'new actor clock should dominate the base clock'); + same($port->clockCompare($afterClock, $newActorClock), null, 'independent actor advances should be concurrent'); + same($port->clockCompare($newActorClock, $afterClock), null, 'concurrent comparison should be symmetric'); + }, + 'rust/automerge/src/clock.rs' +); + +$rustMapped( + 'rust change graph derives sequence clocks for heads', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:change-graph-tests-clock-by-heads', + 'change_graph::tests::clock_by_heads', + function () use ($port): void { + $changes = [ + ['hash' => 'change1', 'actor' => 0, 'seq' => 1, 'deps' => []], + ['hash' => 'change2', 'actor' => 1, 'seq' => 1, 'deps' => ['change1']], + ['hash' => 'change3', 'actor' => 2, 'seq' => 1, 'deps' => ['change1']], + ['hash' => 'change4', 'actor' => 0, 'seq' => 2, 'deps' => ['change2', 'change3']], + ]; + + sameArray( + $port->changeGraphSeqClockForHeads($changes, ['change4']), + [2, 1, 1], + 'sequence clock should include the highest actor sequence reachable from the requested heads' + ); + }, + 'rust/automerge/src/change_graph.rs' +); + +$rustMapped( + 'rust change graph removes ancestors of selected heads', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:change-graph-tests-remove-ancestors', + 'change_graph::tests::remove_ancestors', + function () use ($port): void { + $changes = [ + ['hash' => 'change1', 'actor' => 0, 'seq' => 1, 'deps' => []], + ['hash' => 'change2', 'actor' => 1, 'seq' => 1, 'deps' => ['change1']], + ['hash' => 'change3', 'actor' => 2, 'seq' => 1, 'deps' => ['change1']], + ['hash' => 'change4', 'actor' => 0, 'seq' => 2, 'deps' => ['change2', 'change3']], + ]; + + sameArray( + $port->changeGraphRemoveAncestors($changes, ['change1', 'change2', 'change3', 'change4'], ['change2']), + ['change3', 'change4'], + 'ancestor removal should drop the selected head and its dependencies from the candidate set' + ); + }, + 'rust/automerge/src/change_graph.rs' +); + +$rustMapped( + 'rust columnar unsigned LEB128 size examples match encoded byte counts', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-ulebsize-examples', + 'columnar::encoding::leb128::tests::ulebsize_examples', + function () use ($port): void { + $examples = [ + '0' => 1, + '1' => 1, + '127' => 1, + '128' => 2, + '129' => 2, + '169' => 2, + '18446744073709551615' => 10, + ]; + + foreach ($examples as $value => $expectedSize) { + same($port->uleb128Size($value), $expectedSize, 'unsigned LEB128 size should match upstream example value ' . $value); + } + }, + 'rust/automerge/src/columnar/encoding/leb128.rs' +); + +$rustMapped( + 'rust columnar signed LEB128 size examples match encoded byte counts', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-lebsize-examples', + 'columnar::encoding::leb128::tests::lebsize_examples', + function () use ($port): void { + $examples = [ + '0' => 1, + '1' => 1, + '-1' => 1, + '63' => 1, + '64' => 2, + '-64' => 1, + '-65' => 2, + '127' => 2, + '128' => 2, + '-127' => 2, + '-128' => 2, + '-2097152' => 4, + '169' => 2, + '-9223372036854775808' => 10, + '9223372036854775807' => 10, + ]; + + foreach ($examples as $value => $expectedSize) { + same($port->leb128Size($value), $expectedSize, 'signed LEB128 size should match upstream example value ' . $value); + } + }, + 'rust/automerge/src/columnar/encoding/leb128.rs' +); + +$rustMapped( + 'rust columnar unsigned LEB128 property boundary sizes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-test-ulebsize', + 'columnar::encoding::leb128::tests::test_ulebsize', + function () use ($port): void { + $boundaries = [ + '0' => 1, + '127' => 1, + '128' => 2, + '16383' => 2, + '16384' => 3, + '2097151' => 3, + '2097152' => 4, + '268435455' => 4, + '268435456' => 5, + '34359738367' => 5, + '34359738368' => 6, + '4398046511103' => 6, + '4398046511104' => 7, + '562949953421311' => 7, + '562949953421312' => 8, + '72057594037927935' => 8, + '72057594037927936' => 9, + '9223372036854775807' => 9, + '9223372036854775808' => 10, + '18446744073709551615' => 10, + ]; + + foreach ($boundaries as $value => $expectedSize) { + same($port->uleb128Size($value), $expectedSize, 'unsigned LEB128 boundary size should match encoded byte count for ' . $value); + } + }, + 'rust/automerge/src/columnar/encoding/leb128.rs' +); + +$rustMapped( + 'rust columnar signed LEB128 property boundary sizes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-leb128-tests-test-lebsize', + 'columnar::encoding::leb128::tests::test_lebsize', + function () use ($port): void { + $boundaries = [ + '-64' => 1, + '63' => 1, + '-65' => 2, + '64' => 2, + '-8192' => 2, + '8191' => 2, + '-8193' => 3, + '8192' => 3, + '-1048576' => 3, + '1048575' => 3, + '-1048577' => 4, + '1048576' => 4, + '-134217728' => 4, + '134217727' => 4, + '-134217729' => 5, + '134217728' => 5, + '-17179869184' => 5, + '17179869183' => 5, + '-17179869185' => 6, + '17179869184' => 6, + '-9223372036854775808' => 10, + '9223372036854775807' => 10, + ]; + + foreach ($boundaries as $value => $expectedSize) { + same($port->leb128Size($value), $expectedSize, 'signed LEB128 boundary size should match encoded byte count for ' . $value); + } + }, + 'rust/automerge/src/columnar/encoding/leb128.rs' +); + +$ignoredMapped( + 'ignored Rust storage parse doc example consumes three bytes and leaves remaining input', + 'rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-17', + 'automerge/src/storage/parse.rs - basic usage doc example', + function () use ($port): void { + $input = $port->storageParseInput([ord('1'), ord('2'), ord('3'), ord('4'), ord('5')]); + [$input, $a] = $port->storageInputTakeOne($input); + [$input, $b] = $port->storageInputTakeOne($input); + [$input, $c] = $port->storageInputTakeOne($input); + + sameArray([$a, $b, $c], [ord('1'), ord('2'), ord('3')], 'storage parser doc example should parse three bytes in order'); + same($input['position'], 3, 'storage parser input position should advance after each take'); + sameArray($port->storageInputRemainingBytes($input), [ord('4'), ord('5')], 'storage parser input should retain unconsumed bytes'); + }, + 'rust/automerge/src/storage/parse.rs' +); + +$ignoredMapped( + 'ignored Rust storage parse split doc example separates chunk bytes from remaining input', + 'rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-input-a-split-line-264', + 'automerge/src/storage/parse.rs - Input::split doc example', + function () use ($port): void { + $input = $port->storageParseInput([3, ord('a'), ord('b'), ord('c'), ord('x'), ord('y')]); + [$input, $chunkLength] = $port->storageInputTakeOne($input); + $split = $port->storageInputSplit($input, $chunkLength); + + sameArray($port->storageInputRemainingBytes($split['first']), [ord('a'), ord('b'), ord('c')], 'split first input should contain exactly the length-delimited chunk bytes'); + same($split['first']['position'], 1, 'split first input should preserve the caller position after parsing the length header'); + sameArray($port->storageInputRemainingBytes($split['remaining']), [ord('x'), ord('y')], 'split remaining input should contain bytes after the chunk'); + same($split['remaining']['position'], 4, 'split remaining input should advance past the length header and chunk bytes'); + }, + 'rust/automerge/src/storage/parse.rs' +); + +$ignoredMapped( + 'ignored Rust storage parse range_of doc example records the consumed byte range', + 'rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-range-of-line-561', + 'automerge/src/storage/parse.rs - range_of doc example', + function () use ($port): void { + $input = $port->storageParseInput([ord('m'), ord('s'), ord('g')]); + [$input, $parsed] = $port->storageInputRangeOfTakeOne($input); + + same($parsed['value'], ord('m'), 'range_of should return the parser result value'); + sameArray($parsed['range'], ['start' => 0, 'end' => 1], 'range_of should record the input range consumed by the parser'); + same($input['position'], 1, 'range_of should advance the caller input to the parser output position'); + sameArray($port->storageInputRemainingBytes($input), [ord('s'), ord('g')], 'range_of should preserve bytes after the parser output'); + }, + 'rust/automerge/src/storage/parse.rs' +); + +$ignoredMapped( + 'ignored Rust storage parse split remaining doc example exposes post-split backing bytes', + 'rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-split-remaining-line-325', + 'automerge/src/storage/parse.rs - Split::remaining doc example', + function () use ($port): void { + $input = $port->storageParseInput([3, ord('a'), ord('b'), ord('c'), ord('x'), ord('y')]); + [$input, $chunkLength] = $port->storageInputTakeOne($input); + $split = $port->storageInputSplit($input, $chunkLength); + + sameArray($port->storageInputBytes($split['remaining']), [ord('x'), ord('y')], 'split remaining bytes() should expose only bytes after the split chunk'); + sameArray($port->storageInputRemainingBytes($split['remaining']), [ord('x'), ord('y')], 'split remaining unconsumed bytes should match the post-split bytes'); + same($split['remaining']['position'], 4, 'split remaining should preserve the absolute position reached after the split chunk'); + }, + 'rust/automerge/src/storage/parse.rs' +); + +$ignoredMapped( + 'ignored Rust storage document parse doc example materializes a parsed fixture', + 'rust:doc-tests-automerge:automerge-src-storage-document-rs-storage-document-document-a-parse-line-54', + 'automerge/src/storage/document.rs - Document::parse doc example', + function () use ($port): void { + $payload = file_get_contents(__DIR__ . '/../upstream/automerge/rust/automerge/tests/fixtures/two_change_chunks.automerge'); + truthy(is_string($payload), 'document parse fixture should be readable'); + $bytes = array_values(unpack('C*', $payload)); + $loaded = $port->storageDocumentFromBytes($bytes, 'bbbbbb'); + + sameArray($loaded->toArray(), ['a' => ['a' => 'b']], 'document parse should materialize the fixture after header parsing'); + }, + 'rust/automerge/src/storage/document.rs' +); + +$ignoredMapped( + 'ignored Rust storage parse error doc example wraps parser-specific errors', + 'rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-56', + 'automerge/src/storage/parse.rs - ParseError::Error doc example', + function () use ($port): void { + sameArray( + $port->storageParseApplicationError('MyError'), + ['type' => 'error', 'value' => 'MyError'], + 'parser-specific errors should be represented as application parse errors' + ); + }, + 'rust/automerge/src/storage/parse.rs' +); + +$ignoredMapped( + 'ignored Rust storage parse lift doc example maps application errors and preserves incomplete errors', + 'rust:doc-tests-automerge:automerge-src-storage-parse-rs-storage-parse-line-69', + 'automerge/src/storage/parse.rs - ParseError::lift doc example', + function () use ($port): void { + sameArray( + $port->storageParseErrorLift($port->storageParseApplicationError('BadString'), 'String'), + ['type' => 'error', 'value' => ['variant' => 'String', 'source' => 'BadString']], + 'lift should wrap parser-specific errors in the combined error variant' + ); + sameArray( + $port->storageParseErrorLift($port->storageParseIncomplete(2), 'Number'), + ['type' => 'incomplete', 'needed' => 2], + 'lift should leave incomplete parser errors unchanged' + ); + }, + 'rust/automerge/src/storage/parse.rs' +); + +$rustMapped( + 'rust storage parser decodes canonical u64 LEB128 values', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-u64', + 'storage::parse::leb128::tests::leb_128_u64', + function () use ($port): void { + sameArray($port->storageParseLeb128U64([0b00000001]), ['value' => '1', 'offset' => 1], 'u64 parser should decode one-byte value'); + sameArray($port->storageParseLeb128U64([0b10000001, 0b00000001]), ['value' => '129', 'offset' => 2], 'u64 parser should decode multi-byte value'); + sameArray($port->storageParseLeb128U64([0b00000001, 0b00000011]), ['value' => '1', 'offset' => 1], 'u64 parser should stop after first complete value'); + + $success = [ + [[0], '0'], + [[0x7f], '127'], + [[0x80, 0x01], '128'], + [[0xff, 0x7f], '16383'], + [[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01], '18446744073709551615'], + ]; + foreach ($success as [$bytes, $expected]) { + same($port->storageParseLeb128U64($bytes)['value'], $expected, 'u64 parser should accept canonical value ' . $expected); + } + + foreach ([[129, 129, 129, 129, 129, 129, 129, 129, 129, 129, 129, 129], [129, 129, 129, 129, 129, 129, 129, 129, 129, 2]] as $bytes) { + throwsLike( + static fn (): array => $port->storageParseLeb128U64($bytes), + 'too large for u64', + 'u64 parser should reject oversized encodings' + ); + } + throwsLike( + static fn (): array => $port->storageParseLeb128U64([129, 0]), + 'overlong', + 'u64 parser should reject overlong encodings' + ); + throwsLike( + static fn (): array => $port->storageParseLeb128U64([255]), + 'Truncated', + 'u64 parser should reject truncated encodings' + ); + }, + 'rust/automerge/src/storage/parse/leb128.rs' +); + +$rustMapped( + 'rust storage parser decodes canonical u32 LEB128 values', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-u32', + 'storage::parse::leb128::tests::leb_128_u32', + function () use ($port): void { + $success = [ + [[0], '0'], + [[0x7f], '127'], + [[0x80, 0x01], '128'], + [[0xff, 0x7f], '16383'], + [[0xff, 0xff, 0xff, 0xff, 0x0f], '4294967295'], + ]; + foreach ($success as [$bytes, $expected]) { + same($port->storageParseLeb128U32($bytes)['value'], $expected, 'u32 parser should accept canonical value ' . $expected); + } + + foreach ([[129, 129, 129, 129, 129, 129, 129, 129, 129, 129, 129, 129], [0xff, 0xff, 0xff, 0xff, 0x1f]] as $bytes) { + throwsLike( + static fn (): array => $port->storageParseLeb128U32($bytes), + 'too large', + 'u32 parser should reject oversized encodings' + ); + } + throwsLike( + static fn (): array => $port->storageParseLeb128U32([129, 0]), + 'overlong', + 'u32 parser should reject overlong encodings' + ); + throwsLike( + static fn (): array => $port->storageParseLeb128U32([0xaa]), + 'Truncated', + 'u32 parser should reject truncated encodings' + ); + }, + 'rust/automerge/src/storage/parse/leb128.rs' +); + +$rustMapped( + 'rust storage parser decodes canonical i64 LEB128 values', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-parse-leb128-tests-leb-128-i64', + 'storage::parse::leb128::tests::leb_128_i64', + function () use ($port): void { + $success = [ + [[0], '0'], + [[0x7f], '-1'], + [[0x3f], '63'], + [[0x40], '-64'], + [[0x80, 0x01], '128'], + [[0xff, 0x3f], '8191'], + [[0x80, 0x40], '-8192'], + [[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00], '9223372036854775807'], + [[0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x7f], '-9223372036854775808'], + ]; + foreach ($success as [$bytes, $expected]) { + same($port->storageParseLeb128I64($bytes)['value'], $expected, 'i64 parser should accept canonical value ' . $expected); + } + + foreach ( + [ + [129, 129, 129, 129, 129, 129, 129, 129, 129, 129, 129, 129], + [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01], + [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x7e], + ] as $bytes + ) { + throwsLike( + static fn (): array => $port->storageParseLeb128I64($bytes), + 'too large for i64', + 'i64 parser should reject oversized encodings' + ); + } + foreach ([[0xbf, 0], [0x81, 0xff, 0x7f]] as $bytes) { + throwsLike( + static fn (): array => $port->storageParseLeb128I64($bytes), + 'overlong', + 'i64 parser should reject overlong encodings' + ); + } + throwsLike( + static fn (): array => $port->storageParseLeb128I64([0x90]), + 'Truncated', + 'i64 parser should reject truncated encodings' + ); + }, + 'rust/automerge/src/storage/parse/leb128.rs' +); + +$rustMapped( + 'rust change encoding expanded change round-trips raw storage bytes', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-change-encoding-expanded-change-round-trip', + 'test_change_encoding_expanded_change_round_trip', + function () use ($port): void { + $changeBytes = [ + 0x85, 0x6f, 0x4a, 0x83, + 0xb2, 0x98, 0x9e, 0xa9, + 1, 61, 0, 2, 0x12, 0x34, + 1, 1, 252, 250, 220, 255, 5, + 14, 73, 110, 105, 116, 105, 97, 108, 105, 122, 97, 116, 105, 111, 110, + 0, 6, + 0x15, 3, 0x34, 1, 0x42, 2, + 0x56, 2, 0x57, 1, 0x70, 2, + 0x7f, 1, 0x78, + 1, + 0x7f, 1, + 0x7f, 19, + 1, + 0x7f, 0, + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + ]; + + $change = $port->storageChangeFromBytes($changeBytes); + sameArray($port->storageChangeRawBytes($change), $changeBytes, 'decoded storage change should retain the exact raw chunk bytes'); + same($change['checksum'], 'b2989ea9', 'change checksum should match the upstream fixture header'); + same(substr($change['hash'], 0, 8), 'b2989ea9', 'computed change hash should supply the header checksum bytes'); + same($change['actor'], '1234', 'binary actor ID should decode as the fixture actor'); + same($change['seq'], 1, 'change sequence should decode from unsigned LEB128'); + same($change['startOp'], 1, 'change startOp should decode from nonzero unsigned LEB128'); + same($change['time'], 1610038652, 'change timestamp should decode from signed LEB128'); + same($change['message'], 'Initialization', 'change message should decode as UTF-8'); + sameArray($change['deps'], [], 'fixture change should have no dependencies'); + sameArray($change['otherActors'], [], 'fixture change should have no other actors'); + sameArray( + array_column($change['rawColumns'], 'spec'), + [0x15, 0x34, 0x42, 0x56, 0x57, 0x70], + 'raw column specs should decode in normalized storage order' + ); + sameArray( + array_column($change['rawColumns'], 'length'), + [3, 1, 2, 2, 1, 2], + 'raw column byte lengths should match the fixture metadata' + ); + sameArray($change['opsData'], [0x7f, 1, 0x78, 1, 0x7f, 1, 0x7f, 19, 1, 0x7f, 0], 'operation data bytes should be split from trailing bytes'); + sameArray($change['extraBytes'], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 'trailing bytes should be preserved'); + + $expanded = $port->storageExpandedChangeFromChange($change); + $unexpanded = $port->storageChangeFromExpandedChange($expanded); + sameArray($port->storageChangeRawBytes($unexpanded), $changeBytes, 'expanded change should collapse back to identical raw bytes'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust compressed change chunks inflate to the original raw change bytes', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-compressed-changes', + 'test_compressed_changes', + function () use ($port): void { + $baseChangeBytes = [ + 0x85, 0x6f, 0x4a, 0x83, + 0xb2, 0x98, 0x9e, 0xa9, + 1, 61, 0, 2, 0x12, 0x34, + 1, 1, 252, 250, 220, 255, 5, + 14, 73, 110, 105, 116, 105, 97, 108, 105, 122, 97, 116, 105, 111, 110, + 0, 6, + 0x15, 3, 0x34, 1, 0x42, 2, + 0x56, 2, 0x57, 1, 0x70, 2, + 0x7f, 1, 0x78, + 1, + 0x7f, 1, + 0x7f, 19, + 1, + 0x7f, 0, + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + ]; + $toBytes = static function (string $bytes): array { + return $bytes === '' ? [] : array_values(unpack('C*', $bytes)); + }; + $toString = static function (array $bytes): string { + $raw = ''; + foreach ($bytes as $byte) { + $raw .= chr($byte); + } + + return $raw; + }; + $encodeUleb = static function (int $value): array { + $bytes = []; + do { + $byte = $value & 0x7f; + $value = intdiv($value, 128); + if ($value !== 0) { + $byte |= 0x80; + } + $bytes[] = $byte; + } while ($value !== 0); + + return $bytes; + }; + + $body = array_merge(array_slice($baseChangeBytes, 10), array_fill(0, 300, 10)); + $lengthBytes = $encodeUleb(count($body)); + $hashInput = $toString([1]) . $toString($lengthBytes) . $toString($body); + $checksum = $toBytes(substr(hash('sha256', $hashInput, true), 0, 4)); + $uncompressed = array_merge([0x85, 0x6f, 0x4a, 0x83], $checksum, [1], $lengthBytes, $body); + + truthy(count($uncompressed) > 256, 'uncompressed fixture should cross the upstream deflate threshold'); + $decoded = $port->storageChangeFromBytes($uncompressed); + sameArray($decoded['extraBytes'], array_merge(range(0, 9), array_fill(0, 300, 10)), 'expanded uncompressed change should retain trailing bytes before compression'); + + $compressed = $port->storageCompressChangeBytes($uncompressed); + truthy(count($compressed) < count($uncompressed), 'compressed change chunk should be smaller than the uncompressed chunk'); + same($compressed[8] ?? null, 2, 'compressed change chunk should use the Automerge compressed chunk type'); + + sameArray($port->storageDecompressChangeBytes($compressed), $uncompressed, 'compressed chunk should inflate to the original raw change bytes'); + $reloaded = $port->storageChangeFromBytes($compressed); + sameArray($port->storageChangeRawBytes($reloaded), $uncompressed, 'loading a compressed change should expose the original raw change bytes'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust compressed document columns ignore deflate bit while preserving storage order', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:test-compressed-doc-cols', + 'test_compressed_doc_cols', + function () use ($port): void { + $keyCtrSpec = $port->columnSpecEncode(1, 'DeltaInteger'); + $keyStrSpec = $port->columnSpecEncode(1, 'String'); + $keyCtrBytes = array_fill(0, 320, 7); + $keyStrBytes = [ord('l'), ord('i'), ord('s'), ord('t')]; + $data = array_merge($keyCtrBytes, $keyStrBytes); + + $compressed = $port->storageCompressRawColumns( + [ + ['spec' => $keyCtrSpec, 'length' => count($keyCtrBytes)], + ['spec' => $keyStrSpec, 'length' => count($keyStrBytes)], + ], + $data, + 256 + ); + + $deflatedKeyCtrSpec = $port->columnSpecDeflated($keyCtrSpec); + sameArray( + array_column($compressed['columns'], 'spec'), + [$deflatedKeyCtrSpec, $keyStrSpec], + 'long key counter column should be deflated while the short key string column stays inflated' + ); + truthy($deflatedKeyCtrSpec > $keyStrSpec, 'fixture should prove raw spec ordering would reject the compressed column'); + truthy( + $compressed['columns'][0]['normalized'] < $compressed['columns'][1]['normalized'], + 'compressed document columns should compare normalized specs with the deflate bit ignored' + ); + truthy(count($compressed['data']) < count($data), 'compressed document columns should be smaller than uncompressed column data'); + + $inflated = $port->storageDecompressRawColumns($compressed['columns'], $compressed['data']); + sameArray($inflated['data'], $data, 'compressed document columns should inflate back to original column bytes'); + sameArray( + array_column($inflated['columns'], 'spec'), + [$keyCtrSpec, $keyStrSpec], + 'inflating compressed document columns should clear the deflate bit' + ); + sameArray( + array_column($inflated['columns'], 'length'), + [count($keyCtrBytes), count($keyStrBytes)], + 'inflated document column lengths should match the original storage layout' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust load rejects overlong counter LEB128 fixture encodings', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:overlong-leb', + 'overlong_leb', + function () use ($port): void { + throwsLike( + static fn (): array => $port->storageParseLeb128U64Exact([0x01, 0x00]), + 'trailing bytes', + 'counter value metadata declaring two bytes for a one-byte LEB should be rejected' + ); + throwsLike( + static fn (): array => $port->storageParseLeb128U64Exact([0x80, 0x00]), + 'overlong', + 'counter value encoded in two bytes when one byte suffices should be rejected' + ); + sameArray( + $port->storageParseLeb128U64Exact([0xd0, 0x0f]), + ['value' => '2000', 'offset' => 2], + 'canonical counter fixture LEB should decode to the fixture counter value' + ); + + $doc = $port->set($port->init('aaaaaa'), 'a', new Counter(2000)); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + $counter = $loaded->toArray()['a'] ?? null; + truthy($counter instanceof Counter, 'valid counter fixture should load as a native counter'); + same($counter->value(), 2000, 'valid counter fixture should preserve the counter value'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust load materializes two-change storage fixtures', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:load', + 'load', + function () use ($port): void { + foreach ([ + 'two_change_chunks.automerge', + 'two_change_chunks_compressed.automerge', + 'two_change_chunks_out_of_order.automerge', + ] as $fixtureName) { + $payload = file_get_contents(__DIR__ . '/../upstream/automerge/rust/automerge/tests/fixtures/' . $fixtureName); + truthy(is_string($payload), 'storage fixture should be readable: ' . $fixtureName); + + $loaded = $port->loadStorageDocument($payload, 'bbbbbb'); + sameArray( + $loaded->toArray(), + ['a' => ['a' => 'b']], + 'storage fixture should load the nested map value: ' . $fixtureName + ); + } + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust load rejects fuzz-crasher fixture bytes', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:fuzz-crashers', + 'fuzz_crashers', + function () use ($port): void { + $fixtures = glob(__DIR__ . '/../upstream/automerge/rust/automerge/tests/fuzz-crashers/*'); + sort($fixtures); + same(count($fixtures), 8, 'pinned upstream fuzz-crasher inventory should contain eight fixtures'); + + foreach ($fixtures as $fixture) { + $payload = file_get_contents($fixture); + truthy(is_string($payload), 'fuzz-crasher fixture should be readable: ' . basename($fixture)); + try { + $port->load($payload); + } catch (Throwable) { + continue; + } + + throw new RuntimeException('fuzz-crasher fixture unexpectedly loaded: ' . basename($fixture)); + } + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust ExId root serializes and parses as the root object id', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:exid-tests-test-root-roundtrip', + 'exid::tests::test_root_roundtrip', + function () use ($port): void { + $bytes = $port->exIdToBytes(['type' => 'root']); + + sameArray($bytes, [0], 'root ExId should serialize to the version/type tag only'); + sameArray($port->exIdFromBytes($bytes), ['type' => 'root', 'display' => '_root'], 'root ExId should parse back to root'); + same($port->exIdDisplay(['type' => 'root']), '_root', 'root ExId display should be _root'); + }, + 'rust/automerge/src/exid.rs' +); + +$rustMapped( + 'rust ExId non-root object ids round-trip through bytes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:exid-tests-objid-roundtrip', + 'exid::tests::objid_roundtrip', + function () use ($port): void { + $vectors = [ + ['type' => 'id', 'counter' => '1', 'actor' => '01234567', 'actorIndex' => '0'], + ['type' => 'id', 'counter' => '128', 'actor' => 'deadbeef', 'actorIndex' => '3'], + ['type' => 'id', 'counter' => '18446744073709551615', 'actor' => '', 'actorIndex' => '9223372036854775807'], + ]; + + sameArray( + $port->exIdToBytes(['type' => 'id', 'counter' => '128', 'actor' => '01234567', 'actorIndex' => '3']), + [0x10, 0x04, 0x01, 0x23, 0x45, 0x67, 0x03, 0x80, 0x01], + 'non-root ExId bytes should follow version/type, actor length, actor bytes, actor index, counter' + ); + + foreach ($vectors as $vector) { + $parsed = $port->exIdFromBytes($port->exIdToBytes($vector)); + same($parsed['type'], 'id', 'parsed ExId should remain non-root'); + same($parsed['counter'], $vector['counter'], 'parsed ExId counter should round-trip'); + same($parsed['actor'], strtolower($vector['actor']), 'parsed ExId actor bytes should round-trip'); + same($parsed['actorIndex'], $vector['actorIndex'], 'parsed ExId actor index hint should round-trip'); + same($parsed['display'], $port->exIdDisplay($vector), 'parsed ExId display should match upstream Display shape'); + } + + throwsLike( + static fn (): array => $port->exIdFromBytes([]), + 'version tag', + 'ExId parser should reject missing version tags' + ); + throwsLike( + static fn (): array => $port->exIdFromBytes([0x01]), + 'version', + 'ExId parser should reject unsupported versions' + ); + throwsLike( + static fn (): array => $port->exIdFromBytes([0x20]), + 'type', + 'ExId parser should reject unsupported type tags' + ); + }, + 'rust/automerge/src/exid.rs' +); + +$rustMapped( + 'rust 64-bit object ids do not truncate to root', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:obj-id-64bits', + 'obj_id_64bits', + function () use ($port): void { + $largeCounter = '4398046511104'; + $id = ['type' => 'id', 'counter' => $largeCounter, 'actor' => '0123456789abcdef', 'actorIndex' => '0']; + $bytes = $port->exIdToBytes($id); + $parsed = $port->exIdFromBytes($bytes); + + truthy($bytes !== [0], '64-bit object id should not encode as the root id'); + same($parsed['type'], 'id', '64-bit object id should parse as a non-root id'); + same($parsed['counter'], $largeCounter, '64-bit object id counter should not truncate on PHP'); + same($parsed['display'], $largeCounter . '@0123456789abcdef', '64-bit object id display should preserve the large counter'); + same($port->exIdDisplay($id), $parsed['display'], 'display formatting should match the parsed 64-bit object id'); + sameArray($port->exIdFromBytes([0]), ['type' => 'root', 'display' => '_root'], 'root id should remain distinguishable from large counters'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust storage column specifications encode id type and deflate bit', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-columns-column-specification-tests-column-spec-encoding', + 'storage::columns::column_specification::tests::column_spec_encoding', + function () use ($port): void { + $scenarios = [ + ['id' => 7, 'type' => 'Group', 'raw' => 112], + ['id' => 0, 'type' => 'Actor', 'raw' => 1], + ['id' => 0, 'type' => 'Integer', 'raw' => 2], + ['id' => 1, 'type' => 'DeltaInteger', 'raw' => 19], + ['id' => 3, 'type' => 'Boolean', 'raw' => 52], + ['id' => 1, 'type' => 'String', 'raw' => 21], + ['id' => 5, 'type' => 'ValueMetadata', 'raw' => 86], + ['id' => 5, 'type' => 'Value', 'raw' => 87], + ]; + + foreach ($scenarios as $index => $scenario) { + $raw = $port->columnSpecEncode($scenario['id'], $scenario['type']); + same($raw, $scenario['raw'], 'column spec scenario ' . ($index + 1) . ' should encode to the upstream raw integer'); + + $decoded = $port->columnSpecDecode($raw); + same($decoded['id'], $scenario['id'], 'column spec scenario ' . ($index + 1) . ' should decode the column id'); + same($decoded['type'], $scenario['type'], 'column spec scenario ' . ($index + 1) . ' should decode the column type'); + same($decoded['deflate'], false, 'column spec scenario ' . ($index + 1) . ' should default to inflated'); + same($decoded['normalized'], $raw, 'column spec scenario ' . ($index + 1) . ' should normalize to the raw value when inflated'); + + $deflated = $port->columnSpecEncode($scenario['id'], $scenario['type'], true); + same($port->columnSpecDecode($deflated)['id'], $scenario['id'], 'deflated column spec should preserve id'); + same($port->columnSpecDecode($deflated)['type'], $scenario['type'], 'deflated column spec should preserve type'); + same($port->columnSpecDecode($deflated)['deflate'], true, 'deflated column spec should expose the deflate flag'); + same($deflated, $scenario['raw'] | 0x08, 'deflated column spec should set bit 3'); + same($port->columnSpecNormalize($deflated), $raw, 'deflated column spec should normalize to inflated raw value'); + same($port->columnSpecInflated($deflated), $raw, 'inflated helper should clear the deflate bit'); + same($port->columnSpecDeflated($raw), $deflated, 'deflated helper should set the deflate bit'); + } + }, + 'rust/automerge/src/storage/columns/column_specification.rs' +); + +$rustMapped( + 'rust sequence tree push appends values at the back', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-push-back', + 'sequence_tree::tests::push_back', + function () use ($port): void { + $tree = $port->sequenceTreeNew(); + foreach ([1, 2, 3, 4, 5, 6, 8, 100] as $value) { + $tree = $port->sequenceTreePush($tree, $value); + } + + same($port->sequenceTreeLen($tree), 8, 'SequenceTree push should update length'); + sameArray($port->sequenceTreeIter($tree), [1, 2, 3, 4, 5, 6, 8, 100], 'SequenceTree push should append in order'); + same($port->sequenceTreeGet($tree, 7), 100, 'SequenceTree get should read the final pushed value'); + }, + 'rust/automerge/src/sequence_tree.rs' +); + +$rustMapped( + 'rust sequence tree insert accepts interior and prefix insertions', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert', + 'sequence_tree::tests::insert', + function () use ($port): void { + $tree = $port->sequenceTreeNew(); + foreach ([0, 1, 0, 0, 0, 3, 4] as $offset => $index) { + $tree = $port->sequenceTreeInsert($tree, $index, $offset + 1); + } + + sameArray($port->sequenceTreeIter($tree), [5, 4, 3, 6, 7, 1, 2], 'SequenceTree insert should match Vec-style indexed insertion order'); + same($port->sequenceTreeLen($tree), 7, 'SequenceTree insert should update length for every insertion'); + }, + 'rust/automerge/src/sequence_tree.rs' +); + +$rustMapped( + 'rust sequence tree repeated book insertions remain iterable', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert-book', + 'sequence_tree::tests::insert_book', + function () use ($port): void { + $tree = $port->sequenceTreeNew(); + for ($i = 0; $i < 100; ++$i) { + $tree = $port->sequenceTreeInsert($tree, $i % 2, $i); + } + + same($port->sequenceTreeLen($tree), 100, 'SequenceTree repeated book insertion should preserve every value'); + same(count($port->sequenceTreeIter($tree)), 100, 'SequenceTree iterator should yield every inserted value'); + }, + 'rust/automerge/src/sequence_tree.rs' +); + +$rustMapped( + 'rust sequence tree indexed insertion matches vector behavior', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-insert-book-vec', + 'sequence_tree::tests::insert_book_vec', + function () use ($port): void { + $tree = $port->sequenceTreeNew(); + $vector = []; + for ($i = 0; $i < 100; ++$i) { + $index = $i % 3; + $tree = $port->sequenceTreeInsert($tree, $index, $i); + array_splice($vector, $index, 0, [$i]); + + sameArray($port->sequenceTreeIter($tree), array_values($vector), 'SequenceTree should match vector insertion after step ' . $i); + } + }, + 'rust/automerge/src/sequence_tree.rs' +); + +$rustMapped( + 'rust sequence tree proptest insert workload matches vector behavior', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-proptest-insert', + 'sequence_tree::tests::proptest_insert', + function () use ($port): void { + $tree = $port->sequenceTreeNew(); + $vector = []; + $indices = [ + 0, 1, 0, 2, 1, 3, 0, 4, 2, 5, + 1, 6, 3, 7, 2, 8, 0, 9, 5, 10, + 4, 11, 6, 12, 8, 13, 7, 14, 9, 15, + 11, 16, 10, 17, 12, 18, 14, 19, 13, 20, + ]; + + foreach ($indices as $step => $i) { + if ($i > count($vector)) { + throw new RuntimeException('SequenceTree insert property fixture contains an out-of-bounds index.'); + } + + $index = $i % 3; + $tree = $port->sequenceTreeInsert($tree, $index, $i); + array_splice($vector, $index, 0, [$i]); + + same(true, $port->sequenceTreeEqualsList($tree, array_values($vector)), 'SequenceTree proptest insert should match vector after step ' . $step); + } + }, + 'rust/automerge/src/sequence_tree.rs' +); + +$rustMapped( + 'rust sequence tree proptest remove workload matches vector behavior', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sequence-tree-tests-proptest-remove', + 'sequence_tree::tests::proptest_remove', + function () use ($port): void { + $tree = $port->sequenceTreeNew(); + $vector = []; + $inserts = [ + 0, 1, 1, 2, 0, 3, 2, 4, 1, 5, + 3, 6, 0, 7, 5, 8, 2, 9, 4, 10, + ]; + $removes = [3, 0, 5, 1, 4, 2, 0, 6, 1, 0]; + + foreach ($inserts as $step => $i) { + if ($i > count($vector)) { + throw new RuntimeException('SequenceTree remove property insert fixture contains an out-of-bounds index.'); + } + + $tree = $port->sequenceTreeInsert($tree, $i, $i); + array_splice($vector, $i, 0, [$i]); + + same(true, $port->sequenceTreeEqualsList($tree, array_values($vector)), 'SequenceTree proptest remove insert phase should match vector after step ' . $step); + } + + foreach ($removes as $step => $i) { + if ($i >= count($vector)) { + throw new RuntimeException('SequenceTree remove property fixture contains an out-of-bounds index.'); + } + + $removed = $port->sequenceTreeRemove($tree, $i); + $expected = array_splice($vector, $i, 1)[0]; + $tree = $removed['tree']; + + same($removed['value'], $expected, 'SequenceTree proptest remove should return vector value after step ' . $step); + same(true, $port->sequenceTreeEqualsList($tree, array_values($vector)), 'SequenceTree proptest remove should match vector after step ' . $step); + } + }, + 'rust/automerge/src/sequence_tree.rs' +); + +$rustMapped( + 'rust columnar boolean encoder round-trips boolean runs', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-encoding-boolean-tests-encode-decode-bools', + 'columnar::encoding::boolean::tests::encode_decode_bools', + function () use ($port): void { + $vectors = [ + [], + [false], + [true], + [false, false, true, true, true, false], + [true, false, true, false, true], + array_fill(0, 99, true), + array_merge(array_fill(0, 64, false), array_fill(0, 35, true)), + ]; + + foreach ($vectors as $values) { + sameArray( + $port->columnarDecodeBooleans($port->columnarEncodeBooleans($values)), + $values, + 'boolean column encoding should round-trip ' . json_encode($values) + ); + } + + same(bin2hex($port->columnarEncodeBooleans([])), '', 'empty boolean column should encode as no bytes'); + same(bin2hex($port->columnarEncodeBooleans([true])), '0001', 'leading true values should encode an initial zero false run'); + same(bin2hex($port->columnarEncodeBooleans([false, false, true, true, true, false])), '020301', 'boolean run counts should be unsigned LEB128 values'); + }, + 'rust/automerge/src/columnar/encoding/boolean.rs' +); + +$rustMapped( + 'rust columnar RLE integer encoder round-trips runs and literals', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-rle-int-round-trip', + 'columnar::column_range::rle::tests::rle_int_round_trip', + function () use ($port): void { + $values = [1, 1, 2, 2, 3, 2, 3, 1, 3]; + $encoded = $port->columnarEncodeRleInts($values); + + same(bin2hex($encoded), '020102027b0302030103', 'RLE integer encoding should match upstream run/literal layout'); + sameArray($port->columnarDecodeRleInts($encoded), $values, 'RLE integer decoder should recover the source values'); + }, + 'rust/automerge/src/columnar/column_range/rle.rs' +); + +$rustMapped( + 'rust columnar RLE integer encoder preserves inserted value position', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-rle-int-insert', + 'columnar::column_range::rle::tests::rle_int_insert', + function () use ($port): void { + $values = [1, 1, 2, 2, 5, 3, 2, 3, 1, 3]; + $encoded = $port->columnarEncodeRleInts($values); + + same(bin2hex($encoded), '020102027a050302030103', 'RLE integer encoding should include the inserted literal at index four'); + sameArray($port->columnarDecodeRleInts($encoded), $values, 'RLE integer decoder should recover the inserted value sequence'); + }, + 'rust/automerge/src/columnar/column_range/rle.rs' +); + +$rustMapped( + 'rust columnar RLE integer splice replaces optional integer ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-splice-ints', + 'columnar::column_range::rle::tests::splice_ints', + function () use ($port): void { + $initial = [1, 1, 2, 2, null, 3, 3, 4]; + $encoded = $port->columnarEncodeRleInts($initial); + + $inserted = $port->columnarSpliceRleInts($encoded, 4, 0, [5]); + sameArray( + $port->columnarDecodeRleInts($inserted), + [1, 1, 2, 2, 5, null, 3, 3, 4], + 'RLE integer splice should insert without deleting' + ); + + $replaced = $port->columnarSpliceRleInts($encoded, 2, 3, [7, null, 8]); + sameArray( + $port->columnarDecodeRleInts($replaced), + [1, 1, 7, null, 8, 3, 3, 4], + 'RLE integer splice should replace a mixed value/null range' + ); + + $deleted = $port->columnarSpliceRleInts($encoded, 0, 2, []); + sameArray( + $port->columnarDecodeRleInts($deleted), + [2, 2, null, 3, 3, 4], + 'RLE integer splice should delete a prefix range' + ); + }, + 'rust/automerge/src/columnar/column_range/rle.rs' +); + +$rustMapped( + 'rust columnar RLE string splice replaces optional string ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-rle-tests-splice-strings', + 'columnar::column_range::rle::tests::splice_strings', + function () use ($port): void { + $initial = ['alpha', 'alpha', null, "\u{03b2}", '', "\u{03b2}"]; + $encoded = $port->columnarEncodeRleStrings($initial); + + same(bin2hex($encoded), '0205616c70686100017d02ceb20002ceb2', 'RLE string encoding should preserve runs, nulls, empty strings, and UTF-8 byte lengths'); + sameArray($port->columnarDecodeRleStrings($encoded), $initial, 'RLE string decoder should recover the source values'); + + $replaced = $port->columnarSpliceRleStrings($encoded, 2, 2, ['omega', null]); + sameArray( + $port->columnarDecodeRleStrings($replaced), + ['alpha', 'alpha', 'omega', null, '', "\u{03b2}"], + 'RLE string splice should replace a mixed null/string range' + ); + + $deleted = $port->columnarSpliceRleStrings($encoded, 0, 2, []); + sameArray( + $port->columnarDecodeRleStrings($deleted), + [null, "\u{03b2}", '', "\u{03b2}"], + 'RLE string splice should delete a prefix range' + ); + + same(bin2hex($port->columnarEncodeRleStrings([null, null])), '', 'all-null RLE string columns should encode as no bytes'); + }, + 'rust/automerge/src/columnar/column_range/rle.rs' +); + +$rustMapped( + 'rust columnar delta encoder round-trips the upstream regression vector', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-bugbug', + 'columnar::column_range::delta::tests::bugbug', + function () use ($port): void { + $values = [6, 5, 8, 9, 10, 11, 12, 13]; + $encoded = $port->columnarEncodeDeltaInts($values); + + same(bin2hex($encoded), '7d067f030501', 'delta encoding should match the upstream regression byte layout'); + sameArray($port->columnarDecodeDeltaInts($encoded), $values, 'delta decoder should recover the regression vector'); + }, + 'rust/automerge/src/columnar/column_range/delta.rs' +); + +$rustMapped( + 'rust columnar delta encoder round-trips optional absolute integers', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-encode-decode-delta', + 'columnar::column_range::delta::tests::encode_decode_delta', + function () use ($port): void { + $vectors = [ + [], + [null, null, null], + [0, 1, 3, null, 10, 10, 11], + [6, 5, 8, 9, 10, 11, 12, 13], + ]; + + foreach ($vectors as $values) { + $encoded = $port->columnarEncodeDeltaInts($values); + $allNull = true; + foreach ($values as $value) { + if ($value !== null) { + $allNull = false; + break; + } + } + $expected = $allNull ? [] : $values; + sameArray( + $port->columnarDecodeDeltaInts($encoded), + $expected, + 'delta column encoding should round-trip ' . json_encode($values) + ); + } + + same(bin2hex($port->columnarEncodeDeltaInts([null, null])), '', 'all-null delta columns should encode as no bytes'); + }, + 'rust/automerge/src/columnar/column_range/delta.rs' +); + +$rustMapped( + 'rust columnar delta splice preserves optional absolute integer order', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-delta-tests-splice-delta', + 'columnar::column_range::delta::tests::splice_delta', + function () use ($port): void { + $initial = [1, 3, 6, null, 8, 8, 10]; + $encoded = $port->columnarEncodeDeltaInts($initial); + + $inserted = $port->columnarSpliceDeltaInts($encoded, 2, 0, [4, 5]); + sameArray( + $port->columnarDecodeDeltaInts($inserted), + [1, 3, 4, 5, 6, null, 8, 8, 10], + 'delta splice should insert absolute values without deleting existing values' + ); + + $replaced = $port->columnarSpliceDeltaInts($encoded, 3, 2, [null, 20]); + sameArray( + $port->columnarDecodeDeltaInts($replaced), + [1, 3, 6, null, 20, 8, 10], + 'delta splice should replace a mixed null/value range' + ); + + $deleted = $port->columnarSpliceDeltaInts($encoded, 0, 2, []); + sameArray( + $port->columnarDecodeDeltaInts($deleted), + [6, null, 8, 8, 10], + 'delta splice should delete a prefix range' + ); + }, + 'rust/automerge/src/columnar/column_range/delta.rs' +); + +$rustMapped( + 'rust columnar OpId-list encoder round-trips grouped operation ids', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-opid-list-tests-encode-decode-opid-list', + 'columnar::column_range::opid_list::tests::encode_decode_opid_list', + function () use ($port): void { + $opid = static fn (int $counter, int $actor): array => ['actor' => $actor, 'counter' => $counter]; + $groups = [ + [$opid(1, 2), $opid(2, 2)], + [], + [$opid(10, 3), $opid(13, 3), $opid(14, 4)], + ]; + $encoded = $port->columnarEncodeOpIdLists($groups); + + same(bin2hex($encoded['bytes']), '7d020003020202037f0402017d080301', 'OpId-list encoding should concatenate num, actor, and counter ranges'); + sameArray($encoded['ranges'], ['num' => [0, 4], 'actor' => [4, 10], 'counter' => [10, 16]], 'OpId-list ranges should split the encoded columns'); + sameArray($port->columnarDecodeOpIdLists($encoded), $groups, 'OpId-list decoder should recover grouped operation ids'); + }, + 'rust/automerge/src/columnar/column_range/opid_list.rs' +); + +$rustMapped( + 'rust columnar OpId-list splice replaces grouped operation ids', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-opid-list-tests-splice-opid-list', + 'columnar::column_range::opid_list::tests::splice_opid_list', + function () use ($port): void { + $opid = static fn (int $counter, int $actor): array => ['actor' => $actor, 'counter' => $counter]; + $initial = [ + [$opid(1, 2), $opid(2, 2)], + [], + [$opid(10, 3)], + ]; + $replacement = [ + [$opid(3, 2)], + [$opid(20, 5), $opid(21, 5)], + ]; + + $spliced = $port->columnarSpliceOpIdLists( + $port->columnarEncodeOpIdLists($initial), + 1, + 1, + $replacement + ); + + sameArray( + $port->columnarDecodeOpIdLists($spliced), + [ + [$opid(1, 2), $opid(2, 2)], + [$opid(3, 2)], + [$opid(20, 5), $opid(21, 5)], + [$opid(10, 3)], + ], + 'OpId-list splice should apply Vec::splice-style group replacement' + ); + }, + 'rust/automerge/src/columnar/column_range/opid_list.rs' +); + +$rustMapped( + 'rust storage change op columns round-trip mixed operations', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-change-change-op-columns-tests-test-encode-decode-change-ops', + 'storage::change::change_op_columns::tests::test_encode_decode_change_ops', + function () use ($port): void { + $op = static fn (int $counter, int $actor): array => ['counter' => $counter, 'actor' => $actor]; + $ops = [ + [ + 'obj' => ['type' => 'root'], + 'key' => ['type' => 'map', 'value' => 'title'], + 'val' => ['type' => 'scalar', 'datatype' => 'str', 'value' => 'Hello'], + 'pred' => [], + 'action' => 'put', + 'insert' => false, + 'expand' => false, + 'markName' => null, + ], + [ + 'obj' => $op(1, 0), + 'key' => ['type' => 'seq', 'elem' => $op(2, 0)], + 'val' => ['type' => 'object', 'objectType' => 'map'], + 'pred' => [], + 'action' => 'make', + 'insert' => true, + 'expand' => false, + 'markName' => null, + ], + [ + 'obj' => $op(1, 0), + 'key' => ['type' => 'map', 'value' => 'title'], + 'val' => null, + 'pred' => [$op(1, 0), $op(2, 0)], + 'action' => 'delete', + 'insert' => false, + 'expand' => false, + 'markName' => null, + ], + [ + 'obj' => $op(3, 0), + 'key' => ['type' => 'seq', 'elem' => $op(4, 0)], + 'val' => ['type' => 'scalar', 'datatype' => 'bool', 'value' => true], + 'pred' => [], + 'action' => 'markBegin', + 'insert' => false, + 'expand' => true, + 'markName' => 'strong', + ], + ]; + + $encoded = $port->storageChangeEncodeChangeOps($ops); + same($encoded['rowCount'], count($ops), 'change op column encoder should record the row count'); + sameArray($port->storageChangeDecodeChangeOps($encoded), $ops, 'change op column decoder should reconstruct mixed operation rows'); + }, + 'rust/automerge/src/storage/change/change_op_columns.rs' +); + +$rustMapped( + 'rust op_set2 ValueMeta accumulator tracks raw value offsets', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-meta-tests-column-data-meta-group', + 'op_set2::meta::tests::column_data_meta_group', + function () use ($port): void { + $metas = [ + 1, + 6 + (30 << 4), + 6 + (10 << 4), + 3, + 4, + ]; + + $rows = $port->opSet2ValueMetaWithAccumulator($metas); + sameArray( + $rows[0], + ['value' => 1, 'typeCode' => 1, 'length' => 0, 'acc' => 0], + 'first ValueMeta row should start at raw offset zero' + ); + sameArray( + $rows[1], + ['value' => 486, 'typeCode' => 6, 'length' => 30, 'acc' => 0], + 'second ValueMeta string row should still start at raw offset zero' + ); + sameArray( + $rows[2], + ['value' => 166, 'typeCode' => 6, 'length' => 10, 'acc' => 30], + 'third ValueMeta string row should see the prior string length' + ); + sameArray( + $rows[3], + ['value' => 3, 'typeCode' => 3, 'length' => 0, 'acc' => 40], + 'fourth ValueMeta row should accumulate both previous string byte lengths' + ); + + $advanced = $port->opSet2ValueMetaWithAccumulator($metas, 3); + sameArray( + $advanced[0], + ['value' => 3, 'typeCode' => 3, 'length' => 0, 'acc' => 40], + 'advance_by(3) should resume with the same accumulated raw offset' + ); + + $range = $port->opSet2ValueMetaWithAccumulator($metas, 3, 2); + sameArray( + $range[0], + ['value' => 3, 'typeCode' => 3, 'length' => 0, 'acc' => 40], + 'iter_range(3..5) should preserve the global raw offset accumulator' + ); + }, + 'rust/automerge/src/op_set2/meta.rs' +); + +$rustMapped( + 'rust op_set2 object id iterator seeks exact and missing ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-op-iter-tests-obj-id-iter-seek', + 'op_set2::op_set::op_iter::tests::obj_id_iter_seek', + function () use ($port): void { + $root = ['type' => 'root']; + $op = static fn (int $counter, int $actor): array => ['type' => 'op', 'counter' => $counter, 'actor' => $actor]; + $o11 = $op(1, 1); + $o12 = $op(1, 2); + $o21 = $op(2, 1); + $o22 = $op(2, 2); + $o31 = $op(3, 1); + $o32 = $op(3, 2); + $objects = [ + $root, + $root, + $root, + $root, + $o11, + $o11, + $o12, + $o21, + $o21, + $o21, + $o22, + $o22, + $o32, + $o32, + ]; + + $assertSeek = static function (array $target, array $range, array $expectedValues, string $label) use ($port, $objects): void { + $seek = $port->opSet2ObjectIdSeek($objects, $target); + sameArray($seek['range'], $range, $label . ' should return the expected range'); + same($seek['pos'], $range[0], $label . ' should position the iterator at the range start'); + sameArray($seek['values'], $expectedValues, $label . ' should read only the matching object ids'); + }; + + $normalizedRoot = ['type' => 'root', 'counter' => 0, 'actor' => 0]; + $assertSeek($root, [0, 4], [$normalizedRoot, $normalizedRoot, $normalizedRoot, $normalizedRoot], 'root seek'); + $assertSeek($o11, [4, 6], [$o11, $o11], '1@1 seek'); + $assertSeek($o12, [6, 7], [$o12], '1@2 seek'); + $assertSeek($o21, [7, 10], [$o21, $o21, $o21], '2@1 seek'); + $assertSeek($o22, [10, 12], [$o22, $o22], '2@2 seek'); + $assertSeek($o31, [12, 12], [], 'missing 3@1 seek'); + $assertSeek($o32, [12, 14], [$o32, $o32], '3@2 seek'); + + sameArray($port->opSet2ObjectIdSeek($objects, $o11)['range'], [4, 6], 'odd seek should find 1@1 without reading prior values'); + sameArray($port->opSet2ObjectIdSeek($objects, $o21)['range'], [7, 10], 'odd seek should find 2@1 without reading prior values'); + sameArray($port->opSet2ObjectIdSeek($objects, $o31)['range'], [12, 12], 'odd seek should lower-bound missing 3@1'); + sameArray($port->opSet2ObjectIdSeek($objects, $o12)['range'], [6, 7], 'even seek should find 1@2'); + sameArray($port->opSet2ObjectIdSeek($objects, $o22)['range'], [10, 12], 'even seek should find 2@2'); + sameArray($port->opSet2ObjectIdSeek($objects, $o32)['range'], [12, 14], 'even seek should find 3@2'); + }, + 'rust/automerge/src/op_set2/op_set/op_iter.rs' +); + +$rustMapped( + 'rust op_set2 skip iterator selects op ids by counter and successor ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-op-iter-tests-skip-op-ids', + 'op_set2::op_set::op_iter::tests::skip_op_ids', + function () use ($port): void { + $op = static fn (int $counter, int $actor): array => ['counter' => $counter, 'actor' => $actor]; + + $ids = [ + $op(1, 0), + $op(2, 0), + $op(3, 0), + $op(12, 0), + $op(13, 0), + $op(4, 0), + $op(5, 0), + $op(10, 1), + $op(10, 3), + $op(6, 0), + $op(7, 0), + $op(8, 0), + $op(9, 0), + ]; + + $successors = [ + [], + [], + [$op(12, 0)], + [$op(13, 0)], + [], + [], + [$op(10, 0), $op(10, 1), $op(10, 2), $op(10, 3)], + [], + [], + [], + [$op(11, 0)], + [], + [], + ]; + + sameArray( + $port->opSet2OperationIdsInCounterRange($ids, 3, 5), + [$op(3, 0), $op(4, 0)], + 'CtrWalker 3..5 should select op ids 3@0 and 4@0 in op-set order' + ); + sameArray( + $port->opSet2OperationIdsInCounterRange($ids, 0, 4), + [$op(1, 0), $op(2, 0), $op(3, 0)], + 'CtrWalker 0..4 should select existing ids below counter 4' + ); + sameArray( + $port->opSet2OperationIdsInCounterRange($ids, 9, 20), + [$op(12, 0), $op(13, 0), $op(10, 1), $op(10, 3), $op(9, 0)], + 'CtrWalker 9..20 should select high-counter operation ids in storage order' + ); + + sameArray( + $port->opSet2OperationIdsWithSuccessorsInCounterRange($ids, $successors, 10, 12), + [$op(5, 0), $op(7, 0)], + 'SuccWalker 10..12 should select operations with delete/overwrite successors in that counter range' + ); + sameArray( + $port->opSet2OperationIdsWithSuccessorsInCounterRange($ids, $successors, 10, 99), + [$op(3, 0), $op(12, 0), $op(5, 0), $op(7, 0)], + 'SuccWalker 10..99 should include operations superseded by later counters' + ); + sameArray( + $port->opSet2OperationIdsWithSuccessorsInCounterRange($ids, $successors, 10, 13), + [$op(3, 0), $op(5, 0), $op(7, 0)], + 'SuccWalker 10..13 should exclude op 12@0 because its successor counter is 13' + ); + sameArray( + $port->opSet2OperationIdsWithSuccessorsInCounterRange($ids, $successors, 0, 99), + [$op(3, 0), $op(12, 0), $op(5, 0), $op(7, 0)], + 'SuccWalker 0..99 should preserve op-set order while scanning all successor counters' + ); + sameArray( + $port->opSet2IterCounterRange($ids, $successors, 9, 99), + [$op(3, 0), $op(12, 0), $op(13, 0), $op(5, 0), $op(10, 1), $op(10, 3), $op(7, 0), $op(9, 0)], + 'iter_ctr_range 9..99 should combine own-counter and successor-counter hits without duplication' + ); + }, + 'rust/automerge/src/op_set2/op_set/op_iter.rs' +); + +$rustMapped( + 'rust op_set2 mixed workload survives save load and change replay', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-suspend-resume-op-set-iter', + 'op_set2::op_set::tests::suspend_resume_op_set_iter', + function () use ($port): void { + $largeText = str_repeat('Ab9', 333) . 'Z'; + $doc = $port->init('aaaaaa'); + + foreach (['aaa_int', 'mid_int', 'zzz_int'] as $key) { + $doc = $port->set($doc, $key, 123); + } + foreach (['aaa_str', 'mid_str', 'zzz_str'] as $key) { + $doc = $port->set($doc, $key, $port->immutableString('abc')); + } + + $doc = $port->set($doc, 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, $largeText); + $doc = $port->splice($doc, 'text', 100, 100, ''); + $expectedText = substr($largeText, 0, 100) . substr($largeText, 200); + + $doc = $port->set($doc, 'a_large', $port->immutableString($largeText)); + $doc = $port->set($doc, 'z_large', $port->immutableString($largeText)); + $doc = $port->set($doc, 'a_large', new Counter(100)); + $doc = $port->set($doc, 'z_large', new Counter(200)); + for ($i = 0; $i < 100; ++$i) { + $doc = $port->incrementCounter($doc, ['a_large']); + $doc = $port->incrementCounter($doc, ['z_large']); + } + + $materialized = $doc->toArray(); + same($materialized['text'] ?? null, $expectedText, 'mixed op-set workload should preserve text after middle deletion'); + truthy(($materialized['a_large'] ?? null) instanceof Counter, 'a_large should materialize as a counter'); + truthy(($materialized['z_large'] ?? null) instanceof Counter, 'z_large should materialize as a counter'); + same($materialized['a_large']->value(), 200, 'a_large counter should include all increments'); + same($materialized['z_large']->value(), 300, 'z_large counter should include all increments'); + + $loaded = $port->load($port->save($doc), 'bbbbbb'); + same(json_encode($loaded->toArray()), json_encode($doc->toArray()), 'mixed op-set workload should survive save/load materialization'); + sameArray($port->getHeads($loaded), $port->getHeads($doc), 'mixed op-set workload should preserve heads after save/load'); + + $replayed = $port->applyChanges($port->init('cccccc'), $port->getAllChanges($doc)); + same(json_encode($replayed->toArray()), json_encode($doc->toArray()), 'mixed op-set workload should replay from recorded changes'); + sameArray($port->getHeads($replayed), $port->getHeads($doc), 'mixed op-set workload replay should preserve heads'); + truthy(count($port->getChangesMetaSince($doc, [])) >= 212, 'mixed op-set workload should record the large operation stream'); + }, + 'rust/automerge/src/op_set2/op_set.rs' +); + +$rustMapped( + 'rust op_set2 mark index encodes start and end operation ids', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-mark-index-tests-column-data-delta-simple', + 'op_set2::op_set::mark_index::tests::column_data_delta_simple', + function () use ($port): void { + $start = ['type' => 'start', 'counter' => 7, 'actor' => 3]; + $end = ['type' => 'end', 'counter' => 9, 'actor' => 3]; + + same( + $port->opSet2EncodeMarkIndexValue($start), + 12884901895, + 'MarkIndexValue::Start should pack actor in the high 32 bits and counter in the low 32 bits' + ); + same( + $port->opSet2EncodeMarkIndexValue($end), + -12884901897, + 'MarkIndexValue::End should use the negative packed operation id' + ); + sameArray( + $port->opSet2DecodeMarkIndexValue(12884901895), + $start, + 'positive mark-index values should decode as Start op ids' + ); + sameArray( + $port->opSet2DecodeMarkIndexValue(-12884901897), + $end, + 'negative mark-index values should decode as End op ids' + ); + + $values = [ + $start, + $start, + null, + $end, + ['type' => 'start', 'counter' => 12, 'actor' => 1], + ]; + $encoded = $port->opSet2EncodeMarkIndexColumn($values); + sameArray( + $port->opSet2DecodeMarkIndexColumn($encoded), + $values, + 'MarkIndex column should round-trip start/end values and null spans through signed RLE integers' + ); + }, + 'rust/automerge/src/op_set2/op_set/mark_index.rs' +); + +$rustMapped( + 'rust op_set2 column data iterates saved operation rows', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-basic-iteration', + 'op_set2::op_set::tests::column_data_basic_iteration', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, 'hello'); + $doc = $port->set($doc, 'key', 'value'); + $doc = $port->set($doc, 'key2', 'value2'); + $doc = $port->delete($doc, 'key2'); + + $liveRows = $port->opSet2DecodeOperationColumns($port->opSet2EncodeOperationColumns($doc)); + $loaded = $port->load($port->save($doc), 'aaaaaa'); + $loadedRows = $port->opSet2DecodeOperationColumns($port->opSet2EncodeOperationColumns($loaded)); + + sameArray($loadedRows, $liveRows, 'loaded document operation columns should iterate the same rows as the live op set'); + sameArray( + array_column($liveRows, 'action'), + ['set', 'splice', 'set', 'set', 'delete'], + 'basic op set iteration should preserve text creation splice put put delete order' + ); + sameArray( + array_column($liveRows, 'pos'), + [0, 1, 2, 3, 4], + 'operation column positions should be dense and stable' + ); + sameArray($loaded->toArray(), ['text' => 'hello', 'key' => 'value'], 'loaded document should materialize the same visible map'); + }, + 'rust/automerge/src/op_set2/op_set.rs' +); + +$rustMapped( + 'rust op_set2 column data iter_range scopes rows to one object', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-iter-range', + 'op_set2::op_set::tests::column_data_iter_range', + function () use ($port): void { + $op = static fn (int $counter, int $actor): array => ['counter' => $counter, 'actor' => $actor]; + $rows = [ + [ + 'id' => $op(1, 1), + 'obj' => 'root', + 'action' => 'makeMap', + 'value' => null, + 'key' => 'key', + 'insert' => false, + 'succs' => [$op(5, 1), $op(6, 1), $op(10, 1)], + ], + [ + 'id' => $op(2, 1), + 'obj' => 'root', + 'action' => 'set', + 'value' => 'value1', + 'key' => 'key1', + 'insert' => false, + 'succs' => [], + ], + [ + 'id' => $op(3, 1), + 'obj' => 'root', + 'action' => 'set', + 'value' => 'value2', + 'key' => 'key2', + 'insert' => false, + 'succs' => [$op(6, 1)], + ], + [ + 'id' => $op(4, 1), + 'obj' => '1@1', + 'action' => 'set', + 'value' => 'inner_value1', + 'key' => 'inner_key1', + 'insert' => false, + 'succs' => [$op(7, 1), $op(8, 2), $op(9, 1)], + ], + [ + 'id' => $op(5, 1), + 'obj' => '1@1', + 'action' => 'set', + 'value' => 'inner_value2', + 'key' => 'inner_key2', + 'insert' => false, + 'succs' => [], + ], + ]; + + $range = $port->opSet2OperationRowsForObject($rows, '1@1'); + sameArray($range['range'], [3, 5], 'scope_to_obj should locate the contiguous range for object 1@1'); + same($range['pos'], 3, 'iter_range should start at the object range position'); + sameArray($range['rows'], [$rows[3], $rows[4]], 'iter_range should yield only operations owned by object 1@1'); + }, + 'rust/automerge/src/op_set2/op_set.rs' +); + +$rustMapped( + 'rust op_set2 column data op iterators group visible and top rows', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-op-set-tests-column-data-op-iterators', + 'op_set2::op_set::tests::column_data_op_iterators', + function () use ($port): void { + $op = static fn (int $counter, int $actor): array => ['counter' => $counter, 'actor' => $actor]; + $rows = [ + ['id' => $op(1, 1), 'obj' => 'root', 'action' => 'makeMap', 'value' => null, 'key' => 'map', 'insert' => false, 'succs' => []], + ['id' => $op(2, 1), 'obj' => 'root', 'action' => 'makeMap', 'value' => null, 'key' => 'list', 'insert' => false, 'succs' => []], + ['id' => $op(3, 1), 'obj' => '1@1', 'action' => 'set', 'value' => 'value1', 'key' => 'key1', 'insert' => false, 'succs' => []], + ['id' => $op(4, 1), 'obj' => '1@1', 'action' => 'set', 'value' => 'value2a', 'key' => 'key2', 'insert' => false, 'succs' => []], + ['id' => $op(4, 2), 'obj' => '1@1', 'action' => 'set', 'value' => 'value2b', 'key' => 'key2', 'insert' => false, 'succs' => [$op(5, 2)]], + ['id' => $op(5, 2), 'obj' => '1@1', 'action' => 'set', 'value' => 'value2c', 'key' => 'key2', 'insert' => false, 'succs' => []], + ['id' => $op(6, 1), 'obj' => '1@1', 'action' => 'set', 'value' => 'value3a', 'key' => 'key3', 'insert' => false, 'succs' => [$op(7, 2)]], + ['id' => $op(7, 2), 'obj' => '1@1', 'action' => 'set', 'value' => 'value3b', 'key' => 'key3', 'insert' => false, 'succs' => []], + ['id' => $op(8, 1), 'obj' => '2@1', 'action' => 'set', 'value' => 'a', 'key' => '_head', 'insert' => true, 'succs' => []], + ['id' => $op(9, 1), 'obj' => '2@1', 'action' => 'set', 'value' => 'b', 'key' => '8@1', 'insert' => true, 'succs' => []], + ]; + + sameArray( + $port->opSet2OperationRowsForObject($rows, '1@1')['rows'], + array_slice($rows, 2, 6), + 'iter_obj should yield every row belonging to object 1@1' + ); + sameArray( + $port->opSet2OperationRowsForProperty($rows, '1@1', 'key2')['rows'], + array_slice($rows, 3, 3), + 'prop_range should yield all rows for key2 on object 1@1' + ); + sameArray( + $port->opSet2TopOperationRows($rows, '1@1'), + [$rows[2], $rows[5], $rows[7]], + 'top_ops should keep the last row in each object-key group' + ); + sameArray( + $port->opSet2OperationRowsGroupedByKey($rows, '1@1'), + [[$rows[2]], array_slice($rows, 3, 3), array_slice($rows, 6, 2)], + 'key_ops should group contiguous object rows by key' + ); + sameArray( + $port->opSet2OperationRowsGroupedByKey($rows, '1@1', true), + [[$rows[2]], [$rows[3], $rows[5]], [$rows[7]]], + 'visible_slow key_ops should remove rows that have successors while retaining same-key conflicts' + ); + sameArray( + $port->opSet2TopOperationRows($rows, '1@1', true), + [$rows[2], $rows[5], $rows[7]], + 'visible_slow top_ops should keep the last visible row in each object-key group' + ); + }, + 'rust/automerge/src/op_set2/op_set.rs' +); + +$rustMapped( + 'rust op_set2 parents reports invisible deleted list object parent', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-parents-tests-test-invisible-parents', + 'op_set2::parents::tests::test_invisible_parents', + function () use ($port): void { + $rows = [ + [ + 'id' => '1@1', + 'obj' => 'root', + 'action' => 'makeList', + 'key' => 'list', + 'objectType' => 'list', + 'insert' => false, + 'succs' => [], + ], + [ + 'id' => '2@1', + 'obj' => '1@1', + 'action' => 'makeMap', + 'key' => 0, + 'objectType' => 'map', + 'insert' => true, + 'succs' => ['5@1'], + ], + [ + 'id' => '3@1', + 'obj' => '1@1', + 'action' => 'makeMap', + 'key' => 1, + 'objectType' => 'map', + 'insert' => true, + 'succs' => [], + ], + [ + 'id' => '4@1', + 'obj' => '2@1', + 'action' => 'set', + 'key' => 'key', + 'value' => 'value', + 'insert' => false, + 'succs' => [], + ], + [ + 'id' => '5@1', + 'obj' => '1@1', + 'action' => 'delete', + 'key' => 0, + 'pred' => ['2@1'], + 'insert' => false, + 'succs' => [], + ], + ]; + + sameArray( + $port->opSet2ParentPath($rows, '2@1'), + [ + [ + 'obj' => '_root', + 'prop' => ['type' => 'map', 'value' => 'list'], + 'typ' => 'map', + 'visible' => true, + ], + [ + 'obj' => '1@1', + 'prop' => ['type' => 'seq', 'value' => 0], + 'typ' => 'list', + 'visible' => false, + ], + ], + 'parents for a deleted list object should retain the root list path and mark the deleted sequence parent invisible' + ); + }, + 'rust/automerge/src/op_set2/parents.rs' +); + +$rustMapped( + 'rust op_set2 batch apply matches iterative map changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-map-batch-apply', + 'op_set2::change::batch::tests::map_batch_apply', + function () use ($port): void { + $base = $port->from(['map' => ['key1' => 'val1', 'key2' => 'val2']], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + + $doc2 = $port->clone($base, 'bbbbbb'); + foreach (['val3a', 'val3a.1', 'val3a.2'] as $value) { + $doc2 = $port->setNested($doc2, ['map', 'key1'], $value); + } + $doc2 = $port->deleteNested($doc2, ['map', 'key2']); + $doc2 = $port->setNested($doc2, ['map', 'key3'], 'val4a'); + $doc2 = $port->setNested($doc2, ['map', 'map2'], []); + $doc2 = $port->setNested($doc2, ['map', 'map2', 'key1'], 'val5a'); + + $doc1 = $port->setNested($base, ['map', 'map3'], []); + $doc1 = $port->setNested($doc1, ['map', 'key1'], 'val6a'); + $doc1 = $port->setNested($doc1, ['map', 'map3', 'key1'], 'val7a'); + + $doc3 = $port->clone($doc1, 'aaaaaa'); + $doc3 = $port->setNested($doc3, ['map', 'key1'], 'val3b'); + $doc3 = $port->setNested($doc3, ['map', 'key3'], 'val4b'); + + $changes = array_merge($port->getChanges($baseView, $doc2), $port->getChanges($baseView, $doc3)); + $iterative = $doc1; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($doc1, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched map apply should materialize the same state as iterative apply'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched map apply should retain the same heads as iterative apply'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply matches iterative list changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-list-batch-apply', + 'op_set2::change::batch::tests::list_batch_apply', + function () use ($port): void { + $base = $port->from(['list' => ['val1', 'val2', 'val3']], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + + $doc2 = $port->clone($base, 'bbbbbb'); + foreach ([[1, 'val4a'], [1, 'val4b'], [2, 'val4c'], [0, 'val4d'], [0, 'val4e'], [0, 'val4f']] as [$index, $value]) { + $doc2 = $port->insertListElements($doc2, 'list', $index, [$value]); + } + + $doc3 = $port->clone($base, 'aaaaaa'); + foreach ([[1, 'val5a'], [1, 'val5b'], [2, 'val5c'], [3, 'val5d'], [1, 'val5e'], [1, 'val5f'], [0, 'val5g'], [0, 'val5h']] as [$index, $value]) { + $doc3 = $port->insertListElements($doc3, 'list', $index, [$value]); + } + + $changes = array_merge($port->getChanges($baseView, $doc2), $port->getChanges($baseView, $doc3)); + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched list apply should materialize the same state as iterative apply'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched list apply should retain the same heads as iterative apply'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply matches iterative text changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-text-batch-apply', + 'op_set2::change::batch::tests::text_batch_apply', + function () use ($port): void { + $base = $port->set($port->init('cccccc'), 'text', new TextValue()); + $base = $port->splice($base, 'text', 0, 0, 'the quick fox jumped over the lazy dog'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + + $doc2 = $port->clone($base, 'bbbbbb'); + $doc2 = $port->splice($doc2, 'text', 0, 0, 'abc'); + + $doc3 = $port->clone($base, 'aaaaaa'); + $doc3 = $port->splice($doc3, 'text', 3, 1, 'aalks'); + + $changes = array_merge($port->getChanges($baseView, $doc2), $port->getChanges($baseView, $doc3)); + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched text apply should materialize the same state as iterative apply'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched text apply should retain the same heads as iterative apply'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply handles many concurrent list puts', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-put-batch-apply', + 'op_set2::change::batch::tests::multi_put_batch_apply', + function () use ($port): void { + $base = $port->from(['list' => ['a', 'b', 'c']], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $merged = $port->clone($base, '999999'); + + foreach (['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff'] as $offset => $actor) { + $tmp = $port->clone($base, $actor); + $tmp = $port->setListElement($tmp, 'list', 0, $offset); + $merged = $port->mergeDocuments($merged, $tmp); + } + + $changes = $port->getChanges($baseView, $merged); + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched concurrent list puts should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched concurrent list puts should retain iterative heads'); + sameArray($batched->toArray(), $merged->toArray(), 'batched concurrent list puts should match the merged source document'); + same(count($port->getListElementConflicts($batched, 'list', 0) ?? []), count($port->getListElementConflicts($iterative, 'list', 0) ?? []), 'batched list puts should retain the iterative conflict count'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply handles many concurrent list inserts', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-insert-batch-apply', + 'op_set2::change::batch::tests::multi_insert_batch_apply', + function () use ($port): void { + $base = $port->from(['list' => ['a', 'b', 'c']], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $merged = $port->clone($base, '999999'); + + foreach (['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff'] as $offset => $actor) { + $tmp = $port->clone($base, $actor); + $tmp = $port->insertListElements($tmp, 'list', 1, [$offset]); + $merged = $port->mergeDocuments($merged, $tmp); + } + + $changes = $port->getChanges($baseView, $merged); + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched concurrent list inserts should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched concurrent list inserts should retain iterative heads'); + same(count($batched->toArray()['list']), 8, 'batched concurrent list inserts should preserve all inserted elements'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply handles repeated concurrent list updates', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-multi-update-batch-apply', + 'op_set2::change::batch::tests::multi_update_batch_apply', + function () use ($port): void { + $base = $port->from(['list' => ['a', 'b', 'c']], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $merged = $port->clone($base, '999999'); + + foreach (['aaaaaa', 'bbbbbb', 'dddddd'] as $offset => $actor) { + $tmp = $port->clone($base, $actor); + $tmp = $port->setListElement($tmp, 'list', 2, $offset); + $merged = $port->mergeDocuments($merged, $tmp); + } + + $changes = $port->getChanges($baseView, $merged); + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched repeated list updates should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched repeated list updates should retain iterative heads'); + sameArray($batched->toArray(), $merged->toArray(), 'batched repeated list updates should match the merged source document'); + same(count($port->getListElementConflicts($batched, 'list', 2) ?? []), count($port->getListElementConflicts($iterative, 'list', 2) ?? []), 'batched repeated list updates should retain the iterative conflict count'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply preserves map key conflicts', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-map-key-conflict', + 'op_set2::change::batch::tests::map_key_conflict', + function () use ($port): void { + $doc = $port->set($port->init('cccccc'), 'key1', 'value1'); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff']; + + for ($cycle = 0; $cycle < 3; ++$cycle) { + $baseHeads = $port->getHeads($doc); + $baseView = $port->view($doc, $baseHeads); + $changes = []; + foreach ($actors as $offset => $actor) { + $branch = $port->clone($doc, $actor); + for ($step = 0; $step < 4; ++$step) { + $key = 'key' . (($offset + $step) % 4); + $branch = $port->set($branch, $key, 'value' . $cycle . '-' . $offset . '-' . $step); + } + $branch = $port->delete($branch, 'key' . (($offset + 1) % 4)); + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $doc; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($doc, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched map key conflict cycle ' . $cycle . ' should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched map key conflict cycle ' . $cycle . ' should retain iterative heads'); + $doc = $batched; + } + + truthy($port->getConflicts($doc, 'key0') !== null || $port->getConflicts($doc, 'key1') !== null, 'map key conflict workload should retain visible conflicts'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch apply preserves list element conflicts', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-list-element-conflict', + 'op_set2::change::batch::tests::list_element_conflict', + function () use ($port): void { + $doc = $port->from(['list' => ['_', '_', '_']], 'cccccc'); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff', '111111']; + + for ($cycle = 0; $cycle < 3; ++$cycle) { + $baseHeads = $port->getHeads($doc); + $baseView = $port->view($doc, $baseHeads); + $changes = []; + foreach ($actors as $offset => $actor) { + $branch = $port->clone($doc, $actor); + for ($step = 0; $step < 3; ++$step) { + $index = ($offset + $step) % 3; + $branch = $port->setListElement($branch, 'list', $index, 'value' . $cycle . '-' . $offset . '-' . $step); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $doc; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($doc, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched list element conflict cycle ' . $cycle . ' should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched list element conflict cycle ' . $cycle . ' should retain iterative heads'); + $doc = $batched; + } + + truthy($port->getListElementConflicts($doc, 'list', 0) !== null || $port->getListElementConflicts($doc, 'list', 1) !== null, 'list element conflict workload should retain visible conflicts'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 isolation conflict workload applies without index corruption', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-conflicts-with-isolate', + 'op_set2::change::batch::tests::conflicts_with_isolate', + function () use ($port): void { + $doc = $port->from(['list' => ['_'], 'map' => ['key' => '_']], 'cccccc'); + $docs = [ + $port->clone($doc, 'aaaaaa'), + $port->clone($doc, 'bbbbbb'), + $port->clone($doc, 'dddddd'), + ]; + $heads = [$port->getHeads($doc)]; + $operationPlan = [ + [false, true, false], + [true, false, false], + [false, false, true], + ]; + + foreach ($operationPlan as $cycle => $deletePlan) { + $changes = []; + foreach ($docs as $offset => $branch) { + $branch = $port->mergeDocuments($branch, $doc); + $branchStart = $port->view($branch, $port->getHeads($branch)); + $head = $heads[($cycle + $offset) % count($heads)]; + $isolated = $port->isolate($branch, $head); + + for ($step = 0; $step < 3; ++$step) { + $visible = $port->isolatedDocument($isolated)->toArray(); + $listLength = is_array($visible['list'] ?? null) ? count($visible['list']) : 0; + if ($deletePlan[$step]) { + if ($listLength > 0) { + $isolated = $port->deleteListElementsInIsolation($isolated, 'list', 0); + } + $isolated = $port->deleteNestedInIsolation($isolated, ['map', 'key']); + continue; + } + + $value = 'value' . $cycle . '-' . $offset . '-' . $step; + if ($listLength > 0) { + $isolated = $port->setListElementInIsolation($isolated, 'list', 0, $value); + } else { + $isolated = $port->insertListElementsInIsolation($isolated, 'list', 0, [$value]); + } + $isolated = $port->setNestedInIsolation($isolated, ['map', 'key'], $value); + } + + $branch = $port->integrate($isolated); + $docs[$offset] = $branch; + $changes = array_merge($changes, $port->getChanges($branchStart, $branch)); + + $materialized = $branch->toArray(); + truthy(is_array($materialized['list'] ?? null), 'isolated conflict branch should retain a list container'); + truthy(is_array($materialized['map'] ?? null), 'isolated conflict branch should retain a map container'); + } + + $iterative = $doc; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($doc, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'isolated conflict cycle ' . $cycle . ' should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'isolated conflict cycle ' . $cycle . ' should retain iterative heads'); + truthy(is_array($batched->toArray()['list'] ?? null), 'batched isolated conflict cycle should retain the list container'); + truthy(is_array($batched->toArray()['map'] ?? null), 'batched isolated conflict cycle should retain the map container'); + + $doc = $batched; + $heads[] = $port->getHeads($doc); + } + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 fuzz batch list apply matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-list-apply', + 'op_set2::change::batch::tests::fuzz_batch_list_apply', + function () use ($port): void { + $base = $port->from(['list' => ['a', 'b', 'c']], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff', '111111']; + $changes = []; + $value = 0; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + for ($step = 0; $step < 4; ++$step) { + $list = $branch->toArray()['list']; + $position = ($offset + $step) % max(1, count($list)); + ++$value; + $branch = $port->insertListElements($branch, 'list', $position, [$value]); + } + for ($step = 0; $step < 3; ++$step) { + $list = $branch->toArray()['list']; + $position = ($offset + ($step * 2)) % count($list); + ++$value; + $branch = $port->setListElement($branch, 'list', $position, $value); + } + if ($offset % 2 === 0) { + $list = $branch->toArray()['list']; + $position = ($offset + 1) % count($list); + $branch = $port->deleteListElements($branch, 'list', $position); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'fuzzed batched list apply should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'fuzzed batched list apply should retain iterative heads'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 fuzz batch nested map apply matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map1-apply', + 'op_set2::change::batch::tests::fuzz_batch_map1_apply', + function () use ($port): void { + $base = $port->from(['map1' => ['map2' => ['map3' => []]]], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff', '111111']; + $mapPaths = [['map1'], ['map1', 'map2'], ['map1', 'map2', 'map3']]; + $changes = []; + $value = 0; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + $written = []; + for ($step = 0; $step < 6; ++$step) { + $path = $mapPaths[($offset + $step) % count($mapPaths)]; + $key = 'key' . (($offset * 7 + $step) % 20); + ++$value; + $fullPath = array_merge($path, [$key]); + $branch = $port->setNested($branch, $fullPath, $value); + $written[] = $fullPath; + } + if ($offset % 2 === 0) { + $branch = $port->deleteNested($branch, $written[1]); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'fuzzed batched nested map apply should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'fuzzed batched nested map apply should retain iterative heads'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 fuzz batch sparse nested map apply matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map2-apply', + 'op_set2::change::batch::tests::fuzz_batch_map2_apply', + function () use ($port): void { + $base = $port->from(['map1' => ['map2' => ['map3' => []]]], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff', '111111']; + $mapPaths = [['map1'], ['map1', 'map2'], ['map1', 'map2', 'map3']]; + $changes = []; + $value = 0; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + $written = []; + for ($step = 0; $step < 6; ++$step) { + $path = $mapPaths[($offset + $step) % count($mapPaths)]; + $key = 'key' . (($offset * 101 + $step * 17) % 1000); + ++$value; + $fullPath = array_merge($path, [$key]); + $branch = $port->setNested($branch, $fullPath, $value); + $written[] = $fullPath; + } + if ($offset % 2 === 1) { + $branch = $port->deleteNested($branch, $written[2]); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'fuzzed batched sparse nested map apply should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'fuzzed batched sparse nested map apply should retain iterative heads'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 fuzz batch nested counter map apply matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-fuzz-batch-map-counter-apply', + 'op_set2::change::batch::tests::fuzz_batch_map_counter_apply', + function () use ($port): void { + $base = $port->from([ + 'map1' => [ + 'key1' => new Counter(35), + 'map2' => [ + 'key1' => new Counter(102), + 'map3' => [ + 'key1' => new Counter(1030), + ], + ], + ], + ], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff', '111111']; + $counterPaths = [['map1', 'key1'], ['map1', 'map2', 'key1'], ['map1', 'map2', 'map3', 'key1']]; + $mapPaths = [['map1'], ['map1', 'map2'], ['map1', 'map2', 'map3']]; + $changes = []; + $value = 0; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + foreach ($counterPaths as $step => $path) { + $branch = $port->incrementCounter($branch, $path, $offset + $step + 1); + } + for ($step = 0; $step < 4; ++$step) { + $path = $mapPaths[($offset + $step) % count($mapPaths)]; + $key = 'key' . (($offset * 11 + $step) % 30); + ++$value; + $branch = $port->setNested($branch, array_merge($path, [$key]), new Counter($value)); + } + if ($offset % 2 === 0) { + $branch = $port->deleteNested($branch, ['map1', 'map2', 'key1']); + $branch = $port->setNested($branch, ['map1', 'map2', 'key1'], new Counter(200 + $offset)); + $branch = $port->incrementCounter($branch, ['map1', 'map2', 'key1'], $offset + 1); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + same(json_encode($batched->toArray()), json_encode($iterative->toArray()), 'fuzzed batched nested counter map apply should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'fuzzed batched nested counter map apply should retain iterative heads'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch list patch diff matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-list-patch', + 'op_set2::change::batch::tests::batch_list_patch', + function () use ($port): void { + $base = $port->from(['list1' => [1, 2, 3]], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee']; + $changes = []; + $value = 3; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + for ($step = 0; $step < 4; ++$step) { + $list = $branch->toArray()['list1']; + $position = ($offset + $step) % count($list); + ++$value; + $branch = $port->setListElement($branch, 'list1', $position, $value); + } + for ($step = 0; $step < 3; ++$step) { + $list = $branch->toArray()['list1']; + $position = ($offset + $step + 1) % count($list); + ++$value; + $branch = $port->insertListElements($branch, 'list1', $position, [$value]); + } + if ($offset % 2 === 0) { + $list = $branch->toArray()['list1']; + $position = ($offset + 2) % count($list); + $branch = $port->deleteListElements($branch, 'list1', $position); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched list patch workload should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched list patch workload should retain iterative heads'); + sameArray($port->diff($batched, $baseHeads, $port->getHeads($batched)), $port->diff($iterative, $baseHeads, $port->getHeads($iterative)), 'batched list patch diff should match iterative diff'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch text patch diff matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-text-patch', + 'op_set2::change::batch::tests::batch_text_patch', + function () use ($port): void { + $base = $port->from(['text1' => '--------'], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee', 'ffffff']; + $changes = []; + $value = 0; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + for ($step = 0; $step < 5; ++$step) { + $text = $branch->toArray()['text1']; + $length = max(1, mb_strlen($text)); + $position = ($offset + $step * 2) % $length; + $delete = ($step + $offset) % 2; + ++$value; + $branch = $port->splice($branch, 'text1', $position, $delete, '[' . $value . ']'); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched text patch workload should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched text patch workload should retain iterative heads'); + sameArray($port->diff($batched, $baseHeads, $port->getHeads($batched)), $port->diff($iterative, $baseHeads, $port->getHeads($iterative)), 'batched text patch diff should match iterative diff'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch counter list patch diff matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-counter-list-patch', + 'op_set2::change::batch::tests::batch_counter_list_patch', + function () use ($port): void { + $base = $port->from(['list1' => [new Counter(1), new Counter(2), new Counter(3)]], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee']; + $changes = []; + $value = 3; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + for ($step = 0; $step < 3; ++$step) { + $list = $branch->toArray()['list1']; + $position = ($offset + $step) % count($list); + $branch = $port->incrementCounter($branch, ['list1', $position], $offset + $step + 1); + } + for ($step = 0; $step < 2; ++$step) { + $list = $branch->toArray()['list1']; + $position = ($offset + $step + 1) % count($list); + ++$value; + $branch = $port->insertListElements($branch, 'list1', $position, [new Counter($value)]); + } + if ($offset % 2 === 0) { + $list = $branch->toArray()['list1']; + $position = ($offset + 2) % count($list); + $branch = $port->deleteListElements($branch, 'list1', $position); + } + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + same(json_encode($batched->toArray()), json_encode($iterative->toArray()), 'batched counter list patch workload should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched counter list patch workload should retain iterative heads'); + same(json_encode($port->diff($batched, $baseHeads, $port->getHeads($batched))), json_encode($port->diff($iterative, $baseHeads, $port->getHeads($iterative))), 'batched counter list patch diff should match iterative diff'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust op_set2 batch marks patch diff matches iterative application', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:op-set2-change-batch-tests-batch-marks-patch', + 'op_set2::change::batch::tests::batch_marks_patch', + function () use ($port): void { + $base = $port->from(['text1' => '---------------------'], 'cccccc'); + $baseHeads = $port->getHeads($base); + $baseView = $port->view($base, $baseHeads); + $actors = ['aaaaaa', 'bbbbbb', 'dddddd', 'eeeeee']; + $changes = []; + $value = 0; + + foreach ($actors as $offset => $actor) { + $branch = $port->clone($base, $actor); + for ($step = 0; $step < 4; ++$step) { + $text = $branch->toArray()['text1']; + $length = max(1, mb_strlen($text)); + $position = ($offset + ($step * 3)) % $length; + $delete = ($offset + $step) % 2; + ++$value; + $branch = $port->splice($branch, 'text1', $position, $delete, '[' . $value . ']'); + } + + for ($step = 0; $step < 3; ++$step) { + $text = $branch->toArray()['text1']; + $length = max(1, mb_strlen($text)); + $start = ($offset + ($step * 4)) % $length; + $end = min($length, $start + 3 + $step); + if ($start === $end) { + continue; + } + ++$value; + $branch = $port->mark($branch, ['text1'], $start, $end, 'bold', $value, 'after'); + } + + $changes = array_merge($changes, $port->getChanges($baseView, $branch)); + } + + $iterative = $base; + foreach ($changes as $change) { + $iterative = $port->applyChanges($iterative, [$change]); + } + $batched = $port->applyChangesBatch($base, $changes); + + sameArray($batched->toArray(), $iterative->toArray(), 'batched marks patch workload should match iterative materialization'); + sameArray($port->getHeads($batched), $port->getHeads($iterative), 'batched marks patch workload should retain iterative heads'); + sameArray($port->diff($batched, $baseHeads, $port->getHeads($batched)), $port->diff($iterative, $baseHeads, $port->getHeads($iterative)), 'batched marks patch diff should match iterative diff'); + sameArray($port->spans($batched, ['text1']), $port->spans($iterative, ['text1']), 'batched marks patch spans should match iterative spans'); + }, + 'rust/automerge/src/op_set2/change/batch.rs' +); + +$rustMapped( + 'rust columnar value ULEB metadata decodes unsigned scalars', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-value-uleb', + 'columnar::column_range::value::tests::test_value_uleb', + function () use ($port): void { + $values = [ + ['type' => 'uint', 'value' => 127], + ['type' => 'uint', 'value' => 183], + ]; + $encoded = $port->columnarEncodeScalarValues($values); + + same(bin2hex($encoded['bytes']), '7e13237fb701', 'value column should encode ULEB metadata and raw unsigned integers'); + sameArray($encoded['ranges'], ['meta' => [0, 3], 'raw' => [3, 6]], 'value column ranges should split metadata from raw values'); + sameArray($port->columnarDecodeScalarValues($encoded), $values, 'value column decoder should recover ULEB unsigned scalars'); + }, + 'rust/automerge/src/columnar/column_range/value.rs' +); + +$rustMapped( + 'rust columnar value initialization round-trips scalar values', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-initialize-splice', + 'columnar::column_range::value::tests::test_initialize_splice', + function () use ($port): void { + $values = [ + ['type' => 'null', 'value' => null], + ['type' => 'boolean', 'value' => false], + ['type' => 'boolean', 'value' => true], + ['type' => 'uint', 'value' => 183], + ['type' => 'int', 'value' => -64], + ['type' => 'float64', 'value' => 5.5], + ['type' => 'string', 'value' => "\u{03b2}eta"], + ['type' => 'bytes', 'value' => [0, 255, 17]], + ['type' => 'counter', 'value' => -3], + ['type' => 'timestamp', 'value' => 1700000000123], + ['type' => 'unknown', 'code' => 11, 'value' => [1, 2, 3]], + ]; + $encoded = $port->columnarEncodeScalarValues($values); + + sameArray($port->columnarDecodeScalarValues($encoded), $values, 'value column should round-trip the scalar value set'); + }, + 'rust/automerge/src/columnar/column_range/value.rs' +); + +$rustMapped( + 'rust columnar value row-wise and column-wise encoders match', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-encode-row-wise-and-columnwise-equal', + 'columnar::column_range::value::tests::encode_row_wise_and_columnwise_equal', + function () use ($port): void { + $values = [ + ['type' => 'string', 'value' => 'alpha'], + ['type' => 'string', 'value' => 'alpha'], + ['type' => 'int', 'value' => 64], + ['type' => 'uint', 'value' => 65], + ['type' => 'bytes', 'value' => [1, 2]], + ['type' => 'unknown', 'code' => 12, 'value' => []], + ]; + + sameArray( + $port->columnarEncodeScalarValuesRowWise($values), + $port->columnarEncodeScalarValues($values), + 'row-wise and column-wise scalar value encoders should produce identical bytes and ranges' + ); + }, + 'rust/automerge/src/columnar/column_range/value.rs' +); + +$rustMapped( + 'rust columnar value splice replaces scalar value ranges', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:columnar-column-range-value-tests-test-splice-values', + 'columnar::column_range::value::tests::test_splice_values', + function () use ($port): void { + $initial = [ + ['type' => 'uint', 'value' => 1], + ['type' => 'string', 'value' => 'left'], + ['type' => 'boolean', 'value' => true], + ['type' => 'counter', 'value' => 10], + ]; + $replacement = [ + ['type' => 'null', 'value' => null], + ['type' => 'timestamp', 'value' => -500], + ['type' => 'bytes', 'value' => [10, 20, 30]], + ]; + $spliced = $port->columnarSpliceScalarValues( + $port->columnarEncodeScalarValues($initial), + 1, + 2, + $replacement + ); + + sameArray( + $port->columnarDecodeScalarValues($spliced), + [ + ['type' => 'uint', 'value' => 1], + ['type' => 'null', 'value' => null], + ['type' => 'timestamp', 'value' => -500], + ['type' => 'bytes', 'value' => [10, 20, 30]], + ['type' => 'counter', 'value' => 10], + ], + 'value column splice should apply Vec::splice-style scalar replacement' + ); + }, + 'rust/automerge/src/columnar/column_range/value.rs' +); + +$rustMapped( + 'rust Myers text diff finds the middle snake', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:text-diff-myers-test-find-middle-snake', + 'text_diff::myers::test_find_middle_snake', + function () use ($port): void { + sameArray( + $port->textDiffFindMiddleSnake('ABCABBA', 0, 7, 'CBABAC', 0, 6), + [4, 1], + 'Myers middle snake should match the upstream byte-coordinate example' + ); + }, + 'rust/automerge/src/text_diff/myers.rs' +); + +$mapped( + 'concurrent text insertion deterministically preserves both branches', + 'javascript/test/text_test.ts', + 48, + 'should handle concurrent insertion', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'text', ''); + $s1 = $port->splice($base, 'text', 0, 0, 'abc'); + $s2 = $port->splice($port->clone($base, 'bbbbbb'), 'text', 0, 0, 'xyz'); + $merged = $port->mergeDocuments($s1, $s2); + $text = $merged->text('text')->toString(); + + same($merged->text('text')->length(), 6, 'merged concurrent text should have both insertions'); + oneOf($text, ['abcxyz', 'xyzabc'], 'merged concurrent text should match one accepted upstream ordering'); + } +); + +$rustMapped( + 'rust text updateText merges independent replacements', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:simple-update-text', + 'simple_update_text', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'Hello, world!'); + $doc2 = $port->clone($doc, 'bbbbbb'); + $doc2 = $port->updateText($doc2, 'text', 'Goodbye, world!'); + $doc = $port->updateText($doc, 'text', 'Hello, friends!'); + $merged = $port->mergeDocuments($doc, $doc2); + + same($merged->text('text')->toString(), 'Goodbye, friends!', 'independent updateText replacements should merge into one text value'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text updateText merges multicodepoint graphemes', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:update-text-big-ole-graphemes', + 'update_text_big_ole_graphemes', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'left👨‍👩‍👦right'); + $doc2 = $port->clone($doc, 'bbbbbb'); + $doc2 = $port->updateText($doc2, 'text', 'left👨‍👩‍👧right'); + $doc = $port->updateText($doc, 'text', 'left👨‍👩‍👦‍👦right'); + $merged = $port->mergeDocuments($doc, $doc2); + + same($merged->text('text')->toString(), 'left👨‍👩‍👧👨‍👩‍👦‍👦right', 'updateText should merge independent grapheme replacements without splitting clusters'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text updateText at historical heads integrates later insertions', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:update-text-change-at', + 'update_text_change_at', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->updateText($doc, 'text', "a\n"); + $initialHeads = $port->getHeads($doc); + + $doc = $port->updateText($doc, 'text', "a\nb\n"); + $doc = $port->updateTextAtHeads($doc, $initialHeads, 'text', "a\nc\n"); + + same($doc->text('text')->toString(), "a\nc\nb\n", 'historical updateText should integrate with the later insertion'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text can create separated marks after insertion', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:mark-created-after-insertion', + 'mark_created_after_insertion', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, '12345'); + $doc = $port->mark($doc, ['text'], 1, 2, 'strong', true, 'both'); + $doc = $port->mark($doc, ['text'], 3, 4, 'strong', true, 'both'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => '1'], + ['type' => 'text', 'value' => '2', 'marks' => ['strong' => true]], + ['type' => 'text', 'value' => '3'], + ['type' => 'text', 'value' => '4', 'marks' => ['strong' => true]], + ['type' => 'text', 'value' => '5'], + ], 'separated same-name marks should not throw or collapse intervening text'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text local patches are segmented by marks', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:local-patches-created-for-marks', + 'local_patches_created_for_marks', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'the quick fox jumps over the lazy dog'); + $doc = $port->mark($doc, ['text'], 0, 37, 'bold', true, 'both'); + $doc = $port->mark($doc, ['text'], 4, 19, 'italic', true, 'both'); + $doc = $port->mark($doc, ['text'], 10, 13, 'comment:somerandomcommentid', 'foxes are my favorite animal!', 'both'); + + sameArray($port->diffIncremental($doc), [ + ['action' => 'put', 'path' => ['text'], 'value' => ''], + ['action' => 'splice', 'path' => ['text', 0], 'value' => 'the ', 'marks' => ['bold' => true]], + ['action' => 'splice', 'path' => ['text', 4], 'value' => 'quick ', 'marks' => ['bold' => true, 'italic' => true]], + ['action' => 'splice', 'path' => ['text', 10], 'value' => 'fox', 'marks' => [ + 'bold' => true, + 'comment:somerandomcommentid' => 'foxes are my favorite animal!', + 'italic' => true, + ]], + ['action' => 'splice', 'path' => ['text', 13], 'value' => ' jumps', 'marks' => ['bold' => true, 'italic' => true]], + ['action' => 'splice', 'path' => ['text', 19], 'value' => ' over the lazy dog', 'marks' => ['bold' => true]], + ], 'full diff patches should split inserted text by active mark sets'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text consolidates zero-length mark spans', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:spans-are-consolidated-in-the-presence-of-zero-length-spans', + 'spans_are_consolidated_in_the_presence_of_zero_length_spans', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, '1234'); + $doc = $port->mark($doc, ['text'], 1, 1, 'strong', true, 'both'); + $doc = $port->mark($doc, ['text'], 2, 2, 'strong', true, 'both'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => '1234'], + ], 'zero-length marks should not split visible text spans'); + }, + 'rust/automerge/tests/text.rs' +); + +const MARKS_PROPERTY_SENTINEL = '__am_php_no_previous_marks__'; + +$rustMapped( + 'rust text empty marks before block markers do not repeat text', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:empty-marks-before-block-marker-dont-repeat-text', + 'empty_marks_before_block_marker_dont_repeat_text', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splitBlock($doc, ['text'], 0, []); + $doc = $port->splitBlock($doc, ['text'], 0, []); + $doc = $port->mark($doc, ['text'], 1, 1, 'strong', true, 'both'); + $doc = $port->splice($doc, 'text', 2, 0, 'a'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'block', 'value' => ['parents' => [], 'type' => '', 'attrs' => []]], + ['type' => 'text', 'value' => 'a'], + ], 'empty marks before block markers should not duplicate text spans'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text marks property keeps spans consolidated', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:marks-are-okay', + 'marks_are_okay', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->spliceAtPath($doc, ['text'], 0, 0, 'abcd'); + $expected = 'abcd'; + $doc = $port->mark($doc, ['text'], 1, 4, 'tone', 'warm', 'both'); + $doc = $port->splitBlock($doc, ['text'], 2, []); + $expected = substr($expected, 0, 2) . "\n" . substr($expected, 2); + $doc = $port->spliceAtPath($doc, ['text'], 4, 0, 'XY'); + $expected = substr($expected, 0, 4) . 'XY' . substr($expected, 4); + $doc = $port->spliceAtPath($doc, ['text'], 1, 1, ''); + $expected = substr($expected, 0, 1) . substr($expected, 2); + + $spanText = ''; + $lastMarks = MARKS_PROPERTY_SENTINEL; + foreach ($port->spans($doc, ['text']) as $span) { + if (($span['type'] ?? null) === 'block') { + $spanText .= "\n"; + $lastMarks = MARKS_PROPERTY_SENTINEL; + continue; + } + + $marks = $span['marks'] ?? null; + truthy($marks !== $lastMarks, 'adjacent text spans should not repeat the same mark set'); + $spanText .= (string) ($span['value'] ?? ''); + $lastMarks = $marks; + } + + same($spanText, $expected, 'span text should match the expected text with block markers as newlines'); + same(str_replace("\u{FFFC}", "\n", $doc->toArray()['text']), $expected, 'document text should match the expected text with block markers normalized'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text removed marks do not appear in get marks', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:removed-marks-should-not-appear-in-get-marks', + 'removed_marks_should_not_appear_in_get_marks', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'abcdefg'); + $doc = $port->mark($doc, ['text'], 0, 1, 'name1', 1, 'none'); + $doc = $port->mark($doc, ['text'], 0, 1, 'name1', null, 'none'); + + sameArray($port->marksAt($doc, ['text'], 0), [], 'marksAt should not report marks removed with a null mark operation'); + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'abcdefg'], + ], 'removed marks should leave the text unmarked'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text incremental splices inherit active marks', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:incremental-splice-patches-include-marks', + 'incremental_splice_patches_include_marks', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, '12345'); + $doc = $port->mark($doc, ['text'], 1, 2, 'strong', true, 'both'); + $doc = $port->splice($doc, 'text', 1, 0, '-'); + $doc = $port->splice($doc, 'text', 2, 0, '-'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => '1'], + ['type' => 'text', 'value' => '--2', 'marks' => ['strong' => true]], + ['type' => 'text', 'value' => '345'], + ], 'splices at both boundaries of an expand-both mark should inherit the mark'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text expand-both marks cover boundary insertions', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:expand-marks-are-reported-in-patches', + 'expand_marks_are_reported_in_patches', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'aaabbbccc'); + $doc = $port->mark($doc, ['text'], 3, 6, 'strong', true, 'both'); + $doc = $port->splice($doc, 'text', 6, 0, '<'); + $doc = $port->splice($doc, 'text', 3, 0, '>'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'aaa'], + ['type' => 'text', 'value' => '>bbb<', 'marks' => ['strong' => true]], + ['type' => 'text', 'value' => 'ccc'], + ], 'insertions at both ends of an expand-both mark should stay marked'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text remote expand-after mark merge matches local insertion', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:test-remote-patches-for-marks-with-expand-after', + 'test_remote_patches_for_marks_with_expand_after', + function () use ($port): void { + $docA = $port->set($port->init('aaaaaa'), 'text', ''); + $docA = $port->splice($docA, 'text', 0, 0, 'fox'); + $docA = $port->mark($docA, ['text'], 0, 3, 'strong', true, 'after'); + $docB = $port->clone($docA, 'bbbbbb'); + + $docA = $port->splice($docA, 'text', 3, 0, 'a'); + $docB = $port->mergeDocuments($docB, $docA); + + $expected = [ + ['type' => 'text', 'value' => 'foxa', 'marks' => ['strong' => true]], + ]; + sameArray($port->spans($docA, ['text']), $expected, 'local insertion after an expand-after mark should be marked'); + sameArray($port->spans($docB, ['text']), $expected, 'remote merge should materialize the same marked insertion'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust mark patches at end of text survive incremental load', + 'rust:tests-test-mark-patches-rs-target-debug-deps-test-mark-patches-5c7d6b43cf1dbe46:mark-patches-at-end-of-text', + 'mark_patches_at_end_of_text', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', ''); + $doc1 = $port->splice($doc1, 'text', 0, 0, 'sample'); + $headsBeforeMark = $port->getHeads($doc1); + $patches = []; + $doc2 = $port->cloneWithPatchCallback( + $port->load($port->save($doc1), 'bbbbbb'), + static function (array $patchBatch) use (&$patches): void { + array_push($patches, ...$patchBatch); + }, + 'bbbbbb' + ); + + $doc1 = $port->mark($doc1, ['text'], 5, 6, 'bold', true, 'after'); + $doc2 = $port->loadIncremental($doc2, $port->saveSince($doc1, $headsBeforeMark)); + + sameArray($patches, [[ + 'action' => 'mark', + 'path' => ['text'], + 'marks' => [['name' => 'bold', 'value' => true, 'start' => 5, 'end' => 6]], + ]], 'incremental load should emit a mark patch for an end-of-text mark'); + sameArray($port->spans($doc2, ['text']), [ + ['type' => 'text', 'value' => 'sampl'], + ['type' => 'text', 'value' => 'e', 'marks' => ['bold' => true]], + ], 'incremental mark patch should materialize at the end of text'); + }, + 'rust/automerge/tests/test_mark_patches.rs' +); + +$rustMapped( + 'rust text insertions after noexpand spans stay unmarked', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:insertions-after-noexpand-spans-are-not-marked', + 'insertions_after_noexpand_spans_are_not_marked', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splitBlock($doc, ['text'], 0, [ + 'type' => 'heading', + 'parents' => [], + 'attrs' => [], + ]); + $doc = $port->splice($doc, 'text', 1, 0, 'Heading'); + $doc = $port->splitBlock($doc, ['text'], 8, [ + 'type' => 'paragraph', + 'parents' => [], + 'attrs' => [], + ]); + $doc = $port->splice($doc, 'text', 9, 0, 'a'); + $doc = $port->mark($doc, ['text'], 9, 9, 'strong', true, 'none'); + $doc = $port->updateSpans($doc, ['text'], [ + ['type' => 'block', 'value' => ['type' => 'heading', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'Heading'], + ['type' => 'block', 'value' => ['type' => 'paragraph', 'parents' => [], 'attrs' => []]], + ['type' => 'text', 'value' => 'a'], + ['type' => 'block', 'value' => ['type' => 'paragraph', 'parents' => [], 'attrs' => []]], + ]); + $doc = $port->splice($doc, 'text', 11, 0, 'a'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'heading', 'attrs' => []]], + ['type' => 'text', 'value' => 'Heading'], + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'paragraph', 'attrs' => []]], + ['type' => 'text', 'value' => 'a'], + ['type' => 'block', 'value' => ['parents' => [], 'type' => 'paragraph', 'attrs' => []]], + ['type' => 'text', 'value' => 'a'], + ], 'insertions after noexpand zero-length spans should remain unmarked'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text noexpand terminal mark excludes following insertions', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:noexpand-marks-at-the-end-of-text-should-not-emit-marked-patches-on-following-insertions', + 'noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'Hello world'); + $doc = $port->mark($doc, ['text'], 10, 11, 'strong', true, 'none'); + $doc = $port->splice($doc, 'text', 11, 0, 'a'); + + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'Hello worl'], + ['type' => 'text', 'value' => 'd', 'marks' => ['strong' => true]], + ['type' => 'text', 'value' => 'a'], + ], 'text inserted after a noexpand terminal mark should not inherit the mark'); + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust text far-end insertions after crossed marks stay unmarked', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:marks-which-cross-optree-boundaries-are-not-double-counted-in-splice-patches', + 'marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, str_repeat('a', 32)); + $doc = $port->mark($doc, ['text'], 15, 17, 'strong', true, 'none'); + + for ($i = 0; $i < 100; ++$i) { + $doc = $port->splitBlock($doc, ['text'], $doc->text('text')->length(), []); + $insertAt = $doc->text('text')->length(); + $doc = $port->splice($doc, 'text', $insertAt, 0, 'a'); + + sameArray($port->marksAt($doc, ['text'], $insertAt), [], 'far-end insertion after an ended mark should stay unmarked'); + } + + sameArray($port->marksAt($doc, ['text'], 15), ['strong' => true], 'original crossed mark should still apply at its start'); + sameArray($port->marksAt($doc, ['text'], 17), [], 'original crossed mark should not leak past its end'); + }, + 'rust/automerge/tests/text.rs' +); + +$mapped( + 'text and scalar root operations can happen in the same document state', + 'javascript/test/text_test.ts', + 60, + 'should handle text and other ops in the same change', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + $doc = $port->set($doc, 'foo', 'bar'); + $doc = $port->splice($doc, 'text', 0, 0, 'a'); + + same($doc->toArray()['foo'], 'bar', 'scalar op should materialize beside text'); + same($doc->text('text')->toString(), 'a', 'text op should materialize beside scalar op'); + } +); + +$mapped( + 'document JSON encoding serializes text as a plain string', + 'javascript/test/text_test.ts', + 70, + 'should serialize to JSON as a simple string', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'a"b'); + + same(json_encode($doc, JSON_THROW_ON_ERROR), '{"text":"a\"b"}', 'JSON serialization should expose text as a string'); + } +); + +$mapped( + 'text can be modified after assignment to a document root key', + 'javascript/test/text_test.ts', + 77, + 'should allow modification after an object is assigned to a document', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + $doc = $port->splice($doc, 'text', 0, 0, 'abcd'); + $doc = $port->splice($doc, 'text', 2, 1); + + same($doc->text('text')->toString(), 'abd', 'assigned text should remain mutable through native splice API'); + } +); + +$mapped( + 'public text splice rejects documents outside a change callback', + 'javascript/test/text_test.ts', + 87, + 'should not allow modification outside of a change callback', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'text', ''); + + throwsLike( + static fn (): Document => $port->spliceInChange($doc, ['text'], 0, 0, 'a'), + 'object cannot be modified outside of a change block', + 'strict splice should reject a non-draft document' + ); + + $changed = $port->change( + $doc, + static function (Document $draft) use ($port): void { + $port->spliceInChange($draft, ['text'], 0, 0, 'a'); + } + ); + same($changed->toArray()['text'], 'a', 'strict splice should mutate the active change draft'); + } +); + +$mapped( + 'unicode text values survive root materialization', + 'javascript/test/text_test.ts', + 115, + 'should support unicode when creating text', + function () use ($port): void { + $doc = $port->from(['text' => '🐦'], 'aabbcc'); + + same($doc->toArray()['text'], '🐦', 'unicode text should materialize unchanged'); + } +); + +$mapped( + 'text splice can target string values nested in arrays', + 'javascript/test/text_test.ts', + 122, + 'should allow splicing into text in arrays', + function () use ($port): void { + $doc = $port->from(['dom' => [['world']]], 'aabbcc'); + $doc = $port->spliceAtPath($doc, ['dom', 0, 0], 0, 0, 'Hello '); + + sameArray($doc->toArray(), ['dom' => [['Hello world']]], 'nested array text splice should update the string value in place'); + } +); + +$mapped( + 'from initializes text values with string length and index access', + 'javascript/test/text_test.ts', + 95, + 'should initialize text in Automerge.from()', + function () use ($port): void { + $doc = $port->from(['text' => 'init'], 'aabbcc'); + $text = $doc->text('text'); + + same($text->length(), 4, 'initial text should expose string length'); + same($text->charAt(0), 'i', 'first initial character should be readable'); + same($text->charAt(1), 'n', 'second initial character should be readable'); + same($text->charAt(2), 'i', 'third initial character should be readable'); + same($text->charAt(3), 't', 'fourth initial character should be readable'); + same($text->toString(), 'init', 'initial text should materialize as the original string'); + } +); + +$mapped( + 'from encodes the initial root as one replayable native change', + 'javascript/test/text_test.ts', + 105, + 'should encode the initial value as a change', + function () use ($port): void { + $doc = $port->from(['text' => 'init'], 'aabbcc'); + $changes = $port->getAllChanges($doc); + same(count($changes), 1, 'from({text: "init"}) should produce one native initial change'); + + $applied = $port->applyChanges($port->init('ddeeff'), $changes); + same($applied->text('text')->toString(), 'init', 'applying the initial change should reconstruct text'); + } +); + +$mapped( + 'updateText merges non-overlapping replacements from concurrent actors', + 'javascript/test/text_test.ts', + 132, + 'should calculate a diff when updating text', + function () use ($port): void { + $doc1 = $port->from(['text' => 'Hello world!'], 'aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + + $doc2 = $port->updateText($doc2, 'text', 'Goodbye world!'); + $doc1 = $port->updateText($doc1, 'text', 'Hello friends!'); + + $merged = $port->mergeDocuments($doc1, $doc2); + same($merged->text('text')->toString(), 'Goodbye friends!', 'concurrent updateText edits should merge by preserved anchors'); + } +); + +$mapped( + 'updateText handles multi-character grapheme clusters', + 'javascript/test/text_test.ts', + 148, + 'should handle multi character grapheme clusters', + function () use ($port): void { + $doc1 = $port->from(['text' => 'left👨‍👩‍👦right'], 'aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + + $doc2 = $port->updateText($doc2, 'text', 'left👨‍👩‍👧right'); + $doc1 = $port->updateText($doc1, 'text', 'left👨‍👩‍👦‍👦right'); + + $merged = $port->mergeDocuments($doc1, $doc2); + + same($merged->text('text')->toString(), 'left👨‍👩‍👧👨‍👩‍👦‍👦right', 'concurrent grapheme replacements should preserve both clusters'); + } +); + +$mapped( + 'change metadata since heads matches decoded native changes', + 'javascript/test/basic_test.ts', + 300, + 'get change metadata', + function () use ($port): void { + $doc = $port->from(['text' => 'hello world'], 'aaaaaa'); + $heads = $port->getHeads($doc); + $doc = $port->set($doc, 'foo', 'bar'); + $doc = $port->set($doc, 'zip', 'zop'); + + $changes = array_map( + static fn (array $change): array => $port->decodeChange($change), + $port->getChangesSince($doc, $heads) + ); + $meta = $port->getChangesMetaSince($doc, $heads); + + same(count($changes), 2, 'two changes should be returned after the saved heads'); + same(count($meta), 2, 'two metadata entries should be returned after the saved heads'); + for ($i = 0; $i < 2; ++$i) { + same($changes[$i]['actor'], $meta[$i]['actor'], 'metadata actor should match decoded change'); + same($changes[$i]['hash'], $meta[$i]['hash'], 'metadata hash should match decoded change'); + same($changes[$i]['message'], $meta[$i]['message'], 'metadata message should match decoded change'); + same($changes[$i]['time'], $meta[$i]['time'], 'metadata time should match decoded change'); + sameArray($changes[$i]['deps'], $meta[$i]['deps'], 'metadata deps should match decoded change'); + same($changes[$i]['startOp'], $meta[$i]['startOp'], 'metadata startOp should match decoded change'); + } + } +); + +$mapped( + 'basic load can explicitly allow a change with missing dependencies', + 'javascript/test/basic_test.ts', + 327, + 'should work in unstable', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->set($doc1, 'list', [1, 2, 3]); + $doc3 = $port->pushList($doc2, 'list', [4]); + $changes = $port->getChanges($doc2, $doc3); + + same(count($changes), 1, 'single later change should be selected after doc2 heads'); + throwsLike( + static fn (): Document => $port->loadChange($changes[0]), + 'missing dependencies', + 'loading a change with missing dependencies should fail unless explicitly allowed' + ); + + $loaded = $port->loadChange($changes[0], true, 'bbbbbb'); + sameArray($loaded->toArray(), ['list' => [1, 2, 3, 4]], 'allowing missing dependencies should load the isolated change payload'); + } +); + +$rustMapped( + 'rust save retains orphaned changes until missing deps arrive', + 'rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:save-orphaned-changes', + 'save_orphaned_changes', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'key', 'value'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + + $doc2 = $port->set($doc2, 'key', 'value2'); + $missingChange = $port->getLastLocalChange($doc2); + $doc2 = $port->set($doc2, 'key', 'value3'); + $orphanedChange = $port->getLastLocalChange($doc2); + + $doc1 = $port->applyChanges($doc1, [$orphanedChange]); + $loaded = $port->load($port->save($doc1), 'cccccc'); + $loaded = $port->applyChanges($loaded, [$missingChange]); + + sameArray($loaded->toArray(), ['key' => 'value3'], 'saved orphaned change should apply after the missing dependency arrives'); + }, + 'rust/automerge/tests/test_save_load_orphans.rs' +); + +$rustMapped( + 'rust save can discard orphaned changes', + 'rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:discard-orphans', + 'discard_orphans', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'key', 'value'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + + $doc2 = $port->set($doc2, 'key', 'value2'); + $missingChange = $port->getLastLocalChange($doc2); + $doc2 = $port->set($doc2, 'key', 'value3'); + $orphanedChange = $port->getLastLocalChange($doc2); + + $doc1 = $port->applyChanges($doc1, [$orphanedChange]); + $loaded = $port->load($port->saveWithOptions($doc1, ['retainOrphans' => false]), 'cccccc'); + $loaded = $port->applyChanges($loaded, [$missingChange]); + + sameArray($loaded->toArray(), ['key' => 'value2'], 'discarded orphaned dependent change should not reappear after the missing dependency arrives'); + }, + 'rust/automerge/tests/test_save_load_orphans.rs' +); + +$rustMapped( + 'rust loading standalone incremental change without deps throws', + 'rust:tests-test-save-load-orphans-rs-target-debug-deps-test-save-load-orphans-f9b6758020e89e58:load-incremental-change-without-deps-throws', + 'load_incremental_change_without_deps_throws', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $port->saveIncremental($doc); + $doc = $port->set($doc, 'key', 'value2'); + $orphan = $port->saveIncremental($doc); + + throwsLike( + static fn (): Document => $port->load($orphan, 'bbbbbb'), + 'missing dependencies', + 'loading an incremental change chunk without its document chunk should fail' + ); + }, + 'rust/automerge/tests/test_save_load_orphans.rs' +); + +$mapped( + 'change metadata defaults to the current timestamp', + 'javascript/test/change_time.ts', + 7, + 'should default to current timestamp', + function () use ($port): void { + $before = time(); + $doc = $port->set($port->init('aaaaaa'), 'answer', 42); + $after = time(); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + truthy( + is_int($decoded['time'] ?? null) && $before <= $decoded['time'] && $decoded['time'] <= $after, + 'default change timestamp should be recorded in seconds' + ); + } +); + +$mapped( + 'change metadata accepts a user-provided timestamp', + 'javascript/test/change_time.ts', + 18, + 'should allow user provided timestamp', + function () use ($port): void { + $doc = $port->setWithTime($port->init('aaaaaa'), 'answer', 42, 12345); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + same($decoded['time'], 12345, 'user-provided change timestamp should be recorded'); + } +); + +$mapped( + 'change metadata can explicitly record no timestamp', + 'javascript/test/change_time.ts', + 27, + 'should allow no timestamp', + function () use ($port): void { + $doc = $port->setWithoutTime($port->init('aaaaaa'), 'answer', 42); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + same($decoded['time'], 0, 'explicit no-timestamp changes should record time zero'); + } +); + +$mapped( + 'emptyChange metadata defaults to the current timestamp', + 'javascript/test/change_time.ts', + 37, + 'should default to current timestamp', + function () use ($port): void { + $before = time(); + $doc = $port->emptyChange($port->init('aaaaaa')); + $after = time(); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + truthy( + is_int($decoded['time'] ?? null) && $before <= $decoded['time'] && $decoded['time'] <= $after, + 'default emptyChange timestamp should be recorded in seconds' + ); + } +); + +$mapped( + 'emptyChange metadata accepts a user-provided timestamp', + 'javascript/test/change_time.ts', + 48, + 'should allow user provided timestamp', + function () use ($port): void { + $doc = $port->emptyChangeWithTime($port->init('aaaaaa'), 12345); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + same($decoded['time'], 12345, 'user-provided emptyChange timestamp should be recorded'); + } +); + +$mapped( + 'emptyChange metadata can explicitly record no timestamp', + 'javascript/test/change_time.ts', + 57, + 'should allow no timestamp', + function () use ($port): void { + $doc = $port->emptyChangeWithoutTime($port->init('aaaaaa')); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + same($decoded['time'], 0, 'explicit no-timestamp emptyChange should record time zero'); + } +); + +$mapped( + 'emptyChange advances document heads with a new hash', + 'javascript/test/basic_test.ts', + 396, + 'should generate a hash', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $port->save($doc); + $headsBefore = $port->getHeads($doc); + sort($headsBefore); + + $doc = $port->emptyChange($doc, 'empty change'); + $headsAfter = $port->getHeads($doc); + sort($headsAfter); + + truthy($headsBefore !== $headsAfter, 'emptyChange should generate a distinct head hash'); + } +); + +$mapped( + 'legacy emptyChange appends an empty change to history', + 'javascript/test/legacy_tests.ts', + 402, + 'should append an empty change to the history', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'field', 123); + $after = $port->emptyChange($doc, 'empty change'); + + truthy($after !== $doc, 'emptyChange should return a distinct document instance'); + same($after->toArray(), $doc->toArray(), 'emptyChange should not alter materialized document data'); + + $history = $port->getHistory($after); + sameArray( + array_map(static fn (array $entry): ?string => $entry['change']['message'], $history), + [null, 'empty change'], + 'history should include the set change and appended empty change' + ); + same($history[1]['change']['ops'], [], 'appended empty change should contain no operations'); + } +); + +$mapped( + 'legacy emptyChange references merged dependencies', + 'javascript/test/legacy_tests.ts', + 413, + 'should reference dependencies', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'field', 123); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + $doc2 = $port->set($doc2, 'other', 'hello'); + $doc1 = $port->emptyChange($port->mergeDocuments($doc1, $doc2)); + + $history = $port->getHistory($doc1); + same(count($history), 3, 'history should contain two data changes plus the empty change'); + + $emptyChange = $history[2]['change']; + $expectedDeps = [$history[0]['change']['hash'], $history[1]['change']['hash']]; + $actualDeps = $emptyChange['deps']; + sort($expectedDeps); + sort($actualDeps); + + sameArray($actualDeps, $expectedDeps, 'empty change should reference both merged heads'); + same($emptyChange['ops'], [], 'dependency-only empty change should contain no operations'); + } +); + +$mapped( + 'legacy save/load restores an empty document', + 'javascript/test/legacy_tests.ts', + 1413, + 'should save and restore an empty document', + function () use ($port): void { + $loaded = $port->load($port->save($port->init('aabbcc'))); + + sameArray($loaded->toArray(), [], 'loading a saved empty document should materialize an empty root map'); + } +); + +$mapped( + 'legacy save/load assigns a new actor id by default', + 'javascript/test/legacy_tests.ts', + 1418, + 'should generate a new random actor ID', + function () use ($port): void { + $doc1 = $port->init(); + $doc2 = $port->load($port->save($doc1)); + + truthy(ctype_xdigit($port->getActorId($doc1)), 'initial actor id should be hex'); + truthy(ctype_xdigit($port->getActorId($doc2)), 'loaded actor id should be hex'); + truthy($port->getActorId($doc1) !== $port->getActorId($doc2), 'loading without an actor should assign a fresh actor id'); + } +); + +$mapped( + 'legacy save/load accepts a custom actor id', + 'javascript/test/legacy_tests.ts', + 1432, + 'should allow a custom actor ID to be set', + function () use ($port): void { + $doc = $port->load($port->save($port->init('aabbcc')), '333333'); + + same($port->getActorId($doc), '333333', 'loading with an explicit actor id should use it'); + } +); + +$mapped( + 'legacy save/load reconstitutes nested list and map data', + 'javascript/test/legacy_tests.ts', + 1437, + 'should reconstitute complex datatypes', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'todos', [['title' => 'water plants', 'done' => false]]); + $loaded = $port->load($port->save($doc)); + + sameArray($loaded->toArray(), ['todos' => [['title' => 'water plants', 'done' => false]]], 'nested list/map data should survive save/load'); + } +); + +$mapped( + 'legacy save/load keeps map keys containing at-signs', + 'javascript/test/legacy_tests.ts', + 1448, + 'should save and load maps with @ symbols in the keys', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), '123@4567', 'hello'); + $loaded = $port->load($port->save($doc)); + + sameArray($loaded->toArray(), ['123@4567' => 'hello'], 'map keys containing at-signs should survive save/load'); + } +); + +$mapped( + 'legacy save/load reconstitutes root conflicts', + 'javascript/test/legacy_tests.ts', + 1457, + 'should reconstitute conflicts', + function () use ($port): void { + $doc1 = $port->set($port->init('111111'), 'x', 3); + $doc2 = $port->set($port->init('222222'), 'x', 5); + $merged = $port->mergeDocuments($doc1, $doc2); + $loaded = $port->load($port->save($merged)); + + same($loaded->toArray()['x'], 5, 'conflicted root value should materialize the deterministic winner after load'); + sameArray( + $port->getConflicts($loaded, 'x') ?? [], + [ + '1@111111' => 3, + '1@222222' => 5, + ], + 'root conflicts should survive save/load' + ); + } +); + +$mapped( + 'legacy save/load reconstitutes element id counters', + 'javascript/test/legacy_tests.ts', + 1480, + 'should reconstitute element ID counters', + function () use ($port): void { + $doc = $port->set($port->init('01234567'), 'list', ['a']); + $doc = $port->deleteListElements($doc, 'list', 0); + $loaded = $port->load($port->save($doc), '01234567'); + $loaded = $port->pushList($loaded, 'list', ['b']); + $changes = array_map( + static fn (array $change): array => $port->decodeChange($change), + $port->getAllChanges($loaded) + ); + + sameArray($loaded->toArray(), ['list' => ['b']], 'reloaded list should accept a later insertion after deletion'); + same(count($changes), 3, 'initial list creation, deletion, and insertion should remain in history'); + same($changes[2]['actor'], '01234567', 'post-load insertion should use the requested actor id'); + same($changes[2]['seq'], 3, 'post-load insertion should continue the actor sequence'); + same($changes[2]['startOp'], 5, 'post-load insertion should continue after list/text element operation ids'); + sameArray($changes[2]['deps'], [$changes[1]['hash']], 'post-load insertion should depend on the delete change'); + } +); + +$mapped( + 'legacy history returns an empty list for an empty document', + 'javascript/test/legacy_tests.ts', + 1596, + 'should return an empty history for an empty document', + function () use ($port): void { + sameArray($port->getHistory($port->init('aabbcc')), [], 'empty document should expose no history entries'); + } +); + +$mapped( + 'legacy history exposes past document snapshots', + 'javascript/test/legacy_tests.ts', + 1600, + 'should make past document states accessible', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'config', ['background' => 'blue']); + $doc = $port->set($doc, 'birds', ['mallard']); + $doc = $port->insertListElements($doc, 'birds', 0, ['oystercatcher']); + $snapshots = array_map( + static fn (array $entry): array => $entry['snapshot']->toArray(), + $port->getHistory($doc) + ); + + sameArray( + $snapshots, + [ + ['config' => ['background' => 'blue']], + ['config' => ['background' => 'blue'], 'birds' => ['mallard']], + ['config' => ['background' => 'blue'], 'birds' => ['oystercatcher', 'mallard']], + ], + 'history should expose each materialized past snapshot' + ); + } +); + +$mapped( + 'legacy history exposes change messages', + 'javascript/test/legacy_tests.ts', + 1618, + 'should make change messages accessible', + function () use ($port): void { + $doc = $port->setWithMessage($port->init('aabbcc'), 'books', [], 'Empty Bookshelf'); + $doc = $port->insertListElementsWithMessage($doc, 'books', 0, ['Nineteen Eighty-Four'], 'Add Orwell'); + $doc = $port->insertListElementsWithMessage($doc, 'books', 1, ['Brave New World'], 'Add Huxley'); + + sameArray($doc->toArray()['books'], ['Nineteen Eighty-Four', 'Brave New World'], 'message-bearing list updates should materialize the final list'); + sameArray( + array_map(static fn (array $entry): ?string => $entry['change']['message'], $port->getHistory($doc)), + ['Empty Bookshelf', 'Add Orwell', 'Add Huxley'], + 'history should expose each change message' + ); + } +); + +$mapped( + 'hasHeads returns true for heads present in the document history', + 'javascript/test/basic_test.ts', + 685, + 'should return true if the document in question has all the heads', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 'b'); + $heads = $port->getHeads($doc); + + truthy($port->hasHeads($doc, $heads), 'document should report its own heads as present'); + } +); + +$mapped( + 'hasHeads returns false for heads absent from another document', + 'javascript/test/basic_test.ts', + 692, + 'should return false if the document does not have the heads', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 'b'); + $heads = $port->getHeads($doc); + $otherDoc = $port->init('bbbbbb'); + + truthy(! $port->hasHeads($otherDoc, $heads), 'different document should not report unknown heads as present'); + } +); + +$mapped( + 'basic topo history traversal returns merged change hashes in order', + 'javascript/test/basic_test.ts', + 703, + 'should return the correct history', + function () use ($port): void { + $doc = $port->from(['a' => 'a'], 'aaaaaa'); + $hash1 = $port->decodeChange($port->getLastLocalChange($doc) ?? [])['hash']; + $doc2 = $port->clone($doc, 'bbbbbb'); + $doc = $port->set($doc, 'a', 'b'); + $hash2 = $port->decodeChange($port->getLastLocalChange($doc) ?? [])['hash']; + $doc2 = $port->set($doc2, 'a', 'c'); + $hash3 = $port->decodeChange($port->getLastLocalChange($doc2) ?? [])['hash']; + $doc = $port->mergeDocuments($doc, $doc2); + + sameArray( + $port->topoHistoryTraversal($doc), + [$hash1, $hash2, $hash3], + 'topological history traversal should expose merged change hashes in dependency order' + ); + } +); + +$mapped( + 'basic inspectChange returns decoded change metadata', + 'javascript/test/basic_test.ts', + 730, + 'should return a decoded representation of the change', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $doc = $port->setWithTime($doc, 'a', 'a', 123); + $hash = $port->topoHistoryTraversal($doc)[0]; + + sameArray( + $port->inspectChange($doc, $hash) ?? [], + [ + 'actor' => 'aaaaaa', + 'deps' => [], + 'hash' => $hash, + 'message' => null, + 'ops' => [ + [ + 'action' => 'makeText', + 'key' => 'a', + 'obj' => '_root', + 'pred' => [], + ], + [ + 'action' => 'set', + 'elemId' => '_head', + 'insert' => true, + 'obj' => '1@aaaaaa', + 'pred' => [], + 'value' => 'a', + ], + ], + 'seq' => 1, + 'startOp' => 1, + 'time' => 123, + ], + 'inspectChange should expose decoded metadata and text operations' + ); + } +); + +$mapped( + 'basic stats reports native change and operation counts', + 'javascript/test/basic_test.ts', + 765, + 'should return stats about the document', + function () use ($port): void { + $doc = $port->set($port->init('aabbcc'), 'a', 1); + $doc = $port->set($doc, 'a', 2); + $stats = $port->stats($doc); + + same($stats['numChanges'], 2, 'stats should report two native changes'); + same($stats['numOps'], 2, 'stats should report two native operations'); + truthy(is_string($stats['cargoPackageName']) && $stats['cargoPackageName'] !== '', 'stats should expose a package-name string'); + truthy(is_string($stats['cargoPackageVersion']) && $stats['cargoPackageVersion'] !== '', 'stats should expose a package-version string'); + truthy(is_string($stats['rustcVersion']) && $stats['rustcVersion'] !== '', 'stats should expose a runtime-version string'); + } +); + +$mapped( + 'native save/load round trip hydrates a materialized document', + 'javascript/test/basic_test.ts', + 578, + 'can load a doc without checking the heads', + function () use ($port): void { + $doc = $port->from(['count' => 260], 'aaaaaa'); + $loaded = $port->load($port->save($doc)); + + sameArray($loaded->toArray(), ['count' => 260], 'loaded native payload should materialize the saved root'); + } +); + +$mapped( + 'legacy changes API returns an empty list for an empty document', + 'javascript/test/legacy_tests.ts', + 1639, + 'should return an empty list on an empty document', + function () use ($port): void { + sameArray($port->getAllChanges($port->init('aaaaaa')), [], 'empty document should have no changes'); + } +); + +$mapped( + 'legacy changes API returns an empty list when nothing changed', + 'javascript/test/legacy_tests.ts', + 1644, + 'should return an empty list when nothing changed', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'birds', ['Chaffinch']); + + sameArray($port->getChanges($doc, $doc), [], 'getChanges should be empty for identical document heads'); + } +); + +$mapped( + 'legacy changes API does nothing when applying an empty change list', + 'javascript/test/legacy_tests.ts', + 1652, + 'should do nothing when applying an empty list of changes', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'birds', ['Chaffinch']); + $applied = $port->applyChanges($doc, []); + + sameArray($applied->toArray(), $doc->toArray(), 'applying no changes should preserve the document'); + } +); + +$mapped( + 'legacy changes API returns all changes compared to an empty document', + 'javascript/test/legacy_tests.ts', + 1660, + 'should return all changes when compared to an empty document', + function () use ($port): void { + $empty = $port->init('aaaaaa'); + $doc = $port->set($empty, 'birds', ['Chaffinch']); + $doc = $port->set($doc, 'birds', ['Chaffinch', 'Bullfinch']); + + same(count($port->getChanges($empty, $doc)), 2, 'empty-to-current diff should return both changes'); + } +); + +$mapped( + 'legacy changes API reconstructs a document copy from scratch', + 'javascript/test/legacy_tests.ts', + 1673, + 'should allow a document copy to be reconstructed from scratch', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'birds', ['Chaffinch']); + $doc = $port->set($doc, 'birds', ['Chaffinch', 'Bullfinch']); + $copy = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc)); + + sameArray($copy->toArray(), ['birds' => ['Chaffinch', 'Bullfinch']], 'all changes should reconstruct the list value'); + } +); + +$mapped( + 'legacy changes API returns changes since the last given version', + 'javascript/test/legacy_tests.ts', + 1687, + 'should return changes since the last given version', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'birds', ['Chaffinch']); + $doc2 = $port->set($doc1, 'birds', ['Chaffinch', 'Bullfinch']); + + same(count($port->getAllChanges($doc1)), 1, 'first document should have one change'); + same(count($port->getChanges($doc1, $doc2)), 1, 'diff from first to second document should have one change'); + } +); + +$mapped( + 'legacy changes API incrementally applies changes since the last version', + 'javascript/test/legacy_tests.ts', + 1702, + 'should incrementally apply changes since the last given version', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'birds', ['Chaffinch']); + $changes1 = $port->getAllChanges($doc1); + $doc2 = $port->set($doc1, 'birds', ['Chaffinch', 'Bullfinch']); + $changes2 = $port->getChanges($doc1, $doc2); + + $applied1 = $port->applyChanges($port->init('bbbbbb'), $changes1); + $applied2 = $port->applyChanges($applied1, $changes2); + + sameArray($applied1->toArray(), ['birds' => ['Chaffinch']], 'first batch should reconstruct the first list value'); + sameArray($applied2->toArray(), ['birds' => ['Chaffinch', 'Bullfinch']], 'second batch should add the second list value'); + } +); + +$mapped( + 'legacy changes API handles updates to a list element', + 'javascript/test/legacy_tests.ts', + 1719, + 'should handle updates to a list element', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'birds', ['Chaffinch', 'Bullfinch']); + $doc2 = $port->set($doc1, 'birds', ['Goldfinch', 'Bullfinch']); + $applied = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc2)); + $birds = $applied->toArray()['birds']; + + sameArray($birds, ['Goldfinch', 'Bullfinch'], 'all changes should replay the latest list element value'); + same($port->getConflicts($birds, 0), null, 'single-writer list element update should report no conflict'); + } +); + +$mapped( + 'legacy changes API handles updates to a text object', + 'javascript/test/legacy_tests.ts', + 1734, + 'should handle updates to a text object', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', 'ab'); + $doc2 = $port->splice($doc1, 'text', 0, 1, 'A'); + $applied = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc2)); + $text = $applied->text('text'); + + same($text->charAt(0), 'A', 'text replay should replace the first character'); + same($text->charAt(1), 'b', 'text replay should preserve the second character'); + same($text->toString(), 'Ab', 'text replay should materialize the updated string'); + } +); + +$mapped( + 'legacy changes API reports missing dependencies for out-of-order applyChanges', + 'javascript/test/legacy_tests.ts', + 1764, + 'should report missing dependencies with out-of-order applyChanges', + function () use ($port): void { + $doc0 = $port->init('aaaaaa'); + $doc1 = $port->set($doc0, 'test', ['a']); + $changes01 = $port->getAllChanges($doc1); + + $doc2 = $port->set($doc1, 'test', ['b']); + $changes12 = $port->getChanges($doc1, $doc2); + + $doc3 = $port->set($doc2, 'test', ['c']); + $changes23 = $port->getChanges($doc2, $doc3); + + $outOfOrder = $port->applyChanges($port->init('bbbbbb'), $changes23); + $outOfOrder = $port->applyChanges($outOfOrder, $changes12); + $firstChange = $port->decodeChange($changes01[0]); + + sameArray( + $port->getMissingDeps($outOfOrder, []), + [$firstChange['hash']], + 'out-of-order applyChanges should report the omitted initial change hash' + ); + } +); + +$mapped( + 'sync protocol sends an empty-data sync message', + 'javascript/test/sync_test.ts', + 54, + 'should send a sync message implying no local data', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + [$syncState, $message] = $port->generateSyncMessage($doc, $port->initSyncState()); + $decoded = $port->decodeSyncMessage($message); + + truthy($decoded !== null, 'first empty sync attempt should produce a message'); + sameArray($decoded['heads'], [], 'empty sync message should advertise no heads'); + sameArray($decoded['need'], [], 'empty sync message should request no changes'); + same(count($decoded['have']), 1, 'empty sync message should include one have entry'); + sameArray($decoded['have'][0]['lastSync'], [], 'empty sync message should have no last sync heads'); + same($decoded['have'][0]['bloom']['byteLength'], 0, 'empty sync message should use an empty bloom filter'); + sameArray($decoded['changes'], [], 'empty sync message should include no changes'); + truthy(is_array($syncState), 'sync state should remain a PHP array after message generation'); + } +); + +$rustMapped( + 'rust sync message encoding round-trips an empty message', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-encode-decode-empty-message', + 'sync::tests::encode_decode_empty_message', + function () use ($port): void { + $message = [ + 'heads' => [], + 'need' => [], + 'have' => [], + 'changes' => [], + 'readOnly' => false, + 'syncReset' => false, + ]; + + $encoded = $port->encodeSyncMessage($message); + $decoded = $port->decodeEncodedSyncMessage($encoded); + + truthy($encoded !== '', 'empty sync message should encode to a non-empty native payload'); + sameArray($decoded ?? [], $message, 'empty sync message should parse back to the normalized message fields'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol does not keep replying for two empty documents', + 'javascript/test/sync_test.ts', + 68, + 'should not reply after the first round if we have no data as well', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + if ($message1 !== null) { + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message1); + } + + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + if ($message2 !== null) { + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message2); + } + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + same($message1, null, 'first empty peer should stop sending once the empty state is known'); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + same($message2, null, 'second empty peer should not reply with no local data'); + } +); + +$mapped( + 'sync protocol equal heads do not need a reply message', + 'javascript/test/sync_test.ts', + 91, + 'repos with equal heads do not need a reply message', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $items = []; + $doc1 = $port->set($doc1, 'n', $items); + for ($i = 0; $i < 10; ++$i) { + $items[] = $i; + $doc1 = $port->set($doc1, 'n', $items); + } + + $doc2 = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc1)); + sameArray($doc2->toArray(), $doc1->toArray(), 'peer documents should start with equal materialized state'); + + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + sameArray($sync1['lastSentHeads'], $port->getHeads($doc1), 'first peer should remember advertised heads'); + + if ($message1 !== null) { + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message1); + } + + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + sameArray($sync2['lastSentHeads'], $port->getHeads($doc2), 'second peer should remember known equal heads'); + if ($message2 !== null) { + $decoded = $port->decodeSyncMessage($message2); + sameArray($decoded['heads'] ?? [], $port->getHeads($doc2), 'optional first response should advertise the equal heads'); + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message2); + } + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + same($message1, null, 'first peer should need no further sync message'); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + same($message2, null, 'second peer should need no further sync message'); + } +); + +$rustMapped( + 'rust sync message generation is quiet after first send', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-sync-message-twice-does-nothing', + 'sync::tests::generate_sync_message_twice_does_nothing', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $syncState = $port->initSyncState(); + + [$syncState, $message] = $port->generateSyncMessage($doc, $syncState); + truthy($message !== null, 'first sync message should advertise local heads and changes'); + + [$syncState, $message] = $port->generateSyncMessage($doc, $syncState); + same($message, null, 'second sync generation without new data should be quiet'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync first response is sent even with no missing changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-first-response-is-some-even-if-no-changes', + 'sync::tests::first_response_is_some_even_if_no_changes', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'key', 'value'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + truthy($message1 !== null, 'first peer should send the initial sync message'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message1); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + + truthy($message2 !== null, 'peer should send its first response even when it has no missing changes'); + sameArray(($port->decodeSyncMessage($message2)['changes'] ?? []), [], 'first response with equal heads should not resend changes'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol offers all changes to an empty peer', + 'javascript/test/sync_test.ts', + 127, + 'n1 should offer all changes to n2 when starting from nothing', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $items = []; + $doc1 = $port->set($doc1, 'n', $items); + for ($i = 0; $i < 10; ++$i) { + $items[] = $i; + $doc1 = $port->set($doc1, 'n', $items); + } + + $doc2 = $port->init('bbbbbb'); + truthy($doc1->toArray() !== $doc2->toArray(), 'one-sided sync test should start with different documents'); + + [, $message] = $port->generateSyncMessage($doc1, $port->initSyncState()); + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null, 'data-bearing peer should produce an initial sync message'); + same(count($decoded['changes']), count($port->getAllChanges($doc1)), 'initial one-sided sync message should offer every local change'); + + [$after1, $after2] = syncDocuments($port, $doc1, $doc2); + sameArray($after1->toArray(), $after2->toArray(), 'one-sided sync should converge materialized state'); + sameArray($port->getHeads($after1), $port->getHeads($after2), 'one-sided sync should converge heads'); + } +); + +$mapped( + 'sync protocol synchronizes peers when one has commits', + 'javascript/test/sync_test.ts', + 141, + 'should sync peers where one has commits the other does not', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $items = []; + $doc1 = $port->set($doc1, 'n', $items); + for ($i = 0; $i < 10; ++$i) { + $items[] = $i; + $doc1 = $port->set($doc1, 'n', $items); + } + + $doc2 = $port->init('bbbbbb'); + truthy($doc1->toArray() !== $doc2->toArray(), 'peer missing commits should start behind'); + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2); + sameArray($doc1->toArray(), $doc2->toArray(), 'sync should copy missing commits to the empty peer'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'sync should leave both peers at the same heads'); + } +); + +$mapped( + 'sync protocol converges with prior sync state', + 'javascript/test/sync_test.ts', + 155, + 'should work with prior sync state', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), $doc2->toArray(), 'initial sync should converge before reusing sync state'); + + for ($i = 5; $i < 10; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + } + + truthy($doc1->toArray() !== $doc2->toArray(), 'prior-state sync should start with new local commits on one peer'); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), $doc2->toArray(), 'prior-state sync should converge after later commits'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'prior-state sync should converge heads after later commits'); + } +); + +$mapped( + 'sync protocol records shared heads after synchronization', + 'javascript/test/sync_test.ts', + 403, + 'should ensure non-empty state after sync', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 3; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($sync1['sharedHeads'], $port->getHeads($doc1), 'first sync state should retain converged shared heads'); + sameArray($sync2['sharedHeads'], $port->getHeads($doc1), 'second sync state should retain converged shared heads'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'documents should converge to the same heads'); + } +); + +$mapped( + 'sync protocol resyncs after peer crash with older data', + 'javascript/test/sync_test.ts', + 417, + 'should re-sync after one node crashed with data loss', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 3; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $recovered = $port->clone($doc2); + $recoveredSync = json_decode(json_encode($sync2, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + + for ($i = 3; $i < 6; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge before crash recovery'); + sameArray($doc1->toArray(), $doc2->toArray(), 'peers should materialize the same state before crash recovery'); + + for ($i = 6; $i < 9; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + $sync1 = json_decode(json_encode($sync1, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + truthy($port->getHeads($doc1) !== $port->getHeads($recovered), 'recovered peer should start behind current heads'); + sameArray($doc1->toArray(), ['x' => 8], 'current peer should contain the latest value'); + sameArray($recovered->toArray(), ['x' => 2], 'recovered peer should contain only the pre-crash value'); + + [$doc1, $recovered, $sync1, $recoveredSync] = syncDocuments($port, $doc1, $recovered, $sync1, $recoveredSync); + sameArray($port->getHeads($doc1), $port->getHeads($recovered), 'recovered peer should converge to current heads'); + sameArray($doc1->toArray(), $recovered->toArray(), 'recovered peer should converge to current materialized state'); + } +); + +$mapped( + 'sync protocol resyncs after peer data loss without reconnect', + 'javascript/test/sync_test.ts', + 459, + 'should resync after one node experiences data loss without disconnecting', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 3; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge before data loss'); + sameArray($doc1->toArray(), $doc2->toArray(), 'peers should materialize the same state before data loss'); + + $doc2AfterDataLoss = $port->init('89abcdef'); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2AfterDataLoss, $sync1, $port->initSyncState()); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peer with data loss should regain current heads'); + sameArray($doc1->toArray(), $doc2->toArray(), 'peer with data loss should regain current materialized state'); + sameArray($doc2->toArray(), ['x' => 2], 'recovered peer should contain the pre-loss value'); + } +); + +$mapped( + 'sync protocol converges diverged documents without prior sync state', + 'javascript/test/sync_test.ts', + 350, + 'should work without prior sync state', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2); + + for ($i = 10; $i < 15; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + for ($i = 15; $i < 18; ++$i) { + $doc2 = $port->setWithTime($doc2, 'x', $i, 0); + } + + truthy($doc1->toArray() !== $doc2->toArray(), 'diverged peers should start with different materialized values'); + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'diverged peers without prior state should converge heads'); + sameArray($doc1->toArray(), $doc2->toArray(), 'diverged peers without prior state should converge materialized state'); + sameArray($port->getConflicts($doc1, 'x') ?? [], $port->getConflicts($doc2, 'x') ?? [], 'diverged peers should converge conflict values'); + } +); + +$mapped( + 'sync protocol converges diverged documents with prior sync state', + 'javascript/test/sync_test.ts', + 374, + 'should work with prior sync state', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + for ($i = 10; $i < 15; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + for ($i = 15; $i < 18; ++$i) { + $doc2 = $port->setWithTime($doc2, 'x', $i, 0); + } + + $sync1 = json_decode(json_encode($sync1, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + $sync2 = json_decode(json_encode($sync2, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + truthy($doc1->toArray() !== $doc2->toArray(), 'diverged peers with prior state should start with different materialized values'); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'diverged peers with prior state should converge heads'); + sameArray($doc1->toArray(), $doc2->toArray(), 'diverged peers with prior state should converge materialized state'); + sameArray($port->getConflicts($doc1, 'x') ?? [], $port->getConflicts($doc2, 'x') ?? [], 'diverged peers with prior state should converge conflict values'); + } +); + +$mapped( + 'sync protocol handles changes concurrent to last sync heads', + 'javascript/test/sync_test.ts', + 482, + 'should handle changes concurrent to the last sync heads', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $doc3 = $port->init('fedcba98'); + $sync12 = $port->initSyncState(); + $sync21 = $port->initSyncState(); + $sync23 = $port->initSyncState(); + $sync32 = $port->initSyncState(); + + $doc1 = $port->setWithTime($doc1, 'x', 1, 0); + [$doc1, $doc2, $sync12, $sync21] = syncDocuments($port, $doc1, $doc2, $sync12, $sync21); + [$doc2, $doc3, $sync23, $sync32] = syncDocuments($port, $doc2, $doc3, $sync23, $sync32); + + $doc1 = $port->setWithTime($doc1, 'x', 2, 0); + [$doc1, $doc2, $sync12, $sync21] = syncDocuments($port, $doc1, $doc2, $sync12, $sync21); + + $doc1 = $port->setWithTime($doc1, 'x', 3, 0); + $doc2 = $port->setWithTime($doc2, 'x', 4, 0); + $doc3 = $port->setWithTime($doc3, 'x', 5, 0); + + $doc3Change = $port->getLastLocalChange($doc3); + truthy($doc3Change !== null, 'third peer should expose its latest local change'); + $doc2 = $port->applyChanges($doc2, [$doc3Change]); + + [$doc1, $doc2, $sync12, $sync21] = syncDocuments($port, $doc1, $doc2, $sync12, $sync21); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'sync should converge heads with a third-party concurrent change'); + sameArray($doc1->toArray(), $doc2->toArray(), 'sync should converge materialized state with a third-party concurrent change'); + } +); + +$mapped( + 'sync protocol handles branching and merging histories', + 'javascript/test/sync_test.ts', + 518, + 'should handle histories with lots of branching and merging', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $doc3 = $port->init('fedcba98'); + + $doc1 = $port->setWithTime($doc1, 'x', 0, 0); + $initialChange = $port->getLastLocalChange($doc1); + truthy($initialChange !== null, 'initial peer change should be available'); + $doc2 = $port->applyChanges($doc2, [$initialChange]); + $doc3 = $port->applyChanges($doc3, [$initialChange]); + $doc3 = $port->setWithTime($doc3, 'x', 1, 0); + + for ($i = 1; $i < 20; ++$i) { + $doc1 = $port->setWithTime($doc1, 'n1', $i, 0); + $doc2 = $port->setWithTime($doc2, 'n2', $i, 0); + + $change1 = $port->getLastLocalChange($doc1); + $change2 = $port->getLastLocalChange($doc2); + truthy($change1 !== null && $change2 !== null, 'branch peers should expose latest changes'); + $doc1 = $port->applyChanges($doc1, [$change2]); + $doc2 = $port->applyChanges($doc2, [$change1]); + } + + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $thirdPeerChange = $port->getLastLocalChange($doc3); + truthy($thirdPeerChange !== null, 'third peer concurrent change should be available'); + $doc2 = $port->applyChanges($doc2, [$thirdPeerChange]); + $doc1 = $port->setWithTime($doc1, 'n1', 'final', 0); + $doc2 = $port->setWithTime($doc2, 'n2', 'final', 0); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $left = $doc1->toArray(); + $right = $doc2->toArray(); + ksort($left); + ksort($right); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'branching sync should converge heads'); + sameArray($left, $right, 'branching sync should converge materialized state'); + sameArray($left, ['n1' => 'final', 'n2' => 'final', 'x' => 1], 'branching sync should include the late third-peer change and final local edits'); + } +); + +$rustMapped( + 'rust sync handles lots of branching and merging', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-lots-of-branching-and-merging', + 'sync::tests::should_handle_lots_of_branching_and_merging', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $doc3 = $port->init('fedcba98'); + + $doc1 = $port->setWithTime($doc1, 'x', 0, 0); + $initialChange = $port->getLastLocalChange($doc1); + truthy($initialChange !== null, 'initial branch change should be available'); + $doc2 = $port->applyChanges($doc2, [$initialChange]); + $doc3 = $port->applyChanges($doc3, [$initialChange]); + $doc3 = $port->setWithTime($doc3, 'x', 1, 0); + + for ($i = 1; $i < 20; ++$i) { + $doc1 = $port->setWithTime($doc1, 'n1', $i, 0); + $doc2 = $port->setWithTime($doc2, 'n2', $i, 0); + $change1 = $port->getLastLocalChange($doc1); + $change2 = $port->getLastLocalChange($doc2); + truthy($change1 !== null && $change2 !== null, 'branch peers should expose latest local changes'); + $doc1 = $port->applyChanges($doc1, [$change2]); + $doc2 = $port->applyChanges($doc2, [$change1]); + } + + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $thirdPeerChange = $port->getLastLocalChange($doc3); + truthy($thirdPeerChange !== null, 'third peer concurrent change should be available'); + $doc2 = $port->applyChanges($doc2, [$thirdPeerChange]); + $doc1 = $port->setWithTime($doc1, 'n1', 'final', 0); + $doc2 = $port->setWithTime($doc2, 'n1', 'final', 0); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $left = $doc1->toArray(); + $right = $doc2->toArray(); + ksort($left); + ksort($right); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'rust branching sync should converge heads'); + sameArray($left, $right, 'rust branching sync should converge materialized state'); + same($left['n1'] ?? null, 'final', 'rust branching sync should keep the final shared n1 value'); + same($left['n2'] ?? null, 19, 'rust branching sync should retain the second branch sequence value'); + same($left['x'] ?? null, 1, 'rust branching sync should include the late third-peer change'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol converges two nodes without connection reset', + 'javascript/test/sync_test.ts', + 657, + 'should sync two nodes without connection reset', + function () use ($port): void { + $doc1 = $port->setWithTime($port->init('aaaaaa'), 'x', 'initial @ n1', 0); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $doc1 = $port->setWithTime($doc1, 'x', 'final @ n1', 0); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ n2', 0); + $expectedHeads = array_values(array_unique(array_merge($port->getHeads($doc1), $port->getHeads($doc2)))); + sort($expectedHeads); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($port->getHeads($doc1), $expectedHeads, 'first peer should retain both concurrent heads after sync'); + sameArray($port->getHeads($doc2), $expectedHeads, 'second peer should retain both concurrent heads after sync'); + sameArray($doc1->toArray(), $doc2->toArray(), 'two-node sync without reset should converge materialized state'); + sameArray($port->getConflicts($doc1, 'x') ?? [], $port->getConflicts($doc2, 'x') ?? [], 'two-node sync without reset should converge conflict values'); + } +); + +$mapped( + 'sync protocol converges two nodes with connection reset', + 'javascript/test/sync_test.ts', + 664, + 'should sync two nodes with connection reset', + function () use ($port): void { + $doc1 = $port->setWithTime($port->init('aaaaaa'), 'x', 'initial @ n1', 0); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $doc1 = $port->setWithTime($doc1, 'x', 'final @ n1', 0); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ n2', 0); + $expectedHeads = array_values(array_unique(array_merge($port->getHeads($doc1), $port->getHeads($doc2)))); + sort($expectedHeads); + + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($port->getHeads($doc1), $expectedHeads, 'first peer should retain both concurrent heads after reset sync'); + sameArray($port->getHeads($doc2), $expectedHeads, 'second peer should retain both concurrent heads after reset sync'); + sameArray($doc1->toArray(), $doc2->toArray(), 'two-node sync with reset should converge materialized state'); + sameArray($port->getConflicts($doc1, 'x') ?? [], $port->getConflicts($doc2, 'x') ?? [], 'two-node sync with reset should converge conflict values'); + } +); + +$mapped( + 'sync protocol explicitly recovers a false-positive advertised head', + 'javascript/test/sync_test.ts', + 565, + 'should handle a false-positive head', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', 'final @ n1', 0); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ n2', 0); + $head1 = $port->getHeads($doc1); + $head2 = $port->getHeads($doc2); + $expectedHeads = array_values(array_unique(array_merge($head1, $head2))); + sort($expectedHeads); + + [$sync1, $messageFrom1] = $port->generateSyncMessage($doc1, $sync1); + $falsePositive = $port->decodeSyncMessage($messageFrom1); + truthy($falsePositive !== null && count($falsePositive['changes']) > 0, 'first peer should initially have the missing change available'); + $falsePositive['changes'] = []; + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $falsePositive); + [$sync2, $requestForHead1] = $port->generateSyncMessage($doc2, $sync2); + $decodedRequestForHead1 = $port->decodeSyncMessage($requestForHead1); + truthy($decodedRequestForHead1 !== null, 'second peer should reply after seeing an advertised-but-missing head'); + sameArray($decodedRequestForHead1['need'], $head1, 'second peer should explicitly request the false-positive head'); + sameArray($decodedRequestForHead1['changes'], [], 'second peer should not pretend the missing false-positive change was received'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedRequestForHead1); + [$sync1, $responseWithHead1] = $port->generateSyncMessage($doc1, $sync1); + $decodedResponseWithHead1 = $port->decodeSyncMessage($responseWithHead1); + truthy($decodedResponseWithHead1 !== null && count($decodedResponseWithHead1['changes']) > 0, 'first peer should answer the explicit false-positive request'); + same($decodedResponseWithHead1['changes'][0]['hash'] ?? null, $head1[0], 'first peer should send the requested false-positive head first'); + sameArray($decodedResponseWithHead1['need'], $head2, 'first peer should request the second peer concurrent head while answering'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decodedResponseWithHead1); + [$sync2, $responseWithHead2] = $port->generateSyncMessage($doc2, $sync2); + $decodedResponseWithHead2 = $port->decodeSyncMessage($responseWithHead2); + truthy($decodedResponseWithHead2 !== null && count($decodedResponseWithHead2['changes']) > 0, 'second peer should answer the reciprocal explicit need'); + same($decodedResponseWithHead2['changes'][0]['hash'] ?? null, $head2[0], 'second peer should send its requested concurrent head first'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedResponseWithHead2); + + sameArray($port->getHeads($doc1), $expectedHeads, 'first peer should converge after false-positive recovery'); + sameArray($port->getHeads($doc2), $expectedHeads, 'second peer should converge after false-positive recovery'); + sameArray($doc1->toArray(), $doc2->toArray(), 'false-positive recovery should converge materialized state'); + } +); + +$rustMapped( + 'rust sync handles a false-positive advertised head', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-false-positive-head', + 'sync::tests::should_handle_false_positive_head', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', '0 @ n1', 0); + $doc2 = $port->setWithTime($doc2, 'x', '0 @ n2', 0); + $head1 = $port->getHeads($doc1); + $head2 = $port->getHeads($doc2); + $expectedHeads = array_values(array_unique(array_merge($head1, $head2))); + sort($expectedHeads); + + [$sync1, $messageFrom1] = $port->generateSyncMessage($doc1, $sync1); + $falsePositive = $port->decodeSyncMessage($messageFrom1); + truthy($falsePositive !== null && count($falsePositive['changes']) > 0, 'rust false-positive peer should initially have the missing change available'); + $falsePositive['changes'] = []; + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $falsePositive); + [$sync2, $requestForHead1] = $port->generateSyncMessage($doc2, $sync2); + $decodedRequestForHead1 = $port->decodeSyncMessage($requestForHead1); + truthy($decodedRequestForHead1 !== null, 'rust false-positive receiver should reply after seeing an advertised-but-missing head'); + sameArray($decodedRequestForHead1['need'], $head1, 'rust false-positive receiver should explicitly request the advertised missing head'); + sameArray($decodedRequestForHead1['changes'], [], 'rust false-positive receiver should not send unrelated changes before request resolution'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedRequestForHead1); + [$sync1, $responseWithHead1] = $port->generateSyncMessage($doc1, $sync1); + $decodedResponseWithHead1 = $port->decodeSyncMessage($responseWithHead1); + truthy($decodedResponseWithHead1 !== null && count($decodedResponseWithHead1['changes']) > 0, 'rust false-positive sender should answer the explicit head request'); + same($decodedResponseWithHead1['changes'][0]['hash'] ?? null, $head1[0], 'rust false-positive sender should send the requested head first'); + sameArray($decodedResponseWithHead1['need'], $head2, 'rust false-positive sender should request the reciprocal concurrent head'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decodedResponseWithHead1); + [$sync2, $responseWithHead2] = $port->generateSyncMessage($doc2, $sync2); + $decodedResponseWithHead2 = $port->decodeSyncMessage($responseWithHead2); + truthy($decodedResponseWithHead2 !== null && count($decodedResponseWithHead2['changes']) > 0, 'rust false-positive receiver should answer the reciprocal explicit need'); + same($decodedResponseWithHead2['changes'][0]['hash'] ?? null, $head2[0], 'rust false-positive receiver should send its requested concurrent head first'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedResponseWithHead2); + + sameArray($port->getHeads($doc1), $expectedHeads, 'rust first peer should converge after false-positive recovery'); + sameArray($port->getHeads($doc2), $expectedHeads, 'rust second peer should converge after false-positive recovery'); + sameArray($doc1->toArray(), $doc2->toArray(), 'rust false-positive recovery should converge materialized state'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol allows explicitly requesting a false-positive hash', + 'javascript/test/sync_test.ts', + 818, + 'should allow the false-positive hash to be explicitly requested', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', '1 @ n1', 0); + $doc2 = $port->setWithTime($doc2, 'x', '1 @ n2', 0); + $head1 = $port->getHeads($doc1); + $head2 = $port->getHeads($doc2); + + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null && count($decoded['changes']) > 0, 'first peer should have a change before simulating the false-positive Bloom filter'); + $decoded['changes'] = []; + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decoded); + [$sync2, $message] = $port->generateSyncMessage($doc2, $sync2); + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null, 'second peer should send a response after receiving an advertised missing head'); + sameArray($decoded['changes'], [], 'second peer should not send the false-positive change before it is explicitly requested'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decoded); + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + $request = $port->decodeSyncMessage($message); + truthy($request !== null, 'first peer should generate an explicit request after the false-positive response'); + sameArray($request['need'], $head2, 'first peer should explicitly request the false-positive hash'); + truthy(count($request['changes']) > 0, 'first peer should also answer the second peer request for its advertised head'); + same($request['changes'][0]['hash'] ?? null, $head1[0], 'first peer should send its requested head while asking for the false-positive hash'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $request); + [$sync2, $message] = $port->generateSyncMessage($doc2, $sync2); + $response = $port->decodeSyncMessage($message); + truthy($response !== null && count($response['changes']) > 0, 'second peer should fulfill the explicit false-positive request'); + same($response['changes'][0]['hash'] ?? null, $head2[0], 'second peer should send the explicitly requested false-positive head first'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $response); + $expectedHeads = array_values(array_unique(array_merge($head1, $head2))); + sort($expectedHeads); + sameArray($port->getHeads($doc1), $expectedHeads, 'first peer should include both heads after explicit false-positive recovery'); + sameArray($port->getHeads($doc2), $expectedHeads, 'second peer should include both heads after explicit false-positive recovery'); + } +); + +$mapped( + 'sync protocol resolves a false-positive dependency without an extra request', + 'javascript/test/sync_test.ts', + 701, + 'should not require an additional request when a false-positive depends on a true-negative', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', '1 @ n1', 0); + $doc1 = $port->setWithTime($doc1, 'x', '2 @ n1', 0); + $doc1 = $port->setWithTime($doc1, 'x', 'final @ n1', 0); + $head1 = $port->getHeads($doc1); + + $doc2 = $port->setWithTime($doc2, 'x', '1 @ n2', 0); + $n2Change1 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', '2 @ n2', 0); + $n2Change2 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ n2', 0); + $n2Change3 = $port->getLastLocalChange($doc2); + $head2 = $port->getHeads($doc2); + truthy($n2Change1 !== null && $n2Change2 !== null && $n2Change3 !== null, 'second peer should expose all three branch changes'); + + [$sync1, $messageFrom1] = $port->generateSyncMessage($doc1, $sync1); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $messageFrom1); + [$sync2, $messageFrom2] = $port->generateSyncMessage($doc2, $sync2); + $truncated = $port->decodeSyncMessage($messageFrom2); + truthy($truncated !== null && count($truncated['changes']) >= 3, 'second peer response should contain the full branch before simulating a false positive'); + $truncated['changes'] = [$n2Change3]; + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $truncated); + sameArray($sync1['needHeads'], [$n2Change2['hash']], 'receiver should request the omitted dependency rather than the already advertised head'); + + [$sync1, $request] = $port->generateSyncMessage($doc1, $sync1); + $decodedRequest = $port->decodeSyncMessage($request); + truthy($decodedRequest !== null, 'receiver should generate a dependency request after the truncated response'); + sameArray($decodedRequest['need'], [$n2Change2['hash']], 'dependency request should target the missing false-positive dependency'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decodedRequest); + [$sync2, $response] = $port->generateSyncMessage($doc2, $sync2); + $decodedResponse = $port->decodeSyncMessage($response); + truthy($decodedResponse !== null && count($decodedResponse['changes']) >= 2, 'peer should answer the dependency request with the dependency chain'); + same($decodedResponse['changes'][0]['hash'] ?? null, $n2Change2['hash'], 'response should send the explicitly requested dependency first'); + same($decodedResponse['changes'][1]['hash'] ?? null, $n2Change1['hash'], 'response should include the true-negative ancestor in the same round'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedResponse); + sameArray($sync1['needHeads'], [], 'receiving the dependency chain should clear the need list without another request'); + sameArray($port->getMissingDeps($doc1), [], 'false-positive dependency recovery should leave no missing dependencies'); + + $expectedHeads = array_values(array_unique(array_merge($head1, $head2))); + sort($expectedHeads); + sameArray($port->getHeads($doc1), $expectedHeads, 'first peer should converge to both final branch heads'); + sameArray($port->getHeads($doc2), $expectedHeads, 'second peer should retain both final branch heads'); + } +); + +$pendingMapped( + 'pending sync protocol surfaces an unresolved false-positive branch to a third node', + 'javascript/test/sync_test.ts', + 672, + 'should sync three nodes', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', '29 @ n1', 0); + $doc1 = $port->setWithTime($doc1, 'x', 'final @ n1', 0); + $n1Head = $port->getHeads($doc1); + + $doc2 = $port->setWithTime($doc2, 'x', '29 @ n2', 0); + $n2Change1 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ n2', 0); + $n2Change2 = $port->getLastLocalChange($doc2); + truthy($n2Change1 !== null && $n2Change2 !== null, 'second peer should expose the two branch changes used by the pending three-node scenario'); + + [$sync1, $messageFrom1] = $port->generateSyncMessage($doc1, $sync1); + [$sync2, $messageFrom2] = $port->generateSyncMessage($doc2, $sync2); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $messageFrom1); + + $truncatedFor1 = $port->decodeSyncMessage($messageFrom2); + truthy($truncatedFor1 !== null && count($truncatedFor1['changes']) >= 2, 'second peer should initially offer both branch changes'); + $truncatedFor1['changes'] = [$n2Change2]; + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $truncatedFor1); + + $unresolvedHeads = array_values(array_unique(array_merge($n1Head, [$n2Change2['hash']]))); + sort($unresolvedHeads); + sameArray($port->getHeads($doc1), $unresolvedHeads, 'first peer should retain the unresolved false-positive branch head while tracking the missing dependency'); + sameArray($sync1['needHeads'], [$n2Change1['hash']], 'first peer should ask for the missing false-positive dependency'); + sameArray($port->getMissingDeps($doc1), [$n2Change1['hash']], 'first peer should expose the unresolved false-positive dependency'); + + $doc3 = $port->init('fedcba98'); + $sync13 = $port->initSyncState(); + $sync31 = $port->initSyncState(); + + [$sync13, $messageFrom1To3] = $port->generateSyncMessage($doc1, $sync13); + truthy($messageFrom1To3 !== null, 'first peer should try to sync its unresolved head set to the third peer'); + [$doc3, $sync31] = $port->receiveSyncMessage($doc3, $sync31, $messageFrom1To3); + sameArray($port->getHeads($doc3), $unresolvedHeads, 'third peer should receive the same unresolved head set'); + sameArray($port->getMissingDeps($doc3), [$n2Change1['hash']], 'third peer should retain the explicit missing dependency request'); + + [$sync31, $messageFrom3To1] = $port->generateSyncMessage($doc3, $sync31); + $decodedFrom3 = $port->decodeSyncMessage($messageFrom3To1); + truthy($decodedFrom3 !== null, 'third peer should reply with its outstanding dependency request'); + sameArray($decodedFrom3['need'], [$n2Change1['hash']], 'third peer should ask for the false-positive dependency it cannot satisfy locally'); + sameArray($decodedFrom3['changes'], [], 'third peer should not invent a missing false-positive dependency'); + } +); + +$mapped( + 'sync protocol handles chains of false-positive dependencies', + 'javascript/test/sync_test.ts', + 769, + 'should handle chains of false-positives', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', 5, 0); + $doc2 = $port->setWithTime($doc2, 'x', '2 @ n2', 0); + $n2Change1 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', '141 again', 0); + $n2Change2 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ n2', 0); + $n2Change3 = $port->getLastLocalChange($doc2); + truthy($n2Change1 !== null && $n2Change2 !== null && $n2Change3 !== null, 'second peer should expose chained branch changes'); + + [$sync1, $messageFrom1] = $port->generateSyncMessage($doc1, $sync1); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $messageFrom1); + [$sync2, $messageFrom2] = $port->generateSyncMessage($doc2, $sync2); + $truncated = $port->decodeSyncMessage($messageFrom2); + truthy($truncated !== null && count($truncated['changes']) >= 3, 'second peer response should contain the complete chain before simulating false positives'); + $truncated['changes'] = [$n2Change3]; + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $truncated); + sameArray($sync1['needHeads'], [$n2Change2['hash']], 'first peer should request the closest omitted false-positive dependency'); + + [$sync1, $request] = $port->generateSyncMessage($doc1, $sync1); + $decodedRequest = $port->decodeSyncMessage($request); + truthy($decodedRequest !== null, 'first peer should emit a request for the false-positive chain'); + sameArray($decodedRequest['need'], [$n2Change2['hash']], 'request should name the next missing false-positive dependency'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decodedRequest); + [$sync2, $response] = $port->generateSyncMessage($doc2, $sync2); + $decodedResponse = $port->decodeSyncMessage($response); + truthy($decodedResponse !== null && count($decodedResponse['changes']) >= 2, 'second peer should answer with the requested dependency and its omitted parent'); + same($decodedResponse['changes'][0]['hash'] ?? null, $n2Change2['hash'], 'response should send the requested chained dependency first'); + same($decodedResponse['changes'][1]['hash'] ?? null, $n2Change1['hash'], 'response should include the earlier false-positive parent in the same round'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedResponse); + $expectedHeads = array_values(array_unique(array_merge($port->getHeads($doc1), $port->getHeads($doc2)))); + sort($expectedHeads); + + sameArray($sync1['needHeads'], [], 'the chained false-positive response should clear outstanding needs'); + sameArray($port->getMissingDeps($doc1), [], 'chained false-positive recovery should leave no missing dependencies'); + sameArray($port->getHeads($doc1), $expectedHeads, 'first peer should converge to both branch heads after chain recovery'); + sameArray($port->getHeads($doc2), $expectedHeads, 'second peer should retain both branch heads after chain recovery'); + } +); + +$rustMapped( + 'rust sync handles chains of false-positive dependencies', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-handle-chains-of-false-positives', + 'sync::tests::should_handle_chains_of_false_positives', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 10; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + + $doc1 = $port->setWithTime($doc1, 'x', 5, 0); + $doc2 = $port->setWithTime($doc2, 'x', '0 at 89abdef', 0); + $n2Change1 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', '0 again', 0); + $n2Change2 = $port->getLastLocalChange($doc2); + $doc2 = $port->setWithTime($doc2, 'x', 'final @ 89abcdef', 0); + $n2Change3 = $port->getLastLocalChange($doc2); + truthy($n2Change1 !== null && $n2Change2 !== null && $n2Change3 !== null, 'rust second peer should expose chained branch changes'); + + [$sync1, $messageFrom1] = $port->generateSyncMessage($doc1, $sync1); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $messageFrom1); + [$sync2, $messageFrom2] = $port->generateSyncMessage($doc2, $sync2); + $truncated = $port->decodeSyncMessage($messageFrom2); + truthy($truncated !== null && count($truncated['changes']) >= 3, 'rust second peer response should contain the complete chain before simulating false positives'); + $truncated['changes'] = [$n2Change3]; + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $truncated); + sameArray($sync1['needHeads'], [$n2Change2['hash']], 'rust first peer should request the closest omitted false-positive dependency'); + + [$sync1, $request] = $port->generateSyncMessage($doc1, $sync1); + $decodedRequest = $port->decodeSyncMessage($request); + truthy($decodedRequest !== null, 'rust first peer should emit a request for the false-positive chain'); + sameArray($decodedRequest['need'], [$n2Change2['hash']], 'rust request should name the next missing false-positive dependency'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decodedRequest); + [$sync2, $response] = $port->generateSyncMessage($doc2, $sync2); + $decodedResponse = $port->decodeSyncMessage($response); + truthy($decodedResponse !== null && count($decodedResponse['changes']) >= 2, 'rust second peer should answer with the requested dependency and its omitted parent'); + same($decodedResponse['changes'][0]['hash'] ?? null, $n2Change2['hash'], 'rust response should send the requested chained dependency first'); + same($decodedResponse['changes'][1]['hash'] ?? null, $n2Change1['hash'], 'rust response should include the earlier false-positive parent in the same round'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedResponse); + $expectedHeads = array_values(array_unique(array_merge($port->getHeads($doc1), $port->getHeads($doc2)))); + sort($expectedHeads); + + sameArray($sync1['needHeads'], [], 'rust chained false-positive response should clear outstanding needs'); + sameArray($port->getMissingDeps($doc1), [], 'rust chained false-positive recovery should leave no missing dependencies'); + sameArray($port->getHeads($doc1), $expectedHeads, 'rust first peer should converge to both branch heads after chain recovery'); + sameArray($port->getHeads($doc2), $expectedHeads, 'rust second peer should retain both branch heads after chain recovery'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol suppresses duplicate in-flight changes from multiple have filters', + 'javascript/test/sync_test.ts', + 882, + 'should allow multiple Bloom filters', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $doc3 = $port->init('76543210'); + $sync13 = $port->initSyncState(); + $sync31 = $port->initSyncState(); + $sync32 = $port->initSyncState(); + $sync23 = $port->initSyncState(); + + for ($i = 0; $i < 3; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2); + [$doc1, $doc3, $sync13, $sync31] = syncDocuments($port, $doc1, $doc3, $sync13, $sync31); + [$doc3, $doc2, $sync32, $sync23] = syncDocuments($port, $doc3, $doc2, $sync32, $sync23); + + for ($i = 0; $i < 2; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i . ' @ n1', 0); + } + + for ($i = 0; $i < 2; ++$i) { + $doc2 = $port->setWithTime($doc2, 'x', $i . ' @ n2', 0); + } + + $doc1 = $port->applyChanges($doc1, $port->getAllChanges($doc2)); + $doc2 = $port->applyChanges($doc2, $port->getAllChanges($doc1)); + $doc1 = $port->setWithTime($doc1, 'x', '3 @ n1', 0); + $doc2 = $port->setWithTime($doc2, 'x', '3 @ n2', 0); + for ($i = 0; $i < 3; ++$i) { + $doc3 = $port->setWithTime($doc3, 'x', $i . ' @ n3', 0); + } + + $head1 = $port->getHeads($doc1); + $head2 = $port->getHeads($doc2); + $head3 = $port->getHeads($doc3); + $expectedHeads = array_values(array_unique(array_merge($head1, $head2, $head3))); + sort($expectedHeads); + + $sync13 = $port->decodeSyncState($port->encodeSyncState($sync13)); + $sync31 = $port->decodeSyncState($port->encodeSyncState($sync31)); + $sync23 = $port->decodeSyncState($port->encodeSyncState($sync23)); + $sync32 = $port->decodeSyncState($port->encodeSyncState($sync32)); + + [$sync13, $messageFrom1] = $port->generateSyncMessage($doc1, $sync13); + $decodedFrom1 = $port->decodeSyncMessage($messageFrom1); + truthy($decodedFrom1 !== null && count($decodedFrom1['have'][0]['hashes'] ?? []) > 0, 'first peer should advertise deterministic in-flight hashes in its have filter'); + + [$doc3, $sync31] = $port->receiveSyncMessage($doc3, $sync31, $decodedFrom1); + [$sync31, $messageFrom3To1] = $port->generateSyncMessage($doc3, $sync31); + $decodedFrom3To1 = $port->decodeSyncMessage($messageFrom3To1); + truthy($decodedFrom3To1 !== null && count($decodedFrom3To1['changes']) > 0, 'third peer should send its branch to the first peer'); + [$doc1, $sync13] = $port->receiveSyncMessage($doc1, $sync13, $decodedFrom3To1); + + [$sync32, $messageFrom3To2] = $port->generateSyncMessage($doc3, $sync32); + $modifiedFor2 = $port->decodeSyncMessage($messageFrom3To2); + truthy($modifiedFor2 !== null, 'third peer should generate a sync message for the second peer'); + $modifiedFor2['have'][] = $decodedFrom1['have'][0]; + same(count($modifiedFor2['have']), 2, 'modified sync message should carry two have filters'); + + [$doc2, $sync23] = $port->receiveSyncMessage($doc2, $sync23, $modifiedFor2); + [$sync23, $messageFrom2] = $port->generateSyncMessage($doc2, $sync23); + $decodedFrom2 = $port->decodeSyncMessage($messageFrom2); + truthy($decodedFrom2 !== null, 'second peer should reply after receiving multiple have filters'); + same(count($decodedFrom2['changes']), 1, 'second peer should suppress changes already in flight from the first peer'); + same($decodedFrom2['changes'][0]['hash'] ?? null, $head2[0], 'second peer should send only its final branch head'); + + [$doc3, $sync32] = $port->receiveSyncMessage($doc3, $sync32, $decodedFrom2); + [$sync13, $messageFrom1] = $port->generateSyncMessage($doc1, $sync13); + if ($messageFrom1 !== null) { + [$doc3, $sync31] = $port->receiveSyncMessage($doc3, $sync31, $messageFrom1); + } + + sameArray($port->getHeads($doc3), $expectedHeads, 'third peer should converge to all concurrent branch heads'); + } +); + +$rustMapped( + 'rust sync v1 messages can drive a v2 receiver', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-from-v1-to-v2', + 'sync::v1_compat_test::sync_from_v1_to_v2', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'foo', 'bar'); + $doc2 = $port->set($port->init('bbbbbb'), 'baz', 'quux'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + $iterations = 0; + do { + [$sync1, $fromV1] = $port->generateSyncMessageV1($doc1, $sync1); + if ($fromV1 !== null) { + same(array_key_exists('readOnly', $fromV1), false, 'v1 sync message should not expose v2 readOnly capability flags'); + same(array_key_exists('syncReset', $fromV1), false, 'v1 sync message should not expose v2 syncReset capability flags'); + same(array_key_exists('hashes', $fromV1['have'][0] ?? []), false, 'v1 sync message should not expose v2 deterministic have hashes'); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $fromV1); + } + + [$sync2, $fromV2] = $port->generateSyncMessage($doc2, $sync2); + if ($fromV2 !== null) { + $v1Decoded = $port->syncMessageToV1($fromV2); + truthy($v1Decoded !== null, 'v2 response should be decodable by the v1 compatibility parser'); + [$doc1, $sync1] = $port->receiveSyncMessageV1($doc1, $sync1, $v1Decoded); + } + + if (++$iterations > 10) { + throw new RuntimeException('v1-to-v2 compatibility sync did not converge'); + } + } while ($fromV1 !== null || $fromV2 !== null); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'v1-to-v2 compatibility sync should converge heads'); + $materialized1 = $doc1->toArray(); + $materialized2 = $doc2->toArray(); + ksort($materialized1); + ksort($materialized2); + sameArray($materialized1, $materialized2, 'v1-to-v2 compatibility sync should converge materialized state'); + }, + 'rust/automerge/src/sync/v1_compat_test/mod.rs' +); + +$rustMapped( + 'rust sync v2 messages can drive a v1 receiver', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-from-v2-to-v1', + 'sync::v1_compat_test::sync_from_v2_to_v1', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'foo', 'bar'); + $doc2 = $port->set($port->init('bbbbbb'), 'baz', 'quux'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + $iterations = 0; + do { + [$sync2, $fromV2] = $port->generateSyncMessage($doc2, $sync2); + if ($fromV2 !== null) { + $v1Decoded = $port->syncMessageToV1($fromV2); + truthy($v1Decoded !== null, 'v2 starter message should be decodable by the v1 compatibility parser'); + same(array_key_exists('readOnly', $v1Decoded), false, 'v1-decoded sync message should not expose v2 readOnly capability flags'); + same(array_key_exists('syncReset', $v1Decoded), false, 'v1-decoded sync message should not expose v2 syncReset capability flags'); + same(array_key_exists('hashes', $v1Decoded['have'][0] ?? []), false, 'v1-decoded sync message should not expose v2 deterministic have hashes'); + [$doc1, $sync1] = $port->receiveSyncMessageV1($doc1, $sync1, $v1Decoded); + } + + [$sync1, $fromV1] = $port->generateSyncMessageV1($doc1, $sync1); + if ($fromV1 !== null) { + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $fromV1); + } + + if (++$iterations > 10) { + throw new RuntimeException('v2-to-v1 compatibility sync did not converge'); + } + } while ($fromV2 !== null || $fromV1 !== null); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'v2-to-v1 compatibility sync should converge heads'); + $materialized1 = $doc1->toArray(); + $materialized2 = $doc2->toArray(); + ksort($materialized1); + ksort($materialized2); + sameArray($materialized1, $materialized2, 'v2-to-v1 compatibility sync should converge materialized state'); + }, + 'rust/automerge/src/sync/v1_compat_test/mod.rs' +); + +$rustMapped( + 'rust sync v1 to v2 accepts a large compressed-change payload', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-v1-compat-test-sync-v1-to-v2-with-compressed-change', + 'sync::v1_compat_test::sync_v1_to_v2_with_compressed_change', + function () use ($port): void { + $docV1 = $port->init('aaaaaa'); + $docV2 = $port->set($port->init('bbbbbb'), 'list', []); + $docV2 = $port->insertListElements($docV2, 'list', 0, range(0, 999)); + $syncV1 = $port->initSyncState(); + $syncV2 = $port->initSyncState(); + + $iterations = 0; + do { + [$syncV1, $fromV1] = $port->generateSyncMessageV1($docV1, $syncV1); + if ($fromV1 !== null) { + [$docV2, $syncV2] = $port->receiveSyncMessage($docV2, $syncV2, $fromV1); + } + + [$syncV2, $fromV2] = $port->generateSyncMessage($docV2, $syncV2); + if ($fromV2 !== null) { + $v1Decoded = $port->syncMessageToV1($fromV2); + truthy($v1Decoded !== null, 'large v2 response should be decodable by the v1 compatibility parser'); + same(array_key_exists('hashes', $v1Decoded['have'][0] ?? []), false, 'large v1-compatible response should omit v2 deterministic have hashes'); + [$docV1, $syncV1] = $port->receiveSyncMessageV1($docV1, $syncV1, $v1Decoded); + } + + if (++$iterations > 10) { + throw new RuntimeException('large v1-to-v2 compatibility sync did not converge'); + } + } while ($fromV1 !== null || $fromV2 !== null); + + sameArray($port->getHeads($docV1), $port->getHeads($docV2), 'large v1-to-v2 compatibility sync should converge heads'); + same(count($docV1->toArray()['list'] ?? []), 1000, 'v1 peer should materialize the full large list payload'); + sameArray($docV1->toArray(), $docV2->toArray(), 'v1 peer should materialize the same large payload as the v2 peer'); + + $docV2 = $port->set($docV2, 'foo', 'bar'); + $docV1 = $port->set($docV1, 'baz', 'quux'); + same($docV2->toArray()['foo'] ?? null, 'bar', 'v2 peer should remain writable after large compatibility sync'); + same($docV1->toArray()['baz'] ?? null, 'quux', 'v1 peer should remain writable after large compatibility sync'); + }, + 'rust/automerge/src/sync/v1_compat_test/mod.rs' +); + +$mapped( + 'sync protocol does not generate messages once synced', + 'javascript/test/sync_test.ts', + 175, + 'should not generate messages once synced', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + $doc2 = $port->set($doc2, 'y', $i); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2); + + sameArray($doc1->toArray(), ['x' => 4, 'y' => 4], 'first peer should converge to both peer key updates'); + sameArray($doc2->toArray(), ['y' => 4, 'x' => 4], 'second peer should converge to both peer key updates'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'synced peers should converge heads'); + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + same($message1, null, 'first peer should have no sync message after convergence'); + same($message2, null, 'second peer should have no sync message after convergence'); + } +); + +$mapped( + 'sync protocol works regardless of which peer initiates later exchange', + 'javascript/test/sync_test.ts', + 327, + 'should work regardless of who initiates the exchange', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), $doc2->toArray(), 'initial exchange should synchronize the second peer'); + + for ($i = 5; $i < 10; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + } + + truthy($doc1->toArray() !== $doc2->toArray(), 'later exchange should start with one peer ahead'); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), $doc2->toArray(), 'later exchange should converge regardless of sync initiator'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'later exchange should converge heads'); + } +); + +$mapped( + 'sync protocol preserves independent diverged peer changes', + 'javascript/test/sync_test.ts', + 219, + 'should allow simultaneous messages during synchronization', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + $doc2 = $port->set($doc2, 'y', $i); + } + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $port->initSyncState()); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $port->initSyncState()); + truthy($message1 !== null && $message2 !== null, 'both diverged peers should emit initial sync messages'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message2); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message1); + + sameArray($doc1->toArray(), ['x' => 4, 'y' => 4], 'first peer should keep its local value and apply the remote independent key'); + sameArray($doc2->toArray(), ['y' => 4, 'x' => 4], 'second peer should keep its local value and apply the remote independent key'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'simultaneous independent exchange should converge heads'); + } +); + +$rustMapped( + 'rust sync allows simultaneous independent messages and acknowledgements', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-allow-simultaneous-messages-during-synchronisation', + 'sync::tests::should_allow_simultaneous_messages_during_synchronisation', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 5; ++$i) { + $doc1 = $port->set($doc1, 'x', $i); + $doc2 = $port->set($doc2, 'y', $i); + } + $doc1HeadsBeforeExchange = $port->getHeads($doc1); + $doc2HeadsBeforeExchange = $port->getHeads($doc2); + + [$sync1, $msg1to2] = $port->generateSyncMessage($doc1, $sync1); + [$sync2, $msg2to1] = $port->generateSyncMessage($doc2, $sync2); + truthy($msg1to2 !== null && $msg2to1 !== null, 'both peers should send initial simultaneous sync messages'); + truthy(count($msg1to2['changes'] ?? []) > 0, 'native sync should include first peer changes in the initial message'); + truthy(count($msg2to1['changes'] ?? []) > 0, 'native sync should include second peer changes in the initial message'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $msg2to1); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $msg1to2); + sameArray($doc1->toArray(), ['x' => 4, 'y' => 4], 'first peer should apply the simultaneous remote changes'); + sameArray($doc2->toArray(), ['y' => 4, 'x' => 4], 'second peer should apply the simultaneous remote changes'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'simultaneous exchange should converge heads'); + + [$sync1, $ack1to2] = $port->generateSyncMessage($doc1, $sync1); + [$sync2, $ack2to1] = $port->generateSyncMessage($doc2, $sync2); + truthy($ack1to2 !== null && $ack2to1 !== null, 'both peers should send acknowledgement messages after simultaneous receive'); + sameArray($ack1to2['have'][0]['lastSync'] ?? [], $doc2HeadsBeforeExchange, 'first acknowledgement should report the second peer heads it received'); + sameArray($ack2to1['have'][0]['lastSync'] ?? [], $doc1HeadsBeforeExchange, 'second acknowledgement should report the first peer heads it received'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $ack2to1); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $ack1to2); + same($port->generateSyncMessage($doc1, $sync1)[1], null, 'first peer should be quiet after acknowledgement exchange'); + same($port->generateSyncMessage($doc2, $sync2)[1], null, 'second peer should be quiet after acknowledgement exchange'); + + $sharedHeads = $port->getHeads($doc1); + $doc1 = $port->set($doc1, 'x', 5); + [$sync1, $later] = $port->generateSyncMessage($doc1, $sync1); + truthy($later !== null, 'a later local change should produce a sync message'); + sameArray($later['have'][0]['lastSync'] ?? [], $sharedHeads, 'later sync should advertise the previous shared heads'); + truthy(count($later['changes'] ?? []) > 0, 'later sync should include the new local change'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $later); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge after the later change'); + same($doc2->toArray()['x'] ?? null, 5, 'second peer should receive the later first peer value'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync in-flight acknowledgement does not hide a later local change', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-in-flight-logic-should-not-sabotage-concurrent-changes', + 'sync::tests::in_flight_logic_should_not_sabotage_concurrent_changes', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'initial empty documents should synchronize heads'); + + $doc2 = $port->set($doc2, 'x', 0); + [$sync2, $doc2ToDoc1] = $port->generateSyncMessage($doc2, $sync2); + truthy($doc2ToDoc1 !== null, 'second peer should send its local change'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $doc2ToDoc1); + same($doc1->toArray()['x'] ?? null, 0, 'first peer should receive the second peer value before acknowledging'); + + $doc1 = $port->set($doc1, 'x', 1); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'in-flight sync should converge heads after the later local change'); + same($doc1->toArray()['x'] ?? null, 1, 'first peer should keep its causally later value'); + same($doc2->toArray()['x'] ?? null, 1, 'second peer should receive the causally later value'); + same($port->generateSyncMessage($doc1, $sync1)[1], null, 'first peer should not be quietly divergent after convergence'); + same($port->generateSyncMessage($doc2, $sync2)[1], null, 'second peer should not be quietly divergent after convergence'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync sends whole document when first message has no heads', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-if-first-message-has-no-heads-and-supports-v2-message-send-whole-doc', + 'sync::tests::if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc', + function () use ($port): void { + $emptyPeer = $port->init('abc123'); + $dataPeer = $port->set($port->init('def456'), 'foo', 'bar'); + $emptyState = $port->initSyncState(); + $dataState = $port->initSyncState(); + + [$emptyState, $outgoing] = $port->generateSyncMessage($emptyPeer, $emptyState); + $decodedOutgoing = $port->decodeSyncMessage($outgoing); + truthy($decodedOutgoing !== null, 'empty first peer should produce a first-round sync message'); + sameArray($decodedOutgoing['heads'], [], 'empty first peer message should advertise no heads'); + sameArray($decodedOutgoing['changes'], [], 'empty first peer should not send changes'); + + [$dataPeer, $dataState] = $port->receiveSyncMessage($dataPeer, $dataState, $outgoing); + [$dataState, $response] = $port->generateSyncMessage($dataPeer, $dataState); + $decodedResponse = $port->decodeSyncMessage($response); + truthy($decodedResponse !== null, 'data peer should respond to an empty-head first message'); + truthy(count($decodedResponse['changes']) > 0, 'data peer should send its document changes in response'); + + [$emptyPeer, $emptyState] = $port->receiveSyncMessage($emptyPeer, $emptyState, $response); + sameArray($emptyPeer->toArray(), ['foo' => 'bar'], 'empty peer should materialize the whole document response'); + sameArray($port->getHeads($emptyPeer), $port->getHeads($dataPeer), 'whole-document response should converge heads'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync does not reply when empty peers have no data after first round', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-should-not-reply-if-we-have-no-data-after-first-round', + 'sync::tests::should_not_reply_if_we_have_no_data_after_first_round', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$sync1, $firstMessage] = $port->generateSyncMessage($doc1, $sync1); + truthy($firstMessage !== null, 'first empty peer should still send a first-round state message'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $firstMessage); + [$sync2, $response] = $port->generateSyncMessage($doc2, $sync2); + truthy($response !== null, 'second empty peer should acknowledge the first round'); + + [$sync1, $lateFirstMessage] = $port->generateSyncMessage($doc1, $sync1); + [$sync2, $lateSecondMessage] = $port->generateSyncMessage($doc2, $sync2); + + same($lateFirstMessage, null, 'first empty peer should not send another message without data'); + same($lateSecondMessage, null, 'second empty peer should not send another message without data'); + sameArray($doc1->toArray(), [], 'first empty peer should remain empty'); + sameArray($doc2->toArray(), [], 'second empty peer should remain empty'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol reports when peers have our changes', + 'javascript/test/sync_test.ts', + 1096, + 'should report whether the other end has our changes', + function () use ($port): void { + $left = $port->from(['foo' => 'bar'], 'aaaaaa'); + $right = $port->from(['baz' => 'qux'], 'bbbbbb'); + $leftToRight = $port->initSyncState(); + $rightToLeft = $port->initSyncState(); + $iterations = 0; + + while ( + ! $port->hasOurChanges($left, $leftToRight) + && ! $port->hasOurChanges($right, $rightToLeft) + ) { + $quiet = true; + + [$leftToRight, $message] = $port->generateSyncMessage($left, $leftToRight); + if ($message !== null) { + $quiet = false; + [$right, $rightToLeft] = $port->receiveSyncMessage($right, $rightToLeft, $message); + } + + [$rightToLeft, $message] = $port->generateSyncMessage($right, $rightToLeft); + if ($message !== null) { + $quiet = false; + [$left, $leftToRight] = $port->receiveSyncMessage($left, $leftToRight, $message); + } + + if ($quiet) { + throw new RuntimeException('no sync message generated but the sync states say we are not done'); + } + + if (++$iterations > 10) { + throw new RuntimeException('sync acknowledgement did not converge within 10 iterations'); + } + } + + truthy($port->hasOurChanges($left, $leftToRight), 'left peer should report its changes acknowledged'); + truthy($port->hasOurChanges($right, $rightToLeft), 'right peer should report its changes acknowledged'); + } +); + +$mapped( + 'sync protocol continues sending unacknowledged local changes', + 'javascript/test/sync_test.ts', + 299, + 'should assume sent changes were recieved until we hear otherwise', + function () use ($port): void { + $n1 = $port->set($port->init('01234567'), 'items', []); + $n2 = $port->init('89abcdef'); + [$n1, $n2, $s1] = syncDocuments($port, $n1, $n2); + + foreach (['x', 'y', 'z'] as $value) { + $n1 = $port->pushList($n1, 'items', [$value]); + [$s1, $message] = $port->generateSyncMessage($n1, $s1); + if ($message !== null) { + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null && count($decoded['changes']) > 0, 'unacknowledged local edits should remain in sync messages'); + } + } + } +); + +$mapped( + 'sync read-only state does not apply incoming changes', + 'javascript/test/sync_test.ts', + 1139, + 'should not apply incoming changes when read-only', + function () use ($port): void { + $doc1 = $port->setWithTime($port->init('aaaaaa'), 'from1', 'hello', 0); + $doc2 = $port->setWithTime($port->init('bbbbbb'), 'from2', 'world', 0); + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $port->initSyncState(['readOnly' => true])); + + sameArray($doc2->toArray(), ['from2' => 'world', 'from1' => 'hello'], 'read-write peer should receive read-only peer changes'); + sameArray($doc1->toArray(), ['from1' => 'hello'], 'read-only peer should ignore incoming peer changes'); + } +); + +$mapped( + 'sync read-only state is reported to the peer', + 'javascript/test/sync_test.ts', + 1155, + 'should discover peer read-only status', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + truthy($message !== null, 'read-only peer should generate an initial sync message'); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message); + + truthy($sync2['peerReadOnly'], 'receiving peer should discover read-only status'); + truthy($sync1['readOnly'], 'sending peer should retain read-only status'); + } +); + +$mapped( + 'sync read-only state can switch back to read-write', + 'javascript/test/sync_test.ts', + 1172, + 'should allow switching from read-only to read-write', + function () use ($port): void { + $doc1 = $port->setWithTime($port->init('aaaaaa'), 'from1', 'hello', 0); + $doc2 = $port->setWithTime($port->init('bbbbbb'), 'from2', 'world', 0); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), ['from1' => 'hello'], 'read-only peer should not receive remote changes during the first sync'); + sameArray($doc2->toArray(), ['from2' => 'world', 'from1' => 'hello'], 'read-write peer should receive read-only peer changes'); + + $sync1 = $port->decodeSyncState($port->encodeSyncState($sync1)); + $sync2 = $port->decodeSyncState($port->encodeSyncState($sync2)); + $sync1['readOnly'] = false; + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), ['from1' => 'hello', 'from2' => 'world'], 'peer should receive remote changes after switching back to read-write'); + sameArray($doc2->toArray(), ['from2' => 'world', 'from1' => 'hello'], 'other peer should preserve both changes after read-only transition'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'read-only transition sync should converge heads'); + } +); + +$rustMapped( + 'rust read-only sync does not apply incoming changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-sync-does-not-apply-incoming-changes', + 'sync::tests::read_only_sync_does_not_apply_incoming_changes', + function () use ($port): void { + $doc1 = $port->setWithTime($port->init('aaaaaa'), 'from_doc1', 'hello', 0); + $doc2 = $port->setWithTime($port->init('bbbbbb'), 'from_doc2', 'world', 0); + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $port->initSyncState(['readOnly' => true])); + + sameArray($doc1->toArray(), ['from_doc1' => 'hello'], 'read-only peer should ignore incoming changes'); + sameArray($doc2->toArray(), ['from_doc2' => 'world', 'from_doc1' => 'hello'], 'read-write peer should apply read-only peer changes'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync peer discovers remote read-only status', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-peer-discovers-remote-read-only-status', + 'sync::tests::peer_discovers_remote_read_only_status', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->init('bbbbbb'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + truthy($message !== null, 'read-only peer should advertise its state in an initial message'); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message); + + sameArray($doc2->toArray(), [], 'empty read-write peer should remain empty after read-only hello'); + truthy($sync2['peerReadOnly'], 'receiving peer should discover read-only status'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only empty peer syncs with data peer without applying data', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-empty-peer-syncs-with-data-peer', + 'sync::tests::read_only_empty_peer_syncs_with_data_peer', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->set($port->init('bbbbbb'), 'key', 'value'); + + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $port->initSyncState(['readOnly' => true])); + + sameArray($doc1->toArray(), [], 'empty read-only peer should remain empty'); + sameArray($port->getHeads($doc1), [], 'empty read-only peer should keep empty heads'); + sameArray($doc2->toArray(), ['key' => 'value'], 'data peer should remain unchanged'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers ignore each other changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-peers-read-only', + 'sync::tests::both_peers_read_only', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_doc1', 'hello'); + $doc2 = $port->set($port->init('def456'), 'from_doc2', 'world'); + $doc1Heads = $port->getHeads($doc1); + $doc2Heads = $port->getHeads($doc2); + + [$doc1, $doc2] = syncDocuments( + $port, + $doc1, + $doc2, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState(['readOnly' => true]) + ); + + sameArray($doc1->toArray(), ['from_doc1' => 'hello'], 'first read-only peer should ignore second peer changes'); + sameArray($doc2->toArray(), ['from_doc2' => 'world'], 'second read-only peer should ignore first peer changes'); + sameArray($port->getHeads($doc1), $doc1Heads, 'first read-only peer heads should remain unchanged'); + sameArray($port->getHeads($doc2), $doc2Heads, 'second read-only peer heads should remain unchanged'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers converge to no further messages', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-peers-read-only-converges-to-none', + 'sync::tests::both_peers_read_only_converges_to_none', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_doc1', 'hello'); + $doc2 = $port->set($port->init('def456'), 'from_doc2', 'world'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + + same($message1, null, 'first read-only peer should stop sending after convergence'); + same($message2, null, 'second read-only peer should stop sending after convergence'); + truthy($sync1['peerReadOnly'], 'first read-only peer should discover the second is read-only'); + truthy($sync2['peerReadOnly'], 'second read-only peer should discover the first is read-only'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers exchange updated heads for one local writer', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-one-makes-local-changes', + 'sync::tests::both_read_only_one_makes_local_changes', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $doc1 = $port->set($doc1, 'key', 'value1'); + $doc1Heads = $port->getHeads($doc1); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($sync2['receivedHeads'] ?? [], $doc1Heads, 'second read-only peer should learn first peer updated heads'); + sameArray($doc2->toArray(), [], 'second read-only peer should not apply first peer local changes'); + + $doc1 = $port->set($doc1, 'key', 'value2'); + $doc1Heads = $port->getHeads($doc1); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($sync2['receivedHeads'] ?? [], $doc1Heads, 'second read-only peer should learn the later first peer heads'); + sameArray($doc2->toArray(), [], 'second read-only peer should still not apply first peer local changes'); + same($port->generateSyncMessage($doc1, $sync1)[1], null, 'first read-only peer should be quiet after head exchange'); + same($port->generateSyncMessage($doc2, $sync2)[1], null, 'second read-only peer should be quiet after head exchange'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers exchange updated heads for both local writers', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-both-make-local-changes', + 'sync::tests::both_read_only_both_make_local_changes', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + for ($round = 0; $round < 5; ++$round) { + $doc1 = $port->set($doc1, 'doc1_counter', $round); + $doc2 = $port->set($doc2, 'doc2_counter', $round); + $doc1Heads = $port->getHeads($doc1); + $doc2Heads = $port->getHeads($doc2); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($sync1['receivedHeads'] ?? [], $doc2Heads, 'first read-only peer should learn second peer heads'); + sameArray($sync2['receivedHeads'] ?? [], $doc1Heads, 'second read-only peer should learn first peer heads'); + sameArray($doc1->toArray(), ['doc1_counter' => $round], 'first read-only peer should not apply second peer data'); + sameArray($doc2->toArray(), ['doc2_counter' => $round], 'second read-only peer should not apply first peer data'); + same($port->generateSyncMessage($doc1, $sync1)[1], null, 'first read-only peer should be quiet after each round'); + same($port->generateSyncMessage($doc2, $sync2)[1], null, 'second read-only peer should be quiet after each round'); + } + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers converge after simultaneous local changes during sync', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-read-only-simultaneous-changes-during-sync', + 'sync::tests::both_read_only_simultaneous_changes_during_sync', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + $doc1 = $port->set($doc1, 'x', 1); + $doc2 = $port->set($doc2, 'y', 2); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $doc1 = $port->set($doc1, 'x', 3); + $doc2 = $port->set($doc2, 'y', 4); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + same($port->generateSyncMessage($doc1, $sync1)[1], null, 'first read-only peer should have no further message after simultaneous local changes'); + same($port->generateSyncMessage($doc2, $sync2)[1], null, 'second read-only peer should have no further message after simultaneous local changes'); + sameArray($doc1->toArray(), ['x' => 3], 'first read-only peer should not apply second peer data'); + sameArray($doc2->toArray(), ['y' => 4], 'second read-only peer should not apply first peer data'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only publisher sends new local changes between sync rounds', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-new-changes-between-sync-rounds', + 'sync::tests::read_only_peer_new_changes_between_sync_rounds', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'round1', 'from_doc1'); + $doc2 = $port->set($port->init('def456'), 'round1', 'from_doc2'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + truthy($port->getConflicts($doc2, 'round1') !== null, 'read-write peer should receive the read-only peer round1 conflict'); + + $doc1 = $port->set($doc1, 'round2', 'new_from_doc1'); + $doc2 = $port->set($doc2, 'round2', 'new_from_doc2'); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $doc2Round2Values = array_values($port->getConflicts($doc2, 'round2') ?? []); + sort($doc2Round2Values); + sameArray($doc2Round2Values, ['new_from_doc1', 'new_from_doc2'], 'read-write peer should retain both round2 conflict values'); + sameArray($doc1->toArray(), ['round1' => 'from_doc1', 'round2' => 'new_from_doc1'], 'read-only peer should not apply read-write peer changes'); + sameArray(array_values($port->getConflicts($doc1, 'round2') ?? []), [], 'read-only peer should not record a conflict for ignored remote round2 data'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only peer can publish a local change made during sync', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-concurrent-changes-during-sync', + 'sync::tests::read_only_peer_concurrent_changes_during_sync', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $doc2 = $port->set($doc2, 'x', 0); + [$sync2, $message] = $port->generateSyncMessage($doc2, $sync2); + truthy($message !== null, 'read-write peer should send its new local change'); + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message); + sameArray($doc1->toArray(), [], 'read-only peer should ignore the in-flight read-write change'); + + $doc1 = $port->set($doc1, 'y', 1); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($doc1->toArray(), ['y' => 1], 'read-only peer should keep only its local change'); + sameArray($doc2->toArray(), ['x' => 0, 'y' => 1], 'read-write peer should receive the read-only peer concurrent local change'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync omits changes when peer is known read-only', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-changes-not-sent-to-read-only-peer', + 'sync::tests::changes_not_sent_to_read_only_peer', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->set($port->init('def456'), 'from_b', 'world'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + truthy($message1 !== null, 'read-only peer should advertise its mode'); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message1); + truthy($sync2['peerReadOnly'], 'read-write peer should learn the remote peer is read-only'); + + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + $decoded = $port->decodeSyncMessage($message2); + truthy($decoded !== null, 'read-write peer should still acknowledge heads to the read-only peer'); + sameArray($decoded['changes'], [], 'read-write peer should not send changes to a known read-only peer'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message2); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), [], 'read-only peer should not receive read-write peer data'); + + $sync1['readOnly'] = false; + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($doc1->toArray(), ['from_b' => 'world'], 'former read-only peer should receive changes after switching to read-write'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge heads after read-only peer switches to read-write'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync advertises read-only after mode change with an in-flight message', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-message-after-set-read-only-even-with-in-flight', + 'sync::tests::generate_message_after_set_read_only_even_with_in_flight', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_a', 'hello'); + $doc2 = $port->set($port->init('def456'), 'from_b', 'world'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $doc2 = $port->set($doc2, 'new_from_b', 'secret'); + [$sync2, $message] = $port->generateSyncMessage($doc2, $sync2); + truthy($message !== null, 'second peer should produce an in-flight update'); + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message); + + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + truthy($message !== null, 'first peer should generate an acknowledgement before changing mode'); + + $sync1['readOnly'] = true; + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + $decoded = $port->decodeSyncMessage($message); + + truthy($decoded !== null, 'mode change to read-only should force a control message'); + truthy($decoded['readOnly'], 'mode-change control message should advertise read-only'); + same($decoded['syncReset'], false, 'switching to read-only should not request a sync reset'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync advertises read-write after read-only mode change with an in-flight message', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-generate-message-after-set-read-only-false-even-with-in-flight', + 'sync::tests::generate_message_after_set_read_only_false_even_with_in_flight', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_a', 'hello'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + truthy($message !== null, 'read-only peer should have an in-flight initial message before switching mode'); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message); + truthy($sync2['peerReadOnly'], 'read-write peer should know the remote peer is read-only before the mode switch'); + $doc2 = $port->set($doc2, 'from_b', 'world'); + + $sync1['readOnly'] = false; + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + $decoded = $port->decodeSyncMessage($message); + + truthy($decoded !== null, 'mode change to read-write should force a control message'); + same($decoded['readOnly'], false, 'mode-change control message should clear read-only'); + truthy($decoded['syncReset'], 'switching to read-write should request resend of previously ignored changes'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($doc1->toArray(), ['from_a' => 'hello', 'from_b' => 'world'], 'former read-only peer should receive changes after switching to read-write'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge heads after read-write mode change'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust sync switches read-only peer to read-write with old-peer empty-head fallback', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-to-read-write-with-old-peer', + 'sync::tests::switch_to_read_write_with_old_peer', + function () use ($port): void { + $docA = $port->set($port->init('abc123'), 'from_a', 'hello'); + $docB = $port->set($port->init('def456'), 'from_b', 'world'); + $syncA = $port->initSyncState(['readOnly' => true]); + $syncB = $port->initSyncState(); + + [$docA, $docB, $syncA, $syncB] = syncDocuments($port, $docA, $docB, $syncA, $syncB); + sameArray($docA->toArray(), ['from_a' => 'hello'], 'read-only peer should ignore remote data before switching modes'); + same($docB->toArray()['from_a'] ?? null, 'hello', 'remote peer should receive read-only publisher data'); + same($docB->toArray()['from_b'] ?? null, 'world', 'remote peer should retain its local data before mode switch'); + + $syncA['readOnly'] = false; + $syncA['theirCapabilities'] = null; + [$syncA, $message] = $port->generateSyncMessage($docA, $syncA); + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null, 'read-write switch for old peer should produce a control message'); + sameArray($decoded['heads'], [], 'old peer fallback should send empty heads instead of a sync-reset flag'); + same($decoded['syncReset'], false, 'old peer fallback should not use the sync-reset capability flag'); + same($decoded['readOnly'], false, 'old peer fallback should advertise the new read-write mode'); + + [$docB, $syncB] = $port->receiveSyncMessage($docB, $syncB, $message); + [$docA, $docB, $syncA, $syncB] = syncDocuments($port, $docA, $docB, $syncA, $syncB); + + sameArray($docA->toArray(), ['from_a' => 'hello', 'from_b' => 'world'], 'former read-only peer should receive old-peer data after fallback reset'); + sameArray($docB->toArray(), ['from_b' => 'world', 'from_a' => 'hello'], 'old peer should retain both document branches after fallback reset'); + sameArray($port->getHeads($docA), $port->getHeads($docB), 'peers should converge heads after old-peer read-write fallback'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers exchange changes after simultaneous read-write toggle', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-read-only-to-read-write-simultaneously', + 'sync::tests::both_toggle_read_only_to_read_write_simultaneously', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_doc1', 'hello'); + $doc2 = $port->set($port->init('def456'), 'from_doc2', 'world'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($doc1->toArray(), ['from_doc1' => 'hello'], 'first peer should ignore second peer while read-only'); + sameArray($doc2->toArray(), ['from_doc2' => 'world'], 'second peer should ignore first peer while read-only'); + + $sync1['readOnly'] = false; + $sync2['readOnly'] = false; + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($doc1->toArray(), ['from_doc1' => 'hello', 'from_doc2' => 'world'], 'first peer should receive second peer after read-write toggle'); + sameArray($doc2->toArray(), ['from_doc2' => 'world', 'from_doc1' => 'hello'], 'second peer should receive first peer after read-write toggle'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'both toggled peers should converge heads'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers exchange original and new changes after read-write toggle', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-read-only-to-read-write-with-new-changes', + 'sync::tests::both_toggle_read_only_to_read_write_with_new_changes', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'original_1', 'v1'); + $doc2 = $port->set($port->init('def456'), 'original_2', 'v2'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + $sync1['readOnly'] = false; + $sync2['readOnly'] = false; + $doc1 = $port->set($doc1, 'new_1', 'after_switch'); + $doc2 = $port->set($doc2, 'new_2', 'after_switch'); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($doc1->toArray(), ['original_1' => 'v1', 'new_1' => 'after_switch', 'original_2' => 'v2', 'new_2' => 'after_switch'], 'first peer should receive original and new second-peer changes'); + sameArray($doc2->toArray(), ['original_2' => 'v2', 'new_2' => 'after_switch', 'original_1' => 'v1', 'new_1' => 'after_switch'], 'second peer should receive original and new first-peer changes'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'both peers should converge heads after read-write toggle with new changes'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust both read-only peers exchange accumulated changes after multiple rounds', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-both-toggle-after-multiple-read-only-rounds', + 'sync::tests::both_toggle_after_multiple_read_only_rounds', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(['readOnly' => true]); + + $expected1 = []; + $expected2 = []; + for ($round = 0; $round < 5; ++$round) { + $expected1['doc1_r' . $round] = $round; + $expected2['doc2_r' . $round] = $round; + $doc1 = $port->set($doc1, 'doc1_r' . $round, $round); + $doc2 = $port->set($doc2, 'doc2_r' . $round, $round); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + } + + sameArray($doc1->toArray(), $expected1, 'first peer should retain only its accumulated changes while read-only'); + sameArray($doc2->toArray(), $expected2, 'second peer should retain only its accumulated changes while read-only'); + + $sync1['readOnly'] = false; + $sync2['readOnly'] = false; + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + sameArray($doc1->toArray(), array_merge($expected1, $expected2), 'first peer should receive all accumulated second-peer changes after toggle'); + sameArray($doc2->toArray(), array_merge($expected2, $expected1), 'second peer should receive all accumulated first-peer changes after toggle'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge heads after multiple read-only rounds'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only peer receives accumulated remote changes after switching read-write mid-session', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-only-to-read-write-mid-session', + 'sync::tests::switch_read_only_to_read_write_mid_session', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_a', 'hello'); + $doc2 = $port->set($port->init('def456'), 'from_b', 'world'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + same($doc2->toArray()['from_a'] ?? null, 'hello', 'read-write peer should receive the read-only peer change'); + truthy(! array_key_exists('from_b', $doc1->toArray()), 'read-only peer should ignore remote change before switching'); + + $sync1['readOnly'] = false; + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + same($doc1->toArray()['from_b'] ?? null, 'world', 'former read-only peer should receive remote change after switching read-write'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge heads after switching read-write mid-session'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-write peer switched read-only publishes local changes but ignores remote ones', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-write-to-read-only-mid-session', + 'sync::tests::switch_read_write_to_read_only_mid_session', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_a', 'hello'); + $doc2 = $port->set($port->init('def456'), 'from_b', 'world'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'initial read-write sync should converge heads'); + + $sync1['readOnly'] = true; + $doc2 = $port->set($doc2, 'new_from_b', 'secret'); + $doc1 = $port->set($doc1, 'new_from_a', 'published'); + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + same($doc2->toArray()['new_from_a'] ?? null, 'published', 'read-write peer should receive new changes from the read-only peer'); + truthy(! array_key_exists('new_from_b', $doc1->toArray()), 'read-only peer should ignore remote changes after switching read-only'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only peer receives all accumulated remote rounds after switching read-write', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-switch-read-only-to-read-write-with-multiple-rounds', + 'sync::tests::switch_read_only_to_read_write_with_multiple_rounds', + function () use ($port): void { + $doc1 = $port->set($port->init('abc123'), 'from_a', 'initial'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + foreach (['round1', 'round2', 'round3'] as $key) { + $doc2 = $port->set($doc2, $key, 'from_b'); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + truthy(! array_key_exists($key, $doc1->toArray()), 'read-only peer should ignore ' . $key . ' before switching read-write'); + } + + $sync1['readOnly'] = false; + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + + foreach (['round1', 'round2', 'round3'] as $key) { + same($doc1->toArray()[$key] ?? null, 'from_b', 'former read-only peer should receive accumulated ' . $key); + } + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge heads after accumulated read-only rounds'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only mode can toggle multiple times while preserving withheld changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-toggle-read-only-multiple-times', + 'sync::tests::toggle_read_only_multiple_times', + function () use ($port): void { + $doc1 = $port->init('abc123'); + $doc2 = $port->init('def456'); + $sync1 = $port->initSyncState(['readOnly' => true]); + $sync2 = $port->initSyncState(); + + $doc2 = $port->set($doc2, 'b1', 'val'); + $doc1 = $port->set($doc1, 'a1', 'val'); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + same($doc2->toArray()['a1'] ?? null, 'val', 'read-write peer should receive a1 while peer is read-only'); + truthy(! array_key_exists('b1', $doc1->toArray()), 'read-only peer should ignore b1'); + + $sync1['readOnly'] = false; + $doc2 = $port->set($doc2, 'b2', 'val'); + $doc1 = $port->set($doc1, 'a2', 'val'); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + same($doc1->toArray()['b1'] ?? null, 'val', 'read-write peer should catch up with withheld b1'); + same($doc1->toArray()['b2'] ?? null, 'val', 'read-write peer should receive b2'); + same($doc2->toArray()['a2'] ?? null, 'val', 'other peer should receive a2'); + + $sync1['readOnly'] = true; + $doc2 = $port->set($doc2, 'b3', 'val'); + $doc1 = $port->set($doc1, 'a3', 'val'); + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + same($doc2->toArray()['a3'] ?? null, 'val', 'read-write peer should receive a3 while peer is read-only again'); + truthy(! array_key_exists('b3', $doc1->toArray()), 'read-only peer should ignore b3'); + + $sync1['readOnly'] = false; + [$doc1, $doc2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + same($doc1->toArray()['b3'] ?? null, 'val', 'peer should catch up with b3 after toggling back to read-write'); + sameArray($port->getHeads($doc1), $port->getHeads($doc2), 'peers should converge heads after repeated read-only toggles'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only publisher does not relay ignored consumer changes', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-publisher-to-multiple-consumers', + 'sync::tests::read_only_publisher_to_multiple_consumers', + function () use ($port): void { + $publisher = $port->set($port->init('aaaaaa'), 'from_r', 'hello'); + $consumerA = $port->init('bbbbbb'); + $consumerB = $port->init('cccccc'); + $publisherToA = $port->initSyncState(['readOnly' => true]); + $aToPublisher = $port->initSyncState(); + + [$publisher, $consumerA, $publisherToA, $aToPublisher] = syncDocuments($port, $publisher, $consumerA, $publisherToA, $aToPublisher); + same($consumerA->toArray()['from_r'] ?? null, 'hello', 'first consumer should receive publisher data'); + + $consumerA = $port->set($consumerA, 'from_a', 'world'); + [$publisher, $consumerA, $publisherToA, $aToPublisher] = syncDocuments($port, $publisher, $consumerA, $publisherToA, $aToPublisher); + truthy(! array_key_exists('from_a', $publisher->toArray()), 'read-only publisher should ignore consumer A data'); + + [$publisher, $consumerB] = syncDocuments( + $port, + $publisher, + $consumerB, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + + same($consumerB->toArray()['from_r'] ?? null, 'hello', 'second consumer should receive publisher data'); + truthy(! array_key_exists('from_a', $consumerB->toArray()), 'second consumer should not receive ignored consumer A data through publisher'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust triangle sync preserves read-only publisher state when changes arrive via two paths', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-triangle-changes-arrive-via-two-paths', + 'sync::tests::triangle_changes_arrive_via_two_paths', + function () use ($port): void { + $publisher = $port->set($port->init('aaaaaa'), 'from_r', 'hello'); + $peerA = $port->set($port->init('bbbbbb'), 'from_a', 'world'); + $peerB = $port->init('cccccc'); + + [$publisher, $peerA] = syncDocuments( + $port, + $publisher, + $peerA, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + same($peerA->toArray()['from_r'] ?? null, 'hello', 'peer A should receive publisher data'); + + [$peerA, $peerB] = syncDocuments($port, $peerA, $peerB, $port->initSyncState(), $port->initSyncState()); + same($peerB->toArray()['from_r'] ?? null, 'hello', 'peer B should receive publisher data via peer A'); + same($peerB->toArray()['from_a'] ?? null, 'world', 'peer B should receive peer A data'); + + [$publisher, $peerB] = syncDocuments( + $port, + $publisher, + $peerB, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + + sameArray($publisher->toArray(), ['from_r' => 'hello'], 'read-only publisher should still only have its own data'); + same($peerB->toArray()['from_r'] ?? null, 'hello', 'peer B should retain publisher data'); + same($peerB->toArray()['from_a'] ?? null, 'world', 'peer B should retain peer A data'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only fully connected triangle keeps publisher isolated while consumers converge', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-fully-connected-triangle', + 'sync::tests::read_only_fully_connected_triangle', + function () use ($port): void { + $publisher = $port->set($port->init('aaaaaa'), 'from_r', 'r_val'); + $peerA = $port->set($port->init('bbbbbb'), 'from_a', 'a_val'); + $peerB = $port->set($port->init('cccccc'), 'from_b', 'b_val'); + $publisherHeads = $port->getHeads($publisher); + + [$publisher, $peerA] = syncDocuments( + $port, + $publisher, + $peerA, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + [$publisher, $peerB] = syncDocuments( + $port, + $publisher, + $peerB, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + + same($peerA->toArray()['from_r'] ?? null, 'r_val', 'peer A should receive publisher data'); + same($peerB->toArray()['from_r'] ?? null, 'r_val', 'peer B should receive publisher data'); + + [$peerA, $peerB] = syncDocuments($port, $peerA, $peerB, $port->initSyncState(), $port->initSyncState()); + + foreach (['from_a' => 'a_val', 'from_b' => 'b_val', 'from_r' => 'r_val'] as $key => $value) { + same($peerA->toArray()[$key] ?? null, $value, 'peer A should converge on ' . $key); + same($peerB->toArray()[$key] ?? null, $value, 'peer B should converge on ' . $key); + } + sameArray($port->getHeads($peerA), $port->getHeads($peerB), 'read-write consumers should converge heads'); + sameArray($port->getHeads($publisher), $publisherHeads, 'publisher heads should remain unchanged'); + sameArray($publisher->toArray(), ['from_r' => 'r_val'], 'publisher should still only have its own data'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust stale shared heads after read-only sync do not corrupt direct publisher sync', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-stale-shared-heads-after-read-only-sync', + 'sync::tests::stale_shared_heads_after_read_only_sync', + function () use ($port): void { + $publisher = $port->init('aaaaaa'); + for ($i = 0; $i < 10; ++$i) { + $publisher = $port->set($publisher, 'counter', $i); + } + $peerA = $port->set($port->init('bbbbbb'), 'from_a', 'a_val'); + $peerB = $port->init('cccccc'); + + [$publisher, $peerA] = syncDocuments( + $port, + $publisher, + $peerA, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + same($peerA->toArray()['counter'] ?? null, 9, 'peer A should receive publisher counter changes'); + + [$peerA, $peerB] = syncDocuments($port, $peerA, $peerB, $port->initSyncState(), $port->initSyncState()); + same($peerB->toArray()['counter'] ?? null, 9, 'peer B should receive publisher counter via peer A'); + same($peerB->toArray()['from_a'] ?? null, 'a_val', 'peer B should receive peer A data'); + + [$publisher, $peerB] = syncDocuments( + $port, + $publisher, + $peerB, + $port->initSyncState(['readOnly' => true]), + $port->initSyncState() + ); + + truthy(! array_key_exists('from_a', $publisher->toArray()), 'read-only publisher should ignore peer A data arriving via peer B'); + same($peerB->toArray()['counter'] ?? null, 9, 'peer B should retain publisher counter after direct publisher sync'); + same($peerB->toArray()['from_a'] ?? null, 'a_val', 'peer B should retain peer A data after direct publisher sync'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust read-only peer handles same remote changes from two peers and continues publishing', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:sync-tests-read-only-peer-receives-same-changes-from-two-peers', + 'sync::tests::read_only_peer_receives_same_changes_from_two_peers', + function () use ($port): void { + $publisher = $port->set($port->init('aaaaaa'), 'from_r', 'r_val'); + $peerA = $port->set($port->init('bbbbbb'), 'from_a', 'a_val'); + $peerB = $port->set($port->init('cccccc'), 'from_b', 'b_val'); + + [$peerA, $peerB] = syncDocuments($port, $peerA, $peerB, $port->initSyncState(), $port->initSyncState()); + sameArray($port->getHeads($peerA), $port->getHeads($peerB), 'peer A and B should start with the same heads'); + $publisherHeads = $port->getHeads($publisher); + + $publisherToA = $port->initSyncState(['readOnly' => true]); + $aToPublisher = $port->initSyncState(); + [$publisher, $peerA, $publisherToA, $aToPublisher] = syncDocuments($port, $publisher, $peerA, $publisherToA, $aToPublisher); + same($peerA->toArray()['from_r'] ?? null, 'r_val', 'peer A should receive publisher data'); + sameArray($port->getHeads($publisher), $publisherHeads, 'publisher should ignore peer A and B data via peer A'); + + $publisherToB = $port->initSyncState(['readOnly' => true]); + $bToPublisher = $port->initSyncState(); + [$publisher, $peerB, $publisherToB, $bToPublisher] = syncDocuments($port, $publisher, $peerB, $publisherToB, $bToPublisher); + same($peerB->toArray()['from_r'] ?? null, 'r_val', 'peer B should receive publisher data'); + sameArray($port->getHeads($publisher), $publisherHeads, 'publisher should ignore same peer data via peer B'); + sameArray($publisher->toArray(), ['from_r' => 'r_val'], 'publisher should still only have its own initial data'); + + $publisher = $port->set($publisher, 'from_r_2', 'new'); + [$publisher, $peerA, $publisherToA, $aToPublisher] = syncDocuments($port, $publisher, $peerA, $publisherToA, $aToPublisher); + same($peerA->toArray()['from_r_2'] ?? null, 'new', 'peer A should receive later publisher data'); + [$publisher, $peerB] = syncDocuments($port, $publisher, $peerB, $publisherToB, $bToPublisher); + same($peerB->toArray()['from_r_2'] ?? null, 'new', 'peer B should receive later publisher data'); + }, + 'rust/automerge/src/sync.rs' +); + +$mapped( + 'sync protocol sends explicitly requested known changes', + 'javascript/test/sync_test.ts', + 956, + 'should allow any change to be requested', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 3; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + $lastSync = $port->getHeads($doc1); + + for ($i = 3; $i < 6; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $sync1['sentHeads'] = []; + $sync1['lastSentHeads'] = []; + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null, 'forced post-sync message should be decodable'); + + $decoded['need'] = $lastSync; + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decoded); + [$sync2, $message] = $port->generateSyncMessage($doc2, $sync2); + $response = $port->decodeSyncMessage($message); + + truthy($response !== null && count($response['changes']) > 0, 'peer should answer an explicit need request for a known change'); + same($response['changes'][0]['hash'] ?? null, $lastSync[0], 'explicit need response should send the requested change first'); + } +); + +$mapped( + 'sync protocol ignores requests for nonexistent changes', + 'javascript/test/sync_test.ts', + 985, + 'should ignore requests for a nonexistent change', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + for ($i = 0; $i < 3; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + + $doc2 = $port->applyChanges($doc2, $port->getAllChanges($doc1)); + + [$sync1, $message1] = $port->generateSyncMessage($doc1, $sync1); + if ($message1 !== null) { + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message1); + } + + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + if ($message2 !== null) { + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $message2); + } + + $decoded = $port->decodeSyncMessage($message1); + truthy($decoded !== null, 'initial sync message should be decodable before mutating the need list'); + $decoded['need'] = [str_repeat('0', 64)]; + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decoded); + [$sync2, $message2] = $port->generateSyncMessage($doc2, $sync2); + + same($message2, null, 'peer should not reply to a request for an unknown change hash'); + sameArray($doc2->toArray(), $doc1->toArray(), 'ignoring the nonexistent request should leave the synced document unchanged'); + } +); + +$mapped( + 'sync protocol requests remaining heads after a subset response', + 'javascript/test/sync_test.ts', + 1022, + 'should allow a subset of changes to be sent', + function () use ($port): void { + $doc1 = $port->init('01234567'); + $doc2 = $port->init('89abcdef'); + $doc3 = $port->init('76543210'); + $sync1 = $port->initSyncState(); + $sync2 = $port->initSyncState(); + + $doc1 = $port->setWithTime($doc1, 'x', 0, 0); + $doc3 = $port->applyChanges($doc3, $port->getAllChanges($doc1)); + for ($i = 1; $i <= 2; ++$i) { + $doc1 = $port->setWithTime($doc1, 'x', $i, 0); + } + for ($i = 3; $i <= 4; ++$i) { + $doc3 = $port->setWithTime($doc3, 'x', $i, 0); + } + $c2 = $port->getHeads($doc1)[0]; + $c4 = $port->getHeads($doc3)[0]; + $doc2 = $port->applyChanges($doc2, $port->getAllChanges($doc3)); + + [$doc1, $doc2, $sync1, $sync2] = syncDocuments($port, $doc1, $doc2, $sync1, $sync2); + $expectedInitialSharedHeads = [$c2, $c4]; + sort($expectedInitialSharedHeads); + sameArray($sync1['sharedHeads'], $expectedInitialSharedHeads, 'subset sync should start with both branch heads shared by peer one'); + sameArray($sync2['sharedHeads'], $expectedInitialSharedHeads, 'subset sync should start with both branch heads shared by peer two'); + + $doc3 = $port->setWithTime($doc3, 'x', 5, 0); + $change5 = $port->getLastLocalChange($doc3); + $doc3 = $port->setWithTime($doc3, 'x', 6, 0); + $change6 = $port->getLastLocalChange($doc3); + $c6 = $port->getHeads($doc3)[0]; + for ($i = 7; $i <= 8; ++$i) { + $doc3 = $port->setWithTime($doc3, 'x', $i, 0); + } + $c8 = $port->getHeads($doc3)[0]; + truthy($change5 !== null && $change6 !== null, 'subset sync source should expose changes five and six'); + $doc2 = $port->mergeDocuments($doc2, $doc3); + + [$sync1, $message] = $port->generateSyncMessage($doc1, $sync1); + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $message); + [$sync2, $message] = $port->generateSyncMessage($doc2, $sync2); + $decoded = $port->decodeSyncMessage($message); + truthy($decoded !== null, 'subset response should be decodable before truncation'); + $decoded['changes'] = [$change5, $change6]; + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decoded); + $expectedPartialSharedHeads = [$c2, $c6]; + sort($expectedPartialSharedHeads); + sameArray($sync1['sharedHeads'], $expectedPartialSharedHeads, 'partial response should only share heads actually received'); + + [$sync1, $request] = $port->generateSyncMessage($doc1, $sync1); + $decodedRequest = $port->decodeSyncMessage($request); + truthy($decodedRequest !== null, 'subset follow-up request should be decodable'); + sameArray($decodedRequest['need'], [$c8], 'subset follow-up should request the advertised missing head'); + sameArray($decodedRequest['have'][0]['lastSync'] ?? [], $expectedPartialSharedHeads, 'subset follow-up should advertise the partial shared heads'); + + [$doc2, $sync2] = $port->receiveSyncMessage($doc2, $sync2, $decodedRequest); + [$sync2, $response] = $port->generateSyncMessage($doc2, $sync2); + $decodedResponse = $port->decodeSyncMessage($response); + truthy($decodedResponse !== null && count($decodedResponse['changes']) >= 1, 'peer should answer the explicit missing-head request'); + same($decodedResponse['changes'][0]['hash'] ?? null, $c8, 'missing-head response should send the explicitly requested head first'); + + [$doc1, $sync1] = $port->receiveSyncMessage($doc1, $sync1, $decodedResponse); + $expectedFinalSharedHeads = [$c2, $c8]; + sort($expectedFinalSharedHeads); + sameArray($port->getHeads($doc1), $expectedFinalSharedHeads, 'requesting the missing head should complete peer one to both branch heads'); + sameArray($sync1['sharedHeads'], $expectedFinalSharedHeads, 'requesting the missing head should advance shared heads through the advertised branch'); + } +); + +$mapped( + 'changeAt text splice merges with later text edits', + 'javascript/test/change_at.ts', + 6, + 'should be able to change a doc at a prior state', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', 'aaabbbccc'); + $heads1 = $port->getHeads($doc1); + + $doc1 = $port->splice($doc1, 'text', 3, 3, 'BBB'); + same($doc1->toArray()['text'], 'aaaBBBccc', 'current branch should show the later text replacement'); + + $historical = $port->view($doc1, $heads1); + same($historical->toArray()['text'], 'aaabbbccc', 'changeAt draft should materialize the requested historical text'); + + $changed = $port->spliceAtHeads($doc1, $heads1, 'text', 2, 3, 'XXX'); + same($changed->toArray()['text'], 'aaXXXBBBccc', 'historical text splice should merge with the later text edit'); + } +); + +$mapped( + 'changeAt empty changes preserve forked document heads', + 'javascript/test/change_at.ts', + 22, + 'should leave multiple heads intact on empty changes', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', 'aaabbbccc'); + $headsBeforeFork = $port->getHeads($doc1); + + $doc2 = $port->set($port->clone($doc1, 'bbbbbb'), 'doc2', 'doc2'); + $doc1 = $port->set($doc1, 'doc1', 'doc1'); + $doc1 = $port->mergeDocuments($doc1, $doc2); + + same(count($port->getHeads($doc1)), 2, 'setup should create a forked document with two heads'); + $afterEmptyChange = $port->emptyChangeAtHeads($doc1, $headsBeforeFork); + same(count($port->getHeads($afterEmptyChange)), 2, 'empty changeAt should preserve forked heads'); + sameArray($afterEmptyChange->toArray(), $doc1->toArray(), 'empty changeAt should not alter materialized state'); + } +); + +$mapped( + 'changeAt returns the heads of the change document', + 'javascript/test/change_at.ts', + 47, + 'should return the heads of the change document from changeAt', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', 'aaabbbccc'); + + $doc2 = $port->set($port->clone($doc1, 'bbbbbb'), 'doc2', 'doc2'); + $headsOnFork = $port->getHeads($doc2); + + $doc1 = $port->set($doc1, 'doc1', 'doc1'); + $doc1Heads = $port->getHeads($doc1); + + $merged = $port->mergeDocuments($doc1, $doc2); + $changed = $port->setAtHeads($merged, $doc1Heads, 'text', 'changed'); + $newHeads = array_values(array_diff($port->getHeads($changed), $headsOnFork)); + $expectedHeads = array_values(array_unique(array_merge($headsOnFork, $newHeads))); + sort($expectedHeads, SORT_STRING); + $actualHeads = $port->getHeads($changed); + sort($actualHeads, SORT_STRING); + + same(count($newHeads), 1, 'changeAt should create one new head for the requested branch'); + sameArray($actualHeads, $expectedHeads, 'changeAt should preserve unrelated fork heads alongside the new change head'); + same($changed->toArray()['text'], 'changed', 'changeAt root assignment should materialize the new value'); + same($changed->toArray()['doc2'], 'doc2', 'changeAt root assignment should preserve the unrelated fork state'); + } +); + +$mapped( + 'basic diff emits text patches between before and after heads', + 'javascript/test/basic_test.ts', + 587, + 'can diff a document with before and hafter heads', + function () use ($port): void { + $doc = $port->from(['value' => ''], 'aaaaaa'); + $doc = $port->set($doc, 'value', 'aaa'); + $heads1 = $port->getHeads($doc); + $doc = $port->set($doc, 'value', 'bbb'); + $heads2 = $port->getHeads($doc); + + sameArray( + $port->diff($doc, $heads1, $heads2), + [ + ['action' => 'put', 'path' => ['value'], 'value' => ''], + ['action' => 'splice', 'path' => ['value', 0], 'value' => 'bbb'], + ], + 'forward text diff should recreate the after text value' + ); + sameArray( + $port->diff($doc, $heads2, $heads1), + [ + ['action' => 'put', 'path' => ['value'], 'value' => ''], + ['action' => 'splice', 'path' => ['value', 0], 'value' => 'aaa'], + ], + 'reverse text diff should recreate the before text value' + ); + } +); + +$mapped( + 'basic saveSince matches saveIncremental from the last incremental heads', + 'javascript/test/basic_test.ts', + 606, + 'should be the same as saveIncremental since heads of the last saveIncremental', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $doc = $port->set($doc, 'a', 'b'); + $port->saveIncremental($doc); + $heads = $port->getHeads($doc); + + $doc = $port->set($doc, 'c', 'd'); + + same( + $port->saveIncremental($doc), + $port->saveSince($doc, $heads), + 'incremental save should serialize the same native change batch as saveSince' + ); + } +); + +$mapped( + 'extra API loadIncremental applies full and incremental native batches', + 'javascript/test/extra_api_tests.ts', + 6, + 'should allow you to load incrementally', + function () use ($port): void { + $doc1 = $port->from(['foo' => 'bar'], 'aaaaaa'); + $doc2 = $port->loadIncremental($port->init('bbbbbb'), $port->save($doc1)); + + $doc1 = $port->set($doc1, 'foo2', 'bar2'); + $doc2 = $port->loadIncremental($doc2, $port->saveIncremental($doc1)); + + $doc1 = $port->set($doc1, 'foo', 'bar2'); + $doc2 = $port->loadIncremental($doc2, $port->saveIncremental($doc1)); + + $doc1 = $port->set($doc1, 'x', 'y'); + $doc2 = $port->loadIncremental($doc2, $port->saveIncremental($doc1)); + + sameArray($doc2->toArray(), $doc1->toArray(), 'incremental load should converge with the source document'); + sameArray($port->getHeads($doc2), $port->getHeads($doc1), 'incremental load should advance to the source heads'); + } +); + +$mapped( + 'bundle format saves selected changes and loads them incrementally', + 'javascript/test/bundle_test.ts', + 5, + 'should allow saving and loading a bundle', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $startDoc = $port->clone($doc, 'bbbbbb'); + $startHeads = $port->getHeads($doc); + $doc = $port->set($doc, 'foo', 'baz'); + $doc = $port->set($doc, 'foo', 'qux'); + $changeHashes = array_map( + static fn (array $change): string => $change['hash'], + $port->getChangesMetaSince($doc, $startHeads) + ); + + same(count($changeHashes), 2, 'bundle test should select the two changes after the starting heads'); + $bundle = $port->saveBundle($doc, $changeHashes); + $loaded = $port->loadIncremental($startDoc, $bundle); + + sameArray($loaded->toArray(), ['foo' => 'qux'], 'loading the bundle should apply both selected changes'); + } +); + +$mapped( + 'bundle format exposes inspectable changes by hash', + 'javascript/test/bundle_test.ts', + 27, + 'should allow getting the list of changes in a bundle', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $startHeads = $port->getHeads($doc); + $doc = $port->set($doc, 'foo', 'baz'); + $doc = $port->set($doc, 'foo', 'qux'); + $changeHashes = array_map( + static fn (array $change): string => $change['hash'], + $port->getChangesMetaSince($doc, $startHeads) + ); + + $bundle = $port->saveBundle($doc, $changeHashes); + $bundleChanges = $port->readBundle($bundle)['changes']; + $changesByHash = []; + foreach ($bundleChanges as $change) { + $changesByHash[$change['hash']] = $change; + } + + foreach ($changeHashes as $hash) { + sameArray($changesByHash[$hash] ?? [], $port->inspectChange($doc, $hash) ?? [], 'bundle should expose inspectable change metadata by hash'); + } + } +); + +$mapped( + 'bundle format reports dependencies outside the selected changes', + 'javascript/test/bundle_test.ts', + 56, + 'should show the dependencies of a bundle', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $startHeads = $port->getHeads($doc); + $doc = $port->set($doc, 'foo', 'baz'); + $changeHashes = array_map( + static fn (array $change): string => $change['hash'], + $port->getChangesMetaSince($doc, $startHeads) + ); + + $bundle = $port->saveBundle($doc, $changeHashes); + sameArray($port->readBundle($bundle)['deps'], $startHeads, 'bundle deps should identify heads required before applying selected changes'); + } +); + +$rustMapped( + 'rust storage bundle preserves selected changes and loads into a fork', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:storage-bundle-test-make-bundle', + 'storage::bundle::test::make_bundle', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $doc = $port->set($doc, 'aaa', $port->immutableString('aaa')); + $h0 = $port->getHeads($doc)[0] ?? ''; + $fork = $port->clone($doc, 'bbbbbb'); + + $doc = $port->set($doc, 'bbb', $port->immutableString('bbb')); + $h1 = $port->getHeads($doc)[0] ?? ''; + $doc = $port->set($doc, 'ccc', $port->immutableString('ccc')); + $h2 = $port->getHeads($doc)[0] ?? ''; + + $bundle = $port->saveBundle($doc, [$h0, $h1, $h2]); + $changes = $port->readBundle($bundle)['changes']; + same(count($changes), 3, 'bundle should expose the three selected changes'); + same($changes[0]['hash'] ?? null, $h0, 'first bundled change should be h0'); + same($changes[1]['hash'] ?? null, $h1, 'second bundled change should be h1'); + same($changes[2]['hash'] ?? null, $h2, 'third bundled change should be h2'); + same($changes[0]['startOp'] ?? null, 1, 'first bundled change should start at op 1'); + same($changes[1]['startOp'] ?? null, 2, 'second bundled change should start at op 2'); + same($changes[2]['startOp'] ?? null, 3, 'third bundled change should start at op 3'); + + $loadedFork = $port->loadIncremental($fork, $bundle); + same(json_encode($loadedFork->toArray()), json_encode($doc->toArray()), 'loading a full bundle into a fork should converge materialized data'); + sameArray($port->getHeads($loadedFork), $port->getHeads($doc), 'loading a full bundle into a fork should converge heads'); + + $partialBundle = $port->saveBundle($doc, [$h0, $h2]); + $partialChanges = $port->readBundle($partialBundle)['changes']; + same(count($partialChanges), 2, 'partial bundle should expose only selected changes'); + same($partialChanges[0]['hash'] ?? null, $h0, 'partial bundle should retain selected h0'); + same($partialChanges[1]['hash'] ?? null, $h2, 'partial bundle should retain selected h2'); + same($partialChanges[0]['startOp'] ?? null, 1, 'partial bundle h0 should retain its start op'); + same($partialChanges[1]['startOp'] ?? null, 3, 'partial bundle h2 should retain its start op'); + }, + 'rust/automerge/src/storage/bundle.rs' +); + +$mapped( + 'patch callback exposes before and after heads', + 'javascript/test/patches.ts', + 7, + 'should provide access to before and after states', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $headsBefore = $port->getHeads($doc); + $headsAfter = null; + $callbackPort = $port->withPatchCallback(static function (array $patches, array $info) use ($port, $headsBefore, &$headsAfter): void { + sameArray($port->getHeads($info['before']), $headsBefore, 'patch callback before document should retain the pre-change heads'); + $headsAfter = $port->getHeads($info['after']); + }); + + $newDoc = $callbackPort->set($doc, 'count', 1); + + sameArray($headsAfter ?? [], $port->getHeads($newDoc), 'patch callback after document should expose the committed heads'); + } +); + +$mapped( + 'patch callback exposes before and after states for list deletion', + 'javascript/test/patches.ts', + 27, + 'should provide correct before and after states when an array has a value deleted', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + $callbacks = []; + $callbackPort = $port->withPatchCallback(static function (array $patches, array $info) use (&$callbacks): void { + $callbacks[] = [ + 'before' => $info['before']->toArray(), + 'after' => $info['after']->toArray(), + ]; + }); + + $newDoc = $callbackPort->deleteListElements($doc, 'list', 1); + + sameArray($callbacks[0]['before']['list'], ['a', 'b', 'c'], 'patch callback before state should include the original list'); + sameArray($callbacks[0]['after']['list'], ['a', 'c'], 'patch callback after state should include the deleted list value'); + sameArray($newDoc->toArray(), ['list' => ['a', 'c']], 'list deletion should materialize the expected document'); + } +); + +$mapped( + 'patch callback exposes before and after states for nested property deletion', + 'javascript/test/patches.ts', + 49, + 'should provide correct before and after states when an object property has been removed', + function () use ($port): void { + $doc = $port->from(['obj' => ['a' => 'a', 'b' => 'b']], 'aaaaaa'); + $callbacks = []; + $callbackPort = $port->withPatchCallback(static function (array $patches, array $info) use (&$callbacks): void { + $callbacks[] = [ + 'patches' => $patches, + 'before' => $info['before']->toArray(), + 'after' => $info['after']->toArray(), + ]; + }); + + $newDoc = $callbackPort->deleteNested($doc, ['obj', 'b']); + + sameArray($callbacks[0]['patches'], [['action' => 'del', 'path' => ['obj', 'b']]], 'nested delete should emit a native delete patch path'); + sameArray($callbacks[0]['before']['obj'], ['a' => 'a', 'b' => 'b'], 'patch callback before state should include the removed property'); + sameArray($callbacks[0]['after']['obj'], ['a' => 'a'], 'patch callback after state should omit the removed property'); + sameArray($newDoc->toArray(), ['obj' => ['a' => 'a']], 'nested property deletion should materialize the expected document'); + } +); + +$mapped( + 'patch diff returns insertion and container patches between heads', + 'javascript/test/patches.ts', + 76, + 'should return a set of patches', + function () use ($port): void { + $doc = $port->from(['birds' => ['goldfinch']], 'aaaaaa'); + $before = $port->getHeads($doc); + $newDoc = $port->pushList($doc, 'birds', ['greenfinch']); + $newDoc = $port->set($newDoc, 'fish', ['cod']); + $after = $port->getHeads($newDoc); + + sameArray( + $port->diff($newDoc, $before, $after), + [ + ['action' => 'put', 'path' => ['fish'], 'value' => []], + ['action' => 'insert', 'path' => ['birds', 1], 'values' => ['']], + ['action' => 'splice', 'path' => ['birds', 1, 0], 'value' => 'greenfinch'], + ['action' => 'insert', 'path' => ['fish', 0], 'values' => ['']], + ['action' => 'splice', 'path' => ['fish', 0, 0], 'value' => 'cod'], + ], + 'diff should emit stable Automerge-style patches between known heads' + ); + } +); + +$mapped( + 'patch diff rejects invalid before and after heads', + 'javascript/test/patches.ts', + 96, + 'should throw a nice exception if before or after are not an array', + function () use ($port): void { + $doc = $port->from(['text' => 'hello world'], 'aaaaaa'); + $goodBefore = $port->getHeads($doc); + $doc = $port->splice($doc, 'text', 0, 0, 'hello '); + $goodAfter = $port->getHeads($doc); + + foreach ([null, '', 'ab', ['ab']] as $invalidInput) { + throwsLike( + static fn () => $port->diff($doc, $invalidInput, $goodAfter), + 'invalid before heads', + 'diff should reject invalid before heads' + ); + throwsLike( + static fn () => $port->diff($doc, $goodBefore, $invalidInput), + 'invalid after heads', + 'diff should reject invalid after heads' + ); + } + } +); + +$mapped( + 'patch diffPath supports nested map scopes and shallow recursion', + 'javascript/test/patches.ts', + 120, + 'should allow diffing a sub-object', + function () use ($port): void { + $doc = $port->from(['a' => 1, 'foo' => ['b' => 1, 'bar' => ['c' => 1, 'baz' => ['d' => 1]]]], 'aaaaaa'); + $h1 = $port->getHeads($doc); + $doc = $port->setNested($doc, ['a'], 2); + $doc = $port->setNested($doc, ['foo', 'b'], 2); + $doc = $port->setNested($doc, ['foo', 'bar', 'c'], 2); + $doc = $port->setNested($doc, ['foo', 'bar', 'baz', 'd'], 2); + $h2 = $port->getHeads($doc); + $doc = $port->setNested($doc, ['foo', 'bar', 'baz', 'd'], 3); + $h3 = $port->getHeads($doc); + $doc = $port->setNested($doc, ['a'], 4); + $doc = $port->setNested($doc, ['foo', 'b'], 4); + $doc = $port->setNested($doc, ['foo', 'bar', 'c'], 4); + $doc = $port->setNested($doc, ['foo', 'bar', 'baz'], ['d' => 4]); + $h4 = $port->getHeads($doc); + + $full = [ + ['action' => 'put', 'path' => ['a'], 'value' => 4], + ['action' => 'put', 'path' => ['foo', 'b'], 'value' => 4], + ['action' => 'put', 'path' => ['foo', 'bar', 'baz'], 'value' => []], + ['action' => 'put', 'path' => ['foo', 'bar', 'c'], 'value' => 4], + ['action' => 'put', 'path' => ['foo', 'bar', 'baz', 'd'], 'value' => 4], + ]; + $bar = array_slice($full, 2); + + sameArray($port->diff($doc, $h1, $h4), $full, 'diff should include nested replacement container and scalar updates'); + sameArray($port->diffPath($doc, ['foo', 'bar'], $h1, $h4), $bar, 'diffPath should preserve absolute paths under the selected object'); + sameArray(array_values($port->diffPath($doc, ['foo', 'bar'], $h1, $h4, ['recursive' => false])), array_slice($bar, 0, 2), 'shallow diffPath should omit grandchildren'); + sameArray($port->diffPath($doc, ['foo', 'bar', 'baz'], $h1, $h4), [['action' => 'put', 'path' => ['foo', 'bar', 'baz', 'd'], 'value' => 4]], 'diffPath should support deeper map paths'); + sameArray($port->diffPath($doc, ['foo', 'bar'], $h2, $h3), [['action' => 'put', 'path' => ['foo', 'bar', 'baz', 'd'], 'value' => 3]], 'diffPath should compare adjacent head ranges'); + sameArray($port->diffPath($doc, ['foo', 'bar'], $h3, $h2), [['action' => 'put', 'path' => ['foo', 'bar', 'baz', 'd'], 'value' => 2]], 'diffPath should compare reverse head ranges'); + sameArray($port->diffPath($doc, ['foo', 'bar'], [], $h4), $bar, 'diffPath should diff from an empty head set'); + sameArray(array_values($port->diffPath($doc, ['foo', 'bar'], [], $h4, ['recursive' => false])), array_slice($bar, 0, 2), 'shallow diffPath from empty heads should omit grandchildren'); + sameArray($port->diffPath($doc, ['foo', 'bar'], $h3, $h4), $bar, 'diffPath should report replacement containers after later changes'); + sameArray(array_values($port->diffPath($doc, ['foo', 'bar'], $h3, $h4, ['recursive' => false])), array_slice($bar, 0, 2), 'shallow diffPath after later changes should omit grandchildren'); + } +); + +$mapped( + 'patch diff reverses deletion of a string list value', + 'javascript/test/patches.ts', + 201, + 'should correctly diff the reverse of deleting a string value on next', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + $callbacks = []; + $callbackPort = $port->withPatchCallback(static function (array $patches, array $info) use ($port, &$callbacks): void { + $callbacks[] = $port->diff( + $info['after'], + $port->getHeads($info['after']), + $port->getHeads($info['before']) + ); + }); + + $callbackPort->deleteListElements($doc, 'list', 1); + + sameArray( + $callbacks[0], + [ + ['action' => 'insert', 'path' => ['list', 1], 'values' => ['']], + ['action' => 'splice', 'path' => ['list', 1, 0], 'value' => 'b'], + ], + 'reverse diff should reinsert a deleted string list value' + ); + } +); + +$mapped( + 'patch changeAt style updates do not mix stale scalar content', + 'javascript/test/patches.ts', + 225, + 'should produce correct patches during changeAt', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $beginning = $port->getHeads($doc); + $doc = $port->set($doc, 'name', str_repeat('a', 100)); + + $doc = $port->setAtHeads($doc, $beginning, 'color', 'red'); + $doc = $port->setAtHeads($doc, $beginning, 'color', 'unset'); + + same($doc->toArray()['color'], 'unset', 'changeAt-style scalar update should keep the last visible value intact'); + same($doc->toArray()['name'], str_repeat('a', 100), 'changeAt-style update should preserve unrelated larger scalar content'); + } +); + +$mapped( + 'patches apply a map update to a document', + 'javascript/test/patches.ts', + 254, + 'should apply a map update', + function () use ($port): void { + $doc = $port->from(['foo' => ['bar' => 'baz']], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'put', + 'path' => ['foo', 'bar'], + 'value' => 'qux', + ], + ] + ); + + same($doc->toArray()['foo']['bar'], 'qux', 'put patch should update a nested map field'); + } +); + +$mapped( + 'patches apply a list update to a document', + 'javascript/test/patches.ts', + 267, + 'should apply a list update patch', + function () use ($port): void { + $doc = $port->from(['foo' => ['bar']], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'put', + 'path' => ['foo', 0], + 'value' => 'baz', + ], + ] + ); + + same($doc->toArray()['foo'][0], 'baz', 'put patch should update a list element'); + } +); + +$mapped( + 'patches apply a list insertion to a document', + 'javascript/test/patches.ts', + 278, + 'should apply a list insertion patch', + function () use ($port): void { + $doc = $port->from(['foo' => ['bar']], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'insert', + 'path' => ['foo', 1], + 'values' => ['baz', 'qux'], + ], + ] + ); + + sameArray($doc->toArray()['foo'], ['bar', 'baz', 'qux'], 'insert patch should add list values at the requested index'); + } +); + +$mapped( + 'patches apply a list deletion without length to a document', + 'javascript/test/patches.ts', + 289, + 'should apply a list deletion patch without length', + function () use ($port): void { + $doc = $port->from(['foo' => ['bar', 'baz', 'qux']], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'del', + 'path' => ['foo', 1], + ], + ] + ); + + sameArray($doc->toArray()['foo'], ['bar', 'qux'], 'single del patch should delete one list element'); + } +); + +$mapped( + 'patches apply a list deletion with length to a document', + 'javascript/test/patches.ts', + 301, + 'should apply a list deletion patch with length', + function () use ($port): void { + $doc = $port->from(['foo' => ['bar', 'baz', 'qux']], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'del', + 'path' => ['foo', 0], + 'length' => 2, + ], + ] + ); + + sameArray($doc->toArray()['foo'], ['qux'], 'length del patch should delete the requested range'); + } +); + +$mapped( + 'patches apply a text splice to a document', + 'javascript/test/patches.ts', + 314, + 'should apply a text splice patch', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'splice', + 'path' => ['foo', 3], + 'value' => 'baz', + ], + ] + ); + + same($doc->toArray()['foo'], 'barbaz', 'splice patch should insert text at the requested offset'); + } +); + +$mapped( + 'patches apply a text deletion without length to a document', + 'javascript/test/patches.ts', + 325, + 'should apply a text deletion patch without length', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'del', + 'path' => ['foo', 0], + ], + ] + ); + + same($doc->toArray()['foo'], 'ar', 'single del patch should delete one text character'); + } +); + +$mapped( + 'patches apply a text deletion with length to a document', + 'javascript/test/patches.ts', + 335, + 'should apply a text deletion patch with length', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'del', + 'path' => ['foo', 0], + 'length' => 2, + ], + ] + ); + + same($doc->toArray()['foo'], 'r', 'length del patch should delete the requested text range'); + } +); + +$mapped( + 'patches apply an increment patch to a document counter', + 'javascript/test/patches.ts', + 346, + 'should apply an increment patch', + function () use ($port): void { + $doc = $port->from(['foo' => new Counter(1)], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'inc', + 'path' => ['foo'], + 'value' => 2, + ], + ] + ); + + $counter = $doc->toArray()['foo']; + truthy($counter instanceof Counter, 'increment patch should keep the field as a native Counter'); + same($counter->value(), 3, 'increment patch should add to the counter value'); + } +); + +$mapped( + 'patches apply a mark patch to a document', + 'javascript/test/patches.ts', + 359, + 'should apply a mark patch', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'mark', + 'path' => ['foo'], + 'marks' => [ + [ + 'name' => 'bold', + 'value' => true, + 'start' => 0, + 'end' => 2, + ], + ], + ], + ] + ); + + sameArray($port->marks($doc, ['foo']), [['name' => 'bold', 'value' => true, 'start' => 0, 'end' => 2]], 'mark patch should store text mark metadata'); + } +); + +$mapped( + 'patches apply an unmark patch to a document', + 'javascript/test/patches.ts', + 380, + 'should apply an unmark patch', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [ + [ + 'action' => 'mark', + 'path' => ['foo'], + 'marks' => [['name' => 'bold', 'value' => true, 'start' => 0, 'end' => 2]], + ], + ] + ); + $doc = $port->applyPatches( + $doc, + [['action' => 'unmark', 'path' => ['foo'], 'name' => 'bold', 'start' => 0, 'end' => 2]] + ); + + sameArray($port->marks($doc, ['foo']), [], 'unmark patch should remove matching text mark metadata'); + } +); + +$mapped( + 'patches apply a map update to a plain PHP array', + 'javascript/test/patches.ts', + 405, + 'should apply a map update to a nested map', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => ['bar' => 'baz']], + [['action' => 'put', 'path' => ['foo', 'bar'], 'value' => 'qux']] + ); + + same($doc['foo']['bar'], 'qux', 'plain-array put patch should update a nested map field'); + } +); + +$mapped( + 'patches apply a list update to a plain PHP array', + 'javascript/test/patches.ts', + 416, + 'should apply a list update patch', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => ['bar']], + [['action' => 'put', 'path' => ['foo', 0], 'value' => 'baz']] + ); + + same($doc['foo'][0], 'baz', 'plain-array put patch should update a list element'); + } +); + +$mapped( + 'patches apply a list insertion to a plain PHP array', + 'javascript/test/patches.ts', + 427, + 'should apply a list insertion patch', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => ['bar']], + [['action' => 'insert', 'path' => ['foo', 1], 'values' => ['baz', 'qux']]] + ); + + sameArray($doc['foo'], ['bar', 'baz', 'qux'], 'plain-array insert patch should add list values'); + } +); + +$mapped( + 'patches apply a list deletion without length to a plain PHP array', + 'javascript/test/patches.ts', + 438, + 'should apply a list deletion patch without length', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => ['bar', 'baz', 'qux']], + [['action' => 'del', 'path' => ['foo', 1]]] + ); + + sameArray($doc['foo'], ['bar', 'qux'], 'plain-array del patch should delete one list element'); + } +); + +$mapped( + 'patches apply a list deletion with length to a plain PHP array', + 'javascript/test/patches.ts', + 450, + 'should apply a list deletion patch with length', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => ['bar', 'baz', 'qux']], + [['action' => 'del', 'path' => ['foo', 0], 'length' => 2]] + ); + + sameArray($doc['foo'], ['qux'], 'plain-array length del patch should delete the requested range'); + } +); + +$mapped( + 'patches apply a text splice to a plain PHP array', + 'javascript/test/patches.ts', + 463, + 'should apply a text splice patch', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => 'bar'], + [['action' => 'splice', 'path' => ['foo', 3], 'value' => 'baz']] + ); + + same($doc['foo'], 'barbaz', 'plain-array splice patch should insert text'); + } +); + +$mapped( + 'patches apply a text deletion without length to a plain PHP array', + 'javascript/test/patches.ts', + 474, + 'should apply a text deletion patch without length', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => 'bar'], + [['action' => 'del', 'path' => ['foo', 0]]] + ); + + same($doc['foo'], 'ar', 'plain-array del patch should delete one text character'); + } +); + +$mapped( + 'patches apply a text deletion with length to a plain PHP array', + 'javascript/test/patches.ts', + 484, + 'should apply a text deletion patch with length', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => 'bar'], + [['action' => 'del', 'path' => ['foo', 0], 'length' => 2]] + ); + + same($doc['foo'], 'r', 'plain-array length del patch should delete the requested text range'); + } +); + +$mapped( + 'patches apply an increment patch to a plain PHP array', + 'javascript/test/patches.ts', + 495, + 'should apply an increment patch', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => 1], + [['action' => 'inc', 'path' => ['foo'], 'value' => 2]] + ); + + same($doc['foo'], 3, 'plain-array increment patch should add to the numeric field'); + } +); + +$mapped( + 'patches ignore a mark patch on a plain PHP array', + 'javascript/test/patches.ts', + 506, + 'should ignore a mark patch', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => 'bar'], + [ + [ + 'action' => 'mark', + 'path' => ['foo'], + 'marks' => [['name' => 'bold', 'value' => true, 'start' => 0, 'end' => 2]], + ], + ] + ); + + sameArray($doc, ['foo' => 'bar'], 'plain-array mark patch should be ignored without mutating text'); + } +); + +$mapped( + 'patches ignore an unmark patch on a plain PHP array', + 'javascript/test/patches.ts', + 523, + 'should ignore an unmark patch', + function () use ($port): void { + $doc = $port->applyPatchesToArray( + ['foo' => 'bar'], + [['action' => 'unmark', 'path' => ['foo'], 'name' => 'bold', 'start' => 0, 'end' => 2]] + ); + + sameArray($doc, ['foo' => 'bar'], 'plain-array unmark patch should be ignored without mutating text'); + } +); + +$mapped( + 'patches apply a deep map update to a document', + 'javascript/test/patches.ts', + 535, + 'should apply a map update to a map in a list in a map in a list', + function () use ($port): void { + $doc = $port->from(['foo' => [['bar' => [['foo' => 'hehe']]]]], 'aaaaaa'); + $doc = $port->applyPatches( + $doc, + [['action' => 'put', 'path' => ['foo', 0, 'bar', 0, 'foo'], 'value' => 'qux']] + ); + + same($doc->toArray()['foo'][0]['bar'][0]['foo'], 'qux', 'deep put patch should update nested map value through lists'); + } +); + +$mapped( + 'basic toJS returns each document at its own heads', + 'javascript/test/basic_test.ts', + 778, + 'should return the document at its correct heads', + function () use ($port): void { + $doc = $port->from(['x' => 1], 'aaaaaa'); + $doc1 = $port->setMany($doc, ['a' => 123, 'b' => 456]); + + sameArray($port->toJS($doc), ['x' => 1], 'toJS should materialize the unchanged base document'); + sameArray($port->toJS($doc1), ['x' => 1, 'a' => 123, 'b' => 456], 'toJS should materialize the changed document'); + } +); + +$mapped( + 'basic immutable string accepts symbol-compatible objects', + 'javascript/test/basic_test.ts', + 792, + 'should treat any class which has the correct symbol as a ImmutableString', + function () use ($port): void { + $fake = new class ('something') { + public bool $isImmutableString = true; + + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + return $this->value; + } + }; + + $doc = $port->from(['foo' => null], 'aaaaaa'); + $doc = $port->set($doc, 'foo', $fake); + $foo = $port->toJS($doc)['foo']; + + truthy($foo instanceof ImmutableString, 'symbol-compatible objects should materialize as ImmutableString'); + same($foo->toString(), 'something', 'immutable string content should be preserved'); + } +); + +$mapped( + 'next export initializes native documents', + 'javascript/test/next_test.ts', + 5, + 'should expose a next export to maintain backwards compatiblity with 2.0', + function () use ($port): void { + $next = $port->next(); + $doc = $next->init('aaaaaa'); + + truthy($next instanceof NativePort, 'next export should expose a native port instance'); + truthy($doc instanceof Document, 'next export should support init'); + sameArray($doc->toArray(), [], 'next init should create an empty native document'); + } +); + +$mapped( + 'next export has the same public API as the main port', + 'javascript/test/next_test.ts', + 9, + 'should have the same types as the main export', + function () use ($port): void { + $next = $port->next(); + $mainMethods = array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + (new ReflectionClass($port))->getMethods(ReflectionMethod::IS_PUBLIC) + ); + $nextMethods = array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + (new ReflectionClass($next))->getMethods(ReflectionMethod::IS_PUBLIC) + ); + sort($mainMethods); + sort($nextMethods); + + sameArray($nextMethods, $mainMethods, 'next export should expose the same public method surface as the main port'); + } +); + +$mapped( + 'basic RawString aliases ImmutableString semantics', + 'javascript/test/basic_test.ts', + 821, + 'should export RawString and isRawString for backwards compatibility', + function () use ($port): void { + $raw = $port->rawString('xyz'); + + truthy($raw instanceof RawString, 'rawString should construct the backwards-compatible type'); + truthy($port->isRawString($raw), 'isRawString should accept RawString instances'); + truthy($port->isImmutableString($raw), 'RawString should share ImmutableString predicate semantics'); + } +); + +$mapped( + 'basic immutable string predicate distinguishes raw strings', + 'javascript/test/basic_test.ts', + 829, + 'should export a predicate to check if something is an immutablestring', + function () use ($port): void { + $doc = $port->from( + [ + 'foo' => $port->immutableString('someval2'), + 'bar' => 'notanimmutablestring', + ], + 'aaaaaa' + ); + $value = $port->toJS($doc); + + truthy($port->isImmutableString($value['foo']), 'immutable string values should satisfy the predicate'); + truthy(! $port->isImmutableString($value['bar']), 'plain strings should not satisfy the predicate'); + + $doc = $port->set($doc, 'baz', 1); + $changedValue = $port->toJS($doc); + truthy($port->isImmutableString($changedValue['foo']), 'changed document should preserve immutable string values'); + truthy(! $port->isImmutableString($changedValue['bar']), 'changed document should preserve plain string values'); + } +); + +$mapped( + 'basic transaction rollback preserves the original document', + 'javascript/test/basic_test.ts', + 842, + 'it should be able to roll back a transaction', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + $savedBefore = $port->save($doc); + + throwsLike( + static function () use ($port, $doc): void { + $port->changeTransaction( + $doc, + static function (Document $draft): void { + $draft->set('key', 'value'); + throw new RangeException('no'); + } + ); + }, + 'no', + 'transaction should rethrow the callback failure' + ); + + same($port->save($doc), $savedBefore, 'failed transaction should leave original document save bytes unchanged'); + } +); + +$mapped( + 'basic clone does not copy patch callbacks', + 'javascript/test/basic_test.ts', + 384, + 'should not copy the patchCallback', + function () use ($port): void { + $patches = []; + $callbackPort = $port->withPatchCallback(static function (array $patchBatch) use (&$patches): void { + $patches[] = $patchBatch; + }); + + $doc = $callbackPort->init('aaaaaa'); + $clone = $port->clone($doc, 'bbbbbb'); + $clone = $port->set($clone, 'foo', 'bar'); + + sameArray($patches, [], 'cloned documents should not carry a patch callback from the originating port'); + sameArray($clone->toArray(), ['foo' => 'bar'], 'clone should remain editable after dropping the callback association'); + } +); + +$mapped( + 'basic list convenience methods update materialized arrays', + 'javascript/test/basic_test.ts', + 260, + 'have many list methods', + function () use ($port): void { + $doc = $port->from(['list' => [1, 2, 3]], 'aaaaaa'); + + $doc = $port->spliceList($doc, 'list', 1, 1, [9, 10]); + sameArray($doc->toArray(), ['list' => [1, 9, 10, 3]], 'spliceList should replace the requested list span'); + + $doc = $port->pushList($doc, 'list', [11, 12]); + sameArray($doc->toArray(), ['list' => [1, 9, 10, 3, 11, 12]], 'pushList should append values'); + + $doc = $port->unshiftList($doc, 'list', [2, 2]); + sameArray($doc->toArray(), ['list' => [2, 2, 1, 9, 10, 3, 11, 12]], 'unshiftList should prepend values'); + + $doc = $port->shiftList($doc, 'list'); + sameArray($doc->toArray(), ['list' => [2, 1, 9, 10, 3, 11, 12]], 'shiftList should remove the first value'); + + $doc = $port->insertListElements($doc, 'list', 3, [100, 101]); + sameArray($doc->toArray(), ['list' => [2, 1, 9, 100, 101, 10, 3, 11, 12]], 'insertListElements should insert values at the requested offset'); + } +); + +$mapped( + 'basic proxy lists behave like PHP arrays', + 'javascript/test/basic_test.ts', + 412, + 'behave like arrays', + function () use ($port): void { + $doc = $port->from([ + 'chars' => ['a', 'b', 'c'], + 'numbers' => [20, 3, 100], + 'repeats' => [20, 20, 3, 3, 3, 3, 100, 100], + ], 'aaaaaa'); + $visited = []; + + sameArray($port->listConcat($doc, 'chars', [1, 2]), ['a', 'b', 'c', 1, 2], 'listConcat should append supplied values'); + sameArray($port->listMap($doc, 'chars', static fn (mixed $value): string => $value . '!'), ['a!', 'b!', 'c!'], 'listMap should map string values'); + sameArray($port->listMap($doc, 'numbers', static fn (mixed $value): int => $value + 10), [30, 13, 110], 'listMap should map numeric values'); + same($port->listJoin($doc, 'numbers'), '20,3,100', 'listJoin should match default array stringification'); + same($port->listJoin($doc, 'numbers', '|'), '20|3|100', 'listJoin should honor custom separators'); + $port->listForEach($doc, 'numbers', static function (mixed $value) use (&$visited): void { + $visited[] = $value; + }); + sameArray($visited, [20, 3, 100], 'listForEach should visit every item in order'); + truthy($port->listEvery($doc, 'numbers', static fn (mixed $value): bool => $value > 1), 'listEvery should return true when every item matches'); + truthy(! $port->listEvery($doc, 'numbers', static fn (mixed $value): bool => $value > 10), 'listEvery should return false when an item fails'); + sameArray($port->listFilter($doc, 'numbers', static fn (mixed $value): bool => $value > 10), [20, 100], 'listFilter should preserve matching values'); + same($port->listFind($doc, 'repeats', static fn (mixed $value): bool => $value < 10), 3, 'listFind should return the first matching value'); + same($port->listFind($doc, 'repeats', static fn (mixed $value): bool => $value < 0), null, 'listFind should return null when no value matches'); + same($port->listFindIndex($doc, 'repeats', static fn (mixed $value): bool => $value < 10), 2, 'listFindIndex should return the first matching index'); + same($port->listFindIndex($doc, 'repeats', static fn (mixed $value): bool => $value < 0), -1, 'listFindIndex should return -1 when no value matches'); + truthy($port->listIncludes($doc, 'numbers', 3), 'listIncludes should find present values'); + truthy(! $port->listIncludes($doc, 'numbers', -3), 'listIncludes should reject absent values'); + truthy($port->listSome($doc, 'numbers', static fn (mixed $value): bool => $value === 3), 'listSome should return true for a matching value'); + truthy(! $port->listSome($doc, 'numbers', static fn (mixed $value): bool => $value < 0), 'listSome should return false when no value matches'); + same($port->listReduce($doc, 'numbers', static fn (mixed $sum, mixed $value): int => $sum + $value, 100), 223, 'listReduce should fold left to right'); + same($port->listReduce($doc, 'repeats', static fn (mixed $sum, mixed $value): int => $sum + $value, 100), 352, 'listReduce should fold repeated values'); + same($port->listReduce($doc, 'chars', static fn (mixed $sum, mixed $value): string => $sum . $value, '='), '=abc', 'listReduce should fold strings left to right'); + same($port->listReduceRight($doc, 'chars', static fn (mixed $sum, mixed $value): string => $sum . $value, '='), '=cba', 'listReduceRight should fold strings right to left'); + same($port->listReduceRight($doc, 'numbers', static fn (mixed $sum, mixed $value): int => $sum + $value, 100), 223, 'listReduceRight should fold numeric values right to left'); + same($port->listLastIndexOf($doc, 'repeats', 3), 5, 'listLastIndexOf should return the final matching index'); + same($port->listLastIndexOf($doc, 'repeats', 3, 3), 3, 'listLastIndexOf should honor fromIndex'); + + [$doc, $filledNumbers] = $port->fillListWithValues($doc, 'numbers', -1, 1, 2); + sameArray($filledNumbers, [20, -1, 100], 'fillListWithValues should return the filled list value'); + [$doc, $filledChars] = $port->fillListWithValues($doc, 'chars', 'z', 1, 100); + sameArray($filledChars, ['a', 'z', 'z'], 'fillListWithValues should clamp the fill end to the list length'); + sameArray($doc->toArray()['numbers'], [20, -1, 100], 'fillListWithValues should update the document list'); + sameArray($doc->toArray()['chars'], ['a', 'z', 'z'], 'fillListWithValues should update string lists'); + } +); + +$mapped( + 'proxy list entries iterator exposes indexes and values', + 'javascript/test/proxies.ts', + 28, + 'should return iterable entries', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + + sameArray($port->listEntries($doc, 'list'), [[0, 'a'], [1, 'b'], [2, 'c']], 'list entries should expose index/value pairs in order'); + } +); + +$mapped( + 'proxy list values iterator exposes ordered values', + 'javascript/test/proxies.ts', + 41, + 'should return iterable values', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + + sameArray($port->listValues($doc, 'list'), ['a', 'b', 'c'], 'list values should expose ordered list contents'); + } +); + +$mapped( + 'proxy list keys iterator exposes ordered indexes', + 'javascript/test/proxies.ts', + 53, + 'should return iterable keys', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + + sameArray($port->listKeys($doc, 'list'), [0, 1, 2], 'list keys should expose contiguous indexes'); + } +); + +$rustMapped( + 'rust list range bounds slice ordered values', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-list-range-tests-list-range-bounds', + 'iter::list_range::tests::list_range_bounds', + function () use ($port): void { + $doc = $port->from(['list' => [1, 2, 3, 4, 5]], 'aaaaaa'); + + sameArray($port->listRange($doc, 'list'), [1, 2, 3, 4, 5], 'unbounded list range should yield all values'); + sameArray($port->listRange($doc, 'list', 2), [3, 4, 5], 'list range with lower bound should yield the suffix'); + sameArray($port->listRange($doc, 'list', 1, 4), [2, 3, 4], 'list range should treat the upper bound as exclusive by default'); + sameArray($port->listRange($doc, 'list', null, 3), [1, 2, 3], 'list range with only an upper bound should yield the prefix'); + sameArray($port->listRange($doc, 'list', null, 3, true), [1, 2, 3, 4], 'inclusive upper bound should include the requested end index'); + sameArray($port->listRange($doc, 'list', 1, 3, true), [2, 3, 4], 'inclusive bounded range should include both requested endpoints'); + }, + 'rust/automerge/src/iter/list_range.rs' +); + +$rustMapped( + 'rust list range reports conflicting element flags', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-list-range-tests-list-range-conflict', + 'iter::list_range::tests::list_range_conflict', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaaaa'), 'list', [1, 2, 3, 4, 5]); + $doc2 = $port->clone($doc1, 'bbbbbbbb'); + + $doc2 = $port->setListElement($doc2, 'list', 3, 11); + $doc1 = $port->setListElement($doc1, 'list', 3, 10); + $merged = $port->mergeDocuments($doc2, $doc1); + $range = $port->listRangeEntries($merged, 'list'); + + sameArray(array_column($range, 'value'), [1, 2, 3, 11, 5], 'conflicting list range should expose the visible values in order'); + sameArray(array_column($range, 'conflict'), [false, false, false, true, false], 'conflicting list range should flag only the conflicted element'); + }, + 'rust/automerge/src/iter/list_range.rs' +); + +$mapped( + 'proxy list indexOf returns matching string index', + 'javascript/test/proxies.ts', + 72, + 'should return the index of a value for a string in a list of strings', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + + same($port->listIndexOf($doc, 'list', 'b'), 1, 'listIndexOf should return the matching string index'); + } +); + +$mapped( + 'proxy list indexOf returns -1 for a missing value', + 'javascript/test/proxies.ts', + 78, + 'should return -1 if the value is not found', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + + same($port->listIndexOf($doc, 'list', 'd'), -1, 'listIndexOf should return -1 for missing strings'); + } +); + +$mapped( + 'proxy list splice removes a defined number of entries', + 'javascript/test/proxies.ts', + 86, + 'should be able to remove a defined number of list entries', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + [$doc, $deleted] = $port->spliceListWithDeleted($doc, 'list', 1, 1); + + sameArray($deleted, ['b'], 'spliceListWithDeleted should return removed values'); + sameArray($doc->toArray()['list'], ['a', 'c'], 'spliceListWithDeleted should remove the requested span'); + } +); + +$mapped( + 'proxy list splice replaces removed entries with new values', + 'javascript/test/proxies.ts', + 95, + 'should be able to remove a defined number of list entries and add new ones', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + [$doc, $deleted] = $port->spliceListWithDeleted($doc, 'list', 1, 1, ['d', 'e']); + + sameArray($deleted, ['b'], 'replacement splice should return the deleted value'); + sameArray($doc->toArray()['list'], ['a', 'd', 'e', 'c'], 'replacement splice should materialize inserted values'); + } +); + +$mapped( + 'proxy list splice inserts new values without deletion', + 'javascript/test/proxies.ts', + 104, + 'should be able to insert new values', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + [$doc, $deleted] = $port->spliceListWithDeleted($doc, 'list', 1, 0, ['d', 'e']); + + sameArray($deleted, [], 'insert-only splice should return no deleted values'); + sameArray($doc->toArray()['list'], ['a', 'd', 'e', 'b', 'c'], 'insert-only splice should insert at the requested offset'); + } +); + +$mapped( + 'proxy list splice with only a start removes through the end', + 'javascript/test/proxies.ts', + 113, + 'should work with only a start parameter', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + [$doc, $deleted] = $port->spliceListWithDeleted($doc, 'list', 1); + + sameArray($deleted, ['b', 'c'], 'start-only splice should return every removed tail value'); + sameArray($doc->toArray()['list'], ['a'], 'start-only splice should leave the prefix'); + } +); + +$mapped( + 'proxy list splice rejects undefined inserted values', + 'javascript/test/proxies.ts', + 122, + 'should throw a useful RangeError when attempting to splice undefined values', + function () use ($port): void { + $doc = $port->from(['list' => []], 'aaaaaa'); + + throwsLike( + static fn (): Document => $port->insertListElements($doc, 'list', 0, [5, $port->undefined()]), + 'Cannot assign undefined value at /list at index 1 in the input', + 'list splice should reject undefined inserted values with an input index' + ); + } +); + +$mapped( + 'proxy recursive document assignment is rejected', + 'javascript/test/proxies.ts', + 16, + 'should throw a useful RangeError when attempting to set a document inside itself', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + + throwsLike( + static fn (): Document => $port->set($doc, 'doc', $doc), + 'Cannot create a reference to an existing document object', + 'assigning an existing native document as a nested value should be rejected' + ); + } +); + +$mapped( + 'proxy map allows null values', + 'javascript/test/proxies.ts', + 133, + 'does allow null values', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'foo', null); + + same($doc->toArray()['foo'], null, 'root map should preserve an explicit null value'); + } +); + +$mapped( + 'proxy map rejects undefined values', + 'javascript/test/proxies.ts', + 141, + 'does not allow undefined values', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + + throwsLike( + static fn (): Document => $port->set($doc, 'foo', $port->undefined()), + 'Cannot assign undefined value at /foo', + 'root map should reject undefined values' + ); + } +); + +$mapped( + 'proxy map undefined errors include the property path', + 'javascript/test/proxies.ts', + 150, + 'should print the property path in the error when setting an undefined key', + function () use ($port): void { + $doc = $port->from(['map' => []], 'aaaaaa'); + + throwsLike( + static fn (): Document => $port->setNested($doc, ['map', 'a'], $port->undefined()), + 'Cannot assign undefined value at /map/a', + 'nested map undefined errors should include the property path' + ); + } +); + +$mapped( + 'proxy list undefined errors include the property path', + 'javascript/test/proxies.ts', + 161, + 'should print the property path in the error when setting an undefined key', + function () use ($port): void { + $doc = $port->from(['list' => []], 'aaaaaa'); + + throwsLike( + static fn (): Document => $port->setListElement($doc, 'list', 0, $port->undefined()), + 'Cannot assign undefined value at /list/0', + 'list undefined errors should include the list index path' + ); + } +); + +$mapped( + 'proxy list at returns values by index', + 'javascript/test/proxies.ts', + 170, + 'should support .at() to access values', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b']], 'aaaaaa'); + + same($port->listAt($doc, 'list', 0), 'a', 'listAt should return the value at the requested index'); + same($port->listAt($doc, 'list', -1), 'b', 'listAt should support negative indexes from the end'); + } +); + +$mapped( + 'proxy structured clone input materializes as a nested map', + 'javascript/test/proxies.ts', + 181, + 'should support objects cloned with structuredClone', + function () use ($port): void { + $doc = $port->from(['map' => ['key' => 'value', 'number' => 2]], 'aaaaaa'); + + sameArray($doc->toArray(), ['map' => ['key' => 'value', 'number' => 2]], 'array-cloned map input should materialize as nested data'); + } +); + +$mapped( + 'basic backend access materializes the document', + 'javascript/test/basic_test.ts', + 285, + 'allows access to the backend', + function () use ($port): void { + $doc = $port->from(['hello' => 'world'], 'aaaaaa'); + + sameArray($port->getBackend($doc)->materialize(), ['hello' => 'world'], 'backend view should materialize the visible document'); + } +); + +$mapped( + 'basic lists and text expose indexOf semantics', + 'javascript/test/basic_test.ts', + 292, + 'lists and text have indexof', + function () use ($port): void { + $doc = $port->from([ + 'list' => [0, 1, 2, 3, 4, 5, 6], + 'text' => 'hello world', + ], 'aaaaaa'); + + same($port->listIndexOf($doc, 'list', 5), 5, 'listIndexOf should return the matching list offset'); + same($port->listIndexOf($doc, 'list', 42), -1, 'listIndexOf should return -1 for a missing value'); + same($port->textIndexOf($doc, 'text', 'world'), 6, 'textIndexOf should return the matching text offset'); + same($port->textIndexOf($doc, 'text', 'mars'), -1, 'textIndexOf should return -1 for a missing substring'); + } +); + +$mapped( + 'basic mark and unmark do not mutate path arguments', + 'javascript/test/basic_test.ts', + 656, + 'mark/unmark', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->from(['text' => 'hello'], 'aaaaaa'); + $doc = $port->mark($doc, $path, 0, 2, 'bold', true); + + sameArray($path, $pathCopy, 'mark should not mutate the supplied path array'); + sameArray($port->marks($doc, ['text']), [['name' => 'bold', 'value' => true, 'start' => 0, 'end' => 2]], 'mark should record native text metadata'); + + $doc = $port->unmark($doc, $path, 0, 2, 'bold'); + sameArray($path, $pathCopy, 'unmark should not mutate the supplied path array'); + sameArray($port->marks($doc, ['text']), [], 'unmark should remove native text metadata'); + } +); + +$mapped( + 'basic marks lookup does not mutate path arguments', + 'javascript/test/basic_test.ts', + 673, + 'marks', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->mark($port->from(['text' => 'hello'], 'aaaaaa'), $path, 0, 2, 'bold', true); + + sameArray($port->marks($doc, $path), [['name' => 'bold', 'value' => true, 'start' => 0, 'end' => 2]], 'marks should return native text mark metadata'); + sameArray($path, $pathCopy, 'marks lookup should not mutate the supplied path array'); + } +); + +$mapped( + 'basic marksAt lookup does not mutate path arguments', + 'javascript/test/basic_test.ts', + 678, + 'marksAt', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->mark($port->from(['text' => 'hello'], 'aaaaaa'), $path, 0, 2, 'bold', true); + + sameArray($port->marksAt($doc, $path, 1), ['bold' => true], 'marksAt should return active marks at the requested offset'); + sameArray($port->marksAt($doc, $path, 3), [], 'marksAt should omit marks outside their range'); + sameArray($path, $pathCopy, 'marksAt lookup should not mutate the supplied path array'); + } +); + +$mapped( + 'marks patch callbacks expose mark splits and marked load splices', + 'javascript/test/marks.ts', + 6, + 'should allow marks that can be seen in patches', + function () use ($port): void { + $value = 'bold'; + $callbacks = []; + $doc1 = $port->initWithPatchCallback( + static function (array $patches) use (&$callbacks): void { + $callbacks[] = $patches; + }, + 'aaaaaa' + ); + $doc1 = $port->change( + $doc1, + static function (Document $draft): void { + $draft->set('x', 'the quick fox jumps over the lazy dog'); + } + ); + $doc1 = $port->change( + $doc1, + static function (Document $draft) use ($value): void { + $draft->markText(['x'], [['name' => 'font-weight', 'start' => 5, 'end' => 10, 'value' => $value]]); + } + ); + $doc1 = $port->change( + $doc1, + static function (Document $draft): void { + $draft->unmarkText(['x'], 'font-weight', 7, 9); + } + ); + + sameArray( + $callbacks[1], + [[ + 'action' => 'mark', + 'path' => ['x'], + 'marks' => [['name' => 'font-weight', 'value' => $value, 'start' => 5, 'end' => 10]], + ]], + 'mark callback should expose the added mark range' + ); + sameArray( + $callbacks[2], + [[ + 'action' => 'mark', + 'path' => ['x'], + 'marks' => [['name' => 'font-weight', 'value' => null, 'start' => 7, 'end' => 9]], + ]], + 'unmark callback should expose the removed mark subrange' + ); + sameArray( + $port->marks($doc1, ['x']), + [ + ['name' => 'font-weight', 'value' => $value, 'start' => 5, 'end' => 7], + ['name' => 'font-weight', 'value' => $value, 'start' => 9, 'end' => 10], + ], + 'partial unmark should split the original mark' + ); + + $callbacks = []; + $doc2 = $port->initWithPatchCallback( + static function (array $patches) use (&$callbacks): void { + $callbacks[] = $patches; + }, + 'bbbbbb' + ); + $doc2 = $port->loadIncremental($doc2, $port->save($doc1)); + + sameArray( + $callbacks[0][2], + [ + 'action' => 'splice', + 'path' => ['x', 5], + 'value' => 'ui', + 'marks' => ['font-weight' => $value], + ], + 'incremental load should segment marked text splices with mark metadata' + ); + sameArray( + $port->marks($doc2, ['x']), + [ + ['name' => 'font-weight', 'value' => $value, 'start' => 5, 'end' => 7], + ['name' => 'font-weight', 'value' => $value, 'start' => 9, 'end' => 10], + ], + 'loaded document should preserve split marks' + ); + } +); + +$mapped( + 'marks shift across unicode text splices', + 'javascript/test/marks.ts', + 73, + 'should do unicode sensibly', + function () use ($port): void { + $doc = $port->from(['content' => '😀😀'], 'aaaaaa'); + + $doc = $port->change( + $doc, + static function (Document $draft): void { + $draft->markText(['content'], [['name' => 'bold', 'value' => true, 'start' => 2, 'end' => 4]]); + $draft->spliceText('content', 0, 0, '🙃'); + } + ); + sameArray( + $port->marks($doc, ['content']), + [['name' => 'bold', 'value' => true, 'start' => 4, 'end' => 6]], + 'unicode insertion before a marked range should shift mark offsets by UTF-16 code units' + ); + + $doc = $port->change( + $doc, + static function (Document $draft): void { + $draft->unmarkText(['content'], 'bold', 4, 6); + } + ); + sameArray($port->marks($doc, ['content']), [], 'unmark should remove the shifted unicode mark range'); + } +); + +$mapped( + 'marks expand at splice boundaries and report marked splice patches', + 'javascript/test/marks.ts', + 107, + 'patches properly report marks on end of expand true', + function () use ($port): void { + $patches = []; + $callbackPort = $port->withPatchCallback( + static function (array $patchBatch) use (&$patches): void { + array_push($patches, ...$patchBatch); + } + ); + $doc = $callbackPort->from(['text' => 'aaabbbccc'], 'aaaaaa'); + + $doc = $callbackPort->change( + $doc, + static function (Document $draft): void { + $draft->markText(['text'], [['name' => 'bold', 'value' => true, 'start' => 3, 'end' => 6, 'expand' => 'both']]); + } + ); + sameArray( + $port->marks($doc, ['text']), + [['name' => 'bold', 'value' => true, 'start' => 3, 'end' => 6]], + 'expand metadata should not leak through the public marks API' + ); + + $doc = $callbackPort->change( + $doc, + static function (Document $draft): void { + $draft->spliceText('text', 6, 0, '<'); + $draft->spliceText('text', 3, 0, '>'); + } + ); + sameArray( + $port->marks($doc, ['text']), + [['name' => 'bold', 'value' => true, 'start' => 3, 'end' => 8]], + 'expand=both mark should include insertions at both boundaries' + ); + sameArray( + array_pop($patches), + ['action' => 'splice', 'path' => ['text', 3], 'value' => '>', 'marks' => ['bold' => true]], + 'patch callback should report marks on the start-boundary insertion' + ); + sameArray( + array_pop($patches), + ['action' => 'splice', 'path' => ['text', 6], 'value' => '<', 'marks' => ['bold' => true]], + 'patch callback should report marks on the end-boundary insertion' + ); + sameArray($port->marksAt($doc, ['text'], 2), [], 'marksAt should omit text before the expanded range'); + sameArray($port->marksAt($doc, ['text'], 3), ['bold' => true], 'marksAt should include the start-boundary insertion'); + sameArray($port->marksAt($doc, ['text'], 5), ['bold' => true], 'marksAt should include interior text'); + sameArray($port->marksAt($doc, ['text'], 7), ['bold' => true], 'marksAt should include the end-boundary insertion'); + sameArray($port->marksAt($doc, ['text'], 8), [], 'marksAt should omit text after the expanded range'); + } +); + +$mapped( + 'basic path splice does not mutate path arguments', + 'javascript/test/basic_test.ts', + 631, + 'splice', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->from(['text' => 'abc'], 'aaaaaa'); + $doc = $port->spliceAtPath($doc, $path, 0, 0, 'z'); + + sameArray($path, $pathCopy, 'spliceAtPath should not mutate the supplied path array'); + same($doc->toArray()['text'], 'zabc', 'spliceAtPath should insert text at the requested path'); + } +); + +$mapped( + 'basic path updateText does not mutate path arguments', + 'javascript/test/basic_test.ts', + 638, + 'updateText', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->from(['text' => 'hello world'], 'aaaaaa'); + $doc = $port->updateTextAtPath($doc, $path, 'hello earth'); + + sameArray($path, $pathCopy, 'updateTextAtPath should not mutate the supplied path array'); + same($doc->toArray()['text'], 'hello earth', 'updateTextAtPath should replace text at the requested path'); + } +); + +$mapped( + 'cursor from accepts an existing document as shallow copy input', + 'javascript/test/cursors.ts', + 24, + 'should be able to pass a doc to from() to make a shallow copy', + function () use ($port): void { + $date = new DateTimeImmutable('2026-05-22T05:12:00.000Z'); + $doc1 = $port->from([ + 'text' => 'The sly fox jumped over the lazy dog', + 'x' => 5, + 'y' => $date, + 'z' => [1, 2, 3, ['alpha' => 'bravo']], + ], 'aaaaaa'); + $doc2 = $port->from($doc1, 'bbbbbb'); + + $original = $doc1->toArray(); + $copy = $doc2->toArray(); + same($copy['text'], $original['text'], 'from(Document) should copy string root values'); + same($copy['x'], $original['x'], 'from(Document) should copy numeric root values'); + sameArray($copy['z'], $original['z'], 'from(Document) should copy nested list/map values'); + truthy($copy['y'] instanceof DateTimeInterface, 'from(Document) should copy DateTime values'); + same(dateMillis($copy['y']), dateMillis($original['y']), 'from(Document) should preserve copied DateTime timestamps'); + truthy($doc2 !== $doc1, 'from(Document) should return a distinct native document'); + } +); + +$mapped( + 'cursor Date values from one document can be reused in another change', + 'javascript/test/cursors.ts', + 122, + 'should allow dates from an existing document to be used in another document', + function () use ($port): void { + $date = new DateTimeImmutable('2026-05-22T05:12:30.123Z'); + $original = $port->set($port->init('aaaaaa'), 'date', $date); + $original = $port->set($original, 'dates', [$date]); + $changed = $port->set($original, 'anotherDate', $original->toArray()['date']); + $changed = $port->setListElement($changed, 'dates', 0, $original->toArray()['dates'][0]); + + $materialized = $changed->toArray(); + truthy($materialized['anotherDate'] instanceof DateTimeInterface, 'reused date should remain a native DateTime value'); + same(dateMillis($materialized['anotherDate']), dateMillis($date), 'reused date should preserve its timestamp'); + truthy($materialized['dates'][0] instanceof DateTimeInterface, 'reused list date should remain a native DateTime value'); + same(dateMillis($materialized['dates'][0]), dateMillis($date), 'reused list date should preserve its timestamp'); + } +); + +$mapped( + 'cursor values can be used in splice calls after earlier text edits', + 'javascript/test/cursors.ts', + 5, + 'can use cursors in splice calls', + function () use ($port): void { + $doc = $port->from(['value' => 'The sly fox jumped over the lazy dog'], 'aaaaaa'); + $cursor = $port->getCursor($doc, ['value'], 19); + + $doc = $port->spliceAtPath($doc, ['value'], 0, 3, 'Has the'); + same($doc->toArray()['value'], 'Has the sly fox jumped over the lazy dog', 'initial splice should rewrite the text prefix'); + + $doc = $port->spliceAtPath($doc, ['value'], $cursor, 0, 'right '); + same($doc->toArray()['value'], 'Has the sly fox jumped right over the lazy dog', 'cursor splice should track the original offset through the prefix edit'); + } +); + +$mapped( + 'cursor values support common text operations with backward deletes', + 'javascript/test/cursors.ts', + 37, + 'can use cursors in common text operations', + function () use ($port): void { + $doc = $port->from(['value' => 'The sly fox jumped over the lazy dog'], 'aaaaaa'); + $doc2 = $port->clone($doc, 'bbbbbb'); + $cursor = $port->getCursor($doc, ['value'], 8); + + $doc = $port->spliceAtPath($doc, ['value'], $cursor, 0, 'o'); + $doc = $port->spliceAtPath($doc, ['value'], $cursor, 0, 'l'); + $doc = $port->spliceAtPath($doc, ['value'], $cursor, 0, 'e'); + + $doc2 = $port->spliceAtPath($doc2, ['value'], 3, -3, 'A'); + + $doc = $port->mergeDocuments($doc, $doc2); + $doc = $port->spliceAtPath($doc, ['value'], $cursor, -1, 'd'); + $doc = $port->spliceAtPath($doc, ['value'], $cursor, 0, ' '); + + same($doc->toArray()['value'], 'A sly old fox jumped over the lazy dog', 'cursor should survive common splice, backward-delete, and merge operations'); + } +); + +$mapped( + 'cursor splices use JavaScript UTF-16 string indices', + 'javascript/test/cursors.ts', + 61, + 'should use javascript string indices', + function () use ($port): void { + $doc = $port->from(['value' => "🇬🇧🇩🇪"], 'aaaaaa'); + $cursor = $port->getCursor($doc, ['value'], 4); + + same($port->getCursorPosition($doc, ['value'], $cursor), 4, 'cursor position should report the JavaScript string index before the second flag'); + + $doc = $port->change( + $doc, + static function (Document $draft) use ($port, $cursor): void { + $port->spliceInChange($draft, ['value'], $cursor, -2, ''); + $port->spliceInChange($draft, ['value'], $cursor, -2, ''); + $port->spliceInChange($draft, ['value'], $cursor, 0, "🇫🇷"); + } + ); + + same($doc->toArray()['value'], "🇫🇷🇩🇪", 'cursor splice should treat flag emoji offsets as JavaScript UTF-16 indices'); + same($port->getCursorPosition($doc, ['value'], $cursor), 4, 'cursor should remain before the second flag after replacement'); + } +); + +$mapped( + 'cursor patch callbacks report their source operation', + 'javascript/test/cursors.ts', + 76, + 'patch callbacks inform where they came from', + function () use ($port): void { + $callbacks = []; + $patchCallback = static function (array $patches, array $info) use (&$callbacks): void { + if ($patches !== []) { + $callbacks[] = $info['source'] ?? null; + } + }; + + $doc1 = $port->fromWithPatchCallback(['hello' => 'world'], $patchCallback, 'aaaaaa'); + $heads1 = $port->getHeads($doc1); + $doc2 = $port->cloneWithPatchCallback($doc1, $patchCallback, 'bbbbbb'); + $doc2 = $port->change( + $doc2, + static function (Document $draft): void { + $draft->set('a', 'b'); + } + ); + $doc2 = $port->setAtHeads($doc2, $heads1, 'b', 'c'); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $doc2 = $port->change( + $doc2, + static function (Document $draft): void { + $draft->set('x', 'y'); + } + ); + $doc1 = $port->loadIncremental($doc1, $port->saveIncremental($doc2)); + $doc2 = $port->change( + $doc2, + static function (Document $draft): void { + $draft->set('n', 'm'); + } + ); + + [$sync1, $doc2ToDoc1] = $port->generateSyncMessage($doc2, $port->initSyncState()); + truthy($doc2ToDoc1 !== null, 'source callback test should generate a sync message with the final remote change'); + [$doc1] = $port->receiveSyncMessage($doc1, $port->initSyncState(), $doc2ToDoc1, $patchCallback); + same($doc1->toArray()['n'], 'm', 'source callback test should apply the final remote sync change'); + + sameArray( + $callbacks, + ['from', 'change', 'changeAt', 'merge', 'change', 'loadIncremental', 'change', 'receiveSyncMessage'], + 'patch callbacks should identify each source operation in order' + ); + } +); + +$mapped( + 'cursor start and end sentinels can drive text splices', + 'javascript/test/cursors.ts', + 178, + 'should allow for usage of start/end cursors', + function () use ($port): void { + $doc = $port->from(['text' => 'abc'], 'aaaaaa'); + $end = $port->getCursor($doc, ['text'], 'end'); + $start = $port->getCursor($doc, ['text'], 'start'); + + $doc = $port->spliceAtPath($doc, ['text'], $end, 0, 'def'); + same($doc->toArray()['text'], 'abcdef', 'end cursor should resolve to the current text end'); + + $doc = $port->spliceAtPath($doc, ['text'], $start, 0, 'hello'); + same($doc->toArray()['text'], 'helloabcdef', 'start cursor should resolve to the current text start'); + } +); + +$mapped( + 'cursor creation clamps negative indices to start', + 'javascript/test/cursors.ts', + 212, + 'should convert negative indices into a start cursor', + function () use ($port): void { + $doc = $port->from(['text' => 'is awesome'], 'aaaaaa'); + $cursor = $port->getCursor($doc, ['text'], -1); + + $doc = $port->spliceAtPath($doc, ['text'], $cursor, 0, 'Automerge '); + same($doc->toArray()['text'], 'Automerge is awesome', 'negative cursor index should behave like a start cursor'); + } +); + +$mapped( + 'cursor creation clamps too-large indices to end', + 'javascript/test/cursors.ts', + 223, + 'should convert indices >= string length into an end cursor', + function () use ($port): void { + $doc = $port->from(['text' => 'Alex'], 'aaaaaa'); + $cursorPastEnd = $port->getCursor($doc, ['text'], 1337); + $cursorAtEnd = $port->getCursor($doc, ['text'], 4); + + $doc1 = $port->spliceAtPath($doc, ['text'], $cursorPastEnd, 0, ' Good'); + $doc2 = $port->spliceAtPath($port->clone($doc), ['text'], $cursorAtEnd, 0, ' Good'); + + same($doc1->toArray()['text'], 'Alex Good', 'out-of-range cursor should resolve to the end of the text'); + same($doc2->toArray()['text'], 'Alex Good', 'string-length cursor should also resolve to the end of the text'); + } +); + +$mapped( + 'cursor position resolves against a historical view', + 'javascript/test/cursors.ts', + 135, + 'getCursorPosition should work', + function () use ($port): void { + $doc = $port->from(['text' => 'abc'], 'aaaaaa'); + $cursor = $port->getCursor($doc, ['text'], 1); + + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'x'); + $heads = $port->getHeads($doc); + $doc = $port->spliceAtPath($doc, ['text'], 1, 0, 'y'); + $view = $port->view($doc, $heads); + + same($port->getCursorPosition($view, ['text'], $cursor), 2, 'cursor should resolve against the supplied view heads'); + } +); + +$mapped( + 'cursor creation respects view heads for before after start and end', + 'javascript/test/cursors.ts', + 153, + 'getCursor should respect heads', + function () use ($port): void { + $doc = $port->from(['text' => 'aaa@bbb'], 'aaaaaa'); + $heads = $port->getHeads($doc); + $doc = $port->spliceAtPath($doc, ['text'], 3, 1, '~~~'); + $view = $port->view($doc, $heads); + + $before = $port->getCursor($view, ['text'], 3, 'before'); + $after = $port->getCursor($view, ['text'], 3, 'after'); + $start = $port->getCursor($view, ['text'], 'start'); + $end = $port->getCursor($view, ['text'], 'end'); + + same($port->getCursorPosition($doc, ['text'], $start), 0, 'start cursor should remain at the beginning after a replacement'); + same($port->getCursorPosition($doc, ['text'], $before), 2, 'before cursor should track to the position before the replaced element'); + same($port->getCursorPosition($doc, ['text'], $after), 6, 'after cursor should track past the replacement insertion'); + same($port->getCursorPosition($doc, ['text'], $end), 9, 'end cursor should track to the current text end'); + } +); + +$mapped( + 'cursor move before and after survive text replacement', + 'javascript/test/cursors.ts', + 197, + 'should allow for usage of move before/after', + function () use ($port): void { + $doc = $port->from(['text' => 'aaa@bbb'], 'aaaaaa'); + $before = $port->getCursor($doc, ['text'], 3, 'before'); + $after = $port->getCursor($doc, ['text'], 3, 'after'); + + $doc = $port->spliceAtPath($doc, ['text'], 3, 1, '~~~'); + + same($port->getCursorPosition($doc, ['text'], $before), 2, 'move-before cursor should resolve before the replaced element'); + same($port->getCursorPosition($doc, ['text'], $after), 6, 'move-after cursor should resolve after the replacement'); + } +); + +$mapped( + 'basic getCursor does not mutate path arguments', + 'javascript/test/basic_test.ts', + 645, + 'getCursor', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->from(['text' => 'abc'], 'aaaaaa'); + $cursor = $port->getCursor($doc, $path, 0); + + truthy($cursor !== '', 'getCursor should return a stable cursor string'); + sameArray($path, $pathCopy, 'getCursor should not mutate the supplied path array'); + } +); + +$mapped( + 'basic getCursorPosition does not mutate path arguments', + 'javascript/test/basic_test.ts', + 650, + 'getCursorPosition', + function () use ($port): void { + $path = ['text']; + $pathCopy = $path; + $doc = $port->from(['text' => 'abc'], 'aaaaaa'); + $cursor = $port->getCursor($doc, $path, 1); + $position = $port->getCursorPosition($doc, $path, $cursor); + + same($position, 1, 'getCursorPosition should decode the native cursor offset'); + sameArray($path, $pathCopy, 'getCursorPosition should not mutate the supplied path array'); + } +); + +$mapped( + 'new change API supports simple root assignment', + 'javascript/test/new-change-api.ts', + 5, + 'should be able to make simple changes to a document', + function () use ($port): void { + $doc = $port->from(['foo' => 'bar'], 'aaaaaa'); + same($doc->toArray()['foo'], 'bar', 'document should expose the initial root value before the change'); + $doc = $port->set($doc, 'foo', 'baz'); + + same($doc->toArray()['foo'], 'baz', 'root assignment should update the materialized value'); + } +); + +$mapped( + 'new change API supports insertAt-style list insertion', + 'javascript/test/new-change-api.ts', + 17, + 'should be able to insert into a list', + function () use ($port): void { + $doc = $port->from(['list' => []], 'aaaaaa'); + $doc = $port->insertListElements($doc, 'list', 0, ['a']); + + sameArray($doc->toArray()['list'], ['a'], 'list insertion should materialize at the requested index'); + } +); + +$mapped( + 'new change API supports deleteAt-style list deletion', + 'javascript/test/new-change-api.ts', + 25, + 'should be able to delete from a list', + function () use ($port): void { + $doc = $port->from(['list' => ['a', 'b', 'c']], 'aaaaaa'); + $doc = $port->deleteListElements($doc, 'list', 0); + + sameArray($doc->toArray()['list'], ['b', 'c'], 'list deletion should remove the requested index'); + } +); + +$mapped( + 'basic save load and change preserve integer and float edge values', + 'javascript/test/basic_test.ts', + 855, + 'it should be able to handle ints and floats at their limits', + function () use ($port): void { + $base = [ + 'nan' => NAN, + 'inf' => INF, + 'ninf' => -INF, + 'imax' => $port->bigInt('9223372036854775807'), + 'imin' => $port->bigInt('-9223372036854775808'), + 'umax' => $port->bigInt('18446744073709551615'), + ]; + + $assertEdgeValues = static function (array $actual, string $label): void { + truthy(is_nan($actual['nan']), $label . ' should preserve NaN'); + same($actual['inf'], INF, $label . ' should preserve positive infinity'); + same($actual['ninf'], -INF, $label . ' should preserve negative infinity'); + truthy($actual['imax'] instanceof BigIntValue, $label . ' should preserve signed 64-bit max as BigIntValue'); + truthy($actual['imin'] instanceof BigIntValue, $label . ' should preserve signed 64-bit min as BigIntValue'); + truthy($actual['umax'] instanceof BigIntValue, $label . ' should preserve unsigned 64-bit max as BigIntValue'); + same($actual['imax']->toString(), '9223372036854775807', $label . ' should preserve signed 64-bit max digits'); + same($actual['imin']->toString(), '-9223372036854775808', $label . ' should preserve signed 64-bit min digits'); + same($actual['umax']->toString(), '18446744073709551615', $label . ' should preserve unsigned 64-bit max digits'); + }; + + $doc1 = $port->from($base, 'aaaaaa'); + $assertEdgeValues($doc1->toArray(), 'from()'); + + $doc2 = $port->load($port->save($doc1), 'bbbbbb'); + $assertEdgeValues($doc2->toArray(), 'save/load'); + + $doc3 = $port->change( + $port->init('cccccc'), + static function (Document $draft) use ($base): void { + foreach ($base as $key => $value) { + $draft->set($key, $value); + } + } + ); + $assertEdgeValues($doc3->toArray(), 'change()'); + } +); + +$rustBatchSuite = 'rust:tests-batch-insert-rs-target-debug-deps-batch-insert-5dd7718f4978a0d4:'; +$rustConvertStringSuite = 'rust:tests-convert-string-to-text-rs-target-debug-deps-convert-string-to-text-1c3ac8392298535c:'; +$rustCoreSuite = 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:'; +$rustCurrentStateSuite = 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:automerge-current-state-tests-'; +$rustHydrateSuite = 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:hydrate-tests-'; +$rustIterDocSuite = 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:iter-doc-tests-'; +$rustLegacyOpSuite = 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:legacy-serde-impls-op-tests-'; +$rustOwnedTransactionSuite = 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-owned-transaction-tests-'; +$rustTransactionInnerSuite = 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:transaction-inner-tests-'; + +$rustMapped( + 'rust legacy op serde deserializes object ids', + $rustLegacyOpSuite . 'test-deserialize-obj', + 'legacy::serde_impls::op::tests::test_deserialize_obj', + function () use ($port): void { + $root = $port->legacyDeserializeOp([ + 'action' => 'inc', + 'obj' => '_root', + 'key' => 'somekey', + 'value' => 1, + 'pred' => [], + ]); + sameArray($root['obj'], ['type' => 'root', 'id' => '_root'], 'legacy serde should decode the root object id'); + + $opId = '1@7ef48769b04d47e9a88e98a134d62716'; + $op = $port->legacyDeserializeOp([ + 'action' => 'inc', + 'obj' => $opId, + 'key' => 'somekey', + 'value' => 1, + 'pred' => [], + ]); + sameArray( + $op['obj'], + ['type' => 'op', 'id' => $opId, 'counter' => '1', 'actor' => '7ef48769b04d47e9a88e98a134d62716'], + 'legacy serde should decode operation object ids' + ); + + throwsLike( + static fn (): array => $port->legacyDeserializeOp([ + 'action' => 'inc', + 'obj' => 'notanobject', + 'key' => 'somekey', + 'value' => 1, + 'pred' => [], + ]), + 'A valid ObjectID', + 'legacy serde should reject invalid object ids' + ); + }, + 'rust/automerge/src/legacy/serde_impls/op.rs' +); + +$rustMapped( + 'rust legacy op serde serializes map and element id keys', + $rustLegacyOpSuite . 'test-serialize-key', + 'legacy::serde_impls::op::tests::test_serialize_key', + function () use ($port): void { + sameArray($port->legacySerializeOpKey('somekey'), ['key' => 'somekey'], 'map keys should serialize under key'); + sameArray( + $port->legacySerializeOpKey(['elemId' => '1@7ef48769b04d47e9a88e98a134d62716']), + ['elemId' => '1@7ef48769b04d47e9a88e98a134d62716'], + 'list element ids should serialize under elemId' + ); + }, + 'rust/automerge/src/legacy/serde_impls/op.rs' +); + +$rustMapped( + 'rust legacy op serde deserializes action values', + $rustLegacyOpSuite . 'test-deserialize-action', + 'legacy::serde_impls::op::tests::test_deserialize_action', + function () use ($port): void { + $decodeAction = static function (array $fields) use ($port): array { + $op = $fields + [ + 'obj' => '_root', + 'key' => 'somekey', + 'pred' => [], + ]; + + return $port->legacyDeserializeOp($op)['action']; + }; + + sameArray( + $decodeAction(['action' => 'set', 'datatype' => 'uint', 'value' => 123]), + ['type' => 'put', 'value' => ['type' => 'uint', 'value' => 123]], + 'set uint should decode as a uint put action' + ); + sameArray( + $decodeAction(['action' => 'set', 'datatype' => 'int', 'value' => -123]), + ['type' => 'put', 'value' => ['type' => 'int', 'value' => -123]], + 'set int should decode as an int put action' + ); + sameArray( + $decodeAction(['action' => 'set', 'datatype' => 'float64', 'value' => -123]), + ['type' => 'put', 'value' => ['type' => 'float64', 'value' => -123.0]], + 'set float64 should decode as an f64 put action' + ); + sameArray( + $decodeAction(['action' => 'set', 'value' => 'somestring']), + ['type' => 'put', 'value' => ['type' => 'str', 'value' => 'somestring']], + 'set string should decode as a string put action' + ); + sameArray( + $decodeAction(['action' => 'set', 'value' => 1.23]), + ['type' => 'put', 'value' => ['type' => 'float64', 'value' => 1.23]], + 'set f64 should decode as an f64 put action' + ); + sameArray( + $decodeAction(['action' => 'set', 'value' => true]), + ['type' => 'put', 'value' => ['type' => 'boolean', 'value' => true]], + 'set boolean should decode as a boolean put action' + ); + throwsLike( + static fn (): array => $decodeAction(['action' => 'set', 'datatype' => 'counter']), + 'missing field value', + 'set with counter datatype should still require a value' + ); + sameArray( + $decodeAction(['action' => 'set', 'datatype' => 'counter', 'value' => 123]), + ['type' => 'put', 'value' => ['type' => 'counter', 'value' => 123]], + 'set counter should decode as a counter put action' + ); + throwsLike( + static fn (): array => $decodeAction(['action' => 'set', 'datatype' => 'counter', 'value' => 'somestring']), + 'an integer', + 'counter datatype should reject string values' + ); + throwsLike( + static fn (): array => $decodeAction(['action' => 'set', 'datatype' => 'timestamp', 'value' => 'somestring']), + 'an integer', + 'timestamp datatype should reject string values' + ); + sameArray( + $decodeAction(['action' => 'inc', 'datatype' => 'counter', 'value' => 12]), + ['type' => 'increment', 'value' => 12], + 'inc with counter datatype should decode as an increment' + ); + sameArray( + $decodeAction(['action' => 'inc', 'value' => 12]), + ['type' => 'increment', 'value' => 12], + 'inc without datatype should decode as an increment' + ); + throwsLike( + static fn (): array => $decodeAction(['action' => 'inc']), + 'missing field value', + 'inc without value should be rejected' + ); + sameArray( + $decodeAction(['action' => 'set', 'value' => null]), + ['type' => 'put', 'value' => ['type' => 'null', 'value' => null]], + 'set null should decode as a null put action' + ); + }, + 'rust/automerge/src/legacy/serde_impls/op.rs' +); + +$rustMapped( + 'rust legacy op serde round trips normalized operations', + $rustLegacyOpSuite . 'test-round-trips', + 'legacy::serde_impls::op::tests::test_round_trips', + function () use ($port): void { + $opId = '1@7ef48769b04d47e9a88e98a134d62716'; + $opObject = ['type' => 'op', 'id' => $opId, 'counter' => '1', 'actor' => '7ef48769b04d47e9a88e98a134d62716']; + $rootObject = ['type' => 'root', 'id' => '_root']; + $mapKey = ['type' => 'key', 'value' => 'somekey']; + $elemKey = ['type' => 'elemId', 'id' => $opId]; + + $testcases = [ + [ + 'action' => ['type' => 'put', 'value' => ['type' => 'uint', 'value' => 12]], + 'obj' => $rootObject, + 'key' => $mapKey, + 'insert' => false, + 'pred' => [], + ], + [ + 'action' => ['type' => 'increment', 'value' => 12], + 'obj' => $opObject, + 'key' => $mapKey, + 'insert' => false, + 'pred' => [], + ], + [ + 'action' => ['type' => 'put', 'value' => ['type' => 'uint', 'value' => 12]], + 'obj' => $opObject, + 'key' => $mapKey, + 'insert' => false, + 'pred' => [$opId], + ], + [ + 'action' => ['type' => 'increment', 'value' => 12], + 'obj' => $rootObject, + 'key' => $mapKey, + 'insert' => false, + 'pred' => [], + ], + [ + 'action' => ['type' => 'put', 'value' => ['type' => 'str', 'value' => 'seomthing']], + 'obj' => $opObject, + 'key' => $elemKey, + 'insert' => false, + 'pred' => [$opId], + ], + ]; + + foreach ($testcases as $index => $testcase) { + $serialized = $port->legacySerializeOp($testcase); + $encoded = json_encode($serialized, JSON_THROW_ON_ERROR); + $decoded = json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + $roundTripped = $port->legacyDeserializeOp($decoded); + + sameArray($roundTripped, $testcase, 'legacy op serde testcase ' . $index . ' should round trip'); + } + }, + 'rust/automerge/src/legacy/serde_impls/op.rs' +); + +$rustMapped( + 'rust AutoSerde serializes a root map as JSON', + 'rust:doc-tests-automerge:automerge-src-autoserde-rs-autoserde-autoserde-line-9', + 'automerge/src/autoserde.rs AutoSerde example', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + + same( + json_encode($port->toJS($doc), JSON_THROW_ON_ERROR), + '{"key":"value"}', + 'AutoSerde-style root materialization should serialize as the expected JSON object' + ); + }, + 'rust/automerge/src/autoserde.rs' +); + +$rustMapped( + 'rust lib address book example saves nested contacts', + 'rust:doc-tests-automerge:automerge-src-lib-rs-line-117', + 'automerge/src/lib.rs address book creation example', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'contacts', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob', 'email' => 'bob@example.com'], + ]); + $saved = $port->save($doc); + $loaded = $port->load($saved, 'bbbbbb'); + + sameArray( + $loaded->toArray()['contacts'], + [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob', 'email' => 'bob@example.com'], + ], + 'address book example should persist a nested contact list through native save/load' + ); + }, + 'rust/automerge/src/lib.rs' +); + +$rustMapped( + 'rust lib address book merge example preserves independent nested edits', + 'rust:doc-tests-automerge:automerge-src-lib-rs-line-147', + 'automerge/src/lib.rs address book merge example', + function () use ($port): void { + $base = $port->set($port->init('aaaaaa'), 'contacts', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob', 'email' => 'bob@example.com'], + ]); + $saved = $port->save($base); + + $doc1 = $port->load($saved, 'bbbbbb'); + $doc1 = $port->setNested($doc1, ['contacts', 0, 'email'], 'alicesnewemail@example.com'); + + $doc2 = $port->load($saved, 'cccccc'); + $doc2 = $port->setNested($doc2, ['contacts', 1, 'name'], 'Robert'); + + $merged = $port->mergeDocuments($doc1, $doc2); + $contacts = $merged->toArray()['contacts']; + + same($contacts[0]['email'], 'alicesnewemail@example.com', 'merge should preserve the first peer edit to Alice'); + same($contacts[1]['name'], 'Robert', 'merge should preserve the second peer edit to Bob'); + same($port->getConflicts($merged, 'contacts'), null, 'independent nested contact edits should not create a root conflict'); + }, + 'rust/automerge/src/lib.rs' +); + +$rustMapped( + 'rust sync protocol doc example converges a new peer', + 'rust:doc-tests-automerge:automerge-src-sync-rs-sync-line-25', + 'automerge/src/sync.rs sync protocol example', + function () use ($port): void { + $peer1 = $port->set($port->init('aaaaaa'), 'key', 'value'); + $peer1State = $port->initSyncState(); + [$peer1State, $message1to2] = $port->generateSyncMessage($peer1, $peer1State); + + truthy($message1to2 !== null, 'changed peer should generate an initial sync message'); + + $peer2 = $port->init('bbbbbb'); + $peer2State = $port->initSyncState(); + [$peer2, $peer2State] = $port->receiveSyncMessage($peer2, $peer2State, $message1to2); + + for ($iteration = 0; $iteration < 10; ++$iteration) { + [$peer2State, $twoToOne] = $port->generateSyncMessage($peer2, $peer2State); + if ($twoToOne !== null) { + [$peer1, $peer1State] = $port->receiveSyncMessage($peer1, $peer1State, $twoToOne); + } + + [$peer1State, $oneToTwo] = $port->generateSyncMessage($peer1, $peer1State); + if ($oneToTwo !== null) { + [$peer2, $peer2State] = $port->receiveSyncMessage($peer2, $peer2State, $oneToTwo); + } + + if ($twoToOne === null && $oneToTwo === null) { + break; + } + } + + same($peer2->toArray()['key'] ?? null, 'value', 'sync protocol doc example should copy the root key to peer2'); + same($port->generateSyncMessage($peer1, $peer1State)[1], null, 'peer1 should stop sending after sync convergence'); + same($port->generateSyncMessage($peer2, $peer2State)[1], null, 'peer2 should stop sending after sync convergence'); + }, + 'rust/automerge/src/sync.rs' +); + +$rustMapped( + 'rust patch log doc example records patches from a sync receive', + 'rust:doc-tests-automerge:automerge-src-patches-patch-log-rs-patches-patch-log-patchlog-line-28-compile', + 'automerge/src/patches/patch_log.rs PatchLog sync receive example', + function () use ($port): void { + $source = $port->set($port->init('aaaaaa'), 'key', 'value'); + [$sourceState, $syncMessage] = $port->generateSyncMessage($source, $port->initSyncState()); + + truthy($syncMessage !== null, 'source document should generate a sync message with changes'); + + $target = $port->init('bbbbbb'); + [$target, $targetState, $patches] = $port->receiveSyncMessageLogPatches($target, $port->initSyncState(), $syncMessage); + + same($target->toArray()['key'] ?? null, 'value', 'patch-log receive should apply the remote change'); + sameArray( + $patches, + [ + ['action' => 'put', 'path' => ['key'], 'value' => ''], + ['action' => 'splice', 'path' => ['key', 0], 'value' => 'value'], + ], + 'patch-log receive should expose the relative materialization patches' + ); + truthy(is_array($sourceState) && is_array($targetState), 'sync states should remain native PHP arrays'); + }, + 'rust/automerge/src/patches/patch_log.rs' +); + +$rustMapped( + 'rust applying changes with a patch log from another document reports mismatch', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:applying-changes-with-patch-log-from-another-document-returns-error-not-panic', + 'applying_changes_with_patch_log_from_another_document_returns_error_not_panic', + function () use ($port): void { + $source = $port->set($port->init('bbbbbb'), 'source', 'value'); + $patchLog = $port->initPatchLog(); + [, $patchLog] = $port->applyChangesLogPatches( + $port->init('aaaaaa'), + $port->getAllChanges($source), + $patchLog + ); + + $otherSource = $port->set($port->init('cccccc'), 'source', 'value'); + throwsLike( + static fn (): array => $port->applyChangesLogPatches( + $port->init('dddddd'), + $port->getAllChanges($otherSource), + $patchLog + ), + 'PatchLogMismatch', + 'patch log bound to a different document should return a mismatch error' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust transaction with a patch log from another document reports mismatch', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:transaction-with-patch-log-from-another-document-does-not-panic', + 'transaction_with_patch_log_from_another_document_does_not_panic', + function () use ($port): void { + $source = $port->set($port->init('bbbbbb'), 'source', 'value'); + $patchLog = $port->initPatchLog(); + [, $patchLog] = $port->applyChangesLogPatches( + $port->init('aaaaaa'), + $port->getAllChanges($source), + $patchLog + ); + + $otherDoc = $port->applyChanges( + $port->init('dddddd'), + $port->getAllChanges($port->set($port->init('cccccc'), 'key', 'value')) + ); + + throwsLike( + static fn (): Transaction => $port->transactionLogPatches($otherDoc, $patchLog), + 'PatchLogMismatch', + 'transaction_log_patches should reject patch logs bound to another document' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust transactionAt with a patch log from another document reports mismatch', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:transaction-at-with-patch-log-from-another-document-does-not-panic', + 'transaction_at_with_patch_log_from_another_document_does_not_panic', + function () use ($port): void { + $source = $port->set($port->init('bbbbbb'), 'source', 'value'); + $patchLog = $port->initPatchLog(); + [, $patchLog] = $port->applyChangesLogPatches( + $port->init('aaaaaa'), + $port->getAllChanges($source), + $patchLog + ); + + $otherDoc = $port->applyChanges( + $port->init('dddddd'), + $port->getAllChanges($port->set($port->init('cccccc'), 'key', 'value')) + ); + + throwsLike( + static fn (): Transaction => $port->transactionAtLogPatches($otherDoc, $patchLog, $port->getHeads($otherDoc)), + 'PatchLogMismatch', + 'transaction_at should reject patch logs bound to another document' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust owned transaction with a patch log from another document reports mismatch', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:owned-transaction-with-patch-log-from-another-document-does-not-panic', + 'owned_transaction_with_patch_log_from_another_document_does_not_panic', + function () use ($port): void { + $source = $port->set($port->init('bbbbbb'), 'source', 'value'); + $patchLog = $port->initPatchLog(); + [, $patchLog] = $port->applyChangesLogPatches( + $port->init('aaaaaa'), + $port->getAllChanges($source), + $patchLog + ); + + $otherDoc = $port->applyChanges( + $port->init('dddddd'), + $port->getAllChanges($port->set($port->init('cccccc'), 'key', 'value')) + ); + + throwsLike( + static fn (): Transaction => $port->intoTransactionLogPatches($otherDoc, $patchLog), + 'PatchLogMismatch', + 'owned transaction creation should reject patch logs bound to another document' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust big_list patch log reports a large list insertion', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:big-list', + 'big_list', + function () use ($port): void { + $source = $port->batchCreateObject($port->init('aaaaaa'), 'list', []); + $change1 = $port->getLastLocalChange($source); + truthy($change1 !== null, 'first big_list change should create the list object'); + + $source = $port->insertListElements($source, 'list', 0, array_fill(0, 17, [])); + $change2 = $port->getLastLocalChange($source); + truthy($change2 !== null, 'second big_list change should insert the map values'); + + $target = $port->init('bbbbbb'); + $patchLog = $port->initPatchLog(); + [$target, $patchLog] = $port->applyChangesLogPatches($target, [$change1], $patchLog); + [$target, $patchLog] = $port->applyChangesLogPatches($target, [$change2], $patchLog); + $patches = $port->makePatchesFromLog($patchLog); + $lastPatch = $patches[array_key_last($patches)] ?? null; + + same(count($target->toArray()['list'] ?? []), 17, 'big_list target should materialize all inserted maps'); + truthy(is_array($lastPatch), 'big_list patch log should expose at least one patch'); + same($lastPatch['action'] ?? null, 'insert', 'big_list patch log should end with a list insert patch'); + sameArray($lastPatch['path'] ?? [], ['list', 0], 'big_list insert patch should target the first list index'); + same(count($lastPatch['values'] ?? []), 17, 'big_list insert patch should group the contiguous inserted map values'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust large list patches count string list elements as one slot', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:large-patches-in-lists-are-correct', + 'large_patches_in_lists_are_correct', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $headsBefore = $port->getHeads($doc); + $values = ['123456']; + for ($i = 1; $i < 501; ++$i) { + $values[] = ['a' => $i]; + } + + $doc = $port->set($doc, 'list', $values); + $patches = $port->diff($doc, $headsBefore, $port->getHeads($doc)); + $finalPatch = $patches[array_key_last($patches)] ?? null; + + truthy(is_array($finalPatch), 'large list diff should emit patches'); + same($finalPatch['action'] ?? null, 'insert', 'large list final patch should insert the last map object'); + sameArray($finalPatch['path'] ?? [], ['list', 500], 'string list elements should advance the list index by one slot'); + sameArray(($finalPatch['values'] ?? [])[0] ?? [], ['a' => 500], 'large list final patch should target the 500th inserted map'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust repeated out-of-order changes converge after dependencies arrive', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:handle-repeated-out-of-order-changes', + 'handle_repeated_out_of_order_changes', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'list', ['a']); + $doc2 = $port->clone($doc1); + $doc1 = $port->insertListElements($doc1, 'list', 1, ['b']); + $doc1 = $port->insertListElements($doc1, 'list', 2, ['c']); + $doc1 = $port->insertListElements($doc1, 'list', 3, ['d']); + $changes = $port->getChangesSince($doc1, []); + + same(count($changes), 4, 'out-of-order fixture should include the initial list and three insert changes'); + $doc2 = $port->applyChanges($doc2, array_slice($changes, 2)); + $doc2 = $port->applyChanges($doc2, array_slice($changes, 2)); + $doc2 = $port->applyChanges($doc2, $changes); + + sameArray($doc2->toArray(), ['list' => ['a', 'b', 'c', 'd']], 'out-of-order duplicate changes should materialize once dependencies arrive'); + same($port->save($doc2), $port->save($doc1), 'out-of-order duplicate changes should converge to the same saved document'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust reverse diff reinserts deleted text object in a list', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-object-in-list-correctly', + 'diff_should_reverse_deletion_of_object_in_list_correctly', + function () use ($port): void { + $sequence = 0; + $doc = $port->set($port->init('aaaaaa'), 'list', [ + 'a', + TextValue::fromString('b', 'aaaaaa', $sequence), + 'c', + ]); + $headsBefore = $port->getHeads($doc); + $doc = $port->deleteListElements($doc, 'list', 1); + $headsAfter = $port->getHeads($doc); + + $patches = $port->diff($doc, $headsAfter, $headsBefore); + + sameArray( + $patches, + [ + ['action' => 'insert', 'path' => ['list', 1], 'values' => ['']], + ['action' => 'splice', 'path' => ['list', 1, 0], 'value' => 'b'], + ], + 'reverse diff should recreate the deleted list text object and its text content' + ); + sameArray($port->applyPatches($doc, $patches)->toArray(), ['list' => ['a', 'b', 'c']], 'reverse list-object diff patches should restore visible content'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust forked documents save without missing actor ids', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:missing-actors-when-docs-are-forked', + 'missing_actors_when_docs_are_forked', + function () use ($port): void { + $doc0 = $port->set($port->init('00'), 'a', 1); + $doc1 = $port->set($port->clone($doc0, '02'), 'b', 2); + $doc2 = $port->clone($doc0, '01'); + $doc2 = $port->mergeDocuments($doc2, $doc1); + + $savedBeforeNoopDelete = $port->save($doc2); + $doc2 = $port->delete($doc2, 'c'); + $savedAfterNoopDelete = $port->save($doc2); + + same($savedAfterNoopDelete, $savedBeforeNoopDelete, 'no-op delete on a forked actor should not perturb saved bytes'); + sameArray($port->load($savedAfterNoopDelete, '03')->toArray(), ['a' => 1, 'b' => 2], 'saved forked document should load with all visible keys'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust reverse diff reinserts deleted text object in a map', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-object-in-map-correctly', + 'diff_should_reverse_deletion_of_object_in_map_correctly', + function () use ($port): void { + $sequence = 0; + $doc = $port->set($port->init('aaaaaa'), 'map', [ + 'a' => 'a', + 'b' => TextValue::fromString('b', 'aaaaaa', $sequence), + 'c' => 'c', + ]); + $headsBefore = $port->getHeads($doc); + $doc = $port->deleteNested($doc, ['map', 'b']); + $headsAfter = $port->getHeads($doc); + + $patches = $port->diff($doc, $headsAfter, $headsBefore); + + sameArray( + $patches, + [ + ['action' => 'put', 'path' => ['map', 'b'], 'value' => ''], + ['action' => 'splice', 'path' => ['map', 'b', 0], 'value' => 'b'], + ], + 'reverse diff should recreate the deleted map text object and its text content' + ); + $restored = $port->applyPatches($doc, $patches)->toArray(); + same($restored['map']['a'] ?? null, 'a', 'reverse map-object diff patches should preserve the preceding map value'); + same($restored['map']['b'] ?? null, 'b', 'reverse map-object diff patches should restore the deleted text content'); + same($restored['map']['c'] ?? null, 'c', 'reverse map-object diff patches should preserve the following map value'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust reverse diff reinserts deleted block marker in text', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:diff-should-reverse-deletion-of-block-in-text-correctly', + 'diff_should_reverse_deletion_of_block_in_text_correctly', + function () use ($port): void { + $block = ['parents' => [], 'type' => '', 'attrs' => [], 'key' => 'value']; + $doc = $port->splice($port->init('aaaaaa'), 'text', 0, 0, 'a'); + $doc = $port->splitBlock($doc, ['text'], 1, $block); + $doc = $port->splice($doc, 'text', 2, 0, 'b'); + $headsBefore = $port->getHeads($doc); + + $doc = $port->joinBlock($doc, ['text'], 1); + $headsAfter = $port->getHeads($doc); + $patches = $port->diff($doc, $headsAfter, $headsBefore); + + sameArray($patches, [[ + 'action' => 'splice', + 'path' => ['text', 1], + 'value' => "\u{FFFC}", + 'marks' => ['__automerge_block' => $block], + ]], 'reverse diff should reinsert only the deleted block marker with its block metadata'); + sameArray($port->spans($port->view($doc, $headsBefore), ['text']), [ + ['type' => 'text', 'value' => 'a'], + ['type' => 'block', 'value' => $block], + ['type' => 'text', 'value' => 'b'], + ], 'historical view should retain the deleted block marker and metadata'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust AutoCommit commit_with doc example records message and time', + 'rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-commit-with-line-638', + 'automerge/src/autocommit.rs AutoCommit::commit_with example', + function () use ($port): void { + $now = 1234567890; + $doc = $port->batchCreateObjectWithCommitOptions( + $port->init('aaaaaa'), + 'todos', + [], + 'Create todos list', + $now + ); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + sameArray($doc->toArray(), ['todos' => []], 'commit_with doc example should create the todos list'); + same($decoded['message'], 'Create todos list', 'commit_with should preserve the supplied message'); + same($decoded['time'], $now, 'commit_with should preserve the supplied commit time'); + }, + 'rust/automerge/src/autocommit.rs' +); + +$rustMapped( + 'rust AutoCommit send-equivalent save load boundary stays independent', + 'rust:unittests-src-lib-rs-target-debug-deps-automerge-f7a40786b3c8bc35:autocommit-tests-test-autocommit-is-send', + 'autocommit::tests::test_autocommit_is_send', + function () use ($port): void { + $source = $port->set($port->init('aaaaaa'), 'key', 'value'); + $payload = $port->save($source); + $received = $port->load($payload, 'bbbbbb'); + + sameArray($received->toArray(), $source->toArray(), 'document should materialize identically after crossing a save/load boundary'); + sameArray($port->getHeads($received), $port->getHeads($source), 'document heads should survive the send-equivalent boundary'); + + $changedReceived = $port->set($received, 'remote', true); + sameArray($source->toArray(), ['key' => 'value'], 'changing the received document should not mutate the source document'); + sameArray($changedReceived->toArray(), ['key' => 'value', 'remote' => true], 'received document should remain independently changeable'); + + $roundTripped = $port->applyChanges($port->init('cccccc'), $port->getAllChanges($changedReceived)); + sameArray($roundTripped->toArray(), $changedReceived->toArray(), 'received document changes should replay in another native document'); + sameArray($port->getHeads($roundTripped), $port->getHeads($changedReceived), 'replayed received document should preserve heads'); + }, + 'rust/automerge/src/autocommit.rs' +); + +$rustMapped( + 'rust transaction commit_with doc example records message and time', + 'rust:doc-tests-automerge:automerge-src-transaction-manual-transaction-rs-transaction-manual-transaction-transaction-commit-with-line-83', + 'automerge/src/transaction/manual_transaction.rs Transaction::commit_with example', + function () use ($port): void { + $now = 1234567890; + $tx = $port->transaction($port->init('aaaaaa')); + $tx->batchCreateObject('todos', []); + $doc = $tx->commitWith('Create todos list', $now); + $decoded = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + sameArray($doc->toArray(), ['todos' => []], 'transaction commit_with should publish the todos list'); + same($decoded['message'], 'Create todos list', 'transaction commit_with should preserve the supplied message'); + same($decoded['time'], $now, 'transaction commit_with should preserve the supplied commit time'); + }, + 'rust/automerge/src/transaction/manual_transaction.rs' +); + +$rustMapped( + 'rust owned transaction put and get roundtrip', + $rustOwnedTransactionSuite . 'put-and-get-roundtrip', + 'transaction::owned_transaction::tests::put_and_get_roundtrip', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->set('key', 'value'); + [$doc, $hash] = $tx->commitWithHash(); + + truthy($hash !== null, 'owned transaction with a write should report a change hash'); + same($doc->toArray()['key'] ?? null, 'value', 'committed owned transaction should expose the written value'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction reads writes before commit', + $rustOwnedTransactionSuite . 'read-during-transaction', + 'transaction::owned_transaction::tests::read_during_transaction', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->set('a', '1'); + + same($tx->document()->toArray()['a'] ?? null, '1', 'owned transaction draft should expose writes before commit'); + same($tx->commit()->toArray()['a'] ?? null, '1', 'committed owned transaction should preserve draft writes'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction supports nested list objects', + $rustOwnedTransactionSuite . 'nested-objects', + 'transaction::owned_transaction::tests::nested_objects', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->batchCreateObject('items', []); + $tx->insertListElements('items', 0, ['first']); + $tx->insertListElements('items', 1, ['second']); + [$doc, $hash] = $tx->commitWithHash(); + + truthy($hash !== null, 'owned transaction with nested list writes should report a change hash'); + sameArray($doc->toArray()['items'] ?? [], ['first', 'second'], 'committed owned transaction should preserve nested list insertions'); + same(count($doc->toArray()['items'] ?? []), 2, 'owned transaction list object should have the expected length'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction commit_with records options', + $rustOwnedTransactionSuite . 'commit-with-options', + 'transaction::owned_transaction::tests::commit_with_options', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->set('x', 42); + $doc = $tx->commitWith('test commit'); + $change = $port->decodeChange($port->getLastLocalChange($doc) ?? []); + + same($doc->toArray()['x'] ?? null, 42, 'owned transaction commit_with should publish the written value'); + same($change['message'], 'test commit', 'owned transaction commit_with should preserve the supplied message'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction logs patches', + $rustOwnedTransactionSuite . 'log-patches', + 'transaction::owned_transaction::tests::log_patches', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->set('patched', 'yes'); + [$doc, $patches] = $tx->commitWithPatches(); + + same($doc->toArray()['patched'] ?? null, 'yes', 'patch-logging transaction should publish the written value'); + truthy(count($patches) > 0, 'patch-logging transaction should emit at least one patch'); + same($patches[0]['action'] ?? null, 'put', 'patch-logging transaction should describe the root put'); + sameArray($patches[0]['path'] ?? [], ['patched'], 'patch-logging transaction should target the written root key'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction at historical heads commits against current document', + $rustOwnedTransactionSuite . 'owned-transaction-at', + 'transaction::owned_transaction::tests::owned_transaction_at', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->set('v', 1); + $doc = $tx->commit(); + $headsV1 = $port->getHeads($doc); + + $tx = $port->transaction($doc); + $tx->set('v', 2); + $doc = $tx->commit(); + + $tx = $port->transactionAt($doc, $headsV1); + same($tx->document()->toArray()['v'] ?? null, 1, 'historical owned transaction should read the requested heads'); + + $tx->set('from_v1', true); + [$doc, $hash] = $tx->commitWithHash(); + + truthy($hash !== null, 'historical owned transaction write should report a change hash'); + same($doc->toArray()['from_v1'] ?? null, true, 'historical owned transaction should commit the new root field'); + same($doc->toArray()['v'] ?? null, 2, 'historical owned transaction should preserve the current branch value'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction exposes pre-transaction heads', + $rustOwnedTransactionSuite . 'get-heads-returns-pre-tx-heads', + 'transaction::owned_transaction::tests::get_heads_returns_pre_tx_heads', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $heads = $port->getHeads($doc); + $tx = $port->transaction($doc); + + sameArray($tx->getHeads(), $heads, 'owned transaction should expose the document heads from before the transaction started'); + sameArray($tx->commit()->toArray(), ['a' => 1], 'committing a read-only transaction should leave the document data unchanged'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction counts pending operations', + $rustOwnedTransactionSuite . 'pending-ops', + 'transaction::owned_transaction::tests::pending_ops', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + + same($tx->pendingOps(), 0, 'new owned transaction should start with no pending ops'); + $tx->set('a', 1); + same($tx->pendingOps(), 1, 'first owned transaction write should count as one pending op'); + $tx->set('b', 2); + same($tx->pendingOps(), 2, 'second owned transaction write should count as another pending op'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction empty commit returns null hash', + $rustOwnedTransactionSuite . 'empty-commit-returns-none-hash', + 'transaction::owned_transaction::tests::empty_commit_returns_none_hash', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $tx = $port->transaction($doc); + [$committed, $hash] = $tx->commitWithHash(); + + same($hash, null, 'empty transaction commit should not report a change hash'); + sameArray($port->getHeads($committed), $port->getHeads($doc), 'empty transaction commit should not advance heads'); + same(count($port->getAllChanges($committed)), 0, 'empty transaction commit should not append a change'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction rollback with no writes cancels nothing', + $rustOwnedTransactionSuite . 'rollback-discards-ops', + 'transaction::owned_transaction::tests::rollback_discards_ops', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'keep', 'yes'); + $tx = $port->transaction($doc); + [$rolledBack, $cancelled] = $tx->rollbackWithCancelled(); + + same($cancelled, 0, 'rollback without writes should cancel no pending ops'); + sameArray($rolledBack->toArray(), ['keep' => 'yes'], 'rollback without writes should return the original document state'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust owned transaction rollback undoes pending writes', + $rustOwnedTransactionSuite . 'rollback-undoes-writes', + 'transaction::owned_transaction::tests::rollback_undoes_writes', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $tx = $port->transaction($doc); + $tx->set('gone', 'soon'); + [$rolledBack, $cancelled] = $tx->rollbackWithCancelled(); + + same($cancelled, 1, 'rollback after one write should cancel one pending op'); + sameArray($rolledBack->toArray(), [], 'rollback should discard pending writes from the transaction draft'); + }, + 'rust/automerge/src/transaction/owned_transaction.rs' +); + +$rustMapped( + 'rust transaction inner map rollback setup reads nested write', + $rustTransactionInnerSuite . 'map-rollback-doesnt-panic', + 'transaction::inner::tests::map_rollback_doesnt_panic', + function () use ($port): void { + $tx = $port->transaction($port->init('aaaaaa')); + $tx->batchCreateObject('a', []); + $tx->setNested(['a', 'b'], 1); + + same($tx->document()->toArray()['a']['b'] ?? null, 1, 'transaction should read a nested map write before rollback or commit'); + sameArray($tx->rollback()->toArray(), [], 'rolling back the nested map draft should return the original document'); + }, + 'rust/automerge/src/transaction/inner.rs' +); + +$rustMapped( + 'rust transaction rollback with no ops after merge is stable', + $rustCoreSuite . 'rollback-with-no-ops', + 'rollback_with_no_ops', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $doc2 = $port->clone($doc, 'bbbbbb'); + $doc2 = $port->transaction($doc2)->commit(); + + $doc3 = $port->set($port->clone($doc, 'cccccc'), 'b', 2); + $merged = $port->mergeDocuments($doc2, $doc3); + $before = $port->save($merged); + $rolledBack = $port->transaction($merged)->rollback(); + + same($port->save($rolledBack), $before, 'rolling back a no-op transaction after merge should preserve document bytes'); + sameArray($rolledBack->toArray(), ['a' => 1, 'b' => 2], 'rollback after merge should preserve visible merged state'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust rollback with several actors discards complex draft', + $rustCoreSuite . 'rollback-with-several-actors', + 'rollback_with_several_actors', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', new TextValue()); + $doc1 = $port->splice($doc1, 'text', 0, 0, 'the sly fox jumped over the lazy dog'); + $doc1 = $port->set($doc1, 'map_a', ['key1' => 'value1a', 'key2' => 'value2a']); + + $doc2 = $port->clone($doc1, 'cccccc'); + $doc2 = $port->splice($doc2, 'text', 8, 3, 'monkey'); + $doc2 = $port->splice($doc2, 'text', 36, 3, 'pig'); + $doc2 = $port->set($doc2, 'map_c', []); + $doc2 = $port->setNested($doc2, ['map_a', 'key2'], 'value2c'); + $doc2 = $port->setNested($doc2, ['map_a', 'key3'], 'value3c'); + $doc2 = $port->setNested($doc2, ['map_c', 'key1'], 'value'); + + $doc3 = $port->clone($doc2, 'bbbbbb'); + $beforeRollbackSave = $port->save($doc3); + $beforeRollbackView = $doc3->toArray(); + + $tx = $port->transaction($doc3); + $tx->splice('text', 8, 5, 'zebra'); + $tx->batchCreateObject('map_b', []); + $tx->setNested(['map_a', 'key1'], 'value3b'); + $tx->setNested(['map_a', 'key3'], 'value3b'); + $tx->setNested(['map_b', 'key1'], 'value'); + + truthy($tx->pendingOps() >= 5, 'complex rollback draft should contain pending text and map operations'); + $rolledBack = $tx->rollback(); + + same($port->save($rolledBack), $beforeRollbackSave, 'rollback should restore the pre-transaction document bytes'); + sameArray($rolledBack->toArray(), $beforeRollbackView, 'rollback should restore the pre-transaction visible document'); + sameArray($rolledBack->toArray(), $doc2->toArray(), 'rolled-back fork should still match the source actor visible state'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust AutoCommit diff cursor example emits patches', + 'rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-diff-line-223', + 'automerge/src/autocommit.rs AutoCommit::diff example', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $heads = $port->getHeads($doc); + $diffCursor = $port->diffCursor($doc); + $patches = $port->diff($doc, $diffCursor, $heads); + $port->updateDiffCursor($doc); + + sameArray($diffCursor, [], 'a document without an updated diff cursor should diff from the empty head set'); + sameArray($patches, [['action' => 'put', 'path' => ['key'], 'value' => 'value']], 'diff cursor example should emit the root map insertion patch'); + sameArray($port->diffCursor($doc), $heads, 'updateDiffCursor should advance the stored cursor to the current heads'); + }, + 'rust/automerge/src/autocommit.rs' +); + +$rustMapped( + 'rust AutoCommit diffIncremental example advances the cursor', + 'rust:doc-tests-automerge:automerge-src-autocommit-rs-autocommit-autocommit-diff-incremental-line-319', + 'automerge/src/autocommit.rs AutoCommit::diff_incremental example', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $patches = $port->diffIncremental($doc); + + sameArray($patches, [['action' => 'put', 'path' => ['key'], 'value' => 'value']], 'first diffIncremental call should emit changes since the empty cursor'); + sameArray($port->diffIncremental($doc), [], 'second diffIncremental call should be empty after the cursor advances'); + sameArray($port->diffCursor($doc), $port->getHeads($doc), 'diffIncremental should leave the cursor at the current heads'); + }, + 'rust/automerge/src/autocommit.rs' +); + +$rustMapped( + 'rust concurrent increments of the same property are added', + $rustCoreSuite . 'add-concurrent-increments-of-same-property', + 'add_concurrent_increments_of_same_property', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'counter', new Counter(0)); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + $doc1 = $port->incrementCounter($doc1, ['counter'], 1); + $doc2 = $port->incrementCounter($doc2, ['counter'], 2); + $merged = $port->mergeDocuments($doc1, $doc2); + + $counter = $merged->toArray()['counter']; + truthy($counter instanceof Counter, 'merged counter should remain a native counter'); + same($counter->value(), 3, 'concurrent increments on the same counter should be added'); + same($port->getConflicts($merged, 'counter'), null, 'same-counter increments should not create conflicts'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust increments only apply to preceding counter values', + $rustCoreSuite . 'add-increments-only-to-preceeded-values', + 'add_increments_only_to_preceeded_values', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'counter', new Counter(0)); + $doc1 = $port->incrementCounter($doc1, ['counter'], 1); + $doc2 = $port->set($port->init('bbbbbb'), 'counter', new Counter(0)); + $doc2 = $port->incrementCounter($doc2, ['counter'], 3); + $merged = $port->mergeDocuments($doc1, $doc2); + + $conflicts = $port->getConflicts($merged, 'counter') ?? []; + truthy($conflicts['1@aaaaaa'] instanceof Counter, 'first counter conflict should remain a counter'); + truthy($conflicts['1@bbbbbb'] instanceof Counter, 'second counter conflict should remain a counter'); + same($conflicts['1@aaaaaa']->value(), 1, 'first counter conflict should include only its own increment'); + same($conflicts['1@bbbbbb']->value(), 3, 'second counter conflict should include only its own increment'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust transaction_at applies historical text and scalar edits', + $rustCoreSuite . 'can-transaction-at', + 'can_transaction_at', + function () use ($port): void { + $doc = $port->setMany($port->init('aaaaaa'), [ + 'text' => 'aaabbbccc', + 'size' => 100, + ]); + $heads = $port->getHeads($doc); + + $doc = $port->splice($doc, 'text', 3, 3, 'QQQ'); + $doc = $port->set($doc, 'size', 200); + + $historical = $port->view($doc, $heads); + sameArray($historical->toArray(), ['text' => 'aaabbbccc', 'size' => 100], 'historical transaction view should start at the requested heads'); + + $doc = $port->spliceAtHeads($doc, $heads, 'text', 3, 3, 'ZZZ'); + $doc = $port->setAtHeads($doc, $heads, 'size', 300); + same($doc->toArray()['text'], 'aaaZZZQQQccc', 'first historical text replacement should merge with the current branch'); + same($doc->toArray()['size'], 300, 'first historical scalar write should become the root winner'); + + $doc = $port->spliceAtHeads($doc, $heads, 'text', 3, 3, 'TTT'); + $doc = $port->setAtHeads($doc, $heads, 'size', 400); + same($doc->toArray()['text'], 'aaaTTTZZZQQQccc', 'second historical text replacement should merge with both earlier branches'); + same($doc->toArray()['size'], 400, 'second historical scalar write should become the root winner'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust isolate hides later heads until explicit integration', + $rustCoreSuite . 'can-isolate', + 'can_isolate', + function () use ($port): void { + $doc1 = $port->setMany($port->init('aaaaaa'), [ + 'text' => 'aaabbbccc', + 'size' => 100, + ]); + $heads1 = $port->getHeads($doc1); + $doc1 = $port->set($doc1, 'size', 150); + + $isolated = $port->isolate($doc1, $heads1); + truthy($isolated instanceof IsolatedDocument, 'isolate should return an isolated document handle'); + + $doc2 = $port->clone($doc1, 'bbbbbb'); + $doc2 = $port->set($doc2, 'other', 999); + $doc2 = $port->splice($doc2, 'text', 9, 0, '111'); + + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaabbbccc', 'size' => 100], 'isolate should expose only the requested heads'); + + $isolated = $port->spliceInIsolation($isolated, 'text', 3, 3, 'QQQ'); + $isolated = $port->setInIsolation($isolated, 'size', 200); + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaaQQQccc', 'size' => 200], 'isolated edits should materialize against the isolated heads'); + + $heads2 = $port->getHeads($port->isolatedDocument($isolated)); + truthy($heads1 !== $heads2, 'isolated writes should advance the visible head set'); + + $isolated = $port->mergeIntoIsolation($isolated, $doc2); + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaaQQQccc', 'size' => 200], 'merging while isolated should keep hidden changes invisible'); + + $isolated = $port->isolate($isolated, $heads1); + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaabbbccc', 'size' => 100], 're-isolating at the original heads should hide the first isolated branch'); + + $isolated = $port->spliceInIsolation($isolated, 'text', 3, 3, 'ZZZ'); + $isolated = $port->setInIsolation($isolated, 'size', 300); + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaaZZZccc', 'size' => 300], 'second isolated branch should remain independent until integration'); + + $doc1 = $port->integrate($isolated); + sameArray($doc1->toArray(), ['text' => 'aaaZZZQQQccc111', 'size' => 300, 'other' => 999], 'integrate should materialize hidden and isolated branches together'); + + $isolated = $port->isolate($doc1, $heads1); + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaabbbccc', 'size' => 100], 'integrated documents should still support re-isolation at older heads'); + + $isolated = $port->spliceInIsolation($isolated, 'text', 3, 3, 'TTT'); + $isolated = $port->setInIsolation($isolated, 'size', 400); + sameArray($port->isolatedDocument($isolated)->toArray(), ['text' => 'aaaTTTccc', 'size' => 400], 'third isolated branch should materialize alone before integration'); + + $doc1 = $port->integrate($isolated); + sameArray($doc1->toArray(), ['text' => 'aaaTTTZZZQQQccc111', 'size' => 400, 'other' => 999], 'final integration should preserve deterministic text branch ordering'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust isolate integrate patch log omits stale conflicted text', + 'rust:tests-text-rs-target-debug-deps-text-e79c4b56267af860:incorrect-patches-produced-when-isolating-and-integrating', + 'incorrect_patches_produced_when_isolating_and_integrating', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $beginning = $port->getHeads($doc); + + $doc = $port->splice($doc, 'name', 0, 0, str_repeat('a', 100)); + + $isolated = $port->isolate($doc, $beginning); + $isolated = $port->spliceInIsolation($isolated, 'color', 0, 0, 'red'); + $doc = $port->integrate($isolated); + same($doc->text('color')->toString(), 'red', 'first isolated color text should materialize'); + + $ignoredPatches = $port->diffIncremental($doc); + truthy($ignoredPatches !== [], 'initial diffIncremental should populate and advance the cursor'); + $doc = $port->emptyChange($doc); + + $isolated = $port->isolate($doc, $beginning); + $isolated = $port->spliceInIsolation($isolated, 'color', 0, 0, 'unset'); + $visible = $port->emptyChange($port->isolatedDocument($isolated)); + $isolated = $isolated->withVisibleDocument($visible); + $doc = $port->integrate($isolated); + + same($doc->toArray()['color'] ?? null, 'unset', 'later isolated color object should win the root conflict'); + same(count($port->getConflicts($doc, 'color') ?? []), 2, 'concurrent color text object creations should be root-key conflicts'); + + $patches = $port->diffIncremental($doc); + truthy($patches !== [], 'second diffIncremental should emit patches for the integrated conflicting text'); + foreach ($patches as $patch) { + if (is_array($patch['path'] ?? null) && ($patch['path'][0] ?? null) === 'color') { + truthy(($patch['value'] ?? null) !== 'red', 'patches should not reinsert the stale red text conflict'); + } + } + }, + 'rust/automerge/tests/text.rs' +); + +$rustMapped( + 'rust marks survive expansion unmarking and prefix insertions', + $rustCoreSuite . 'marks', + 'marks', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splice($doc, 'text', 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 5, 'bold', true, 'both'); + $doc = $port->splice($doc, 'text', 5, 0, ' cool'); + $doc = $port->unmark($doc, ['text'], 0, 5, 'bold'); + $doc = $port->splice($doc, 'text', 0, 0, 'why '); + + sameArray($port->marks($doc, ['text']), [[ + 'name' => 'bold', + 'value' => true, + 'start' => 9, + 'end' => 14, + ]], 'mark range should track expansion, unmarking, and prefix insertions'); + sameArray($port->spans($doc, ['text']), [ + ['type' => 'text', 'value' => 'why hello'], + ['type' => 'text', 'value' => ' cool', 'marks' => ['bold' => true]], + ['type' => 'text', 'value' => ' world'], + ], 'only the expanded inserted text should remain bold'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust marks can be queried at historical heads', + $rustCoreSuite . 'get-marks-at-heads', + 'get_marks_at_heads', + function () use ($port): void { + $doc = $port->from(['text' => ''], 'aaaaaa'); + $doc = $port->splice($doc, 'text', 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 0, 10, 'bold', true, 'after'); + $heads = $port->getHeads($doc); + + $docWithPendingRemoval = $port->unmark($doc, ['text'], 0, 10, 'bold'); + sameArray( + $port->marksAtHeads($docWithPendingRemoval, ['text'], 1, $heads), + ['bold' => true], + 'marksAtHeads should return the mark active at the supplied historical heads' + ); + sameArray($port->marksAt($docWithPendingRemoval, ['text'], 1), [], 'current document should have removed the mark'); + + $committed = $port->emptyChange($docWithPendingRemoval); + sameArray( + $port->marksAtHeads($committed, ['text'], 1, $heads), + ['bold' => true], + 'historical mark lookup should keep working after later commits' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust sync state reports acknowledged local changes', + $rustCoreSuite . 'has-our-changes', + 'has_our_changes', + function () use ($port): void { + $left = $port->set($port->init('aaaaaa'), 'a', 1); + $right = $port->set($port->init('bbbbbb'), 'b', 2); + $leftToRight = $port->initSyncState(); + $rightToLeft = $port->initSyncState(); + $iterations = 0; + + truthy(! $port->hasOurChanges($left, $leftToRight), 'left should not start with acknowledged changes'); + truthy(! $port->hasOurChanges($right, $rightToLeft), 'right should not start with acknowledged changes'); + + while ( + ! $port->hasOurChanges($left, $leftToRight) + || ! $port->hasOurChanges($right, $rightToLeft) + ) { + $quiet = true; + + [$leftToRight, $message] = $port->generateSyncMessage($left, $leftToRight); + if ($message !== null) { + $quiet = false; + [$right, $rightToLeft] = $port->receiveSyncMessage($right, $rightToLeft, $message); + } + + [$rightToLeft, $message] = $port->generateSyncMessage($right, $rightToLeft); + if ($message !== null) { + $quiet = false; + [$left, $leftToRight] = $port->receiveSyncMessage($left, $leftToRight, $message); + } + + if ($quiet) { + throw new RuntimeException('no sync messages were sent but the sync state says peers are not in sync'); + } + + if (++$iterations > 10) { + throw new RuntimeException('sync acknowledgement did not converge within 10 iterations'); + } + } + + truthy($port->hasOurChanges($left, $leftToRight), 'left changes should be acknowledged after sync exchange'); + truthy($port->hasOurChanges($right, $rightToLeft), 'right changes should be acknowledged after sync exchange'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust local map increment applies to counter conflicts only', + $rustCoreSuite . 'test-local-inc-in-map', + 'test_local_inc_in_map', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'hello', 'world'); + $doc2 = $port->load($port->save($doc1), 'bbbbbb'); + $doc3 = $port->load($port->save($doc1), 'cccccc'); + + $doc1 = $port->set($doc1, 'cnt', 20); + $doc2 = $port->set($doc2, 'cnt', new Counter(0)); + $doc3 = $port->set($doc3, 'cnt', new Counter(10)); + $merged = $port->mergeDocuments($port->mergeDocuments($doc1, $doc2), $doc3); + + $before = $port->getConflicts($merged, 'cnt') ?? []; + same(count($before), 3, 'setup should expose the integer and two counter conflicts'); + same($before['2@aaaaaa'] ?? null, 20, 'integer conflict should remain visible before increment'); + truthy(($before['2@bbbbbb'] ?? null) instanceof Counter, 'first counter conflict should be present before increment'); + truthy(($before['2@cccccc'] ?? null) instanceof Counter, 'second counter conflict should be present before increment'); + same($before['2@bbbbbb']->value(), 0, 'first counter should start at zero'); + same($before['2@cccccc']->value(), 10, 'second counter should start at ten'); + + $incremented = $port->incrementCounter($merged, ['cnt'], 5); + $after = $port->getConflicts($incremented, 'cnt') ?? []; + + same($incremented->toArray()['hello'] ?? null, 'world', 'increment should preserve unrelated map values'); + same(count($after), 2, 'incrementing should drop the non-counter conflict and keep both counters'); + truthy(! array_key_exists('2@aaaaaa', $after), 'non-counter conflict should be omitted after counter increment'); + truthy(($after['2@bbbbbb'] ?? null) instanceof Counter, 'first counter conflict should remain after increment'); + truthy(($after['2@cccccc'] ?? null) instanceof Counter, 'second counter conflict should remain after increment'); + same($after['2@bbbbbb']->value(), 5, 'first counter conflict should include the local increment'); + same($after['2@cccccc']->value(), 15, 'second counter conflict should include the local increment'); + same($incremented->toArray()['cnt']->value(), 15, 'incremented counter conflict winner should materialize at the root'); + + $loaded = $port->load($port->save($incremented)); + $loadedConflicts = $port->getConflicts($loaded, 'cnt') ?? []; + same(count($loadedConflicts), 2, 'counter-only conflict set should survive save/load'); + same($loadedConflicts['2@bbbbbb']->value(), 5, 'first incremented counter should survive save/load'); + same($loadedConflicts['2@cccccc']->value(), 15, 'second incremented counter should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust list counter conflicts can be incremented and deleted', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:list-counter-del', + 'list_counter_del', + function () use ($port): void { + $conflictValues = static function (?array $conflicts): array { + $values = []; + foreach ($conflicts ?? [] as $value) { + $values[] = $value instanceof Counter ? $value->value() : $value; + } + sort($values); + + return $values; + }; + + $base = $port->set($port->init('aaaaaa'), 'list', ['a', 'b', 'c']); + $doc1 = $port->load($port->save($base), 'aaaaaa'); + $doc2 = $port->load($port->save($base), 'bbbbbb'); + $doc3 = $port->load($port->save($base), 'cccccc'); + + $doc1 = $port->setListElement($doc1, 'list', 1, new Counter(0)); + $doc2 = $port->setListElement($doc2, 'list', 1, new Counter(10)); + $doc3 = $port->setListElement($doc3, 'list', 1, new Counter(100)); + + $doc1 = $port->setListElement($doc1, 'list', 2, new Counter(0)); + $doc2 = $port->setListElement($doc2, 'list', 2, new Counter(10)); + $doc3 = $port->setListElement($doc3, 'list', 2, 100); + + $doc1 = $port->incrementCounter($doc1, ['list', 1], 1); + $doc1 = $port->incrementCounter($doc1, ['list', 2], 1); + $merged = $port->mergeDocuments($port->mergeDocuments($doc1, $doc2), $doc3); + + same($merged->toArray()['list'][0] ?? null, 'a', 'merged list should retain the unchanged first element'); + sameArray($conflictValues($port->getListElementConflicts($merged, 'list', 1)), [1, 10, 100], 'first conflicted list element should expose all counter values'); + sameArray($conflictValues($port->getListElementConflicts($merged, 'list', 2)), [1, 10, 100], 'second conflicted list element should expose counter and scalar values'); + + $merged = $port->incrementCounter($merged, ['list', 1], 1); + $merged = $port->incrementCounter($merged, ['list', 2], 1); + + sameArray($conflictValues($port->getListElementConflicts($merged, 'list', 1)), [2, 11, 101], 'incrementing a list conflict should update all counter alternatives'); + sameArray($conflictValues($port->getListElementConflicts($merged, 'list', 2)), [2, 11], 'incrementing should drop the non-counter list conflict alternative'); + + $merged = $port->deleteListElements($merged, 'list', 2); + same(count($merged->toArray()['list']), 2, 'deleting the conflicted tail element should shorten the list'); + same(count($port->load($port->save($merged), 'dddddd')->toArray()['list']), 2, 'tail deletion should survive save/load'); + + $merged = $port->deleteListElements($merged, 'list', 1); + same(count($merged->toArray()['list']), 1, 'deleting the remaining conflicted element should leave one item'); + same(count($port->load($port->save($merged), 'eeeeee')->toArray()['list']), 1, 'second deletion should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust save/load preserves concurrent todo map conflicts', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-restore-complex1', + 'save_restore_complex1', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'todos', [['title' => 'water plants', 'done' => false]]); + $doc2 = $port->load($port->save($doc1), 'bbbbbb'); + + $doc2 = $port->setListElement($doc2, 'todos', 0, ['title' => 'weed plants', 'done' => false]); + $doc1 = $port->setListElement($doc1, 'todos', 0, ['title' => 'kill plants', 'done' => false]); + $loaded = $port->load($port->save($port->mergeDocuments($doc1, $doc2)), 'cccccc'); + + $conflicts = $port->getListElementConflicts($loaded, 'todos', 0) ?? []; + $titles = []; + foreach ($conflicts as $value) { + truthy(is_array($value), 'conflicting todo item should remain a map after save/load'); + same($value['done'] ?? null, false, 'conflicting todo item should preserve the unchanged done flag'); + $titles[] = $value['title'] ?? null; + } + sort($titles); + + sameArray($titles, ['kill plants', 'weed plants'], 'saved list map conflict should retain both concurrent titles'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust transactional save/load preserves concurrent todo map conflicts', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:save-restore-complex-transactional', + 'save_restore_complex_transactional', + function () use ($port): void { + $tx1 = $port->transaction($port->init('aaaaaa')); + $tx1->set('todos', [['title' => 'water plants', 'done' => false]]); + $doc1 = $tx1->commit(); + + $doc2 = $port->load($port->save($doc1), 'bbbbbb'); + $tx2 = $port->transaction($doc2); + $tx2->set('todos', [['title' => 'weed plants', 'done' => false]]); + $doc2 = $tx2->commit(); + + $tx1 = $port->transaction($doc1); + $tx1->set('todos', [['title' => 'kill plants', 'done' => false]]); + $doc1 = $tx1->commit(); + + $loaded = $port->load($port->save($port->mergeDocuments($doc1, $doc2)), 'cccccc'); + $conflicts = $port->getListElementConflicts($loaded, 'todos', 0) ?? []; + $titles = []; + foreach ($conflicts as $value) { + truthy(is_array($value), 'transactional conflicting todo item should remain a map after save/load'); + same($value['done'] ?? null, false, 'transactional conflicting todo item should preserve the unchanged done flag'); + $titles[] = $value['title'] ?? null; + } + sort($titles); + + sameArray($titles, ['kill plants', 'weed plants'], 'transactional saved list map conflict should retain both concurrent titles'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust incremental load keeps concurrent heads when one head is common', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:make-sure-load-incremental-doesnt-skip-a-load-with-a-common-head', + 'make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'string', 'hello'); + $doc2 = $port->load($port->save($doc1), 'bbbbbb'); + $doc3 = $port->load($port->save($doc1), 'cccccc'); + + same(count($port->getHeads($doc1)), 1, 'initial document should have one head'); + $doc1 = $port->set($doc1, 'concurrent1', '123'); + $hashB = $port->getHeads($doc1)[0] ?? null; + + $doc3 = $port->loadIncremental($doc3, $port->save($doc1)); + $hashC = $port->getHeads($doc3)[0] ?? null; + same($hashC, $hashB, 'incremental load should apply the first concurrent branch'); + + $doc2 = $port->set($doc2, 'concurrent2', 'abc'); + $hashD = $port->getHeads($doc2)[0] ?? null; + $doc2 = $port->mergeDocuments($doc2, $doc1); + $heads = $port->getHeads($doc2); + + same(count($heads), 2, 'merged document should retain both concurrent heads'); + truthy(in_array($hashB, $heads, true), 'merged heads should contain the first branch'); + truthy(in_array($hashD, $heads, true), 'merged heads should contain the second branch'); + + $doc3 = $port->loadIncremental($doc3, $port->save($doc2)); + + sameArray($port->getHeads($doc3), $port->getHeads($doc2), 'incremental load should not skip the load when one head is already common'); + $doc3Materialized = $doc3->toArray(); + $doc2Materialized = $doc2->toArray(); + ksort($doc3Materialized); + ksort($doc2Materialized); + sameArray($doc3Materialized, $doc2Materialized, 'incremental load should materialize both concurrent branches'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust mappings allow empty string keys', + $rustCoreSuite . 'allows-empty-keys-in-mappings', + 'allows_empty_keys_in_mappings', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), '', 1); + + sameArray($doc->toArray(), ['' => 1], 'empty string map key should materialize with its value'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent updates of the same field expose both values', + $rustCoreSuite . 'concurrent-updates-of-same-field', + 'concurrent_updates_of_same_field', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'field', 'one'); + $doc2 = $port->set($port->init('aaaaaa'), 'field', 'two'); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray( + $port->getConflicts($merged, 'field') ?? [], + [ + '1@aaaaaa' => 'two', + '1@bbbbbb' => 'one', + ], + 'same-field merge should expose both conflicting scalar values' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust assignment conflicts of different types expose all contenders', + $rustCoreSuite . 'assignment-conflicts-of-different-types', + 'assignment_conflicts_of_different_types', + function () use ($port): void { + $doc1 = $port->set($port->init('cccccc'), 'field', 'string'); + $doc2 = $port->set($port->init('bbbbbb'), 'field', []); + $doc3 = $port->set($port->init('aaaaaa'), 'field', ['nested' => true]); + $merged = $port->mergeDocuments($port->mergeDocuments($doc1, $doc2), $doc3); + + same($merged->toArray()['field'], 'string', 'different-type conflict should materialize the deterministic winner'); + sameArray( + $port->getConflicts($merged, 'field') ?? [], + [ + '1@aaaaaa' => ['nested' => true], + '1@bbbbbb' => [], + '1@cccccc' => 'string', + ], + 'different-type conflict should retain scalar list and map contenders' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent updates of the same list element expose both values', + $rustCoreSuite . 'concurrent-updates-of-same-list-element', + 'concurrent_updates_of_same_list_element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['finch']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + $doc1 = $port->setListElement($doc1, 'birds', 0, 'greenfinch'); + $doc2 = $port->setListElement($doc2, 'birds', 0, 'goldfinch'); + $merged = $port->mergeDocuments($doc1, $doc2); + + $conflicts = $port->getListElementConflicts($merged, 'birds', 0) ?? []; + sort($conflicts); + sameArray($conflicts, ['goldfinch', 'greenfinch'], 'same-list-element merge should expose both conflicting values'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust changes within a conflicting map field stay attached', + $rustCoreSuite . 'changes-within-conflicting-map-field', + 'changes_within_conflicting_map_field', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'field', 'string'); + $doc2 = $port->set($port->init('aaaaaa'), 'field', []); + $doc2 = $port->setNested($doc2, ['field', 'innerKey'], 42); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray( + $port->getConflicts($merged, 'field') ?? [], + [ + '1@aaaaaa' => ['innerKey' => 42], + '1@bbbbbb' => 'string', + ], + 'nested map edits should stay attached to the conflicted root map value' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust changes within a conflicting list element stay attached', + $rustCoreSuite . 'changes-within-conflicting-list-element', + 'changes_within_conflicting_list_element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['hello']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setListElement($doc1, 'list', 0, ['map1' => true]); + $doc1 = $port->setNested($doc1, ['list', 0, 'key'], 1); + $doc2 = $port->setListElement($doc2, 'list', 0, ['map2' => true]); + $doc2 = $port->setNested($doc2, ['list', 0, 'key'], 2); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray( + $port->getListElementConflicts($merged, 'list', 0) ?? [], + [ + '2@aaaaaa' => ['map2' => true, 'key' => 2], + '2@bbbbbb' => ['map1' => true, 'key' => 1], + ], + 'nested list element map edits should stay attached to the conflicted element values' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent assignment and deletion of a map entry is add-wins', + $rustCoreSuite . 'concurrent-assignment-and-deletion-of-a-map-entry', + 'concurrent_assignment_and_deletion_of_a_map_entry', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'bestBird', 'robin'); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->delete($doc1, 'bestBird'); + $doc2 = $port->set($doc2, 'bestBird', 'magpie'); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['bestBird' => 'magpie'], 'concurrent map assignment should win over deletion'); + same($port->getConflicts($merged, 'bestBird'), null, 'map assignment/delete merge should not create conflicts'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent assignment and deletion of a list entry is add-wins', + $rustCoreSuite . 'concurrent-assignment-and-deletion-of-list-entry', + 'concurrent_assignment_and_deletion_of_list_entry', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['blackbird', 'thrush', 'goldfinch']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setListElement($doc1, 'birds', 1, 'starling'); + $doc2 = $port->deleteListElements($doc2, 'birds', 1); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['birds'], ['blackbird', 'starling', 'goldfinch'], 'concurrent list assignment should resurrect the deleted element'); + same($port->getListElementConflicts($merged, 'birds', 1), null, 'list assignment/delete merge should not create element conflicts'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent deletion of the same list element is idempotent', + $rustCoreSuite . 'concurrent-deletion-of-same-list-element', + 'concurrent_deletion_of_same_list_element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['albatross', 'buzzard', 'cormorant']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteListElements($doc1, 'birds', 1); + $doc2 = $port->deleteListElements($doc2, 'birds', 1); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['birds'], ['albatross', 'cormorant'], 'concurrent deletion of the same list element should remove it once'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent insertions at different list positions merge cleanly', + $rustCoreSuite . 'concurrent-insertions-at-different-list-positions', + 'concurrent_insertions_at_different_list_positions', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['one', 'three']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->insertListElements($doc1, 'list', 1, ['two']); + $doc2 = $port->pushList($doc2, 'list', ['four']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['list'], ['one', 'two', 'three', 'four'], 'different-position concurrent list insertions should merge cleanly'); + same($port->getConflicts($merged, 'list'), null, 'different-position list insertions should not conflict'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent insertions at the same list position merge cleanly', + $rustCoreSuite . 'concurrent-insertions-at-same-list-position', + 'concurrent_insertions_at_same_list_position', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['parakeet']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->pushList($doc1, 'birds', ['starling']); + $doc2 = $port->pushList($doc2, 'birds', ['chaffinch']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['birds'], ['parakeet', 'starling', 'chaffinch'], 'same-position concurrent list insertions should keep both values'); + same($port->getConflicts($merged, 'birds'), null, 'same-position list insertions should not conflict'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent updates at different tree levels prefer the delete', + $rustCoreSuite . 'concurrent-updates-at-different-levels', + 'concurrent_updates_at_different_levels', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'animals', [ + 'birds' => ['pink' => 'flamingo', 'black' => 'starling'], + 'mammals' => ['badger'], + ]); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->setNested($doc1, ['animals', 'birds', 'brown'], 'sparrow'); + $doc2 = $port->deleteNested($doc2, ['animals', 'birds']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['animals'], ['mammals' => ['badger']], 'higher-level delete should win over the subtree update'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent update under a deleted object does not resurrect it', + $rustCoreSuite . 'concurrent-updates-of-concurrently-deleted-objects', + 'concurrent_updates_of_concurrently_deleted_objects', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['blackbird' => ['feathers' => 'black']]); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteNested($doc1, ['birds', 'blackbird']); + $doc2 = $port->setNested($doc2, ['birds', 'blackbird', 'beak'], 'orange'); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray(), ['birds' => []], 'nested update under a deleted object should not resurrect it'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrently assigned nested maps do not merge', + $rustCoreSuite . 'concurrently-assigned-nested-maps-should-not-merge', + 'concurrently_assigned_nested_maps_should_not_merge', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'config', ['background' => 'blue']); + $doc2 = $port->set($port->init('aaaaaa'), 'config', ['logo_url' => 'logo.png']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray( + $port->getConflicts($merged, 'config') ?? [], + [ + '1@aaaaaa' => ['logo_url' => 'logo.png'], + '1@bbbbbb' => ['background' => 'blue'], + ], + 'concurrently assigned nested maps should remain root conflicts' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust same-position sequence insertions do not interleave', + $rustCoreSuite . 'does-not-interleave-sequence-insertions-at-same-position', + 'does_not_interleave_sequence_insertions_at_same_position', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'wisdom', []); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->insertListElements($doc1, 'wisdom', 0, ['to', 'be', 'is', 'to', 'do']); + $doc2 = $port->insertListElements($doc2, 'wisdom', 0, ['to', 'do', 'is', 'to', 'be']); + $wisdom = $port->mergeDocuments($doc1, $doc2)->toArray()['wisdom']; + + truthy( + $wisdom === ['to', 'be', 'is', 'to', 'do', 'to', 'do', 'is', 'to', 'be'] + || $wisdom === ['to', 'do', 'is', 'to', 'be', 'to', 'be', 'is', 'to', 'do'], + 'same-position sequence insertions should stay grouped' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust insertion after a deleted list element survives', + $rustCoreSuite . 'insertion-after-a-deleted-list-element', + 'insertion_after_a_deleted_list_element', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'birds', ['blackbird', 'thrush', 'goldfinch']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc1 = $port->deleteListElements($doc1, 'birds', 1, 2); + $doc2 = $port->insertListElements($doc2, 'birds', 2, ['starling']); + $merged = $port->mergeDocuments($doc1, $doc2); + + sameArray($merged->toArray()['birds'], ['blackbird', 'starling'], 'insertion after deleted list elements should survive'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust causal list insertions preserve insertion order', + $rustCoreSuite . 'insertion-consistent-with-causality', + 'insertion_consistent_with_causality', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['four']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + + $doc2 = $port->insertListElements($doc2, 'list', 0, ['three']); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $doc1 = $port->insertListElements($doc1, 'list', 0, ['two']); + $doc2 = $port->mergeDocuments($doc2, $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two', 'three', 'four'], 'causal list insertions should materialize in causal order'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust repeated map assignment has no conflict', + $rustCoreSuite . 'no-conflict-on-repeated-assignment', + 'no_conflict_on_repeated_assignment', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'foo', 'bar'); + $doc = $port->set($doc, 'foo', 'baz'); + + sameArray($doc->toArray(), ['foo' => 'baz'], 'repeated map assignment should keep the latest value'); + same($port->getConflicts($doc, 'foo'), null, 'repeated map assignment should not create conflicts'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust save and restore empty document', + $rustCoreSuite . 'save-and-restore-empty', + 'save_and_restore_empty', + function () use ($port): void { + $loaded = $port->load($port->save($port->init('aaaaaa')), 'bbbbbb'); + + sameArray($loaded->toArray(), [], 'saved and restored empty document should stay empty'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust same-position insertion by greater actor id prepends to the list', + $rustCoreSuite . 'mutliple-insertions-at-same-list-position-with-insertion-by-greater-actor-id', + 'mutliple_insertions_at_same_list_position_with_insertion_by_greater_actor_id', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'list', ['two']); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two'], 'later same-position insertion by greater actor should appear before the existing value'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust same-position insertion by lesser actor id prepends to the list', + $rustCoreSuite . 'mutliple-insertions-at-same-list-position-with-insertion-by-lesser-actor-id', + 'mutliple_insertions_at_same_list_position_with_insertion_by_lesser_actor_id', + function () use ($port): void { + $doc1 = $port->set($port->init('bbbbbb'), 'list', ['two']); + $doc2 = $port->mergeDocuments($port->init('aaaaaa'), $doc1); + $doc2 = $port->insertListElements($doc2, 'list', 0, ['one']); + + sameArray($doc2->toArray()['list'], ['one', 'two'], 'later same-position insertion by lesser actor should appear before the existing value'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust save and reload preserves object created without child operations', + $rustCoreSuite . 'save-and-reload-create-object', + 'save_and_reload_create_object', + function () use ($port): void { + $doc = $port->batchCreateObject($port->init('aaaaaa'), 'foo', []); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + $loaded = $port->insertListElements($loaded, 'foo', 0, [1]); + + sameArray($loaded->toArray(), ['foo' => [1]], 'a saved empty list object should reload and accept later insertions'); + sameArray($port->load($port->save($loaded), 'cccccc')->toArray(), ['foo' => [1]], 'saved list insertion after reload should load again'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust counter changes can be applied to a fresh document', + $rustCoreSuite . 'observe-counter-change-application', + 'observe_counter_change_application', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'counter', new Counter(1)); + $doc = $port->incrementCounter($doc, ['counter'], 2); + $doc = $port->incrementCounter($doc, ['counter'], 5); + $applied = $port->applyChanges($port->init('bbbbbb'), $port->getAllChanges($doc)); + + $counter = $applied->toArray()['counter'] ?? null; + truthy($counter instanceof Counter, 'applied counter changes should materialize as a native counter'); + same($counter->value(), 8, 'applied counter increments should preserve the accumulated counter value'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust negative 64-bit-adjacent integer stores and loads', + $rustCoreSuite . 'negative-64', + 'negative_64', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', -64); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + + same($loaded->toArray()['a'], -64, 'negative integer scalar should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust repeated optree-boundary map updates apply after save-load', + $rustCoreSuite . 'bad-change-on-optree-node-boundary', + 'bad_change_on_optree_node_boundary', + function () use ($port): void { + $doc = $port->setMany($port->init('aaaaaa'), [ + 'a' => 'z', + 'b' => 0, + 'c' => 0, + ]); + + $iterations = 15; + for ($i = 0; $i < $iterations; ++$i) { + $doc = $port->setMany($doc, [ + 'a' => str_repeat('a', $i), + 'b' => $i + 1, + 'c' => $i + 1, + ]); + } + + $loaded = $port->load($port->save($doc), 'bbbbbb'); + $loadedHeads = $port->getHeads($loaded); + + $i = $iterations + 2; + $doc = $port->setMany($doc, [ + 'a' => str_repeat('a', $i), + 'b' => $i, + 'c' => $i, + ]); + + $changes = $port->getChangesSince($doc, $loadedHeads); + same(count($changes), 1, 'only one change should be needed after the loaded heads'); + + $loaded = $port->applyChanges($loaded, $changes); + $roundTripped = $port->load($port->save($loaded), 'cccccc'); + + sameArray( + $roundTripped->toArray(), + ['a' => str_repeat('a', $i), 'b' => $i, 'c' => $i], + 'applied post-load change should survive a second save/load cycle' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust load document with deleted root objects succeeds', + $rustCoreSuite . 'load-doc-with-deleted-objects', + 'load_doc_with_deleted_objects', + function () use ($port): void { + $doc = $port->batchCreateObject($port->init('aaaaaa'), 'list', []); + $doc = $port->batchCreateObject($doc, 'text', new TextValue()); + $doc = $port->batchCreateObject($doc, 'map', ['child' => 'value']); + $doc = $port->batchCreateObject($doc, 'table', []); + foreach (['list', 'text', 'map', 'table'] as $key) { + $doc = $port->delete($doc, $key); + } + + sameArray($port->load($port->save($doc), 'bbbbbb')->toArray(), [], 'document containing deleted root objects should load successfully'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust insertion after many map deletes keeps the container valid', + $rustCoreSuite . 'insert-after-many-deletes', + 'insert_after_many_deletes', + function () use ($port): void { + $doc = $port->batchCreateObject($port->init('aaaaaa'), 'object', []); + for ($i = 0; $i < 100; ++$i) { + $key = (string) $i; + $doc = $port->setNested($doc, ['object', $key], $i); + $doc = $port->deleteNested($doc, ['object', $key]); + } + + sameArray($doc->toArray(), ['object' => []], 'repeated nested insert/delete should leave an empty map container'); + sameArray($port->load($port->save($doc), 'bbbbbb')->toArray(), ['object' => []], 'empty map container should survive save/load after many deletes'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust simple no-op bad save/load regression loads successfully', + $rustCoreSuite . 'simple-bad-saveload', + 'simple_bad_saveload', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'count', 0); + $doc = $port->emptyChange($doc); + $doc = $port->set($doc, 'count', 0); + + sameArray($port->load($port->save($doc), 'bbbbbb')->toArray(), ['count' => 0], 'save/load should tolerate a no-op set after an empty change'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust load incremental applies complete prefix and ignores corrupted tail', + $rustCoreSuite . 'load-incremental-with-corrupted-tail', + 'load_incremental_with_corrupted_tail', + function () use ($port): void { + $source = $port->set($port->init('aaaaaa'), 'key', 'value'); + $payload = $port->save($source); + $corruptedTail = "\x01\x02\x03\x04"; + $result = $port->loadIncrementalPrefix($port->init('bbbbbb'), $payload . $corruptedTail); + $loaded = $result['document']; + + same($result['loadedChanges'], 1, 'loadIncrementalPrefix should apply the single complete change before the corrupted tail'); + same($result['bytesConsumed'], strlen($payload), 'loadIncrementalPrefix should report the complete JSON payload length'); + same($result['trailingBytes'], $corruptedTail, 'loadIncrementalPrefix should expose unconsumed corrupted tail bytes'); + sameArray($loaded->toArray(), ['key' => 'value'], 'document should materialize the complete prefix despite trailing bytes'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust regression nth miscount resolves nested list maps', + $rustCoreSuite . 'regression-nth-miscount', + 'regression_nth_miscount', + function () use ($port): void { + $doc = $port->batchCreateObject($port->init('aaaaaa'), 'listval', []); + for ($i = 0; $i < 30; ++$i) { + $doc = $port->insertListElements($doc, 'listval', $i, [[]]); + $doc = $port->setNested($doc, ['listval', $i, 'test'], $i); + } + + foreach ($doc->toArray()['listval'] as $index => $item) { + same($item['test'] ?? null, $index, 'nested list map should remain readable at its insertion index'); + } + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust regression nth miscount smaller resolves overwritten list scalars', + $rustCoreSuite . 'regression-nth-miscount-smaller', + 'regression_nth_miscount_smaller', + function () use ($port): void { + $doc = $port->batchCreateObject($port->init('aaaaaa'), 'listval', []); + for ($i = 0; $i < 64; ++$i) { + $doc = $port->insertListElements($doc, 'listval', $i, [null]); + $doc = $port->setListElement($doc, 'listval', $i, $i); + } + + foreach ($doc->toArray()['listval'] as $index => $item) { + same($item, $index, 'overwritten list scalar should remain readable at its insertion index'); + } + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust regression insert opid replays list insertions', + $rustCoreSuite . 'regression-insert-opid', + 'regression_insert_opid', + function () use ($port): void { + $doc = $port->batchCreateObject($port->init('aaaaaa'), 'list', []); + $change1 = $port->getLastLocalChange($doc); + + for ($i = 0; $i <= 30; ++$i) { + $doc = $port->insertListElements($doc, 'list', $i, [null]); + $doc = $port->setListElement($doc, 'list', $i, $i); + } + + $change2 = $port->getLastLocalChange($doc); + $replayed = $port->applyChanges($port->init('bbbbbb'), [$change1]); + $replayed = $port->applyChanges($replayed, [$change2]); + + sameArray($replayed->toArray(), $doc->toArray(), 'replayed list insertion change should match the source document'); + sameArray($port->load($port->save($replayed), 'cccccc')->toArray(), ['list' => range(0, 30)], 'replayed list insertion result should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust text splice save/load keeps loaded edits', + $rustCoreSuite . 'test-merging-test-conflicts-then-saving-and-loading', + 'test_merging_test_conflicts_then_saving_and_loading', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'text', new TextValue()); + $doc1 = $port->splice($doc1, 'text', 0, 0, 'hello'); + + $doc2 = $port->load($port->save($doc1), 'bbbbbb'); + sameArray($doc2->toArray(), ['text' => 'hello'], 'loaded text document should expose the original text'); + + $doc2 = $port->splice($doc2, 'text', 4, 1, ''); + $doc2 = $port->splice($doc2, 'text', 4, 0, '!'); + $doc2 = $port->splice($doc2, 'text', 5, 0, ' '); + $doc2 = $port->splice($doc2, 'text', 6, 0, 'world'); + + sameArray($doc2->toArray(), ['text' => 'hell! world'], 'text splices should materialize the edited text'); + sameArray($port->load($port->save($doc2), 'cccccc')->toArray(), ['text' => 'hell! world'], 'edited text should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust inserting text near deleted marks keeps text coherent', + $rustCoreSuite . 'inserting-text-near-deleted-marks', + 'inserting_text_near_deleted_marks', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, 'hello world'); + $doc = $port->mark($doc, ['text'], 2, 8, 'bold', true, 'after'); + $doc = $port->mark($doc, ['text'], 3, 6, 'link', true, 'none'); + + $doc = $port->splice($doc, 'text', 1, 10, ''); + sameArray($doc->toArray(), ['text' => 'h'], 'deleting across marked text should leave the first character'); + + $doc = $port->splice($doc, 'text', 0, 0, 'a'); + sameArray($doc->toArray(), ['text' => 'ah'], 'inserting before the remaining character should preserve text order'); + + $doc = $port->splice($doc, 'text', 2, 0, 'a'); + sameArray($doc->toArray(), ['text' => 'aha'], 'inserting after the remaining character should preserve text order'); + sameArray($port->load($port->save($doc), 'bbbbbb')->toArray(), ['text' => 'aha'], 'post-mark-deletion insertions should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust overwriting identical scalar conflicts clears duplicates', + $rustCoreSuite . 'test-overwriting-a-conflict', + 'test_overwriting_a_conflict', + function () use ($port): void { + $doc1 = $port->init('aaaaaa'); + $doc2 = $port->clone($doc1, 'bbbbbb'); + + $doc1 = $port->set($doc1, 'key', 'value'); + $doc2 = $port->set($doc2, 'key', 'value'); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $doc2 = $port->mergeDocuments($doc2, $doc1); + + same(count($port->getConflicts($doc1, 'key') ?? []), 2, 'identical concurrent scalar puts should expose both conflict entries in the first document'); + same(count($port->getConflicts($doc2, 'key') ?? []), 2, 'identical concurrent scalar puts should expose both conflict entries in the second document'); + + $doc1 = $port->set($doc1, 'key', 'value'); + $doc2 = $port->set($doc2, 'key', 'value'); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $doc2 = $port->mergeDocuments($doc2, $doc1); + + same($port->getConflicts($doc1, 'key'), null, 'overwriting the identical conflict should collapse to one value in the first document'); + same($port->getConflicts($doc2, 'key'), null, 'overwriting the identical conflict should collapse to one value in the second document'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust repeated map assignment resolves existing conflict', + $rustCoreSuite . 'repeated-map-assignment-which-resolves-conflict-not-ignored', + 'repeated_map_assignment_which_resolves_conflict_not_ignored', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'field', 123); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + $doc2 = $port->set($doc2, 'field', 456); + $doc1 = $port->set($doc1, 'field', 789); + $merged = $port->mergeDocuments($doc1, $doc2); + + same(count($port->getConflicts($merged, 'field') ?? []), 2, 'concurrent map assignments should create a two-value conflict'); + $resolved = $port->set($merged, 'field', 123); + + sameArray($resolved->toArray(), ['field' => 123], 'repeated map assignment should resolve the conflict to the assigned value'); + same($port->getConflicts($resolved, 'field'), null, 'resolved map assignment should clear conflicts'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust repeated list assignment resolves existing conflict', + $rustCoreSuite . 'repeated-list-assignment-which-resolves-conflict-not-ignored', + 'repeated_list_assignment_which_resolves_conflict_not_ignored', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'list', [123]); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + $doc2 = $port->setListElement($doc2, 'list', 0, 456); + $doc1 = $port->mergeDocuments($doc1, $doc2); + $doc1 = $port->setListElement($doc1, 'list', 0, 789); + + sameArray($doc1->toArray(), ['list' => [789]], 'repeated list assignment should resolve the element conflict'); + same($port->getListElementConflicts($doc1, 'list', 0), null, 'resolved list assignment should clear element conflicts'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust list deletion removes the selected element', + $rustCoreSuite . 'list-deletion', + 'list_deletion', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', [123, 456, 789]); + $doc = $port->deleteListElements($doc, 'list', 1); + + sameArray($doc->toArray(), ['list' => [123, 789]], 'list deletion should remove exactly the selected element'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust concurrent map property updates merge', + $rustCoreSuite . 'merge-concurrent-map-prop-updates', + 'merge_concurrent_map_prop_updates', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'foo', 'bar'); + $doc2 = $port->set($port->init('bbbbbb'), 'hello', 'world'); + $merged1 = $port->mergeDocuments($doc1, $doc2); + $merged2 = $port->mergeDocuments($doc2, $merged1); + + same($merged1->toArray()['foo'] ?? null, 'bar', 'different-property concurrent merge should preserve foo'); + same($merged1->toArray()['hello'] ?? null, 'world', 'different-property concurrent merge should preserve hello'); + same($merged2->toArray()['foo'] ?? null, 'bar', 'merging back should preserve foo'); + same($merged2->toArray()['hello'] ?? null, 'world', 'merging back should preserve hello'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust incrementing non-counter map values fails but counters still increment', + $rustCoreSuite . 'increment-non-counter-map', + 'increment_non_counter_map', + function () use ($port): void { + throwsLike( + static fn () => $port->incrementCounter($port->init('aaaaaa'), ['nothing'], 2), + 'Cannot increment a non-counter value', + 'incrementing a missing map key should fail' + ); + + $doc = $port->set($port->init('aaaaaa'), 'non-counter', 'mystring'); + throwsLike( + static fn () => $port->incrementCounter($doc, ['non-counter'], 2), + 'Cannot increment a non-counter value', + 'incrementing a scalar map key should fail' + ); + + $doc = $port->set($doc, 'counter', new Counter(1)); + $doc = $port->incrementCounter($doc, ['counter'], 2); + same($doc->toArray()['counter']->value(), 3, 'map counter should still increment after non-counter failures'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust incrementing non-counter list values fails but counters still increment', + $rustCoreSuite . 'increment-non-counter-list', + 'increment_non_counter_list', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['mystring']); + throwsLike( + static fn () => $port->incrementCounter($doc, ['list', 0], 2), + 'Cannot increment a non-counter value', + 'incrementing a scalar list element should fail' + ); + + $doc = $port->insertListElements($doc, 'list', 0, [new Counter(1)]); + $doc = $port->incrementCounter($doc, ['list', 0], 2); + same($doc->toArray()['list'][0]->value(), 3, 'list counter should still increment after non-counter failure'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust list operations reject invalid indexes', + $rustCoreSuite . 'invalid-index', + 'invalid_index', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', []); + $doc = $port->insertListElementsStrict($doc, 'a', 0, [1]); + $doc = $port->setListElementStrict($doc, 'a', 0, 2); + + sameArray($doc->toArray(), ['a' => [2]], 'valid list put should update the existing element'); + throwsLike( + static fn (): Document => $port->insertListElementsStrict($doc, 'a', 2, [1]), + 'List insertion index is out of bounds', + 'insert should reject an index beyond the list end' + ); + throwsLike( + static fn (): Document => $port->setListElementStrict($doc, 'a', 1, 2), + 'List assignment index is out of bounds', + 'put should reject an index at the list end' + ); + throwsLike( + static fn (): Document => $port->insertListElementsStrict($doc, 'a', 100, [1]), + 'List insertion index is out of bounds', + 'insert should reject a distant out-of-bounds index' + ); + throwsLike( + static fn (): Document => $port->setListElementStrict($doc, 'a', 100, 2), + 'List assignment index is out of bounds', + 'put should reject a distant out-of-bounds index' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust operations on the wrong object type are rejected', + $rustCoreSuite . 'ops-on-wrong-objets', + 'ops_on_wrong_objets', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['a', 'b']); + throwsLike( + static fn (): Document => $port->setNested($doc, ['list', 'a'], 'AAA'), + 'Cannot use a map key on a list value', + 'putting a map key on a list should fail' + ); + throwsLike( + static fn (): Document => $port->splice($doc, 'list', 0, 0, 'hello world'), + 'Invalid text operation on non-text value', + 'splicing text into a list should fail' + ); + + $doc = $port->set($doc, 'map', ['a' => 'AAA', 'b' => 'BBB']); + throwsLike( + static fn (): Document => $port->spliceList($doc, 'map', 0, 0, ['b']), + 'Invalid list operation on non-list value', + 'inserting a list value into a map should fail' + ); + throwsLike( + static fn (): Document => $port->splice($doc, 'map', 0, 0, 'hello world'), + 'Invalid text operation on non-text value', + 'splicing text into a map should fail' + ); + + $doc = $port->set($doc, 'text', 'hello world'); + throwsLike( + static fn (): Document => $port->setNested($doc, ['text', 'a'], 'AAA'), + 'Cannot modify a scalar value as a container', + 'putting a map key on text should fail' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust save and load incremented counter change metadata', + $rustCoreSuite . 'save-and-load-incremented-counter', + 'save_and_load_incremented_counter', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'counter', new Counter(1)); + $doc = $port->incrementCounter($doc, ['counter'], 1); + $changes = $port->getAllChanges($doc); + $roundTripped = json_decode(json_encode($changes, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + + sameArray($roundTripped, $changes, 'expanded native counter changes should round trip through JSON'); + same($port->load($port->save($doc), 'bbbbbb')->toArray()['counter']->value(), 2, 'incremented counter should survive save/load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust get change metadata since heads returns the next change', + $rustCoreSuite . 'test-get-change-meta', + 'test_get_change_meta', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $startHeads = $port->getHeads($doc); + $doc = $port->set($doc, 'b', 2); + $changes = $port->getChangesMetaSince($doc, $startHeads); + + same(count($changes), 1, 'change metadata since heads should include one later change'); + same($changes[0]['actor'] ?? null, $port->getActorId($doc), 'change metadata should expose the document actor'); + same($changes[0]['seq'] ?? null, 2, 'change metadata should expose the second sequence number'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust get changes after an empty-change head is empty', + $rustCoreSuite . 'get-changes-with-hash-of-empty-change-produces-correct-result', + 'get_changes_with_hash_of_empty_change_produces_correct_result', + function () use ($port): void { + $doc = $port->emptyChange($port->init('aaaaaa')); + $heads = $port->getHeads($doc); + + sameArray($port->getChangesSince($doc, $heads), [], 'getChanges since the empty-change head should be empty'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust change graph clock cache regression keeps merged branch ancestors covered', + 'rust:tests-test-rs-target-debug-deps-test-769fe2d72b51cc45:reproduce-clock-cache-bug', + 'reproduce_clock_cache_bug', + function () use ($port): void { + $base = $port->init('aaaaaa'); + for ($i = 0; $i < 100; ++$i) { + $base = $port->set($base, 'initial_commit_' . $i, true); + } + + $branches = []; + for ($i = 0; $i < 19; ++$i) { + $branches[] = $port->clone($base, sprintf('%06x', $i + 1)); + } + $branches[] = $base; + + foreach ($branches as $branchNo => $branch) { + for ($commitNo = 0; $commitNo < 2; ++$commitNo) { + $branch = $port->set($branch, 'branch_' . $branchNo . '-' . $commitNo, true); + $branch = $port->clone($branch, sprintf('%06x', 100 + ($branchNo * 2) + $commitNo)); + } + $branches[$branchNo] = $branch; + } + + $base = array_pop($branches); + foreach ($branches as $branch) { + $base = $port->mergeDocuments($base, $branch); + } + + for ($i = 0; $i < 100; ++$i) { + $base = $port->set($base, 'after-merge-' . $i, true); + } + + $heads = $port->getHeads($base); + same(count($port->getAllChanges($base)), 240, 'clock-cache regression fixture should create the expected branchy graph size'); + sameArray($port->getChangesSince($base, $heads), [], 'getChanges since current heads should treat every branch ancestor as covered'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust last local change hash matches document heads', + $rustCoreSuite . 'test-get-last-local-change-generation', + 'test_get_last_local_change_generation', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', new TextValue()); + foreach ([[0, 0, 'hello world'], [5, 1, 'X'], [6, 1, ''], [0, 0, 'ten thousand and five hundred']] as $splice) { + $doc = $port->splice($doc, 'text', $splice[0], $splice[1], $splice[2]); + $change = $port->getLastLocalChange($doc); + sameArray($port->getHeads($doc), [$change['hash'] ?? ''], 'last local change hash should be the current document head'); + } + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust stats reports change and operation counts', + $rustCoreSuite . 'stats-smoke-test', + 'stats_smoke_test', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $doc = $port->set($doc, 'b', 2); + $stats = $port->stats($doc); + + same($stats['numChanges'], 2, 'stats should report two native changes'); + same($stats['numOps'], 2, 'stats should report two native operations'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust zero-length strings and bytes store as values', + $rustCoreSuite . 'zero-length-data', + 'zero_length_data', + function () use ($port): void { + $doc = $port->setMany($port->init('aaaaaa'), [ + 'string' => '', + 'bytes' => new BytesValue([]), + ]); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + + same($loaded->toArray()['string'], '', 'empty string should survive save/load'); + truthy($loaded->toArray()['bytes'] instanceof BytesValue, 'empty bytes should remain a native BytesValue'); + sameArray($loaded->toArray()['bytes']->bytes(), [], 'empty bytes should preserve an empty byte list'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust save with delete-only actor references loads', + $rustCoreSuite . 'save-with-ops-which-reference-actors-only-via-delete', + 'save_with_ops_which_reference_actors_only_via_delete', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $forked = $port->mergeDocuments($port->init('bbbbbb'), $doc); + $forked = $port->delete($forked, 'a'); + $merged = $port->mergeDocuments($doc, $forked); + + sameArray($port->load($port->save($merged), 'cccccc')->toArray(), [], 'document containing delete-only actor references should load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust delete-only list change survives load and later insert', + $rustCoreSuite . 'delete-only-change', + 'delete_only_change', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['a']); + $doc = $port->load($port->save($doc), 'aaaaaa'); + $doc = $port->deleteListElements($doc, 'list', 0); + + sameArray($doc->toArray(), ['list' => []], 'delete-only list change should remove the original element'); + + $loaded = $port->load($port->save($doc), 'aaaaaa'); + $loaded = $port->insertListElements($loaded, 'list', 0, ['b']); + $changes = $port->getAllChanges($loaded); + + sameArray($loaded->toArray(), ['list' => ['b']], 'a reloaded delete-only list change should accept later insertions'); + same(count($changes), 3, 'delete-only change and later insertion should both remain in history'); + truthy( + ($changes[2]['startOp'] ?? 0) > ($changes[1]['startOp'] ?? 0), + 'operation counters should advance after reloading a delete-only list change' + ); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust save with empty commits loads', + $rustCoreSuite . 'save-with-empty-commits', + 'save_with_empty_commits', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $forked = $port->mergeDocuments($port->init('bbbbbb'), $doc); + $forked = $port->emptyChange($forked); + $merged = $port->mergeDocuments($doc, $forked); + + sameArray($port->load($port->save($merged), 'cccccc')->toArray(), ['a' => 1], 'document merged with an empty commit should load'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust partial incremental load applies selected changes', + $rustCoreSuite . 'test-load-incremental-partial-load', + 'test_load_incremental_partial_load', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'a', 1); + $startHeads = $port->getHeads($doc); + $doc = $port->set($doc, 'b', 2); + $loaded = $port->loadIncremental($port->init('bbbbbb'), $port->saveSince($doc, $startHeads)); + + sameArray($loaded->toArray(), ['b' => 2], 'partial incremental load should apply the selected later change'); + same($port->getMissingDeps($loaded), [$startHeads[0]], 'partial incremental load should report the missing dependency'); + }, + 'rust/automerge/tests/test.rs' +); + +$rustMapped( + 'rust current_state renders root, map, list, and text values', + $rustCurrentStateSuite . 'basic-test', + 'automerge::current_state::tests::basic_test', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $doc = $port->set($doc, 'map', []); + $doc = $port->setNested($doc, ['map', 'nested_key'], 'value'); + $doc = $port->set($doc, 'list', []); + $doc = $port->insertListElements($doc, 'list', 0, ['value']); + $doc = $port->set($doc, 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, 'a'); + + sameArray( + $doc->toArray(), + ['key' => 'value', 'map' => ['nested_key' => 'value'], 'list' => ['value'], 'text' => 'a'], + 'current_state basic setup should materialize the expected document' + ); + + $patches = $port->diff($doc, [], $port->getHeads($doc)); + foreach ([['key'], ['map'], ['list'], ['text'], ['map', 'nested_key']] as $path) { + truthy( + count(array_filter($patches, static fn (array $patch): bool => ($patch['path'] ?? null) === $path)) >= 1, + 'current_state should include a patch for ' . json_encode($path) + ); + } + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'insert' + && ($patch['path'] ?? null) === ['list', 0] + )) === 1, + 'current_state should include the list insertion' + ); + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'put' + && ($patch['path'] ?? null) === ['text'] + && ($patch['value'] ?? null) === 'a' + )) === 1, + 'current_state should include the text materialization' + ); + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state omits deleted operations while keeping live containers', + $rustCurrentStateSuite . 'test-deleted-ops-omitted', + 'automerge::current_state::tests::test_deleted_ops_omitted', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'value'); + $doc = $port->delete($doc, 'key'); + $doc = $port->set($doc, 'map', []); + $doc = $port->setNested($doc, ['map', 'nested_key'], 'value'); + $doc = $port->deleteNested($doc, ['map', 'nested_key']); + $doc = $port->set($doc, 'list', []); + $doc = $port->insertListElements($doc, 'list', 0, ['value']); + $doc = $port->deleteListElements($doc, 'list', 0); + $doc = $port->set($doc, 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, 'a'); + $doc = $port->splice($doc, 'text', 0, 1, ''); + + foreach (['deleted_map' => [], 'deleted_list' => [], 'deleted_text' => new TextValue()] as $key => $value) { + $doc = $port->set($doc, $key, $value); + $doc = $port->delete($doc, $key); + } + + sameArray($doc->toArray(), ['map' => [], 'list' => [], 'text' => ''], 'current_state should retain only live containers'); + + $patches = $port->diff($doc, [], $port->getHeads($doc)); + foreach ([['key'], ['deleted_map'], ['deleted_list'], ['deleted_text'], ['map', 'nested_key'], ['list', 0]] as $path) { + truthy( + count(array_filter($patches, static fn (array $patch): bool => ($patch['path'] ?? null) === $path)) === 0, + 'current_state should omit deleted path ' . json_encode($path) + ); + } + foreach ([['map'], ['list'], ['text']] as $path) { + truthy( + count(array_filter($patches, static fn (array $patch): bool => ($patch['path'] ?? null) === $path)) === 1, + 'current_state should retain live container path ' . json_encode($path) + ); + } + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state text splice coalesces delete-insert output', + $rustCurrentStateSuite . 'test-text-spliced', + 'automerge::current_state::tests::test_text_spliced', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, 'a'); + $doc = $port->splice($doc, 'text', 1, 0, 'bcdef'); + $doc = $port->splice($doc, 'text', 2, 2, 'g'); + $patches = $port->diff($doc, [], $port->getHeads($doc)); + + same($doc->toArray()['text'], 'abgef', 'text splice should insert at the original splice index after deletion'); + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'put' + && ($patch['path'] ?? null) === ['text'] + && ($patch['value'] ?? null) === 'abgef' + )) === 1, + 'current_state should render the final coalesced text' + ); + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state reports counter conflicts with the current counter value', + $rustCurrentStateSuite . 'test-counters', + 'automerge::current_state::tests::test_counters', + function () use ($port): void { + $doc = $port->init('bbbbbb'); + $doc2 = $port->clone($doc, 'aaaaaa'); + $doc2 = $port->set($doc2, 'key', 'someval'); + + $doc = $port->set($doc, 'key', new Counter(1)); + $doc = $port->incrementCounter($doc, ['key'], 2); + $doc = $port->incrementCounter($doc, ['key'], 3); + $merged = $port->mergeDocuments($doc, $doc2); + + $value = $merged->toArray()['key'] ?? null; + truthy($value instanceof Counter, 'counter conflict winner should materialize as a counter'); + same($value->value(), 6, 'counter conflict winner should include both increments'); + same(count($port->getConflicts($merged, 'key') ?? []), 2, 'current_state counter setup should preserve the conflict flag source data'); + truthy( + count(array_filter( + $port->diff($merged, [], $port->getHeads($merged)), + static fn (array $patch): bool => ($patch['path'] ?? null) === ['key'] + && ($patch['value'] ?? null) instanceof Counter + && $patch['value']->value() === 6 + )) === 1, + 'current_state should render the current counter value' + ); + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state load emits counter put patches', + $rustCurrentStateSuite . 'test-load-changes', + 'automerge::current_state::tests::test_load_changes', + function () use ($port): void { + $source = $port->set($port->init('aaaaaa'), 'a', new Counter(2000)); + $callbacks = []; + $loaded = $port->loadWithPatchCallback( + $port->save($source), + static function (array $patches, array $metadata) use (&$callbacks): void { + $callbacks[] = ['patches' => $patches, 'source' => $metadata['source'] ?? null]; + }, + 'bbbbbb' + ); + $value = $loaded->toArray()['a'] ?? null; + + truthy($value instanceof Counter, 'loaded counter fixture should materialize as a native counter'); + same($value->value(), 2000, 'loaded counter fixture should preserve the counter value'); + same(count($callbacks), 1, 'load should emit one patch callback batch'); + same($callbacks[0]['source'], 'load', 'load patch callback should identify the load source'); + + $patch = $callbacks[0]['patches'][0] ?? null; + truthy(is_array($patch), 'load patch callback should include a put patch'); + same($patch['action'] ?? null, 'put', 'load patch should put the counter at root'); + sameArray($patch['path'] ?? [], ['a'], 'load patch should target the root counter key'); + truthy(($patch['value'] ?? null) instanceof Counter, 'load patch value should be a native counter'); + same($patch['value']->value(), 2000, 'load patch counter value should match the fixture'); + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state renders multiple sequential list insertions', + $rustCurrentStateSuite . 'test-multiple-list-insertions', + 'automerge::current_state::tests::test_multiple_list_insertions', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->insertListElements($doc, 'list', 0, [1]); + $doc = $port->insertListElements($doc, 'list', 1, [2]); + $patches = $port->diff($doc, [], $port->getHeads($doc)); + + sameArray($doc->toArray(), ['list' => [1, 2]], 'sequential list insertions should materialize in insertion order'); + foreach ([[0, 1], [1, 2]] as [$index, $value]) { + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'insert' + && ($patch['path'] ?? null) === ['list', $index] + && ($patch['values'] ?? null) === [$value] + )) === 1, + 'current_state should include list insertion at index ' . $index + ); + } + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state renders concurrent same-index list insertions', + $rustCurrentStateSuite . 'test-concurrent-insertions-at-same-index', + 'automerge::current_state::tests::test_concurrent_insertions_at_same_index', + function (): void { + $port = new NativePort(); + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc2 = $port->clone($doc, 'bbbbbb'); + + $doc = $port->insertListElements($doc, 'list', 0, [1]); + $doc2 = $port->insertListElements($doc2, 'list', 0, [2]); + $merged1 = $port->mergeDocuments($doc, $doc2); + $merged2 = $port->mergeDocuments($doc2, $merged1); + $patches = $port->diff($merged1, [], $port->getHeads($merged1)); + + sameArray($merged1->toArray(), $merged2->toArray(), 'both peers should hydrate the same concurrent list state'); + sameArray($merged1->toArray(), ['list' => [2, 1]], 'same-index concurrent inserts should use deterministic actor ordering'); + foreach ([[0, 2], [1, 1]] as [$index, $value]) { + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'insert' + && ($patch['path'] ?? null) === ['list', $index] + && ($patch['values'] ?? null) === [$value] + )) === 1, + 'current_state should include concurrent list insertion at index ' . $index + ); + } + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state renders inserted map objects in lists', + $rustCurrentStateSuite . 'test-insert-objects', + 'automerge::current_state::tests::test_insert_objects', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->insertListElements($doc, 'list', 0, [['key' => 'value']]); + $patches = $port->diff($doc, [], $port->getHeads($doc)); + + sameArray($doc->toArray(), ['list' => [['key' => 'value']]], 'inserted object should materialize inside the list'); + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'insert' + && ($patch['path'] ?? null) === ['list', 0] + && ($patch['values'] ?? null) === [['key' => 'value']] + )) === 1, + 'current_state should include the inserted map object' + ); + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust current_state renders list inserts after element updates', + $rustCurrentStateSuite . 'test-insert-and-update', + 'automerge::current_state::tests::test_insert_and_update', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->insertListElements($doc, 'list', 0, ['one']); + $doc = $port->insertListElements($doc, 'list', 1, ['two']); + $doc = $port->setListElement($doc, 'list', 0, 'three'); + $doc = $port->setListElement($doc, 'list', 1, 'four'); + + sameArray($doc->toArray(), ['list' => ['three', 'four']], 'updated list elements should materialize in current state'); + $patches = $port->diff($doc, [], $port->getHeads($doc)); + foreach ([[0, 'three'], [1, 'four']] as [$index, $value]) { + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'splice' + && ($patch['path'] ?? null) === ['list', $index, 0] + && ($patch['value'] ?? null) === $value + )) === 1, + 'current_state should include updated text-like list element ' . $index + ); + } + }, + 'rust/automerge/src/automerge/current_state.rs' +); + +$rustMapped( + 'rust hydrate materializes root data and applies text patches', + $rustHydrateSuite . 'simple-hydrate', + 'hydrate::tests::simple_hydrate', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->insertListElements($doc, 'list', 0, [5, 6, 7, 'hello', new Counter(100), [], []]); + $doc = $port->set($doc, 'text', new TextValue()); + $doc = $port->splice($doc, 'text', 0, 0, 'hello world'); + + $hydrated = $port->hydrate($doc); + same($hydrated['list'][0] ?? null, 5, 'hydrated list should include the first scalar'); + same($hydrated['list'][3] ?? null, 'hello', 'hydrated list should include string scalars'); + truthy(($hydrated['list'][4] ?? null) instanceof Counter, 'hydrated list should retain counter scalars'); + same($hydrated['list'][4]->value(), 100, 'hydrated counter should retain its value'); + sameArray($hydrated['list'][5] ?? ['missing'], [], 'hydrated list should include an empty map object'); + sameArray($hydrated['list'][6] ?? ['missing'], [], 'hydrated list should include an empty list object'); + same($hydrated['text'] ?? null, 'hello world', 'hydrated text should expose the text value'); + + $beforeHeads = $port->getHeads($doc); + $doc = $port->splice($doc, 'text', 6, 0, 'big bad '); + same($port->hydrate($doc, ['text']), 'hello big bad world', 'path hydration should return the updated text'); + + $patches = $port->diff($doc, $beforeHeads, $port->getHeads($doc)); + $hydrated = $port->applyHydratedPatches($hydrated, $patches); + + same($hydrated['text'] ?? null, 'hello big bad world', 'hydrated text should update after applying patches'); + }, + 'rust/automerge/src/hydrate/tests.rs' +); + +$rustMapped( + 'rust document iterator walks live root and child values', + $rustIterDocSuite . 'doc-iter', + 'iter::doc::tests::doc_iter', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key01', 'value1'); + $doc = $port->set($doc, 'key02', 'value2'); + $doc = $port->set($doc, 'key03', []); + $doc = $port->set($doc, 'key04', 'value4'); + $doc = $port->set($doc, 'key05', []); + $doc = $port->set($doc, 'key06', []); + $doc = $port->set($doc, 'key07', []); + $doc = $port->set($doc, 'key08', []); + $doc = $port->set($doc, 'key09', []); + $doc = $port->set($doc, 'key10', []); + $doc = $port->set($doc, 'key11', []); + $doc = $port->set($doc, 'key12', []); + $doc = $port->set($doc, 'key13', new TextValue()); + $doc = $port->splice($doc, 'key13', 0, 0, 'hello world'); + $doc = $port->setNested($doc, ['key03', 'm1key1'], 'm1value1'); + $doc = $port->setNested($doc, ['key06', 'm3key1'], 'm3value1'); + $doc = $port->setNested($doc, ['key06', 'm3key2'], 'm3value2'); + $doc = $port->setNested($doc, ['key06', 'm3key3'], 'm3value3'); + $doc = $port->setNested($doc, ['key07', 'm4key1'], 'm4value1'); + $doc = $port->setNested($doc, ['key07', 'm4key2'], 'm4value2'); + $doc = $port->insertListElements($doc, 'key08', 0, ['l1e1', 'l1e2', 'l1e3']); + $doc = $port->setNested($doc, ['key10', 'm6key1'], 'm6value1'); + $doc = $port->setNested($doc, ['key10', 'm6key2'], 'm6value2'); + $doc = $port->setNested($doc, ['key10', 'm6key3'], 'm6value3'); + $doc = $port->insertListElements($doc, 'key11', 0, ['l2e1']); + $doc = $port->delete($doc, 'key06'); + $doc = $port->deleteNested($doc, ['key10', 'm6key2']); + + $items = $port->iterDocument($doc); + $pairs = array_map( + static fn (array $item): array => [$item['key'], $item['kind'] === 'scalar' ? $item['value'] : $item['kind']], + $items + ); + + sameArray( + $pairs, + [ + ['key01', 'value1'], + ['key02', 'value2'], + ['key03', 'map'], + ['key04', 'value4'], + ['key05', 'list'], + ['key07', 'map'], + ['key08', 'list'], + ['key09', 'list'], + ['key10', 'map'], + ['key11', 'list'], + ['key12', 'list'], + ['key13', 'text'], + ['m1key1', 'm1value1'], + ['m4key1', 'm4value1'], + ['m4key2', 'm4value2'], + ['', 'l1e1'], + ['', 'l1e2'], + ['', 'l1e3'], + ['m6key1', 'm6value1'], + ['m6key3', 'm6value3'], + ['', 'l2e1'], + ['', 'hello world'], + ], + 'document iterator should match upstream live entry order and omit deleted values' + ); + }, + 'rust/automerge/src/iter/doc.rs' +); + +$rustMapped( + 'rust batch insert creates a flat map', + $rustBatchSuite . 'batch-insert-flat-map', + 'batch_insert_flat_map', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'data', [ + 'a' => 'hello', + 'b' => 42, + 'c' => true, + ]); + + sameArray($doc->toArray()['data'], ['a' => 'hello', 'b' => 42, 'c' => true], 'flat map batch insert should materialize the nested map'); + } +); + +$rustMapped( + 'rust batch insert creates nested maps', + $rustBatchSuite . 'batch-insert-nested-maps', + 'batch_insert_nested_maps', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'nested', [ + 'outer' => [ + 'inner_a' => 'deep', + 'inner_b' => 99, + ], + 'top_level' => 'flat', + ]); + + sameArray( + $doc->toArray()['nested'], + ['outer' => ['inner_a' => 'deep', 'inner_b' => 99], 'top_level' => 'flat'], + 'nested map batch insert should preserve nested map values' + ); + } +); + +$rustMapped( + 'rust batch insert map overwrites an existing root key', + $rustBatchSuite . 'batch-insert-map-overwrites-existing-key', + 'batch_insert_map_overwrites_existing_key', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'key', 'old_value'); + $doc = $port->set($doc, 'key', ['child' => 'new']); + + sameArray($doc->toArray(), ['key' => ['child' => 'new']], 'map batch insert should overwrite an existing scalar key'); + } +); + +$rustMapped( + 'rust batch insert rejects scalar object creation', + $rustBatchSuite . 'batch-insert-scalar-fails', + 'batch_insert_scalar_fails', + function () use ($port): void { + throwsLike( + static fn () => $port->batchCreateObject($port->init('aaaaaa'), 'foo', 1), + 'Batch object creation requires', + 'batch object creation should reject scalar values' + ); + } +); + +$rustMapped( + 'rust batch insert creates a flat list', + $rustBatchSuite . 'batch-insert-flat-list', + 'batch_insert_flat_list', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'nums', [1, 2, 3]); + + sameArray($doc->toArray()['nums'], [1, 2, 3], 'flat list batch insert should preserve list order'); + } +); + +$rustMapped( + 'rust batch insert creates a list with nested objects', + $rustBatchSuite . 'batch-insert-list-with-nested-objects', + 'batch_insert_list_with_nested_objects', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'users', [ + ['name' => 'alice'], + ['name' => 'bob'], + ]); + + sameArray($doc->toArray()['users'], [['name' => 'alice'], ['name' => 'bob']], 'list batch insert should preserve nested map elements'); + } +); + +$rustMapped( + 'rust batch insert appends an object to an existing list', + $rustBatchSuite . 'batch-insert-into-list-at-end', + 'batch_insert_into_list_at_end', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'items', ['first', 'second']); + $doc = $port->insertListElements($doc, 'items', 2, [['key' => 'third']]); + + sameArray($doc->toArray()['items'], ['first', 'second', ['key' => 'third']], 'list batch insert at end should append the nested map'); + } +); + +$rustMapped( + 'rust batch insert inserts an object into the middle of a list', + $rustBatchSuite . 'batch-insert-into-list-at-middle', + 'batch_insert_into_list_at_middle', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'items', ['a', 'c']); + $doc = $port->insertListElements($doc, 'items', 1, [['val' => 'b']]); + + sameArray($doc->toArray()['items'], ['a', ['val' => 'b'], 'c'], 'list batch insert in the middle should shift later values'); + } +); + +$rustMapped( + 'rust batch put overwrites an existing list element', + $rustBatchSuite . 'batch-put-overwrites-existing-list-element', + 'batch_put_overwrites_existing_list_element', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'items', ['old_a', 'old_b', 'old_c']); + $doc = $port->setListElement($doc, 'items', 1, ['replaced' => true]); + + sameArray($doc->toArray()['items'], ['old_a', ['replaced' => true], 'old_c'], 'list batch put should replace the element without changing list length'); + } +); + +$rustMapped( + 'rust batch insert supports text values in a map', + $rustBatchSuite . 'batch-insert-with-text', + 'batch_insert_with_text', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'data', ['greeting' => 'hello world']); + + sameArray($doc->toArray(), ['data' => ['greeting' => 'hello world']], 'batch insert should preserve text-like string values inside maps'); + } +); + +$rustMapped( + 'rust batch insert supports text values in a list', + $rustBatchSuite . 'batch-insert-text-in-list', + 'batch_insert_text_in_list', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'texts', ['one', 'two']); + + sameArray($doc->toArray()['texts'], ['one', 'two'], 'batch insert should preserve text-like list values'); + } +); + +$rustMapped( + 'rust batch insert supports deeply nested maps', + $rustBatchSuite . 'batch-insert-deeply-nested', + 'batch_insert_deeply_nested', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'deep', [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => 'deep_value', + ], + ], + ], + ]); + + same($doc->toArray()['deep']['level1']['level2']['level3']['level4'], 'deep_value', 'deep nested batch insert should preserve the leaf value'); + } +); + +$rustMapped( + 'rust batch insert supports mixed map and list nesting', + $rustBatchSuite . 'batch-insert-mixed-nesting', + 'batch_insert_mixed_nesting', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'data', [ + 'users' => [ + ['name' => 'alice', 'scores' => [10, 20, 30]], + ['name' => 'bob', 'scores' => [40, 50]], + ], + 'count' => 2, + ]); + + $data = $doc->toArray()['data']; + same($data['count'], 2, 'mixed nesting batch insert should preserve scalar map entries'); + same($data['users'][0]['name'], 'alice', 'mixed nesting batch insert should preserve first nested map'); + sameArray($data['users'][0]['scores'], [10, 20, 30], 'mixed nesting batch insert should preserve first nested list'); + same($data['users'][1]['name'], 'bob', 'mixed nesting batch insert should preserve second nested map'); + sameArray($data['users'][1]['scores'], [40, 50], 'mixed nesting batch insert should preserve second nested list'); + } +); + +$rustMapped( + 'rust batch insert survives save and load', + $rustBatchSuite . 'batch-insert-survives-save-load', + 'batch_insert_survives_save_load', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'data', [ + 'name' => 'test', + 'items' => [1, 2, 3], + 'nested' => ['deep' => true], + ]); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + + sameArray($loaded->toArray()['data'], ['name' => 'test', 'items' => [1, 2, 3], 'nested' => ['deep' => true]], 'batch insert values should survive native save/load'); + } +); + +$rustMapped( + 'rust batch insert merges independent root objects', + $rustBatchSuite . 'batch-insert-merges-correctly', + 'batch_insert_merges_correctly', + function () use ($port): void { + $base = $port->init('aaaaaa'); + $doc1 = $port->set($base, 'obj1', ['from' => 'doc1']); + $doc2 = $port->set($port->clone($base, 'bbbbbb'), 'obj2', ['from' => 'doc2']); + $merged = $port->mergeDocuments($doc1, $doc2); + + same($merged->toArray()['obj1']['from'], 'doc1', 'merge should preserve the first independent batch object'); + same($merged->toArray()['obj2']['from'], 'doc2', 'merge should preserve the second independent batch object'); + } +); + +$rustMapped( + 'rust multiple batch inserts preserve all root objects', + $rustBatchSuite . 'multiple-batch-inserts', + 'multiple_batch_inserts', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'first', ['a' => 1]); + $doc = $port->set($doc, 'second', ['b' => 2]); + $doc = $port->set($doc, 'third', ['c' => 3]); + + sameArray($doc->toArray(), ['first' => ['a' => 1], 'second' => ['b' => 2], 'third' => ['c' => 3]], 'multiple batch inserts should preserve all root objects'); + } +); + +$rustMapped( + 'rust batch insert into an existing map', + $rustBatchSuite . 'batch-insert-into-existing-map', + 'batch_insert_into_existing_map', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'parent', ['existing' => 'value']); + $doc = $port->setNested($doc, ['parent', 'child'], ['x' => 1, 'y' => 2]); + + sameArray($doc->toArray()['parent'], ['existing' => 'value', 'child' => ['x' => 1, 'y' => 2]], 'batch insert into an existing map should preserve existing and child entries'); + } +); + +$rustMapped( + 'rust batch insert into an existing list', + $rustBatchSuite . 'batch-insert-into-existing-list', + 'batch_insert_into_existing_list', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['existing']); + $doc = $port->insertListElements($doc, 'list', 1, [['appended' => true]]); + + sameArray($doc->toArray()['list'], ['existing', ['appended' => true]], 'batch insert into an existing list should append the object'); + } +); + +$rustMapped( + 'rust batch insert hydrate output materializes matching data', + $rustBatchSuite . 'batch-insert-matches-hydrate-output', + 'batch_insert_matches_hydrate_output', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'data', [ + 'name' => 'test', + 'count' => 42, + 'tags' => ['alpha', 'beta'], + ]); + + sameArray($doc->toArray()['data'], ['name' => 'test', 'count' => 42, 'tags' => ['alpha', 'beta']], 'hydrated batch insert output should match the input shape'); + } +); + +$rustMapped( + 'rust batch insert works inside a committed transaction', + $rustBatchSuite . 'batch-insert-with-transaction', + 'batch_insert_with_transaction', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $tx = $port->transaction($doc); + + $tx->batchCreateObject('data', ['key' => 'from_tx']); + sameArray($tx->document()->toArray()['data'], ['key' => 'from_tx'], 'transaction draft should expose batch-created object data'); + sameArray($doc->toArray(), [], 'transaction draft changes should not mutate the base document before commit'); + + $committed = $tx->commit(); + sameArray($committed->toArray()['data'], ['key' => 'from_tx'], 'committed transaction should publish the batch-created object'); + } +); + +$rustMapped( + 'rust batch insert transaction rollback discards the draft', + $rustBatchSuite . 'batch-insert-transaction-rollback', + 'batch_insert_transaction_rollback', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $tx = $port->transaction($doc); + + $tx->batchCreateObject('data', ['key' => 'should_be_gone']); + $rolledBack = $tx->rollback(); + + sameArray($rolledBack->toArray(), [], 'rolled-back transaction should return the original document'); + sameArray($doc->toArray(), [], 'rolled-back transaction should not mutate the base document'); + } +); + +$rustMapped( + 'rust batch insert supports various scalar types', + $rustBatchSuite . 'batch-insert-various-scalar-types', + 'batch_insert_various_scalar_types', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'scalars', [ + 'str_val' => 'hello', + 'int_val' => 42, + 'uint_val' => 100, + 'float_val' => 5.14, + 'bool_true' => true, + 'bool_false' => false, + 'null_val' => null, + ]); + + sameArray( + $doc->toArray()['scalars'], + ['str_val' => 'hello', 'int_val' => 42, 'uint_val' => 100, 'float_val' => 5.14, 'bool_true' => true, 'bool_false' => false, 'null_val' => null], + 'batch insert should preserve scalar values' + ); + } +); + +$rustMapped( + 'rust batch insert is equivalent to individual nested operations', + $rustBatchSuite . 'batch-insert-equivalent-to-individual-ops', + 'batch_insert_equivalent_to_individual_ops', + function () use ($port): void { + $batch = $port->set($port->init('aaaaaa'), 'data', ['name' => 'test', 'count' => 5, 'items' => ['a', 'b', 'c']]); + $individual = $port->set($port->init('aaaaaa'), 'data', []); + $individual = $port->setNested($individual, ['data', 'name'], 'test'); + $individual = $port->setNested($individual, ['data', 'count'], 5); + $individual = $port->setNested($individual, ['data', 'items'], ['a', 'b', 'c']); + + sameArray($batch->toArray(), $individual->toArray(), 'batch insert should materialize the same shape as individual nested operations'); + } +); + +$rustMapped( + 'rust batch put overwrites a list element with nested structure', + $rustBatchSuite . 'batch-put-overwrite-with-nested-structure', + 'batch_put_overwrite_with_nested_structure', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'items', ['placeholder', 'keep']); + $doc = $port->setListElement($doc, 'items', 0, [ + 'name' => 'complex', + 'children' => [ + ['id' => 1], + ['id' => 2], + ], + ]); + + sameArray( + $doc->toArray()['items'], + [['name' => 'complex', 'children' => [['id' => 1], ['id' => 2]]], 'keep'], + 'batch put should overwrite one list element with a nested object and preserve the rest' + ); + } +); + +$rustMapped( + 'rust splice inserts scalar list values', + $rustBatchSuite . 'splice-insert-scalars', + 'splice_insert_scalars', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['a', 'd']); + $doc = $port->spliceList($doc, 'list', 1, 0, ['b', 'c']); + + sameArray($doc->toArray()['list'], ['a', 'b', 'c', 'd'], 'splice should insert scalar values in order'); + } +); + +$rustMapped( + 'rust splice inserts object list values', + $rustBatchSuite . 'splice-insert-objects', + 'splice_insert_objects', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->spliceList($doc, 'list', 0, 0, [['name' => 'alice'], ['name' => 'bob']]); + + sameArray($doc->toArray()['list'], [['name' => 'alice'], ['name' => 'bob']], 'splice should insert object values in order'); + } +); + +$rustMapped( + 'rust splice inserts mixed list values', + $rustBatchSuite . 'splice-insert-mixed', + 'splice_insert_mixed', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->spliceList($doc, 'list', 0, 0, ['hello', ['nested' => true], [1, 2]]); + + sameArray($doc->toArray()['list'], ['hello', ['nested' => true], [1, 2]], 'splice should insert mixed scalar map and list values'); + } +); + +$rustMapped( + 'rust splice deletes and inserts list values', + $rustBatchSuite . 'splice-delete-and-insert', + 'splice_delete_and_insert', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['a', 'b', 'c']); + $doc = $port->spliceList($doc, 'list', 1, 1, ['x', 'y']); + + sameArray($doc->toArray()['list'], ['a', 'x', 'y', 'c'], 'splice should delete and insert values in one operation'); + } +); + +$rustMapped( + 'rust splice merges concurrent list insertions', + $rustBatchSuite . 'splice-merges-correctly', + 'splice_merges_correctly', + function () use ($port): void { + $doc1 = $port->set($port->init('aaaaaa'), 'list', ['shared']); + $doc2 = $port->mergeDocuments($port->init('bbbbbb'), $doc1); + + $doc1 = $port->spliceList($doc1, 'list', 1, 0, [['from' => 'doc1']]); + $doc2 = $port->spliceList($doc2, 'list', 1, 0, [['from' => 'doc2']]); + $merged = $port->mergeDocuments($doc1, $doc2); + + same(count($merged->toArray()['list']), 3, 'merged splice list should contain the shared value and both inserts'); + same($merged->toArray()['list'][0], 'shared', 'merged splice list should retain the shared first element'); + same($port->getConflicts($merged, 'list'), null, 'concurrent splice insertions should merge without a root conflict'); + } +); + +$rustMapped( + 'rust batch insert creates an empty map object', + $rustBatchSuite . 'batch-insert-empty-map', + 'batch_insert_empty_map', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'empty', []); + + sameArray($doc->toArray(), ['empty' => []], 'batch insert should preserve an empty map container'); + } +); + +$rustMapped( + 'rust batch insert creates an empty list object', + $rustBatchSuite . 'batch-insert-empty-list', + 'batch_insert_empty_list', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'empty', []); + + sameArray($doc->toArray()['empty'], [], 'batch insert should preserve an empty list-like container'); + } +); + +$rustMapped( + 'rust batch insert creates an empty text object', + $rustBatchSuite . 'batch-insert-empty-text', + 'batch_insert_empty_text', + function () use ($port): void { + $sequence = 0; + $doc = $port->set($port->init('aaaaaa'), 'empty', TextValue::fromString('', 'aaaaaa', $sequence)); + + same($doc->text('empty')->toString(), '', 'batch insert should preserve an empty text object'); + same($doc->toArray()['empty'], '', 'empty text should materialize as an empty string'); + } +); + +$rustMapped( + 'rust batch insert supports a list of lists', + $rustBatchSuite . 'batch-insert-list-of-lists', + 'batch_insert_list_of_lists', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'matrix', [[1, 2], [3, 4]]); + + sameArray($doc->toArray()['matrix'], [[1, 2], [3, 4]], 'batch insert should preserve nested list containers'); + } +); + +$rustMapped( + 'rust batch init map creates flat root keys', + $rustBatchSuite . 'batch-init-map-flat', + 'batch_init_map_flat', + function () use ($port): void { + $doc = $port->from(['name' => 'test', 'count' => 42], 'aaaaaa'); + + sameArray($doc->toArray(), ['name' => 'test', 'count' => 42], 'batch init map should materialize flat root keys'); + } +); + +$rustMapped( + 'rust batch init map creates nested root objects', + $rustBatchSuite . 'batch-init-map-nested', + 'batch_init_map_nested', + function () use ($port): void { + $doc = $port->from([ + 'users' => [ + ['name' => 'alice'], + ['name' => 'bob'], + ], + 'meta' => ['version' => 1], + ], 'aaaaaa'); + + same($doc->toArray()['users'][0]['name'], 'alice', 'batch init map should preserve first nested list map'); + same($doc->toArray()['users'][1]['name'], 'bob', 'batch init map should preserve second nested list map'); + same($doc->toArray()['meta']['version'], 1, 'batch init map should preserve nested map scalar'); + } +); + +$rustMapped( + 'rust batch init map supports text values', + $rustBatchSuite . 'batch-init-map-with-text', + 'batch_init_map_with_text', + function () use ($port): void { + $sequence = 0; + $doc = $port->from(['greeting' => TextValue::fromString('hello world', 'aaaaaa', $sequence)], 'aaaaaa'); + + same($doc->text('greeting')->toString(), 'hello world', 'batch init map should preserve a text object'); + same($doc->toArray()['greeting'], 'hello world', 'batch init text should materialize as a string'); + } +); + +$rustMapped( + 'rust batch init map survives save and load', + $rustBatchSuite . 'batch-init-map-survives-save-load', + 'batch_init_map_survives_save_load', + function () use ($port): void { + $doc = $port->from(['name' => 'test', 'items' => [1, 2, 3]], 'aaaaaa'); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + + sameArray($loaded->toArray(), ['name' => 'test', 'items' => [1, 2, 3]], 'batch init map should survive native save/load'); + } +); + +$rustMapped( + 'rust batch init map is equivalent to individual root operations', + $rustBatchSuite . 'batch-init-map-equivalent-to-individual-ops', + 'batch_init_map_equivalent_to_individual_ops', + function () use ($port): void { + $batch = $port->from(['name' => 'test', 'count' => 5, 'items' => ['a', 'b', 'c']], 'aaaaaa'); + $individual = $port->set($port->init('aaaaaa'), 'name', 'test'); + $individual = $port->set($individual, 'count', 5); + $individual = $port->set($individual, 'items', ['a', 'b', 'c']); + + sameArray($batch->toArray(), $individual->toArray(), 'batch init map should materialize like individual root operations'); + } +); + +$rustMapped( + 'rust batch insert generates root container patches', + $rustBatchSuite . 'batch-insert-generates-patches', + 'batch_insert_generates_patches', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $before = $port->getHeads($doc); + $doc = $port->set($doc, 'data', [ + 'name' => 'test', + 'items' => [1, 2], + ]); + $patches = $port->diff($doc, $before, $port->getHeads($doc)); + + truthy($patches !== [], 'batch insert should generate native diff patches'); + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'put' + && ($patch['path'] ?? null) === ['data'] + && ($patch['value'] ?? null) === [] + )) === 1, + 'batch insert should include a root map container patch for data' + ); + } +); + +$rustMapped( + 'rust batch init map generates root patches', + $rustBatchSuite . 'batch-init-map-generates-patches', + 'batch_init_map_generates_patches', + function () use ($port): void { + $doc = $port->from([ + 'name' => 'test', + 'items' => [1, 2], + ], 'aaaaaa'); + $patches = $port->diff($doc, [], $port->getHeads($doc)); + + truthy($patches !== [], 'batch init map should generate native diff patches'); + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'put' + && ($patch['path'] ?? null) === ['name'] + && ($patch['value'] ?? null) === 'test' + )) === 1, + 'batch init map should include a root put patch for name' + ); + } +); + +$rustMapped( + 'rust batch insert text generates splice patches', + $rustBatchSuite . 'batch-insert-text-generates-splice-patch', + 'batch_insert_text_generates_splice_patch', + function () use ($port): void { + $doc = $port->init('aaaaaa'); + $before = $port->getHeads($doc); + $doc = $port->set($doc, 'data', ['greeting' => 'hi']); + $patches = $port->diff($doc, $before, $port->getHeads($doc)); + + truthy( + count(array_filter( + $patches, + static fn (array $patch): bool => ($patch['action'] ?? null) === 'splice' + && ($patch['path'] ?? null) === ['data', 'greeting', 0] + && ($patch['value'] ?? null) === 'hi' + )) === 1, + 'batch inserting nested text should report a native text splice patch' + ); + } +); + +$rustMapped( + 'rust splice deletes list values without insertion', + $rustBatchSuite . 'splice-delete-only', + 'splice_delete_only', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['a', 'b', 'c']); + $doc = $port->spliceList($doc, 'list', 1, 1, []); + + sameArray($doc->toArray()['list'], ['a', 'c'], 'splice should support delete-only list operations'); + } +); + +$rustMapped( + 'rust splice inserts text values into a list', + $rustBatchSuite . 'splice-with-text', + 'splice_with_text', + function () use ($port): void { + $sequence = 0; + $hello = TextValue::fromString('hello', 'aaaaaa', $sequence); + $world = TextValue::fromString('world', 'aaaaaa', $sequence); + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->spliceList($doc, 'list', 0, 0, [$hello, $world]); + + sameArray($doc->toArray()['list'], ['hello', 'world'], 'splice should preserve text values in list materialization'); + } +); + +$rustMapped( + 'rust splice inserts deeply nested list values', + $rustBatchSuite . 'splice-deeply-nested', + 'splice_deeply_nested', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->spliceList($doc, 'list', 0, 0, [[ + 'users' => [ + [ + 'name' => 'alice', + 'scores' => [10, 20], + ], + ], + ]]); + + same($doc->toArray()['list'][0]['users'][0]['name'], 'alice', 'splice should preserve deeply nested map values'); + sameArray($doc->toArray()['list'][0]['users'][0]['scores'], [10, 20], 'splice should preserve deeply nested list values'); + } +); + +$rustMapped( + 'rust splice survives save and load', + $rustBatchSuite . 'splice-survives-save-load', + 'splice_survives_save_load', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', []); + $doc = $port->spliceList($doc, 'list', 0, 0, [['key' => 'val'], 42]); + $loaded = $port->load($port->save($doc), 'bbbbbb'); + + sameArray($loaded->toArray()['list'], [['key' => 'val'], 42], 'splice should survive native save/load'); + } +); + +$rustMapped( + 'rust string migration converts map strings to text', + $rustConvertStringSuite . 'test-strings-in-maps-are-converted-to-text', + 'test_strings_in_maps_are_converted_to_text', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'somestring', 'hello'); + $loaded = $port->loadMigratingStringsToText($port->save($doc), 'aaaaaa'); + $payload = json_decode($port->save($loaded), true, 512, JSON_THROW_ON_ERROR); + + same($loaded->toArray()['somestring'], 'hello', 'migrated map text should materialize as the original string'); + same($payload['root']['somestring']['type'] ?? null, 'text', 'migrated map string should be stored as a text object'); + }, + 'rust/automerge/tests/convert_string_to_text.rs' +); + +$rustMapped( + 'rust string migration converts list strings to text', + $rustConvertStringSuite . 'test-strings-in-lists-are-converted-to-text', + 'test_strings_in_lists_are_converted_to_text', + function () use ($port): void { + $doc = $port->set($port->init('aaaaaa'), 'list', ['hello']); + $loaded = $port->loadMigratingStringsToText($port->save($doc), 'aaaaaa'); + $payload = json_decode($port->save($loaded), true, 512, JSON_THROW_ON_ERROR); + + same($loaded->toArray()['list'][0], 'hello', 'migrated list text should materialize as the original string'); + same($payload['root']['list']['value'][0]['type'] ?? null, 'text', 'migrated list string should be stored as a text object'); + }, + 'rust/automerge/tests/convert_string_to_text.rs' +); + +$rustMapped( + 'rust string migration does not grow an empty document', + $rustConvertStringSuite . 'test-does-not-add-size-when-strings-are-not-converted', + 'test_does_not_add_size_when_strings_are_not_converted', + function () use ($port): void { + $emptyDocument = $port->init('aaaaaa'); + $saved = $port->save($emptyDocument); + $loaded = $port->loadMigratingStringsToText($saved, 'aaaaaa'); + + same(strlen($port->save($loaded)), strlen($saved), 'loading an empty document with string migration should not change save size'); + }, + 'rust/automerge/tests/convert_string_to_text.rs' +); + +$wordpress( + 'Different top-level paragraph blocks edited by two users', + function () use ($port): void { + $base = '

Hello

' . "\n\n" + . '

World

'; + $alice = '

Hello Alice

' . "\n\n" + . '

World

'; + $bob = '

Hello

' . "\n\n" + . '

World Bob

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $aliceDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $alice]); + $bobDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'bbbbbb', 'postContent' => $bob]); + + $merged = $port->merge( + $base, + $port->encodeUpdate($aliceDocument), + $port->encodeUpdate($bobDocument) + ); + + truthy($merged['ok'] === true, 'different paragraph edits should merge without conflict'); + same( + $merged['postContent'], + '

Hello Alice

' . "\n\n" + . '

World Bob

', + 'merged post content should include both paragraph edits' + ); + } +); + +$wordpress( + 'Same paragraph block concurrent text insertion', + function () use ($port): void { + $base = '

Hello world

'; + $alice = '

Hello brave world

'; + $bob = '

Hello small world

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $aliceDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $alice]); + $bobDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'bbbbbb', 'postContent' => $bob]); + + $merged = $port->merge( + $base, + $port->encodeUpdate($aliceDocument), + $port->encodeUpdate($bobDocument) + ); + + truthy($merged['ok'] === true, 'same paragraph insertions should merge without conflict'); + same( + $merged['postContent'], + '

Hello brave small world

', + 'merged post content should include both same-paragraph insertions in deterministic order' + ); + } +); + +$wordpress( + 'One-sided edge insertion at start and end of a post', + function () use ($port): void { + $base = '

Middle

'; + $startInserted = '

Intro

' . "\n\n" . $base; + $endInserted = $base . "\n\n" . '

Outro

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $startDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $startInserted]); + $unchangedForStart = $port->applyLocalEdit($baseDocument, ['actorId' => 'bbbbbb', 'postContent' => $base]); + $startMerged = $port->merge( + $base, + $port->encodeUpdate($startDocument), + $port->encodeUpdate($unchangedForStart) + ); + + truthy($startMerged['ok'] === true, 'one-sided start insertion should merge without conflict'); + same($startMerged['postContent'], $startInserted, 'merged post content should preserve the inserted start block'); + + $endDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $endInserted]); + $unchangedForEnd = $port->applyLocalEdit($baseDocument, ['actorId' => 'bbbbbb', 'postContent' => $base]); + $endMerged = $port->merge( + $base, + $port->encodeUpdate($endDocument), + $port->encodeUpdate($unchangedForEnd) + ); + + truthy($endMerged['ok'] === true, 'one-sided end insertion should merge without conflict'); + same($endMerged['postContent'], $endInserted, 'merged post content should preserve the inserted end block'); + } +); + +$wordpress( + 'One-sided deletion of an unchanged block', + function () use ($port): void { + $first = '

Keep first

'; + $middle = '

Delete middle

'; + $last = '

Keep last

'; + $base = $first . "\n\n" . $middle . "\n\n" . $last; + $deleted = $first . "\n\n" . $last; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $deleteDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $deleted]); + $unchangedDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'bbbbbb', 'postContent' => $base]); + + $merged = $port->merge( + $base, + $port->encodeUpdate($deleteDocument), + $port->encodeUpdate($unchangedDocument) + ); + + truthy($merged['ok'] === true, 'one-sided block deletion should merge without conflict'); + same($merged['postContent'], $deleted, 'merged post content should preserve the deleted middle block'); + } +); + +$wordpress( + 'Server wp_update_post edit merges with editor edit on another block', + function () use ($port): void { + $first = '

Editor block

'; + $second = '

Server block

'; + $base = $first . "\n\n" . $second; + $editor = '

Editor block revised in Gutenberg

' + . "\n\n" . $second; + $server = $first . "\n\n" + . '

Server block revised by wp_update_post

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $editorDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $editor]); + $serverDocument = $port->applyServerPostUpdate($baseDocument, $server, 'cccccc'); + + $merged = $port->merge( + $base, + $port->encodeUpdate($editorDocument), + $port->encodeUpdate($serverDocument) + ); + + truthy($merged['ok'] === true, 'server and editor edits in separate blocks should merge without conflict'); + same( + $merged['postContent'], + '

Editor block revised in Gutenberg

' + . "\n\n" + . '

Server block revised by wp_update_post

', + 'merged post content should include both editor and server block updates' + ); + } +); + +$wordpress( + 'Server wp_update_post deletion merges with editor edit on another block', + function () use ($port): void { + $first = '

Editor block

'; + $second = '

Server block to delete

'; + $third = '

Shared tail

'; + $base = $first . "\n\n" . $second . "\n\n" . $third; + $editor = '

Editor block revised in Gutenberg

' + . "\n\n" . $second . "\n\n" . $third; + $server = $first . "\n\n" . $third; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $editorDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $editor]); + $serverDocument = $port->applyServerPostUpdate($baseDocument, $server, 'cccccc'); + + $merged = $port->merge( + $base, + $port->encodeUpdate($editorDocument), + $port->encodeUpdate($serverDocument) + ); + + truthy($merged['ok'] === true, 'server deletion and editor edit in separate blocks should merge without conflict'); + same( + $merged['postContent'], + '

Editor block revised in Gutenberg

' + . "\n\n" . $third, + 'merged post content should include the editor block update and server-side deletion' + ); + } +); + +$wordpress( + 'Server wp_update_post insertion merges with editor edit on another block', + function () use ($port): void { + $first = '

Existing intro

'; + $second = '

Editor block

'; + $inserted = '

Server inserted block

'; + $base = $first . "\n\n" . $second; + $editor = $first . "\n\n" + . '

Editor block revised in Gutenberg

'; + $server = $inserted . "\n\n" . $base; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $editorDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $editor]); + $serverDocument = $port->applyServerPostUpdate($baseDocument, $server, 'cccccc'); + + $merged = $port->merge( + $base, + $port->encodeUpdate($editorDocument), + $port->encodeUpdate($serverDocument) + ); + + truthy($merged['ok'] === true, 'server insertion and editor edit in separate blocks should merge without conflict'); + same( + $merged['postContent'], + $inserted . "\n\n" . $first . "\n\n" + . '

Editor block revised in Gutenberg

', + 'merged post content should include the server insertion and editor block update' + ); + } +); + +$wordpress( + 'Identical editor and wp_update_post replacements merge without conflict', + function () use ($port): void { + $base = '

Shared block

'; + $updated = '

Shared block revised consistently

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $editorDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $updated]); + $serverDocument = $port->applyServerPostUpdate($baseDocument, $updated, 'cccccc'); + + $merged = $port->merge( + $base, + $port->encodeUpdate($editorDocument), + $port->encodeUpdate($serverDocument) + ); + + truthy($merged['ok'] === true, 'identical overlapping editor and server replacements should merge without conflict'); + same($merged['postContent'], $updated, 'merged post content should keep the shared replacement exactly once'); + } +); + +$wordpress( + 'Server wp_update_post no-op preserves a Gutenberg editor edit', + function () use ($port): void { + $base = '

Original editor block

'; + $editor = '

Original editor block revised in Gutenberg

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $editorDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $editor]); + $serverDocument = $port->applyServerPostUpdate($baseDocument, $base, 'cccccc'); + + $merged = $port->merge( + $base, + $port->encodeUpdate($editorDocument), + $port->encodeUpdate($serverDocument) + ); + + truthy($merged['ok'] === true, 'server no-op should not conflict with a Gutenberg editor edit'); + same($merged['postContent'], $editor, 'merged post content should preserve the editor edit when the server update is a no-op'); + } +); + +$wordpress( + 'Overlapping editor and wp_update_post edits report a merge conflict', + function () use ($port): void { + $base = '

Shared block

'; + $editor = '

Gutenberg block

'; + $server = '

Server block

'; + + $baseDocument = $port->createDocument($base, ['actorId' => 'aaaaaa']); + $editorDocument = $port->applyLocalEdit($baseDocument, ['actorId' => 'aaaaaa', 'postContent' => $editor]); + $serverDocument = $port->applyServerPostUpdate($baseDocument, $server, 'cccccc'); + + $merged = $port->merge( + $base, + $port->encodeUpdate($editorDocument), + $port->encodeUpdate($serverDocument) + ); + + truthy($merged['ok'] === false, 'overlapping editor and server edits should not be silently merged'); + same($merged['conflict']['reason'] ?? null, 'overlapping-post-content-edits', 'overlapping post-content edits should report the explicit conflict reason'); + } +); + +$activeTests = array_values(array_filter( + $tests, + static fn (array $test): bool => ($test['mappedFrom']['upstreamStatus'] ?? 'active') === 'active' +)); +$optionalRegisteredTests = array_values(array_filter( + $tests, + static fn (array $test): bool => ($test['mappedFrom']['upstreamStatus'] ?? 'active') !== 'active' +)); +$optionalPendingTests = array_values(array_filter( + $tests, + static fn (array $test): bool => ($test['mappedFrom']['upstreamStatus'] ?? 'active') === 'pending' +)); +$optionalIgnoredTests = array_values(array_filter( + $tests, + static fn (array $test): bool => ($test['mappedFrom']['upstreamStatus'] ?? 'active') === 'ignored' +)); +$passing = count(array_filter($activeTests, static fn (array $test): bool => $test['passed'])); +$total = count($activeTests); +$registeredPassing = count(array_filter($tests, static fn (array $test): bool => $test['passed'])); +$registeredTotal = count($tests); +$optionalPassing = count(array_filter($optionalRegisteredTests, static fn (array $test): bool => $test['passed'])); +$optionalPendingPassing = count(array_filter($optionalPendingTests, static fn (array $test): bool => $test['passed'])); +$optionalIgnoredPassing = count(array_filter($optionalIgnoredTests, static fn (array $test): bool => $test['passed'])); +$rustMappedTests = array_filter( + $activeTests, + static fn (array $test): bool => is_string($test['mappedFrom']['id'] ?? null) + && str_starts_with($test['mappedFrom']['id'], 'rust:') +); +$rustMappedTotal = count($rustMappedTests); +$rustPassing = count(array_filter($rustMappedTests, static fn (array $test): bool => $test['passed'])); +$javascriptMappedTotal = $total - $rustMappedTotal; +$javascriptPassing = $passing - $rustPassing; +$wordpressPassing = count(array_filter($wordpressScenarios, static fn (array $test): bool => $test['passed'])); +$wordpressTotal = 10; +$knownJavascriptTests = 312; +$registeredJavascriptTests = 313; +$knownRustTests = 368; +$registeredRustTests = 375; +$knownUpstreamTests = $knownJavascriptTests + $knownRustTests; +$registeredUpstreamTests = $registeredJavascriptTests + $registeredRustTests; +$activeParityReached = $passing === $knownUpstreamTests && $total === $knownUpstreamTests; +$registeredParityReached = $registeredPassing === $registeredTotal && $registeredTotal === $registeredUpstreamTests; +$status = [ + 'library' => 'automerge', + 'phase' => 'runtime-active-upstream-parity', + 'passPercent' => round(($passing / $knownUpstreamTests) * 100, 2), + 'mappedPassPercent' => $total === 0 ? 0 : round(($passing / $total) * 100, 2), + 'passingTests' => $passing, + 'failingTests' => $total - $passing, + 'skippedTests' => 0, + 'mappedUpstreamTests' => $total, + 'registeredMappedUpstreamTests' => $registeredTotal, + 'registeredMappedPassingTests' => $registeredPassing, + 'optionalMappedUpstreamTests' => count($optionalRegisteredTests), + 'optionalMappedPassingTests' => $optionalPassing, + 'optionalPendingMappedTests' => count($optionalPendingTests), + 'optionalPendingPassingTests' => $optionalPendingPassing, + 'optionalIgnoredMappedTests' => count($optionalIgnoredTests), + 'optionalIgnoredPassingTests' => $optionalIgnoredPassing, + 'totalKnownUpstreamTests' => $knownUpstreamTests, + 'registeredTotalUpstreamTests' => $registeredUpstreamTests, + 'unmappedKnownUpstreamTests' => $knownUpstreamTests - $total, + 'upstreamSuiteBreakdown' => [ + 'benchmarkArtifact' => 'UPSTREAM_BENCHMARK.json', + 'manifestArtifact' => 'UPSTREAM_TEST_MANIFEST.json', + 'countMethod' => 'runtime-runner-counts', + 'javascriptMocha' => [ + 'mapped' => $javascriptMappedTotal, + 'passing' => $javascriptPassing, + 'knownActive' => $knownJavascriptTests, + 'registered' => $registeredJavascriptTests, + 'pending' => 1, + 'pendingMapped' => count($optionalPendingTests), + 'pendingPassing' => $optionalPendingPassing, + ], + 'rustAutomerge' => [ + 'mapped' => $rustMappedTotal, + 'passing' => $rustPassing, + 'knownActive' => $knownRustTests, + 'registered' => $registeredRustTests, + 'ignored' => 7, + 'ignoredMapped' => count($optionalIgnoredTests), + 'ignoredPassing' => $optionalIgnoredPassing, + ], + 'combined' => [ + 'mapped' => $total, + 'passing' => $passing, + 'knownActive' => $knownUpstreamTests, + 'registered' => $registeredUpstreamTests, + ], + ], + 'wordpressScenariosPassing' => $wordpressPassing, + 'wordpressScenariosTotal' => $wordpressTotal, + 'currentTask' => $activeParityReached + ? ($registeredParityReached + ? 'Runtime-active and registered upstream parity reached; no unmapped active, pending, or ignored manifest rows remain.' + : 'Runtime-active upstream parity reached; map remaining optional registered pending/ignored rows outside the active denominator.') + : 'Fix failing first-slice Automerge map/text tests before expanding coverage.', + 'denominatorNote' => 'The older static 724 audit has been superseded by successful runtime upstream runs: 312 JavaScript passing plus 1 pending, and 368 Rust passing plus 7 ignored. Active parity uses 680; the registered manifest total is 688.', + 'blocker' => null, + 'upstream' => [ + 'url' => 'https://github.com/automerge/automerge', + 'submodulePath' => 'upstream/automerge', + 'referenceCommit' => submoduleHeadCommit(__DIR__ . '/../upstream/automerge'), + 'canonicalJavascriptTestCommand' => 'cd upstream/automerge/javascript && npm test', + 'canonicalRustCoreTestCommand' => 'cd upstream/automerge/rust && cargo test -p automerge', + ], + 'lastTestCommand' => 'composer test', + 'lastTestAt' => gmdate('c'), + 'lastCommit' => null, + 'tests' => $tests, + 'wordpressScenarios' => $wordpressScenarios, + 'unmappedNextTargets' => [], +]; + +file_put_contents(__DIR__ . '/../PORTING_STATUS.json', json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); +echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; +exit($registeredPassing === $registeredTotal && $wordpressPassing === count($wordpressScenarios) ? 0 : 1); diff --git a/src/wp-includes/automerge-php/tests/upstream-test-map.md b/src/wp-includes/automerge-php/tests/upstream-test-map.md new file mode 100644 index 0000000000000..d60f62ec11558 --- /dev/null +++ b/src/wp-includes/automerge-php/tests/upstream-test-map.md @@ -0,0 +1,45 @@ +# Upstream Test Map + +Reference checkout: `upstream/automerge`. + +Canonical upstream commands discovered for this checkpoint: + +```bash +cd upstream/automerge/javascript && npm test +cd upstream/automerge/rust && cargo test -p automerge +``` + +The first mapped PHP slice targets user-visible materialization behavior from +the JavaScript test suite before binary change decoding is ported. The PHP API +is not pretending to be the JavaScript proxy API; each row maps the upstream +behavior into native PHP method calls. + +| PHP test | Upstream source | +| --- | --- | +| init clone and free creates independent PHP document values | `javascript/test/basic_test.ts:12` `should init clone and free` | +| root map set/read materializes PHP array in insertion order | `javascript/test/basic_test.ts:44` `handle basic set and read on root object` | +| root map delete keeps only the live property across repeated changes | `javascript/test/basic_test.ts:66` `should be able to insert and delete a large number of properties` | +| root map overwrites retain the last scalar value | `javascript/test/basic_test.ts:183` `handle overwrites to values` | +| object values materialize through root map set | `javascript/test/basic_test.ts:200` `handle set with object value` | +| list creation materializes an empty PHP list | `javascript/test/basic_test.ts:210` `handle simple list creation` | +| simple list values can be read and replaced | `javascript/test/basic_test.ts:216` `handle simple lists` | +| text insertion exposes length, index access, and string materialization | `javascript/test/text_test.ts:17` `should support insertion` | +| text deletion removes the visible character at the splice range | `javascript/test/text_test.ts:25` `should support deletion` | +| text zero-length splice after deletion is a no-op | `javascript/test/text_test.ts:36` `should support implicit and explicit deletion` | +| concurrent text insertion deterministically preserves both branches | `javascript/test/text_test.ts:48` `should handle concurrent insertion` | +| text and scalar root operations can happen in the same document state | `javascript/test/text_test.ts:60` `should handle text and other ops in the same change` | +| document JSON encoding serializes text as a plain string | `javascript/test/text_test.ts:70` `should serialize to JSON as a simple string` | +| text can be modified after assignment to a document root key | `javascript/test/text_test.ts:77` `should allow modification after an object is assigned to a document` | +| unicode text values survive root materialization | `javascript/test/text_test.ts:115` `should support unicode when creating text` | +| from initializes text values with string length and index access | `javascript/test/text_test.ts:95` `should initialize text in Automerge.from()` | +| from encodes the initial root as one replayable native change | `javascript/test/text_test.ts:105` `should encode the initial value as a change` | +| updateText merges non-overlapping replacements from concurrent actors | `javascript/test/text_test.ts:132` `should calculate a diff when updating text` | +| change metadata since heads matches decoded native changes | `javascript/test/basic_test.ts:300` `get change metadata` | +| emptyChange advances document heads with a new hash | `javascript/test/basic_test.ts:396` `should generate a hash` | +| hasHeads returns true for heads present in the document history | `javascript/test/basic_test.ts:685` `should return true if the document in question has all the heads` | +| hasHeads returns false for heads absent from another document | `javascript/test/basic_test.ts:692` `should return false if the document does not have the heads` | +| native save/load round trip hydrates a materialized document | `javascript/test/basic_test.ts:578` `can load a doc without checking the heads` | + +Next mapping targets are full change encoding/applyChanges beyond initial root +snapshots, head/view history, nested object IDs, list splices, and Rust binary +save/load fixtures. diff --git a/src/wp-includes/automerge-php/tools/generate-upstream-benchmark.php b/src/wp-includes/automerge-php/tools/generate-upstream-benchmark.php new file mode 100644 index 0000000000000..92de608da669f --- /dev/null +++ b/src/wp-includes/automerge-php/tools/generate-upstream-benchmark.php @@ -0,0 +1,540 @@ + ($test['passed'] ?? false) ? 'ported-passing' : 'ported-failing', + 'phpTest' => 'tests/run.php:' . ($test['name'] ?? 'mapped upstream test'), + 'upstreamStatus' => $mapped['upstreamStatus'] ?? 'active', + ]; + + if (isset($mapped['file'], $mapped['line'])) { + $portedBySource[$mapped['file'] . ':' . $mapped['line']] = $ported; + } + + if (isset($mapped['id']) && is_string($mapped['id'])) { + $portedByRustId[$mapped['id']] = $ported; + } +} + +[$jsEntries, $jsSkipped, $jsPending] = javascriptManifest($root, $portedBySource); +[$rustEntries, $rustIgnored] = rustManifest($root, $portedByRustId); + +$tests = array_merge($jsEntries, $rustEntries); +$portedPassing = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'ported-passing')); +$portedFailing = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'ported-failing')); +$portedPendingPassing = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'ported-pending-passing')); +$portedPendingFailing = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'ported-pending-failing')); +$portedIgnoredPassing = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'ported-ignored-passing')); +$portedIgnoredFailing = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'ported-ignored-failing')); +$todoUnported = count(array_filter($tests, static fn (array $test): bool => $test['status'] === 'todo-unported')); +$registeredTotal = count($tests); + +if ($registeredTotal !== $registeredUpstreamTests) { + throw new RuntimeException('Expected registered manifest total ' . $registeredUpstreamTests . ', generated ' . $registeredTotal); +} + +if (count($jsEntries) !== $jsRegisteredTests || count($jsPending) !== $jsPendingTests) { + throw new RuntimeException('Expected JavaScript runtime count ' . $jsRegisteredTests . ' with ' . $jsPendingTests . ' pending.'); +} + +if (count($rustEntries) !== $rustRegisteredTests || count($rustIgnored) !== $rustIgnoredTests) { + throw new RuntimeException('Expected Rust runtime count ' . $rustRegisteredTests . ' with ' . $rustIgnoredTests . ' ignored.'); +} + +$manifest = [ + 'library' => 'automerge', + 'generatedAt' => $generatedAt, + 'upstreamCommit' => $upstreamCommit, + 'total' => $registeredTotal, + 'registeredTotal' => $registeredTotal, + 'activeTotal' => $activeUpstreamTests, + 'portedPassing' => $portedPassing, + 'portedFailing' => $portedFailing, + 'portedPendingPassing' => $portedPendingPassing, + 'portedPendingFailing' => $portedPendingFailing, + 'portedIgnoredPassing' => $portedIgnoredPassing, + 'portedIgnoredFailing' => $portedIgnoredFailing, + 'todoUnported' => $todoUnported, + 'upstreamPending' => count($jsPending), + 'upstreamIgnored' => count($rustIgnored), + 'tests' => $tests, +]; + +$benchmark = [ + 'library' => 'automerge', + 'generatedAt' => $generatedAt, + 'upstreamCommit' => $upstreamCommit, + 'status' => $failedUpstreamTests === 0 ? 'passed-with-pending-ignored' : 'failed-with-pending-ignored', + 'benchmarkScope' => 'full upstream default suite: JavaScript npm test plus Rust cargo test -p automerge', + 'countMethod' => 'runtime-runner-counts', + 'totalRan' => $activeUpstreamTests, + 'registeredTotal' => $registeredTotal, + 'activeTotal' => $activeUpstreamTests, + 'passed' => $runtimeCounts['javascript']['passed'] + $runtimeCounts['rust']['passed'], + 'failed' => $failedUpstreamTests, + 'skipped' => $skippedUpstreamTests, + 'pending' => $jsPendingTests, + 'ignored' => $rustIgnoredTests, + 'commands' => [ + [ + 'command' => 'cd upstream/automerge/javascript && npm run build', + 'cwd' => '.', + 'status' => 'passed', + 'exitCode' => 0, + 'totalRan' => 0, + 'passed' => 0, + 'failed' => 0, + 'skipped' => 0, + 'outputArtifact' => 'artifacts/upstream-benchmark/javascript-npm-build-current.txt', + 'notes' => 'Build passed with PATH preferring wasm-bindgen 0.2.121 and the installed wasm32-unknown-unknown Rust target.', + ], + [ + 'command' => 'cd upstream/automerge/javascript && npm test', + 'cwd' => '.', + 'status' => $jsFailedTests === 0 ? 'passed-with-pending' : 'failed-with-pending', + 'exitCode' => $jsFailedTests === 0 ? 0 : 1, + 'totalRan' => $jsActiveTests, + 'registeredTotal' => $jsRegisteredTests, + 'passed' => $runtimeCounts['javascript']['passed'], + 'failed' => $jsFailedTests, + 'skipped' => $jsPendingTests, + 'pending' => $jsPendingTests, + 'outputArtifact' => 'artifacts/upstream-benchmark/javascript-npm-test-current.txt', + 'notes' => "Runtime result: {$runtimeCounts['javascript']['passed']} passing, {$jsFailedTests} failing, and {$jsPendingTests} pending.", + ], + [ + 'command' => 'cd upstream/automerge/rust && cargo test -p automerge', + 'cwd' => '.', + 'status' => $rustFailedTests === 0 ? 'passed-with-ignored' : 'failed-with-ignored', + 'exitCode' => $rustFailedTests === 0 ? 0 : 1, + 'totalRan' => $rustActiveTests, + 'registeredTotal' => $rustRegisteredTests, + 'passed' => $runtimeCounts['rust']['passed'], + 'failed' => $rustFailedTests, + 'skipped' => $rustIgnoredTests, + 'ignored' => $rustIgnoredTests, + 'outputArtifact' => 'artifacts/upstream-benchmark/rust-cargo-test-current.txt', + 'notes' => "Runtime result: {$runtimeCounts['rust']['passed']} passing, {$rustFailedTests} failing, and {$rustIgnoredTests} ignored.", + ], + ], + 'blocker' => null, + 'runtimeInventory' => [ + 'javascriptActive' => $jsActiveTests, + 'javascriptRegistered' => $jsRegisteredTests, + 'javascriptPassing' => $runtimeCounts['javascript']['passed'], + 'javascriptFailing' => $jsFailedTests, + 'javascriptPending' => $jsPendingTests, + 'javascriptExcludedSkippedDeclarations' => count($jsSkipped), + 'rustActive' => $rustActiveTests, + 'rustRegistered' => $rustRegisteredTests, + 'rustPassing' => $runtimeCounts['rust']['passed'], + 'rustFailing' => $rustFailedTests, + 'rustIgnored' => $rustIgnoredTests, + 'combinedActive' => $activeUpstreamTests, + 'combinedRegistered' => $registeredUpstreamTests, + ], + 'notes' => "The benchmark derives counts from the current raw upstream artifacts: JavaScript npm test reported {$runtimeCounts['javascript']['passed']} passing, {$jsFailedTests} failing, and {$jsPendingTests} pending; Rust cargo test -p automerge reported {$runtimeCounts['rust']['passed']} passing, {$rustFailedTests} failing, and {$rustIgnoredTests} ignored. Active progress uses the {$activeUpstreamTests} active-test denominator, while the manifest registers {$registeredUpstreamTests} total units including pending/ignored entries.", +]; + +file_put_contents($root . '/UPSTREAM_TEST_MANIFEST.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); +file_put_contents($root . '/UPSTREAM_BENCHMARK.json', json_encode($benchmark, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); +file_put_contents($root . '/UPSTREAM_BENCHMARK.md', benchmarkMarkdown($benchmark, $manifest, $jsSkipped)); +file_put_contents( + $root . '/artifacts/upstream-benchmark/static-inventory.txt', + "Automerge upstream runtime inventory\n" + . "Generated: {$generatedAt}\n" + . "Upstream commit: {$upstreamCommit}\n" + . "JavaScript active tests: {$jsActiveTests}\n" + . "JavaScript registered tests: {$jsRegisteredTests}\n" + . "JavaScript passing tests: {$runtimeCounts['javascript']['passed']}\n" + . "JavaScript failing tests: {$jsFailedTests}\n" + . "JavaScript pending tests: {$jsPendingTests}\n" + . "JavaScript excluded skipped declarations: " . count($jsSkipped) . "\n" + . "Rust active tests: {$rustActiveTests}\n" + . "Rust registered tests: {$rustRegisteredTests}\n" + . "Rust passing tests: {$runtimeCounts['rust']['passed']}\n" + . "Rust failing tests: {$rustFailedTests}\n" + . "Rust ignored tests: {$rustIgnoredTests}\n" + . "Combined active denominator: {$activeUpstreamTests}\n" + . "Combined registered total: {$registeredUpstreamTests}\n" +); + +echo "Generated {$registeredTotal} manifest entries with {$portedPassing} active ported-passing entries, {$portedPendingPassing} pending ported-passing entries, and {$portedIgnoredPassing} ignored ported-passing entries.\n"; + +function shelllessSubmoduleHead(string $path): ?string +{ + $gitFile = $path . '/.git'; + if (! is_file($gitFile)) { + return null; + } + + $gitPointer = trim((string) file_get_contents($gitFile)); + if (! str_starts_with($gitPointer, 'gitdir:')) { + return null; + } + + $gitDir = trim(substr($gitPointer, strlen('gitdir:'))); + $gitDirPath = realpath(dirname($gitFile) . '/' . $gitDir); + if ($gitDirPath === false || ! is_file($gitDirPath . '/HEAD')) { + return null; + } + + $head = trim((string) file_get_contents($gitDirPath . '/HEAD')); + if (! str_starts_with($head, 'ref:')) { + return $head !== '' ? $head : null; + } + + $refFile = $gitDirPath . '/' . trim(substr($head, strlen('ref:'))); + if (! is_file($refFile)) { + return null; + } + + $commit = trim((string) file_get_contents($refFile)); + + return $commit !== '' ? $commit : null; +} + +/** + * @return array{ + * javascript:array{passed:int,failed:int,pending:int,active:int,registered:int}, + * rust:array{passed:int,failed:int,ignored:int,active:int,registered:int} + * } + */ +function runtimeCounts(string $root): array +{ + $jsOutput = (string) file_get_contents($root . '/artifacts/upstream-benchmark/javascript-npm-test-current.txt'); + $rustOutput = (string) file_get_contents($root . '/artifacts/upstream-benchmark/rust-cargo-test-current.txt'); + + if (! preg_match('/(\d+) passing\b/', $jsOutput, $jsPassingMatches)) { + throw new RuntimeException('Unable to parse JavaScript passing count from current npm test artifact.'); + } + + $jsPassed = (int) $jsPassingMatches[1]; + $jsFailed = preg_match('/(\d+) failing\b/', $jsOutput, $jsFailingMatches) === 1 ? (int) $jsFailingMatches[1] : 0; + $jsPending = preg_match('/(\d+) pending\b/', $jsOutput, $jsPendingMatches) === 1 ? (int) $jsPendingMatches[1] : 0; + + $rustPassed = 0; + $rustFailed = 0; + $rustIgnored = 0; + if (preg_match_all('/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored\b/', $rustOutput, $rustMatches, PREG_SET_ORDER) === 0) { + throw new RuntimeException('Unable to parse Rust test-result summaries from current cargo test artifact.'); + } + + foreach ($rustMatches as $match) { + $rustPassed += (int) $match[1]; + $rustFailed += (int) $match[2]; + $rustIgnored += (int) $match[3]; + } + + return [ + 'javascript' => [ + 'passed' => $jsPassed, + 'failed' => $jsFailed, + 'pending' => $jsPending, + 'active' => $jsPassed + $jsFailed, + 'registered' => $jsPassed + $jsFailed + $jsPending, + ], + 'rust' => [ + 'passed' => $rustPassed, + 'failed' => $rustFailed, + 'ignored' => $rustIgnored, + 'active' => $rustPassed + $rustFailed, + 'registered' => $rustPassed + $rustFailed + $rustIgnored, + ], + ]; +} + +/** + * @param array $portedBySource + * @return array{0:list>,1:list>,2:list>} + */ +function javascriptManifest(string $root, array $portedBySource): array +{ + $entries = []; + $skipped = []; + $pending = []; + foreach (glob($root . '/upstream/automerge/javascript/test/*.ts') ?: [] as $file) { + $relative = substr($file, strlen($root . '/upstream/automerge/')); + $lines = uncommentedLines($file); + if ($lines === false) { + continue; + } + + foreach ($lines as $offset => $line) { + if (! preg_match('/\bit(?P\.skip)?\s*\(\s*(["\'])(?P.*?)\2/', $line, $matches)) { + continue; + } + + $lineNumber = $offset + 1; + $title = $matches['title']; + if (($matches['skip'] ?? '') === '.skip') { + $entry = [ + 'source' => $relative . ':' . $lineNumber, + 'title' => $title, + ]; + if ($title === 'should sync three nodes') { + $key = $relative . ':' . $lineNumber; + $ported = $portedBySource[$key] ?? null; + $status = 'upstream-pending'; + if ($ported !== null) { + $status = $ported['status'] === 'ported-passing' + ? 'ported-pending-passing' + : 'ported-pending-failing'; + } + $pending[] = $entry; + $entries[] = [ + 'id' => 'js:' . $key . ':' . stableSlug($title), + 'source' => 'upstream/automerge/' . $key, + 'status' => $status, + 'phpTest' => $ported['phpTest'] ?? null, + 'notes' => $ported === null + ? 'Registered upstream JavaScript pending test from runtime npm test output.' + : 'Upstream JavaScript pending test has optional native PHP parity coverage.', + ]; + continue; + } + + $skipped[] = $entry; + continue; + } + + $key = $relative . ':' . $lineNumber; + $ported = $portedBySource[$key] ?? null; + $entries[] = [ + 'id' => 'js:' . $key . ':' . stableSlug($title), + 'source' => 'upstream/automerge/' . $key, + 'status' => $ported['status'] ?? 'todo-unported', + 'phpTest' => $ported['phpTest'] ?? null, + 'notes' => $ported === null ? 'Unported active JavaScript mocha test from runtime inventory.' : 'Mapped PHP parity test passes.', + ]; + } + } + + return [$entries, $skipped, $pending]; +} + +/** + * @param array<string,array{status:string,phpTest:string}> $portedByRustId + * @return array{0:list<array<string,mixed>>,1:list<array<string,mixed>>} + */ +function rustManifest(string $root, array $portedByRustId): array +{ + $entries = []; + $ignored = []; + $suite = 'unknown'; + $lines = file($root . '/artifacts/upstream-benchmark/rust-cargo-test-current.txt', FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new RuntimeException('Unable to read Rust runtime benchmark artifact.'); + } + + foreach ($lines as $line) { + if (preg_match('/^ Running (.+)$/', $line, $matches)) { + $suite = $matches[1]; + continue; + } + + if (preg_match('/^ Doc-tests (.+)$/', $line, $matches)) { + $suite = 'doc-tests:' . $matches[1]; + continue; + } + + if (! preg_match('/^test (.+) \.\.\. (ok|ignored)$/', $line, $matches)) { + continue; + } + + $name = $matches[1]; + $id = 'rust:' . stableSlug($suite) . ':' . stableSlug($name); + $ignoredStatus = $matches[2] === 'ignored'; + $ported = $portedByRustId[$id] ?? null; + if ($ignoredStatus) { + if (($ported['upstreamStatus'] ?? null) === 'ignored') { + $status = $ported['status'] === 'ported-passing' + ? 'ported-ignored-passing' + : 'ported-ignored-failing'; + } else { + $ported = null; + $status = 'upstream-ignored'; + } + } else { + $status = $ported['status'] ?? 'todo-unported'; + } + $entry = [ + 'id' => $id, + 'source' => 'upstream/automerge/rust runtime:' . $suite, + 'status' => $status, + 'phpTest' => $ported['phpTest'] ?? null, + 'notes' => $ignoredStatus + ? ($ported === null + ? 'Registered upstream Rust ignored test from runtime cargo test output.' + : 'Upstream Rust ignored test has optional native PHP parity coverage.') + : ($ported === null ? 'Unported active Rust automerge crate test from runtime cargo test output.' : 'Mapped PHP parity test passes.'), + ]; + $entries[] = $entry; + if ($ignoredStatus) { + $ignored[] = $entry; + } + } + + usort($entries, static fn (array $left, array $right): int => strcmp($left['id'], $right['id'])); + + return [$entries, $ignored]; +} + +/** + * @return list<string>|false + */ +function uncommentedLines(string $file): array|false +{ + $rawLines = file($file, FILE_IGNORE_NEW_LINES); + if ($rawLines === false) { + return false; + } + + $inBlock = false; + $lines = []; + foreach ($rawLines as $rawLine) { + $line = $rawLine; + $code = ''; + while ($line !== '') { + if ($inBlock) { + $end = strpos($line, '*/'); + if ($end === false) { + $line = ''; + continue; + } + + $line = substr($line, $end + 2); + $inBlock = false; + continue; + } + + $start = strpos($line, '/*'); + if ($start === false) { + $code .= $line; + $line = ''; + continue; + } + + $code .= substr($line, 0, $start); + $end = strpos($line, '*/', $start + 2); + if ($end === false) { + $inBlock = true; + $line = ''; + continue; + } + + $line = substr($line, $end + 2); + } + + $lines[] = preg_replace('/\/\/.*$/', '', $code) ?? $code; + } + + return $lines; +} + +function stableSlug(string $title): string +{ + $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $title) ?? ''); + $slug = trim($slug, '-'); + + return $slug !== '' ? $slug : substr(hash('sha256', $title), 0, 12); +} + +/** + * @param array<string,mixed> $benchmark + * @param array<string,mixed> $manifest + * @param list<array<string,string>> $jsSkipped + */ +function benchmarkMarkdown(array $benchmark, array $manifest, array $jsSkipped): string +{ + $commands = $benchmark['commands']; + $inventory = $benchmark['runtimeInventory']; + $activeTotal = $manifest['activeTotal']; + $registeredTotal = $manifest['registeredTotal']; + $commandRows = rtrim(implode('', array_map( + static fn (array $command): string => "| `{$command['command']}` | `{$command['cwd']}` | `{$command['status']}` | {$command['exitCode']} | `{$command['outputArtifact']}` |\n", + $commands + ))); + + return <<<MD +# Upstream Benchmark: Automerge + +Generated: {$benchmark['generatedAt']} + +Upstream commit: `{$benchmark['upstreamCommit']}` + +## Result + +Status: `{$benchmark['status']}` + +Count method: `{$benchmark['countMethod']}` + +The canonical upstream runners now complete in this environment. Active +progress uses passing/runnable tests as the denominator, while the manifest also +registers upstream pending/ignored entries: + +| Surface | Active | Pending/Ignored | Registered | +| --- | ---: | ---: | ---: | +| JavaScript mocha suite | {$inventory['javascriptActive']} | {$inventory['javascriptPending']} | {$inventory['javascriptRegistered']} | +| Rust automerge crate | {$inventory['rustActive']} | {$inventory['rustIgnored']} | {$inventory['rustRegistered']} | +| Combined | {$activeTotal} | {$benchmark['skipped']} | {$registeredTotal} | + +`UPSTREAM_TEST_MANIFEST.json` contains {$registeredTotal} entries, of which +{$activeTotal} are active. Current PHP ported-passing entries: +{$manifest['portedPassing']} active, {$manifest['portedPendingPassing']} optional upstream-pending, +and {$manifest['portedIgnoredPassing']} optional upstream-ignored. + +## Runner Attempts + +| Command | CWD | Status | Exit | Output | +| --- | --- | --- | ---: | --- | +{$commandRows} + +## Runtime Artifacts + +- `artifacts/upstream-benchmark/javascript-npm-build-current.txt` +- `artifacts/upstream-benchmark/javascript-npm-test-current.txt` +- `artifacts/upstream-benchmark/rust-cargo-test-current.txt` + +## Notes + +{$benchmark['notes']} + +Skipped JavaScript declarations excluded from the registered runtime total: + +MD + . implode('', array_map( + static fn (array $skip): string => "- `{$skip['source']}` {$skip['title']}\n", + $jsSkipped + )); +} diff --git a/src/wp-includes/blocks/accordion-item.php b/src/wp-includes/blocks/accordion-item.php index a16a1426e346d..8530b34de12d8 100644 --- a/src/wp-includes/blocks/accordion-item.php +++ b/src/wp-includes/blocks/accordion-item.php @@ -39,7 +39,6 @@ function block_core_accordion_item_render( array $attributes, string $content ): if ( $p->next_tag( array( 'class_name' => 'wp-block-accordion-heading__toggle' ) ) ) { $p->set_attribute( 'data-wp-on--click', 'actions.toggle' ); - $p->set_attribute( 'data-wp-on--keydown', 'actions.handleKeyDown' ); $p->set_attribute( 'id', $unique_id ); $p->set_attribute( 'aria-controls', $unique_id . '-panel' ); $p->set_attribute( 'data-wp-bind--aria-expanded', 'state.isOpen' ); diff --git a/src/wp-includes/blocks/accordion-item/block.json b/src/wp-includes/blocks/accordion-item/block.json index 74bfddde0e68b..22987b9558a5f 100644 --- a/src/wp-includes/blocks/accordion-item/block.json +++ b/src/wp-includes/blocks/accordion-item/block.json @@ -16,6 +16,7 @@ "interactivity": true, "spacing": { "margin": [ "top", "bottom" ], + "padding": true, "blockGap": true }, "__experimentalBorder": { diff --git a/src/wp-includes/blocks/blocks-json.php b/src/wp-includes/blocks/blocks-json.php index e905b113502ac..e35268ffe6c74 100644 --- a/src/wp-includes/blocks/blocks-json.php +++ b/src/wp-includes/blocks/blocks-json.php @@ -224,6 +224,7 @@ 'top', 'bottom' ), + 'padding' => true, 'blockGap' => true ), '__experimentalBorder' => array( @@ -759,9 +760,6 @@ ), 'gradient' => array( 'type' => 'string' - ), - 'width' => array( - 'type' => 'number' ) ), 'supports' => array( @@ -777,6 +775,15 @@ 'text' => true ) ), + 'dimensions' => array( + 'width' => true, + '__experimentalSkipSerialization' => array( + 'width' + ), + '__experimentalDefaultControls' => array( + 'width' => true + ) + ), 'typography' => array( '__experimentalSkipSerialization' => array( 'fontSize', @@ -851,6 +858,10 @@ 'root' => '.wp-block-button .wp-block-button__link', 'typography' => array( 'writingMode' => '.wp-block-button' + ), + 'dimensions' => array( + 'root' => '.wp-block-button', + 'width' => '.wp-block-button' ) ) ), @@ -2865,8 +2876,10 @@ 'background' => array( 'backgroundImage' => true, 'backgroundSize' => true, + 'gradient' => true, '__experimentalDefaultControls' => array( - 'backgroundImage' => true + 'backgroundImage' => true, + 'gradient' => true ) ), 'color' => array( @@ -2893,7 +2906,8 @@ ) ), 'dimensions' => array( - 'minHeight' => true + 'minHeight' => true, + 'minWidth' => true ), '__experimentalBorder' => array( 'color' => true, @@ -3711,6 +3725,7 @@ ), 'supports' => array( 'anchor' => true, + 'html' => false, 'className' => false, 'splitting' => true, '__experimentalBorder' => array( @@ -4378,6 +4393,11 @@ 'clientNavigation' => true ) ), + 'selectors' => array( + 'states' => array( + '@current' => '.wp-block-navigation .current-menu-item' + ) + ), 'editorStyle' => 'wp-block-navigation-link-editor', 'style' => 'wp-block-navigation-link' ), @@ -5348,9 +5368,6 @@ 'type' => 'string', 'role' => 'content' ), - 'textAlign' => array( - 'type' => 'string' - ), 'format' => array( 'type' => 'string' ), @@ -5387,6 +5404,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, '__experimentalFontFamily' => true, '__experimentalFontWeight' => true, '__experimentalFontStyle' => true, @@ -5423,9 +5441,6 @@ 'description' => 'Display the excerpt.', 'textdomain' => 'default', 'attributes' => array( - 'textAlign' => array( - 'type' => 'string' - ), 'moreText' => array( 'type' => 'string', 'role' => 'content' @@ -5466,6 +5481,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, 'textColumns' => true, '__experimentalFontFamily' => true, '__experimentalFontWeight' => true, @@ -5624,9 +5640,6 @@ 'description' => 'Displays the next or previous post link that is adjacent to the current post.', 'textdomain' => 'default', 'attributes' => array( - 'textAlign' => array( - 'type' => 'string' - ), 'type' => array( 'type' => 'string', 'default' => 'next' @@ -5665,6 +5678,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, '__experimentalFontFamily' => true, '__experimentalFontWeight' => true, '__experimentalFontStyle' => true, @@ -5922,9 +5936,6 @@ 'queryId' ), 'attributes' => array( - 'textAlign' => array( - 'type' => 'string' - ), 'level' => array( 'type' => 'number', 'default' => 2 @@ -5947,6 +5958,9 @@ 'type' => 'string', 'default' => '_self', 'role' => 'content' + ), + 'placeholder' => array( + 'type' => 'string' ) ), 'example' => array( @@ -5975,6 +5989,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, '__experimentalFontFamily' => true, '__experimentalFontWeight' => true, '__experimentalFontStyle' => true, @@ -6114,10 +6129,7 @@ ) ), 'dimensions' => array( - 'minHeight' => true, - '__experimentalDefaultControls' => array( - 'minHeight' => false - ) + 'minHeight' => true ), 'spacing' => array( 'margin' => true, @@ -6523,9 +6535,6 @@ 'type' => array( 'type' => 'string' ), - 'textAlign' => array( - 'type' => 'string' - ), 'level' => array( 'type' => 'number', 'default' => 1 @@ -6571,6 +6580,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, '__experimentalFontFamily' => true, '__experimentalFontStyle' => true, '__experimentalFontWeight' => true, @@ -6984,10 +6994,6 @@ 'default' => array( ) - ), - 'isSearchFieldHidden' => array( - 'type' => 'boolean', - 'default' => false ) ), 'supports' => array( @@ -7038,7 +7044,11 @@ 'html' => false ), 'editorStyle' => 'wp-block-search-editor', - 'style' => 'wp-block-search' + 'style' => 'wp-block-search', + 'selectors' => array( + 'color' => '.wp-block-search .wp-block-search__button, .wp-block-search.wp-block-search__no-button .wp-block-search__input', + 'border' => '.wp-block-search.wp-block-search__button-outside .wp-block-search__input, .wp-block-search.wp-block-search__button-outside .wp-block-search__button, .wp-block-search.wp-block-search__no-button .wp-block-search__input, .wp-block-search.wp-block-search__button-only .wp-block-search__input, .wp-block-search.wp-block-search__button-only .wp-block-search__button, .wp-block-search.wp-block-search__button-inside .wp-block-search__inside-wrapper' + ) ), 'separator' => array( '$schema' => 'https://schemas.wp.org/trunk/block.json', @@ -7224,9 +7234,6 @@ ), 'textdomain' => 'default', 'attributes' => array( - 'textAlign' => array( - 'type' => 'string' - ), 'level' => array( 'type' => 'number', 'default' => 0 @@ -7247,7 +7254,11 @@ 'example' => array( 'viewportWidth' => 350, 'attributes' => array( - 'textAlign' => 'center' + 'style' => array( + 'typography' => array( + 'textAlign' => 'center' + ) + ) ) ), 'supports' => array( @@ -7276,6 +7287,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, '__experimentalFontFamily' => true, '__experimentalTextTransform' => true, '__experimentalTextDecoration' => true, @@ -7325,9 +7337,6 @@ 6 ) ), - 'textAlign' => array( - 'type' => 'string' - ), 'isLink' => array( 'type' => 'boolean', 'default' => true, @@ -7369,6 +7378,7 @@ 'typography' => array( 'fontSize' => true, 'lineHeight' => true, + 'textAlign' => true, '__experimentalFontFamily' => true, '__experimentalTextTransform' => true, '__experimentalTextDecoration' => true, diff --git a/src/wp-includes/blocks/button.php b/src/wp-includes/blocks/button.php index 0d03440b1cb0f..8a7b43a2df315 100644 --- a/src/wp-includes/blocks/button.php +++ b/src/wp-includes/blocks/button.php @@ -60,6 +60,74 @@ function render_block_core_button( $attributes, $content ) { return ''; } + $width = $attributes['style']['dimensions']['width'] ?? null; + + if ( $width ) { + // Resolve preset references to their actual values. + $resolved_width = $width; + $is_preset = str_starts_with( $width, 'var:preset|dimension|' ); + + if ( $is_preset ) { + $slug = substr( $width, strlen( 'var:preset|dimension|' ) ); + $dimension_presets = wp_get_global_settings( + array( 'dimensions', 'dimensionSizes' ), + array( 'block_name' => 'core/button' ) + ); + + // Search origins in priority order: custom > theme > default. + if ( is_array( $dimension_presets ) ) { + foreach ( array( 'custom', 'theme', 'default' ) as $origin ) { + if ( empty( $dimension_presets[ $origin ] ) || ! is_array( $dimension_presets[ $origin ] ) ) { + continue; + } + foreach ( $dimension_presets[ $origin ] as $preset ) { + if ( isset( $preset['slug'] ) && $preset['slug'] === $slug ) { + $resolved_width = $preset['size'] ?? $width; + break 2; + } + } + } + } + } + + $is_percentage = str_ends_with( $resolved_width, '%' ); + + $processor = new WP_HTML_Tag_Processor( $content ); + // Target the outer wrapper div. + if ( $processor->next_tag( array( 'class_name' => 'wp-block-button' ) ) ) { + $processor->add_class( 'has-custom-width' ); + $existing_style = $processor->get_attribute( 'style' ); + $existing_style = is_string( $existing_style ) ? $existing_style : ''; + + if ( $is_percentage ) { + $numeric_width = (float) $resolved_width; + $processor->add_class( 'wp-block-button__width' ); + + // Maintain legacy class for the standard percentage widths. + $legacy_widths = array( + '25%' => 'wp-block-button__width-25', + '50%' => 'wp-block-button__width-50', + '75%' => 'wp-block-button__width-75', + '100%' => 'wp-block-button__width-100', + ); + if ( isset( $legacy_widths[ $resolved_width ] ) ) { + $processor->add_class( $legacy_widths[ $resolved_width ] ); + } + + $width_style = "--wp--block-button--width: $numeric_width;"; + $processor->set_attribute( 'style', $width_style . ( $existing_style ? ' ' . $existing_style : '' ) ); + } else { + $css_value = $is_preset + ? 'var(--wp--preset--dimension--' . _wp_to_kebab_case( $slug ) . ')' + : $width; + $width_style = "width: $css_value;"; + $processor->set_attribute( 'style', $width_style . ( $existing_style ? ' ' . $existing_style : '' ) ); + } + + $content = $processor->get_updated_html(); + } + } + return $content; } diff --git a/src/wp-includes/blocks/button/block.json b/src/wp-includes/blocks/button/block.json index 50ba4cda9c688..2e23a64c8f559 100644 --- a/src/wp-includes/blocks/button/block.json +++ b/src/wp-includes/blocks/button/block.json @@ -63,9 +63,6 @@ }, "gradient": { "type": "string" - }, - "width": { - "type": "number" } }, "supports": { @@ -81,6 +78,13 @@ "text": true } }, + "dimensions": { + "width": true, + "__experimentalSkipSerialization": [ "width" ], + "__experimentalDefaultControls": { + "width": true + } + }, "typography": { "__experimentalSkipSerialization": [ "fontSize", @@ -145,6 +149,10 @@ "root": ".wp-block-button .wp-block-button__link", "typography": { "writingMode": ".wp-block-button" + }, + "dimensions": { + "root": ".wp-block-button", + "width": ".wp-block-button" } } } diff --git a/src/wp-includes/blocks/cover.php b/src/wp-includes/blocks/cover.php index 8da5db23ddc3f..16533acacd764 100644 --- a/src/wp-includes/blocks/cover.php +++ b/src/wp-includes/blocks/cover.php @@ -39,13 +39,13 @@ function render_block_core_cover( $attributes, $content ) { $lower_src = strtolower( $iframe_src ); $provider = null; - if ( strpos( $lower_src, 'youtube.com' ) !== false || strpos( $lower_src, 'youtu.be' ) !== false ) { + if ( str_contains( $lower_src, 'youtube.com' ) || str_contains( $lower_src, 'youtu.be' ) ) { $provider = 'youtube'; - } elseif ( strpos( $lower_src, 'vimeo.com' ) !== false ) { + } elseif ( str_contains( $lower_src, 'vimeo.com' ) ) { $provider = 'vimeo'; - } elseif ( strpos( $lower_src, 'videopress.com' ) !== false ) { + } elseif ( str_contains( $lower_src, 'videopress.com' ) ) { $provider = 'videopress'; - } elseif ( strpos( $lower_src, 'wordpress.tv' ) !== false ) { + } elseif ( str_contains( $lower_src, 'wordpress.tv' ) ) { $provider = 'wordpress-tv'; } diff --git a/src/wp-includes/blocks/group/block.json b/src/wp-includes/blocks/group/block.json index e83fb60d31fc7..7fa2ad2ccf4c7 100644 --- a/src/wp-includes/blocks/group/block.json +++ b/src/wp-includes/blocks/group/block.json @@ -28,8 +28,10 @@ "background": { "backgroundImage": true, "backgroundSize": true, + "gradient": true, "__experimentalDefaultControls": { - "backgroundImage": true + "backgroundImage": true, + "gradient": true } }, "color": { @@ -53,7 +55,8 @@ } }, "dimensions": { - "minHeight": true + "minHeight": true, + "minWidth": true }, "__experimentalBorder": { "color": true, diff --git a/src/wp-includes/blocks/home-link.php b/src/wp-includes/blocks/home-link.php index d61aa0bc235e2..30042687c7c63 100644 --- a/src/wp-includes/blocks/home-link.php +++ b/src/wp-includes/blocks/home-link.php @@ -5,6 +5,8 @@ * @package WordPress */ +require_once __DIR__ . '/navigation-link/shared/build-css-font-sizes.php'; + /** * Build an array with CSS classes and inline styles defining the colors * which will be applied to the home link markup in the front-end. @@ -59,36 +61,6 @@ function block_core_home_link_build_css_colors( $context ) { return $colors; } -/** - * Build an array with CSS classes and inline styles defining the font sizes - * which will be applied to the home link markup in the front-end. - * - * @since 6.0.0 - * - * @param array $context Home link block context. - * @return array Font size CSS classes and inline styles. - */ -function block_core_home_link_build_css_font_sizes( $context ) { - // CSS classes. - $font_sizes = array( - 'css_classes' => array(), - 'inline_styles' => '', - ); - - $has_named_font_size = array_key_exists( 'fontSize', $context ); - $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); - - if ( $has_named_font_size ) { - // Add the font size class. - $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); - } elseif ( $has_custom_font_size ) { - // Add the custom font size inline style. - $font_sizes['inline_styles'] = sprintf( 'font-size: %s;', $context['style']['typography']['fontSize'] ); - } - - return $font_sizes; -} - /** * Builds an array with classes and style for the li wrapper * @@ -98,12 +70,21 @@ function block_core_home_link_build_css_font_sizes( $context ) { * @return string The li wrapper attributes. */ function block_core_home_link_build_li_wrapper_attributes( $context ) { - $colors = block_core_home_link_build_css_colors( $context ); - $font_sizes = block_core_home_link_build_css_font_sizes( $context ); - $classes = array_merge( + $colors = block_core_home_link_build_css_colors( $context ); + // The build system prefixes this function with "gutenberg_" to avoid + // collisions with the core version. Until this function is backported to + // core, we need to guard it's use and only call the prefixed name in + // the plugin. + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $font_sizes = gutenberg_block_core_shared_navigation_build_css_font_sizes( $context ); + } else { + $font_sizes = block_core_shared_navigation_build_css_font_sizes( $context ); + } + $classes = array_merge( $colors['css_classes'], $font_sizes['css_classes'] ); + $style_attribute = ( $colors['inline_styles'] . $font_sizes['inline_styles'] ); $classes[] = 'wp-block-navigation-item'; diff --git a/src/wp-includes/blocks/icon.php b/src/wp-includes/blocks/icon.php index e09319cffea3b..1b7a27a147698 100644 --- a/src/wp-includes/blocks/icon.php +++ b/src/wp-includes/blocks/icon.php @@ -10,9 +10,7 @@ * * @since 7.0.0 * - * @param array $attributes The block attributes. - * @param string $content The block content. - * @param WP_Block $block The block instance. + * @param array $attributes The block attributes. * * @return string Returns the Icon. */ diff --git a/src/wp-includes/blocks/latest-posts.php b/src/wp-includes/blocks/latest-posts.php index 44231ac6d14a1..c829852f8cfde 100644 --- a/src/wp-includes/blocks/latest-posts.php +++ b/src/wp-includes/blocks/latest-posts.php @@ -163,7 +163,7 @@ function render_block_core_latest_posts( $attributes ) { $trimmed_excerpt = substr( $trimmed_excerpt, 0, -11 ); $trimmed_excerpt .= sprintf( /* translators: 1: A URL to a post, 2: Hidden accessibility text: Post title */ - __( '… <a class="wp-block-latest-posts__read-more" href="%1$s" rel="noopener noreferrer">Read more<span class="screen-reader-text">: %2$s</span></a>' ), + __( '… <a class="wp-block-latest-posts__read-more" href="%1$s" rel="noopener">Read more<span class="screen-reader-text">: %2$s</span></a>' ), esc_url( $post_link ), esc_html( $title ) ); diff --git a/src/wp-includes/blocks/list-item/block.json b/src/wp-includes/blocks/list-item/block.json index 1cdba86f19b2e..cdefd6c232bf3 100644 --- a/src/wp-includes/blocks/list-item/block.json +++ b/src/wp-includes/blocks/list-item/block.json @@ -21,6 +21,7 @@ }, "supports": { "anchor": true, + "html": false, "className": false, "splitting": true, "__experimentalBorder": { diff --git a/src/wp-includes/blocks/loginout.php b/src/wp-includes/blocks/loginout.php index f83d8be424ece..a9e05f8630bfe 100644 --- a/src/wp-includes/blocks/loginout.php +++ b/src/wp-includes/blocks/loginout.php @@ -38,6 +38,19 @@ function render_block_core_loginout( $attributes ) { // Get the form. $contents = wp_login_form( array( 'echo' => false ) ); + + if ( wp_is_block_theme() ) { + $processor = new WP_HTML_Tag_Processor( $contents ); + + while ( $processor->next_tag( 'input' ) ) { + if ( 'submit' === $processor->get_attribute( 'type' ) && 'wp-submit' === $processor->get_attribute( 'name' ) ) { + $processor->add_class( 'wp-block-button__link' ); + $processor->add_class( wp_theme_get_element_class_name( 'button' ) ); + $contents = $processor->get_updated_html(); + break; + } + } + } } $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $classes ) ); diff --git a/src/wp-includes/blocks/navigation-link.php b/src/wp-includes/blocks/navigation-link.php index 0888d7b5acebd..9a0d3c84d9da5 100644 --- a/src/wp-includes/blocks/navigation-link.php +++ b/src/wp-includes/blocks/navigation-link.php @@ -5,14 +5,9 @@ * @package WordPress */ -// Path differs between source and build: './shared/' in source, './navigation-link/shared/' in build. -if ( file_exists( __DIR__ . '/shared/item-should-render.php' ) ) { - require_once __DIR__ . '/shared/item-should-render.php'; - require_once __DIR__ . '/shared/render-submenu-icon.php'; -} else { - require_once __DIR__ . '/navigation-link/shared/item-should-render.php'; - require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php'; -} +require_once __DIR__ . '/navigation-link/shared/item-should-render.php'; +require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php'; +require_once __DIR__ . '/navigation-link/shared/build-css-font-sizes.php'; /** * Build an array with CSS classes and inline styles defining the colors @@ -86,43 +81,6 @@ function block_core_navigation_link_build_css_colors( $context, $attributes, $is return $colors; } -/** - * Build an array with CSS classes and inline styles defining the font sizes - * which will be applied to the navigation markup in the front-end. - * - * @since 5.9.0 - * - * @param array $context Navigation block context. - * @return array Font size CSS classes and inline styles. - */ -function block_core_navigation_link_build_css_font_sizes( $context ) { - // CSS classes. - $font_sizes = array( - 'css_classes' => array(), - 'inline_styles' => '', - ); - - $has_named_font_size = array_key_exists( 'fontSize', $context ); - $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); - - if ( $has_named_font_size ) { - // Add the font size class. - $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); - } elseif ( $has_custom_font_size ) { - // Add the custom font size inline style. - $font_sizes['inline_styles'] = sprintf( - 'font-size: %s;', - wp_get_typography_font_size_value( - array( - 'size' => $context['style']['typography']['fontSize'], - ) - ) - ); - } - - return $font_sizes; -} - /** * Decodes a url if it's encoded, returning the same url if not. * @@ -169,7 +127,7 @@ function block_core_navigation_link_maybe_urldecode( $url ) { */ function render_block_core_navigation_link( $attributes, $content, $block ) { // Check if this navigation item should render based on post status. - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { if ( ! gutenberg_block_core_shared_navigation_item_should_render( $attributes, $block ) ) { return ''; } @@ -180,7 +138,15 @@ function render_block_core_navigation_link( $attributes, $content, $block ) { return ''; } - $font_sizes = block_core_navigation_link_build_css_font_sizes( $block->context ); + // The build system prefixes this function with "gutenberg_" to avoid + // collisions with the core version. Until this function is backported to + // core, we need to guard its use and only call the prefixed name in + // the plugin. + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $font_sizes = gutenberg_block_core_shared_navigation_build_css_font_sizes( $block->context ); + } else { + $font_sizes = block_core_shared_navigation_build_css_font_sizes( $block->context ); + } $classes = array_merge( $font_sizes['css_classes'] ); @@ -262,7 +228,13 @@ function render_block_core_navigation_link( $attributes, $content, $block ) { if ( isset( $block->context['showSubmenuIcon'] ) && $block->context['showSubmenuIcon'] && $has_submenu ) { // The submenu icon can be hidden by a CSS rule on the Navigation Block. - $html .= '<span class="wp-block-navigation__submenu-icon">' . block_core_navigation_render_submenu_icon() . '</span>'; + $html .= '<span class="wp-block-navigation__submenu-icon">'; + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $html .= gutenberg_block_core_shared_navigation_render_submenu_icon(); + } else { + $html .= block_core_shared_navigation_render_submenu_icon(); + } + $html .= '</span>'; } if ( $has_submenu ) { @@ -485,4 +457,4 @@ function register_block_core_navigation_link() { * Creates all variations for post types / taxonomies dynamically (= each time when variations are requested). * Do not use variation_callback, to also account for unregistering post types/taxonomies later on. */ -add_action( 'get_block_type_variations', 'block_core_navigation_link_filter_variations', 10, 2 ); +add_filter( 'get_block_type_variations', 'block_core_navigation_link_filter_variations', 10, 2 ); diff --git a/src/wp-includes/blocks/navigation-link/block.json b/src/wp-includes/blocks/navigation-link/block.json index 997275574f1ac..0735461d0b29a 100644 --- a/src/wp-includes/blocks/navigation-link/block.json +++ b/src/wp-includes/blocks/navigation-link/block.json @@ -85,6 +85,11 @@ "clientNavigation": true } }, + "selectors": { + "states": { + "@current": ".wp-block-navigation .current-menu-item" + } + }, "editorStyle": "wp-block-navigation-link-editor", "style": "wp-block-navigation-link" } diff --git a/src/wp-includes/blocks/navigation-link/shared/build-css-font-sizes.php b/src/wp-includes/blocks/navigation-link/shared/build-css-font-sizes.php new file mode 100644 index 0000000000000..38fd82d12dac8 --- /dev/null +++ b/src/wp-includes/blocks/navigation-link/shared/build-css-font-sizes.php @@ -0,0 +1,43 @@ +<?php +/** + * Shared helper function for building CSS font sizes in navigation blocks. + * + * @package WordPress + */ + +/** + * Build an array with CSS classes and inline styles defining the font sizes + * which will be applied to the navigation markup in the front-end. + * + * @since 7.1.0 + * + * @param array $context Navigation block context. + * @return array Font size CSS classes and inline styles. + */ +function block_core_shared_navigation_build_css_font_sizes( $context ) { + // CSS classes. + $font_sizes = array( + 'css_classes' => array(), + 'inline_styles' => '', + ); + + $has_named_font_size = array_key_exists( 'fontSize', $context ); + $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); + + if ( $has_named_font_size ) { + // Add the font size class. + $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); + } elseif ( $has_custom_font_size ) { + // Add the custom font size inline style. + $font_sizes['inline_styles'] = sprintf( + 'font-size: %s;', + wp_get_typography_font_size_value( + array( + 'size' => $context['style']['typography']['fontSize'], + ) + ) + ); + } + + return $font_sizes; +} diff --git a/src/wp-includes/blocks/navigation-submenu.php b/src/wp-includes/blocks/navigation-submenu.php index 9138b5a5e08da..ac0ab313b868d 100644 --- a/src/wp-includes/blocks/navigation-submenu.php +++ b/src/wp-includes/blocks/navigation-submenu.php @@ -5,6 +5,10 @@ * @package WordPress */ +require_once __DIR__ . '/navigation-link/shared/item-should-render.php'; +require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php'; +require_once __DIR__ . '/navigation-link/shared/build-css-font-sizes.php'; + /** * Returns the submenu visibility value with backward compatibility * for the deprecated openSubmenusOnClick attribute. @@ -46,52 +50,6 @@ function block_core_navigation_submenu_get_submenu_visibility( $context ) { return $submenu_visibility ?? 'hover'; } -// Path differs between source and build: '../navigation-link/shared/' in source, './navigation-link/shared/' in build. -if ( file_exists( __DIR__ . '/../navigation-link/shared/item-should-render.php' ) ) { - require_once __DIR__ . '/../navigation-link/shared/item-should-render.php'; - require_once __DIR__ . '/../navigation-link/shared/render-submenu-icon.php'; -} else { - require_once __DIR__ . '/navigation-link/shared/item-should-render.php'; - require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php'; -} - -/** - * Build an array with CSS classes and inline styles defining the font sizes - * which will be applied to the navigation markup in the front-end. - * - * @since 5.9.0 - * - * @param array $context Navigation block context. - * @return array Font size CSS classes and inline styles. - */ -function block_core_navigation_submenu_build_css_font_sizes( $context ) { - // CSS classes. - $font_sizes = array( - 'css_classes' => array(), - 'inline_styles' => '', - ); - - $has_named_font_size = array_key_exists( 'fontSize', $context ); - $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); - - if ( $has_named_font_size ) { - // Add the font size class. - $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); - } elseif ( $has_custom_font_size ) { - // Add the custom font size inline style. - $font_sizes['inline_styles'] = sprintf( - 'font-size: %s;', - wp_get_typography_font_size_value( - array( - 'size' => $context['style']['typography']['fontSize'], - ) - ) - ); - } - - return $font_sizes; -} - /** * Renders the `core/navigation-submenu` block. * @@ -105,7 +63,7 @@ function block_core_navigation_submenu_build_css_font_sizes( $context ) { */ function render_block_core_navigation_submenu( $attributes, $content, $block ) { // Check if this navigation item should render based on post status. - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { if ( ! gutenberg_block_core_shared_navigation_item_should_render( $attributes, $block ) ) { return ''; } @@ -116,7 +74,15 @@ function render_block_core_navigation_submenu( $attributes, $content, $block ) { return ''; } - $font_sizes = block_core_navigation_submenu_build_css_font_sizes( $block->context ); + // The build system prefixes this function with "gutenberg_" to avoid + // collisions with the core version. Until this function is backported to + // core, we need to guard its use and only call the prefixed name in + // the plugin. + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $font_sizes = gutenberg_block_core_shared_navigation_build_css_font_sizes( $block->context ); + } else { + $font_sizes = block_core_shared_navigation_build_css_font_sizes( $block->context ); + } $style_attribute = $font_sizes['inline_styles']; // Render inner blocks first to check if any menu items will actually display. @@ -240,7 +206,13 @@ function render_block_core_navigation_submenu( $attributes, $content, $block ) { if ( $show_submenu_indicators && $has_submenu ) { // The submenu icon is rendered in a button here // so that there's a clickable element to open the submenu. - $html .= '<button aria-label="' . esc_attr( $aria_label ) . '" class="wp-block-navigation__submenu-icon wp-block-navigation-submenu__toggle" aria-expanded="false">' . block_core_navigation_render_submenu_icon() . '</button>'; + $html .= '<button aria-label="' . esc_attr( $aria_label ) . '" class="wp-block-navigation__submenu-icon wp-block-navigation-submenu__toggle" aria-expanded="false">'; + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $html .= gutenberg_block_core_shared_navigation_render_submenu_icon(); + } else { + $html .= block_core_shared_navigation_render_submenu_icon(); + } + $html .= '</button>'; } } else { $html .= '<button aria-label="' . esc_attr( $aria_label ) . '" class="wp-block-navigation-item__content wp-block-navigation-submenu__toggle" aria-expanded="false">'; @@ -262,7 +234,13 @@ function render_block_core_navigation_submenu( $attributes, $content, $block ) { $html .= '</button>'; if ( $has_submenu ) { - $html .= '<span class="wp-block-navigation__submenu-icon">' . block_core_navigation_render_submenu_icon() . '</span>'; + $html .= '<span class="wp-block-navigation__submenu-icon">'; + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $html .= gutenberg_block_core_shared_navigation_render_submenu_icon(); + } else { + $html .= block_core_shared_navigation_render_submenu_icon(); + } + $html .= '</span>'; } } diff --git a/src/wp-includes/blocks/navigation.php b/src/wp-includes/blocks/navigation.php index 7863f80a9bdc4..a530494ff36e7 100644 --- a/src/wp-includes/blocks/navigation.php +++ b/src/wp-includes/blocks/navigation.php @@ -425,7 +425,11 @@ private static function get_overlay_blocks_from_template_part( $overlay_template $full_template_part_id = $theme . '//' . $slug; $block_template = get_block_file_template( $full_template_part_id, 'wp_template_part' ); if ( isset( $block_template->content ) ) { - $parsed_blocks = parse_blocks( $block_template->content ); + // Expand shortcodes before parsing blocks, matching the order in + // `render_block_core_template_part()`. + $content = shortcode_unautop( $block_template->content ); + $content = do_shortcode( $content ); + $parsed_blocks = parse_blocks( $content ); $blocks = block_core_navigation_filter_out_empty_blocks( $parsed_blocks ); // Disable overlay menu for any navigation blocks within the overlay to prevent nested overlays. $blocks = static::disable_overlay_menu_for_nested_navigation_blocks( $blocks ); @@ -449,6 +453,12 @@ private static function get_overlay_blocks_from_template_part( $overlay_template // Re-serialize, and run Block Hooks algorithm to inject hooked blocks. $markup = serialize_blocks( $blocks ); $markup = apply_block_hooks_to_content_from_post_object( $markup, $template_part_post ); + + // Expand shortcodes before parsing blocks, matching the order in + // `render_block_core_template_part()`. + $markup = shortcode_unautop( $markup ); + $markup = do_shortcode( $markup ); + $blocks = parse_blocks( $markup ); // Disable overlay menu for any navigation blocks within the overlay to prevent nested overlays. @@ -1344,17 +1354,6 @@ function block_core_navigation_build_css_font_sizes( $attributes ) { return $font_sizes; } -/** - * Returns the top-level submenu SVG chevron icon. - * - * @since 5.9.0 - * - * @return string - */ -function block_core_navigation_render_submenu_icon() { - return '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true" focusable="false"><path d="M1.50002 4L6.00002 8L10.5 4" stroke-width="1.5"></path></svg>'; -} - /** * Filter out empty "null" blocks from the block list. * 'parse_blocks' includes a null block with '\n\n' as the content when diff --git a/src/wp-includes/blocks/page-list.php b/src/wp-includes/blocks/page-list.php index 27e63f250a811..2fe0869ea7363 100644 --- a/src/wp-includes/blocks/page-list.php +++ b/src/wp-includes/blocks/page-list.php @@ -5,6 +5,8 @@ * @package WordPress */ +require_once __DIR__ . '/navigation-link/shared/build-css-font-sizes.php'; + /** * Returns the submenu visibility value with backward compatibility * for the deprecated openSubmenusOnClick attribute. @@ -123,50 +125,12 @@ function block_core_page_list_build_css_colors( $attributes, $context ) { return $colors; } - -/** - * Build an array with CSS classes and inline styles defining the font sizes - * which will be applied to the pages markup in the front-end when it is a descendant of navigation. - * - * @since 5.8.0 - * - * @param array $context Navigation block context. - * @return array Font size CSS classes and inline styles. - */ -function block_core_page_list_build_css_font_sizes( $context ) { - // CSS classes. - $font_sizes = array( - 'css_classes' => array(), - 'inline_styles' => '', - ); - - $has_named_font_size = array_key_exists( 'fontSize', $context ); - $has_custom_font_size = isset( $context['style']['typography']['fontSize'] ); - - if ( $has_named_font_size ) { - // Add the font size class. - $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] ); - } elseif ( $has_custom_font_size ) { - // Add the custom font size inline style. - $font_sizes['inline_styles'] = sprintf( - 'font-size: %s;', - wp_get_typography_font_size_value( - array( - 'size' => $context['style']['typography']['fontSize'], - ) - ) - ); - } - - return $font_sizes; -} - /** * Outputs Page list markup from an array of pages with nested children. * * @since 5.8.0 * - * @param boolean $open_submenus_on_click Whether to open submenus on click instead of hover. + * @param string $submenu_visibility The submenu visibility mode: 'hover', 'click', or 'always'. * @param boolean $show_submenu_icons Whether to show submenu indicator icons. * @param boolean $is_navigation_child If block is a child of Navigation block. * @param array $nested_pages The array of nested pages. @@ -342,12 +306,21 @@ function render_block_core_page_list( $attributes, $content, $block ) { } } - $colors = block_core_page_list_build_css_colors( $attributes, $block->context ); - $font_sizes = block_core_page_list_build_css_font_sizes( $block->context ); - $classes = array_merge( + $colors = block_core_page_list_build_css_colors( $attributes, $block->context ); + // The build system prefixes this function with "gutenberg_" to avoid + // collisions with the core version. Until this function is backported to + // core, we need to guard its use and only call the prefixed name in + // the plugin. + if ( function_exists( 'gutenberg_block_core_shared_navigation_build_css_font_sizes' ) ) { + $font_sizes = gutenberg_block_core_shared_navigation_build_css_font_sizes( $block->context ); + } else { + $font_sizes = block_core_shared_navigation_build_css_font_sizes( $block->context ); + } + $classes = array_merge( $colors['css_classes'], $font_sizes['css_classes'] ); + $style_attribute = ( $colors['inline_styles'] . $font_sizes['inline_styles'] ); $css_classes = trim( implode( ' ', $classes ) ); diff --git a/src/wp-includes/blocks/post-date/block.json b/src/wp-includes/blocks/post-date/block.json index 7952e36af3661..75eb1eda38d8f 100644 --- a/src/wp-includes/blocks/post-date/block.json +++ b/src/wp-includes/blocks/post-date/block.json @@ -11,9 +11,6 @@ "type": "string", "role": "content" }, - "textAlign": { - "type": "string" - }, "format": { "type": "string" }, @@ -46,6 +43,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "__experimentalFontFamily": true, "__experimentalFontWeight": true, "__experimentalFontStyle": true, diff --git a/src/wp-includes/blocks/post-excerpt/block.json b/src/wp-includes/blocks/post-excerpt/block.json index 99f6d5dcc99ed..17678a35c2950 100644 --- a/src/wp-includes/blocks/post-excerpt/block.json +++ b/src/wp-includes/blocks/post-excerpt/block.json @@ -7,9 +7,6 @@ "description": "Display the excerpt.", "textdomain": "default", "attributes": { - "textAlign": { - "type": "string" - }, "moreText": { "type": "string", "role": "content" @@ -46,6 +43,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "textColumns": true, "__experimentalFontFamily": true, "__experimentalFontWeight": true, diff --git a/src/wp-includes/blocks/post-featured-image.php b/src/wp-includes/blocks/post-featured-image.php index e9fc60f7038f5..56cf9a66e4e03 100644 --- a/src/wp-includes/blocks/post-featured-image.php +++ b/src/wp-includes/blocks/post-featured-image.php @@ -166,10 +166,8 @@ function get_block_core_post_featured_image_overlay_element_markup( $attributes } // Apply overlay and gradient classes. - if ( $has_dim_background ) { - $class_names[] = 'has-background-dim'; - $class_names[] = "has-background-dim-{$attributes['dimRatio']}"; - } + $class_names[] = 'has-background-dim'; + $class_names[] = "has-background-dim-{$attributes['dimRatio']}"; if ( $has_solid_overlay ) { $class_names[] = "has-{$attributes['overlayColor']}-background-color"; diff --git a/src/wp-includes/blocks/post-navigation-link/block.json b/src/wp-includes/blocks/post-navigation-link/block.json index 6d51d619637c9..83cf63af7841b 100644 --- a/src/wp-includes/blocks/post-navigation-link/block.json +++ b/src/wp-includes/blocks/post-navigation-link/block.json @@ -7,9 +7,6 @@ "description": "Displays the next or previous post link that is adjacent to the current post.", "textdomain": "default", "attributes": { - "textAlign": { - "type": "string" - }, "type": { "type": "string", "default": "next" @@ -46,6 +43,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "__experimentalFontFamily": true, "__experimentalFontWeight": true, "__experimentalFontStyle": true, diff --git a/src/wp-includes/blocks/post-template.php b/src/wp-includes/blocks/post-template.php index 72ebbe0e13d13..9ce4ce47343e1 100644 --- a/src/wp-includes/blocks/post-template.php +++ b/src/wp-includes/blocks/post-template.php @@ -94,6 +94,9 @@ function render_block_core_post_template( $attributes, $content, $block ) { if ( isset( $attributes['layout']['type'] ) && 'grid' === $attributes['layout']['type'] && ! empty( $attributes['layout']['columnCount'] ) ) { $classnames .= ' ' . sanitize_title( 'columns-' . $attributes['layout']['columnCount'] ); } + if ( isset( $attributes['layout']['type'] ) && 'grid' === $attributes['layout']['type'] && ! empty( $attributes['layout']['columnCount'] ) && ! empty( $attributes['layout']['minimumColumnWidth'] ) ) { + $classnames .= ' has-native-responsive-grid'; + } $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => trim( $classnames ) ) ); diff --git a/src/wp-includes/blocks/post-title/block.json b/src/wp-includes/blocks/post-title/block.json index 1fb7efcf82db5..d1ded484486bd 100644 --- a/src/wp-includes/blocks/post-title/block.json +++ b/src/wp-includes/blocks/post-title/block.json @@ -8,9 +8,6 @@ "textdomain": "default", "usesContext": [ "postId", "postType", "queryId" ], "attributes": { - "textAlign": { - "type": "string" - }, "level": { "type": "number", "default": 2 @@ -33,6 +30,9 @@ "type": "string", "default": "_self", "role": "content" + }, + "placeholder": { + "type": "string" } }, "example": { @@ -58,6 +58,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "__experimentalFontFamily": true, "__experimentalFontWeight": true, "__experimentalFontStyle": true, diff --git a/src/wp-includes/blocks/pullquote/block.json b/src/wp-includes/blocks/pullquote/block.json index 271bba74d0252..9469f420d1db7 100644 --- a/src/wp-includes/blocks/pullquote/block.json +++ b/src/wp-includes/blocks/pullquote/block.json @@ -43,10 +43,7 @@ } }, "dimensions": { - "minHeight": true, - "__experimentalDefaultControls": { - "minHeight": false - } + "minHeight": true }, "spacing": { "margin": true, diff --git a/src/wp-includes/blocks/query-title.php b/src/wp-includes/blocks/query-title.php index d26a3d08ae42a..845a4bdc05aa7 100644 --- a/src/wp-includes/blocks/query-title.php +++ b/src/wp-includes/blocks/query-title.php @@ -13,7 +13,7 @@ * @since 5.8.0 * * @param array $attributes Block attributes. - * @param array $_content Block content. + * @param array $content Block content. * @param object $block Block instance. * * @return string Returns the query title based on the queried object. diff --git a/src/wp-includes/blocks/query-title/block.json b/src/wp-includes/blocks/query-title/block.json index 41e9e3fd29b62..786a46fe5cb1b 100644 --- a/src/wp-includes/blocks/query-title/block.json +++ b/src/wp-includes/blocks/query-title/block.json @@ -10,9 +10,6 @@ "type": { "type": "string" }, - "textAlign": { - "type": "string" - }, "level": { "type": "number", "default": 1 @@ -53,6 +50,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "__experimentalFontFamily": true, "__experimentalFontStyle": true, "__experimentalFontWeight": true, diff --git a/src/wp-includes/blocks/search.php b/src/wp-includes/blocks/search.php index 7073d6ce8ab3f..07c051b8f6eb9 100644 --- a/src/wp-includes/blocks/search.php +++ b/src/wp-includes/blocks/search.php @@ -69,10 +69,13 @@ function render_block_core_search( $attributes ) { if ( ! empty( $typography_classes ) ) { $input_classes[] = $typography_classes; } + if ( ! $show_button && ! empty( $color_classes ) ) { + $input_classes[] = $color_classes; + } if ( $input->next_tag() ) { $input->add_class( implode( ' ', $input_classes ) ); $input->set_attribute( 'id', $input_id ); - $input->set_attribute( 'value', get_search_query() ); + $input->set_attribute( 'value', get_search_query( false ) ); $input->set_attribute( 'placeholder', $attributes['placeholder'] ); // If it's interactive, enqueue the script module and add the directives. @@ -430,20 +433,37 @@ function styles_for_block_core_search( $attributes ) { } } + $use_input_for_colors = ! empty( $attributes['buttonPosition'] ) && 'no-button' === $attributes['buttonPosition']; + // Add color styles. $has_text_color = ! empty( $attributes['style']['color']['text'] ); if ( $has_text_color ) { - $button_styles[] = sprintf( 'color: %s;', $attributes['style']['color']['text'] ); + $text_color_style = sprintf( 'color: %s;', $attributes['style']['color']['text'] ); + if ( $use_input_for_colors ) { + $input_styles[] = $text_color_style; + } else { + $button_styles[] = $text_color_style; + } } $has_background_color = ! empty( $attributes['style']['color']['background'] ); if ( $has_background_color ) { - $button_styles[] = sprintf( 'background-color: %s;', $attributes['style']['color']['background'] ); + $background_color_style = sprintf( 'background-color: %s;', $attributes['style']['color']['background'] ); + if ( $use_input_for_colors ) { + $input_styles[] = $background_color_style; + } else { + $button_styles[] = $background_color_style; + } } $has_custom_gradient = ! empty( $attributes['style']['color']['gradient'] ); if ( $has_custom_gradient ) { - $button_styles[] = sprintf( 'background: %s;', $attributes['style']['color']['gradient'] ); + $custom_gradient_style = sprintf( 'background: %s;', $attributes['style']['color']['gradient'] ); + if ( $use_input_for_colors ) { + $input_styles[] = $custom_gradient_style; + } else { + $button_styles[] = $custom_gradient_style; + } } // Get typography styles to be shared across inner elements. diff --git a/src/wp-includes/blocks/search/block.json b/src/wp-includes/blocks/search/block.json index a6146d4404041..be9c0e3cdc2d5 100644 --- a/src/wp-includes/blocks/search/block.json +++ b/src/wp-includes/blocks/search/block.json @@ -42,10 +42,6 @@ "query": { "type": "object", "default": {} - }, - "isSearchFieldHidden": { - "type": "boolean", - "default": false } }, "supports": { @@ -92,5 +88,9 @@ "html": false }, "editorStyle": "wp-block-search-editor", - "style": "wp-block-search" + "style": "wp-block-search", + "selectors": { + "color": ".wp-block-search .wp-block-search__button, .wp-block-search.wp-block-search__no-button .wp-block-search__input", + "border": ".wp-block-search.wp-block-search__button-outside .wp-block-search__input, .wp-block-search.wp-block-search__button-outside .wp-block-search__button, .wp-block-search.wp-block-search__no-button .wp-block-search__input, .wp-block-search.wp-block-search__button-only .wp-block-search__input, .wp-block-search.wp-block-search__button-only .wp-block-search__button, .wp-block-search.wp-block-search__button-inside .wp-block-search__inside-wrapper" + } } diff --git a/src/wp-includes/blocks/site-tagline/block.json b/src/wp-includes/blocks/site-tagline/block.json index 756b2dcb8183a..1b2764d0cfa9a 100644 --- a/src/wp-includes/blocks/site-tagline/block.json +++ b/src/wp-includes/blocks/site-tagline/block.json @@ -8,9 +8,6 @@ "keywords": [ "description" ], "textdomain": "default", "attributes": { - "textAlign": { - "type": "string" - }, "level": { "type": "number", "default": 0 @@ -23,7 +20,11 @@ "example": { "viewportWidth": 350, "attributes": { - "textAlign": "center" + "style": { + "typography": { + "textAlign": "center" + } + } } }, "supports": { @@ -49,6 +50,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "__experimentalFontFamily": true, "__experimentalTextTransform": true, "__experimentalTextDecoration": true, diff --git a/src/wp-includes/blocks/site-title/block.json b/src/wp-includes/blocks/site-title/block.json index ac6a3c10e086a..0631de6560ba4 100644 --- a/src/wp-includes/blocks/site-title/block.json +++ b/src/wp-includes/blocks/site-title/block.json @@ -15,9 +15,6 @@ "type": "array", "default": [ 0, 1, 2, 3, 4, 5, 6 ] }, - "textAlign": { - "type": "string" - }, "isLink": { "type": "boolean", "default": true, @@ -56,6 +53,7 @@ "typography": { "fontSize": true, "lineHeight": true, + "textAlign": true, "__experimentalFontFamily": true, "__experimentalTextTransform": true, "__experimentalTextDecoration": true, diff --git a/src/wp-includes/build/constants.php b/src/wp-includes/build/constants.php index 37c81b5ccc21b..953e376d24bb3 100644 --- a/src/wp-includes/build/constants.php +++ b/src/wp-includes/build/constants.php @@ -9,6 +9,6 @@ */ return array( - 'version' => '22.6.0-rc.1', + 'version' => '23.1.1', 'build_url' => includes_url( 'build/' ), ); diff --git a/src/wp-includes/build/pages.php b/src/wp-includes/build/pages.php index 14ca6a08fbbc1..d9fa3cfef0f7f 100644 --- a/src/wp-includes/build/pages.php +++ b/src/wp-includes/build/pages.php @@ -6,7 +6,23 @@ * @package wp */ -require_once __DIR__ . '/pages/font-library/page.php'; -require_once __DIR__ . '/pages/font-library/page-wp-admin.php'; -require_once __DIR__ . '/pages/options-connectors/page.php'; -require_once __DIR__ . '/pages/options-connectors/page-wp-admin.php'; +foreach ( [ + __DIR__ . '/pages/media-editor/page.php', + __DIR__ . '/pages/media-editor/page-wp-admin.php', + __DIR__ . '/pages/font-library/page.php', + __DIR__ . '/pages/font-library/page-wp-admin.php', + __DIR__ . '/pages/options-connectors/page.php', + __DIR__ . '/pages/options-connectors/page-wp-admin.php', + __DIR__ . '/pages/guidelines/page.php', + __DIR__ . '/pages/guidelines/page-wp-admin.php', + __DIR__ . '/pages/experiments/page.php', + __DIR__ . '/pages/experiments/page-wp-admin.php', + __DIR__ . '/pages/content-types/page.php', + __DIR__ . '/pages/content-types/page-wp-admin.php', + __DIR__ . '/pages/dashboard/page.php', + __DIR__ . '/pages/dashboard/page-wp-admin.php', +] as $file ) { + if ( file_exists( $file ) ) { + require_once $file; + } +} diff --git a/src/wp-includes/build/pages/content-types/loader.js b/src/wp-includes/build/pages/content-types/loader.js new file mode 100644 index 0000000000000..9f7e5db19d51d --- /dev/null +++ b/src/wp-includes/build/pages/content-types/loader.js @@ -0,0 +1 @@ +// Empty module loader for page dependencies diff --git a/src/wp-includes/build/pages/content-types/page-wp-admin.php b/src/wp-includes/build/pages/content-types/page-wp-admin.php new file mode 100644 index 0000000000000..f59c8b4f34c80 --- /dev/null +++ b/src/wp-includes/build/pages/content-types/page-wp-admin.php @@ -0,0 +1,294 @@ +<?php +/** + * Page: content-types (wp-admin integrated) + * Auto-generated by build process. + * Do not edit this file manually. + * + * This version integrates with the standard WordPress admin interface, + * keeping the wp-admin sidebar and scripts/styles intact. + * + * @package wp + */ + +// Global storage for content-types routes and menu items +global $wp_content_types_wp_admin_routes, $wp_content_types_wp_admin_menu_items; +$wp_content_types_wp_admin_routes = array(); +$wp_content_types_wp_admin_menu_items = array(); + +/** + * Register a route for the content-types-wp-admin page. + * + * @param string $path Route path (e.g., '/types/$type/edit/$id'). + * @param string|null $content_module Script module ID for content (stage/inspector). + * @param string|null $route_module Script module ID for route lifecycle hooks. + */ +function wp_register_content_types_wp_admin_route( $path, $content_module = null, $route_module = null ) { + global $wp_content_types_wp_admin_routes; + + $route = array( 'path' => $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_content_types_wp_admin_routes[] = $route; +} + +/** + * Register a menu item for the content-types-wp-admin page. + * Note: Menu items are registered but not displayed in single-page mode. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + */ +function wp_register_content_types_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) { + global $wp_content_types_wp_admin_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $wp_content_types_wp_admin_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the content-types-wp-admin page. + * + * @return array Array of route objects. + */ +function wp_get_content_types_wp_admin_routes() { + global $wp_content_types_wp_admin_routes; + return $wp_content_types_wp_admin_routes ?? array(); +} + +/** + * Get all registered menu items for the content-types-wp-admin page. + * + * @return array Array of menu item objects. + */ +function wp_get_content_types_wp_admin_menu_items() { + global $wp_content_types_wp_admin_menu_items; + return $wp_content_types_wp_admin_menu_items ?? array(); +} + +/** + * Preload REST API data for the content-types-wp-admin page. + * Automatically called during page rendering. + */ +function wp_content_types_wp_admin_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Enqueue scripts and styles for the content-types-wp-admin page. + * Hooked to admin_enqueue_scripts. + * + * @param string $hook_suffix The current admin page. + */ +function wp_content_types_wp_admin_enqueue_scripts( $hook_suffix ) { + // Check all possible ways this page can be accessed: + // 1. Menu page via admin.php?page=content-types-wp-admin (plugin) + // 2. Direct file via content-types.php (Core) - screen ID will be 'content-types' + $current_screen = get_current_screen(); + $is_our_page = ( + ( isset( $_GET['page'] ) && 'content-types-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( $current_screen && 'content-types' === $current_screen->id ) + ); + + if ( ! $is_our_page ) { + return; + } + + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Fire init action for extensions to register routes and menu items + do_action( 'content-types-wp-admin_init' ); + + // Preload REST API data + wp_content_types_wp_admin_preload_data(); + + // Get all registered routes + $routes = wp_get_content_types_wp_admin_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'content-types-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app using initSinglePage (no menuItems) + wp_add_inline_script( + 'content-types-wp-admin-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "%s", routes: %s}));', + 'content-types-wp-admin-app', + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'content-types-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for content-types-wp-admin module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * content-types-wp-admin page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'content-types-wp-admin_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'content-types-wp-admin', + $build_constants['build_url'] . 'pages/content-types/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'content-types-wp-admin-prerequisites' ); + wp_enqueue_script_module( 'content-types-wp-admin' ); + wp_enqueue_style( 'content-types-wp-admin-prerequisites' ); + } +} + +/** + * Render the content-types-wp-admin page. + * Call this function from add_menu_page or add_submenu_page. + * This renders within the normal WordPress admin interface. + */ +function wp_content_types_wp_admin_render_page() { + ?> + <style> + /* Critical styles to prevent layout shifts - inlined for immediate application */ + + /* Background colors */ + #wpwrap { + background: var(--wpds-color-fg-content-neutral, #1e1e1e); + overflow-y: auto; + } + body { + background: #fff; + } + + /* Reset wp-admin padding */ + #wpcontent { + padding-inline-start: 0; + } + #wpbody-content { + padding-bottom: 0; + } + + /* Hide legacy admin elements */ + #wpbody-content > div:not(.boot-layout-container):not(#screen-meta) { + display: none; + } + #wpfooter { + display: none; + } + + /* Accessibility regions */ + .a11y-speak-region { + inset-inline-start: -1px; + top: -1px; + } + + /* Admin menu indicators */ + ul#adminmenu a.wp-has-current-submenu::after, + ul#adminmenu > li.current > a.current::after { + border-inline-end-color: #fff; + } + + /* Media frame fix */ + .media-frame select.attachment-filters:last-of-type { + width: auto; + max-width: 100%; + } + + /* Responsive overflow fix for #wpwrap */ + @media (min-width: 782px) { + #wpwrap { + overflow-y: initial; + } + } + </style> + <div id="content-types-wp-admin-app" class="boot-layout-container"></div> + <?php +} + +// Hook the enqueue function to admin_enqueue_scripts +add_action( 'admin_enqueue_scripts', 'wp_content_types_wp_admin_enqueue_scripts' ); + diff --git a/src/wp-includes/build/pages/content-types/page.php b/src/wp-includes/build/pages/content-types/page.php new file mode 100644 index 0000000000000..ac656bd4e6c7c --- /dev/null +++ b/src/wp-includes/build/pages/content-types/page.php @@ -0,0 +1,332 @@ +<?php +/** + * Page: content-types + * Auto-generated by build process. + * Do not edit this file manually. + * + * @package wp + */ + +// Global storage for content-types routes and menu items +global $wp_content_types_routes, $wp_content_types_menu_items; +$wp_content_types_routes = array(); +$wp_content_types_menu_items = array(); + +/** + * Register a route for the content-types page. + * + * @param string $path Route path (e.g., '/types/$type/edit/$id'). + * @param string|null $content_module Script module ID for content (stage/inspector). + * @param string|null $route_module Script module ID for route lifecycle hooks. + */ +function wp_register_content_types_route( $path, $content_module = null, $route_module = null ) { + global $wp_content_types_routes; + + $route = array( 'path' => $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_content_types_routes[] = $route; +} + +/** + * Register a menu item for the content-types page. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + * @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'. + */ +function wp_register_content_types_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) { + global $wp_content_types_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) { + $menu_item['parent_type'] = $parent_type; + } + + $wp_content_types_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the content-types page. + * + * @return array Array of route objects. + */ +function wp_get_content_types_routes() { + global $wp_content_types_routes; + return $wp_content_types_routes ?? array(); +} + +/** + * Get all registered menu items for the content-types page. + * + * @return array Array of menu item objects. + */ +function wp_get_content_types_menu_items() { + global $wp_content_types_menu_items; + return $wp_content_types_menu_items ?? array(); +} + +/** + * Preload REST API data for the content-types page. + * Automatically called during page rendering. + */ +function wp_content_types_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Render the content-types page. + * Call this function from add_menu_page or add_submenu_page. + */ +function wp_content_types_render_page() { + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Set current screen + set_current_screen(); + + // Remove unwanted deprecated handler + remove_action( 'admin_head', 'wp_admin_bar_header' ); + + // Remove unwanted scripts and styles that were enqueued during `admin_init` + foreach ( wp_scripts()->queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items + do_action( 'content-types_init' ); + + // Enqueue command palette assets for boot-based pages + if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { + wp_enqueue_command_palette_assets(); + } + + // Preload REST API data + wp_content_types_preload_data(); + + // Get all registered routes and menu items + $menu_items = wp_get_content_types_menu_items(); + $routes = wp_get_content_types_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'content-types-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app + $init_modules = []; + wp_add_inline_script( + 'content-types-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));', + 'content-types-app', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + esc_url( admin_url( '/' ) ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'content-types-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for content-types module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add init modules as static dependencies + // No init modules configured + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * content-types page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'content-types_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'content-types', + $build_constants['build_url'] . 'pages/content-types/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'content-types-prerequisites' ); + wp_enqueue_script_module( 'content-types' ); + wp_enqueue_style( 'content-types-prerequisites' ); + } + + // Output the HTML + ?> + <!DOCTYPE html> + <html <?php language_attributes(); ?>> + <head> + <meta charset="<?php bloginfo( 'charset' ); ?>"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title><?php echo esc_html( get_admin_page_title() ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php + ?> + + + $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_dashboard_wp_admin_routes[] = $route; +} + +/** + * Register a menu item for the dashboard-wp-admin page. + * Note: Menu items are registered but not displayed in single-page mode. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + */ +function wp_register_dashboard_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) { + global $wp_dashboard_wp_admin_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $wp_dashboard_wp_admin_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the dashboard-wp-admin page. + * + * @return array Array of route objects. + */ +function wp_get_dashboard_wp_admin_routes() { + global $wp_dashboard_wp_admin_routes; + return $wp_dashboard_wp_admin_routes ?? array(); +} + +/** + * Get all registered menu items for the dashboard-wp-admin page. + * + * @return array Array of menu item objects. + */ +function wp_get_dashboard_wp_admin_menu_items() { + global $wp_dashboard_wp_admin_menu_items; + return $wp_dashboard_wp_admin_menu_items ?? array(); +} + +/** + * Preload REST API data for the dashboard-wp-admin page. + * Automatically called during page rendering. + */ +function wp_dashboard_wp_admin_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Enqueue scripts and styles for the dashboard-wp-admin page. + * Hooked to admin_enqueue_scripts. + * + * @param string $hook_suffix The current admin page. + */ +function wp_dashboard_wp_admin_enqueue_scripts( $hook_suffix ) { + // Check all possible ways this page can be accessed: + // 1. Menu page via admin.php?page=dashboard-wp-admin (plugin) + // 2. Direct file via dashboard.php (Core) - screen ID will be 'dashboard' + $current_screen = get_current_screen(); + $is_our_page = ( + ( isset( $_GET['page'] ) && 'dashboard-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( $current_screen && 'dashboard' === $current_screen->id ) + ); + + if ( ! $is_our_page ) { + return; + } + + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Fire init action for extensions to register routes and menu items + do_action( 'dashboard-wp-admin_init' ); + + // Preload REST API data + wp_dashboard_wp_admin_preload_data(); + + // Get all registered routes + $routes = wp_get_dashboard_wp_admin_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'dashboard-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app using initSinglePage (no menuItems) + wp_add_inline_script( + 'dashboard-wp-admin-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "%s", routes: %s}));', + 'dashboard-wp-admin-app', + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'dashboard-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for dashboard-wp-admin module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * dashboard-wp-admin page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'dashboard-wp-admin_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'dashboard-wp-admin', + $build_constants['build_url'] . 'pages/dashboard/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'dashboard-wp-admin-prerequisites' ); + wp_enqueue_script_module( 'dashboard-wp-admin' ); + wp_enqueue_style( 'dashboard-wp-admin-prerequisites' ); + } +} + +/** + * Render the dashboard-wp-admin page. + * Call this function from add_menu_page or add_submenu_page. + * This renders within the normal WordPress admin interface. + */ +function wp_dashboard_wp_admin_render_page() { + ?> + +
+ $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_dashboard_routes[] = $route; +} + +/** + * Register a menu item for the dashboard page. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + * @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'. + */ +function wp_register_dashboard_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) { + global $wp_dashboard_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) { + $menu_item['parent_type'] = $parent_type; + } + + $wp_dashboard_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the dashboard page. + * + * @return array Array of route objects. + */ +function wp_get_dashboard_routes() { + global $wp_dashboard_routes; + return $wp_dashboard_routes ?? array(); +} + +/** + * Get all registered menu items for the dashboard page. + * + * @return array Array of menu item objects. + */ +function wp_get_dashboard_menu_items() { + global $wp_dashboard_menu_items; + return $wp_dashboard_menu_items ?? array(); +} + +/** + * Preload REST API data for the dashboard page. + * Automatically called during page rendering. + */ +function wp_dashboard_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Render the dashboard page. + * Call this function from add_menu_page or add_submenu_page. + */ +function wp_dashboard_render_page() { + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Set current screen + set_current_screen(); + + // Remove unwanted deprecated handler + remove_action( 'admin_head', 'wp_admin_bar_header' ); + + // Remove unwanted scripts and styles that were enqueued during `admin_init` + foreach ( wp_scripts()->queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items + do_action( 'dashboard_init' ); + + // Enqueue command palette assets for boot-based pages + if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { + wp_enqueue_command_palette_assets(); + } + + // Preload REST API data + wp_dashboard_preload_data(); + + // Get all registered routes and menu items + $menu_items = wp_get_dashboard_menu_items(); + $routes = wp_get_dashboard_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'dashboard-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app + $init_modules = []; + wp_add_inline_script( + 'dashboard-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));', + 'dashboard-app', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + esc_url( admin_url( '/' ) ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'dashboard-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for dashboard module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add init modules as static dependencies + // No init modules configured + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * dashboard page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'dashboard_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'dashboard', + $build_constants['build_url'] . 'pages/dashboard/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'dashboard-prerequisites' ); + wp_enqueue_script_module( 'dashboard' ); + wp_enqueue_style( 'dashboard-prerequisites' ); + } + + // Output the HTML + ?> + + > + + + + <?php echo esc_html( get_admin_page_title() ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php + ?> + + + $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_experiments_wp_admin_routes[] = $route; +} + +/** + * Register a menu item for the experiments-wp-admin page. + * Note: Menu items are registered but not displayed in single-page mode. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + */ +function wp_register_experiments_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) { + global $wp_experiments_wp_admin_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $wp_experiments_wp_admin_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the experiments-wp-admin page. + * + * @return array Array of route objects. + */ +function wp_get_experiments_wp_admin_routes() { + global $wp_experiments_wp_admin_routes; + return $wp_experiments_wp_admin_routes ?? array(); +} + +/** + * Get all registered menu items for the experiments-wp-admin page. + * + * @return array Array of menu item objects. + */ +function wp_get_experiments_wp_admin_menu_items() { + global $wp_experiments_wp_admin_menu_items; + return $wp_experiments_wp_admin_menu_items ?? array(); +} + +/** + * Preload REST API data for the experiments-wp-admin page. + * Automatically called during page rendering. + */ +function wp_experiments_wp_admin_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Enqueue scripts and styles for the experiments-wp-admin page. + * Hooked to admin_enqueue_scripts. + * + * @param string $hook_suffix The current admin page. + */ +function wp_experiments_wp_admin_enqueue_scripts( $hook_suffix ) { + // Check all possible ways this page can be accessed: + // 1. Menu page via admin.php?page=experiments-wp-admin (plugin) + // 2. Direct file via experiments.php (Core) - screen ID will be 'experiments' + $current_screen = get_current_screen(); + $is_our_page = ( + ( isset( $_GET['page'] ) && 'experiments-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( $current_screen && 'experiments' === $current_screen->id ) + ); + + if ( ! $is_our_page ) { + return; + } + + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Fire init action for extensions to register routes and menu items + do_action( 'experiments-wp-admin_init' ); + + // Preload REST API data + wp_experiments_wp_admin_preload_data(); + + // Get all registered routes + $routes = wp_get_experiments_wp_admin_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'experiments-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app using initSinglePage (no menuItems) + wp_add_inline_script( + 'experiments-wp-admin-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "%s", routes: %s}));', + 'experiments-wp-admin-app', + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'experiments-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for experiments-wp-admin module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * experiments-wp-admin page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'experiments-wp-admin_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'experiments-wp-admin', + $build_constants['build_url'] . 'pages/experiments/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'experiments-wp-admin-prerequisites' ); + wp_enqueue_script_module( 'experiments-wp-admin' ); + wp_enqueue_style( 'experiments-wp-admin-prerequisites' ); + } +} + +/** + * Render the experiments-wp-admin page. + * Call this function from add_menu_page or add_submenu_page. + * This renders within the normal WordPress admin interface. + */ +function wp_experiments_wp_admin_render_page() { + ?> + +
+ $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_experiments_routes[] = $route; +} + +/** + * Register a menu item for the experiments page. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + * @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'. + */ +function wp_register_experiments_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) { + global $wp_experiments_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) { + $menu_item['parent_type'] = $parent_type; + } + + $wp_experiments_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the experiments page. + * + * @return array Array of route objects. + */ +function wp_get_experiments_routes() { + global $wp_experiments_routes; + return $wp_experiments_routes ?? array(); +} + +/** + * Get all registered menu items for the experiments page. + * + * @return array Array of menu item objects. + */ +function wp_get_experiments_menu_items() { + global $wp_experiments_menu_items; + return $wp_experiments_menu_items ?? array(); +} + +/** + * Preload REST API data for the experiments page. + * Automatically called during page rendering. + */ +function wp_experiments_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Render the experiments page. + * Call this function from add_menu_page or add_submenu_page. + */ +function wp_experiments_render_page() { + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Set current screen + set_current_screen(); + + // Remove unwanted deprecated handler + remove_action( 'admin_head', 'wp_admin_bar_header' ); + + // Remove unwanted scripts and styles that were enqueued during `admin_init` + foreach ( wp_scripts()->queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items + do_action( 'experiments_init' ); + + // Enqueue command palette assets for boot-based pages + if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { + wp_enqueue_command_palette_assets(); + } + + // Preload REST API data + wp_experiments_preload_data(); + + // Get all registered routes and menu items + $menu_items = wp_get_experiments_menu_items(); + $routes = wp_get_experiments_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'experiments-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app + $init_modules = []; + wp_add_inline_script( + 'experiments-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));', + 'experiments-app', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + esc_url( admin_url( '/' ) ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'experiments-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for experiments module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add init modules as static dependencies + // No init modules configured + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * experiments page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'experiments_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'experiments', + $build_constants['build_url'] . 'pages/experiments/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'experiments-prerequisites' ); + wp_enqueue_script_module( 'experiments' ); + wp_enqueue_style( 'experiments-prerequisites' ); + } + + // Output the HTML + ?> + + > + + + + <?php echo esc_html( get_admin_page_title() ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php + ?> + + + $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_guidelines_wp_admin_routes[] = $route; +} + +/** + * Register a menu item for the guidelines-wp-admin page. + * Note: Menu items are registered but not displayed in single-page mode. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + */ +function wp_register_guidelines_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) { + global $wp_guidelines_wp_admin_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $wp_guidelines_wp_admin_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the guidelines-wp-admin page. + * + * @return array Array of route objects. + */ +function wp_get_guidelines_wp_admin_routes() { + global $wp_guidelines_wp_admin_routes; + return $wp_guidelines_wp_admin_routes ?? array(); +} + +/** + * Get all registered menu items for the guidelines-wp-admin page. + * + * @return array Array of menu item objects. + */ +function wp_get_guidelines_wp_admin_menu_items() { + global $wp_guidelines_wp_admin_menu_items; + return $wp_guidelines_wp_admin_menu_items ?? array(); +} + +/** + * Preload REST API data for the guidelines-wp-admin page. + * Automatically called during page rendering. + */ +function wp_guidelines_wp_admin_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Enqueue scripts and styles for the guidelines-wp-admin page. + * Hooked to admin_enqueue_scripts. + * + * @param string $hook_suffix The current admin page. + */ +function wp_guidelines_wp_admin_enqueue_scripts( $hook_suffix ) { + // Check all possible ways this page can be accessed: + // 1. Menu page via admin.php?page=guidelines-wp-admin (plugin) + // 2. Direct file via guidelines.php (Core) - screen ID will be 'guidelines' + $current_screen = get_current_screen(); + $is_our_page = ( + ( isset( $_GET['page'] ) && 'guidelines-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( $current_screen && 'guidelines' === $current_screen->id ) + ); + + if ( ! $is_our_page ) { + return; + } + + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Fire init action for extensions to register routes and menu items + do_action( 'guidelines-wp-admin_init' ); + + // Preload REST API data + wp_guidelines_wp_admin_preload_data(); + + // Get all registered routes + $routes = wp_get_guidelines_wp_admin_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'guidelines-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app using initSinglePage (no menuItems) + wp_add_inline_script( + 'guidelines-wp-admin-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "%s", routes: %s}));', + 'guidelines-wp-admin-app', + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'guidelines-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for guidelines-wp-admin module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * guidelines-wp-admin page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'guidelines-wp-admin_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'guidelines-wp-admin', + $build_constants['build_url'] . 'pages/guidelines/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'guidelines-wp-admin-prerequisites' ); + wp_enqueue_script_module( 'guidelines-wp-admin' ); + wp_enqueue_style( 'guidelines-wp-admin-prerequisites' ); + } +} + +/** + * Render the guidelines-wp-admin page. + * Call this function from add_menu_page or add_submenu_page. + * This renders within the normal WordPress admin interface. + */ +function wp_guidelines_wp_admin_render_page() { + ?> + +
+ $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_guidelines_routes[] = $route; +} + +/** + * Register a menu item for the guidelines page. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + * @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'. + */ +function wp_register_guidelines_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) { + global $wp_guidelines_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) { + $menu_item['parent_type'] = $parent_type; + } + + $wp_guidelines_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the guidelines page. + * + * @return array Array of route objects. + */ +function wp_get_guidelines_routes() { + global $wp_guidelines_routes; + return $wp_guidelines_routes ?? array(); +} + +/** + * Get all registered menu items for the guidelines page. + * + * @return array Array of menu item objects. + */ +function wp_get_guidelines_menu_items() { + global $wp_guidelines_menu_items; + return $wp_guidelines_menu_items ?? array(); +} + +/** + * Preload REST API data for the guidelines page. + * Automatically called during page rendering. + */ +function wp_guidelines_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Render the guidelines page. + * Call this function from add_menu_page or add_submenu_page. + */ +function wp_guidelines_render_page() { + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Set current screen + set_current_screen(); + + // Remove unwanted deprecated handler + remove_action( 'admin_head', 'wp_admin_bar_header' ); + + // Remove unwanted scripts and styles that were enqueued during `admin_init` + foreach ( wp_scripts()->queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items + do_action( 'guidelines_init' ); + + // Enqueue command palette assets for boot-based pages + if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { + wp_enqueue_command_palette_assets(); + } + + // Preload REST API data + wp_guidelines_preload_data(); + + // Get all registered routes and menu items + $menu_items = wp_get_guidelines_menu_items(); + $routes = wp_get_guidelines_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'guidelines-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app + $init_modules = []; + wp_add_inline_script( + 'guidelines-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));', + 'guidelines-app', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + esc_url( admin_url( '/' ) ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'guidelines-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for guidelines module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add init modules as static dependencies + // No init modules configured + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * guidelines page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'guidelines_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'guidelines', + $build_constants['build_url'] . 'pages/guidelines/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'guidelines-prerequisites' ); + wp_enqueue_script_module( 'guidelines' ); + wp_enqueue_style( 'guidelines-prerequisites' ); + } + + // Output the HTML + ?> + + > + + + + <?php echo esc_html( get_admin_page_title() ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php + ?> + + + $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_media_editor_wp_admin_routes[] = $route; +} + +/** + * Register a menu item for the media-editor-wp-admin page. + * Note: Menu items are registered but not displayed in single-page mode. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + */ +function wp_register_media_editor_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) { + global $wp_media_editor_wp_admin_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $wp_media_editor_wp_admin_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the media-editor-wp-admin page. + * + * @return array Array of route objects. + */ +function wp_get_media_editor_wp_admin_routes() { + global $wp_media_editor_wp_admin_routes; + return $wp_media_editor_wp_admin_routes ?? array(); +} + +/** + * Get all registered menu items for the media-editor-wp-admin page. + * + * @return array Array of menu item objects. + */ +function wp_get_media_editor_wp_admin_menu_items() { + global $wp_media_editor_wp_admin_menu_items; + return $wp_media_editor_wp_admin_menu_items ?? array(); +} + +/** + * Preload REST API data for the media-editor-wp-admin page. + * Automatically called during page rendering. + */ +function wp_media_editor_wp_admin_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Enqueue scripts and styles for the media-editor-wp-admin page. + * Hooked to admin_enqueue_scripts. + * + * @param string $hook_suffix The current admin page. + */ +function wp_media_editor_wp_admin_enqueue_scripts( $hook_suffix ) { + // Check all possible ways this page can be accessed: + // 1. Menu page via admin.php?page=media-editor-wp-admin (plugin) + // 2. Direct file via media-editor.php (Core) - screen ID will be 'media-editor' + $current_screen = get_current_screen(); + $is_our_page = ( + ( isset( $_GET['page'] ) && 'media-editor-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( $current_screen && 'media-editor' === $current_screen->id ) + ); + + if ( ! $is_our_page ) { + return; + } + + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Fire init action for extensions to register routes and menu items + do_action( 'media-editor-wp-admin_init' ); + + // Preload REST API data + wp_media_editor_wp_admin_preload_data(); + + // Get all registered routes + $routes = wp_get_media_editor_wp_admin_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'media-editor-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app using initSinglePage (no menuItems) + wp_add_inline_script( + 'media-editor-wp-admin-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "%s", routes: %s}));', + 'media-editor-wp-admin-app', + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'media-editor-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for media-editor-wp-admin module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * media-editor-wp-admin page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'media-editor-wp-admin_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'media-editor-wp-admin', + $build_constants['build_url'] . 'pages/media-editor/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'media-editor-wp-admin-prerequisites' ); + wp_enqueue_script_module( 'media-editor-wp-admin' ); + wp_enqueue_style( 'media-editor-wp-admin-prerequisites' ); + } +} + +/** + * Render the media-editor-wp-admin page. + * Call this function from add_menu_page or add_submenu_page. + * This renders within the normal WordPress admin interface. + */ +function wp_media_editor_wp_admin_render_page() { + ?> + +
+ $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $wp_media_editor_routes[] = $route; +} + +/** + * Register a menu item for the media-editor page. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + * @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'. + */ +function wp_register_media_editor_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) { + global $wp_media_editor_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) { + $menu_item['parent_type'] = $parent_type; + } + + $wp_media_editor_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the media-editor page. + * + * @return array Array of route objects. + */ +function wp_get_media_editor_routes() { + global $wp_media_editor_routes; + return $wp_media_editor_routes ?? array(); +} + +/** + * Get all registered menu items for the media-editor page. + * + * @return array Array of menu item objects. + */ +function wp_get_media_editor_menu_items() { + global $wp_media_editor_menu_items; + return $wp_media_editor_menu_items ?? array(); +} + +/** + * Preload REST API data for the media-editor page. + * Automatically called during page rendering. + */ +function wp_media_editor_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Render the media-editor page. + * Call this function from add_menu_page or add_submenu_page. + */ +function wp_media_editor_render_page() { + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Set current screen + set_current_screen(); + + // Remove unwanted deprecated handler + remove_action( 'admin_head', 'wp_admin_bar_header' ); + + // Remove unwanted scripts and styles that were enqueued during `admin_init` + foreach ( wp_scripts()->queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items + do_action( 'media-editor_init' ); + + // Enqueue command palette assets for boot-based pages + if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { + wp_enqueue_command_palette_assets(); + } + + // Preload REST API data + wp_media_editor_preload_data(); + + // Get all registered routes and menu items + $menu_items = wp_get_media_editor_menu_items(); + $routes = wp_get_media_editor_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'media-editor-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app + $init_modules = []; + wp_add_inline_script( + 'media-editor-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));', + 'media-editor-app', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + esc_url( admin_url( '/' ) ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'media-editor-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for media-editor module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add init modules as static dependencies + // No init modules configured + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * media-editor page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'media-editor_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'media-editor', + $build_constants['build_url'] . 'pages/media-editor/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'media-editor-prerequisites' ); + wp_enqueue_script_module( 'media-editor' ); + wp_enqueue_style( 'media-editor-prerequisites' ); + } + + // Output the HTML + ?> + + > + + + + <?php echo esc_html( get_admin_page_title() ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php + ?> + + + $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $gutenberg_site_editor_v2_wp_admin_routes[] = $route; +} + +/** + * Register a menu item for the site-editor-v2-wp-admin page. + * Note: Menu items are registered but not displayed in single-page mode. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + */ +function gutenberg_register_site_editor_v2_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) { + global $gutenberg_site_editor_v2_wp_admin_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $gutenberg_site_editor_v2_wp_admin_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the site-editor-v2-wp-admin page. + * + * @return array Array of route objects. + */ +function gutenberg_get_site_editor_v2_wp_admin_routes() { + global $gutenberg_site_editor_v2_wp_admin_routes; + return $gutenberg_site_editor_v2_wp_admin_routes ?? array(); +} + +/** + * Get all registered menu items for the site-editor-v2-wp-admin page. + * + * @return array Array of menu item objects. + */ +function gutenberg_get_site_editor_v2_wp_admin_menu_items() { + global $gutenberg_site_editor_v2_wp_admin_menu_items; + return $gutenberg_site_editor_v2_wp_admin_menu_items ?? array(); +} + +/** + * Preload REST API data for the site-editor-v2-wp-admin page. + * Automatically called during page rendering. + */ +function gutenberg_site_editor_v2_wp_admin_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Enqueue scripts and styles for the site-editor-v2-wp-admin page. + * Hooked to admin_enqueue_scripts. + * + * @param string $hook_suffix The current admin page. + */ +function gutenberg_site_editor_v2_wp_admin_enqueue_scripts( $hook_suffix ) { + // Check all possible ways this page can be accessed: + // 1. Menu page via admin.php?page=site-editor-v2-wp-admin (plugin) + // 2. Direct file via site-editor-v2.php (Core) - screen ID will be 'site-editor-v2' + $current_screen = get_current_screen(); + $is_our_page = ( + ( isset( $_GET['page'] ) && 'site-editor-v2-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( $current_screen && 'site-editor-v2' === $current_screen->id ) + ); + + if ( ! $is_our_page ) { + return; + } + + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Fire init action for extensions to register routes and menu items + do_action( 'site-editor-v2-wp-admin_init' ); + + // Preload REST API data + gutenberg_site_editor_v2_wp_admin_preload_data(); + + // Get all registered routes + $routes = gutenberg_get_site_editor_v2_wp_admin_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'site-editor-v2-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app using initSinglePage (no menuItems) + wp_add_inline_script( + 'site-editor-v2-wp-admin-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "%s", routes: %s}));', + 'site-editor-v2-wp-admin-app', + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'site-editor-v2-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for site-editor-v2-wp-admin module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * site-editor-v2-wp-admin page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'site-editor-v2-wp-admin_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'site-editor-v2-wp-admin', + $build_constants['build_url'] . 'pages/site-editor-v2/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'site-editor-v2-wp-admin-prerequisites' ); + wp_enqueue_script_module( 'site-editor-v2-wp-admin' ); + wp_enqueue_style( 'site-editor-v2-wp-admin-prerequisites' ); + } +} + +/** + * Render the site-editor-v2-wp-admin page. + * Call this function from add_menu_page or add_submenu_page. + * This renders within the normal WordPress admin interface. + */ +function gutenberg_site_editor_v2_wp_admin_render_page() { + ?> + +
+ $path ); + if ( ! empty( $content_module ) ) { + $route['content_module'] = $content_module; + } + if ( ! empty( $route_module ) ) { + $route['route_module'] = $route_module; + } + + $gutenberg_site_editor_v2_routes[] = $route; +} + +/** + * Register a menu item for the site-editor-v2 page. + * + * @param string $id Menu item ID. + * @param string $label Display label. + * @param string $to Route path to navigate to. + * @param string $parent_id Optional. Parent menu item ID. + * @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'. + */ +function gutenberg_register_site_editor_v2_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) { + global $gutenberg_site_editor_v2_menu_items; + + $menu_item = array( + 'id' => $id, + 'label' => $label, + 'to' => $to, + ); + + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) { + $menu_item['parent_type'] = $parent_type; + } + + $gutenberg_site_editor_v2_menu_items[] = $menu_item; +} + +/** + * Get all registered routes for the site-editor-v2 page. + * + * @return array Array of route objects. + */ +function gutenberg_get_site_editor_v2_routes() { + global $gutenberg_site_editor_v2_routes; + return $gutenberg_site_editor_v2_routes ?? array(); +} + +/** + * Get all registered menu items for the site-editor-v2 page. + * + * @return array Array of menu item objects. + */ +function gutenberg_get_site_editor_v2_menu_items() { + global $gutenberg_site_editor_v2_menu_items; + return $gutenberg_site_editor_v2_menu_items ?? array(); +} + +/** + * Preload REST API data for the site-editor-v2 page. + * Automatically called during page rendering. + */ +function gutenberg_site_editor_v2_preload_data() { + // Define paths to preload - same for all pages + // Please also change packages/core-data/src/entities.js when changing this. + $preload_paths = array( + '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front', + array( '/wp/v2/settings', 'OPTIONS' ), + ); + + // Use rest_preload_api_request to gather the preloaded data + $preload_data = array_reduce( + $preload_paths, + 'rest_preload_api_request', + array() + ); + + // Register the preloading middleware with wp-api-fetch + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', + wp_json_encode( $preload_data ) + ), + 'after' + ); +} + +/** + * Render the site-editor-v2 page. + * Call this function from add_menu_page or add_submenu_page. + */ +function gutenberg_site_editor_v2_render_page() { + // Load build constants + $build_constants = require __DIR__ . '/../../constants.php'; + + // Set current screen + set_current_screen(); + + // Remove unwanted deprecated handler + remove_action( 'admin_head', 'wp_admin_bar_header' ); + + // Remove unwanted scripts and styles that were enqueued during `admin_init` + foreach ( wp_scripts()->queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items + do_action( 'site-editor-v2_init' ); + + // Enqueue command palette assets for boot-based pages + if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) { + wp_enqueue_command_palette_assets(); + } + + // Preload REST API data + gutenberg_site_editor_v2_preload_data(); + + // Get all registered routes and menu items + $menu_items = gutenberg_get_site_editor_v2_menu_items(); + $routes = gutenberg_get_site_editor_v2_routes(); + + // Get boot module asset file for dependencies + $asset_file = ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'site-editor-v2-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app + $init_modules = ["@wordpress/edit-site-init"]; + wp_add_inline_script( + 'site-editor-v2-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));', + 'site-editor-v2-app', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + esc_url( admin_url( '/' ) ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'site-editor-v2-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Build dependencies for site-editor-v2 module + $boot_dependencies = array( + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ), + ); + + // Add init modules as static dependencies + $boot_dependencies[] = array( 'import' => 'static', 'id' => '@wordpress/edit-site-init' ); + + // Add all registered routes as dependencies + foreach ( $routes as $route ) { + if ( isset( $route['route_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'static', + 'id' => $route['route_module'], + ); + } + if ( isset( $route['content_module'] ) ) { + $boot_dependencies[] = array( + 'import' => 'dynamic', + 'id' => $route['content_module'], + ); + } + } + + /** + * Filters the boot script-module dependencies for the + * site-editor-v2 page. + * + * Surfaces extending this page can append entries to the boot + * dependency list. Each entry is an array with 'import' (string + * 'static' or 'dynamic') and 'id' (script-module handle) keys. + * + * @param array $boot_dependencies Boot dependencies for the page. + */ + $boot_dependencies = apply_filters( + 'site-editor-v2_boot_dependencies', + $boot_dependencies + ); + + // Dummy script module to ensure dependencies are loaded + wp_register_script_module( + 'site-editor-v2', + $build_constants['build_url'] . 'pages/site-editor-v2/loader.js', + $boot_dependencies + ); + + // Enqueue the boot scripts and styles + wp_enqueue_script( 'site-editor-v2-prerequisites' ); + wp_enqueue_script_module( 'site-editor-v2' ); + wp_enqueue_style( 'site-editor-v2-prerequisites' ); + } + + // Output the HTML + ?> + + > + + + + <?php echo esc_html( get_admin_page_title() ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php + ?> + + + function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) @@ -31,10 +35,17 @@ var require_i18n = __commonJS({ } }); -// package-external:@wordpress/components -var require_components = __commonJS({ - "package-external:@wordpress/components"(exports, module) { - module.exports = window.wp.components; +// package-external:@wordpress/element +var require_element = __commonJS({ + "package-external:@wordpress/element"(exports, module) { + module.exports = window.wp.element; + } +}); + +// vendor-external:react +var require_react = __commonJS({ + "vendor-external:react"(exports, module) { + module.exports = window.React; } }); @@ -45,17 +56,179 @@ var require_jsx_runtime = __commonJS({ } }); -// package-external:@wordpress/element -var require_element = __commonJS({ - "package-external:@wordpress/element"(exports, module) { - module.exports = window.wp.element; +// vendor-external:react-dom +var require_react_dom = __commonJS({ + "vendor-external:react-dom"(exports, module) { + module.exports = window.ReactDOM; } }); -// vendor-external:react -var require_react = __commonJS({ - "vendor-external:react"(exports, module) { - module.exports = window.React; +// node_modules/use-sync-external-store/cjs/use-sync-external-store-shim.development.js +var require_use_sync_external_store_shim_development = __commonJS({ + "node_modules/use-sync-external-store/cjs/use-sync-external-store-shim.development.js"(exports) { + "use strict"; + (function() { + function is(x, y) { + return x === y && (0 !== x || 1 / x === 1 / y) || x !== x && y !== y; + } + function useSyncExternalStore$2(subscribe, getSnapshot) { + didWarnOld18Alpha || void 0 === React53.startTransition || (didWarnOld18Alpha = true, console.error( + "You are using an outdated, pre-release alpha of React 18 that does not support useSyncExternalStore. The use-sync-external-store shim will not work correctly. Upgrade to a newer pre-release." + )); + var value = getSnapshot(); + if (!didWarnUncachedGetSnapshot) { + var cachedValue = getSnapshot(); + objectIs(value, cachedValue) || (console.error( + "The result of getSnapshot should be cached to avoid an infinite loop" + ), didWarnUncachedGetSnapshot = true); + } + cachedValue = useState14({ + inst: { value, getSnapshot } + }); + var inst = cachedValue[0].inst, forceUpdate = cachedValue[1]; + useLayoutEffect4( + function() { + inst.value = value; + inst.getSnapshot = getSnapshot; + checkIfSnapshotChanged(inst) && forceUpdate({ inst }); + }, + [subscribe, value, getSnapshot] + ); + useEffect15( + function() { + checkIfSnapshotChanged(inst) && forceUpdate({ inst }); + return subscribe(function() { + checkIfSnapshotChanged(inst) && forceUpdate({ inst }); + }); + }, + [subscribe] + ); + useDebugValue2(value); + return value; + } + function checkIfSnapshotChanged(inst) { + var latestGetSnapshot = inst.getSnapshot; + inst = inst.value; + try { + var nextValue = latestGetSnapshot(); + return !objectIs(inst, nextValue); + } catch (error2) { + return true; + } + } + function useSyncExternalStore$1(subscribe, getSnapshot) { + return getSnapshot(); + } + "undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error()); + var React53 = require_react(), objectIs = "function" === typeof Object.is ? Object.is : is, useState14 = React53.useState, useEffect15 = React53.useEffect, useLayoutEffect4 = React53.useLayoutEffect, useDebugValue2 = React53.useDebugValue, didWarnOld18Alpha = false, didWarnUncachedGetSnapshot = false, shim = "undefined" === typeof window || "undefined" === typeof window.document || "undefined" === typeof window.document.createElement ? useSyncExternalStore$1 : useSyncExternalStore$2; + exports.useSyncExternalStore = void 0 !== React53.useSyncExternalStore ? React53.useSyncExternalStore : shim; + "undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error()); + })(); + } +}); + +// node_modules/use-sync-external-store/shim/index.js +var require_shim = __commonJS({ + "node_modules/use-sync-external-store/shim/index.js"(exports, module) { + "use strict"; + if (false) { + module.exports = null; + } else { + module.exports = require_use_sync_external_store_shim_development(); + } + } +}); + +// node_modules/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +var require_with_selector_development = __commonJS({ + "node_modules/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js"(exports) { + "use strict"; + (function() { + function is(x, y) { + return x === y && (0 !== x || 1 / x === 1 / y) || x !== x && y !== y; + } + "undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error()); + var React53 = require_react(), shim = require_shim(), objectIs = "function" === typeof Object.is ? Object.is : is, useSyncExternalStore2 = shim.useSyncExternalStore, useRef21 = React53.useRef, useEffect15 = React53.useEffect, useMemo17 = React53.useMemo, useDebugValue2 = React53.useDebugValue; + exports.useSyncExternalStoreWithSelector = function(subscribe, getSnapshot, getServerSnapshot, selector, isEqual) { + var instRef = useRef21(null); + if (null === instRef.current) { + var inst = { hasValue: false, value: null }; + instRef.current = inst; + } else inst = instRef.current; + instRef = useMemo17( + function() { + function memoizedSelector(nextSnapshot) { + if (!hasMemo) { + hasMemo = true; + memoizedSnapshot = nextSnapshot; + nextSnapshot = selector(nextSnapshot); + if (void 0 !== isEqual && inst.hasValue) { + var currentSelection = inst.value; + if (isEqual(currentSelection, nextSnapshot)) + return memoizedSelection = currentSelection; + } + return memoizedSelection = nextSnapshot; + } + currentSelection = memoizedSelection; + if (objectIs(memoizedSnapshot, nextSnapshot)) + return currentSelection; + var nextSelection = selector(nextSnapshot); + if (void 0 !== isEqual && isEqual(currentSelection, nextSelection)) + return memoizedSnapshot = nextSnapshot, currentSelection; + memoizedSnapshot = nextSnapshot; + return memoizedSelection = nextSelection; + } + var hasMemo = false, memoizedSnapshot, memoizedSelection, maybeGetServerSnapshot = void 0 === getServerSnapshot ? null : getServerSnapshot; + return [ + function() { + return memoizedSelector(getSnapshot()); + }, + null === maybeGetServerSnapshot ? void 0 : function() { + return memoizedSelector(maybeGetServerSnapshot()); + } + ]; + }, + [getSnapshot, getServerSnapshot, selector, isEqual] + ); + var value = useSyncExternalStore2(subscribe, instRef[0], instRef[1]); + useEffect15( + function() { + inst.hasValue = true; + inst.value = value; + }, + [value] + ); + useDebugValue2(value); + return value; + }; + "undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error()); + })(); + } +}); + +// node_modules/use-sync-external-store/shim/with-selector.js +var require_with_selector = __commonJS({ + "node_modules/use-sync-external-store/shim/with-selector.js"(exports, module) { + "use strict"; + if (false) { + module.exports = null; + } else { + module.exports = require_with_selector_development(); + } + } +}); + +// package-external:@wordpress/primitives +var require_primitives = __commonJS({ + "package-external:@wordpress/primitives"(exports, module) { + module.exports = window.wp.primitives; + } +}); + +// package-external:@wordpress/theme +var require_theme = __commonJS({ + "package-external:@wordpress/theme"(exports, module) { + module.exports = window.wp.theme; } }); @@ -66,6 +239,13 @@ var require_private_apis = __commonJS({ } }); +// package-external:@wordpress/components +var require_components = __commonJS({ + "package-external:@wordpress/components"(exports, module) { + module.exports = window.wp.components; + } +}); + // package-external:@wordpress/data var require_data = __commonJS({ "package-external:@wordpress/data"(exports, module) { @@ -110,27 +290,26 @@ function clsx() { } var clsx_default = clsx; -// packages/admin-ui/build-module/navigable-region/index.mjs -var import_element = __toESM(require_element(), 1); -var import_jsx_runtime = __toESM(require_jsx_runtime(), 1); -var NavigableRegion = (0, import_element.forwardRef)( - ({ children, className, ariaLabel, as: Tag = "div", ...props }, ref) => { - return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( - Tag, - { - ref, - className: clsx_default("admin-ui-navigable-region", className), - "aria-label": ariaLabel, - role: "region", - tabIndex: "-1", - ...props, - children - } - ); +// packages/ui/build-module/badge/badge.mjs +var import_element9 = __toESM(require_element(), 1); + +// node_modules/@base-ui/utils/esm/error.js +var set; +if (true) { + set = /* @__PURE__ */ new Set(); +} +function error(...messages) { + if (true) { + const messageKey = messages.join(" "); + if (!set.has(messageKey)) { + set.add(messageKey); + console.error(`Base UI: ${messageKey}`); + } } -); -NavigableRegion.displayName = "NavigableRegion"; -var navigable_region_default = NavigableRegion; +} + +// node_modules/@base-ui/utils/esm/useStableCallback.js +var React3 = __toESM(require_react(), 1); // node_modules/@base-ui/utils/esm/useRefWithInit.js var React2 = __toESM(require_react(), 1); @@ -143,8 +322,71 @@ function useRefWithInit(init, initArg) { return ref; } -// node_modules/@base-ui/react/esm/utils/useRenderElement.js +// node_modules/@base-ui/utils/esm/useStableCallback.js +var useInsertionEffect = React3[`useInsertionEffect${Math.random().toFixed(1)}`.slice(0, -3)]; +var useSafeInsertionEffect = ( + // React 17 doesn't have useInsertionEffect. + useInsertionEffect && // Preact replaces useInsertionEffect with useLayoutEffect and fires too late. + useInsertionEffect !== React3.useLayoutEffect ? useInsertionEffect : (fn) => fn() +); +function useStableCallback(callback) { + const stable = useRefWithInit(createStableCallback).current; + stable.next = callback; + useSafeInsertionEffect(stable.effect); + return stable.trampoline; +} +function createStableCallback() { + const stable = { + next: void 0, + callback: assertNotCalled, + trampoline: (...args) => stable.callback?.(...args), + effect: () => { + stable.callback = stable.next; + } + }; + return stable; +} +function assertNotCalled() { + if (true) { + throw ( + /* minify-error-disabled */ + new Error("Base UI: Cannot call an event handler while rendering.") + ); + } +} + +// node_modules/@base-ui/utils/esm/useIsoLayoutEffect.js +var React4 = __toESM(require_react(), 1); +var noop = () => { +}; +var useIsoLayoutEffect = typeof document !== "undefined" ? React4.useLayoutEffect : noop; + +// node_modules/@base-ui/utils/esm/warn.js +var set2; +if (true) { + set2 = /* @__PURE__ */ new Set(); +} +function warn(...messages) { + if (true) { + const messageKey = messages.join(" "); + if (!set2.has(messageKey)) { + set2.add(messageKey); + console.warn(`Base UI: ${messageKey}`); + } + } +} + +// node_modules/@base-ui/react/esm/internals/direction-context/DirectionContext.js var React5 = __toESM(require_react(), 1); +var DirectionContext = /* @__PURE__ */ React5.createContext(void 0); +if (true) DirectionContext.displayName = "DirectionContext"; +function useDirection() { + const context = React5.useContext(DirectionContext); + return context?.direction ?? "ltr"; +} + +// node_modules/@base-ui/react/esm/internals/useRenderElement.js +var React8 = __toESM(require_react(), 1); // node_modules/@base-ui/utils/esm/useMergedRefs.js function useMergedRefs(a, b, c, d) { @@ -172,7 +414,7 @@ function didChange(forkRef, a, b, c, d) { return forkRef.refs[0] !== a || forkRef.refs[1] !== b || forkRef.refs[2] !== c || forkRef.refs[3] !== d; } function didChangeN(forkRef, newRefs) { - return forkRef.refs.length !== newRefs.length || forkRef.refs.some((ref, index) => ref !== newRefs[index]); + return forkRef.refs.length !== newRefs.length || forkRef.refs.some((ref, index2) => ref !== newRefs[index2]); } function update(forkRef, refs) { forkRef.refs = refs; @@ -236,18 +478,18 @@ function update(forkRef, refs) { } // node_modules/@base-ui/utils/esm/getReactElementRef.js -var React4 = __toESM(require_react(), 1); +var React7 = __toESM(require_react(), 1); // node_modules/@base-ui/utils/esm/reactVersion.js -var React3 = __toESM(require_react(), 1); -var majorVersion = parseInt(React3.version, 10); +var React6 = __toESM(require_react(), 1); +var majorVersion = parseInt(React6.version, 10); function isReactVersionAtLeast(reactVersionToCheck) { return majorVersion >= reactVersionToCheck; } // node_modules/@base-ui/utils/esm/getReactElementRef.js function getReactElementRef(element) { - if (!/* @__PURE__ */ React4.isValidElement(element)) { + if (!/* @__PURE__ */ React7.isValidElement(element)) { return null; } const reactElement = element; @@ -272,7 +514,13 @@ function mergeObjects(a, b) { return void 0; } -// node_modules/@base-ui/react/esm/utils/getStateAttributesProps.js +// node_modules/@base-ui/utils/esm/empty.js +function NOOP() { +} +var EMPTY_ARRAY = Object.freeze([]); +var EMPTY_OBJECT = Object.freeze({}); + +// node_modules/@base-ui/react/esm/internals/getStateAttributesProps.js function getStateAttributesProps(state, customMapping) { const props = {}; for (const key in state) { @@ -306,20 +554,21 @@ function resolveStyle(style, state) { // node_modules/@base-ui/react/esm/merge-props/mergeProps.js var EMPTY_PROPS = {}; function mergeProps(a, b, c, d, e) { - let merged = { - ...resolvePropsGetter(a, EMPTY_PROPS) - }; + if (!c && !d && !e && !a) { + return createInitialMergedProps(b); + } + let merged = createInitialMergedProps(a); if (b) { - merged = mergeOne(merged, b); + merged = mergeInto(merged, b); } if (c) { - merged = mergeOne(merged, c); + merged = mergeInto(merged, c); } if (d) { - merged = mergeOne(merged, d); + merged = mergeInto(merged, d); } if (e) { - merged = mergeOne(merged, e); + merged = mergeInto(merged, e); } return merged; } @@ -328,22 +577,40 @@ function mergePropsN(props) { return EMPTY_PROPS; } if (props.length === 1) { - return resolvePropsGetter(props[0], EMPTY_PROPS); + return createInitialMergedProps(props[0]); } - let merged = { - ...resolvePropsGetter(props[0], EMPTY_PROPS) - }; + let merged = createInitialMergedProps(props[0]); for (let i = 1; i < props.length; i += 1) { - merged = mergeOne(merged, props[i]); + merged = mergeInto(merged, props[i]); } return merged; } -function mergeOne(merged, inputProps) { +function createInitialMergedProps(inputProps) { + if (isPropsGetter(inputProps)) { + return { + ...resolvePropsGetter(inputProps, EMPTY_PROPS) + }; + } + return copyInitialProps(inputProps); +} +function mergeInto(merged, inputProps) { if (isPropsGetter(inputProps)) { - return inputProps(merged); + return resolvePropsGetter(inputProps, merged); } return mutablyMergeInto(merged, inputProps); } +function copyInitialProps(inputProps) { + const copiedProps = { + ...inputProps + }; + for (const propName in copiedProps) { + const propValue = copiedProps[propName]; + if (isEventHandler(propName, propValue)) { + copiedProps[propName] = wrapEventHandler(propValue); + } + } + return copiedProps; +} function mutablyMergeInto(mergedProps, externalProps) { if (!externalProps) { return mergedProps; @@ -390,23 +657,36 @@ function mergeEventHandlers(ourHandler, theirHandler) { return ourHandler; } if (!ourHandler) { - return theirHandler; + return wrapEventHandler(theirHandler); } - return (event) => { + return (...args) => { + const event = args[0]; if (isSyntheticEvent(event)) { const baseUIEvent = event; makeEventPreventable(baseUIEvent); - const result2 = theirHandler(baseUIEvent); + const result2 = theirHandler(...args); if (!baseUIEvent.baseUIHandlerPrevented) { - ourHandler?.(baseUIEvent); + ourHandler?.(...args); } return result2; } - const result = theirHandler(event); - ourHandler?.(event); + const result = theirHandler(...args); + ourHandler?.(...args); return result; }; } +function wrapEventHandler(handler) { + if (!handler) { + return handler; + } + return (...args) => { + const event = args[0]; + if (isSyntheticEvent(event)) { + makeEventPreventable(event); + } + return handler(...args); + }; +} function makeEventPreventable(event) { event.preventBaseUIHandler = () => { event.baseUIHandlerPrevented = true; @@ -426,11 +706,7 @@ function isSyntheticEvent(event) { return event != null && typeof event === "object" && "nativeEvent" in event; } -// node_modules/@base-ui/utils/esm/empty.js -var EMPTY_ARRAY = Object.freeze([]); -var EMPTY_OBJECT = Object.freeze({}); - -// node_modules/@base-ui/react/esm/utils/useRenderElement.js +// node_modules/@base-ui/react/esm/internals/useRenderElement.js var import_react = __toESM(require_react(), 1); function useRenderElement(element, componentProps, params = {}) { const renderProp = componentProps.render; @@ -451,13 +727,14 @@ function useRenderElementProps(componentProps, params = {}) { state = EMPTY_OBJECT, ref, props, - stateAttributesMapping, + stateAttributesMapping: stateAttributesMapping3, enabled = true } = params; const className = enabled ? resolveClassName(classNameProp, state) : void 0; const style = enabled ? resolveStyle(styleProp, state) : void 0; - const stateProps = enabled ? getStateAttributesProps(state, stateAttributesMapping) : EMPTY_OBJECT; - const outProps = enabled ? mergeObjects(stateProps, Array.isArray(props) ? mergePropsN(props) : props) ?? EMPTY_OBJECT : EMPTY_OBJECT; + const stateProps = enabled ? getStateAttributesProps(state, stateAttributesMapping3) : EMPTY_OBJECT; + const resolvedProps = enabled && props ? resolveRenderFunctionProps(props) : void 0; + const outProps = enabled ? mergeObjects(stateProps, resolvedProps) ?? {} : EMPTY_OBJECT; if (typeof document !== "undefined") { if (!enabled) { useMergedRefs(null, null); @@ -467,199 +744,9847 @@ function useRenderElementProps(componentProps, params = {}) { outProps.ref = useMergedRefs(outProps.ref, getReactElementRef(renderProp), ref); } } - if (!enabled) { - return EMPTY_OBJECT; + if (!enabled) { + return EMPTY_OBJECT; + } + if (className !== void 0) { + outProps.className = mergeClassNames(outProps.className, className); + } + if (style !== void 0) { + outProps.style = mergeObjects(outProps.style, style); + } + return outProps; +} +function resolveRenderFunctionProps(props) { + if (Array.isArray(props)) { + return mergePropsN(props); + } + return mergeProps(void 0, props); +} +var REACT_LAZY_TYPE = /* @__PURE__ */ Symbol.for("react.lazy"); +var COMPONENT_IDENTIFIER_PATTERN = /^[A-Z][A-Za-z0-9$]*$/; +var LOWERCASE_CHARACTER_PATTERN = /[a-z]/; +function evaluateRenderProp(element, render, props, state) { + if (render) { + if (typeof render === "function") { + if (true) { + warnIfRenderPropLooksLikeComponent(render); + } + return render(props, state); + } + const mergedProps = mergeProps(props, render.props); + mergedProps.ref = props.ref; + let newElement = render; + if (newElement?.$$typeof === REACT_LAZY_TYPE) { + const children = React8.Children.toArray(render); + newElement = children[0]; + } + if (true) { + if (!/* @__PURE__ */ React8.isValidElement(newElement)) { + throw new Error(["Base UI: The `render` prop was provided an invalid React element as `React.isValidElement(render)` is `false`.", "A valid React element must be provided to the `render` prop because it is cloned with props to replace the default element.", "https://base-ui.com/r/invalid-render-prop"].join("\n")); + } + } + return /* @__PURE__ */ React8.cloneElement(newElement, mergedProps); + } + if (element) { + if (typeof element === "string") { + return renderTag(element, props); + } + } + throw new Error(true ? "Base UI: Render element or function are not defined." : formatErrorMessage_default(8)); +} +function warnIfRenderPropLooksLikeComponent(renderFn) { + const functionName = renderFn.name; + if (functionName.length === 0) { + return; + } + if (!COMPONENT_IDENTIFIER_PATTERN.test(functionName)) { + return; + } + if (!LOWERCASE_CHARACTER_PATTERN.test(functionName)) { + return; + } + warn(`The \`render\` prop received a function named \`${functionName}\` that starts with an uppercase letter.`, "This usually means a React component was passed directly as `render={Component}`.", "Base UI calls `render` as a plain function, which can break the Rules of Hooks during reconciliation.", "If this is an intentional render callback, rename it to start with a lowercase letter.", "Use `render={}` or `render={(props) => }` instead.", "https://base-ui.com/r/invalid-render-prop"); +} +function renderTag(Tag, props) { + if (Tag === "button") { + return /* @__PURE__ */ (0, import_react.createElement)("button", { + type: "button", + ...props, + key: props.key + }); + } + if (Tag === "img") { + return /* @__PURE__ */ (0, import_react.createElement)("img", { + alt: "", + ...props, + key: props.key + }); + } + return /* @__PURE__ */ React8.createElement(Tag, props); +} + +// node_modules/@base-ui/react/esm/internals/reason-parts.js +var reason_parts_exports = {}; +__export(reason_parts_exports, { + cancelOpen: () => cancelOpen, + chipRemovePress: () => chipRemovePress, + clearPress: () => clearPress, + closePress: () => closePress, + closeWatcher: () => closeWatcher, + decrementPress: () => decrementPress, + disabled: () => disabled, + drag: () => drag, + escapeKey: () => escapeKey, + focusOut: () => focusOut, + imperativeAction: () => imperativeAction, + incrementPress: () => incrementPress, + inputBlur: () => inputBlur, + inputChange: () => inputChange, + inputClear: () => inputClear, + inputPaste: () => inputPaste, + inputPress: () => inputPress, + itemPress: () => itemPress, + keyboard: () => keyboard, + linkPress: () => linkPress, + listNavigation: () => listNavigation, + none: () => none, + outsidePress: () => outsidePress, + pointer: () => pointer, + scrub: () => scrub, + siblingOpen: () => siblingOpen, + swipe: () => swipe, + trackPress: () => trackPress, + triggerFocus: () => triggerFocus, + triggerHover: () => triggerHover, + triggerPress: () => triggerPress, + wheel: () => wheel, + windowResize: () => windowResize +}); +var none = "none"; +var triggerPress = "trigger-press"; +var triggerHover = "trigger-hover"; +var triggerFocus = "trigger-focus"; +var outsidePress = "outside-press"; +var itemPress = "item-press"; +var closePress = "close-press"; +var linkPress = "link-press"; +var clearPress = "clear-press"; +var chipRemovePress = "chip-remove-press"; +var trackPress = "track-press"; +var incrementPress = "increment-press"; +var decrementPress = "decrement-press"; +var inputChange = "input-change"; +var inputClear = "input-clear"; +var inputBlur = "input-blur"; +var inputPaste = "input-paste"; +var inputPress = "input-press"; +var focusOut = "focus-out"; +var escapeKey = "escape-key"; +var closeWatcher = "close-watcher"; +var listNavigation = "list-navigation"; +var keyboard = "keyboard"; +var pointer = "pointer"; +var drag = "drag"; +var wheel = "wheel"; +var scrub = "scrub"; +var cancelOpen = "cancel-open"; +var siblingOpen = "sibling-open"; +var disabled = "disabled"; +var imperativeAction = "imperative-action"; +var swipe = "swipe"; +var windowResize = "window-resize"; + +// node_modules/@base-ui/react/esm/internals/createBaseUIEventDetails.js +function createChangeEventDetails(reason, event, trigger, customProperties) { + let canceled = false; + let allowPropagation = false; + const custom = customProperties ?? EMPTY_OBJECT; + const details = { + reason, + event: event ?? new Event("base-ui"), + cancel() { + canceled = true; + }, + allowPropagation() { + allowPropagation = true; + }, + get isCanceled() { + return canceled; + }, + get isPropagationAllowed() { + return allowPropagation; + }, + trigger, + ...custom + }; + return details; +} + +// node_modules/@base-ui/utils/esm/useId.js +var React10 = __toESM(require_react(), 1); + +// node_modules/@base-ui/utils/esm/safeReact.js +var React9 = __toESM(require_react(), 1); +var SafeReact = { + ...React9 +}; + +// node_modules/@base-ui/utils/esm/useId.js +var globalId = 0; +function useGlobalId(idOverride, prefix = "mui") { + const [defaultId, setDefaultId] = React10.useState(idOverride); + const id = idOverride || defaultId; + React10.useEffect(() => { + if (defaultId == null) { + globalId += 1; + setDefaultId(`${prefix}-${globalId}`); + } + }, [defaultId, prefix]); + return id; +} +var maybeReactUseId = SafeReact.useId; +function useId(idOverride, prefix) { + if (maybeReactUseId !== void 0) { + const reactId = maybeReactUseId(); + return idOverride ?? (prefix ? `${prefix}-${reactId}` : reactId); + } + return useGlobalId(idOverride, prefix); +} + +// node_modules/@base-ui/react/esm/internals/useBaseUiId.js +function useBaseUiId(idOverride) { + return useId(idOverride, "base-ui"); +} + +// node_modules/@base-ui/react/esm/internals/useAnimationsFinished.js +var ReactDOM = __toESM(require_react_dom(), 1); + +// node_modules/@base-ui/utils/esm/useOnMount.js +var React11 = __toESM(require_react(), 1); +var EMPTY = []; +function useOnMount(fn) { + React11.useEffect(fn, EMPTY); +} + +// node_modules/@base-ui/utils/esm/useAnimationFrame.js +var EMPTY2 = null; +var LAST_RAF = globalThis.requestAnimationFrame; +var Scheduler = class { + /* This implementation uses an array as a backing data-structure for frame callbacks. + * It allows `O(1)` callback cancelling by inserting a `null` in the array, though it + * never calls the native `cancelAnimationFrame` if there are no frames left. This can + * be much more efficient if there is a call pattern that alterns as + * "request-cancel-request-cancel-…". + * But in the case of "request-request-…-cancel-cancel-…", it leaves the final animation + * frame to run anyway. We turn that frame into a `O(1)` no-op via `callbacksCount`. */ + callbacks = []; + callbacksCount = 0; + nextId = 1; + startId = 1; + isScheduled = false; + tick = (timestamp) => { + this.isScheduled = false; + const currentCallbacks = this.callbacks; + const currentCallbacksCount = this.callbacksCount; + this.callbacks = []; + this.callbacksCount = 0; + this.startId = this.nextId; + if (currentCallbacksCount > 0) { + for (let i = 0; i < currentCallbacks.length; i += 1) { + currentCallbacks[i]?.(timestamp); + } + } + }; + request(fn) { + const id = this.nextId; + this.nextId += 1; + this.callbacks.push(fn); + this.callbacksCount += 1; + const didRAFChange = LAST_RAF !== requestAnimationFrame && (LAST_RAF = requestAnimationFrame, true); + if (!this.isScheduled || didRAFChange) { + requestAnimationFrame(this.tick); + this.isScheduled = true; + } + return id; + } + cancel(id) { + const index2 = id - this.startId; + if (index2 < 0 || index2 >= this.callbacks.length) { + return; + } + this.callbacks[index2] = null; + this.callbacksCount -= 1; + } +}; +var scheduler = new Scheduler(); +var AnimationFrame = class _AnimationFrame { + static create() { + return new _AnimationFrame(); + } + static request(fn) { + return scheduler.request(fn); + } + static cancel(id) { + return scheduler.cancel(id); + } + currentId = EMPTY2; + /** + * Executes `fn` after `delay`, clearing any previously scheduled call. + */ + request(fn) { + this.cancel(); + this.currentId = scheduler.request(() => { + this.currentId = EMPTY2; + fn(); + }); + } + cancel = () => { + if (this.currentId !== EMPTY2) { + scheduler.cancel(this.currentId); + this.currentId = EMPTY2; + } + }; + disposeEffect = () => { + return this.cancel; + }; +}; +function useAnimationFrame() { + const timeout = useRefWithInit(AnimationFrame.create).current; + useOnMount(timeout.disposeEffect); + return timeout; +} + +// node_modules/@base-ui/react/esm/utils/resolveRef.js +function resolveRef(maybeRef) { + if (maybeRef == null) { + return maybeRef; + } + return "current" in maybeRef ? maybeRef.current : maybeRef; +} + +// node_modules/@base-ui/react/esm/internals/stateAttributesMapping.js +var TransitionStatusDataAttributes = /* @__PURE__ */ (function(TransitionStatusDataAttributes2) { + TransitionStatusDataAttributes2["startingStyle"] = "data-starting-style"; + TransitionStatusDataAttributes2["endingStyle"] = "data-ending-style"; + return TransitionStatusDataAttributes2; +})({}); +var STARTING_HOOK = { + [TransitionStatusDataAttributes.startingStyle]: "" +}; +var ENDING_HOOK = { + [TransitionStatusDataAttributes.endingStyle]: "" +}; +var transitionStatusMapping = { + transitionStatus(value) { + if (value === "starting") { + return STARTING_HOOK; + } + if (value === "ending") { + return ENDING_HOOK; + } + return null; + } +}; + +// node_modules/@base-ui/react/esm/internals/useAnimationsFinished.js +function useAnimationsFinished(elementOrRef, waitForStartingStyleRemoved = false, treatAbortedAsFinished = true) { + const frame = useAnimationFrame(); + return useStableCallback((fnToExecute, signal = null) => { + frame.cancel(); + const element = resolveRef(elementOrRef); + if (element == null) { + return; + } + const resolvedElement = element; + const done = () => { + ReactDOM.flushSync(fnToExecute); + }; + if (typeof resolvedElement.getAnimations !== "function" || globalThis.BASE_UI_ANIMATIONS_DISABLED) { + fnToExecute(); + return; + } + function exec() { + Promise.all(resolvedElement.getAnimations().map((animation) => animation.finished)).then(() => { + if (!signal?.aborted) { + done(); + } + }).catch(() => { + if (treatAbortedAsFinished) { + if (!signal?.aborted) { + done(); + } + return; + } + const currentAnimations = resolvedElement.getAnimations(); + if (!signal?.aborted && currentAnimations.length > 0 && currentAnimations.some((animation) => animation.pending || animation.playState !== "finished")) { + exec(); + } + }); + } + if (waitForStartingStyleRemoved) { + const startingStyleAttribute = TransitionStatusDataAttributes.startingStyle; + if (!resolvedElement.hasAttribute(startingStyleAttribute)) { + frame.request(exec); + return; + } + const attributeObserver = new MutationObserver(() => { + if (!resolvedElement.hasAttribute(startingStyleAttribute)) { + attributeObserver.disconnect(); + exec(); + } + }); + attributeObserver.observe(resolvedElement, { + attributes: true, + attributeFilter: [startingStyleAttribute] + }); + signal?.addEventListener("abort", () => attributeObserver.disconnect(), { + once: true + }); + return; + } + frame.request(exec); + }); +} + +// node_modules/@base-ui/react/esm/internals/useTransitionStatus.js +var React12 = __toESM(require_react(), 1); +function useTransitionStatus(open, enableIdleState = false, deferEndingState = false) { + const [transitionStatus, setTransitionStatus] = React12.useState(open && enableIdleState ? "idle" : void 0); + const [mounted, setMounted] = React12.useState(open); + if (open && !mounted) { + setMounted(true); + setTransitionStatus("starting"); + } + if (!open && mounted && transitionStatus !== "ending" && !deferEndingState) { + setTransitionStatus("ending"); + } + if (!open && !mounted && transitionStatus === "ending") { + setTransitionStatus(void 0); + } + useIsoLayoutEffect(() => { + if (!open && mounted && transitionStatus !== "ending" && deferEndingState) { + const frame = AnimationFrame.request(() => { + setTransitionStatus("ending"); + }); + return () => { + AnimationFrame.cancel(frame); + }; + } + return void 0; + }, [open, mounted, transitionStatus, deferEndingState]); + useIsoLayoutEffect(() => { + if (!open || enableIdleState) { + return void 0; + } + const frame = AnimationFrame.request(() => { + setTransitionStatus(void 0); + }); + return () => { + AnimationFrame.cancel(frame); + }; + }, [enableIdleState, open]); + useIsoLayoutEffect(() => { + if (!open || !enableIdleState) { + return void 0; + } + if (open && mounted && transitionStatus !== "idle") { + setTransitionStatus("starting"); + } + const frame = AnimationFrame.request(() => { + setTransitionStatus("idle"); + }); + return () => { + AnimationFrame.cancel(frame); + }; + }, [enableIdleState, open, mounted, transitionStatus]); + return { + mounted, + setMounted, + transitionStatus + }; +} + +// node_modules/@base-ui/react/esm/internals/use-button/useButton.js +var React15 = __toESM(require_react(), 1); + +// node_modules/@floating-ui/utils/dist/floating-ui.utils.dom.mjs +function hasWindow() { + return typeof window !== "undefined"; +} +function getNodeName(node) { + if (isNode(node)) { + return (node.nodeName || "").toLowerCase(); + } + return "#document"; +} +function getWindow(node) { + var _node$ownerDocument; + return (node == null || (_node$ownerDocument = node.ownerDocument) == null ? void 0 : _node$ownerDocument.defaultView) || window; +} +function getDocumentElement(node) { + var _ref; + return (_ref = (isNode(node) ? node.ownerDocument : node.document) || window.document) == null ? void 0 : _ref.documentElement; +} +function isNode(value) { + if (!hasWindow()) { + return false; + } + return value instanceof Node || value instanceof getWindow(value).Node; +} +function isElement(value) { + if (!hasWindow()) { + return false; + } + return value instanceof Element || value instanceof getWindow(value).Element; +} +function isHTMLElement(value) { + if (!hasWindow()) { + return false; + } + return value instanceof HTMLElement || value instanceof getWindow(value).HTMLElement; +} +function isShadowRoot(value) { + if (!hasWindow() || typeof ShadowRoot === "undefined") { + return false; + } + return value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot; +} +function isOverflowElement(element) { + const { + overflow, + overflowX, + overflowY, + display + } = getComputedStyle2(element); + return /auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) && display !== "inline" && display !== "contents"; +} +function isTableElement(element) { + return /^(table|td|th)$/.test(getNodeName(element)); +} +function isTopLayer(element) { + try { + if (element.matches(":popover-open")) { + return true; + } + } catch (_e) { + } + try { + return element.matches(":modal"); + } catch (_e) { + return false; + } +} +var willChangeRe = /transform|translate|scale|rotate|perspective|filter/; +var containRe = /paint|layout|strict|content/; +var isNotNone = (value) => !!value && value !== "none"; +var isWebKitValue; +function isContainingBlock(elementOrCss) { + const css = isElement(elementOrCss) ? getComputedStyle2(elementOrCss) : elementOrCss; + return isNotNone(css.transform) || isNotNone(css.translate) || isNotNone(css.scale) || isNotNone(css.rotate) || isNotNone(css.perspective) || !isWebKit() && (isNotNone(css.backdropFilter) || isNotNone(css.filter)) || willChangeRe.test(css.willChange || "") || containRe.test(css.contain || ""); +} +function getContainingBlock(element) { + let currentNode = getParentNode(element); + while (isHTMLElement(currentNode) && !isLastTraversableNode(currentNode)) { + if (isContainingBlock(currentNode)) { + return currentNode; + } else if (isTopLayer(currentNode)) { + return null; + } + currentNode = getParentNode(currentNode); + } + return null; +} +function isWebKit() { + if (isWebKitValue == null) { + isWebKitValue = typeof CSS !== "undefined" && CSS.supports && CSS.supports("-webkit-backdrop-filter", "none"); + } + return isWebKitValue; +} +function isLastTraversableNode(node) { + return /^(html|body|#document)$/.test(getNodeName(node)); +} +function getComputedStyle2(element) { + return getWindow(element).getComputedStyle(element); +} +function getNodeScroll(element) { + if (isElement(element)) { + return { + scrollLeft: element.scrollLeft, + scrollTop: element.scrollTop + }; + } + return { + scrollLeft: element.scrollX, + scrollTop: element.scrollY + }; +} +function getParentNode(node) { + if (getNodeName(node) === "html") { + return node; + } + const result = ( + // Step into the shadow DOM of the parent of a slotted node. + node.assignedSlot || // DOM Element detected. + node.parentNode || // ShadowRoot detected. + isShadowRoot(node) && node.host || // Fallback. + getDocumentElement(node) + ); + return isShadowRoot(result) ? result.host : result; +} +function getNearestOverflowAncestor(node) { + const parentNode = getParentNode(node); + if (isLastTraversableNode(parentNode)) { + return node.ownerDocument ? node.ownerDocument.body : node.body; + } + if (isHTMLElement(parentNode) && isOverflowElement(parentNode)) { + return parentNode; + } + return getNearestOverflowAncestor(parentNode); +} +function getOverflowAncestors(node, list, traverseIframes) { + var _node$ownerDocument2; + if (list === void 0) { + list = []; + } + if (traverseIframes === void 0) { + traverseIframes = true; + } + const scrollableAncestor = getNearestOverflowAncestor(node); + const isBody = scrollableAncestor === ((_node$ownerDocument2 = node.ownerDocument) == null ? void 0 : _node$ownerDocument2.body); + const win = getWindow(scrollableAncestor); + if (isBody) { + const frameElement = getFrameElement(win); + return list.concat(win, win.visualViewport || [], isOverflowElement(scrollableAncestor) ? scrollableAncestor : [], frameElement && traverseIframes ? getOverflowAncestors(frameElement) : []); + } else { + return list.concat(scrollableAncestor, getOverflowAncestors(scrollableAncestor, [], traverseIframes)); + } +} +function getFrameElement(win) { + return win.parent && Object.getPrototypeOf(win.parent) ? win.frameElement : null; +} + +// node_modules/@base-ui/react/esm/internals/composite/root/CompositeRootContext.js +var React13 = __toESM(require_react(), 1); +var CompositeRootContext = /* @__PURE__ */ React13.createContext(void 0); +if (true) CompositeRootContext.displayName = "CompositeRootContext"; +function useCompositeRootContext(optional = false) { + const context = React13.useContext(CompositeRootContext); + if (context === void 0 && !optional) { + throw new Error(true ? "Base UI: CompositeRootContext is missing. Composite parts must be placed within ." : formatErrorMessage_default(16)); + } + return context; +} + +// node_modules/@base-ui/react/esm/utils/useFocusableWhenDisabled.js +var React14 = __toESM(require_react(), 1); +function useFocusableWhenDisabled(parameters) { + const { + focusableWhenDisabled, + disabled: disabled2, + composite = false, + tabIndex: tabIndexProp = 0, + isNativeButton + } = parameters; + const isFocusableComposite = composite && focusableWhenDisabled !== false; + const isNonFocusableComposite = composite && focusableWhenDisabled === false; + const props = React14.useMemo(() => { + const additionalProps = { + // allow Tabbing away from focusableWhenDisabled elements + onKeyDown(event) { + if (disabled2 && focusableWhenDisabled && event.key !== "Tab") { + event.preventDefault(); + } + } + }; + if (!composite) { + additionalProps.tabIndex = tabIndexProp; + if (!isNativeButton && disabled2) { + additionalProps.tabIndex = focusableWhenDisabled ? tabIndexProp : -1; + } + } + if (isNativeButton && (focusableWhenDisabled || isFocusableComposite) || !isNativeButton && disabled2) { + additionalProps["aria-disabled"] = disabled2; + } + if (isNativeButton && (!focusableWhenDisabled || isNonFocusableComposite)) { + additionalProps.disabled = disabled2; + } + return additionalProps; + }, [composite, disabled2, focusableWhenDisabled, isFocusableComposite, isNonFocusableComposite, isNativeButton, tabIndexProp]); + return { + props + }; +} + +// node_modules/@base-ui/react/esm/internals/use-button/useButton.js +function useButton(parameters = {}) { + const { + disabled: disabled2 = false, + focusableWhenDisabled, + tabIndex = 0, + native: isNativeButton = true, + composite: compositeProp + } = parameters; + const elementRef = React15.useRef(null); + const compositeRootContext = useCompositeRootContext(true); + const isCompositeItem = compositeProp ?? compositeRootContext !== void 0; + const { + props: focusableWhenDisabledProps + } = useFocusableWhenDisabled({ + focusableWhenDisabled, + disabled: disabled2, + composite: isCompositeItem, + tabIndex, + isNativeButton + }); + if (true) { + React15.useEffect(() => { + if (!elementRef.current) { + return; + } + const isButtonTag = isButtonElement(elementRef.current); + if (isNativeButton) { + if (!isButtonTag) { + const ownerStackMessage = SafeReact.captureOwnerStack?.() || ""; + const message = "A component that acts as a button expected a native