From 4b357c98cd439d8280f71e6036f8c4a02abf0415 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 30 Jul 2026 17:08:49 +0530 Subject: [PATCH 1/2] refactor(uts): share test infra via testFixtures, move objects unit suite into :liveobjects Three related changes from the cross-SDK objects audit follow-up: 1. Shared UTS test infra (mock transport, FakeClock, SandboxApp, proxy control) moves from :uts's src/test to its src/testFixtures variant, so other modules can consume it via testFixtures(project(":uts")). Acyclicity invariant documented: :liveobjects test -> :uts testFixtures -> :java, with :uts test -> :liveobjects kept runtime-only. 2. The objects UTS unit suite moves out of :uts into the :liveobjects module's own test source set (package io.ably.lib.liveobjects.uts.unit) so the internal-graph specs can reach `internal` members directly. Coverage expands: adds InternalLiveCounter/Map, ObjectId, ObjectsPool and ParentReferences suites. runLiveObjectsUnitTests now covers both .unit.* and .uts.unit.*. 3. Spec-conformance in production source: - op-path applyObject/applyOperation now returns the ObjectUpdate instead of a Boolean (RTLC9g/RTLM7f); the RTO9a2a4 on-ack gate uses !update.noOp. - root object is excluded from GC (RTO10c1b1) and rejects tombstone attempts (RTLO4e10); both covered by new tests. Deviations recorded in liveobjects/.../uts/deviations.md. Unit suites and the CI static-analysis gate are green. --- .claude/skills/uts-to-kotlin/SKILL.md | 61 +- .../references/objects-mapping.md | 274 +++- .../scripts/audit_translation.py | 2 +- .../uts-to-kotlin/scripts/resolve_uts.py | 11 +- .../uts-to-kotlin/uts-package-mapping.json | 7 +- FUTURE_WORK_UTS_INFRA.md | 261 ++++ liveobjects/build.gradle.kts | 7 +- .../lib/liveobjects/DefaultRealtimeObject.kt | 2 +- .../io/ably/lib/liveobjects/ObjectsManager.kt | 9 +- .../io/ably/lib/liveobjects/ObjectsPool.kt | 6 +- .../value/BaseRealtimeLiveObject.kt | 26 +- .../value/livecounter/InternalLiveCounter.kt | 2 +- .../value/livecounter/LiveCounterManager.kt | 12 +- .../value/livemap/InternalLiveMap.kt | 2 +- .../value/livemap/LiveMapManager.kt | 18 +- .../liveobjects/integration/setup/Sandbox.kt | 58 +- .../DefaultRealtimeObjectChannelStateTest.kt | 84 ++ .../unit/LiveObjectTombstoneTest.kt | 89 ++ .../io/ably/lib/liveobjects/uts/README.md | 14 + .../io/ably/lib/liveobjects/uts/deviations.md | 135 ++ .../ably/lib/liveobjects/uts/unit/Helpers.kt | 408 ++++++ .../lib/liveobjects/uts/unit/InstanceTest.kt | 420 ++++++ .../uts/unit/InternalLiveCounterApiTest.kt | 165 +++ .../uts/unit/InternalLiveCounterTest.kt | 528 +++++++ .../uts/unit/InternalLiveMapApiTest.kt | 325 +++++ .../uts/unit/InternalLiveMapTest.kt | 929 +++++++++++++ .../uts/unit/LiveObjectSubscribeTest.kt | 331 +++++ .../lib/liveobjects/uts/unit/ObjectIdTest.kt | 129 ++ .../liveobjects/uts/unit/ObjectsPoolTest.kt | 796 +++++++++++ .../uts/unit/ParentReferencesTest.kt | 545 ++++++++ .../uts/unit/PathObjectMutationsTest.kt | 219 +++ .../uts/unit}/PathObjectSubscribeTest.kt | 360 +++-- .../liveobjects/uts/unit/PathObjectTest.kt | 515 +++++++ .../uts/unit/PublicObjectMessageTest.kt | 378 +++++ .../uts/unit/RealtimeObjectTest.kt | 1214 +++++++++++++++++ .../liveobjects/uts/unit/ValueTypesTest.kt | 363 +++++ uts/README.md | 111 +- uts/build.gradle.kts | 16 +- .../test/kotlin/io/ably/lib/uts/deviations.md | 276 +--- .../io/ably/lib/uts/private_deviations.md | 286 ---- .../ably/lib/uts/unit/liveobjects/Helpers.kt | 419 ------ .../lib/uts/unit/liveobjects/InstanceTest.kt | 370 ----- .../liveobjects/InternalLiveCounterApiTest.kt | 186 --- .../liveobjects/InternalLiveMapApiTest.kt | 284 ---- .../liveobjects/LiveObjectSubscribeTest.kt | 303 ---- .../liveobjects/PathObjectMutationsTest.kt | 194 --- .../uts/unit/liveobjects/PathObjectTest.kt | 451 ------ .../liveobjects/PublicObjectMessageTest.kt | 382 ------ .../unit/liveobjects/RealtimeObjectTest.kt | 847 ------------ .../uts/unit/liveobjects/ValueTypesTest.kt | 329 ----- .../kotlin/io/ably/lib/uts/infra/Utils.kt | 0 .../lib/uts/infra/integration/SandboxApp.kt | 0 .../infra/integration/proxy/ProxyManager.kt | 0 .../infra/integration/proxy/ProxySession.kt | 0 .../lib/uts/infra/unit/ClientFactories.kt | 0 .../infra/unit/DefaultPendingConnection.kt | 0 .../uts/infra/unit/DefaultPendingRequest.kt | 0 .../io/ably/lib/uts/infra/unit/FakeClock.kt | 0 .../io/ably/lib/uts/infra/unit/MockEvent.kt | 0 .../ably/lib/uts/infra/unit/MockHttpClient.kt | 0 .../ably/lib/uts/infra/unit/MockHttpEngine.kt | 0 .../ably/lib/uts/infra/unit/MockWebSocket.kt | 0 .../infra/unit/MockWebSocketEngineFactory.kt | 0 .../lib/uts/infra/unit/PendingConnection.kt | 0 .../ably/lib/uts/infra/unit/PendingRequest.kt | 0 .../io/ably/lib/uts/infra/unit/Utils.kt | 0 66 files changed, 8538 insertions(+), 4621 deletions(-) create mode 100644 FUTURE_WORK_UTS_INFRA.md create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/DefaultRealtimeObjectChannelStateTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/LiveObjectTombstoneTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InstanceTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterApiTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapApiTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/LiveObjectSubscribeTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectIdTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectsPoolTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ParentReferencesTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectMutationsTest.kt rename {uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects => liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit}/PathObjectSubscribeTest.kt (58%) create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PublicObjectMessageTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/RealtimeObjectTest.kt create mode 100644 liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ValueTypesTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveCounterApiTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveMapApiTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/Utils.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt (100%) rename uts/src/{test => testFixtures}/kotlin/io/ably/lib/uts/infra/unit/Utils.kt (100%) diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index 7c991e5ed..052e8f4b2 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -61,6 +61,13 @@ Phase 2** — see Step 1. The target dirs come from `uts-package-mapping.json` (alongside this skill); spec and ably-java module names don't always match (e.g. `objects` → `liveobjects`), which is why it's explicit. +A tier's mapping value is either a **string** (a path relative to the global `testRoot`, i.e. inside the +`:uts` module) or an **object** `{root, path}` carrying its own module root — used when a tier's tests live +in another Gradle module's test source set (e.g. `objects`/`unit` → +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects` + `uts/unit`, because those specs assert on +`:liveobjects` internals only visible to that module's own tests). The resolver handles both forms; its +`targetDir`/`package` output is what you use either way. + - **If `mapped` is `true`**: show the resolved `targetDir` for each present tier and ask the user to confirm. If they say the mapping is wrong, ask for the correct ably-java module base name and re-run with `--create` (below) to overwrite the entry, then re-resolve. @@ -157,7 +164,11 @@ integration tests** section; **proxy** → the proxy subsections of the **Integr > the *what's available*; the per-file list below is the *what to open for exact signatures* before > writing code. -Infrastructure is split by tier under `uts/src/test/kotlin/io/ably/lib/uts/infra/`: +Infrastructure lives in `:uts`'s **test-fixtures** variant — +`uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/` — so other Gradle modules can consume it via +`testImplementation(testFixtures(project(":uts")))`; `:uts`'s own tests see it automatically. +Kotlin packages are `io.ably.lib.uts.infra.*` regardless of source set, so imports in generated +tests are unaffected. It is split by tier: - `infra/Utils.kt` — shared async helpers (`awaitState`, `awaitChannelState`, `pollUntil`), package `io.ably.lib.uts.infra`. - `infra/unit/` — unit-test mocks/factories (`ClientFactories.kt`, `MockWebSocket.kt`, `MockHttpClient.kt`, `FakeClock.kt`, `MockEvent.kt`, the `PendingConnection`/`PendingRequest` pairs, and `Utils.kt` with the `ConnectionDetails { }` builder), package `io.ably.lib.uts.infra.unit`. @@ -165,6 +176,16 @@ Infrastructure is split by tier under `uts/src/test/kotlin/io/ably/lib/uts/infra For a **unit** test, read all files under `infra/unit/` plus `infra/Utils.kt` before generating any code (you need exact method signatures). +**Objects/unit additionally:** the suite lives in `:liveobjects`'s own test source set (Step B), so also +read the module-local helpers at +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt` (package +`io.ably.lib.liveobjects.uts.unit`) — `setupSyncedChannel`, the `build_*` message builders (typed `Wire*` +constructions, no JSON/reflection), `STANDARD_POOL_OBJECTS` and the canonical serial constants. Access +convention for that suite: **public-tier specs use only the public API + helpers**; only the five +internal-graph specs (`internal_live_counter`, `internal_live_map`, `object_id`, `objects_pool`, +`parent_references`) and documented deviations may reference `io.ably.lib.liveobjects` `internal` members — +their symbol map is `references/objects-mapping.md` §17. + ## Step 4 — Generate the Kotlin test file Apply the translation rules below, then write the file. @@ -355,6 +376,11 @@ This scaffold is for the **unit** tier — it wires the mocked transport (`infra `ConnectionDetails`). For the **integration** (direct sandbox) and **proxy** tiers, start from the **Proxy integration tests** section instead (`SandboxApp` / `ProxySession` wiring), not from this template. +For **objects/unit** the same scaffold applies with two differences: the package is the resolver's +`io.ably.lib.liveobjects.uts.unit`, and channel/objects setup goes through the module-local helpers +(`setupSyncedChannel` etc. from the same package — see Step 3) rather than raw `MockWebSocket` wiring. +The `io.ably.lib.uts.infra.*` imports are unchanged. + ```kotlin package // the resolver's package for the chosen tier (Step 2) @@ -409,8 +435,12 @@ class { ## Step 5 — Compile +Compile the module that owns the chosen tier's `targetDir` (`:uts` unless the mapping's object form says +otherwise): + ```bash -./gradlew :uts:compileTestKotlin +./gradlew :uts:compileTestKotlin # tiers inside the :uts module +./gradlew :liveobjects:compileTestKotlin # objects/unit (targetDir under liveobjects/) ``` Fix any compilation errors and recompile until clean. Common issues: @@ -430,18 +460,21 @@ every failure via the decision tree below. Each test must end in exactly one of - a documented **UTS spec error** — **fails fast** (the spec is wrong; fix belongs in the spec). This is the one acceptable red. -Use the per-tier task that matches the chosen tier (both are registered in `uts/build.gradle.kts`), and the -resolver's `package` + the spec's `className` for the `--tests` filter: +Use the per-tier task of the module that owns the tier's `targetDir`, and the resolver's `package` + the +spec's `className` for the `--tests` filter: ```bash -# unit tier → io.ably.lib.uts.unit.* +# unit tier, tiers inside :uts → io.ably.lib.uts.unit.* ./gradlew :uts:runUtsUnitTests --tests "." -# integration / proxy → io.ably.lib.uts.integration.* +# objects/unit (in :liveobjects) → io.ably.lib.liveobjects.uts.unit.* +./gradlew :liveobjects:runLiveObjectsUnitTests --tests "." + +# integration / proxy → io.ably.lib.uts.integration.* ./gradlew :uts:runUtsIntegrationTests --tests "." ``` -(`./gradlew :uts:test` still runs all tiers — unit, standard, and proxy.) +(`./gradlew :uts:test` still runs all `:uts` tiers — unit, standard, and proxy.) Handle test failures using this decision tree (the **Required reading** doc you fetched up front has the full detail): @@ -454,7 +487,7 @@ Test fails | YES | +-- Does test accurately translate the UTS spec? | NO → fix the test (no deviation entry needed) - | YES → SDK deviation — adapt test, record in deviations file + | YES → SDK deviation — env-gated skip or adapted assertion (below); record in deviations file ``` ### Test patterns for a diagnosed failure @@ -462,7 +495,7 @@ Test fails Two patterns are for an **SDK deviation** (both write the spec-correct assertions); the third, **spec-error fail-fast**, is for a **UTS spec error** and is not a deviation. -**Env-gated skip (preferred)** — test contains spec-correct assertions but is skipped by default: +**Env-gated skip** (preferred *for a deviation you expect to be fixed*) — test contains spec-correct assertions but is skipped by default: ```kotlin /** @@ -477,7 +510,7 @@ fun `RSA4c2 - callback error connecting disconnected`() = runTest { } ``` -**Adapted assertion** — when you still want to assert on the SDK's actual behaviour to prevent regressions: +**Adapted assertion** — assert the SDK's actual behaviour to prevent regressions. **Prefer this over an env-gated skip when the divergence is permanent or intentional** (a running test guards regressions; a permanently-skipped spec assertion verifies nothing): ```kotlin // DEVIATION: spec requires error code 40106, SDK returns 40160 — see deviations.md @@ -502,8 +535,10 @@ fun `RTLC7c2 - LOCAL source does not write siteTimeserials`() = runTest { ### Deviations file -Append to `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`, using the manual's **Recording deviations** -entry format and sections. The ably-java-specific mapping: a **UTS Spec Error** (test fails fast — fix in +Append to the deviations file that belongs to the tier's module: +`uts/src/test/kotlin/io/ably/lib/uts/deviations.md` for tiers inside `:uts`, or +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md` for objects/unit. Use the manual's +**Recording deviations** entry format and sections. The ably-java-specific mapping: a **UTS Spec Error** (test fails fast — fix in the spec) goes under the manual's *UTS Spec Errors* section; an **SDK deviation** (env-gated/adapted — fix in the SDK) goes under *Failing Tests* / *Adapted Tests*. @@ -668,7 +703,7 @@ Use generous timeouts (10–30s) — real network is involved. Everything else i ### Infrastructure -Three helpers live under `uts/src/test/kotlin/io/ably/lib/uts/infra/integration/`. **Read the ones your tier uses before translating an integration spec** — they hold the exact method signatures. `SandboxApp` serves **both** tiers; `ProxyManager` and `ProxySession` are **proxy-only**. +Three helpers live under `uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/`. **Read the ones your tier uses before translating an integration spec** — they hold the exact method signatures. `SandboxApp` serves **both** tiers; `ProxyManager` and `ProxySession` are **proxy-only**. - **`ProxyManager`** (`infra/integration/proxy/ProxyManager.kt`, package `io.ably.lib.uts.infra.integration.proxy`) — downloads/starts the shared `uts-proxy` process. Call `ProxyManager.ensureProxy()` once per suite in setup. - **`ProxySession`** (`infra/integration/proxy/ProxySession.kt`, same package) — one programmable session wrapping the proxy control API; also defines the `connectThroughProxy` extension and the rule-builder helpers. diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md index 9f64ce661..18770c788 100644 --- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md @@ -30,6 +30,7 @@ doubt, that IDL is the source of truth; this doc is the applied version of it fo 14. [Integration-test helpers — REST fixture provisioning](#14-integration-helpers) 15. [Worked example](#15-worked-example) 16. [Quick symbol index](#16-symbol-index) +17. [Internal-graph symbol map (unit specs → `:liveobjects`'s own tests)](#17-internal-mappings) --- @@ -42,7 +43,7 @@ different things**, and a third *internal* layer underneath. Keep them straight: |---|---|---|---|---| | **Creation value type** — immutable blueprint you pass *into* `set` | `LiveMap` / `LiveCounter` (the `RTLMV*` / `RTLCV*` classes) | `LiveMap.create()` / `LiveCounter.create()` | `LiveMap` / `LiveCounter` | `io.ably.lib.liveobjects.value` | | **Public read/write view** — what you navigate & subscribe on | `PathObject`, `Instance` | `PathObject`, `Instance` | typed hierarchy (§4, §5) | base in `io.ably.lib.liveobjects.path` / `.instance`; **typed subtypes in `.path.types` / `.instance.types`** | -| **Internal graph object** — the live CRDT node | `InternalLiveMap` / `InternalLiveCounter` (`RTLM*` / `RTLC*`), `ObjectsPool` | internal | `DefaultLiveMap` / `DefaultLiveCounter` etc. (impl, `:liveobjects` module) | not public — see §13 | +| **Internal graph object** — the live CRDT node | `InternalLiveMap` / `InternalLiveCounter` (`RTLM*` / `RTLC*`), `ObjectsPool` | internal | `InternalLiveMap` / `InternalLiveCounter` / `ObjectsPool` — **same names as the spec** (`internal` classes, `:liveobjects` module) | `io.ably.lib.liveobjects[.value.livemap/.value.livecounter]` — not public; access rules §13, full symbol map §17 | So when a spec says `counter = LiveCounter.create(5)` and passes it to `set`, that's the **value type** (`io.ably.lib.liveobjects.value.LiveCounter`). When a spec says "the resolved value is an @@ -503,9 +504,10 @@ Several **unit** specs assert on the **internal CRDT graph**, not the public API - `objects_pool.md`, `parent_references.md`, `object_id.md` — pool sync state, the reverse parent-reference graph, and object-id generation: entirely internal. -- the internal-state assertions in `live_counter.md` / `live_map.md` (`.data`, `.siteTimeserials`, - `.createOperationIsMerged`, `.isTombstone`, `applyOperation`, `replaceData`) — internal; their - public-facing read/write counterparts live in `live_counter_api.md` / `live_map_api.md`. +- the internal-state assertions in `internal_live_counter.md` / `internal_live_map.md` (`.data`, + `.siteTimeserials`, `.createOperationIsMerged`, `.isTombstone`, `applyOperation`, `replaceData`) — + internal; their public-facing read/write counterparts live in `internal_live_counter_api.md` / + `internal_live_map_api.md` (which *are* translated in `:uts`). - `value_types.md` — the *public* `LiveMap.create` / `LiveCounter.create` surface maps via §6, but the evaluation half (`COUNTER_CREATE` / `MAP_CREATE` `ObjectMessage` generation, nonce/`initialValue`/ `objectId` derivation, the `*WithObjectId` wire forms) is internal/wire-level. @@ -516,25 +518,28 @@ Several **unit** specs assert on the **internal CRDT graph**, not the public API reflectively performs the `PAOM3`/`PAOOP3` construction (`WireObjectMessage` → `DefaultObjectMessage`) that is otherwise `internal`. Build the source with the op builders and assert the public getters (§11). -In ably-java these are **not public**. They live in the `:liveobjects` module as `Default*` / `Wire*` / +In ably-java these are **not public**. They live in the `:liveobjects` module as `Internal*` / `Default*` / `Wire*` / `ResolvedValue` / `Leaf` / `MapRef` / `CounterRef` classes (package `io.ably.lib.liveobjects.*`, -implementation source set). The `uts` module keeps them **off its compile classpath** (it compiles against -`:java` only) but now has `testRuntimeOnly(project(":liveobjects"))`, so the helpers reach the internal -wire/message classes **by reflection** at runtime. Consequences when translating: - -- **Public-API unit specs** (`path_object*.md`, `instance.md`, `live_object_subscribe.md`, - `public_object_message.md`, and the public-surface parts of `realtime_object.md` and `value_types.md`) - translate cleanly against the §1–§12 map + the helpers below, and compile against `:java`. (Note - `path_object.md` / `instance.md` also contain `compact()` cases, which are deviations per §4/§5 since - ably-java implements only `compactJson()`.) -- **Internal-graph unit specs** (`objects_pool.md`, `parent_references.md`, the internal-state assertions in - `live_counter.md` / `live_map.md`) assert on internal CRDT state the public API can't see. Options: (a) - add reflective accessors to the helpers for the `Default*`/internal classes (the technique - `buildPublicObjectMessage` and `infra/unit/Utils.kt` already use), (b) translate them in the - `:liveobjects` module's own test source where the types are directly accessible, or (c) skip them. Flag - rather than forcing a public-API assertion that can't reach internal state. -- Spec name → ably-java impl (for orientation, not public use): `InternalLiveMap` → `DefaultLiveMap`, - `InternalLiveCounter` → `DefaultLiveCounter`, the public-view impls are `DefaultPathObject` / +implementation source set), visible only to that module's own tests. Because of this, **the entire +objects unit tier translates into `:liveobjects`'s own test source set** — +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/` (the resolver's Step B object-form +mapping) — while `:uts` hosts the objects **integration** and **proxy** tiers (public API + +`testRuntimeOnly(project(":liveobjects"))` for the plugin at runtime). Consequences when translating: + +- **Public-tier unit specs** (`path_object*.md`, `instance.md`, `live_object_subscribe.md`, + `public_object_message.md`, `internal_live_counter_api.md`, `internal_live_map_api.md`, and the + public-surface parts of `realtime_object.md` and `value_types.md`) translate against the §1–§12 map + + the helpers below. **Convention: these tests use only the public API + helpers** even though module + internals are technically visible. (Note `path_object.md` / `instance.md` also contain `compact()` + cases, which are deviations per §4/§5 since ably-java implements only `compactJson()`.) +- **Internal-graph unit specs** (`objects_pool.md`, `parent_references.md`, `object_id.md`, + `internal_live_counter.md`, `internal_live_map.md`) assert on internal CRDT state — their complete + spec-symbol → Kotlin-symbol map is **§17**. +- Spec name → ably-java impl (for orientation, not public use): the internal CRDT engine + **uses the spec's own names** — `InternalLiveMap` / `InternalLiveCounter` + (packages `value.livemap` / `value.livecounter`, shared base `BaseRealtimeObject`), `ObjectsPool`, + `ObjectsManager`, `ObjectId`. Do **not** confuse them with `DefaultLiveMap` / `DefaultLiveCounter`, + which are the *creation value-type* impls (§1/§6). The public-view impls are `DefaultPathObject` / `DefaultLiveMapPathObject` / `DefaultInstance` / …, wire form is `WireObjectMessage` / `WireObjectOperation` / `WireObjectState` etc. @@ -542,24 +547,42 @@ wire/message classes **by reflection** at runtime. Consequences when translating Every objects unit spec opens with `setup_synced_channel` and constructs protocol/object messages with the `build_*` helpers. These are implemented in -`uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt` — **call them; don't hand-roll the mock -setup or message JSON.** +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt` (package +`io.ably.lib.liveobjects.uts.unit`; transport bootstrap via the shared `io.ably.lib.uts.infra.*` fixtures, +message construction via the typed `Wire*` DTOs — §17.10) — **call them; don't hand-roll the mock setup or +message wire forms.** | Spec helper | `Helpers.kt` | |---|---| | `{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test")` | `val (client, channel, root, mockWs) = setupSyncedChannel("test")` (`suspend`, returns `SyncedChannel`) | | `setup_synced_channel_no_ack(...)` | `setupSyncedChannelNoAck(...)` | | `build_object_sync_message` / `build_object_message` / `build_ack_message` | `buildObjectSyncMessage` / `buildObjectMessage` / `buildAckMessage` → `ProtocolMessage` | -| `build_counter_inc` / `build_map_set` / `build_map_remove` / `build_map_clear` / `build_object_delete` / `build_counter_create` / `build_map_create` | same names camelCased → wire `JsonObject` | -| `build_object_state` / `build_object_message_with_state` | `buildObjectState` / `buildObjectMessageWithState` | -| `build_public_object_message(msg, channel)` | `buildPublicObjectMessage(wireJson, channel)` (reflective; §11) | +| `build_counter_inc` / `build_map_set` / `build_map_remove` / `build_map_clear` / `build_object_delete` / `build_counter_create` / `build_map_create` | same names camelCased → typed `WireObjectMessage` (§17.10 constructions) | +| `build_object_state` / `build_object_message_with_state` | `buildObjectState` / `buildObjectMessageWithState` → typed `WireObjectState` / `WireObjectMessage` | +| `build_public_object_message(msg, channel)` | `buildPublicObjectMessage(wireMessage, channel)` (direct `toPublicMessage` call — no reflection; §11) | | `STANDARD_POOL_OBJECTS` | `STANDARD_POOL_OBJECTS` | +| `captured_messages` (outgoing OBJECT publishes — RTLC12/RTLM20/RTO15 wire asserts) | `mockWs.capturedObjectMessages(): List` | | inline ObjectData / map-entry / state fragments | `dataString` / `dataNumber` / `dataBoolean` / `dataObjectId` / `dataBytes` / `dataJson`, `mapEntry`, `mapState`, `counterState`, `mapCreateOp`, `counterCreateOp` | | Canonical Constants: `POOL_SERIAL`, `ack_serial(m, i)`, `remote_serial(i)`, `below_ack_serial(i)` | `POOL_SERIAL` (`"t:0"`), `ackSerial(msgSerial, i)`, `remoteSerial(i)`, `belowAckSerial(i)` — use these, never hand-rolled `"t:N"` literals (serials are compared as strings, so ad-hoc values silently sort wrong) | `mock_ws.send_to_client(...)` is the existing `mockWs.sendToClient(...)` (§ mock API in the main skill). The wire `action` / `semantics` are integer enum codes — the builders emit the codes for you. +> **Helper-mock scope & known per-test patterns:** +> - `setupSyncedChannel`'s mock answers **`attach` and `object` (ACK) only**, and serves `/time` over a +> mocked HTTP client (hermetic `*_CREATE` id derivation). The spec pool's `DETACH → DETACHED` branch and +> `enable_fake_timers()` are **not** wired in — tests that need them follow the local patterns in +> `RealtimeObjectTest.kt` (`detachClientSide`, `setupSyncedChannelWithFakeTimers`; the fake-clock variant +> must set a large `maxIdleInterval` or virtual-time advances trip the transport idle timer). +> - **Async delivery:** the SDK applies inbound messages asynchronously; when a later step depends on the +> effect of a `send_to_client(...)` (positive read-after-send, subscribing "after" a seed, writes after +> ATTACHED), await the observable effect first (`pollUntil { ... }` / `assertWaiter`) — the spec's +> pseudocode assumes synchronous mock processing. +> - `evaluate(vt)` (value_types.md RTLCV4/RTLMV4): maps to the internal +> `DefaultLiveCounter.createCounterCreateMessage(realtimeObject)` / +> `DefaultLiveMap.createMapCreateMessages(realtimeObject)` against a §17.1 `DefaultRealtimeObject`; +> retained-create derivation is asserted via the `*CreateWithObjectId.derivedFrom` wire fields. + > **Runtime status:** the `:liveobjects` SDK's OBJECT_SYNC processing and `RealtimeObject.get()` are > implemented — `setupSyncedChannel` and the full objects unit suite run green, so translate **and > evaluate** (don't stop at compile-only). @@ -674,4 +697,199 @@ wrapped in `LiveMapValue.of`; `at(...)` followed by `asLiveCounter()` before cou | type tag `'LiveMap'` etc. | `ValueType.LIVE_MAP` etc. | | `PublicAPI::ObjectMessage` | `ObjectMessage` (getters) | | `PublicAPI::ObjectOperation` | `ObjectOperation` (getters, one payload non-null) | -| `InternalLiveMap` / `InternalLiveCounter` / `ObjectsPool` | internal `:liveobjects` impl — see §13 | +| `InternalLiveMap` / `InternalLiveCounter` / `ObjectsPool` | internal `:liveobjects` impl, same names as spec — access rules §13, symbol map §17 | + +--- + +## 17. Internal-graph symbol map (unit specs → `:liveobjects`'s own tests) + +This section maps the **5 internal-graph unit specs** — `internal_live_counter.md`, +`internal_live_map.md`, `object_id.md`, `objects_pool.md`, `parent_references.md` (they assert on +internal CRDT state, so they cannot translate against the public API — background in +`JAVA_LIVEOBJECTS_INTERNAL_METHODS_ACCESS_REPORT.md` at the repo root) — onto the internal engine. +Unlike §1–§12 these do **not** translate into the `:uts` module: they go into +**`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/`** (package +`io.ably.lib.liveobjects.uts.unit`, task `:liveobjects:runLiveObjectsUnitTests`), where the test +compilation is associated with `main`, so every `internal` declaration is directly visible — no +reflection. + +> **Access.** Every spec-required member is `internal` (or a public member of an `internal` class) +> and therefore directly accessible from `:liveobjects`'s own tests — no reflection needed. The +> only things to handle are the S-1…S-4 *shape* deviations (§17.9); record those per-test. (The +> symbol-by-symbol audit behind this section is `JAVA_LIVEOBJECTS_INTERNAL_METHODS_ACCESS_REPORT.md` +> at the repo root.) + +### 17.1 Instantiation — no public constructors, use the factories + +The internal classes have `private constructor`s; each pairs with an `internal` companion factory that +needs a `DefaultRealtimeObject`. Build one from the mocked adapter (helper already exists in +`liveobjects/src/test/.../unit/TestHelpers.kt`): + +```kotlin +val ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) +// teardown: unmockkAll() and ro.objectsPool.dispose() (the pool init starts a real GC coroutine) +``` + +| Spec | ably-java (Kotlin) | +|---|---| +| `counter = InternalLiveCounter(objectId: "counter:abc@1000")` | `val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro)` | +| `map = InternalLiveMap(objectId: "root", semantics: "LWW")` | `val map = InternalLiveMap.zeroValue("root", ro)` (semantics is a defaulted ctor param, always `WireObjectsMapSemantics.LWW` — no factory override exists; fine, the specs only use LWW) | +| `pool = ObjectsPool()` | `ro.objectsPool` (`internal val`, auto-created with the `"root"` `InternalLiveMap` per RTO3b) | +| `map = InternalLiveMap(..., pool: pool)` / `pool["id"] = obj` | pool wiring is implicit via `ro` — register children with `ro.objectsPool.set("counter:child@1000", child)` | +| `realtime_object = RealtimeObject(pool: pool)` | `ro` itself (`DefaultRealtimeObject`) | + +> **From a live-channel fixture instead of the factory.** When a test drives internals off a channel +> built by `setupSyncedChannel` (§13 unit-test helpers) rather than constructing standalone, obtain the same internal +> object by casting the public field: `` val ro = channel.`object` as DefaultRealtimeObject ``. +> `` channel.`object` `` is typed as the public `RealtimeObject` interface, so the cast is required to +> reach the `internal` members mapped in §17.6 (`handleStateChange`, `objectsPool`, …). Teardown is the +> fixture's own (`client.close()`), not `ro.objectsPool.dispose()`. + +### 17.2 Shared LiveObject base — `BaseRealtimeObject` (`value/BaseRealtimeLiveObject.kt`) + +Both node types share these; spec setups that *assign* collections mutate them in place instead +(`val` + mutable collection). + +| Spec | ably-java (Kotlin) | Notes | +|---|---|---| +| `obj.objectId` | `objectId` (`internal val`) | | +| `obj.siteTimeserials` (read/assign) | `siteTimeserials: MutableMap` (`internal val`) | assign → `putAll(...)`; assert with map equality | +| `obj.createOperationIsMerged` | `createOperationIsMerged` (`internal var`) | r/w | +| `obj.isTombstone` | `isTombstoned` (`internal var`) | rename | +| `obj.tombstonedAt` | `tombstonedAt: Long?` (`internal var`) | r/w | +| `obj.applyOperation(msg, source: CHANNEL)` | `applyObject(wireObjectMessage, ObjectsOperationSource.CHANNEL): ObjectUpdate` (`internal`) | template method → abstract `applyObjectOperation` → per-type manager; **returns the `ObjectUpdate`** (RTLC9g/RTLM7f), `ObjectUpdate.NoOp` when nothing is applied | +| `obj.replaceData(state_msg)` | `applyObjectSync(wireObjectMessage): ObjectUpdate` (`internal`) | **returns the update** — all `RTLC6`/`RTLM6`/`RTLM22`-style assertions work on the return value | +| `canApplyOperation` (RTLO4a) | `canApplyOperation(siteCode, timeSerial): Boolean` (`internal`) | directly callable standalone | +| tombstone entry (RTLO4e/5/6) | `tombstone(serialTimestamp, message): ObjectUpdate` (`internal`) | returns update with `tombstone = true` + `objectMessage`; RTLO4e10 root-noop built in | +| `source: CHANNEL / LOCAL` | `ObjectsOperationSource.CHANNEL / LOCAL` (`internal enum`) | | +| `current_time()` control (RTLO6b, GC) | `clock` ← `SystemClock.clockFrom(adapter.clientOptions)` (Java static) | `mockkStatic(SystemClock::class)` returning a fixed `Clock` — deviation S-3 | + +### 17.3 `InternalLiveCounter` (`value/livecounter/`) + +| Spec | ably-java (Kotlin) | Notes | +|---|---|---| +| `counter.data` (read/write `Double`) | `data: AtomicReference` (`internal val`) | `data.get()` / `data.set(10.0)` | +| `counter.value()` | `value(): Double` (`internal`) | | + +There is **no `applyOperation` member on `InternalLiveCounter` itself** — use the base +`applyObject(...)` (§17.2). The per-action switch lives in `LiveCounterManager.applyOperation` +(`internal class`, `internal fun`), reached in production via a `private val` field; if a test needs +manager-level calls (e.g. `applyState(...): ObjectUpdate`), construct `LiveCounterManager(counter)` +directly (public-in-internal-class ctor; note it owns a separate subscription emitter). + +### 17.4 `InternalLiveMap` + `LiveMapEntry` (`value/livemap/`) + +| Spec | ably-java (Kotlin) | Notes | +|---|---|---| +| `map.data` (entry dict, r/w) | `data: ConcurrentHashMap` (`internal val`) | seed with `put`. `ASSERT "k" IN map.data` → `map.data.containsKey("k")` — Kotlin's `"k" in map.data` is a **compile error** on `ConcurrentHashMap` (KT-18053: `contains` resolves to `containsValue`) | +| entry `{ data, timeserial, tombstone, tombstonedAt }` | `LiveMapEntry(isTombstoned, tombstonedAt, timeserial, data)` (`internal data class`) | `tombstone` → `isTombstoned`; `data` is `WireObjectData?` | +| `map.clearTimeserial` | `clearTimeserial: String?` (`internal var`) | r/w | +| `map.semantics` | `semantics` (`internal val`) | | +| `map.get(key)` / `map.size()` | `get(keyName): ResolvedValue?` / `size(): Long` (`internal`) | RTLM5 / RTLM10d; RTLM14c ref-tombstone behaviour included | +| `isTombstoned(entry)` (RTLM14) | extension `entry.isEntryOrRefTombstoned(ro.objectsPool)` (`internal fun`, `LiveMapEntry.kt`) | | +| `map.gcTombstonedEntries(grace, now)` | `onGCInterval(gcGracePeriod)` (override) | no `now` param — time via `clock` (mock per §17.2 / S-3) | +| `InternalLiveMap.diff(previousData, newData)` (RTLM22, static) | `LiveMapManager(map).calculateUpdateFromDataDiff(prev: Map, new: Map): ObjectUpdate` (`internal`) | instance method — construct the manager in the test | +| `MAP_SET` value `{ objectId: ... }` creates zero-value child (RTLM7g) | manager calls `objectsPool.createZeroValueObjectIfNotExists` internally | assert `ro.objectsPool.get("counter:new@2000") is InternalLiveCounter` | + +### 17.5 The update object — spec `LiveObjectUpdate` → `ObjectUpdate` (`value/ObjectUpdate.kt`) + +| Spec | ably-java (Kotlin) | +|---|---| +| `update.noop == true` | `update is ObjectUpdate.NoOp` (or `update.noOp` extension) | +| `update.update.amount` (counter) | `(update as ObjectUpdate.CounterUpdate).amount: Double` | +| `update.update == { "k": "updated"/"removed" }` (map) | `(update as ObjectUpdate.MapUpdate).update: Map`, values `MapChange.Updated / Removed` | +| `update.tombstone` | `update.tombstone: Boolean` | +| `update.objectMessage` | `update.objectMessage: WireObjectMessage?` | +| `result == false` (op rejected) | `applyObject(...).noOp` (returns `ObjectUpdate.NoOp` for rejected/noop ops) | + +> **Which paths return it:** the op path `applyObject` (RTLC9g/RTLM7f — `ObjectUpdate.NoOp` when +> nothing is applied), `applyObjectSync` (spec `replaceData`), `tombstone(...)`, `clearData()` and +> `calculateUpdateFromDataDiff` all **return** the `ObjectUpdate`. + +### 17.6 Pool & sync state machine — spec `ObjectsPool` splits across three classes + +The spec's monolithic pool = `ObjectsPool` (storage) + `ObjectsManager` (sync/apply logic) + +`DefaultRealtimeObject` (state, ack-serials, ATTACHED handling). + +| Spec | ably-java (Kotlin) | Notes | +|---|---|---| +| `pool["id"]` / `pool["id"] = obj` / `"id" IN pool` | `ro.objectsPool.get(id)` / `.set(id, obj)` / `get(id) != null` (`internal`) | backing map is `private`; accessors cover all spec uses | +| `pool.keys().length` | `ro.objectsPool.all().size` (`internal fun all()`) | | +| `"root"` invariant (RTO3b) | `ROOT_OBJECT_ID` (`internal const`, `ObjectsPool.kt`) | root auto-created in pool `init` | +| `pool.syncState` (read **and** write) | `ro.state: ObjectsState` (`internal var`) — `Initialized / Syncing / Synced` | setup `pool.syncState = SYNCED` → `ro.state = ObjectsState.Synced` | +| `pool.processAttached(ProtocolMessage(flags: HAS_OBJECTS))` | `ro.handleStateChange(ChannelState.attached, hasObjects = true/false)` (`internal`) | **async** (`sequentialScope.launch`) — await with `assertWaiter`, or drive the manager steps directly — deviation S-2 | +| `channel.object.processChannelState(state)` | `ro.handleStateChange(state, false)` (`internal`) | **async** (`sequentialScope.launch`) — flush with `ro.asyncFuture { }.await()` — deviation S-2 | +| `channel.object.objectsPool` | `ro.objectsPool` (`internal val`) | the `ObjectsPool` storage on the `DefaultRealtimeObject` | +| `pool.processObjectSync(build_object_sync_message(ch, "sync1:", msgs))` | `objectsManager.handleObjectSyncMessages(wireMessages, "sync1:")` (`internal`) | sync-serial parsing = `ObjectsSyncTracker` (`internal`; `syncId` / `hasSyncStarted` / `hasSyncEnded`) | +| `pool.processObjectMessage(...)` | `objectsManager.handleObjectMessages(wireMessages)` (`internal`) | buffers unless `ro.state == Synced` (RTO8) | +| explicit sync phases | `objectsManager.startNewSync(syncId)` / `endSync()` (`internal`) | `endSync()` also runs the RTO5c10 `parentReferences` rebuild + RTO5c9 ack-serial clear | +| `pool.applyObjectMessages(msgs, source: LOCAL)` (RTO9a2a4) | `ro.objectsManager.applyObjectMessages(msgs, ObjectsOperationSource.LOCAL)` (`internal`) | `applyAckResult(msgs)` (`internal suspend`) is the production LOCAL path if you prefer driving it end-to-end (requires `ro.state = Synced` first) | +| the manager instance itself | `ro.objectsManager` (`internal val`) | all `handle*`/`startNewSync`/`endSync` calls go through it | +| `realtime_object.bufferedObjectOperations` | `ro.objectsManager.bufferedObjectOperations: MutableList` (`internal val`) | assert `.size`; spec never writes it | +| `realtime_object.appliedOnAckSerials` (r/w) | `ro.appliedOnAckSerials: MutableSet` (`internal val`) | mutate in place | +| zero-value from objectId prefix (RTO6) | `ro.objectsPool.createZeroValueObjectIfNotExists(objectId)` (`internal`) | uses `ObjectId.fromString` for RTO6b1 | +| `pool["root"].subscribe((update) => ...)` | `internalLiveMap.subscribe(InstanceListener)` (`internal`) | event = `DefaultInstanceSubscriptionEvent` — carries `getObject()` + `getMessage()`, **no diff payload** (S-1); `getMessage()` is the *public* `ObjectMessage` (PAOM3-mapped), `null` for sync-sourced updates (exactly RTO4b2a) | + +### 17.7 `ObjectId` — spec `generateObjectId` (RTO14) (`ObjectId.kt`) + +| Spec | ably-java (Kotlin) | +|---|---| +| `generateObjectId(type: "counter", initialValue, nonce, timestamp)` | `ObjectId.fromInitialValue(ObjectType.Counter, initialValue, nonce, timestamp).toString()` (`internal` companion) | +| type `"map"` / `"counter"` | `ObjectType.Map / Counter` (`internal enum`, `value/`) | +| parse `"type:hash@ts"` (RTO6b1) | `ObjectId.fromString(objectId)`; `.type` (`internal`) — `hash`/`timestampMs` are `private`, but every RTO14 assertion works on the **string** form | + +Pure functions — these tests need no `DefaultRealtimeObject`, no mocking, no §17.1 setup at all. + +### 17.8 Parent references & `getFullPaths` (RTLO3f / RTLO4f / RTLO4g / RTLO4h, RTO5c10) + +| Spec | ably-java (Kotlin) | Notes | +|---|---|---| +| `obj.parentReferences` (read + seed) | `parentReferences: MutableMap>` (`internal val`, keyed by parent objectId) | seed via `put`; structure matches spec exactly. Asserting a single entry needs an explicit type parameter — `assertEquals?>(setOf("score"), obj.parentReferences["map:parent@1000"])` — because `kotlin.test.assertEquals`'s `@OnlyInputTypes` rejects `Set` (expected) vs `MutableSet?` (actual) | +| `child.addParentReference(parent, key)` | `addParentReference(parent: InternalLiveMap, key: String)` (`internal`) | | +| `child.removeParentReference(parent, key)` | `removeParentReference(...)` (`internal`; RTLO4h1–h3 all implemented) | | +| `obj.getFullPaths()` | `getFullPaths(): List>` (`internal`; cycle-safe, order unspecified) | **parents resolve through `ro.objectsPool`** — `pool.set(...)` every node you wire, or paths silently drop | +| reset (RTO5c10a) | `clearParentReferences()` (`internal`) | | +| post-sync rebuild (RTO5c10/a/b) | happens inside `endSync()` (`rebuildAllParentReferences` is `private` — assert outcomes via `parentReferences`) | drive the sync via `ro.objectsManager.handleObjectSyncMessages(...)` (§17.6) | + +The pure-graph tests (RTLO3f2, RTLO4g\*, RTLO4h\*, RTLO4f\*) need only the §17.1 setup — no sync +machinery involved. + +### 17.9 Shape deviations — record per-test in the module-local `deviations.md` + +All spec-required members are accessible (see the §17 access note); these are the only structural +differences between the spec pseudocode and the Kotlin surface: + +| Id | What | Impact | Handling | +|---|---|---|---| +| S-1 | subscriber event (`InstanceSubscriptionEvent`) carries the message but no diff/`noop`/`tombstone` payload | pool emit-path `updates[0].update.*` assertions (RTO4b2a/RTO5c7) → assert pool/root state + event `getMessage()` instead | record per-test deviation | +| S-2 | `processAttached` ≡ async `handleStateChange` | synchronous asserts after ATTACHED | `assertWaiter` (`liveobjects/src/test/.../TestUtils.kt`) or drive manager steps directly | +| S-3 | time from `clock`, not `now` params | RTLO6b, RTLM19 GC | `mockkStatic(SystemClock::class)` | +| S-4 | `ObjectsPool.init` starts GC coroutine + adapter subscription | leaked coroutines | `dispose()` in teardown | + +### 17.10 Message builders — `build_*` map to `Wire*` constructors natively + +Tests in `:liveobjects` construct the `internal data class`es directly — the module-local +`Helpers.kt` (§13's helpers table) implements the `build_*` builders as plain Kotlin functions over +these constructions: + +| Spec builder | ably-java (Kotlin) construction | +|---|---| +| `build_counter_inc(objectId, n, serial, siteCode)` | `WireObjectMessage(serial = serial, siteCode = siteCode, operation = WireObjectOperation(action = WireObjectOperationAction.CounterInc, objectId = objectId, counterInc = WireCounterInc(number = n)))` | +| `build_counter_create(objectId, { count }, serial, siteCode)` | op `action = CounterCreate, counterCreate = WireCounterCreate(count = ...)` | +| `build_map_set(objectId, key, value, serial, siteCode)` | op `action = MapSet, mapSet = WireMapSet(key, WireObjectData(...))` | +| `build_map_remove(..., serialTimestamp?)` | op `action = MapRemove, mapRemove = WireMapRemove(key)`; `serialTimestamp` goes on the **message** | +| `build_map_clear(objectId, serial, siteCode)` | op `action = MapClear, mapClear = WireMapClear` (payload-less `internal object`) | +| `build_map_create(objectId, { semantics, entries }, ...)` | op `action = MapCreate, mapCreate = WireMapCreate(semantics = WireObjectsMapSemantics.LWW, entries = mapOf(k to WireObjectsMapEntry(...)))` | +| `build_object_delete(objectId, serial, siteCode, serialTimestamp?)` | op `action = ObjectDelete, objectDelete = WireObjectDelete`; `serialTimestamp` on the message | +| `build_object_state(objectId, siteTimeserials, { map/counter/createOp/tombstone })` | `WireObjectState(objectId, siteTimeserials, tombstone, createOp, map = WireObjectsMap(semantics, entries, clearTimeserial), counter = WireObjectsCounter(count))` — ⚠️ `objectId`, `siteTimeserials` and `tombstone` have **no defaults**, pass them explicitly | +| `ObjectMessage(object: state)` / `ObjectMessage(object: null)` | `WireObjectMessage(objectState = ...)` — the field is named **`objectState`** in Kotlin (wire key stays `"object"` via `@SerializedName`) | +| wire entry `{ data, timeserial, tombstone, serialTimestamp }` (inside `mapCreate`/`ObjectState.map.entries`) | `WireObjectsMapEntry(tombstone, timeserial, serialTimestamp, data)` — this is the **wire** entry (`tombstone`); don't confuse it with the graph-side `LiveMapEntry` (`isTombstoned`, §17.4) | +| value `{ string }` / `{ number }` / `{ boolean }` / `{ bytes }` / `{ objectId }` / json | `WireObjectData(string = / number = / boolean = / bytes = / objectId = / json = )` | +| `ProtocolMessage(action: ATTACHED, flags: HAS_OBJECTS)` | no protocol-message parsing at this layer — translate to `ro.handleStateChange(ChannelState.attached, hasObjects)` (§17.6) | +| `build_object_sync_message(ch, channelSerial, msgs)` | pass `List` + `channelSerial` straight to `handleObjectSyncMessages` (no `ProtocolMessage` wrapper needed) | + +(`build_ack_message` from `standard_test_pool.md` is not needed here — it belongs to the mock-WS +tiers, and none of the 5 internal unit specs use it.) + +Keep the `uts` discipline in the migrated tests: one `@Test` per spec case, `/** @UTS objects/unit/… */` +KDoc tags, and a module-local `deviations.md` recording every §17.9 deviation used. diff --git a/.claude/skills/uts-to-kotlin/scripts/audit_translation.py b/.claude/skills/uts-to-kotlin/scripts/audit_translation.py index 10f634aeb..fbecaa284 100644 --- a/.claude/skills/uts-to-kotlin/scripts/audit_translation.py +++ b/.claude/skills/uts-to-kotlin/scripts/audit_translation.py @@ -56,7 +56,7 @@ UTS_TAG_RE = re.compile(r"@UTS\s+(\S+)") KOTLIN_ASSERT_RE = re.compile( r"\b(assertEquals|assertNotEquals|assertNull|assertNotNull|assertTrue|assertFalse|" - r"assertIs|assertIsNot|assertContains|assertFailsWith|assertFails|assertSame|" + r"assertIs|assertIsNot|assertContains|assertContentEquals|assertFailsWith|assertFails|assertSame|" r"assertNotSame|awaitState|awaitChannelState|pollUntil)\b" ) diff --git a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py index 675b6ef93..813b43c8b 100644 --- a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py @@ -162,7 +162,16 @@ def main(): tiers_out = {} for tier in TIERS: - target_dir = f"{test_root}/{entry[tier]}" if (mapped and tier in entry) else None + # A tier value is either a string (relative to the global testRoot) or an object + # {root, path} carrying its own module root — used when a tier's tests live outside + # the :uts module (e.g. objects/unit -> :liveobjects's own test source set). + tier_val = entry.get(tier) if mapped else None + if isinstance(tier_val, dict): + target_dir = f"{tier_val['root']}/{tier_val['path']}" + elif tier_val: + target_dir = f"{test_root}/{tier_val}" + else: + target_dir = None tiers_out[tier] = { "present": src[tier].is_dir(), "sourceDir": str(src[tier]), diff --git a/.claude/skills/uts-to-kotlin/uts-package-mapping.json b/.claude/skills/uts-to-kotlin/uts-package-mapping.json index 6e3c8172a..53ef7da00 100644 --- a/.claude/skills/uts-to-kotlin/uts-package-mapping.json +++ b/.claude/skills/uts-to-kotlin/uts-package-mapping.json @@ -1,5 +1,5 @@ { - "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. Output dir = testRoot + '/' + tier entry; Kotlin package = that path after 'src/test/kotlin/' with '/' -> '.'. An optional 'notes' field points (relative to this skill dir) to a per-module ably-js -> ably-java translation reference, read before translating that module. Used by the uts-to-kotlin skill.", + "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. A tier value is either a string (output dir = testRoot + '/' + value) or an object {root, path} with its own module root (output dir = root + '/' + path) for tiers that live outside the :uts module. Kotlin package = the output dir after 'src/test/kotlin/' with '/' -> '.'. An optional 'notes' field points (relative to this skill dir) to a per-module ably-js -> ably-java translation reference, read before translating that module. Used by the uts-to-kotlin skill (scripts/resolve_uts.py).", "testRoot": "uts/src/test/kotlin/io/ably/lib/uts", "packages": { "realtime": { @@ -8,7 +8,10 @@ "proxy": "integration/proxy/realtime" }, "objects": { - "unit": "unit/liveobjects", + "unit": { + "root": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects", + "path": "uts/unit" + }, "integration": "integration/standard/liveobjects", "proxy": "integration/proxy/liveobjects", "notes": "references/objects-mapping.md" diff --git a/FUTURE_WORK_UTS_INFRA.md b/FUTURE_WORK_UTS_INFRA.md new file mode 100644 index 000000000..d93cc6145 --- /dev/null +++ b/FUTURE_WORK_UTS_INFRA.md @@ -0,0 +1,261 @@ +# Future work — extract shared UTS test infra into a dedicated module + +**Status:** proposed / not started. Deferred from `MOVE_COMMON_INFRA/` (which put the infra in +`:uts`'s `testFixtures` variant — the correct incremental step). This doc captures the extraction to +do **when a second consumer beyond `:liveobjects` materialises**. + +## 1. Why (the trigger) + +The shared UTS test infrastructure (mock WebSocket/HTTP transports, `FakeClock`, client factories, +`SandboxApp`, proxy control) currently lives in **`uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/`** +and is consumed by: + +- `:uts`'s own tests (automatically — a module sees its own test fixtures), and +- `:liveobjects` tests via `testImplementation(testFixtures(project(":uts")))`. + +Two more consumers are anticipated: + +1. **The Chat SDK** — will reuse the mock transport / sandbox provisioning for its own UTS-derived tests. +2. **`lib/src/test`** — i.e. the **`:java` module's test source set** (its build file is `java/build.gradle.kts`; + sources are wired in via `srcDirs(".../lib/src/test/java")`). + +Once infra is shared by three-plus modules, hanging it off `:uts`'s test-fixtures variant becomes a +naming/ownership smell: modules that have nothing to do with UTS would depend on `testFixtures(project(":uts"))`, +and the package is `io.ably.lib.uts.infra`. At that point a **dedicated module** is the clean home — it +gets a name that reflects "shared test support," and IntelliJ shows it as an ordinary module with a normal +`src/main/kotlin` (no special test-fixtures source-root category). + +Until then, `testFixtures` is intentionally preferred: zero custom Gradle, test-only, already cross-module +consumable. **Do not do this extraction speculatively** — it only pays for itself once the second consumer +is real. + +## 2. Target state + +A new module, e.g. `:test-support` (neutral name; not `:uts-test-infra`, since chat/`:java` aren't UTS): + +``` +test-support/ + build.gradle.kts + src/main/kotlin/io/ably/lib/testsupport/… # the infra, moved from uts/src/testFixtures +``` + +Consumed uniformly: + +```kotlin +// uts/build.gradle.kts, liveobjects/build.gradle.kts, java/build.gradle.kts, chat/… +testImplementation(project(":test-support")) +``` + +`:uts` drops the `java-test-fixtures` plugin and its `testFixtures*` dependency block; the +`testFixtures(project(":uts"))` line in `:liveobjects` becomes `project(":test-support")`. + +## 3. The hard constraint — Kotlin infra vs. Java consumers ⚠️ + +**This is the single biggest consideration and the reason the extraction is non-trivial.** + +The infra is **idiomatic Kotlin**: lambda-with-receiver builder DSLs (`MockWebSocket { onConnectionAttempt = … }`), +`suspend` functions (`setupSyncedChannel`, `SandboxApp.create`, `awaitState`), extension functions, +`data class`es, and default arguments. + +`lib/src/test` is **pure Java** (verified: 75 `.java` files, 0 `.kt`). Java can put compiled Kotlin on its +classpath, but **cannot ergonomically call this API**: + +- `suspend` functions are effectively uncallable from Java (they compile to a hidden `Continuation` + parameter) — `setupSyncedChannel`, `SandboxApp.create()`, the `await*` helpers. +- Lambda-with-receiver config DSLs don't exist in Java. +- Extension functions become awkward static calls; default args require `@JvmOverloads` to be visible. + +**Decision (confirmed): add a Kotlin test source set to `:java`** and write the Java module's new +UTS-derived tests in Kotlin. The infra is Kotlin-first by design and UTS tests are Kotlin, so a Java +facade (`@JvmStatic`/`@JvmOverloads`/blocking `suspend` wrappers/builder classes) would fight the grain; +it's the rejected alternative. Existing Java tests in `lib/src/test/java` are untouched. + +(The Chat SDK is Kotlin, so it has no such friction — only `lib/src/test` does.) + +### 3.1 Concrete `:java` wiring + +`:java` is today a **pure-Java `java-library`** (build file `java/build.gradle.kts`; `sourceCompatibility`/ +`targetCompatibility = 1.8`; sources wired from `../lib/src/…/java` via `sourceSets { … srcDirs(…) }`). It +does **not** apply the Kotlin plugin. Steps: + +1. Apply the Kotlin JVM plugin: `alias(libs.plugins.kotlin.jvm)` in `java/build.gradle.kts` `plugins {}`. +2. Add a Kotlin test source dir mirroring the existing Java wiring, and create the folder: + ```kotlin + sourceSets { + named("test") { + java { srcDirs("src/test/java", "../lib/src/test/java") } // existing + // new — Kotlin UTS tests + consumed infra + kotlin { srcDirs("src/test/kotlin", "../lib/src/test/kotlin") } + } + } + ``` + (`mkdir -p lib/src/test/kotlin`.) +3. `testImplementation(project(":test-support"))` + the `--add-opens` JVM args (§5 step 4) + + `kotlin("test")` as needed. + +### 3.2 ⚠️ Guardrail — do NOT ship kotlin-stdlib in the `:java` main artifact + +`:java` is the **core, widely-consumed SDK artifact and is currently Kotlin-free at runtime**. Applying +`org.jetbrains.kotlin.jvm` adds `kotlin-stdlib` to the `implementation` (main) configuration by default, +which would leak into the published artifact's runtime dependencies — every ably-java consumer would then +pull kotlin-stdlib. **This must be prevented.** Approach: + +- Disable the automatic stdlib dependency for this module and add stdlib to **test scope only**, e.g. set + `kotlin.stdlib.default.dependency=false` and declare `testImplementation(kotlin("stdlib"))` (verify the + flag's scope — if `gradle.properties` is project-wide, other Kotlin modules like `:liveobjects` that + *do* ship Kotlin still need stdlib in main, so prefer a per-module control or an explicit main-scope + stdlib there). +- **Acceptance check:** after wiring, inspect the published POM / `./gradlew :java:dependencies + --configuration runtimeClasspath` and confirm **no `kotlin-stdlib`** on `:java`'s main runtime classpath. +- Align the Kotlin `jvmTarget` with `:java`'s Java 8 target (`compileTestKotlin { compilerOptions.jvmTarget + = JVM_1_8 }`) so test bytecode matches. + +This guardrail is the main reason the `:java` change is more than a one-liner — treat the "stdlib stays out +of main" verification as a required gate of the step. + +## 4. Dependency & cycle analysis + +The infra's actual dependencies (from the current `testFixtures` block — keep these exact scopes): + +| Dep | Scope | Why | +|---|---|---| +| `:java` | `api` | `AblyRealtime`/`AblyRest`/`ProtocolMessage`/`Clock`/`ConnectionDetails` appear in fixture signatures | +| `:network-client-core` | `api` | `HttpEngine`/`WebSocketEngine` SPI the mocks implement, in signatures | +| `libs.coroutine.core` | `implementation` | `suspend` helpers | +| `libs.ktor.client.core` + `libs.ktor.client.cio` | `implementation` | `SandboxApp` / `ProxySession` HTTP | + +**Cycle safety:** + +- `:test-support` main → `:java` main, `:network-client-core` main. Fine. +- **INVARIANT (carried over from `MOVE_COMMON_INFRA` Phase 1): `:test-support` must never depend on + `:liveobjects`.** That keeps `:liveobjects:test → :test-support → :java` acyclic against + `:uts`'s existing `testRuntimeOnly(:liveobjects)`. +- `:java:test → :test-support → :java:main` is **not** a cycle — a module's *test* compilation may depend + on a module that depends on its *main*. (Gradle treats `main` and `test` as separate nodes.) Confirm on + first `:java:compileTestKotlin`/`compileTestJava`. + +## 5. Migration steps (when triggered) + +1. **Create `:test-support`**: new dir, `settings.gradle.kts` `include("test-support")`, `build.gradle.kts` + applying `kotlin("jvm")` with the §4 deps as `api`/`implementation` (NOT `testFixtures*` scopes — this is + a normal module now). +2. **Move the tree**: `git mv uts/src/testFixtures/kotlin/io/ably/lib/uts/infra` → + `test-support/src/main/kotlin/io/ably/lib/testsupport` (rename package `io.ably.lib.uts.infra` → + `io.ably.lib.testsupport` — do this now, while there's one Kotlin consumer, not later). Update imports in + `:uts` tests and `:liveobjects`'s `uts/unit/Helpers.kt`. + - Alternative: keep the `io.ably.lib.uts.infra` package to avoid import churn. Weigh churn vs. a + misleading `uts` name in a shared module. Package rename is a mechanical find/replace across two + consumers today; it gets more expensive with every new consumer, so **prefer renaming now**. +3. **Rewire consumers**: + - `:uts` — remove `java-test-fixtures` plugin + `testFixtures*` block; add + `testImplementation(project(":test-support"))`. Restore the plain `plugins { alias(libs.plugins.kotlin.jvm) }`. + - `:liveobjects` — `testImplementation(testFixtures(project(":uts")))` → `testImplementation(project(":test-support"))`. +4. **JVM args parity**: consumers running the mock transport need the same + `--add-opens java.base/java.time=ALL-UNNAMED` / `java.base/java.lang=ALL-UNNAMED` flags that + `uts/build.gradle.kts` and `liveobjects/build.gradle.kts` already set. Add to any new consumer's + `tasks.withType` (needed by the coroutines/FakeClock machinery, not by the `ConnectionDetails` + reflection — that targets a plain classpath class and needs no `--add-opens`). +5. **Proxy system property**: the `uts.proxy.localPath` / `UTS_PROXY_LOCAL_PATH` forwarding in + `uts/build.gradle.kts`'s `tasks.withType` must be replicated by any module that runs proxy-tier + fixtures. Consider extracting it into a shared Gradle convention/snippet at that point. (`:liveobjects` + does not run proxy fixtures today, so it doesn't need this yet.) +6. **`:java` consumption** (when `lib/src/test` is a driver): follow §3.1 (apply `kotlin("jvm")`, add the + `../lib/src/test/kotlin` source dir) **and §3.2 (keep kotlin-stdlib out of the `:java` main artifact — + required gate)**. Existing Java tests are unaffected. +7. **Docs**: update the three places that describe infra location — `uts/README.md` §4/§4.2/Appendix B, + `.claude/skills/uts-to-kotlin/SKILL.md` (Step 3 infra paths, Step 3 objects/unit note, integration + Infrastructure section), and `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §13. All + currently say `uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/…`. + +## 6. Cross-repo caveat (Chat) + +If the Chat SDK lives in a **separate repository** (not a module in this monorepo), "sharing" is not a +`project(":test-support")` dependency — it requires **publishing** `:test-support` as a versioned artifact +(Maven coordinates, `maven-publish`, a release cadence). That is a materially bigger commitment than an +in-repo module and turns test infra into a maintained public-ish artifact. Confirm whether chat is: + +- **In-repo** (a future module here) → plain `project(":test-support")`, this doc's plan as written; or +- **Separate repo** → decide publish-and-version vs. source-copy vs. a shared git submodule. Do **not** + assume publishing without an explicit decision — it changes ownership, versioning, and CI. + +## 7. What NOT to do (rejected alternatives, for the record) + +- **Move infra to `src/main` of `:uts`** — makes it part of a publishable main artifact, drags + mockk/ktor into non-test scopes, and `:uts` stops being self-evidently test-only. +- **Move infra back to `src/test`** — invisible to other modules; breaks the `:liveobjects` consumption + that `MOVE_COMMON_INFRA` established. +- **Rename `src/testFixtures` → `src/testInfra` while keeping `java-test-fixtures`** — the plugin's source + set stays named `testFixtures` and the consumer accessor stays `testFixtures(project(":uts"))`, so you + get a directory/source-set name mismatch that is *more* confusing than the convention. A custom-named + consumable test source set means hand-rolling the Gradle variant + capability (fragile). The dedicated + module in §2 is the only clean way to get a chosen name. + +## 8. Related future work — simplify `uts-package-mapping.json` to one full path per tier + +**Motivation.** The skill's `.claude/skills/uts-to-kotlin/uts-package-mapping.json` today uses a **hybrid** +schema: a global relative `testRoot` (`uts/src/test/kotlin/io/ably/lib/uts`) with each tier a string +*relative to it*, **except** tiers that live in another module, which carry an explicit `{root, path}` +override (this is what `objects.unit` → `:liveobjects` needed). The resolver +(`scripts/resolve_uts.py`) branches on string-vs-object to handle both. + +That special-casing is fine for one out-of-`:uts` tier, but **it multiplies as tiers land in more +modules** — which is exactly what this doc's extraction (`:test-support`) and the `:java`/chat consumers +bring. Every future "this tier lives in module X" becomes another `{root, path}` object and keeps the +resolver's two-form branch alive. + +**Proposed change.** Drop the global `testRoot` and the `{root, path}` object form; make **every tier a +single full, repo-root-relative path string**. Uniform, no special-casing, self-describing: + +```jsonc +// before (hybrid) +{ + "testRoot": "uts/src/test/kotlin/io/ably/lib/uts", + "packages": { + "realtime": { "unit": "unit/realtime", "integration": "integration/standard/realtime", "proxy": "integration/proxy/realtime" }, + "objects": { "unit": { "root": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects", "path": "uts/unit" }, + "integration": "integration/standard/liveobjects", "proxy": "integration/proxy/liveobjects" } + } +} + +// after (one full path per tier) +{ + "packages": { + "realtime": { + "unit": "uts/src/test/kotlin/io/ably/lib/uts/unit/realtime", + "integration": "uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime", + "proxy": "uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime" + }, + "objects": { + "unit": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit", + "integration": "uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects", + "proxy": "uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects" + } + } +} +``` + +**Resolver change** (`scripts/resolve_uts.py`): delete the `testRoot` read and the `isinstance(dict)` +branch; `target_dir = entry[tier]` directly. `package_for()` is unchanged — it already derives the Kotlin +package by splitting each path on `src/test/kotlin/`, which every full path still contains. `--create` emits +full-path entries (its `unit`/`integration`/`proxy` template becomes the three full paths for the chosen +module). Update the `_comment` accordingly. + +> **⚠️ "Absolute" must mean repo-root-relative, NOT machine-absolute.** Do **not** put +> `/Users//IdeaProjects/ably-java/...` in this committed file — a machine-specific path breaks CI, +> every other developer, and the skill (which resolves paths from the ably-java repo root). "Full path" +> here = the complete path **from the repo root**, exactly as the values above. + +**Trade-off.** More verbose (each tier repeats the `uts/src/test/kotlin/io/ably/lib/uts/...` prefix; the +shared-`testRoot` DRY is lost) in exchange for a uniform, branch-free schema that scales cleanly to +tiers in arbitrary modules. Given the direction (infra + tiers spreading across `:uts` / `:liveobjects` / +`:java` / chat), the uniformity wins. Do this **together with** the §7 doc-path updates so the mapping, +`SKILL.md` Step B/2/5/6, and `objects-mapping.md` §13 all describe the single-form schema at once. + +## 9. References + +- `MOVE_COMMON_INFRA/` — the completed 5-phase consolidation this defers from (esp. Phase 1 fixtures + extraction and its cycle invariant). +- `uts/README.md` §4.2 "Cross-module exception" — current infra location + the objects-unit-in-`:liveobjects` split. +- `.claude/skills/uts-to-kotlin/SKILL.md` Step 3 + `references/objects-mapping.md` §13/§17 — how the skill + points at the infra and the objects/unit destination. +- Current infra deps: `uts/build.gradle.kts` (`testFixturesApi`/`testFixturesImplementation` block). diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index 9d6ad9420..db88d4d1d 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -18,6 +18,10 @@ dependencies { testImplementation(project(":java")) testImplementation(kotlin("test")) testImplementation(libs.bundles.kotlin.tests) + // Shared UTS test infra (mock transport, FakeClock, SandboxApp) from :uts's test-fixtures + // variant. Compile-safe: the fixtures depend only on :java/:network-client-core, never on + // this module (see the invariant note in uts/build.gradle.kts). + testImplementation(testFixtures(project(":uts"))) } tasks.withType().configureEach { @@ -32,7 +36,8 @@ tasks.withType().configureEach { tasks.register("runLiveObjectsUnitTests") { filter { - includeTestsMatching("io.ably.lib.liveobjects.unit.*") + includeTestsMatching("io.ably.lib.liveobjects.unit.*") // the module's own unit tests + includeTestsMatching("io.ably.lib.liveobjects.uts.unit.*") // UTS objects unit suite (skill-generated) } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt index 5f7f93138..1331d046d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt @@ -55,7 +55,7 @@ internal class DefaultRealtimeObject( /** * @spec RTO4 - Used for handling object messages and object sync messages */ - private val objectsManager = ObjectsManager(this) + internal val objectsManager = ObjectsManager(this) /** * Registry for PathObject subscriptions and path-event dispatch. diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsManager.kt index b1712ce36..cd17ed15f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsManager.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsManager.kt @@ -7,6 +7,7 @@ import io.ably.lib.liveobjects.message.WireObjectState import io.ably.lib.liveobjects.message.WireObjectsMap import io.ably.lib.liveobjects.value.BaseRealtimeObject import io.ably.lib.liveobjects.value.ObjectUpdate +import io.ably.lib.liveobjects.value.noOp import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter import io.ably.lib.liveobjects.value.livemap.InternalLiveMap import io.ably.lib.liveobjects.value.livemap.isEntryOrRefTombstoned @@ -26,7 +27,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject /** * @spec RTO7 - Buffered object operations during sync */ - private val bufferedObjectOperations = mutableListOf() // RTO7a + internal val bufferedObjectOperations = mutableListOf() // RTO7a /** * Handles object messages (non-sync messages). @@ -206,7 +207,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * * @spec RTO9 - Creates zero-value objects if they don't exist */ - private fun applyObjectMessages( + internal fun applyObjectMessages( wireObjectMessages: List, source: ObjectsOperationSource = ObjectsOperationSource.CHANNEL, ) { @@ -244,8 +245,8 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject // so to simplify operations handling, we always try to create a zero-value object in the pool first, // and then we can always apply the operation on the existing object in the pool. val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(wireObjectOperation.objectId) // RTO9a2a1 - val applied = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3 - if (source == ObjectsOperationSource.LOCAL && applied && objectMessage.serial != null) { + val update = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3 + if (source == ObjectsOperationSource.LOCAL && !update.noOp && objectMessage.serial != null) { realtimeObjects.appliedOnAckSerials.add(objectMessage.serial) // RTO9a2a4 } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsPool.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsPool.kt index df15d0d6f..901b2a90f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsPool.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsPool.kt @@ -139,8 +139,10 @@ internal class ObjectsPool( * Garbage collection interval handler. */ private fun onGCInterval() { - pool.entries.removeIf { (_, obj) -> - if (obj.isEligibleForGc(gcGracePeriod)) { true } // Remove from pool + pool.entries.removeIf { (key, obj) -> + // RTO10c1b1 - the root object must never be removed from the pool (RTO3b). It can never + // become tombstoned per RTLO4e10, so this exclusion is an additional safeguard + if (key != ROOT_OBJECT_ID && obj.isEligibleForGc(gcGracePeriod)) { true } // Remove from pool else { obj.onGCInterval(gcGracePeriod) false // Keep in pool diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/BaseRealtimeLiveObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/BaseRealtimeLiveObject.kt index 04883f574..3a1ff20f8 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/BaseRealtimeLiveObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/BaseRealtimeLiveObject.kt @@ -45,7 +45,7 @@ internal abstract class BaseRealtimeObject( @Volatile internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter - private var tombstonedAt: Long? = null + internal var tombstonedAt: Long? = null /** * Reverse references: parent InternalLiveMap objectId -> set of keys at which that map @@ -125,11 +125,11 @@ internal abstract class BaseRealtimeObject( /** * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object` - * @return true if the operation was meaningfully applied, false otherwise + * @return the [ObjectUpdate] produced by the operation (RTLC9g/RTLM7f); [ObjectUpdate.NoOp] if nothing was applied * * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter */ - internal fun applyObject(wireObjectMessage: WireObjectMessage, source: ObjectsOperationSource): Boolean { + internal fun applyObject(wireObjectMessage: WireObjectMessage, source: ObjectsOperationSource): ObjectUpdate { validateObjectId(wireObjectMessage.operation?.objectId) val msgTimeSerial = wireObjectMessage.serial @@ -145,7 +145,7 @@ internal abstract class BaseRealtimeObject( "objectId=$objectId" ) } - return false // RTLC7b / RTLM15b + return ObjectUpdate.NoOp // RTLC7b / RTLM15b } // RTLC7c / RTLM15c - only update siteTimeserials for CHANNEL source if (source == ObjectsOperationSource.CHANNEL) { @@ -154,7 +154,7 @@ internal abstract class BaseRealtimeObject( if (isTombstoned) { // this object is tombstoned so the operation cannot be applied - return false // RTLC7e / RTLM15e + return ObjectUpdate.NoOp // RTLC7e / RTLM15e } return applyObjectOperation(wireObjectOperation, wireObjectMessage) // RTLC7d } @@ -184,8 +184,20 @@ internal abstract class BaseRealtimeObject( /** * Marks the object as tombstoned. The returned update carries `tombstone = true` and the * source message (RTLO4e5..e8); the caller emits it via notifyUpdated. + * The root object can never be tombstoned (RTLO4e10); such attempts return a noop update. */ internal fun tombstone(serialTimestamp: Long?, message: WireObjectMessage?): ObjectUpdate { + if (objectId == ROOT_OBJECT_ID) { + // RTLO4e10 - the root object must always exist in the ObjectsPool (RTO3b); the realtime + // system never publishes an OBJECT_DELETE operation or a tombstoned object state for it, + // so an attempt to tombstone it indicates a faulty message. Log a warning and skip it + Log.w( + tag, + "Attempt to tombstone the root object was rejected; " + + "serial=${message?.serial}, siteCode=${message?.siteCode}, messageId=${message?.id}", + ) + return ObjectUpdate.NoOp + } if (serialTimestamp == null) { Log.w(tag, "Tombstoning object $objectId without serial timestamp, using local timestamp instead") // RTLO6b1 } @@ -247,10 +259,10 @@ internal abstract class BaseRealtimeObject( * * @param operation The operation containing the action and data to apply * @param message The complete object message containing the operation - * @return true if the operation was meaningfully applied, false otherwise + * @return the [ObjectUpdate] produced by the operation (RTLC9g/RTLM7f); [ObjectUpdate.NoOp] if nothing was applied * */ - abstract fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): Boolean + abstract fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): ObjectUpdate /** * Clears the object's data and returns an update describing the changes. diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/InternalLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/InternalLiveCounter.kt index ca43021d7..2cd4219cf 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/InternalLiveCounter.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/InternalLiveCounter.kt @@ -72,7 +72,7 @@ internal class InternalLiveCounter private constructor( return liveCounterManager.applyState(wireObjectState, message) } - override fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): Boolean { + override fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): ObjectUpdate { return liveCounterManager.applyOperation(operation, message) } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/LiveCounterManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/LiveCounterManager.kt index 51a83dff8..c9b9cf82f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/LiveCounterManager.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livecounter/LiveCounterManager.kt @@ -46,33 +46,33 @@ internal class LiveCounterManager(private val liveCounter: InternalLiveCounter): * @spec RTLC7 - Applies operations to LiveCounter * @spec RTLC7f1 - [message] is the source ObjectMessage that contains the operation */ - internal fun applyOperation(operation: WireObjectOperation, message: WireObjectMessage): Boolean { + internal fun applyOperation(operation: WireObjectOperation, message: WireObjectMessage): ObjectUpdate { return when (operation.action) { WireObjectOperationAction.CounterCreate -> { val update = applyCounterCreate(operation, message) // RTLC7d1 liveCounter.notifyUpdated(update) // RTLC7d1a - true // RTLC7d1b + update // RTLC7d1b - RTLC9g: return the LiveCounterUpdate } WireObjectOperationAction.CounterInc -> { if (operation.counterInc != null) { val update = applyCounterInc(operation.counterInc, message) // RTLC7d5 liveCounter.notifyUpdated(update) // RTLC7d5a - true // RTLC7d5b + update // RTLC7d5b - RTLC9g: return the LiveCounterUpdate } else { // Log a warning and skip only this operation - throwing would abort every // sibling operation in the same ProtocolMessage batch Log.w(tag, "No payload found for ${operation.action} op for LiveCounter objectId=${objectId}, skipping") - false + ObjectUpdate.NoOp } } WireObjectOperationAction.ObjectDelete -> { val update = liveCounter.tombstone(message.serialTimestamp, message) // RTLC7d4 liveCounter.notifyUpdated(update) // RTLC7d4c - true // RTLC7d4b + update // RTLC7d4b - RTLC9g: return the LiveCounterUpdate } else -> { Log.w(tag, "Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3 - false + ObjectUpdate.NoOp } } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/InternalLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/InternalLiveMap.kt index fcecaddf8..1703800db 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/InternalLiveMap.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/InternalLiveMap.kt @@ -192,7 +192,7 @@ internal class InternalLiveMap private constructor( return liveMapManager.applyState(wireObjectState, message) } - override fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): Boolean { + override fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): ObjectUpdate { return liveMapManager.applyOperation(operation, message) } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/LiveMapManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/LiveMapManager.kt index 7594d0c1a..e4fa8e113 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/LiveMapManager.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/livemap/LiveMapManager.kt @@ -56,48 +56,48 @@ internal class LiveMapManager(private val liveMap: InternalLiveMap): LiveMapChan /** * @spec RTLM15 - Applies operations to LiveMap */ - internal fun applyOperation(operation: WireObjectOperation, message: WireObjectMessage): Boolean { + internal fun applyOperation(operation: WireObjectOperation, message: WireObjectMessage): ObjectUpdate { return when (operation.action) { WireObjectOperationAction.MapCreate -> { val update = applyMapCreate(operation, message) // RTLM15d1 liveMap.notifyUpdated(update) // RTLM15d1a - true // RTLM15d1b + update // RTLM15d1b - RTLM7f: return the LiveMapUpdate } WireObjectOperationAction.MapSet -> { if (operation.mapSet != null) { val update = applyMapSet(operation.mapSet, message.serial, message) // RTLM15d6 liveMap.notifyUpdated(update) // RTLM15d6a - true // RTLM15d6b + update // RTLM15d6b - RTLM7f: return the LiveMapUpdate } else { // Log a warning and skip only this operation - throwing would abort every // sibling operation in the same ProtocolMessage batch Log.w(tag, "No payload found for ${operation.action} op for LiveMap objectId=${objectId}, skipping") - false + ObjectUpdate.NoOp } } WireObjectOperationAction.MapRemove -> { if (operation.mapRemove != null) { val update = applyMapRemove(operation.mapRemove, message.serial, message.serialTimestamp, message) // RTLM15d7 liveMap.notifyUpdated(update) // RTLM15d7a - true // RTLM15d7b + update // RTLM15d7b - RTLM7f: return the LiveMapUpdate } else { Log.w(tag, "No payload found for ${operation.action} op for LiveMap objectId=${objectId}, skipping") - false + ObjectUpdate.NoOp } } WireObjectOperationAction.ObjectDelete -> { val update = liveMap.tombstone(message.serialTimestamp, message) // RTLM15d5 liveMap.notifyUpdated(update) // RTLM15d5c - true // RTLM15d5b + update // RTLM15d5b - RTLM7f: return the LiveMapUpdate } WireObjectOperationAction.MapClear -> { val update = applyMapClear(message) // RTLM15d8 liveMap.notifyUpdated(update) // RTLM15d8a - true // RTLM15d8b + update // RTLM15d8b - RTLM7f: return the LiveMapUpdate } else -> { Log.w(tag, "Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4 - false + ObjectUpdate.NoOp } } } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt index 5cc2f9360..91ec057d4 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt @@ -1,62 +1,30 @@ package io.ably.lib.liveobjects.integration.setup -import com.google.gson.JsonElement -import com.google.gson.JsonParser import io.ably.lib.liveobjects.ablyException import io.ably.lib.liveobjects.integration.helpers.RestObjects -import io.ably.lib.realtime.* +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.ConnectionEvent +import io.ably.lib.realtime.ConnectionState import io.ably.lib.types.ClientOptions -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.network.sockets.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.* +import io.ably.lib.uts.infra.integration.SandboxApp import kotlinx.coroutines.CompletableDeferred -private val client = HttpClient(CIO) { - install(HttpRequestRetry) { - maxRetries = 5 - retryIf { _, response -> - !response.status.isSuccess() - } - retryOnExceptionIf { _, cause -> - cause is ConnectTimeoutException || - cause is HttpRequestTimeoutException || - cause is SocketTimeoutException - } - exponentialDelay() - } -} - +/** + * A sandbox test app for the LiveObjects integration tests. Provisioning is delegated to the + * shared [SandboxApp] fixture from `:uts` (single source of truth for the sandbox host and the + * canonical `test-app-setup.json` app spec); this type just carries the fields the local + * client-factory extensions need. + */ class Sandbox private constructor(val appId: String, val apiKey: String) { companion object { - private suspend fun loadAppCreationJson(): JsonElement = - JsonParser.parseString( - client.get("https://raw.githubusercontent.com/ably/ably-common/refs/heads/main/test-resources/test-app-setup.json") { - contentType(ContentType.Application.Json) - }.bodyAsText(), - ).asJsonObject.get("post_apps") - internal suspend fun createInstance(): Sandbox { - val response: HttpResponse = client.post("https://sandbox.realtime.ably-nonprod.net/apps") { - contentType(ContentType.Application.Json) - setBody(loadAppCreationJson().toString()) - } - val body = JsonParser.parseString(response.bodyAsText()) - - return Sandbox( - appId = body.asJsonObject["appId"].asString, - // From JS chat repo at 7985ab7 — "The key we need to use is the one at index 5, which gives enough permissions to interact with Chat and Channels" - apiKey = body.asJsonObject["keys"].asJsonArray[0].asJsonObject["keyStr"].asString, - ) + val app = SandboxApp.create() + // defaultKey is the full-capability "appId.keyId:keySecret" key (index 0 of the app spec) + return Sandbox(appId = app.appId, apiKey = app.defaultKey) } } } - internal fun Sandbox.createRealtimeClient(options: ClientOptions.() -> Unit): AblyRealtime { val clientOptions = ClientOptions().apply { apply(options) diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/DefaultRealtimeObjectChannelStateTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/DefaultRealtimeObjectChannelStateTest.kt new file mode 100644 index 000000000..19fa6c3ae --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/DefaultRealtimeObjectChannelStateTest.kt @@ -0,0 +1,84 @@ +package io.ably.lib.liveobjects.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ROOT_OBJECT_ID +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter +import io.ably.lib.liveobjects.value.livemap.InternalLiveMap +import io.ably.lib.liveobjects.value.livemap.LiveMapEntry +import io.ably.lib.realtime.ChannelState +import io.mockk.unmockkAll +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * SDK-local regression guard — **NOT a UTS spec test**. No normative `RTO` point governs this + * behaviour and it is not observable through the public API (access on a DETACHED channel throws per + * RTO25b, and a re-attach re-syncs, replacing the pool regardless of whether detach cleared it), so it + * is intentionally excluded from the shared `objects/unit` UTS suite — see the tracking note in the + * spec's `objects_pool.md` / `realtime_object.md`. This test pins the ably-java implementation so a + * future refactor can't silently drop the clear. + * + * Behaviour ([DefaultRealtimeObject.handleStateChange]): a channel transition to DETACHED or FAILED + * clears all objects data **without emitting update events**; SUSPENDED **retains** the data (the + * current data is unknown in DETACHED/FAILED, whereas a SUSPENDED channel may still recover). + */ +class DefaultRealtimeObjectChannelStateTest { + + private lateinit var ro: DefaultRealtimeObject + + @AfterTest + fun tearDown() { + ro.objectsPool.dispose() + unmockkAll() // getMockAblyClientAdapter uses mockkStatic - clean up global state + } + + /** A populated pool: the root map with one entry, plus a child counter carrying data. */ + private fun seedPool() { + ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + rootMap().data["name"] = LiveMapEntry(timeserial = "01", data = WireObjectData(string = "Alice")) + ro.objectsPool.set("counter:x@1", InternalLiveCounter.zeroValue("counter:x@1", ro).apply { data.set(42.0) }) + } + + private fun rootMap(): InternalLiveMap = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + private fun childCounter(): InternalLiveCounter = ro.objectsPool.get("counter:x@1") as InternalLiveCounter + + /** + * [DefaultRealtimeObject.handleStateChange] launches on the internal sequential scope; enqueue an + * empty [DefaultRealtimeObject.asyncFuture] and await it to deterministically flush that scope + * (FIFO, `limitedParallelism(1)`) so the state-change handler has run before we assert — no polling, + * no flaky timing. + */ + private suspend fun flush() = ro.asyncFuture { }.await() + + @Test + fun `channel DETACHED clears objects data without emitting`() = runTest { + seedPool() + ro.handleStateChange(ChannelState.detached, false) + flush() + assertTrue(rootMap().data.isEmpty(), "root data must be cleared on DETACHED") + assertEquals(0.0, childCounter().value(), "child counter data must be cleared on DETACHED") + } + + @Test + fun `channel FAILED clears objects data without emitting`() = runTest { + seedPool() + ro.handleStateChange(ChannelState.failed, false) + flush() + assertTrue(rootMap().data.isEmpty(), "root data must be cleared on FAILED") + assertEquals(0.0, childCounter().value(), "child counter data must be cleared on FAILED") + } + + @Test + fun `channel SUSPENDED retains objects data`() = runTest { + seedPool() + ro.handleStateChange(ChannelState.suspended, false) + flush() + assertTrue(rootMap().data.containsKey("name"), "root data must be retained on SUSPENDED") + assertEquals(42.0, childCounter().value(), "child counter data must be retained on SUSPENDED") + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/LiveObjectTombstoneTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/LiveObjectTombstoneTest.kt new file mode 100644 index 000000000..057222be4 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/LiveObjectTombstoneTest.kt @@ -0,0 +1,89 @@ +package io.ably.lib.liveobjects.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ObjectsOperationSource +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectDelete +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.value.ObjectUpdate +import io.ably.lib.liveobjects.value.livemap.InternalLiveMap +import io.ably.lib.liveobjects.value.livemap.LiveMapEntry +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Derived from UTS `objects/unit/internal_live_map.md` — the RTLO4e10 root-tombstone guard. + * + * Lives in the `:liveobjects` module's own test source because these tests assert on the + * internal CRDT graph (`InternalLiveMap`, `WireObjectMessage`, `ObjectUpdate.NoOp`), which is + * not on the `uts` module's compile classpath (see uts-to-kotlin `objects-mapping.md` §13). + * The channel-level complement is `RTO10c1b1` in the uts module's `RealtimeObjectTest`. + */ +class LiveObjectTombstoneTest { + + private fun rootMapWithNameEntry(): InternalLiveMap { + val realtimeObject = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + val map = InternalLiveMap.zeroValue("root", realtimeObject) + map.data["name"] = LiveMapEntry(timeserial = "01", data = WireObjectData(string = "Alice")) + map.siteTimeserials["site1"] = "00" + return map + } + + @After + fun tearDown() = unmockkAll() // getMockAblyClientAdapter uses mockkStatic - clean up global state + + /** + * @UTS objects/unit/RTLO4e10/object-delete-root-noop-0 + */ + @Test + fun `RTLO4e10 - OBJECT_DELETE targeting root is rejected`() { + val map = rootMapWithNameEntry() + + val msg = WireObjectMessage( + serial = "01", + siteCode = "site1", + serialTimestamp = 1_700_000_000_000L, + operation = WireObjectOperation( + action = WireObjectOperationAction.ObjectDelete, + objectId = "root", + objectDelete = WireObjectDelete, + ), + ) + + map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertFalse(map.isTombstoned) + assertEquals("Alice", map.data["name"]?.data?.string) // data untouched + } + + /** + * @UTS objects/unit/RTLO4e10/replace-data-tombstone-root-noop-0 + */ + @Test + fun `RTLO4e10 - replaceData with tombstone flag targeting root is rejected`() { + val map = rootMapWithNameEntry() + + val stateMsg = WireObjectMessage( + objectState = WireObjectState( + objectId = "root", + siteTimeserials = mapOf("site1" to "01"), + tombstone = true, + map = WireObjectsMap(semantics = WireObjectsMapSemantics.LWW, entries = emptyMap()), + ), + ) + + val update = map.applyObjectSync(stateMsg) + + assertFalse(map.isTombstoned) + assertEquals("Alice", map.data["name"]?.data?.string) // data untouched + assertEquals(ObjectUpdate.NoOp, update) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md new file mode 100644 index 000000000..50570a791 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md @@ -0,0 +1,14 @@ +# UTS objects unit suite + +Skill-generated tests for the UTS `objects/unit` specs (`/uts-to-kotlin`), one class per spec, +package `io.ably.lib.liveobjects.uts.unit`. Run: `./gradlew :liveobjects:runLiveObjectsUnitTests`. + +- This suite lives in `:liveobjects`'s own test source set so the internal-graph specs can reach + `internal` members — the symbol map is `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17. +- **Convention:** public-tier spec tests use only the public API + `unit/Helpers.kt`; only the five + internal-graph specs (`internal_live_counter`, `internal_live_map`, `object_id`, `objects_pool`, + `parent_references`) and documented deviations may reference `io.ably.lib.liveobjects` internals. +- Transport bootstrap comes from the shared `:uts` fixtures (`io.ably.lib.uts.infra.*`); message + builders are the typed `Wire*` constructions in `unit/Helpers.kt`. +- Deviations are recorded in [`deviations.md`](deviations.md) (same discipline as + `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`). diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md new file mode 100644 index 000000000..3e5335b6e --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md @@ -0,0 +1,135 @@ +# Deviations — UTS objects unit suite (`io.ably.lib.liveobjects.uts.unit`) + +> Records every place a generated test deviates from its UTS spec, using the manual's +> **Recording deviations** entry format. Structural deviation vocabulary for this suite: +> S-1…S-4 in `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17.9. +> (`:uts`-hosted tiers keep their own file at `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`.) + +## UTS Spec Errors + +*(none)* + +## Failing Tests + +*(none)* + +## Adapted Tests + +### RTO18d — duplicate listener registration de-duplicates, fires once (`RealtimeObjectTest`) + +1. **Spec point:** RTO18d (`objects/unit/RTO18d/duplicate-listener-0`). +2. **What the spec says:** the default asserts the general `EventEmitter#on` / RTE4 reference — the same listener registered twice for one event fires **twice**. The UTS carries an **OPTIONAL note** sanctioning SDKs that de-duplicate listeners by instance to instead assert `call_count == 1` (or skip the test). +3. **What the SDK does:** ably-java de-duplicates — the same listener registered twice for `SYNCED` is invoked **once** per emission. Its core `EventEmitter` keys per-event registrations by listener instance (`filters.put(listener, …)`, `lib/.../util/EventEmitter.java`, reached via `ObjectsStateEmitter`), so the duplicate registration overwrites the first. This is a long-standing, deliberate SDK-wide choice (its own Javadoc documents it as an RTE4 deviation) — a listener registered twice runs identical logic, so double invocation serves no purpose. +4. **Root cause:** `EventEmitter.on(Event, Listener)` de-duplicates by listener instance. This is the core emitter backing Connection/Channel/Presence, not a LiveObjects-specific choice; any move to the RTE4 default would need scope-limiting to `ObjectsStateEmitter` (large blast radius otherwise) and would change `off()`'s remove-one-vs-all semantics. +5. **Test impact:** the test asserts ably-java's actual behaviour (`call_count == 1`) per the RTO18d optional note — it **passes unconditionally** (no env-gate) and pins the dedup (a regression that lost it would fire twice in one emission and fail the assertion). Cross-SDK reference (verified in source): ably-js (`eventemitter.ts`, `listeners.push`) and ably-cocoa (`ARTEventEmitter.m`, a fresh `ARTEventListener` per `on:`) both append → fire **twice**; ably-java dedupes → fires **once**. Sanctioned by the UTS optional clause, so this is a documented design divergence, **not** an SDK bug to fix. + +### S-2 — `pool.processAttached(...)` maps to the async `handleStateChange` (`ParentReferencesTest`) + +1. **Spec point:** RTO5c10 / RTO5c10a (setup step `pool.processAttached(ProtocolMessage(action: ATTACHED, channel: "test", channelSerial: "...", flags: HAS_OBJECTS))` in `objects/unit/parent_references.md`). +2. **What the spec says:** the pseudocode calls `processAttached` synchronously, then immediately delivers the OBJECT_SYNC. +3. **What the SDK does:** the equivalent `DefaultRealtimeObject.handleStateChange(ChannelState.attached, hasObjects = true)` launches on the internal sequential scope, so its effects (SYNCING transition) are asynchronous (shape deviation S-2, `objects-mapping.md` §17.9). +4. **Root cause:** `DefaultRealtimeObject.handleStateChange` wraps its body in `sequentialScope.launch { ... }` (`DefaultRealtimeObject.kt`). +5. **Test impact:** the tests await the transition with `assertWaiter { ro.state == ObjectsState.Syncing }` before delivering the sync (helper `processAttachedWithObjects()`), then call `objectsManager.handleObjectSyncMessages(...)` directly (the §17.6 mapping of `processObjectSync`). Affected tests (all pass): + - `objects/unit/RTO5c10/rebuild-from-sync-0` + - `objects/unit/RTO5c10a/rebuild-clears-stale-refs-0` (two attach+sync rounds — the S-2 handling applies to each) + - `objects/unit/RTO5c10/unreferenced-empty-refs-0` + +### S-1 — emitted subscription event carries no diff/`noop`/`tombstone` payload (`ObjectsPoolTest`) + +1. **Spec point:** RTO4b2a and RTO5c7 in `objects/unit/objects_pool.md` — on a no-objects ATTACHED / sync completion the pool emits a `LiveObjectUpdate` to `pool["root"].subscribe` listeners, and the tests assert on the emitted `updates[0].update` per-key diff. +2. **What the spec says:** the emitted `LiveObjectUpdate` carries the per-key `update` diff (e.g. `{ "name": "removed"/"updated" }`) as well as `noop`, `tombstone` and `objectMessage`. +3. **What the SDK does:** the update reaches subscribers as a `DefaultInstanceSubscriptionEvent`, which carries `getObject()` + `getMessage()` but **no diff/`noop`/`tombstone` accessors** (shape deviation S-1, `objects-mapping.md` §17.9). (Note: the op path `applyObject` now *returns* the `ObjectUpdate` per RTLC9g/RTLM7f, so the `internal_live_counter`/`internal_live_map` op-path tests assert the returned update directly — no adaptation is needed there; this deviation only concerns the emit path.) +4. **Root cause:** `InstanceSubscriptionEvent` exposes no diff payload (`instance/DefaultInstanceSubscriptionEvent.kt`). +5. **Test impact:** the `ObjectsPoolTest` RTO4b2a/RTO5c7 tests (marked `// DEVIATION S-1` inline) assert the emitted diff (`{ "name": "removed"/"updated" }`) via the pool/root **data state** instead of `updates[0].update`, and RTO4b2a's `objectMessage IS null` directly via `event.getMessage() == null`. All affected tests pass. + +### S-2 — `pool.processAttached(...)` maps to the async `handleStateChange` (`ObjectsPoolTest`) + +1. **Spec point:** every `pool.processAttached(ProtocolMessage(action: ATTACHED, ...))` setup/step in `objects/unit/objects_pool.md`. +2. **What the spec says:** `processAttached` executes synchronously; subsequent sync/object messages and assertions follow immediately. +3. **What the SDK does:** the equivalent `DefaultRealtimeObject.handleStateChange(ChannelState.attached, hasObjects)` launches its body on the internal sequential scope (shape deviation S-2, `objects-mapping.md` §17.9), so its effects are asynchronous — and several tests re-attach while already `Syncing`, where no observable state transition exists to await. +4. **Root cause:** `DefaultRealtimeObject.handleStateChange` wraps its body in `sequentialScope.launch { ... }` (`DefaultRealtimeObject.kt`). +5. **Test impact:** `ObjectsPoolTest.processAttached(hasObjects)` drives the manager steps of the attached branch synchronously (the §17.6/§17.9 sanctioned alternative to `assertWaiter`): `clearBufferedObjectOperations()` (RTO4d), `startNewSync(null)` (RTO4c), and for `HAS_OBJECTS=0` additionally `resetToInitialPool(true)` (RTO4b1/RTO4b2/RTO4b2a), `clearSyncObjectsPool()` (RTO4b3), `endSync()` (RTO4b4) — mirroring `handleStateChange`'s attached branch line-for-line. Affects every `ObjectsPoolTest` test that attaches; all pass. (The async production path itself is exercised by `ParentReferencesTest.processAttachedWithObjects`.) + +### S-3 — GC time comes from the clock, not a `now` parameter (`InternalLiveMapTest`) + +1. **Spec point:** RTLM19 (`objects/unit/RTLM19/gc-tombstoned-entries-0` — `map.gcTombstonedEntries(grace_period, now)`). +2. **What the spec says:** the GC entry point takes the current time as an explicit `now` argument alongside the grace period. +3. **What the SDK does:** the equivalent `InternalLiveMap.onGCInterval(gcGracePeriod)` has no `now` parameter — `LiveMapEntry.isEligibleForGc` reads the current time from `clock.currentTimeMillis()` (`SystemClock.clockFrom(adapter.clientOptions)`) (shape deviation S-3, `objects-mapping.md` §17.9). +4. **Root cause:** `InternalLiveMap.onGCInterval` / `LiveMapEntry.isEligibleForGc(gcGracePeriod, clock)` (`value/livemap/`). +5. **Test impact:** the test stubs `SystemClock.clockFrom(any())` via `mockkStatic(SystemClock::class)` to a fixed `Clock` returning the spec's `now` (a synchronous stub window — no poll/timeout overlaps it), then calls `onGCInterval(grace_period)`. `unmockkAll()` in `@AfterTest` restores it. The test passes. + +### S-4 — `ObjectsPool` construction starts a real GC coroutine (`InternalLiveCounterTest`, `InternalLiveMapTest`, `ObjectsPoolTest`) + +1. **Spec point:** every test in `objects/unit/internal_live_counter.md`, `objects/unit/internal_live_map.md` and `objects/unit/objects_pool.md` (setup steps `counter = InternalLiveCounter(...)` / `map = InternalLiveMap(...)` / `pool = ObjectsPool()`). +2. **What the spec says:** the objects are constructed as plain data structures with no lifecycle to manage. +3. **What the SDK does:** the internal classes are built against a `DefaultRealtimeObject` (§17.1 — no public constructors), whose `ObjectsPool.init` starts a GC coroutine plus an adapter `onGCGracePeriodUpdated` subscription (shape deviation S-4, `objects-mapping.md` §17.9). +4. **Root cause:** `ObjectsPool.init` (`ObjectsPool.kt`) — `startGCJob()` + `adapter.onGCGracePeriodUpdated`. +5. **Test impact:** each class builds against a `DefaultRealtimeObject("test", getMockAblyClientAdapter())` created in `@BeforeTest`, and `@AfterTest` tears down with `ro.objectsPool.dispose()` + `unmockkAll()`. No assertion is affected; all tests pass. + +### T-1 — wrong-typed `Instance` operations surface as throwing `as*` casts, not ErrorInfo 92007 (`InstanceTest`) + +1. **Spec point:** RTINS12d (`objects/unit/RTINS12d/set-non-map-throws-0`), RTINS14d (`objects/unit/RTINS14d/increment-non-counter-throws-0`), RTINS16c (`objects/unit/RTINS16c/subscribe-primitive-throws-0`). +2. **What the spec says:** calling `set` on a non-map, `increment` on a non-counter, or `subscribe` on a primitive Instance fails with an `ErrorInfo` with code `92007`. +3. **What the SDK does:** ably-java implements the typed-SDK variant (RTTS7c): `set`/`increment`/`subscribe` do not exist on the base `Instance` or on the wrong-typed sub-interface, so the dynamic-API call is not expressible; the equivalent failure is the fail-fast `Instance` cast (`asLiveMap()`/`asLiveCounter()`), which throws `IllegalStateException` per RTTS9d (`DefaultInstance.kt`) — no `ErrorInfo`/92007 is produced on this path. +4. **Root cause:** typed `Instance` hierarchy (RTTS7/RTTS9d); `DefaultInstance.as*` throws `IllegalStateException`. +5. **Test impact:** each test performs the spec's operation through the throwing cast and asserts `assertFailsWith { inst.asLiveMap().set(...) }` (and analogues) instead of asserting error code 92007 (marked `// DEVIATION` inline). All three tests pass. + +### T-2 — dynamic-API null-result reads have no typed-`Instance` equivalent (`InstanceTest`, `PathObjectTest`) + +1. **Spec point:** RTINS4d (`objects/unit/RTINS4/value-counter-0` — `map_inst.value() == null`), RTINS9c (`objects/unit/RTINS9/size-0` — `counter_inst.size() == null`), RTINS3b via RTPO8f (`objects/unit/RTPO8f/instance-primitive-wrapped-0` — `name_inst.id() == null`). +2. **What the spec says:** the polymorphic `Instance` returns `null` from `value()` on a map, `size()` on a counter, and `id()` on a primitive. +3. **What the SDK does:** the typed `Instance` partition (RTTS7c) puts each accessor only on the sub-interfaces where it is meaningful — a `LiveMapInstance` has no `value()`, a `LiveCounterInstance` has no `size()`, primitive instances have no `id` — and `Instance` casts to the wrong view throw (RTTS9d), so there is no non-throwing expression that returns `null`. +4. **Root cause:** typed `Instance` hierarchy (RTTS7c/RTTS9d). +5. **Test impact:** the tests assert the wrapped type instead (`assertEquals(ValueType.LIVE_MAP, mapInst.type)` etc., marked `// DEVIATION` inline) — the accessor's absence on that type is enforced at compile time. (Contrast `PathObject`: its casts never throw (RTTS5d), so the analogous `PathObject` null-reads in `path_object.md` are translated as real `null` assertions, not deviations.) All affected tests pass. + +### T-3 — `compact()` is not implemented; `compactJson()` is the typed-SDK equivalent (`InstanceTest`, `PathObjectTest`) + +1. **Spec point:** RTINS10 (`objects/unit/RTINS10/compact-0`), RTPO13 (`objects/unit/RTPO13/compact-recursive-0`), RTPO13c5 (`objects/unit/RTPO13c5/compact-cycle-detection-0`), RTPO13c (`objects/unit/RTPO13c/compact-counter-0`), and the `compact() == null` line of RTPO3c1 (`objects/unit/RTPO3c1/read-null-on-failure-0`). +2. **What the spec says:** `compact()` returns a plain recursive snapshot with raw binary values and, for cycles, reuses the already-compacted in-memory object (`result["prefs"]["back_ref"] IS result`). +3. **What the SDK does:** typed SDKs need not implement `compact` (RTTS3f/RTTS7d); ably-java exposes only `compactJson()`, which base64-encodes binary values (RTPO14b1) and represents cyclic references as `{ "objectId": ... }` markers (RTPO14b2). +4. **Root cause:** `PathObject`/`Instance` expose `compactJson()` only (RTTS3f/RTTS7d). +5. **Test impact:** each test calls `compactJson()` (marked `// DEVIATION` inline); the snapshot assertions are unchanged except `avatar` asserting the base64 form `"AQID"` (RTPO13) and the cycle asserting the `{ "objectId": "map:profile@1000" }` marker (RTPO13c5), which makes RTPO13c5 assert the same observable as RTPO14. All affected tests pass. + +### T-4 — invalid-input cases rejected at compile time by the typed signatures (`InternalLiveCounterApiTest`, `InternalLiveMapApiTest`, `PathObjectTest`) + +1. **Spec point:** RTLC12e1 (`objects/unit/RTLC12e1/increment-non-number-0` and the `null`/string/boolean/array/object rows of `objects/unit/RTLC12e1/increment-invalid-amounts-table-0`), RTLM20/RTLMV4c (`objects/unit/RTLM20/set-invalid-values-table-0`), RTPO5b (`objects/unit/RTPO5b/get-non-string-throws-0`), RTPO6b (`objects/unit/RTPO6b/at-non-string-throws-0`). +2. **What the spec says:** passing a non-Number increment amount throws 40003; setting an unsupported value type (function/undefined/symbol) throws 40013; passing a non-String key/path to `get`/`at` throws 40003. +3. **What the SDK does:** the typed signatures (`increment(@NotNull Number)`, `set(String, LiveMapValue)` with the closed `LiveMapValue` union, `get(@NotNull String)`, `at(@NotNull String)`) reject these inputs at compile time, so the runtime validation cannot be reached; function/undefined/symbol have no Kotlin/Java equivalent at all. +4. **Root cause:** typed-SDK signatures (`objects-mapping.md` §6 note — signature-forbidden cases are not expressible). +5. **Test impact:** the tests exist under their `@UTS` ids with a `// DEVIATION` comment documenting the compile-time enforcement and no runtime assertion (`increment-non-number-0`, `set-invalid-values-table-0`, `get-non-string-throws-0`, `at-non-string-throws-0`). The runtime-reachable rows (NaN / ±Infinity) are asserted for real in `increment-invalid-amounts-table-0` (ErrorInfo 40003); the `null` row is skipped per the spec's own language-applicability note (null is not passable through Kotlin's non-null `Number` parameter). All affected tests pass. + +### S-4 — `ObjectsPool` construction starts a real GC coroutine (`ParentReferencesTest`) + +1. **Spec point:** every test in `objects/unit/parent_references.md` (setup step `pool = ObjectsPool()` / the implicit pool behind `InternalLiveCounter(...)` / `InternalLiveMap(...)` construction). +2. **What the spec says:** `ObjectsPool()` is constructed as a plain data structure with no lifecycle to manage. +3. **What the SDK does:** the pool is created inside `DefaultRealtimeObject` (§17.1 instantiation — internal classes have no public constructors), and `ObjectsPool.init` starts a GC coroutine plus an adapter `onGCGracePeriodUpdated` subscription (shape deviation S-4, `objects-mapping.md` §17.9). +4. **Root cause:** `ObjectsPool.init` (`ObjectsPool.kt`) — `startGCJob()` + `adapter.onGCGracePeriodUpdated`. +5. **Test impact:** every `ParentReferencesTest` test builds its objects against a `DefaultRealtimeObject("test", getMockAblyClientAdapter())` created in `@BeforeTest`, and `@AfterTest` tears down with `ro.objectsPool.dispose()` + `unmockkAll()`. No assertion is affected; all tests pass. + +### T-4 — value-type invalid-input cases rejected at compile time by the typed signatures (`ValueTypesTest`) + +1. **Spec point:** RTLCV3c (`objects/unit/RTLCV3c/no-validation-at-create-0`), RTLCV4a (`objects/unit/RTLCV4a/evaluate-validates-count-0`), RTLMV4a (`objects/unit/RTLMV4a/evaluate-validates-entries-0`), RTLMV4b (`objects/unit/RTLMV4b/evaluate-validates-keys-0`), RTLMV4c (`objects/unit/RTLMV4c/evaluate-validates-values-0`). +2. **What the spec says:** `LiveCounter.create("not_a_number")` succeeds at creation (RTLCV3c) and its evaluation throws 40003 (RTLCV4a); `LiveMap.create(null)` evaluation throws 40003 (RTLMV4a); a non-String key throws 40003 (RTLMV4b); an unsupported value (a function) throws 40013 (RTLMV4c). +3. **What the SDK does:** the typed factories (`LiveCounter.create(@NotNull Number)`, `LiveMap.create(@NotNull Map)` with the closed `LiveMapValue` union) reject every one of these inputs at compile time (`objects-mapping.md` §6), so the runtime validations are unreachable as specified. +4. **Root cause:** typed-SDK signatures — same mechanism as the existing T-4 entry above. +5. **Test impact:** RTLMV4a/RTLMV4b/RTLMV4c exist under their `@UTS` ids with a `// DEVIATION` comment and no runtime assertion (RTLMV4b per the spec's own language-applicability note). RTLCV3c and RTLCV4a substitute the runtime-reachable invalid input the same RTLCV4a clause covers ("not a Number **or not finite**"): `LiveCounter.create(Double.NaN)` — creation does not throw (RTLCV3c) and evaluation throws ErrorInfo 40003 (RTLCV4a), asserted for real. All five tests pass. + +### T-6 — value-type retained state and evaluation are internal; retained creates carried as `derivedFrom` (`ValueTypesTest`) + +1. **Spec point:** RTLCV3 (`create-with-count-0`, `create-default-zero-0`), RTLCV4g5, RTLCV4 (`evaluate-zero-count-0`), RTLMV3 (`create-with-entries-0`), RTLMV4j5, RTLMV4d/RTLMV4d1/RTLMV4e2 and the `map-set-all-types-table-0` table (all in `objects/unit/value_types.md`). +2. **What the spec says:** the value type exposes its retained state (`vt.count`, `vt.entries`); `evaluate(vt)` is a first-class operation returning ObjectMessages; the evaluated operation retains `counterCreate`/`mapCreate` alongside the `*WithObjectId` payload. +3. **What the SDK does:** the retained state is deliberately non-public (RTLCV3d/RTLMV3d — no accessor on the public `LiveCounter`/`LiveMap`); evaluation is the internal `DefaultLiveCounter.createCounterCreateMessage` / `DefaultLiveMap.createMapCreateMessages` (RTLCV4h/RTLMV4k, `objects-mapping.md` §13 — the evaluation half of `value_types.md` is internal/wire-level); the retained create is carried as the local-only `derivedFrom` field on the wire `*CreateWithObjectId` types (`@Transient`, MCRO2/CCRO2 — the same modelling `public_object_message.md` uses for PAOOP3b2/PAOOP3c2). +4. **Root cause:** typed-SDK/wire modelling; no public evaluation surface exists by design. +5. **Test impact:** these tests (in `:liveobjects`'s own test source set) read `DefaultLiveCounter.initialCount` / `DefaultLiveMap.entries` for the retained-state assertions (marked `// DEVIATION T-6` inline), call the internal evaluation entry points via a local `evaluate(...)` helper against a §17.1 `DefaultRealtimeObject`, and assert the spec's `operation.counterCreate` / `operation.mapCreate` through `*CreateWithObjectId.derivedFrom`. All assertions are otherwise unchanged; all tests pass. + +### S-4 — `ObjectsPool` construction starts a real GC coroutine (`ValueTypesTest`) + +1. **Spec point:** every evaluation test in `objects/unit/value_types.md` (the `evaluate(vt)` step needs a realtime-object context for RTO14/RTO16 objectId derivation). +2. **What the spec says:** `evaluate(vt)` is a pure operation with no lifecycle to manage. +3. **What the SDK does:** evaluation runs against a `DefaultRealtimeObject` (§17.1 — internal classes have no public constructors), whose `ObjectsPool.init` starts a GC coroutine plus an adapter `onGCGracePeriodUpdated` subscription (shape deviation S-4, `objects-mapping.md` §17.9). +4. **Root cause:** `ObjectsPool.init` (`ObjectsPool.kt`) — `startGCJob()` + `adapter.onGCGracePeriodUpdated`. +5. **Test impact:** `ValueTypesTest` builds the harness in `@BeforeTest` (`DefaultRealtimeObject("test", getMockAblyClientAdapter())`) and tears down in `@AfterTest` with `unmockkAll()` + `ro.objectsPool.dispose()`. No assertion is affected; all tests pass. + +## Mock Infrastructure Limitations + +*(none)* diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt new file mode 100644 index 000000000..a86e48f54 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt @@ -0,0 +1,408 @@ +package io.ably.lib.liveobjects.uts.unit + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import io.ably.lib.liveobjects.message.ObjectMessage +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapRemove +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireMapClear +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectDelete +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectState +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.message.WireObjectsMap +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.message.toPublicMessage +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.PublishResult +import io.ably.lib.uts.infra.unit.ConnectionDetails +import io.ably.lib.uts.infra.unit.MockEvent +import io.ably.lib.uts.infra.unit.MockHttpClient +import io.ably.lib.uts.infra.unit.MockWebSocket +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.future.await + +/** + * LiveObjects unit-test helpers — the ably-java translation of the UTS + * `objects/helpers/standard_test_pool.md` (standard test pool, protocol-message / + * object-message builders, and the synced-channel setup) used by every objects + * unit spec (see `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §13/§17). + * + * This file lives in `:liveobjects`'s own test source set, so the builders construct the + * **typed internal wire DTOs** (`Wire*`) directly — no wire JSON, no reflection. The mock + * transport serializes [ProtocolMessage.state] through the SDK's own `ObjectJsonSerializer`, + * so the typed messages are the single source of truth for the wire form. + * + * The transport bootstrap uses the shared `:uts` fixtures (`io.ably.lib.uts.infra.unit.*`). + */ + +// --------------------------------------------------------------------------- +// Canonical constants (standard_test_pool.md "Canonical Constants") +// --------------------------------------------------------------------------- + +/** The harness `ConnectionDetails` siteCode delivered on CONNECT. */ +const val SITE_CODE: String = "test-site" + +/** + * The fixed apply-on-ACK serial scheme: `ack_serial(msgSerial, i) == "t:${msgSerial + 1}:$i"`, so the + * first publish's first op is `t:1:0`. The value must sort AFTER the standard pool's `t:0` entry + * timeserials under string LWW comparison (RTLM9) — otherwise locally applied MAP_SETs on existing pool + * entries would be rejected as stale. Replay tests that reuse the apply-on-ACK serial MUST reference this. + */ +fun ackSerial(msgSerial: Long?, i: Int): String = "t:${(msgSerial ?: 0) + 1}:$i" + +/** + * Baseline timeserial every standard-pool entry/object is seeded with; every synthetic serial is chosen + * relative to this under lexicographic string LWW comparison (RTLM9e). + */ +const val POOL_SERIAL: String = "t:0" + +/** + * A REMOTE inbound MAP_SET / MAP_REMOVE serial on an EXISTING pool entry: it sorts after [POOL_SERIAL], so it + * wins the per-entry LWW comparison (RTLM9e). A bare number like `"99"` sorts BEFORE `"t:0"` and would be + * rejected as stale. 0-based → `remoteSerial(0) == "t:1"`, `remoteSerial(1) == "t:2"`. (Counter increments and + * other object-level ops from a fresh siteCode compare per-site, not per-entry, so they apply regardless of + * serial value and need NOT use this.) + */ +fun remoteSerial(i: Int): String = "t:${i + 1}" + +/** + * A serial that is NOT an [ackSerial] (so it escapes the RTO9a3 apply-on-ACK echo dedup) yet sorts BELOW the + * first ackSerial (`ackSerial(0, 0) == "t:1:0"`), while still after [POOL_SERIAL]. Used by RTO20f to prove a + * LOCAL apply-on-ACK left siteTimeserials untouched (RTLC7c): had it wrongly recorded + * `siteTimeserials[SITE_CODE] = "t:1:0"`, this lower serial would be rejected by the per-site newness check. + * 0-based → `belowAckSerial(9) == "t:0:9"`. + */ +fun belowAckSerial(i: Int): String = "t:0:$i" + +// --------------------------------------------------------------------------- +// ObjectData (leaf value) builders — the `data` of a map entry / mapSet value +// --------------------------------------------------------------------------- + +internal fun dataString(value: String): WireObjectData = WireObjectData(string = value) +internal fun dataNumber(value: Number): WireObjectData = WireObjectData(number = value.toDouble()) +internal fun dataBoolean(value: Boolean): WireObjectData = WireObjectData(boolean = value) +internal fun dataObjectId(objectId: String): WireObjectData = WireObjectData(objectId = objectId) +internal fun dataBytes(base64: String): WireObjectData = WireObjectData(bytes = base64) +// The typed DTO carries the JsonElement; the SDK's WireObjectDataJsonSerializer stringifies it on the +// wire (OD4c5) and parses it back — no manual encoding needed here. +internal fun dataJson(element: JsonElement): WireObjectData = WireObjectData(json = element) + +// --------------------------------------------------------------------------- +// map / counter state + createOp fragments +// --------------------------------------------------------------------------- + +internal fun mapEntry( + data: WireObjectData, + timeserial: String = POOL_SERIAL, + tombstone: Boolean? = null, + serialTimestamp: Long? = null, +): WireObjectsMapEntry = + WireObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) + +/** A tombstoned map entry (`data` absent on the wire), e.g. RTLM6c1 / RTLM23a2 fixtures. */ +internal fun tombstonedMapEntry( + timeserial: String = POOL_SERIAL, + serialTimestamp: Long? = null, +): WireObjectsMapEntry = + WireObjectsMapEntry(tombstone = true, timeserial = timeserial, serialTimestamp = serialTimestamp, data = null) + +internal fun mapState( + entries: Map, + semantics: WireObjectsMapSemantics = WireObjectsMapSemantics.LWW, + clearTimeserial: String? = null, +): WireObjectsMap = WireObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) + +internal fun counterState(count: Number): WireObjectsCounter = WireObjectsCounter(count = count.toDouble()) + +/** + * A `createOp` fragment for [buildObjectState]. The operation's `objectId` is stamped by + * [buildObjectState] (it must equal the enclosing object's id — `validate`/`validateObjectId`), + * so it is intentionally blank here. + */ +internal fun mapCreateOp( + semantics: WireObjectsMapSemantics = WireObjectsMapSemantics.LWW, + entries: Map = emptyMap(), +): WireObjectOperation = + WireObjectOperation(action = WireObjectOperationAction.MapCreate, objectId = "", mapCreate = WireMapCreate(semantics, entries)) + +internal fun counterCreateOp(count: Number): WireObjectOperation = + WireObjectOperation( + action = WireObjectOperationAction.CounterCreate, + objectId = "", + counterCreate = WireCounterCreate(count = count.toDouble()), + ) + +// --------------------------------------------------------------------------- +// ObjectMessage builders — STATE (for OBJECT_SYNC) and OPERATIONS (for OBJECT) +// --------------------------------------------------------------------------- + +/** `build_object_state` — an ObjectMessage wrapping an ObjectState in its (wire) `object` field. */ +internal fun buildObjectState( + objectId: String, + siteTimeserials: Map, + map: WireObjectsMap? = null, + counter: WireObjectsCounter? = null, + tombstone: Boolean = false, + createOp: WireObjectOperation? = null, +): WireObjectMessage = WireObjectMessage( + objectState = WireObjectState( + objectId = objectId, + siteTimeserials = siteTimeserials, + tombstone = tombstone, + // the createOp's objectId must equal the object's id (LiveMapManager.validate / validateObjectId) + createOp = createOp?.copy(objectId = objectId), + map = map, + counter = counter, + ), +) + +/** `build_object_message_with_state` — wraps an already-built ObjectState in an ObjectMessage. */ +internal fun buildObjectMessageWithState(objectState: WireObjectState): WireObjectMessage = + WireObjectMessage(objectState = objectState) + +private fun operationMessage( + serial: String?, + siteCode: String?, + serialTimestamp: Long? = null, + operation: WireObjectOperation, +): WireObjectMessage = + WireObjectMessage(serial = serial, siteCode = siteCode, serialTimestamp = serialTimestamp, operation = operation) + +internal fun buildCounterInc(objectId: String, number: Number, serial: String? = null, siteCode: String? = null): WireObjectMessage = + operationMessage(serial, siteCode, operation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, objectId = objectId, counterInc = WireCounterInc(number = number.toDouble()), + )) + +internal fun buildMapSet(objectId: String, key: String, value: WireObjectData, serial: String? = null, siteCode: String? = null): WireObjectMessage = + operationMessage(serial, siteCode, operation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, objectId = objectId, mapSet = WireMapSet(key = key, value = value), + )) + +internal fun buildMapRemove(objectId: String, key: String, serial: String? = null, siteCode: String? = null, serialTimestamp: Long? = null): WireObjectMessage = + operationMessage(serial, siteCode, serialTimestamp, operation = WireObjectOperation( + action = WireObjectOperationAction.MapRemove, objectId = objectId, mapRemove = WireMapRemove(key = key), + )) + +internal fun buildMapClear(objectId: String, serial: String? = null, siteCode: String? = null): WireObjectMessage = + operationMessage(serial, siteCode, operation = WireObjectOperation( + action = WireObjectOperationAction.MapClear, objectId = objectId, mapClear = WireMapClear, + )) + +internal fun buildObjectDelete(objectId: String, serial: String? = null, siteCode: String? = null, serialTimestamp: Long? = null): WireObjectMessage = + operationMessage(serial, siteCode, serialTimestamp, operation = WireObjectOperation( + action = WireObjectOperationAction.ObjectDelete, objectId = objectId, objectDelete = WireObjectDelete, + )) + +internal fun buildCounterCreate(objectId: String, counterCreate: WireCounterCreate, serial: String? = null, siteCode: String? = null): WireObjectMessage = + operationMessage(serial, siteCode, operation = WireObjectOperation( + action = WireObjectOperationAction.CounterCreate, objectId = objectId, counterCreate = counterCreate, + )) + +internal fun buildMapCreate(objectId: String, mapCreate: WireMapCreate, serial: String? = null, siteCode: String? = null): WireObjectMessage = + operationMessage(serial, siteCode, operation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, objectId = objectId, mapCreate = mapCreate, + )) + +// --------------------------------------------------------------------------- +// ProtocolMessage builders +// --------------------------------------------------------------------------- + +internal fun buildObjectSyncMessage(channel: String, channelSerial: String, objectMessages: List): ProtocolMessage = + ProtocolMessage(ProtocolMessage.Action.object_sync).apply { + this.channel = channel + this.channelSerial = channelSerial + state = objectMessages.toTypedArray() + } + +internal fun buildObjectMessage(channel: String, objectMessages: List): ProtocolMessage = + ProtocolMessage(ProtocolMessage.Action.`object`).apply { + this.channel = channel + state = objectMessages.toTypedArray() + } + +fun buildAckMessage(msgSerial: Long?, serials: List): ProtocolMessage = + ProtocolMessage(ProtocolMessage.Action.ack).apply { + this.msgSerial = msgSerial + // `count` is the number of protocol messages acknowledged starting at msgSerial (one OBJECT + // publish per ACK here). ConnectionManager.PendingMessageQueue.ack acks `subList(0, count)`, so + // an unset count (0) would acknowledge nothing and the publish future would hang. The single + // acked message's PublishResult (res[0]) carries the per-object serials. + count = 1 + res = arrayOf(PublishResult(serials.toTypedArray())) + } + +/** + * `build_public_object_message` — constructs a public [ObjectMessage] (PAOM3) from a wire object message + * (as produced by the operation builders above) and a channel name, via the SDK's own mapping. + */ +internal fun buildPublicObjectMessage(objectMessage: WireObjectMessage, channelName: String): ObjectMessage = + objectMessage.toPublicMessage(channelName) + +// `provision_objects_via_rest(...)` is intentionally not here — it's REST fixture provisioning for +// *integration* tests and belongs with the :uts integration tier. + +// --------------------------------------------------------------------------- +// STANDARD_POOL_OBJECTS — the fixed tree shared by all objects unit specs +// --------------------------------------------------------------------------- + +private val SITE = mapOf("aaa" to POOL_SERIAL) + +internal val STANDARD_POOL_OBJECTS: List = listOf( + buildObjectState( + "root", SITE, + map = mapState( + linkedMapOf( + "name" to mapEntry(dataString("Alice")), + "age" to mapEntry(dataNumber(30)), + "active" to mapEntry(dataBoolean(true)), + "score" to mapEntry(dataObjectId("counter:score@1000")), + "profile" to mapEntry(dataObjectId("map:profile@1000")), + "data" to mapEntry(dataJson(JsonParser.parseString("""{"tags":["a","b"]}"""))), + "avatar" to mapEntry(dataBytes("AQID")), + ), + ), + createOp = mapCreateOp(), + ), + // Matches standard_test_pool.md: this counter's object-state carries the *post-create residual* + // count (0), with the initial value on the createOp. Counter sync is additive (RTLC6c+RTLC6d/RTLC16 + // → data = count + createOp.count; the spec's own RTLC6/replace-data-with-create-op asserts 100+50=150), + // so 0 + 100 = 100 with createOperationIsMerged==true, as every consumer asserts. + // (Was UTS spec issue SI-1: the spec previously declared count:100 AND createOp:100, materialising 200; + // fixed upstream in standard_test_pool.md to count:0 — see uts/SPEC_ISSUES.md.) + buildObjectState("counter:score@1000", SITE, counter = counterState(0), createOp = counterCreateOp(100)), + buildObjectState( + "map:profile@1000", SITE, + map = mapState( + linkedMapOf( + "email" to mapEntry(dataString("alice@example.com")), + "nested_counter" to mapEntry(dataObjectId("counter:nested@1000")), + "prefs" to mapEntry(dataObjectId("map:prefs@1000")), + ), + ), + createOp = mapCreateOp(), + ), + // Matches standard_test_pool.md: residual count 0, the initial 5 carried by the createOp + // (0 + 5 = 5, merged=true). (Was SI-1: spec previously had count:5 + createOp:5 → 10; fixed + // upstream to count:0. See uts/SPEC_ISSUES.md.) + buildObjectState("counter:nested@1000", SITE, counter = counterState(0), createOp = counterCreateOp(5)), + buildObjectState( + "map:prefs@1000", SITE, + map = mapState(linkedMapOf("theme" to mapEntry(dataString("dark")))), + createOp = mapCreateOp(), + ), +) + +// --------------------------------------------------------------------------- +// synced-channel setup +// --------------------------------------------------------------------------- + +/** Result of [setupSyncedChannel] — the spec's `{ client, channel, root, mock_ws }`. */ +data class SyncedChannel( + val client: AblyRealtime, + val channel: Channel, + val root: LiveMapPathObject, + val mockWs: MockWebSocket, +) + +/** + * The spec's `captured_messages` for OBJECT publishes: every [ProtocolMessage] the client sent to + * the mock with action `object`, in send order (RTLC12/RTLC13/RTLM20/RTLM21/RTO15 wire assertions). + */ +internal fun MockWebSocket.capturedObjectMessages(): List = + events.filterIsInstance() + .map { it.message } + .filter { it.action == ProtocolMessage.Action.`object` } + +/** `setup_synced_channel` — connected client + channel synced with [STANDARD_POOL_OBJECTS]; auto-ACKs OBJECT publishes. */ +internal suspend fun setupSyncedChannel(channelName: String): SyncedChannel = setup(channelName, autoAck = true) + +/** `setup_synced_channel_no_ack` — as above but does not ACK OBJECT publishes (for tests that control ACK timing). */ +internal suspend fun setupSyncedChannelNoAck(channelName: String): SyncedChannel = setup(channelName, autoAck = false) + +private suspend fun setup(channelName: String, autoAck: Boolean): SyncedChannel { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess( + ProtocolMessage(ProtocolMessage.Action.connected).apply { + connectionId = "conn-1" + connectionDetails = ConnectionDetails { + connectionKey = "conn-key-1" + siteCode = SITE_CODE + objectsGCGracePeriod = 86_400_000L + // Without an explicit maxMessageSize the field defaults to 0, which makes the + // SDK's RTO15d size check reject every OBJECT publish ("size N exceeds 0 bytes"). + maxMessageSize = 65_536 + } + }, + ) + } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.`object` -> if (autoAck) { + val serials = (msg.state?.indices ?: IntRange.EMPTY).map { ackSerial(msg.msgSerial, it) } + mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) + } + // standard_test_pool.md: an outbound DETACH is answered with DETACHED (both the + // ack and no-ack variants have this branch). Lets a solicited channel.detach() + // settle to DETACHED without an RTL13a re-attach. + ProtocolMessage.Action.detach -> mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel }, + ) + else -> Unit + } + } + } + + // Hermetic REST: *_CREATE publishes derive object ids from server time (RTO14/RTO16 — + // ServerTime.getCurrentTime -> adapter.time -> GET /time). Answer it locally so no test + // depends on a live rest.ably.io round-trip. (ServerTime caches the offset per JVM, so + // only the first create in a test JVM even reaches this handler.) + val mockHttp = MockHttpClient { + // the mock's HTTP call is two-phase: the connection attempt must succeed before the + // request is delivered to onRequest (MockHttpCall.execute) + onConnectionAttempt = { conn -> conn.respondWithSuccess() } + onRequest = { req -> + if (req.url.path.endsWith("/time")) { + req.respondWith(200, "[${System.currentTimeMillis()}]", mapOf("Content-Type" to "application/json")) + } else { + req.respondWith(404, "unexpected request in unit test: ${req.method} ${req.url}") + } + } + } + + val client = TestRealtimeClient { + key = "fake:key" + install(mockWs) + install(mockHttp) + } + val channel = client.channels.get( + channelName, + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + return SyncedChannel(client, channel, root, mockWs) +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InstanceTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InstanceTest.kt new file mode 100644 index 000000000..833f755cb --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InstanceTest.kt @@ -0,0 +1,420 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS spec `objects/unit/instance.md` — the public `Instance` API + * (`RTINS1`–`RTINS16`): identity (`id`), value/entry reads, mutations delegating to the + * underlying live objects, and identity-based subscriptions. + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt` + * (`setupSyncedChannel`, message builders). Typed-SDK notes + * (`objects-mapping.md` §5): the base `Instance` is partitioned into typed sub-interfaces + * reached via throwing `as*` casts (RTTS9d), the dynamic API's polymorphic + * `value()`/`size()`/`id()` null results and 92007 wrong-type errors are therefore expressed + * differently — see the `// DEVIATION` comments and `deviations.md`. + */ +class InstanceTest { + + /** + * @UTS objects/unit/RTINS3/id-returns-objectid-0 + */ + @Test + fun `RTINS3 - id property returns objectId`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val counterInst = assertNotNull(root.get("score").instance()) + assertEquals("counter:score@1000", counterInst.asLiveCounter().id) + + val mapInst = assertNotNull(root.get("profile").instance()) + assertEquals("map:profile@1000", mapInst.asLiveMap().id) + + client.close() + } + + /** + * @UTS objects/unit/RTINS4/value-counter-0 + */ + @Test + fun `RTINS4 - value returns counter number or primitive`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val counterInst = assertNotNull(root.get("score").instance()) + assertEquals(100.0, counterInst.asLiveCounter().value()) + + // DEVIATION (typed-SDK partition, see deviations.md): the base Instance has no value() + // and Instance casts throw on mismatch (RTTS9d), so `map_inst.value() == null` is not + // expressible; assert the wrapped type instead — a LIVE_MAP instance carries no value + // accessor by construction (RTINS4d / RTTS7c). + val mapInst = assertNotNull(root.instance()) + assertEquals(ValueType.LIVE_MAP, mapInst.type) + + client.close() + } + + /** + * @UTS objects/unit/RTINS5/get-wraps-entry-0 + */ + @Test + fun `RTINS5 - get returns Instance wrapping entry value`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val rootInst = assertNotNull(root.instance()).asLiveMap() + + val nameInst = rootInst.get("name") + assertIs(nameInst) + assertEquals("Alice", nameInst.asString().value()) + + val scoreInst = assertNotNull(rootInst.get("score")) + assertEquals("counter:score@1000", scoreInst.asLiveCounter().id) + + val nullInst = rootInst.get("nonexistent") + assertNull(nullInst) + + client.close() + } + + /** + * @UTS objects/unit/RTINS6/entries-yields-instances-0 + */ + @Test + fun `RTINS6 - entries returns array of key Instance pairs`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val rootInst = assertNotNull(root.instance()).asLiveMap() + + val entries = mutableMapOf() + for ((key, inst) in rootInst.entries()) { + entries[key] = inst + } + + assertEquals(7, entries.size) + assertIs(entries["name"]) + assertEquals("Alice", entries.getValue("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS9/size-0 + */ + @Test + fun `RTINS9 - size returns non-tombstoned count`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val rootInst = assertNotNull(root.instance()).asLiveMap() + assertEquals(7L, rootInst.size()) + + // DEVIATION (typed-SDK partition, see deviations.md): LiveCounterInstance has no size() + // and the Instance cast to LiveMapInstance throws (RTTS9d), so + // `counter_inst.size() == null` is not expressible; assert the wrapped type instead — + // a LIVE_COUNTER instance carries no size accessor by construction (RTINS9c / RTTS7c). + val counterInst = assertNotNull(root.get("score").instance()) + assertEquals(ValueType.LIVE_COUNTER, counterInst.type) + + client.close() + } + + /** + * @UTS objects/unit/RTINS10/compact-0 + */ + @Test + fun `RTINS10 - compact recursively compacts`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val rootInst = assertNotNull(root.instance()).asLiveMap() + + // DEVIATION (RTTS7d, see deviations.md): compact() is not implemented in ably-java; + // compactJson() is the typed-SDK equivalent and behaves identically for this tree. + val result = rootInst.compactJson() + + assertEquals("Alice", result.get("name").asString) + assertEquals(100.0, result.get("score").asDouble) + assertEquals("alice@example.com", result.getAsJsonObject("profile").get("email").asString) + + client.close() + } + + /** + * @UTS objects/unit/RTINS12/set-delegates-0 + */ + @Test + fun `RTINS12 - set delegates to InternalLiveMap set`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val rootInst = assertNotNull(root.instance()).asLiveMap() + + rootInst.set("name", LiveMapValue.of("Bob")).await() + + assertEquals("Bob", root.get("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS12d/set-non-map-throws-0 + */ + @Test + fun `RTINS12d - set on non-map throws`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()) + + // DEVIATION (typed-SDK, see deviations.md): set() does not exist on the wrong-typed + // Instance view; the equivalent failure is the throwing asLiveMap() cast (RTTS9d), + // which raises IllegalStateException rather than an ErrorInfo with code 92007. + assertFailsWith { + counterInst.asLiveMap().set("key", LiveMapValue.of("value")).await() + } + + client.close() + } + + /** + * @UTS objects/unit/RTINS13/remove-delegates-0 + */ + @Test + fun `RTINS13 - remove delegates to InternalLiveMap remove`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val rootInst = assertNotNull(root.instance()).asLiveMap() + + rootInst.remove("name").await() + + assertNull(root.get("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS14/increment-delegates-0 + */ + @Test + fun `RTINS14 - increment delegates to InternalLiveCounter increment`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()) + + counterInst.asLiveCounter().increment(25).await() + + assertEquals(125.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS14d/increment-non-counter-throws-0 + */ + @Test + fun `RTINS14d - increment on non-counter throws`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val mapInst = assertNotNull(root.instance()) + + // DEVIATION (typed-SDK, see deviations.md): increment() does not exist on the + // wrong-typed Instance view; the equivalent failure is the throwing asLiveCounter() + // cast (RTTS9d) — IllegalStateException, not ErrorInfo 92007. + assertFailsWith { + mapInst.asLiveCounter().increment(5).await() + } + + client.close() + } + + /** + * @UTS objects/unit/RTINS15/decrement-delegates-0 + */ + @Test + fun `RTINS15 - decrement delegates to InternalLiveCounter decrement`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()) + + counterInst.asLiveCounter().decrement(10).await() + + assertEquals(90.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS14a/increment-default-0 + */ + @Test + fun `RTINS14a - increment defaults to 1`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()) + + counterInst.asLiveCounter().increment().await() + + assertEquals(101.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS15a/decrement-default-0 + */ + @Test + fun `RTINS15a - decrement defaults to 1`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()) + + counterInst.asLiveCounter().decrement().await() + + assertEquals(99.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTINS16/subscribe-receives-events-0 + */ + @Test + fun `RTINS16 - subscribe receives InstanceSubscriptionEvent`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()).asLiveCounter() + val events = mutableListOf() + val sub = counterInst.subscribe(InstanceListener { event -> events.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertIs(sub) + assertEquals(1, events.size) + assertIs(events[0].getObject()) + assertEquals("counter:score@1000", events[0].getObject().asLiveCounter().id) + + client.close() + } + + /** + * @UTS objects/unit/RTINS16c/subscribe-primitive-throws-0 + */ + @Test + fun `RTINS16c - subscribe on primitive throws`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val nameInst = assertNotNull(assertNotNull(root.instance()).asLiveMap().get("name")) + // DEVIATION (typed-SDK, see deviations.md): subscribe() does not exist on primitive + // Instance sub-types (RTTS7b/RTTS7c); the equivalent failure is the throwing cast to a + // subscribable view (RTTS9d) — IllegalStateException, not ErrorInfo 92007. + assertFailsWith { + nameInst.asLiveMap().subscribe(InstanceListener { }) + } + + client.close() + } + + /** + * @UTS objects/unit/RTINS16e2/subscription-event-message-0 + */ + @Test + fun `RTINS16e2 - InstanceSubscriptionEvent contains public ObjectMessage`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val rootInst = assertNotNull(root.instance()).asLiveMap() + val events = mutableListOf() + rootInst.subscribe(InstanceListener { event -> events.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertIs(events[0].getObject()) + assertEquals("root", events[0].getObject().asLiveMap().id) + val message = assertNotNull(events[0].message) + assertEquals("test", message.channel) + assertEquals(ObjectOperationAction.MAP_SET, message.operation.action) + assertEquals("root", message.operation.objectId) + assertEquals("name", assertNotNull(message.operation.mapSet).key) + + client.close() + } + + /** + * @UTS objects/unit/RTINS16f/subscribe-returns-subscription-0 + */ + @Test + fun `RTINS16f - subscribe returns Subscription for deregistration`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()).asLiveCounter() + val events = mutableListOf() + val sub = counterInst.subscribe(InstanceListener { event -> events.add(event) }) + sub.unsubscribe() + + // Quiescence control: a second, still-subscribed listener on the same counter instance + // that WILL fire on the same dispatch as the send below + // (helpers/standard_test_pool.md "Negative-assertion quiescence"). + val controlEvents = mutableListOf() + counterInst.subscribe(InstanceListener { event -> controlEvents.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + + // Await the control listener; once it has fired, the unsubscribed listener would also + // have fired had it remained subscribed — then assert its count is unchanged. + pollUntil(5.seconds) { controlEvents.size >= 1 } + assertEquals(0, events.size) + + client.close() + } + + /** + * @UTS objects/unit/RTINS16g/subscription-follows-identity-0 + */ + @Test + fun `RTINS16g - Instance subscription follows identity not path`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()).asLiveCounter() + val events = mutableListOf() + counterInst.subscribe(InstanceListener { event -> events.add(event) }) + + // Repoint root's "score" key to a different object, then increment the ORIGINAL counter. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), remoteSerial(0), "remote")), + ), + ) + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertTrue(events.size >= 1) + // RTINS16e1: assert against the DELIVERED EVENT's object id (not the pre-existing + // counter_inst handle) — the listener followed the identity, not the "score" path. + assertIs(events[0].getObject()) + assertEquals("counter:score@1000", events[0].getObject().asLiveCounter().id) + + client.close() + } + + /** + * @UTS objects/unit/RTINS16h/subscribe-no-side-effects-0 + */ + @Test + fun `RTINS16h - subscribe has no side effects`() = runTest { + val (client, channel, root, _) = setupSyncedChannel("test") + val counterInst = assertNotNull(root.get("score").instance()).asLiveCounter() + val channelStateBefore = channel.state + + counterInst.subscribe(InstanceListener { }) + + assertEquals(channelStateBefore, channel.state) + + client.close() + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterApiTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterApiTest.kt new file mode 100644 index 000000000..d3aa6337d --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterApiTest.kt @@ -0,0 +1,165 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.types.AblyException +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.MockEvent +import io.ably.lib.uts.infra.unit.MockWebSocket +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS spec `objects/unit/internal_live_counter_api.md` — the public counter + * read/write surface (`RTLC5`, `RTLC11`–`RTLC13`): `value()`, `increment`/`decrement` wire + * messages and local apply-on-ACK, amount validation, and the counter update event. + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt`. The spec's + * hand-rolled capturing mock maps to the standard `setupSyncedChannel` transport (identical + * CONNECTED/ATTACHED/OBJECT_SYNC/ACK behaviour) plus the mock's recorded `MessageFromClient` + * event log as the spec's `captured_messages` — see [capturedObjectMessages]. + */ +class InternalLiveCounterApiTest { + + /** + * The spec's `captured_messages`: every OBJECT ProtocolMessage the client sent, read from + * the mock transport's event log. + */ + private fun capturedObjectMessages(mockWs: MockWebSocket): List = + mockWs.events.filterIsInstance() + .map { it.message } + .filter { it.action == ProtocolMessage.Action.`object` } + + /** + * @UTS objects/unit/RTLC5/value-returns-data-0 + */ + @Test + fun `RTLC5 - value returns current counter data`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val counter = root.get("score") + assertEquals(100.0, counter.asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLC12/increment-sends-counter-inc-0 + */ + @Test + fun `RTLC12 - increment sends v6 COUNTER_INC message`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(25).await() + + val capturedMessages = capturedObjectMessages(mockWs) + assertEquals(1, capturedMessages.size) + val objMsg = assertNotNull(capturedMessages[0].state)[0] as WireObjectMessage + val op = assertNotNull(objMsg.operation) + assertEquals(WireObjectOperationAction.CounterInc, op.action) + assertEquals("counter:score@1000", op.objectId) + assertEquals(25.0, assertNotNull(op.counterInc).number) + + client.close() + } + + /** + * @UTS objects/unit/RTLC12/increment-applies-locally-0 + */ + @Test + fun `RTLC12 - increment applies locally after ACK`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(50).await() + + assertEquals(150.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLC12e1/increment-non-number-0 + */ + @Test + fun `RTLC12e1 - increment with non-number throws`() { + // DEVIATION (see deviations.md): `increment` takes a `@NotNull Number`, so the spec's + // increment("not_a_number") is rejected at compile time — the invalid-input contract + // (ErrorInfo 40003) for non-Number amounts is enforced by the type system and cannot be + // exercised at runtime. The runtime-reachable invalid amounts (NaN / ±Infinity) are + // covered by `RTLC12e1 - Table-driven invalid increment amounts`. + } + + /** + * @UTS objects/unit/RTLC13/decrement-negates-0 + */ + @Test + fun `RTLC13 - decrement delegates to increment with negated amount`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().decrement(15).await() + + val capturedMessages = capturedObjectMessages(mockWs) + val objMsg = assertNotNull(capturedMessages[0].state)[0] as WireObjectMessage + assertEquals(-15.0, assertNotNull(assertNotNull(objMsg.operation).counterInc).number) + assertEquals(85.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLC11/counter-update-on-inc-0 + */ + @Test + fun `RTLC11 - LiveCounterUpdate emitted on increment`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + val updates = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote-site"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(7.0, assertNotNull(assertNotNull(updates[0].message).operation.counterInc).number) + + client.close() + } + + /** + * @UTS objects/unit/RTLC12e1/increment-invalid-amounts-table-0 + */ + @Test + fun `RTLC12e1 - Table-driven invalid increment amounts`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + // DEVIATION (see deviations.md): only the NaN / Infinity / -Infinity rows are + // expressible against `increment(@NotNull Number)`. The string / boolean / array / + // object rows are rejected at compile time by the Number signature, and the `null` row + // is not passable from Kotlin (non-null parameter) — per the spec's own + // language-applicability note, that row does not apply where null cannot be passed. + val invalidAmounts = listOf( + Double.NaN to "NaN", + Double.POSITIVE_INFINITY to "Infinity", + Double.NEGATIVE_INFINITY to "-Infinity", + ) + + for ((value, label) in invalidAmounts) { + val error = assertFailsWith("expected failure for $label") { + root.get("score").asLiveCounter().increment(value).await() + } + assertEquals(40003, error.errorInfo.code, "wrong error code for $label") + } + + client.close() + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterTest.kt new file mode 100644 index 000000000..2e79b84d2 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterTest.kt @@ -0,0 +1,528 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ObjectsOperationSource +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectsCounter +import io.ably.lib.liveobjects.unit.getMockAblyClientAdapter +import io.ably.lib.liveobjects.value.ObjectUpdate +import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter +import io.ably.lib.liveobjects.value.noOp +import io.mockk.unmockkAll +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Derived from UTS spec `objects/unit/internal_live_counter.md` — the `InternalLiveCounter` + * CRDT data structure: increment/create operations (`RTLC7`–`RTLC9`, `RTLC16`), data + * replacement during sync (`RTLC6`, `RTLC14`), tombstoning (`RTLO4e`, `RTLO5`, `RTLO6`, + * `RTLC7e`), and serial-based newness checks (`RTLO4a`, `RTLC7c`). + * + * Internal-graph spec: asserts on the internal CRDT graph, so it lives in `:liveobjects`'s own + * test source set — symbol map in `.claude/skills/uts-to-kotlin/references/objects-mapping.md` + * §17 (instantiation §17.1, counter §17.3, update object §17.5). + * + * The op path (`applyObject`) returns the `ObjectUpdate` per the spec contract (RTLC9g/RTLM7f), so + * op-path tests assert the returned update directly, exactly like the sync path (`applyObjectSync`). + */ +class InternalLiveCounterTest { + + private lateinit var ro: DefaultRealtimeObject + + @BeforeTest + fun setUp() { + // §17.1 - internal classes have no public constructors; build them against a + // DefaultRealtimeObject backed by the mocked adapter. + ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + } + + @AfterTest + fun tearDown() { + // DEVIATION S-4 (see deviations.md): ObjectsPool.init starts a real GC coroutine + + // adapter subscription - dispose it; unmockkAll clears the mockkStatic global state. + ro.objectsPool.dispose() + unmockkAll() + } + + /** + * @UTS objects/unit/RTLC4/zero-value-0 + */ + @Test + fun `RTLC4 - zero-value InternalLiveCounter`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + assertEquals(0.0, counter.data.get()) + assertEquals("counter:abc@1000", counter.objectId) + assertFalse(counter.isTombstoned) + assertNull(counter.tombstonedAt) + assertFalse(counter.createOperationIsMerged) + assertEquals(emptyMap(), counter.siteTimeserials) + } + + /** + * @UTS objects/unit/RTLC9/counter-inc-basic-0 + */ + @Test + fun `RTLC9 - COUNTER_INC adds number to data`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterInc("counter:abc@1000", 5, "01", "site1") + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(5.0, counter.data.get()) + val counterUpdate = assertIs(update) // update.noop == false + assertEquals(5.0, counterUpdate.amount) // update.update.amount == 5 + assertEquals(msg, counterUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLC9/counter-inc-negative-0 + */ + @Test + fun `RTLC9 - COUNTER_INC with negative number`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(10.0) + counter.siteTimeserials["site1"] = "00" + + val msg = buildCounterInc("counter:abc@1000", -3, "01", "site1") + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(7.0, counter.data.get()) + val counterUpdate = assertIs(update) + assertEquals(-3.0, counterUpdate.amount) // update.update.amount == -3 + assertEquals(msg, counterUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLC9/counter-inc-missing-number-0 + */ + @Test + fun `RTLC9 - COUNTER_INC with missing number is noop`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(10.0) + + val msg = WireObjectMessage( + serial = "01", + siteCode = "site1", + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, + objectId = "counter:abc@1000", + counterInc = WireCounterInc(number = null), + ), + ) + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(10.0, counter.data.get()) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLC9/counter-inc-accumulate-0 + */ + @Test + fun `RTLC9 - multiple COUNTER_INC operations accumulate`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + counter.applyObject(buildCounterInc("counter:abc@1000", 10, "01", "site1"), ObjectsOperationSource.CHANNEL) + counter.applyObject(buildCounterInc("counter:abc@1000", 20, "02", "site1"), ObjectsOperationSource.CHANNEL) + counter.applyObject(buildCounterInc("counter:abc@1000", -5, "01", "site2"), ObjectsOperationSource.CHANNEL) + + assertEquals(25.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLC8/counter-create-merge-0 + */ + @Test + fun `RTLC8 - COUNTER_CREATE merges initial count`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterCreate("counter:abc@1000", WireCounterCreate(count = 42.0), "01", "site1") + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(42.0, counter.data.get()) + assertTrue(counter.createOperationIsMerged) + val counterUpdate = assertIs(update) + assertEquals(42.0, counterUpdate.amount) // update.update.amount == 42 + assertEquals(msg, counterUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLC8/counter-create-already-merged-0 + */ + @Test + fun `RTLC8 - COUNTER_CREATE noop when already merged`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(42.0) + counter.createOperationIsMerged = true + counter.siteTimeserials["site1"] = "00" + + val msg = buildCounterCreate("counter:abc@1000", WireCounterCreate(count = 99.0), "01", "site1") + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(42.0, counter.data.get()) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLC16/counter-create-no-count-0 + */ + @Test + fun `RTLC16 - COUNTER_CREATE with missing count is noop`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterCreate("counter:abc@1000", WireCounterCreate(count = null), "01", "site1") + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(0.0, counter.data.get()) + assertTrue(counter.createOperationIsMerged) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLO4a/apply-empty-site-serial-0 + */ + @Test + fun `RTLO4a - canApplyOperation allows when siteSerial is empty`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterInc("counter:abc@1000", 5, "01", "site1") + val result = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + // ASSERT result IS NOT false -> a real (non-noop) update was returned + assertFalse(result.noOp) + assertEquals(5.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLO4a/reject-stale-serial-0 + */ + @Test + fun `RTLO4a - canApplyOperation rejects stale serial`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.siteTimeserials["site1"] = "05" + counter.data.set(10.0) + + val msg = buildCounterInc("counter:abc@1000", 99, "03", "site1") + val result = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(result.noOp) // rejected -> nothing applied + assertEquals(10.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLO4a/reject-equal-serial-0 + */ + @Test + fun `RTLO4a - canApplyOperation rejects equal serial`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.siteTimeserials["site1"] = "05" + counter.data.set(10.0) + + val msg = buildCounterInc("counter:abc@1000", 99, "05", "site1") + val result = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(result.noOp) // rejected -> nothing applied + assertEquals(10.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLO4a/warn-invalid-serial-0 + */ + @Test + fun `RTLO4a - canApplyOperation warns on empty serial or siteCode`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msgNoSerial = buildCounterInc("counter:abc@1000", 5, "", "site1") + val result1 = counter.applyObject(msgNoSerial, ObjectsOperationSource.CHANNEL) + + val msgNoSite = buildCounterInc("counter:abc@1000", 5, "01", "") + val result2 = counter.applyObject(msgNoSite, ObjectsOperationSource.CHANNEL) + + assertEquals(0.0, counter.data.get()) + assertTrue(result1.noOp) + assertTrue(result2.noOp) + } + + /** + * @UTS objects/unit/RTLC7c/channel-source-updates-serials-0 + */ + @Test + fun `RTLC7c - CHANNEL source updates siteTimeserials`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterInc("counter:abc@1000", 5, "01", "site1") + counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals("01", counter.siteTimeserials["site1"]) + } + + /** + * @UTS objects/unit/RTLC7c/local-source-no-serial-update-0 + */ + @Test + fun `RTLC7c - LOCAL source does not update siteTimeserials`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterInc("counter:abc@1000", 5, "01", "site1") + counter.applyObject(msg, ObjectsOperationSource.LOCAL) + + assertEquals(emptyMap(), counter.siteTimeserials) + assertEquals(5.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLC7g/apply-returns-true-0 + */ + @Test + fun `RTLC7g - applyOperation returns true on success`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildCounterInc("counter:abc@1000", 5, "01", "site1") + val result = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertFalse(result.noOp) // result == true -> a real (non-noop) update was returned + } + + /** + * @UTS objects/unit/RTLO5/object-delete-tombstones-0 + */ + @Test + fun `RTLO5 - OBJECT_DELETE tombstones counter`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(42.0) + counter.siteTimeserials["site1"] = "00" + + val msg = buildObjectDelete("counter:abc@1000", "01", "site1", 1_700_000_000_000L) + val update = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(counter.isTombstoned) + assertEquals(0.0, counter.data.get()) + assertEquals(1_700_000_000_000L, counter.tombstonedAt) + val counterUpdate = assertIs(update) + assertEquals(-42.0, counterUpdate.amount) // update.update.amount == -42 + assertTrue(counterUpdate.tombstone) // update.tombstone == true + assertEquals(msg, counterUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLC7e/tombstoned-reject-ops-0 + */ + @Test + fun `RTLC7e - operations on tombstoned counter are rejected`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.isTombstoned = true + counter.tombstonedAt = 1_700_000_000_000L + + val msg = buildCounterInc("counter:abc@1000", 5, "01", "site1") + val result = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(result.noOp) // tombstoned -> rejected, nothing applied + assertEquals(0.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLO6/tombstoned-at-from-serial-timestamp-0 + */ + @Test + fun `RTLO6 - tombstonedAt from serialTimestamp`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildObjectDelete("counter:abc@1000", "01", "site1", 1_700_000_050_000L) + counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(1_700_000_050_000L, counter.tombstonedAt) + } + + /** + * @UTS objects/unit/RTLO6/tombstoned-at-local-clock-0 + */ + @Test + fun `RTLO6 - tombstonedAt from local clock when no serialTimestamp`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + val beforeTime = System.currentTimeMillis() + + val msg = buildObjectDelete("counter:abc@1000", "01", "site1") + counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + val afterTime = System.currentTimeMillis() + assertTrue(counter.tombstonedAt!! >= beforeTime) + assertTrue(counter.tombstonedAt!! <= afterTime) + } + + /** + * @UTS objects/unit/RTLC7d3/unsupported-action-0 + */ + @Test + fun `RTLC7d3 - unsupported action is discarded`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val msg = buildMapSet("counter:abc@1000", "x", dataString("y"), "01", "site1") + val result = counter.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(result.noOp) // unsupported action -> discarded, nothing applied + assertEquals(0.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLC6/replace-data-basic-0 + */ + @Test + fun `RTLC6 - replaceData sets data from ObjectState`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(10.0) + counter.createOperationIsMerged = true + counter.siteTimeserials["site1"] = "00" + + val stateMsg = buildObjectState("counter:abc@1000", mapOf("site2" to "05"), counter = counterState(50)) + val update = counter.applyObjectSync(stateMsg) + + assertEquals(50.0, counter.data.get()) + assertEquals(mapOf("site2" to "05"), counter.siteTimeserials) + assertFalse(counter.createOperationIsMerged) + val counterUpdate = assertIs(update) + assertEquals(40.0, counterUpdate.amount) + assertEquals(stateMsg, counterUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLC6/replace-data-with-create-op-0 + */ + @Test + fun `RTLC6 - replaceData with createOp merges initial value`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + val stateMsg = buildObjectState( + "counter:abc@1000", mapOf("site1" to "01"), + counter = counterState(100), + createOp = counterCreateOp(50), + ) + val update = counter.applyObjectSync(stateMsg) + + assertEquals(150.0, counter.data.get()) + assertTrue(counter.createOperationIsMerged) + val counterUpdate = assertIs(update) + assertEquals(150.0, counterUpdate.amount) + assertEquals(stateMsg, counterUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLC6e/replace-data-tombstoned-noop-0 + */ + @Test + fun `RTLC6e - replaceData on tombstoned counter is noop`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.isTombstoned = true + counter.tombstonedAt = 1_700_000_000_000L + counter.data.set(0.0) + + val stateMsg = buildObjectState("counter:abc@1000", mapOf("site1" to "01"), counter = counterState(999)) + val update = counter.applyObjectSync(stateMsg) + + assertEquals(0.0, counter.data.get()) + assertIs(update) + } + + /** + * @UTS objects/unit/RTLC6f/replace-data-tombstone-flag-0 + */ + @Test + fun `RTLC6f - replaceData with tombstone flag tombstones counter`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(30.0) + + val stateMsg = buildObjectState( + "counter:abc@1000", mapOf("site1" to "01"), + counter = counterState(0), + tombstone = true, + ) + val update = counter.applyObjectSync(stateMsg) + + assertTrue(counter.isTombstoned) + assertEquals(0.0, counter.data.get()) + val counterUpdate = assertIs(update) + assertEquals(-30.0, counterUpdate.amount) + assertTrue(counterUpdate.tombstone) + assertEquals(stateMsg, counterUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLC6/replace-data-missing-count-0 + */ + @Test + fun `RTLC6 - replaceData with missing counter count defaults to 0`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(42.0) + + val stateMsg = buildObjectState( + "counter:abc@1000", mapOf("site1" to "01"), + counter = WireObjectsCounter(count = null), + ) + val update = counter.applyObjectSync(stateMsg) + + assertEquals(0.0, counter.data.get()) + val counterUpdate = assertIs(update) + assertEquals(-42.0, counterUpdate.amount) + assertEquals(stateMsg, counterUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLC14/diff-calculation-0 + */ + @Test + fun `RTLC14 - diff calculation`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(20.0) + + val stateMsg = buildObjectState("counter:abc@1000", mapOf("site1" to "01"), counter = counterState(75)) + val update = counter.applyObjectSync(stateMsg) + + val counterUpdate = assertIs(update) + assertEquals(55.0, counterUpdate.amount) + assertEquals(stateMsg, counterUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLC8/create-then-inc-0 + */ + @Test + fun `RTLC8 - COUNTER_CREATE then COUNTER_INC accumulates`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + counter.applyObject( + buildCounterCreate("counter:abc@1000", WireCounterCreate(count = 100.0), "01", "site1"), + ObjectsOperationSource.CHANNEL, + ) + counter.applyObject( + buildCounterInc("counter:abc@1000", 25, "02", "site1"), + ObjectsOperationSource.CHANNEL, + ) + + assertEquals(125.0, counter.data.get()) + assertTrue(counter.createOperationIsMerged) + } + + /** + * @UTS objects/unit/RTLO3/live-object-init-properties-0 + */ + @Test + fun `RTLO3 - LiveObject properties initialized correctly`() { + val counter = InternalLiveCounter.zeroValue("counter:test@2000", ro) + + assertEquals("counter:test@2000", counter.objectId) + assertEquals(emptyMap(), counter.siteTimeserials) + assertFalse(counter.createOperationIsMerged) + assertFalse(counter.isTombstoned) + assertNull(counter.tombstonedAt) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapApiTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapApiTest.kt new file mode 100644 index 000000000..719851c88 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapApiTest.kt @@ -0,0 +1,325 @@ +package io.ably.lib.liveobjects.uts.unit + +import com.google.gson.JsonParser +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.value.LiveCounter +import io.ably.lib.liveobjects.value.LiveMap +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.uts.infra.unit.MockEvent +import io.ably.lib.uts.infra.unit.MockWebSocket +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Derived from UTS spec `objects/unit/internal_live_map_api.md` — the public map read/write + * surface (`RTLM5`, `RTLM10`–`RTLM12`, `RTLM20`–`RTLM21`, `RTLMV4`, `RTLCV4`): key reads, + * size/entries/keys, `set`/`remove` wire messages (including value-type evaluation into + * `*_CREATE` + `MAP_SET` sequences) and local apply-on-ACK. + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt`. The spec's + * hand-rolled capturing mock maps to the standard `setupSyncedChannel` transport plus the + * mock's recorded `MessageFromClient` event log as the spec's `captured_messages` — see + * [capturedObjectMessages]. + * + * Note: evaluating a `LiveCounter`/`LiveMap` value type generates its objectId from server + * time (RTO16), which the SDK fetches once per JVM via REST `GET /time` — the mock transport + * does not intercept HTTP, so the first `*_CREATE` test in a run performs that single + * unauthenticated request against the real endpoint. + */ +class InternalLiveMapApiTest { + + /** + * The spec's `captured_messages`: every OBJECT ProtocolMessage the client sent, read from + * the mock transport's event log. + */ + private fun capturedObjectMessages(mockWs: MockWebSocket): List = + mockWs.events.filterIsInstance() + .map { it.message } + .filter { it.action == ProtocolMessage.Action.`object` } + + /** + * @UTS objects/unit/RTLM5/get-string-value-0 + */ + @Test + fun `RTLM5 - get returns resolved value from InternalLiveMap`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals("Alice", root.get("name").asString().value()) + assertEquals(30.0, root.get("age").asNumber().value()?.toDouble()) + assertEquals(true, root.get("active").asBoolean().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLM5/get-nonexistent-key-0 + */ + @Test + fun `RTLM5 - get returns null for non-existent key`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("nonexistent").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLM5/get-objectid-reference-0 + */ + @Test + fun `RTLM5 - get resolves objectId to LiveObject`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals(100.0, root.get("score").asLiveCounter().value()) + assertEquals("alice@example.com", root.get("profile").asLiveMap().get("email").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLM10/size-non-tombstoned-0 + */ + @Test + fun `RTLM10 - size returns non-tombstoned entry count`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals(7L, root.size()) + + client.close() + } + + /** + * @UTS objects/unit/RTLM11/entries-yields-pairs-0 + */ + @Test + fun `RTLM11 - entries yields key-value pairs`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val entries = mutableListOf() + for ((key, _) in root.entries()) { + entries.add(key) + } + + assertContains(entries, "name") + assertContains(entries, "age") + assertContains(entries, "active") + assertContains(entries, "score") + assertContains(entries, "profile") + assertContains(entries, "data") + assertContains(entries, "avatar") + assertEquals(7, entries.size) + + client.close() + } + + /** + * @UTS objects/unit/RTLM12/keys-0 + */ + @Test + fun `RTLM12 - keys yields only keys`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val keys = root.keys().toList() + + assertEquals(7, keys.size) + assertContains(keys, "name") + + client.close() + } + + /** + * @UTS objects/unit/RTLM20/set-sends-map-set-0 + */ + @Test + fun `RTLM20 - set sends MAP_SET message with v6 format`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.set("name", LiveMapValue.of("Bob")).await() + + val capturedMessages = capturedObjectMessages(mockWs) + assertEquals(1, capturedMessages.size) + val objMsg = assertNotNull(capturedMessages[0].state)[0] as WireObjectMessage + val op = assertNotNull(objMsg.operation) + assertEquals(WireObjectOperationAction.MapSet, op.action) + assertEquals("root", op.objectId) + assertEquals("name", assertNotNull(op.mapSet).key) + assertEquals("Bob", assertNotNull(op.mapSet).value.string) + + client.close() + } + + /** + * @UTS objects/unit/RTLM20/set-value-types-0 + */ + @Test + fun `RTLM20 - set with different value types`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.set("num_key", LiveMapValue.of(42)).await() + root.set("bool_key", LiveMapValue.of(false)).await() + root.set("json_key", LiveMapValue.of(JsonParser.parseString("""{"nested": true}""").asJsonObject)).await() + + val capturedMessages = capturedObjectMessages(mockWs) + val mapSet0 = assertNotNull((assertNotNull(capturedMessages[0].state)[0] as WireObjectMessage).operation?.mapSet) + val mapSet1 = assertNotNull((assertNotNull(capturedMessages[1].state)[0] as WireObjectMessage).operation?.mapSet) + val mapSet2 = assertNotNull((assertNotNull(capturedMessages[2].state)[0] as WireObjectMessage).operation?.mapSet) + assertEquals(42.0, mapSet0.value.number) + assertEquals(false, mapSet1.value.boolean) + assertEquals(JsonParser.parseString("""{"nested": true}"""), mapSet2.value.json) + + client.close() + } + + /** + * @UTS objects/unit/RTLM20e7g/set-counter-value-type-0 + */ + @Test + fun `RTLM20e7g - set with LiveCounter generates COUNTER_CREATE and MAP_SET`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.set("new_counter", LiveMapValue.of(LiveCounter.create(50))).await() + + val capturedMessages = capturedObjectMessages(mockWs) + assertEquals(1, capturedMessages.size) + val state = assertNotNull(capturedMessages[0].state) + assertEquals(2, state.size) + val createOp = assertNotNull((state[0] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.CounterCreate, createOp.action) + assertTrue(createOp.objectId.startsWith("counter:")) + val setOp = assertNotNull((state[1] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.MapSet, setOp.action) + assertEquals(createOp.objectId, assertNotNull(setOp.mapSet).value.objectId) + + client.close() + } + + /** + * @UTS objects/unit/RTLM20e7g/set-map-value-type-0 + */ + @Test + fun `RTLM20e7g - set with LiveMap generates nested CREATE messages and MAP_SET`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.set("nested_map", LiveMapValue.of(LiveMap.create(mapOf("key1" to LiveMapValue.of("value1"))))).await() + + val capturedMessages = capturedObjectMessages(mockWs) + assertEquals(1, capturedMessages.size) + val state = assertNotNull(capturedMessages[0].state) + assertEquals(2, state.size) + val createOp = assertNotNull((state[0] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.MapCreate, createOp.action) + assertTrue(createOp.objectId.startsWith("map:")) + val setOp = assertNotNull((state[1] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.MapSet, setOp.action) + assertEquals("nested_map", assertNotNull(setOp.mapSet).key) + assertEquals(createOp.objectId, assertNotNull(setOp.mapSet).value.objectId) + + client.close() + } + + /** + * @UTS objects/unit/RTLM20h1/set-nested-value-types-0 + */ + @Test + fun `RTLM20h1 - set with nested LiveMap containing LiveCounter`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.set( + "stats", + LiveMapValue.of( + LiveMap.create( + mapOf( + "count" to LiveMapValue.of(LiveCounter.create(0)), + "label" to LiveMapValue.of("test"), + ), + ), + ), + ).await() + + val capturedMessages = capturedObjectMessages(mockWs) + assertEquals(1, capturedMessages.size) + val state = assertNotNull(capturedMessages[0].state) + // Expect: COUNTER_CREATE, MAP_CREATE, MAP_SET (depth-first, then the MAP_SET at root) + assertEquals(3, state.size) + val counterCreateOp = assertNotNull((state[0] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.CounterCreate, counterCreateOp.action) + assertTrue(counterCreateOp.objectId.startsWith("counter:")) + val mapCreateOp = assertNotNull((state[1] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.MapCreate, mapCreateOp.action) + assertTrue(mapCreateOp.objectId.startsWith("map:")) + val setOp = assertNotNull((state[2] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.MapSet, setOp.action) + assertEquals("stats", assertNotNull(setOp.mapSet).key) + assertEquals(mapCreateOp.objectId, assertNotNull(setOp.mapSet).value.objectId) + + client.close() + } + + /** + * @UTS objects/unit/RTLM21/remove-sends-map-remove-0 + */ + @Test + fun `RTLM21 - remove sends MAP_REMOVE message`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.remove("name").await() + + val capturedMessages = capturedObjectMessages(mockWs) + val objMsg = assertNotNull(capturedMessages[0].state)[0] as WireObjectMessage + val op = assertNotNull(objMsg.operation) + assertEquals(WireObjectOperationAction.MapRemove, op.action) + assertEquals("root", op.objectId) + assertEquals("name", assertNotNull(op.mapRemove).key) + + client.close() + } + + /** + * @UTS objects/unit/RTLM20/set-applies-locally-0 + */ + @Test + fun `RTLM20 - set applies locally after ACK`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.set("name", LiveMapValue.of("Bob")).await() + + assertEquals("Bob", root.get("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTLM20/set-invalid-values-table-0 + */ + @Test + fun `RTLM20 - Table-driven invalid set value types`() { + // DEVIATION (see deviations.md): the spec's invalid values (function / undefined / + // symbol) are JavaScript-only values with no Kotlin/Java equivalent, and `set` accepts + // only the closed `LiveMapValue` union — unsupported value types are rejected at + // compile time, so the runtime 40013 validation (RTLMV4c) is not expressible. + } + + /** + * @UTS objects/unit/RTLM20/set-bytes-value-0 + */ + @Test + fun `RTLM20 - set with bytes value type`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.set("binary_data", LiveMapValue.of(byteArrayOf(1, 2, 3))).await() + + val capturedMessages = capturedObjectMessages(mockWs) + val objMsg = assertNotNull(capturedMessages[0].state)[0] as WireObjectMessage + assertEquals("AQID", assertNotNull(assertNotNull(objMsg.operation).mapSet).value.bytes) + + client.close() + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapTest.kt new file mode 100644 index 000000000..ce69200d3 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapTest.kt @@ -0,0 +1,929 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ObjectsOperationSource +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.unit.getMockAblyClientAdapter +import io.ably.lib.liveobjects.value.ObjectUpdate +import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter +import io.ably.lib.liveobjects.value.livemap.InternalLiveMap +import io.ably.lib.liveobjects.value.livemap.LiveMapEntry +import io.ably.lib.liveobjects.value.livemap.LiveMapManager +import io.ably.lib.liveobjects.value.livemap.MapChange +import io.ably.lib.liveobjects.value.livemap.isEntryOrRefTombstoned +import io.ably.lib.liveobjects.value.noOp +import io.ably.lib.util.Clock +import io.ably.lib.util.SystemClock +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Derived from UTS spec `objects/unit/internal_live_map.md` — the `InternalLiveMap` LWW-map + * CRDT data structure: set/remove/clear operations (`RTLM7`–`RTLM9`, `RTLM24`), create + * operations (`RTLM16`, `RTLM23`), data replacement during sync (`RTLM6`), tombstoning + * (`RTLM15e`, `RTLO4e`, `RTLO5`), GC (`RTLM19`), diff calculation (`RTLM22`) and + * parentReferences maintenance (`RTLM7a3`, `RTLM7g2`, `RTLM8a3`, `RTLM24e1c`, `RTLO4e9`). + * + * Internal-graph spec: asserts on the internal CRDT graph, so it lives in `:liveobjects`'s own + * test source set — symbol map in `.claude/skills/uts-to-kotlin/references/objects-mapping.md` + * §17 (instantiation §17.1, map §17.4, update object §17.5). + * + * The op path (`applyObject`) returns the `ObjectUpdate` per the spec contract (RTLC9g/RTLM7f), so + * op-path tests assert the returned update directly, exactly like the sync path (`applyObjectSync`). + */ +class InternalLiveMapTest { + + private lateinit var ro: DefaultRealtimeObject + + @BeforeTest + fun setUp() { + // §17.1 - internal classes have no public constructors; build them against a + // DefaultRealtimeObject backed by the mocked adapter. The spec's `pool` is ro.objectsPool. + ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + } + + @AfterTest + fun tearDown() { + // DEVIATION S-4 (see deviations.md): ObjectsPool.init starts a real GC coroutine + + // adapter subscription - dispose it; unmockkAll clears the mockkStatic global state. + ro.objectsPool.dispose() + unmockkAll() + } + + /** + * @UTS objects/unit/RTLM4/zero-value-0 + */ + @Test + fun `RTLM4 - zero-value InternalLiveMap`() { + val map = InternalLiveMap.zeroValue("root", ro) + + assertTrue(map.data.isEmpty()) + assertNull(map.clearTimeserial) + assertFalse(map.isTombstoned) + assertFalse(map.createOperationIsMerged) + assertEquals(emptyMap(), map.siteTimeserials) + } + + /** + * @UTS objects/unit/RTLM7/map-set-new-entry-0 + */ + @Test + fun `RTLM7 - MAP_SET creates new entry`() { + val map = InternalLiveMap.zeroValue("root", ro) + + val msg = buildMapSet("root", "name", dataString("Alice"), "01", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + assertEquals("01", map.data["name"]?.timeserial) + assertEquals(false, map.data["name"]?.isTombstoned) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Updated), mapUpdate.update) // update.update == { "name": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM7/map-set-update-entry-0 + */ + @Test + fun `RTLM7 - MAP_SET updates existing entry`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + + val msg = buildMapSet("root", "name", dataString("Bob"), "02", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Bob"), map.data["name"]?.data) + assertEquals("02", map.data["name"]?.timeserial) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Updated), mapUpdate.update) // update.update == { "name": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM9/lww-reject-stale-0 + */ + @Test + fun `RTLM9 - LWW rejects stale serial on existing entry`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "05", data = dataString("Alice")) + + val msg = buildMapSet("root", "name", dataString("Bob"), "03", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM9/lww-reject-equal-0 + */ + @Test + fun `RTLM9 - LWW rejects equal serial`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "05", data = dataString("Alice")) + + val msg = buildMapSet("root", "name", dataString("Bob"), "05", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM9b/both-empty-reject-0 + */ + @Test + fun `RTLM9b - both serials empty rejects operation`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "", data = dataString("Alice")) + + val msg = buildMapSet("root", "name", dataString("Bob"), "", "site1") + val result = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + // The op's ObjectMessage.serial is empty, so the OBJECT-level gate (RTLO4a3, via + // canApplyOperation) rejects it before the entry-level RTLM9b comparison, and + // applyOperation returns a noop (RTLM15b). + assertTrue(result.noOp) + } + + /** + * @UTS objects/unit/RTLM9d/missing-entry-serial-allows-0 + */ + @Test + fun `RTLM9d - missing entry serial allows operation`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = null, data = dataString("Alice")) + + val msg = buildMapSet("root", "name", dataString("Bob"), "01", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Bob"), map.data["name"]?.data) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Updated), mapUpdate.update) // update.update == { "name": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM7h/map-set-clear-timeserial-floor-0 + */ + @Test + fun `RTLM7h - MAP_SET rejected when serial not greater than clearTimeserial`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.clearTimeserial = "05" + + val msg = buildMapSet("root", "name", dataString("Alice"), "03", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertFalse(map.data.containsKey("name")) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM7g/map-set-objectid-creates-zero-value-0 + */ + @Test + fun `RTLM7g - MAP_SET with objectId creates zero-value object`() { + // pool wiring is implicit via ro (§17.1) - the map creates the zero-value child in ro.objectsPool + val map = InternalLiveMap.zeroValue("root", ro) + + val msg = buildMapSet("root", "score", dataObjectId("counter:new@2000"), "01", "site1") + map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + val created = ro.objectsPool.get("counter:new@2000") + assertNotNull(created) // "counter:new@2000" IN pool + val counter = assertIs(created) + assertEquals(0.0, counter.data.get()) + } + + /** + * @UTS objects/unit/RTLM8/map-remove-existing-0 + */ + @Test + fun `RTLM8 - MAP_REMOVE tombstones existing entry`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + + val msg = buildMapRemove("root", "name", "02", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertNull(map.data["name"]?.data) + assertEquals(true, map.data["name"]?.isTombstoned) + assertEquals("02", map.data["name"]?.timeserial) + assertEquals(1_700_000_000_000L, map.data["name"]?.tombstonedAt) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Removed), mapUpdate.update) // update.update == { "name": "removed" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM8/map-remove-nonexistent-0 + */ + @Test + fun `RTLM8 - MAP_REMOVE creates tombstoned entry if not exists`() { + val map = InternalLiveMap.zeroValue("root", ro) + + val msg = buildMapRemove("root", "ghost", "01", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(true, map.data["ghost"]?.isTombstoned) + assertEquals(1_700_000_000_000L, map.data["ghost"]?.tombstonedAt) + val mapUpdate = assertIs(update) + assertEquals(mapOf("ghost" to MapChange.Removed), mapUpdate.update) // update.update == { "ghost": "removed" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM8g/map-remove-clear-timeserial-floor-0 + */ + @Test + fun `RTLM8g - MAP_REMOVE rejected when serial not greater than clearTimeserial`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.clearTimeserial = "05" + map.data["name"] = LiveMapEntry(timeserial = "04", data = dataString("Alice")) + + val msg = buildMapRemove("root", "name", "03", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + assertEquals(false, map.data["name"]?.isTombstoned) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM24/map-clear-basic-0 + */ + @Test + fun `RTLM24 - MAP_CLEAR sets clearTimeserial and removes older entries`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["old"] = LiveMapEntry(timeserial = "02", data = dataString("old")) + map.data["new"] = LiveMapEntry(timeserial = "06", data = dataString("new")) + map.data["same"] = LiveMapEntry(timeserial = "04", data = dataString("same")) + + val msg = buildMapClear("root", "04", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals("04", map.clearTimeserial) + assertFalse(map.data.containsKey("old")) + // RTLM24e1: an entry is removed only if the clear serial is lexicographically GREATER + // than the entry's timeserial. "same" has timeserial "04" == clear serial "04" -> KEPT. + assertTrue(map.data.containsKey("same")) + assertTrue(map.data.containsKey("new")) + val mapUpdate = assertIs(update) + assertEquals(mapOf("old" to MapChange.Removed), mapUpdate.update) // update.update == { "old": "removed" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM24c/map-clear-stale-0 + */ + @Test + fun `RTLM24c - MAP_CLEAR rejected when clearTimeserial is already greater`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.clearTimeserial = "10" + + val msg = buildMapClear("root", "05", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals("10", map.clearTimeserial) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM16/map-create-merge-0 + */ + @Test + fun `RTLM16 - MAP_CREATE merges entries`() { + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + + val msg = buildMapCreate( + "map:test@1000", + WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, + entries = mapOf( + "name" to mapEntry(dataString("Alice"), timeserial = "01"), + "removed_key" to tombstonedMapEntry(timeserial = "01", serialTimestamp = 1_700_000_000_000L), + ), + ), + "02", "site1", + ) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + assertEquals(true, map.data["removed_key"]?.isTombstoned) + assertTrue(map.createOperationIsMerged) + val mapUpdate = assertIs(update) + // update.update == { "name": "updated", "removed_key": "removed" } + assertEquals(mapOf("name" to MapChange.Updated, "removed_key" to MapChange.Removed), mapUpdate.update) + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM16b/map-create-already-merged-0 + */ + @Test + fun `RTLM16b - MAP_CREATE noop when already merged`() { + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + map.createOperationIsMerged = true + map.siteTimeserials["site1"] = "00" + + val msg = buildMapCreate( + "map:test@1000", + WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, + entries = mapOf("name" to mapEntry(dataString("Bob"), timeserial = "01")), + ), + "01", "site1", + ) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertFalse(map.data.containsKey("name")) + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM15c/channel-source-updates-serials-0 + */ + @Test + fun `RTLM15c - CHANNEL source updates siteTimeserials`() { + val map = InternalLiveMap.zeroValue("root", ro) + + val msg = buildMapSet("root", "x", dataNumber(1), "01", "site1") + map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals("01", map.siteTimeserials["site1"]) + } + + /** + * @UTS objects/unit/RTLM15e/tombstoned-reject-ops-0 + */ + @Test + fun `RTLM15e - operations on tombstoned map are rejected`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.isTombstoned = true + + val msg = buildMapSet("root", "x", dataNumber(1), "01", "site1") + val result = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(result.noOp) // tombstoned -> rejected, nothing applied + assertTrue(map.data.isEmpty()) + } + + /** + * @UTS objects/unit/RTLO5/object-delete-tombstones-map-0 + */ + @Test + fun `RTLO5 - OBJECT_DELETE tombstones map`() { + // Uses a non-root map: an OBJECT_DELETE targeting root is rejected per RTLO4e10 + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + map.data["age"] = LiveMapEntry(timeserial = "01", data = dataNumber(30)) + map.siteTimeserials["site1"] = "00" + + val msg = buildObjectDelete("map:test@1000", "01", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(map.isTombstoned) + assertTrue(map.data.isEmpty()) + val mapUpdate = assertIs(update) + // update.update == { "name": "removed", "age": "removed" } + assertEquals(mapOf("name" to MapChange.Removed, "age" to MapChange.Removed), mapUpdate.update) + assertTrue(mapUpdate.tombstone) // update.tombstone == true + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLO4e10/object-delete-root-noop-0 + */ + @Test + fun `RTLO4e10 - OBJECT_DELETE targeting root is rejected`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + map.siteTimeserials["site1"] = "00" + + val msg = buildObjectDelete("root", "01", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertFalse(map.isTombstoned) + assertEquals("Alice", map.data["name"]?.data?.string) // data untouched + assertIs(update) // update.noop == true + } + + /** + * @UTS objects/unit/RTLM14/tombstone-check-objectid-ref-0 + */ + @Test + fun `RTLM14 - tombstoned entry check includes objectId reference`() { + val tombstonedCounter = InternalLiveCounter.zeroValue("counter:dead@1000", ro) + tombstonedCounter.isTombstoned = true + ro.objectsPool.set("counter:dead@1000", tombstonedCounter) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["alive"] = LiveMapEntry(timeserial = "01", data = dataString("ok")) + map.data["dead_entry"] = LiveMapEntry(isTombstoned = true, timeserial = "01", data = null) + map.data["dead_ref"] = LiveMapEntry(timeserial = "01", data = dataObjectId("counter:dead@1000")) + + // isTombstoned(entry) -> entry.isEntryOrRefTombstoned(pool) (§17.4) + assertFalse(map.data["alive"]!!.isEntryOrRefTombstoned(ro.objectsPool)) + assertTrue(map.data["dead_entry"]!!.isEntryOrRefTombstoned(ro.objectsPool)) + assertTrue(map.data["dead_ref"]!!.isEntryOrRefTombstoned(ro.objectsPool)) + } + + /** + * @UTS objects/unit/RTLM6/replace-data-basic-0 + */ + @Test + fun `RTLM6 - replaceData sets data from ObjectState`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["old"] = LiveMapEntry(timeserial = "01", data = dataString("old")) + map.createOperationIsMerged = true + + val stateMsg = buildObjectState( + "root", mapOf("site2" to "05"), + map = mapState( + entries = mapOf("new" to mapEntry(dataString("new"), timeserial = "04")), + clearTimeserial = "03", + ), + ) + val update = map.applyObjectSync(stateMsg) + + assertEquals(mapOf("site2" to "05"), map.siteTimeserials) + assertFalse(map.createOperationIsMerged) + assertEquals("03", map.clearTimeserial) + assertFalse(map.data.containsKey("old")) + assertEquals(dataString("new"), map.data["new"]?.data) + val mapUpdate = assertIs(update) + assertEquals(mapOf("old" to MapChange.Removed, "new" to MapChange.Updated), mapUpdate.update) + assertEquals(stateMsg, mapUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLM6c1/replace-data-tombstoned-entries-0 + */ + @Test + fun `RTLM6c1 - replaceData sets tombstonedAt on tombstoned entries`() { + val map = InternalLiveMap.zeroValue("root", ro) + + val stateMsg = buildObjectState( + "root", mapOf("site1" to "01"), + map = mapState( + entries = mapOf("dead" to tombstonedMapEntry(timeserial = "01", serialTimestamp = 1_700_000_050_000L)), + ), + ) + map.applyObjectSync(stateMsg) + + assertEquals(1_700_000_050_000L, map.data["dead"]?.tombstonedAt) + } + + /** + * @UTS objects/unit/RTLM6d/replace-data-with-create-op-0 + */ + @Test + fun `RTLM6d - replaceData with createOp merges initial entries`() { + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + + val stateMsg = buildObjectState( + "map:test@1000", mapOf("site1" to "01"), + map = mapState(entries = mapOf("from_sync" to mapEntry(dataString("synced"), timeserial = "01"))), + createOp = mapCreateOp(entries = mapOf("from_create" to mapEntry(dataString("created"), timeserial = "00"))), + ) + map.applyObjectSync(stateMsg) + + assertEquals(dataString("synced"), map.data["from_sync"]?.data) + assertEquals(dataString("created"), map.data["from_create"]?.data) + assertTrue(map.createOperationIsMerged) + } + + /** + * @UTS objects/unit/RTLM6f/replace-data-tombstone-flag-0 + */ + @Test + fun `RTLM6f - replaceData with tombstone flag tombstones map`() { + // Uses a non-root map: tombstoning root is rejected per RTLO4e10 + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + + val stateMsg = buildObjectState( + "map:test@1000", mapOf("site1" to "01"), + map = mapState(entries = emptyMap()), + tombstone = true, + ) + val update = map.applyObjectSync(stateMsg) + + assertTrue(map.isTombstoned) + assertTrue(map.data.isEmpty()) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Removed), mapUpdate.update) + assertTrue(mapUpdate.tombstone) + assertEquals(stateMsg, mapUpdate.objectMessage) + } + + /** + * @UTS objects/unit/RTLO4e10/replace-data-tombstone-root-noop-0 + */ + @Test + fun `RTLO4e10 - replaceData with tombstone flag targeting root is rejected`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + + val stateMsg = buildObjectState( + "root", mapOf("site1" to "01"), + map = mapState(entries = emptyMap()), + tombstone = true, + ) + val update = map.applyObjectSync(stateMsg) + + assertFalse(map.isTombstoned) + assertEquals("Alice", map.data["name"]?.data?.string) // data untouched + assertIs(update) + } + + /** + * @UTS objects/unit/RTLM19/gc-tombstoned-entries-0 + */ + @Test + fun `RTLM19 - GC removes tombstoned entries past grace period`() { + val map = InternalLiveMap.zeroValue("root", ro) + val gracePeriod = 86_400_000L + val now = 1_700_100_000_000L + + map.data["recent_dead"] = LiveMapEntry(isTombstoned = true, tombstonedAt = now - 1000, timeserial = "01", data = null) + map.data["old_dead"] = LiveMapEntry(isTombstoned = true, tombstonedAt = now - gracePeriod - 1, timeserial = "01", data = null) + map.data["alive"] = LiveMapEntry(timeserial = "01", data = dataString("ok")) + + // DEVIATION S-3 (see deviations.md): the SDK has no `now` parameter on the GC entry + // point - gcTombstonedEntries(grace, now) maps to onGCInterval(grace) with the current + // time read from the clock, so the clock is stubbed to the spec's fixed `now`. + mockkStatic(SystemClock::class) + val fixedClock = mockk { every { currentTimeMillis() } returns now } + every { SystemClock.clockFrom(any()) } returns fixedClock + + map.onGCInterval(gracePeriod) + + assertTrue(map.data.containsKey("recent_dead")) + assertFalse(map.data.containsKey("old_dead")) + assertTrue(map.data.containsKey("alive")) + } + + /** + * @UTS objects/unit/RTLM22/diff-calculation-0 + */ + @Test + fun `RTLM22 - diff between two data states`() { + val previousData = mapOf( + "removed" to LiveMapEntry(timeserial = "01", data = dataString("gone")), + "changed" to LiveMapEntry(timeserial = "01", data = dataString("old")), + "unchanged" to LiveMapEntry(timeserial = "01", data = dataString("same")), + "was_dead" to LiveMapEntry(isTombstoned = true, timeserial = "01", data = null), + ) + val newData = mapOf( + "added" to LiveMapEntry(timeserial = "02", data = dataString("new")), + "changed" to LiveMapEntry(timeserial = "02", data = dataString("new_val")), + "unchanged" to LiveMapEntry(timeserial = "01", data = dataString("same")), + "now_dead" to LiveMapEntry(isTombstoned = true, timeserial = "02", data = null), + ) + + // InternalLiveMap.diff(prev, new) -> LiveMapManager(map).calculateUpdateFromDataDiff (§17.4) + val manager = LiveMapManager(InternalLiveMap.zeroValue("root", ro)) + val update = manager.calculateUpdateFromDataDiff(previousData, newData) + + val mapUpdate = assertIs(update) + assertEquals(MapChange.Removed, mapUpdate.update["removed"]) + assertEquals(MapChange.Updated, mapUpdate.update["added"]) + assertEquals(MapChange.Updated, mapUpdate.update["changed"]) + assertFalse("unchanged" in mapUpdate.update) + assertFalse("was_dead" in mapUpdate.update) + assertFalse("now_dead" in mapUpdate.update) + } + + /** + * @UTS objects/unit/RTLM15d4/unsupported-action-0 + */ + @Test + fun `RTLM15d4 - unsupported action is discarded`() { + val map = InternalLiveMap.zeroValue("root", ro) + + val msg = buildCounterInc("root", 5, "01", "site1") + val result = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(result.noOp) // unsupported action -> discarded, nothing applied + } + + /** + * @UTS objects/unit/RTLM6i/replace-data-resets-clear-timeserial-0 + */ + @Test + fun `RTLM6i - replaceData without clearTimeserial resets to null`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.clearTimeserial = "05" + map.data["x"] = LiveMapEntry(timeserial = "03", data = dataNumber(1)) + + val stateMsg = buildObjectState( + "root", mapOf("site1" to "01"), + map = mapState(entries = mapOf("y" to mapEntry(dataNumber(2), timeserial = "01"))), + ) + map.applyObjectSync(stateMsg) + + assertNull(map.clearTimeserial) + assertTrue(map.data.containsKey("y")) + } + + /** + * @UTS objects/unit/RTLM14c/tombstoned-ref-yields-null-0 + */ + @Test + fun `RTLM14c - MAP_SET referencing tombstoned objectId yields null value`() { + val tombstonedCounter = InternalLiveCounter.zeroValue("counter:dead@1000", ro) + tombstonedCounter.isTombstoned = true + ro.objectsPool.set("counter:dead@1000", tombstonedCounter) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["ref"] = LiveMapEntry(timeserial = "01", data = dataObjectId("counter:dead@1000")) + + // The entry itself is not tombstoned, but the referenced object is + assertEquals(false, map.data["ref"]?.isTombstoned) + // size() must NOT count this entry because RTLM14c makes it tombstoned + assertEquals(0L, map.size()) + // get() must return null for the value + assertNull(map.get("ref")) + } + + /** + * @UTS objects/unit/RTLM7/map-set-revives-tombstoned-0 + */ + @Test + fun `RTLM7 - MAP_SET revives tombstoned entry`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(isTombstoned = true, tombstonedAt = 1_700_000_000_000L, timeserial = "01", data = null) + + val msg = buildMapSet("root", "name", dataString("Alice"), "02", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("Alice"), map.data["name"]?.data) + assertEquals(false, map.data["name"]?.isTombstoned) + assertNull(map.data["name"]?.tombstonedAt) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Updated), mapUpdate.update) // update.update == { "name": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM24/map-clear-preserves-newer-0 + */ + @Test + fun `RTLM24 - MAP_CLEAR preserves entries with newer serial`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["before"] = LiveMapEntry(timeserial = "03", data = dataString("a")) + map.data["after"] = LiveMapEntry(timeserial = "07", data = dataString("b")) + map.data["no_ts"] = LiveMapEntry(timeserial = null, data = dataString("c")) + + val msg = buildMapClear("root", "05", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertFalse(map.data.containsKey("before")) + assertFalse(map.data.containsKey("no_ts")) + assertEquals(dataString("b"), map.data["after"]?.data) + val mapUpdate = assertIs(update) + // "before"/"no_ts" IN update.update (removed); "after" NOT IN update.update + assertEquals(MapChange.Removed, mapUpdate.update["before"]) + assertEquals(MapChange.Removed, mapUpdate.update["no_ts"]) + assertFalse("after" in mapUpdate.update) + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM7a3/map-set-overwrite-objectid-parent-refs-0 + */ + @Test + fun `RTLM7a3 - MAP_SET overwrites entry referencing LiveObject updates parentReferences`() { + val oldCounter = InternalLiveCounter.zeroValue("counter:old@1000", ro) + val newCounter = InternalLiveCounter.zeroValue("counter:new@2000", ro) + ro.objectsPool.set("counter:old@1000", oldCounter) + ro.objectsPool.set("counter:new@2000", newCounter) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["ref"] = LiveMapEntry(timeserial = "01", data = dataObjectId("counter:old@1000")) + // Simulate existing parentReference + oldCounter.parentReferences["root"] = mutableSetOf("ref") + + val msg = buildMapSet("root", "ref", dataObjectId("counter:new@2000"), "02", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataObjectId("counter:new@2000"), map.data["ref"]?.data) + // removeParentReference was called on the old child + assertTrue("root" !in oldCounter.parentReferences || "ref" !in oldCounter.parentReferences["root"]!!) + // addParentReference was called on the new child + assertTrue("root" in newCounter.parentReferences) + assertTrue("ref" in newCounter.parentReferences["root"]!!) + val mapUpdate = assertIs(update) + assertEquals(mapOf("ref" to MapChange.Updated), mapUpdate.update) // update.update == { "ref": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM7g2/map-set-new-entry-add-parent-ref-0 + */ + @Test + fun `RTLM7g2 - MAP_SET new entry referencing LiveObject adds parentReference`() { + val childCounter = InternalLiveCounter.zeroValue("counter:child@1000", ro) + ro.objectsPool.set("counter:child@1000", childCounter) + + val map = InternalLiveMap.zeroValue("root", ro) + + val msg = buildMapSet("root", "score", dataObjectId("counter:child@1000"), "01", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataObjectId("counter:child@1000"), map.data["score"]?.data) + assertTrue("root" in childCounter.parentReferences) + assertTrue("score" in childCounter.parentReferences["root"]!!) + val mapUpdate = assertIs(update) + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM7/map-set-primitive-no-parent-refs-0 + */ + @Test + fun `RTLM7 - MAP_SET with non-LiveObject value does not affect parentReferences`() { + val oldCounter = InternalLiveCounter.zeroValue("counter:old@1000", ro) + ro.objectsPool.set("counter:old@1000", oldCounter) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["ref"] = LiveMapEntry(timeserial = "01", data = dataObjectId("counter:old@1000")) + oldCounter.parentReferences["root"] = mutableSetOf("ref") + + val msg = buildMapSet("root", "ref", dataString("plain_value"), "02", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataString("plain_value"), map.data["ref"]?.data) + // removeParentReference was called on old child (entry previously had objectId); + // no addParentReference call because the new value is a primitive + assertTrue("root" !in oldCounter.parentReferences || "ref" !in oldCounter.parentReferences["root"]!!) + val mapUpdate = assertIs(update) + assertEquals(mapOf("ref" to MapChange.Updated), mapUpdate.update) // update.update == { "ref": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM8a3/map-remove-objectid-parent-refs-0 + */ + @Test + fun `RTLM8a3 - MAP_REMOVE entry referencing LiveObject removes parentReference`() { + val childCounter = InternalLiveCounter.zeroValue("counter:child@1000", ro) + ro.objectsPool.set("counter:child@1000", childCounter) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["score"] = LiveMapEntry(timeserial = "01", data = dataObjectId("counter:child@1000")) + childCounter.parentReferences["root"] = mutableSetOf("score") + + val msg = buildMapRemove("root", "score", "02", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(true, map.data["score"]?.isTombstoned) + // removeParentReference was called on the child + assertTrue("root" !in childCounter.parentReferences || "score" !in childCounter.parentReferences["root"]!!) + val mapUpdate = assertIs(update) + assertEquals(mapOf("score" to MapChange.Removed), mapUpdate.update) // update.update == { "score": "removed" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM8/map-remove-primitive-no-parent-refs-0 + */ + @Test + fun `RTLM8 - MAP_REMOVE entry with non-LiveObject value needs no parentReference calls`() { + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + + val msg = buildMapRemove("root", "name", "02", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(true, map.data["name"]?.isTombstoned) + val mapUpdate = assertIs(update) + assertEquals(mapOf("name" to MapChange.Removed), mapUpdate.update) // update.update == { "name": "removed" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + // No parentReference calls needed - test passes without errors + } + + /** + * @UTS objects/unit/RTLM24e1c/map-clear-parent-refs-0 + */ + @Test + fun `RTLM24e1c - MAP_CLEAR removes parent references for cleared entries`() { + val counterA = InternalLiveCounter.zeroValue("counter:a@1000", ro) + val counterB = InternalLiveCounter.zeroValue("counter:b@1000", ro) + ro.objectsPool.set("counter:a@1000", counterA) + ro.objectsPool.set("counter:b@1000", counterB) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["ref_a"] = LiveMapEntry(timeserial = "02", data = dataObjectId("counter:a@1000")) + map.data["ref_b"] = LiveMapEntry(timeserial = "02", data = dataObjectId("counter:b@1000")) + map.data["primitive"] = LiveMapEntry(timeserial = "02", data = dataString("hello")) + map.data["newer"] = LiveMapEntry(timeserial = "09", data = dataString("kept")) + counterA.parentReferences["root"] = mutableSetOf("ref_a") + counterB.parentReferences["root"] = mutableSetOf("ref_b") + + val msg = buildMapClear("root", "05", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + // ref_a and ref_b removed (timeserial "02" < "05"), newer kept (timeserial "09" > "05") + assertFalse(map.data.containsKey("ref_a")) + assertFalse(map.data.containsKey("ref_b")) + assertFalse(map.data.containsKey("primitive")) + assertTrue(map.data.containsKey("newer")) + // removeParentReference was called on both child counters + assertTrue("root" !in counterA.parentReferences || "ref_a" !in counterA.parentReferences["root"]!!) + assertTrue("root" !in counterB.parentReferences || "ref_b" !in counterB.parentReferences["root"]!!) + val mapUpdate = assertIs(update) + // update.update == { "ref_a": "removed", "ref_b": "removed", "primitive": "removed" } + assertEquals( + mapOf("ref_a" to MapChange.Removed, "ref_b" to MapChange.Removed, "primitive" to MapChange.Removed), + mapUpdate.update, + ) + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLO4e9/tombstone-map-parent-refs-0 + */ + @Test + fun `RTLO4e9 - tombstoning InternalLiveMap removes parent references for all entries`() { + val childCounter = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val childMap = InternalLiveMap.zeroValue("map:child@1000", ro) + ro.objectsPool.set("counter:child@1000", childCounter) + ro.objectsPool.set("map:child@1000", childMap) + + // Uses a non-root map: tombstoning root is rejected per RTLO4e10 + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + map.data["counter_ref"] = LiveMapEntry(timeserial = "01", data = dataObjectId("counter:child@1000")) + map.data["map_ref"] = LiveMapEntry(timeserial = "01", data = dataObjectId("map:child@1000")) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + map.siteTimeserials["site1"] = "00" + childCounter.parentReferences["map:test@1000"] = mutableSetOf("counter_ref") + childMap.parentReferences["map:test@1000"] = mutableSetOf("map_ref") + + val msg = buildObjectDelete("map:test@1000", "01", "site1", 1_700_000_000_000L) + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertTrue(map.isTombstoned) + assertTrue(map.data.isEmpty()) + // removeParentReference was called on both children + assertTrue( + "map:test@1000" !in childCounter.parentReferences || + "counter_ref" !in childCounter.parentReferences["map:test@1000"]!!, + ) + assertTrue( + "map:test@1000" !in childMap.parentReferences || + "map_ref" !in childMap.parentReferences["map:test@1000"]!!, + ) + val mapUpdate = assertIs(update) + // update.update == { "counter_ref": "removed", "map_ref": "removed", "name": "removed" } + assertEquals( + mapOf("counter_ref" to MapChange.Removed, "map_ref" to MapChange.Removed, "name" to MapChange.Removed), + mapUpdate.update, + ) + assertTrue(mapUpdate.tombstone) // update.tombstone == true + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } + + /** + * @UTS objects/unit/RTLM7a3/map-set-replace-objectid-both-refs-0 + */ + @Test + fun `RTLM7a3 - MAP_SET overwriting LiveObject with LiveObject calls both remove and add`() { + val oldMap = InternalLiveMap.zeroValue("map:old@1000", ro) + val newMap = InternalLiveMap.zeroValue("map:new@2000", ro) + ro.objectsPool.set("map:old@1000", oldMap) + ro.objectsPool.set("map:new@2000", newMap) + + val map = InternalLiveMap.zeroValue("root", ro) + map.data["child"] = LiveMapEntry(timeserial = "01", data = dataObjectId("map:old@1000")) + oldMap.parentReferences["root"] = mutableSetOf("child") + + val msg = buildMapSet("root", "child", dataObjectId("map:new@2000"), "02", "site1") + val update = map.applyObject(msg, ObjectsOperationSource.CHANNEL) + + assertEquals(dataObjectId("map:new@2000"), map.data["child"]?.data) + // Old child no longer references root + assertTrue("root" !in oldMap.parentReferences || "child" !in oldMap.parentReferences["root"]!!) + // New child references root + assertTrue("root" in newMap.parentReferences) + assertTrue("child" in newMap.parentReferences["root"]!!) + val mapUpdate = assertIs(update) + assertEquals(mapOf("child" to MapChange.Updated), mapUpdate.update) // update.update == { "child": "updated" } + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/LiveObjectSubscribeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/LiveObjectSubscribeTest.kt new file mode 100644 index 000000000..d01b3e5b1 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/LiveObjectSubscribeTest.kt @@ -0,0 +1,331 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS spec `objects/unit/live_object_subscribe.md` — LiveObject data + * subscriptions (`RTLO4b` and sub-points): listener registration/deregistration through the + * public `Instance#subscribe` (RTINS16), noop suppression, tombstone-driven deregistration, + * and the source ObjectMessage carried on the event. + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt`. The spec's + * internal `LiveObjectUpdate` diff payload is not exposed on the public + * `InstanceSubscriptionEvent` (only `getObject()` + `getMessage()`, `objects-mapping.md` §8); + * these tests assert listener delivery counts and the public message fields, which is all this + * spec requires. + */ +class LiveObjectSubscribeTest { + + /** + * @UTS objects/unit/RTLO4b/subscribe-receives-updates-0 + */ + @Test + fun `RTLO4b - subscribe registers listener for data updates`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + val sub = instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertIs(sub) + assertEquals(1, updates.size) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b7/subscribe-returns-subscription-0 + */ + @Test + fun `RTLO4b7 - subscribe returns Subscription with unsubscribe method`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + + val sub = instance.subscribe(InstanceListener { }) + + assertIs(sub) + // `sub.unsubscribe IS Function`: guaranteed at compile time by the Subscription + // interface; assert the callable reference exists. + assertNotNull(sub::unsubscribe) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0 + */ + @Test + fun `RTLO4b7 - Subscription unsubscribe stops delivery`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val control = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + val sub = instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + sub.unsubscribe() + + // Negative-assertion quiescence (helpers/standard_test_pool.md): subscribe a control + // listener that WILL fire on the same dispatch as the message under test, then await it + // before asserting `updates` is unchanged. + instance.subscribe(InstanceListener { event -> control.add(event) }) + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "02", "remote"))), + ) + pollUntil(5.seconds) { control.size >= 1 } + + // Control delivered, so the unsubscribed listener would also have run had it still been + // registered. + assertEquals(1, updates.size) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0 + */ + @Test + fun `RTLO4b7 - Subscription unsubscribe is idempotent`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + val sub = instance.subscribe(InstanceListener { }) + + sub.unsubscribe() + sub.unsubscribe() + + // No error thrown — both calls complete without error. + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b4c1/noop-no-trigger-0 + */ + @Test + fun `RTLO4b4c1 - noop update does not trigger listener`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val control = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + // Serial "02" passes the newness check (RTLO4a6); an increment with no `number` is the + // noop (RTLC9h). A raw ObjectMessage with no `number` field exercises the real + // RTLC9h/RTLO4b4c1 noop branch (a `number: 0` would EXIST per RTLC9g and produce a + // non-noop update with amount 0). + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf( + WireObjectMessage( + serial = "02", + siteCode = "remote", + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, + objectId = "counter:score@1000", + counterInc = WireCounterInc(number = null), + ), + ), + ), + ), + ) + // Negative-assertion quiescence: drive a follow-up "03" increment and await it via a + // SEPARATE control listener. Because "03" is dispatched after the noop "02" on the same + // channel, once the control fires the noop has certainly been processed. + val controlSub = instance.subscribe(InstanceListener { event -> control.add(event) }) + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 3, "03", "remote"))), + ) + pollUntil(5.seconds) { control.size >= 1 } + controlSub.unsubscribe() + + // The noop "02" produced no update, so the original listener fired only for "01" and + // "03". Had the noop wrongly fired, updates.size would be 3. + assertEquals(2, updates.size) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b6/subscribe-no-side-effects-0 + */ + @Test + fun `RTLO4b6 - subscribe has no side effects`() = runTest { + val (client, channel, root, _) = setupSyncedChannel("test") + val stateBefore = channel.state + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + + instance.subscribe(InstanceListener { }) + + assertEquals(stateBefore, channel.state) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b/subscribe-map-update-0 + */ + @Test + fun `RTLO4b - subscribe on InternalLiveMap receives LiveMapUpdate`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = assertNotNull(root.instance()).asLiveMap() + instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0 + */ + @Test + fun `RTLO4b4c3c - tombstone update deregisters all Instance subscribe listeners`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updatesA = mutableListOf() + val updatesB = mutableListOf() + val control = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + instance.subscribe(InstanceListener { event -> updatesA.add(event) }) + instance.subscribe(InstanceListener { event -> updatesB.add(event) }) + + // Send an OBJECT_DELETE which causes a tombstone. Await ALL involved listeners on this + // dispatch before asserting either count (negative-assertion quiescence, multi-listener + // case). + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))), + ) + pollUntil(5.seconds) { updatesA.size >= 1 } + pollUntil(5.seconds) { updatesB.size >= 1 } + + // Both listeners should have received the tombstone update. + assertEquals(1, updatesA.size) + assertEquals(ObjectOperationAction.OBJECT_DELETE, assertNotNull(updatesA[0].message).operation.action) + assertEquals(1, updatesB.size) + assertEquals(ObjectOperationAction.OBJECT_DELETE, assertNotNull(updatesB[0].message).operation.action) + + // Send another update to the tombstoned object — the deregistered listeners must not + // fire. QUIESCENCE: a tombstoned object ignores further operations (RTLC7e), so use a + // SEPARATE LIVE object as the control: an update on map:profile@1000 dispatched AFTER + // the message under test. Messages are processed in order, so once the control fires, + // "51" has also been processed. + val controlInst = assertNotNull(root.get("profile").instance()).asLiveMap() + controlInst.subscribe(InstanceListener { event -> control.add(event) }) + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 3, "51", "remote"))), + ) + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:profile@1000", "quiescence_probe", dataString("x"), "52", "remote")), + ), + ) + pollUntil(5.seconds) { control.size >= 1 } + + // Control delivered, so any still-registered original listener would also have run. + assertEquals(1, updatesA.size) + assertEquals(1, updatesB.size) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b4d/update-has-object-message-0 + */ + @Test + fun `RTLO4b4d - InstanceSubscriptionEvent message is populated from source ObjectMessage`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + val message = assertNotNull(updates[0].message) + assertEquals("99", message.serial) + assertEquals("remote", message.siteCode) + assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) + assertEquals("counter:score@1000", message.operation.objectId) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b4e/tombstone-flag-true-0 + */ + @Test + fun `RTLO4b4e - tombstone update identified by OBJECT_DELETE action`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + assertEquals(ObjectOperationAction.OBJECT_DELETE, assertNotNull(updates[0].message).operation.action) + + client.close() + } + + /** + * @UTS objects/unit/RTLO4b4e/tombstone-flag-false-0 + */ + @Test + fun `RTLO4b4e - normal update carries non-tombstone action`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + instance.subscribe(InstanceListener { event -> updates.add(event) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + assertEquals(ObjectOperationAction.COUNTER_INC, assertNotNull(updates[0].message).operation.action) + + client.close() + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectIdTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectIdTest.kt new file mode 100644 index 000000000..a7ad64566 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectIdTest.kt @@ -0,0 +1,129 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.ObjectId +import io.ably.lib.liveobjects.value.ObjectType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Derived from UTS spec `objects/unit/object_id.md` — ObjectId generation (RTO14). + * + * Pure-function tests: no mocks, no `DefaultRealtimeObject` setup required + * (see `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17.7). + * The spec's `generateObjectId(type, initialValue, nonce, timestamp)` maps to + * `ObjectId.fromInitialValue(ObjectType, initialValue, nonce, timestamp).toString()`. + */ +class ObjectIdTest { + + /** The spec's `generateObjectId` helper mapped onto the internal `ObjectId` factory (§17.7). */ + private fun generateObjectId(type: ObjectType, initialValue: String, nonce: String, timestamp: Long): String = + ObjectId.fromInitialValue(type, initialValue, nonce, timestamp).toString() + + /** + * @UTS objects/unit/RTO14/objectid-format-counter-0 + */ + @Test + fun `RTO14 - ObjectId format for counter type`() { + val objectId = generateObjectId( + type = ObjectType.Counter, + initialValue = """{"counter":{"count":42}}""", + nonce = "test-nonce-12345678", + timestamp = 1_700_000_000_000L, + ) + + assertTrue(objectId.startsWith("counter:"), "objectId must start with \"counter:\", was: $objectId") + assertTrue(objectId.contains("@1700000000000"), "objectId must contain \"@1700000000000\", was: $objectId") + val parts = objectId.split(":") + val typePart = parts[0] + val rest = parts[1] + val hashAndTs = rest.split("@") + val hashPart = hashAndTs[0] + val tsPart = hashAndTs[1] + assertEquals("counter", typePart) + assertEquals("1700000000000", tsPart) + // RTO14b2 - hash is a valid base64url string (RFC 4648 s.5 alphabet) + assertTrue(hashPart.matches(Regex("^[A-Za-z0-9_-]+$")), "hash must be valid base64url, was: $hashPart") + assertFalse(hashPart.contains("+")) + assertFalse(hashPart.contains("/")) + assertFalse(hashPart.contains("=")) + } + + /** + * @UTS objects/unit/RTO14/objectid-format-map-0 + */ + @Test + fun `RTO14 - ObjectId format for map type`() { + val objectId = generateObjectId( + type = ObjectType.Map, + initialValue = """{"map":{"semantics":"LWW","entries":{}}}""", + nonce = "test-nonce-12345678", + timestamp = 1_700_000_000_000L, + ) + + assertTrue(objectId.startsWith("map:"), "objectId must start with \"map:\", was: $objectId") + assertTrue(objectId.contains("@1700000000000"), "objectId must contain \"@1700000000000\", was: $objectId") + } + + /** + * @UTS objects/unit/RTO14/deterministic-0 + */ + @Test + fun `RTO14 - deterministic output for same inputs`() { + val id1 = generateObjectId( + type = ObjectType.Counter, + initialValue = """{"counter":{"count":0}}""", + nonce = "same-nonce-1234567", + timestamp = 1_700_000_000_000L, + ) + val id2 = generateObjectId( + type = ObjectType.Counter, + initialValue = """{"counter":{"count":0}}""", + nonce = "same-nonce-1234567", + timestamp = 1_700_000_000_000L, + ) + + assertEquals(id1, id2) + } + + /** + * @UTS objects/unit/RTO14/different-nonce-0 + */ + @Test + fun `RTO14 - different nonce produces different objectId`() { + val id1 = generateObjectId( + type = ObjectType.Counter, + initialValue = """{"counter":{"count":0}}""", + nonce = "nonce-aaaaaaaaaaaaa", + timestamp = 1_700_000_000_000L, + ) + val id2 = generateObjectId( + type = ObjectType.Counter, + initialValue = """{"counter":{"count":0}}""", + nonce = "nonce-bbbbbbbbbbbbb", + timestamp = 1_700_000_000_000L, + ) + + assertNotEquals(id1, id2) + } + + /** + * @UTS objects/unit/RTO14b/base64url-encoding-0 + */ + @Test + fun `RTO14b - SHA-256 hash is base64url encoded not standard base64`() { + val objectId = generateObjectId( + type = ObjectType.Counter, + initialValue = """{"counter":{"count":0}}""", + nonce = "test-nonce-12345678", + timestamp = 1_700_000_000_000L, + ) + val hashPart = objectId.split(":")[1].split("@")[0] + + assertFalse(hashPart.contains("+"), "base64url hash must not contain '+', was: $hashPart") + assertFalse(hashPart.contains("/"), "base64url hash must not contain '/', was: $hashPart") + assertFalse(hashPart.endsWith("="), "base64url hash must not end with '=', was: $hashPart") + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectsPoolTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectsPoolTest.kt new file mode 100644 index 000000000..6c4e49c41 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectsPoolTest.kt @@ -0,0 +1,796 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ObjectsOperationSource +import io.ably.lib.liveobjects.ObjectsState +import io.ably.lib.liveobjects.ROOT_OBJECT_ID +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.unit.getMockAblyClientAdapter +import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter +import io.ably.lib.liveobjects.value.livemap.InternalLiveMap +import io.ably.lib.liveobjects.value.livemap.LiveMapEntry +import io.mockk.unmockkAll +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Derived from UTS spec `objects/unit/objects_pool.md` — the `ObjectsPool` data structure and + * sync state machine (`RTO3`–`RTO9`): ATTACHED handling (`RTO4`), OBJECT_SYNC sequences + * (`RTO5`), zero-value object creation (`RTO6`), operation buffering (`RTO7`, `RTO8`), + * OBJECT message application (`RTO9`) and the post-sync parent-reference rebuild (`RTO5c10`). + * + * Internal-graph spec: asserts on the internal pool/sync state, so it lives in `:liveobjects`'s + * own test source set — symbol map in + * `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17 (pool & sync §17.6). + * The spec's monolithic `pool` splits across three classes: `ro.objectsPool` (storage), + * `ro.objectsManager` (sync/apply logic) and `ro` itself (state, ack-serials — the spec's + * `realtime_object`). + * + * DEVIATION S-2 (see deviations.md): the spec's synchronous + * `pool.processAttached(ProtocolMessage(action: ATTACHED, ...))` maps to the async + * `handleStateChange`; these tests drive the manager steps of its attached branch + * synchronously instead ([processAttached] below). + */ +class ObjectsPoolTest { + + private lateinit var ro: DefaultRealtimeObject + + @BeforeTest + fun setUp() { + // §17.1 - the spec's `pool = ObjectsPool()` / `realtime_object = RealtimeObject(pool: pool)`: + // the pool ("root" auto-created per RTO3b) is created inside DefaultRealtimeObject. + ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + } + + @AfterTest + fun tearDown() { + // DEVIATION S-4 (see deviations.md): ObjectsPool.init starts a real GC coroutine + + // adapter subscription - dispose it; unmockkAll clears the mockkStatic global state. + ro.objectsPool.dispose() + unmockkAll() + } + + /** + * The spec's `pool.processAttached(ProtocolMessage(action: ATTACHED, channelSerial: ..., flags: ...))`. + * DEVIATION S-2 (see deviations.md): `handleStateChange(ChannelState.attached, hasObjects)` runs + * asynchronously on the sequential scope, so these tests replay its attached branch synchronously: + * RTO4d clear buffer, RTO4c start a new sync, and for HAS_OBJECTS=0 the RTO4b immediate completion. + */ + private fun processAttached(hasObjects: Boolean) { + ro.objectsManager.clearBufferedObjectOperations() // RTO4d + ro.objectsManager.startNewSync(null) // RTO4c + if (!hasObjects) { + ro.objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2, RTO4b2a + ro.objectsManager.clearSyncObjectsPool() // RTO4b3 + ro.objectsManager.endSync() // RTO4b4 + } + } + + /** The spec's `pool.processObjectSync(build_object_sync_message(channel, serial, msgs))` (§17.6). */ + private fun processObjectSync(channelSerial: String, messages: List) = + ro.objectsManager.handleObjectSyncMessages(messages, channelSerial) + + /** The spec's `pool.processObjectMessage(build_object_message(channel, msgs))` (§17.6). */ + private fun processObjectMessage(messages: List) = + ro.objectsManager.handleObjectMessages(messages) + + /** Captures the instance-subscription events emitted by the pool's root map. */ + private fun subscribeRootEvents(): MutableList { + val events = mutableListOf() + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + root.subscribe(InstanceListener { event -> events.add(event) }) + return events + } + + private fun rootMap(): InternalLiveMap = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + + /** + * @UTS objects/unit/RTO3/pool-init-root-0 + */ + @Test + fun `RTO3 - ObjectsPool initialization with root InternalLiveMap`() { + assertNotNull(ro.objectsPool.get("root")) + val root = assertIs(ro.objectsPool.get("root")) + assertTrue(root.data.isEmpty()) + assertEquals("root", root.objectId) + } + + /** + * @UTS objects/unit/RTO4/attached-has-objects-syncing-0 + */ + @Test + fun `RTO4a - ATTACHED with HAS_OBJECTS flag starts SYNCING`() { + processAttached(hasObjects = true) + + assertEquals(ObjectsState.Syncing, ro.state) + } + + /** + * @UTS objects/unit/RTO4b/attached-no-objects-synced-0 + */ + @Test + fun `RTO4b - ATTACHED without HAS_OBJECTS clears pool and goes to SYNCED`() { + ro.objectsPool.set("counter:abc@1000", InternalLiveCounter.zeroValue("counter:abc@1000", ro)) + rootMap().data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + + val updates = subscribeRootEvents() + processAttached(hasObjects = false) + + assertEquals(ObjectsState.Synced, ro.state) + assertNull(ro.objectsPool.get("counter:abc@1000")) + assertNotNull(ro.objectsPool.get("root")) + assertTrue(rootMap().data.isEmpty()) + assertTrue(updates.size >= 1) + // DEVIATION S-1 (see deviations.md): updates[0].update == { "name": "removed" } is not + // exposed on the subscription event - the cleared root data above covers it. + // RTO4b2a - the update is emitted without populating objectMessage + assertNull(updates[0].message) + } + + /** + * @UTS objects/unit/RTO5/sync-complete-sequence-0 + */ + @Test + fun `RTO5 - OBJECT_SYNC complete sequence`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState(mapOf("name" to mapEntry(dataString("Alice"), timeserial = POOL_SERIAL))), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(42), + ), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + assertNotNull(ro.objectsPool.get("root")) + assertNotNull(ro.objectsPool.get("counter:abc@1000")) + assertEquals(dataString("Alice"), rootMap().data["name"]?.data) + assertEquals(42.0, (ro.objectsPool.get("counter:abc@1000") as InternalLiveCounter).data.get()) + } + + /** + * @UTS objects/unit/RTO5a2/new-sequence-discards-old-0 + */ + @Test + fun `RTO5a2 - new sync sequence discards previous`() { + processAttached(hasObjects = true) + processObjectSync( + "seq1:more", + listOf( + buildObjectState("counter:old@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(10)), + ), + ) + + processObjectSync( + "seq2:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + buildObjectState("counter:new@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(99)), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + assertNull(ro.objectsPool.get("counter:old@1000")) + assertNotNull(ro.objectsPool.get("counter:new@1000")) + } + + /** + * @UTS objects/unit/RTO5f2a/partial-map-merge-0 + */ + @Test + fun `RTO5f2a - partial object state merge for maps`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:more", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState(mapOf("name" to mapEntry(dataString("Alice"), timeserial = POOL_SERIAL))), + ), + ), + ) + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState(mapOf("age" to mapEntry(dataNumber(30), timeserial = POOL_SERIAL))), + createOp = mapCreateOp(), + ), + ), + ) + + assertEquals(dataString("Alice"), rootMap().data["name"]?.data) + assertEquals(dataNumber(30), rootMap().data["age"]?.data) + } + + /** + * @UTS objects/unit/RTO5c2/remove-absent-objects-0 + */ + @Test + fun `RTO5c2 - sync completion removes objects not in sync`() { + val oldCounter = InternalLiveCounter.zeroValue("counter:old@1000", ro) + oldCounter.data.set(99.0) + ro.objectsPool.set("counter:old@1000", oldCounter) + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + ), + ) + + assertNull(ro.objectsPool.get("counter:old@1000")) + assertNotNull(ro.objectsPool.get("root")) + } + + /** + * @UTS objects/unit/RTO5c9/clear-applied-on-ack-serials-0 + */ + @Test + fun `RTO5c9 - sync completion clears appliedOnAckSerials`() { + ro.appliedOnAckSerials.addAll(setOf("serial-1", "serial-2")) + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + ), + ) + + assertEquals(emptySet(), ro.appliedOnAckSerials) + } + + /** + * @UTS objects/unit/RTO8a/buffer-during-syncing-0 + */ + @Test + fun `RTO8a - OBJECT messages buffered during SYNCING`() { + processAttached(hasObjects = true) + + processObjectMessage(listOf(buildCounterInc("counter:abc@1000", 5, "01", "site1"))) + + assertEquals(ObjectsState.Syncing, ro.state) + assertEquals(1, ro.objectsManager.bufferedObjectOperations.size) + assertNull(ro.objectsPool.get("counter:abc@1000")) + } + + /** + * @UTS objects/unit/RTO5c6/apply-buffered-on-sync-0 + */ + @Test + fun `RTO5c6 - buffered operations applied on sync completion`() { + processAttached(hasObjects = true) + processObjectMessage(listOf(buildCounterInc("counter:abc@1000", 10, "02", "site1"))) + + processObjectSync( + "sync1:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(100), + ), + ), + ) + + assertEquals(110.0, (ro.objectsPool.get("counter:abc@1000") as InternalLiveCounter).data.get()) + assertEquals(0, ro.objectsManager.bufferedObjectOperations.size) + } + + /** + * @UTS objects/unit/RTO9a1/null-operation-warning-0 + */ + @Test + fun `RTO9a1 - null operation is discarded with warning`() { + ro.state = ObjectsState.Synced + + processObjectMessage(listOf(WireObjectMessage(serial = "01", siteCode = "site1", operation = null))) + + assertEquals(1, ro.objectsPool.all().size) + } + + /** + * @UTS objects/unit/RTO9a3/dedup-applied-on-ack-0 + */ + @Test + fun `RTO9a3 - appliedOnAckSerials deduplication`() { + ro.state = ObjectsState.Synced + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(10.0) + ro.objectsPool.set("counter:abc@1000", counter) + ro.appliedOnAckSerials.add("echo-serial-1") + + processObjectMessage(listOf(buildCounterInc("counter:abc@1000", 5, "echo-serial-1", "site1"))) + + assertEquals(10.0, counter.data.get()) + assertFalse("echo-serial-1" in ro.appliedOnAckSerials) + } + + /** + * @UTS objects/unit/RTO9a2a4/local-source-adds-serial-0 + */ + @Test + fun `RTO9a2a4 - LOCAL source adds serial to appliedOnAckSerials`() { + ro.state = ObjectsState.Synced + ro.objectsPool.set("counter:abc@1000", InternalLiveCounter.zeroValue("counter:abc@1000", ro)) + + ro.objectsManager.applyObjectMessages( + listOf(buildCounterInc("counter:abc@1000", 5, "local-serial-1", "test-site")), + ObjectsOperationSource.LOCAL, + ) + + assertTrue("local-serial-1" in ro.appliedOnAckSerials) + assertEquals(5.0, (ro.objectsPool.get("counter:abc@1000") as InternalLiveCounter).data.get()) + } + + /** + * @UTS objects/unit/RTO9a2b/unsupported-action-warning-0 + */ + @Test + fun `RTO9a2b - unsupported action is discarded with warning`() { + ro.state = ObjectsState.Synced + + processObjectMessage( + listOf( + WireObjectMessage( + serial = "01", + siteCode = "site1", + operation = WireObjectOperation( + action = WireObjectOperationAction.Unknown, + objectId = "counter:abc@1000", + ), + ), + ), + ) + + assertEquals(1, ro.objectsPool.all().size) + } + + /** + * @UTS objects/unit/RTO6/zero-value-from-prefix-0 + */ + @Test + fun `RTO6 - zero-value object creation from objectId prefix`() { + ro.state = ObjectsState.Synced + + processObjectMessage(listOf(buildCounterInc("counter:new@2000", 5, "01", "site1"))) + processObjectMessage(listOf(buildMapSet("map:new@2000", "key", dataString("val"), "01", "site1"))) + + assertNotNull(ro.objectsPool.get("counter:new@2000")) + val counter = assertIs(ro.objectsPool.get("counter:new@2000")) + assertEquals(5.0, counter.data.get()) + + assertNotNull(ro.objectsPool.get("map:new@2000")) + val map = assertIs(ro.objectsPool.get("map:new@2000")) + assertEquals(dataString("val"), map.data["key"]?.data) + } + + /** + * @UTS objects/unit/RTO5d/null-object-skipped-0 + */ + @Test + fun `RTO5d - OBJECT_SYNC with null object field is skipped`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + WireObjectMessage(), // ObjectMessage(object: null) + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + } + + /** + * @UTS objects/unit/RTO5f3/unsupported-type-skipped-0 + */ + @Test + fun `RTO5f3 - OBJECT_SYNC with unsupported object type is skipped`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + // ObjectMessage(object: { objectId: "unknown:xyz@1000", siteTimeserials: {} }) - no map/counter + buildObjectState("unknown:xyz@1000", emptyMap()), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + assertNull(ro.objectsPool.get("unknown:xyz@1000")) + } + + /** + * @UTS objects/unit/RTO5e/object-sync-transitions-syncing-0 + */ + @Test + fun `RTO5e - OBJECT_SYNC transitions to SYNCING`() { + processObjectSync( + "sync1:more", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap())), + ), + ) + + assertEquals(ObjectsState.Syncing, ro.state) + } + + /** + * @UTS objects/unit/RTO5c7/sync-emits-updates-0 + */ + @Test + fun `RTO5c7 - sync completion emits updates for existing objects`() { + rootMap().data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Old")) + + val updates = subscribeRootEvents() + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState(mapOf("name" to mapEntry(dataString("New"), timeserial = POOL_SERIAL))), + createOp = mapCreateOp(), + ), + ), + ) + + assertTrue(updates.size >= 1) + // DEVIATION S-1 (see deviations.md): updates[0].update["name"] == "updated" is not + // exposed on the subscription event - the replaced entry data below covers it. + assertEquals(dataString("New"), rootMap().data["name"]?.data) + } + + /** + * @UTS objects/unit/RTO5f2b/partial-counter-error-0 + */ + @Test + fun `RTO5f2b - partial counter state logs error`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:more", + listOf( + buildObjectState("counter:abc@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(10)), + ), + ) + processObjectSync( + "sync1:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + buildObjectState("counter:abc@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(5)), + ), + ) + + assertEquals(10.0, (ro.objectsPool.get("counter:abc@1000") as InternalLiveCounter).data.get()) + } + + /** + * @UTS objects/unit/RTO4d/attached-clears-buffer-0 + */ + @Test + fun `RTO4d - ATTACHED clears buffered operations`() { + processAttached(hasObjects = true) + + processObjectMessage(listOf(buildCounterInc("counter:abc@1000", 5, "01", "site1"))) + assertEquals(1, ro.objectsManager.bufferedObjectOperations.size) + + processAttached(hasObjects = true) + + assertEquals(0, ro.objectsManager.bufferedObjectOperations.size) + } + + /** + * @UTS objects/unit/RTO4-RTO5/attached-during-syncing-resets-0 + */ + @Test + fun `RTO4 RTO5 - ATTACHED during SYNCING resets sync`() { + processAttached(hasObjects = true) + processObjectSync( + "sync1:more", + listOf( + buildObjectState("counter:old@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(10)), + ), + ) + assertEquals(ObjectsState.Syncing, ro.state) + + processAttached(hasObjects = true) + + processObjectSync( + "sync2:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + buildObjectState("counter:new@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(99)), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + assertNull(ro.objectsPool.get("counter:old@1000")) + assertNotNull(ro.objectsPool.get("counter:new@1000")) + } + + /** + * @UTS objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0 + */ + @Test + fun `RTO5 RTO7 - new OBJECT_SYNC sequence does NOT clear buffer`() { + processAttached(hasObjects = true) + + processObjectMessage(listOf(buildCounterInc("counter:abc@1000", 5, "01", "site1"))) + assertEquals(1, ro.objectsManager.bufferedObjectOperations.size) + + processObjectSync( + "seq2:", + listOf( + buildObjectState("root", mapOf("aaa" to POOL_SERIAL), map = mapState(emptyMap()), createOp = mapCreateOp()), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(100), + ), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + assertEquals(105.0, (ro.objectsPool.get("counter:abc@1000") as InternalLiveCounter).data.get()) + } + + /** + * @UTS objects/unit/RTO7-RTO8/buffer-without-attached-0 + */ + @Test + fun `RTO7 RTO8 - OBJECT messages buffered even without preceding ATTACHED`() { + assertEquals(ObjectsState.Initialized, ro.state) + + processObjectMessage(listOf(buildCounterInc("counter:abc@1000", 5, "01", "site1"))) + + assertEquals(1, ro.objectsManager.bufferedObjectOperations.size) + } + + /** + * @UTS objects/unit/RTO5c-RTLM23/sync-clear-timeserial-hides-create-entries-0 + */ + @Test + fun `RTO5c RTLM23 - sync with clearTimeserial hides initial createOp entries`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState(entries = emptyMap(), clearTimeserial = "05"), + createOp = mapCreateOp( + entries = mapOf( + "old_key" to mapEntry(dataString("old"), timeserial = "03"), + "new_key" to mapEntry(dataString("new"), timeserial = "07"), + ), + ), + ), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + assertFalse(rootMap().data.containsKey("old_key")) + assertEquals(dataString("new"), rootMap().data["new_key"]?.data) + } + + /** + * @UTS objects/unit/RTO5c10/sync-rebuilds-parent-refs-0 + */ + @Test + fun `RTO5c10 - sync completion rebuilds parentReferences`() { + processAttached(hasObjects = true) + + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf( + "score" to mapEntry(dataObjectId("counter:score@1000"), timeserial = POOL_SERIAL), + "profile" to mapEntry(dataObjectId("map:profile@1000"), timeserial = POOL_SERIAL), + "name" to mapEntry(dataString("Alice"), timeserial = POOL_SERIAL), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:score@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(100), + ), + buildObjectState( + "map:profile@1000", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf( + "nested_counter" to mapEntry(dataObjectId("counter:nested@1000"), timeserial = POOL_SERIAL), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:nested@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(5), + ), + ), + ) + + // root is not referenced by any parent + assertEquals(emptyMap(), ro.objectsPool.get("root")!!.parentReferences) + + // counter:score@1000 is referenced by root at key "score" + assertEquals>>( + mapOf("root" to setOf("score")), + ro.objectsPool.get("counter:score@1000")!!.parentReferences, + ) + + // map:profile@1000 is referenced by root at key "profile" + assertEquals>>( + mapOf("root" to setOf("profile")), + ro.objectsPool.get("map:profile@1000")!!.parentReferences, + ) + + // counter:nested@1000 is referenced by map:profile@1000 at key "nested_counter" + assertEquals>>( + mapOf("map:profile@1000" to setOf("nested_counter")), + ro.objectsPool.get("counter:nested@1000")!!.parentReferences, + ) + + // Primitive-valued entries ("name") do not appear in any parentReferences - covered by + // the exact-equality assertions above (no extra entries anywhere). + } + + /** + * @UTS objects/unit/RTO5c10/resync-rebuilds-parent-refs-0 + */ + @Test + fun `RTO5c10 - re-sync rebuilds parentReferences with new tree structure`() { + processAttached(hasObjects = true) + + // First sync: counter:abc@1000 is a child of root + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf("counter_key" to mapEntry(dataObjectId("counter:abc@1000"), timeserial = POOL_SERIAL)), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(10), + ), + ), + ) + + // Verify first sync parentReferences + assertEquals>>( + mapOf("root" to setOf("counter_key")), + ro.objectsPool.get("counter:abc@1000")!!.parentReferences, + ) + + // Second sync: counter:abc@1000 is now a child of map:wrapper@1000, not root + processAttached(hasObjects = true) + processObjectSync( + "sync2:", + listOf( + buildObjectState( + "root", mapOf("aaa" to remoteSerial(0)), + map = mapState( + linkedMapOf("wrapper" to mapEntry(dataObjectId("map:wrapper@1000"), timeserial = remoteSerial(0))), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "map:wrapper@1000", mapOf("aaa" to remoteSerial(0)), + map = mapState( + linkedMapOf("moved_counter" to mapEntry(dataObjectId("counter:abc@1000"), timeserial = remoteSerial(0))), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to remoteSerial(0)), + counter = counterState(0), + createOp = counterCreateOp(20), + ), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + + // root is not referenced by any parent + assertEquals(emptyMap(), ro.objectsPool.get("root")!!.parentReferences) + + // map:wrapper@1000 is now a child of root at key "wrapper" + assertEquals>>( + mapOf("root" to setOf("wrapper")), + ro.objectsPool.get("map:wrapper@1000")!!.parentReferences, + ) + + // counter:abc@1000 is now a child of map:wrapper@1000, NOT of root + assertEquals>>( + mapOf("map:wrapper@1000" to setOf("moved_counter")), + ro.objectsPool.get("counter:abc@1000")!!.parentReferences, + ) + } + + /** + * @UTS objects/unit/RTO5c10/empty-sync-parent-refs-0 + */ + @Test + fun `RTO5c10 - empty sync leaves root with empty parentReferences`() { + // First, do a normal sync to populate parentReferences + processAttached(hasObjects = true) + processObjectSync( + "sync1:", + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf("child" to mapEntry(dataObjectId("counter:child@1000"), timeserial = POOL_SERIAL)), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:child@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(1), + ), + ), + ) + + // Verify parentReferences are populated after first sync + assertEquals>>( + mapOf("root" to setOf("child")), + ro.objectsPool.get("counter:child@1000")!!.parentReferences, + ) + + // Empty sync: ATTACHED without HAS_OBJECTS + processAttached(hasObjects = false) + + assertEquals(ObjectsState.Synced, ro.state) + + // counter:child@1000 was removed from pool (RTO4b1) + assertNull(ro.objectsPool.get("counter:child@1000")) + + // root exists with empty data and empty parentReferences + assertNotNull(ro.objectsPool.get("root")) + assertTrue(rootMap().data.isEmpty()) + assertEquals(emptyMap(), ro.objectsPool.get("root")!!.parentReferences) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ParentReferencesTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ParentReferencesTest.kt new file mode 100644 index 000000000..9ad6e08c6 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ParentReferencesTest.kt @@ -0,0 +1,545 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ObjectsState +import io.ably.lib.liveobjects.ROOT_OBJECT_ID +import io.ably.lib.liveobjects.assertWaiter +import io.ably.lib.liveobjects.unit.getMockAblyClientAdapter +import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter +import io.ably.lib.liveobjects.value.livemap.InternalLiveMap +import io.ably.lib.realtime.ChannelState +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Derived from UTS spec `objects/unit/parent_references.md` — `parentReferences` tracking on + * LiveObject (RTLO3f), `addParentReference`/`removeParentReference` (RTLO4g/RTLO4h), the + * `getFullPaths` graph traversal (RTLO4f), and the post-sync rebuild (RTO5c10). + * + * Internal-graph spec: asserts on the internal CRDT graph (`InternalLiveCounter`, + * `InternalLiveMap`, `ObjectsPool`), so it lives in `:liveobjects`'s own test source set — + * symbol map in `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17 + * (instantiation §17.1, parent references §17.8, pool/sync §17.6). + */ +class ParentReferencesTest { + + private lateinit var ro: DefaultRealtimeObject + + @BeforeTest + fun setUp() { + // §17.1 - internal classes have no public constructors; build them against a + // DefaultRealtimeObject backed by the mocked adapter. The pool ("root" auto-created + // per RTO3b) is the spec's `pool = ObjectsPool()`. + ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + } + + @AfterTest + fun tearDown() { + // DEVIATION S-4 (see deviations.md): ObjectsPool.init starts a real GC coroutine + + // adapter subscription - dispose it; unmockkAll clears the mockkStatic global state. + ro.objectsPool.dispose() + unmockkAll() + } + + // ----------------------------------------------------------------------- + // RTLO3f2 - initialization + // ----------------------------------------------------------------------- + + /** + * @UTS objects/unit/RTLO3f2/init-empty-counter-0 + */ + @Test + fun `RTLO3f2 - parentReferences initialized to empty map on InternalLiveCounter`() { + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + + assertEquals(emptyMap(), counter.parentReferences) + } + + /** + * @UTS objects/unit/RTLO3f2/init-empty-map-0 + */ + @Test + fun `RTLO3f2 - parentReferences initialized to empty map on InternalLiveMap`() { + val map = InternalLiveMap.zeroValue("map:abc@1000", ro) + + assertEquals(emptyMap(), map.parentReferences) + } + + // ----------------------------------------------------------------------- + // RTLO4g - addParentReference + // ----------------------------------------------------------------------- + + /** + * @UTS objects/unit/RTLO4g2/first-reference-new-entry-0 + */ + @Test + fun `RTLO4g2 - addParentReference creates new entry for first reference`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parent = InternalLiveMap.zeroValue("map:parent@1000", ro) + + child.addParentReference(parent, "score") + + assertContains(child.parentReferences, "map:parent@1000") + assertEquals?>(setOf("score"), child.parentReferences["map:parent@1000"]) + } + + /** + * @UTS objects/unit/RTLO4g1/second-key-same-parent-0 + */ + @Test + fun `RTLO4g1 - addParentReference adds key to existing entry for same parent`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parent = InternalLiveMap.zeroValue("map:parent@1000", ro) + child.parentReferences["map:parent@1000"] = mutableSetOf("score") + + child.addParentReference(parent, "points") + + assertEquals?>(setOf("score", "points"), child.parentReferences["map:parent@1000"]) + } + + /** + * @UTS objects/unit/RTLO4g/different-parent-separate-entry-0 + */ + @Test + fun `RTLO4g - addParentReference with different parent creates separate entry`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parentA = InternalLiveMap.zeroValue("map:a@1000", ro) + val parentB = InternalLiveMap.zeroValue("map:b@1000", ro) + + child.addParentReference(parentA, "x") + child.addParentReference(parentB, "y") + + assertEquals?>(setOf("x"), child.parentReferences["map:a@1000"]) + assertEquals?>(setOf("y"), child.parentReferences["map:b@1000"]) + } + + /** + * @UTS objects/unit/RTLO4g/multiple-parents-multiple-keys-0 + */ + @Test + fun `RTLO4g - addParentReference with multiple parents and multiple keys`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parentA = InternalLiveMap.zeroValue("map:a@1000", ro) + val parentB = InternalLiveMap.zeroValue("map:b@1000", ro) + + child.addParentReference(parentA, "x") + child.addParentReference(parentA, "y") + child.addParentReference(parentB, "p") + child.addParentReference(parentB, "q") + + assertEquals?>(setOf("x", "y"), child.parentReferences["map:a@1000"]) + assertEquals?>(setOf("p", "q"), child.parentReferences["map:b@1000"]) + } + + // ----------------------------------------------------------------------- + // RTLO4h - removeParentReference + // ----------------------------------------------------------------------- + + /** + * @UTS objects/unit/RTLO4h1/nonexistent-parent-noop-0 + */ + @Test + fun `RTLO4h1 - removeParentReference no-op for non-existent parent`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parent = InternalLiveMap.zeroValue("map:parent@1000", ro) + + child.removeParentReference(parent, "score") + + assertEquals(emptyMap(), child.parentReferences) + } + + /** + * @UTS objects/unit/RTLO4h2/remove-key-leaves-others-0 + */ + @Test + fun `RTLO4h2 - removeParentReference removes key but leaves other keys`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parent = InternalLiveMap.zeroValue("map:parent@1000", ro) + child.parentReferences["map:parent@1000"] = mutableSetOf("score", "points") + + child.removeParentReference(parent, "score") + + assertEquals?>(setOf("points"), child.parentReferences["map:parent@1000"]) + } + + /** + * @UTS objects/unit/RTLO4h3/remove-last-key-removes-entry-0 + */ + @Test + fun `RTLO4h3 - removeParentReference removes entry when set becomes empty`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parent = InternalLiveMap.zeroValue("map:parent@1000", ro) + child.parentReferences["map:parent@1000"] = mutableSetOf("score") + + child.removeParentReference(parent, "score") + + assertFalse("map:parent@1000" in child.parentReferences) + assertEquals(emptyMap(), child.parentReferences) + } + + /** + * @UTS objects/unit/RTLO4h/remove-nonexistent-key-0 + */ + @Test + fun `RTLO4h - removeParentReference for non-existent key in existing parent`() { + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + val parent = InternalLiveMap.zeroValue("map:parent@1000", ro) + child.parentReferences["map:parent@1000"] = mutableSetOf("score") + + child.removeParentReference(parent, "nonexistent") + + assertEquals?>(setOf("score"), child.parentReferences["map:parent@1000"]) + } + + // ----------------------------------------------------------------------- + // RTLO4f - getFullPaths + // ----------------------------------------------------------------------- + + /** + * @UTS objects/unit/RTLO4f2/root-returns-empty-path-0 + */ + @Test + fun `RTLO4f2 - getFullPaths for root returns empty key-path`() { + val root = ro.objectsPool.get(ROOT_OBJECT_ID)!! + + val paths = root.getFullPaths() + assertEquals(1, paths.size) + assertContains(paths, emptyList()) + } + + /** + * @UTS objects/unit/RTLO4f/direct-child-single-path-0 + */ + @Test + fun `RTLO4f - getFullPaths for direct child of root`() { + val counter = InternalLiveCounter.zeroValue("counter:score@1000", ro) + ro.objectsPool.set("counter:score@1000", counter) + + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + counter.addParentReference(root, "score") + + val paths = counter.getFullPaths() + assertEquals(1, paths.size) + assertContains(paths, listOf("score")) + } + + /** + * @UTS objects/unit/RTLO4f/deep-nesting-0 + */ + @Test + fun `RTLO4f - getFullPaths for deeply nested object`() { + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + + val profile = InternalLiveMap.zeroValue("map:profile@1000", ro) + ro.objectsPool.set("map:profile@1000", profile) + profile.addParentReference(root, "profile") + + val prefs = InternalLiveMap.zeroValue("map:prefs@1000", ro) + ro.objectsPool.set("map:prefs@1000", prefs) + prefs.addParentReference(profile, "prefs") + + val themeCounter = InternalLiveCounter.zeroValue("counter:theme@1000", ro) + ro.objectsPool.set("counter:theme@1000", themeCounter) + themeCounter.addParentReference(prefs, "theme_counter") + + val paths = themeCounter.getFullPaths() + assertEquals(1, paths.size) + assertContains(paths, listOf("profile", "prefs", "theme_counter")) + } + + /** + * @UTS objects/unit/RTLO4f/diamond-graph-0 + */ + @Test + fun `RTLO4f - getFullPaths with multiple parents diamond graph`() { + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + + val mapA = InternalLiveMap.zeroValue("map:a@1000", ro) + ro.objectsPool.set("map:a@1000", mapA) + mapA.addParentReference(root, "a") + + val mapB = InternalLiveMap.zeroValue("map:b@1000", ro) + ro.objectsPool.set("map:b@1000", mapB) + mapB.addParentReference(root, "b") + + val leaf = InternalLiveCounter.zeroValue("counter:leaf@1000", ro) + ro.objectsPool.set("counter:leaf@1000", leaf) + leaf.addParentReference(mapA, "x") + leaf.addParentReference(mapB, "y") + + val paths = leaf.getFullPaths() + assertEquals(2, paths.size) + assertContains(paths, listOf("a", "x")) + assertContains(paths, listOf("b", "y")) + } + + /** + * @UTS objects/unit/RTLO4f/single-parent-multiple-keys-0 + */ + @Test + fun `RTLO4f - getFullPaths with single parent referencing at multiple keys`() { + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + + val child = InternalLiveCounter.zeroValue("counter:child@1000", ro) + ro.objectsPool.set("counter:child@1000", child) + child.addParentReference(root, "primary") + child.addParentReference(root, "alias") + + val paths = child.getFullPaths() + assertEquals(2, paths.size) + assertContains(paths, listOf("primary")) + assertContains(paths, listOf("alias")) + } + + /** + * @UTS objects/unit/RTLO4f/orphan-returns-empty-0 + */ + @Test + fun `RTLO4f - getFullPaths for orphan returns empty list`() { + val orphan = InternalLiveCounter.zeroValue("counter:orphan@1000", ro) + ro.objectsPool.set("counter:orphan@1000", orphan) + + val paths = orphan.getFullPaths() + assertEquals(0, paths.size) + } + + /** + * @UTS objects/unit/RTLO4f/cycle-suppression-0 + */ + @Test + fun `RTLO4f - getFullPaths suppresses cycles`() { + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + + val mapA = InternalLiveMap.zeroValue("map:a@1000", ro) + ro.objectsPool.set("map:a@1000", mapA) + mapA.addParentReference(root, "a") + + val mapB = InternalLiveMap.zeroValue("map:b@1000", ro) + ro.objectsPool.set("map:b@1000", mapB) + mapB.addParentReference(mapA, "b") + + // Create a cycle: map:A also has map:B as a parent + mapA.addParentReference(mapB, "a") + + val pathsB = mapB.getFullPaths() + assertEquals(1, pathsB.size) + assertContains(pathsB, listOf("a", "b")) + + val pathsA = mapA.getFullPaths() + assertEquals(1, pathsA.size) + assertContains(pathsA, listOf("a")) + } + + /** + * @UTS objects/unit/RTLO4f/complex-diamond-deep-0 + */ + @Test + fun `RTLO4f - getFullPaths with complex diamond and deep nesting`() { + val root = ro.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + + val mapL = InternalLiveMap.zeroValue("map:l@1000", ro) + ro.objectsPool.set("map:l@1000", mapL) + mapL.addParentReference(root, "left") + + val mapR = InternalLiveMap.zeroValue("map:r@1000", ro) + ro.objectsPool.set("map:r@1000", mapR) + mapR.addParentReference(root, "right") + + val mapM = InternalLiveMap.zeroValue("map:m@1000", ro) + ro.objectsPool.set("map:m@1000", mapM) + mapM.addParentReference(mapL, "mid") + + val target = InternalLiveCounter.zeroValue("counter:t@1000", ro) + ro.objectsPool.set("counter:t@1000", target) + target.addParentReference(mapM, "target") + target.addParentReference(mapR, "target") + + val paths = target.getFullPaths() + assertEquals(2, paths.size) + assertContains(paths, listOf("left", "mid", "target")) + assertContains(paths, listOf("right", "target")) + } + + // ----------------------------------------------------------------------- + // RTO5c10 - post-sync rebuild + // ----------------------------------------------------------------------- + + /** + * The spec's `pool.processAttached(ProtocolMessage(action: ATTACHED, flags: HAS_OBJECTS))`. + * DEVIATION S-2 (see deviations.md): maps to the async `handleStateChange` (launched on the + * sequential scope), so await the SYNCING transition before delivering sync messages. + */ + private suspend fun processAttachedWithObjects() { + ro.handleStateChange(ChannelState.attached, hasObjects = true) + assertWaiter { ro.state == ObjectsState.Syncing } + } + + /** + * @UTS objects/unit/RTO5c10/rebuild-from-sync-0 + */ + @Test + fun `RTO5c10 - post-sync rebuild populates parentReferences from InternalLiveMap entries`() = runTest { + processAttachedWithObjects() + + // pool.processObjectSync(build_object_sync_message("test", "sync1:", [...])) - the wire + // messages + sync serial go straight to handleObjectSyncMessages (§17.6/§17.10) + ro.objectsManager.handleObjectSyncMessages( + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf( + "score" to mapEntry(dataObjectId("counter:score@1000"), timeserial = POOL_SERIAL), + "profile" to mapEntry(dataObjectId("map:profile@1000"), timeserial = POOL_SERIAL), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:score@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(100), + ), + buildObjectState( + "map:profile@1000", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf( + "nested" to mapEntry(dataObjectId("counter:nested@1000"), timeserial = POOL_SERIAL), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:nested@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(5), + ), + ), + "sync1:", + ) + + assertEquals(ObjectsState.Synced, ro.state) + + // counter:score@1000 is referenced by root at key "score" + val score = ro.objectsPool.get("counter:score@1000")!! + assertEquals?>(setOf("score"), score.parentReferences["root"]) + + // map:profile@1000 is referenced by root at key "profile" + val profile = ro.objectsPool.get("map:profile@1000")!! + assertEquals?>(setOf("profile"), profile.parentReferences["root"]) + + // counter:nested@1000 is referenced by map:profile@1000 at key "nested" + val nested = ro.objectsPool.get("counter:nested@1000")!! + assertEquals?>(setOf("nested"), nested.parentReferences["map:profile@1000"]) + + // root has no parent references + assertEquals(emptyMap(), ro.objectsPool.get(ROOT_OBJECT_ID)!!.parentReferences) + + // getFullPaths works correctly after rebuild + assertContains(score.getFullPaths(), listOf("score")) + assertContains(nested.getFullPaths(), listOf("profile", "nested")) + } + + /** + * @UTS objects/unit/RTO5c10a/rebuild-clears-stale-refs-0 + */ + @Test + fun `RTO5c10a - post-sync rebuild clears stale parentReferences`() = runTest { + // First sync: root --"score"--> counter:abc@1000 + processAttachedWithObjects() + ro.objectsManager.handleObjectSyncMessages( + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf( + "score" to mapEntry(dataObjectId("counter:abc@1000"), timeserial = POOL_SERIAL), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(10), + ), + ), + "sync1:", + ) + assertEquals?>(setOf("score"), ro.objectsPool.get("counter:abc@1000")!!.parentReferences["root"]) + + // Second sync: root --"points"--> counter:abc@1000 (key changed from "score" to "points") + processAttachedWithObjects() + ro.objectsManager.handleObjectSyncMessages( + listOf( + buildObjectState( + "root", mapOf("aaa" to remoteSerial(0)), + map = mapState( + linkedMapOf( + "points" to mapEntry(dataObjectId("counter:abc@1000"), timeserial = remoteSerial(0)), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:abc@1000", mapOf("aaa" to remoteSerial(0)), + counter = counterState(0), + createOp = counterCreateOp(20), + ), + ), + "sync2:", + ) + + val counter = ro.objectsPool.get("counter:abc@1000")!! + + // Old "score" reference should be gone, replaced by "points" + assertEquals?>(setOf("points"), counter.parentReferences["root"]) + assertContains(counter.getFullPaths(), listOf("points")) + + val paths = counter.getFullPaths() + assertEquals(1, paths.size) + } + + /** + * @UTS objects/unit/RTO5c10/unreferenced-empty-refs-0 + */ + @Test + fun `RTO5c10 - post-sync unreferenced objects have empty parentReferences`() = runTest { + processAttachedWithObjects() + + ro.objectsManager.handleObjectSyncMessages( + listOf( + buildObjectState( + "root", mapOf("aaa" to POOL_SERIAL), + map = mapState( + linkedMapOf( + "name" to mapEntry(dataString("Alice"), timeserial = POOL_SERIAL), + ), + ), + createOp = mapCreateOp(), + ), + buildObjectState( + "counter:orphan@1000", mapOf("aaa" to POOL_SERIAL), + counter = counterState(0), + createOp = counterCreateOp(42), + ), + ), + "sync1:", + ) + + assertEquals(ObjectsState.Synced, ro.state) + + // The counter exists in the pool but no InternalLiveMap entry points to it + val orphan = ro.objectsPool.get("counter:orphan@1000")!! + assertEquals(emptyMap(), orphan.parentReferences) + + // getFullPaths returns empty list for unreferenced object + assertEquals(0, orphan.getFullPaths().size) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectMutationsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectMutationsTest.kt new file mode 100644 index 000000000..c6497481f --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectMutationsTest.kt @@ -0,0 +1,219 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.types.AblyException +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * Derived from UTS spec `objects/unit/path_object_mutations.md` — PathObject write operations + * (`RTPO15`–`RTPO18`, `RTPO3c2`): set/remove/increment/decrement delegation, default amounts, + * wrong-type failures (92007) and unresolvable-path failures (92005). + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt`. Wrong-type + * write failures translate through the never-throwing `PathObject` casts per + * `objects-mapping.md` §7/§12 (RTTS5d) — the typed view lacks the wrong method, so the test + * casts to the view carrying it and asserts the *operation* throws. + */ +class PathObjectMutationsTest { + + /** + * @UTS objects/unit/RTPO15/set-delegates-to-map-0 + */ + @Test + fun `RTPO15 - set delegates to InternalLiveMap set`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.set("name", LiveMapValue.of("Bob")).await() + + assertEquals("Bob", root.get("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO15/set-nested-path-0 + */ + @Test + fun `RTPO15 - set on nested path`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("profile").asLiveMap().set("email", LiveMapValue.of("bob@example.com")).await() + + assertEquals("bob@example.com", root.get("profile").asLiveMap().get("email").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO15d/set-non-map-throws-0 + */ + @Test + fun `RTPO15d - set on non-InternalLiveMap throws 92007`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.get("score").asLiveMap().set("key", LiveMapValue.of("value")).await() + } + + assertEquals(92007, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTPO16/remove-delegates-to-map-0 + */ + @Test + fun `RTPO16 - remove delegates to InternalLiveMap remove`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.remove("name").await() + + assertNull(root.get("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO16d/remove-non-map-throws-0 + */ + @Test + fun `RTPO16d - remove on non-InternalLiveMap throws 92007`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.get("score").asLiveMap().remove("key").await() + } + + assertEquals(92007, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTPO17/increment-delegates-to-counter-0 + */ + @Test + fun `RTPO17 - increment delegates to InternalLiveCounter increment`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(25).await() + + assertEquals(125.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO17/increment-default-amount-0 + */ + @Test + fun `RTPO17 - increment defaults to 1`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment().await() + + assertEquals(101.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO17d/increment-non-counter-throws-0 + */ + @Test + fun `RTPO17d - increment on non-InternalLiveCounter throws 92007`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.asLiveCounter().increment(5).await() + } + + assertEquals(92007, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTPO18/decrement-delegates-to-counter-0 + */ + @Test + fun `RTPO18 - decrement delegates to InternalLiveCounter decrement`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().decrement(10).await() + + assertEquals(90.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO18/decrement-default-amount-0 + */ + @Test + fun `RTPO18 - decrement defaults to 1`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().decrement().await() + + assertEquals(99.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO18d/decrement-non-counter-throws-0 + */ + @Test + fun `RTPO18d - decrement on non-InternalLiveCounter throws 92007`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.asLiveCounter().decrement(5).await() + } + + assertEquals(92007, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTPO3c2/set-unresolvable-throws-0 + */ + @Test + fun `RTPO3c2 - set on unresolvable path throws 92005`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.get("nonexistent").asLiveMap().get("deep").asLiveMap().set("key", LiveMapValue.of("value")).await() + } + + assertEquals(92005, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTPO3c2/increment-unresolvable-throws-0 + */ + @Test + fun `RTPO3c2 - increment on unresolvable path throws 92005`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.get("nonexistent").asLiveCounter().increment(5).await() + } + + assertEquals(92005, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectSubscribeTest.kt similarity index 58% rename from uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectSubscribeTest.kt index 422be76c8..a7411b347 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectSubscribeTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.unit.liveobjects +package io.ably.lib.liveobjects.uts.unit import io.ably.lib.liveobjects.Subscription import io.ably.lib.liveobjects.message.ObjectOperationAction @@ -21,26 +21,20 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** - * Derived from UTS `objects/unit/path_object_subscribe.md` (RTPO19, RTO24, RTO25) — path-based - * subscriptions on `PathObject`. + * Derived from UTS spec `objects/unit/path_object_subscribe.md` — PathObject subscriptions + * (`RTPO19`, `RTO24`, `RTO25`): Subscription return, event object/message payloads, depth + * filtering (RTO24c1/RTO24c2), candidate-path construction (RTO24b2a), multi-path dispatch + * via getFullPaths (RTO24b1), exactly-once delivery per dispatch (RTO24b2b), and the RTO25 + * access preconditions. * - * Mapping (§8): `pathObj.subscribe(PathObjectListener { event -> … }[, PathObjectSubscriptionOptions(depth)])` - * returns a `Subscription`; the event exposes `getObject(): PathObject` and `getMessage(): ObjectMessage?`. - * A non-positive `depth` throws `AblyException` 400/`40003` from the `PathObjectSubscriptionOptions(int)` - * constructor (RTPO19c1a). Negative "listener did not fire" assertions use the quiescence-barrier pattern - * (a still-subscribed control listener awaited via `pollUntil`, per standard_test_pool.md). - * - * Inbound MAP_SET / MAP_REMOVE ops on an **existing** standard-pool entry (baseline entry timeserial - * `"t:0"`) must carry a serial that sorts *after* `"t:0"` under map-entry LWW (RTLM9e: - * `incomingSerial > existingEntrySerial`, lexicographic), so they use `"t:1"`/`"t:2"`. (This was formerly - * spec-issue SI-2, where those ops carried bare `"98"/"99"/"100"/"101"` serials that sort *before* `"t:0"` - * and were correctly rejected as stale; it was fixed upstream in the spec.) Counter increments, new-key - * MAP_SETs, and MAP_CLEAR are unaffected (no per-existing-entry LWW / fresh site), so they keep bare serials. + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt`. */ class PathObjectSubscribeTest { @@ -49,26 +43,27 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19 - subscribe returns Subscription and receives events`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - val sub: Subscription = root.get("score").subscribe(PathObjectListener { events.add(it) }) + val sub = root.get("score").subscribe(PathObjectListener { event -> events.add(event) }) mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - assertNotNull(sub) // IS Subscription + assertIs(sub) assertEquals(1, events.size) - assertNotNull(events[0].getObject()) // IS PathObject + assertIs(events[0].getObject()) assertEquals("score", events[0].getObject().path()) - val message = events[0].getMessage() - assertNotNull(message) - assertEquals("99", message!!.serial) + val message = assertNotNull(events[0].message) + assertEquals("99", message.serial) assertEquals("remote", message.siteCode) assertNotNull(message.operation) assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) assertEquals("test", message.channel) + + client.close() } /** @@ -76,7 +71,6 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19b - subscribe on DETACHED channel throws 90001`() = runTest { - // Custom mock (spec setup): responds to ATTACH with ATTACHED+OBJECT_SYNC and to DETACH with DETACHED. lateinit var mockWs: MockWebSocket mockWs = MockWebSocket { onConnectionAttempt = { conn -> @@ -85,9 +79,8 @@ class PathObjectSubscribeTest { connectionId = "conn-1" connectionDetails = ConnectionDetails { connectionKey = "conn-key-1" - siteCode = SITE_CODE + siteCode = "test-site" objectsGCGracePeriod = 86_400_000L - maxMessageSize = 65_536 } }, ) @@ -104,12 +97,16 @@ class PathObjectSubscribeTest { ) mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) } - ProtocolMessage.Action.detach -> - mockWs.sendToClient(ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel }) + ProtocolMessage.Action.detach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel }, + ) + } else -> Unit } } } + val client = TestRealtimeClient { key = "fake:key" install(mockWs) @@ -123,10 +120,14 @@ class PathObjectSubscribeTest { channel.detach() awaitChannelState(channel, ChannelState.detached) - // RTO25b: access on a DETACHED channel throws ErrorInfo 400/90001. - val ex = assertFailsWith { root.subscribe(PathObjectListener { }) } - assertEquals(90001, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) + val error = assertFailsWith { + root.subscribe(PathObjectListener { }) + } + + assertEquals(90001, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() } /** @@ -134,11 +135,15 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19c1a - subscribe with non-positive depth throws 40003`() = runTest { - setupSyncedChannel("test") + val (client, _, root, _) = setupSyncedChannel("test") + + val error = assertFailsWith { + root.subscribe(PathObjectListener { }, PathObjectSubscriptionOptions(0)) + } + + assertEquals(40003, error.errorInfo.code) - // depth validation happens in the PathObjectSubscriptionOptions(int) constructor (RTPO19c1a). - val ex = assertFailsWith { PathObjectSubscriptionOptions(0) } - assertEquals(40003, ex.errorInfo.code) + client.close() } /** @@ -146,10 +151,15 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19c1a - subscribe with negative depth throws 40003`() = runTest { - setupSyncedChannel("test") + val (client, _, root, _) = setupSyncedChannel("test") - val ex = assertFailsWith { PathObjectSubscriptionOptions(-1) } - assertEquals(40003, ex.errorInfo.code) + val error = assertFailsWith { + root.subscribe(PathObjectListener { }, PathObjectSubscriptionOptions(-1)) + } + + assertEquals(40003, error.errorInfo.code) + + client.close() } /** @@ -157,27 +167,31 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19c1 - subscribe with depth 1 only receives self events`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(1)) - // Quiescence control: an unlimited-depth root listener that covers the out-of-scope child path. + root.subscribe(PathObjectListener { event -> events.add(event) }, PathObjectSubscriptionOptions(1)) + // Quiescence control: an unlimited-depth root listener that DOES cover the out-of-scope + // child path, so it fires on the send below and gives us a delivery to await + // (Negative-assertion quiescence, helpers/standard_test_pool.md). val control = mutableListOf() - root.subscribe(PathObjectListener { control.add(it) }) + root.subscribe(PathObjectListener { event -> control.add(event) }) - // Self event (root map update) — path [] covered at depth 1. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - // Child event (root["score"], relativeDepth 2) — NOT covered by depth 1; await the control instead. val controlBefore = control.size mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))), ) + // Negative-assertion quiescence: the unlimited-depth control covers ["score"], so await + // its delivery for this dispatch, THEN assert the depth-1 listener did NOT fire. pollUntil(5.seconds) { control.size > controlBefore } assertEquals(1, events.size) + + client.close() } /** @@ -185,32 +199,42 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19c1 - subscribe with depth 2 receives self and children`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(2)) + root.subscribe(PathObjectListener { event -> events.add(event) }, PathObjectSubscriptionOptions(2)) + // Quiescence control: an unlimited-depth root listener that covers the out-of-scope + // grandchild path, so it fires on the send below. val control = mutableListOf() - root.subscribe(PathObjectListener { control.add(it) }) + root.subscribe(PathObjectListener { event -> control.add(event) }) - // Self event (root map update) — candidate [] covered at depth 2. + // Self event (root map update) — candidate [] is covered at depth 2. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - // Child event (root["score"] counter, relativeDepth 2 <= 2) — covered. + // Child event (root["score"] counter) — candidate ["score"], relativeDepth 1-0+1 = 2 <= 2, covered. mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))), ) pollUntil(5.seconds) { events.size >= 2 } - // Grandchild event (root["profile"]["nested_counter"], relativeDepth 3 > 2) — NOT covered. + // Grandchild event (root["profile"]["nested_counter"] counter) — candidate + // ["profile","nested_counter"], relativeDepth 2-0+1 = 3 > 2, NOT covered. A COUNTER_INC + // yields ONLY this single candidate (no key candidate), unlike a MAP_SET on a child map + // which would also emit the covered parent-map path (RTO24b2a1). val controlBefore = control.size mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 1, "101", "remote"))), ) + // Negative-assertion quiescence: the unlimited-depth control covers + // ["profile","nested_counter"], so await its delivery for this dispatch, THEN assert the + // depth-2 listener did NOT fire on the grandchild update. pollUntil(5.seconds) { control.size > controlBefore } assertEquals(2, events.size) + + client.close() } /** @@ -218,9 +242,9 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19c1 - subscribe with no depth receives all descendants`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.subscribe(PathObjectListener { events.add(it) }) + root.subscribe(PathObjectListener { event -> events.add(event) }) mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), @@ -232,13 +256,14 @@ class PathObjectSubscribeTest { ) pollUntil(5.seconds) { events.size >= 2 } - // Grandchild update at map:prefs["theme"] — with no depth, a descendant at any depth is covered. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("map:prefs@1000", "theme", dataString("light"), remoteSerial(1), "remote"))), ) pollUntil(5.seconds) { events.size >= 3 } assertTrue(events.size >= 3) + + client.close() } /** @@ -246,22 +271,27 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19d - subscribe returns Subscription with unsubscribe`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - val sub = root.get("score").subscribe(PathObjectListener { events.add(it) }) - - // Quiescence control: a separate, still-subscribed listener on the same path that WILL fire. + val sub = root.get("score").subscribe(PathObjectListener { event -> events.add(event) }) + // Quiescence control: a separate, still-subscribed listener on the same (live) object + // that WILL fire on the send below, giving a delivery to await. val control = mutableListOf() - root.get("score").subscribe(PathObjectListener { control.add(it) }) + root.get("score").subscribe(PathObjectListener { event -> control.add(event) }) - assertNotNull(sub) // IS Subscription + assertIs(sub) sub.unsubscribe() mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), ) + // Negative-assertion quiescence: the separate control listener (still subscribed) fires + // on this dispatch; await it, THEN assert the unsubscribed listener did not fire. pollUntil(5.seconds) { control.size >= 1 } + assertEquals(0, events.size) + + client.close() } /** @@ -269,58 +299,61 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19e1 - subscribe event provides correct PathObject`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.subscribe(PathObjectListener { events.add(it) }) + root.subscribe(PathObjectListener { event -> events.add(event) }) mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - assertNotNull(events[0].getObject()) // IS PathObject + assertIs(events[0].getObject()) assertEquals("score", events[0].getObject().path()) - assertEquals(107.0, events[0].getObject().asLiveCounter().value()) // 100 + 7 + assertEquals(107.0, events[0].getObject().asLiveCounter().value()) + + client.close() } /** * @UTS objects/unit/RTPO19e2/event-message-delivery-0 */ @Test - fun `RTPO19e2 - subscribe event delivers ObjectMessage for operations`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + fun `RTPO19e2 - subscribe event delivers PublicAPI ObjectMessage for operations`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.get("score").subscribe(PathObjectListener { events.add(it) }) + root.get("score").subscribe(PathObjectListener { event -> events.add(event) }) mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 42, "serial-1", "site-a"))), ) pollUntil(5.seconds) { events.size >= 1 } - val message = events[0].getMessage() - assertNotNull(message) - assertEquals("test", message!!.channel) + val message = assertNotNull(events[0].message) + assertEquals("test", message.channel) assertEquals("serial-1", message.serial) assertEquals("site-a", message.siteCode) assertNotNull(message.operation) assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) assertEquals("counter:score@1000", message.operation.objectId) - assertEquals(42.0, message.operation.counterInc!!.number) + assertEquals(42.0, assertNotNull(message.operation.counterInc).number) + + client.close() } /** * @UTS objects/unit/RTPO19e2/event-message-omitted-no-operation-0 */ @Test - fun `RTPO19e2 - subscribe event omits message when no operation`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + fun `RTPO19e2 - subscribe event omits message when objectMessage has no operation`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.subscribe(PathObjectListener { events.add(it) }) + root.subscribe(PathObjectListener { event -> events.add(event) }) - // An OBJECT_SYNC that changes counter:score@1000's state via replaceData (no operation field). - // The sync intentionally omits root: per RTO5c2a root is never removed from the pool, so it - // is retained and still references "score" — counter:score stays reachable and its - // sync-triggered update dispatches to the root subscription (message omitted). + // Send an OBJECT_SYNC that changes counter:score@1000's state (100 -> 200) via + // replaceData (RTLC6) — a sync-triggered update, so its objectMessage has no `operation` + // field. The sync intentionally omits `root`: per RTO5c2a the root object must never be + // removed from the pool (RTO3b), so root is retained and still references "score". mockWs.sendToClient( buildObjectSyncMessage( "test", @@ -337,10 +370,12 @@ class PathObjectSubscribeTest { ) pollUntil(5.seconds) { events.size >= 1 } - // Events from sync-triggered updates carry no message (RTPO19e2 / RTO24b2b2). + // Events from sync-triggered updates should have no message. for (event in events) { - assertEquals(null, event.getMessage()) + assertNull(event.message) } + + client.close() } /** @@ -348,22 +383,25 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19f - subscribe follows path not identity`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.get("score").subscribe(PathObjectListener { events.add(it) }) + root.get("score").subscribe(PathObjectListener { event -> events.add(event) }) - // Replace the counter at "score" with a new counter (identity change at the path). + // Replace the counter at "score" with a new counter. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), remoteSerial(0), "remote"))), ) - // Increment the NEW counter now at "score". + + // Increment the NEW counter at "score". mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:new@2000", 10, "100", "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - // Subscription is by path, so it delivers events for the new object living at "score". + // Should receive event for the new counter, since subscription follows path. assertTrue(events.any { it.getObject().path() == "score" }) + + client.close() } /** @@ -371,12 +409,14 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19g - subscribe has no side effects`() = runTest { - val (_, channel, root, _) = setupSyncedChannel("test") + val (client, channel, root, _) = setupSyncedChannel("test") val stateBefore = channel.state root.get("score").subscribe(PathObjectListener { }) assertEquals(stateBefore, channel.state) + + client.close() } /** @@ -384,9 +424,9 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19 - subscribe on primitive path receives change events`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.get("name").subscribe(PathObjectListener { events.add(it) }) + root.get("name").subscribe(PathObjectListener { event -> events.add(event) }) mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), @@ -395,6 +435,8 @@ class PathObjectSubscribeTest { assertEquals(1, events.size) assertEquals("name", events[0].getObject().path()) + + client.close() } /** @@ -402,17 +444,18 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19 - MAP_CLEAR triggers subscription events on child paths`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.subscribe(PathObjectListener { events.add(it) }) + root.subscribe(PathObjectListener { event -> events.add(event) }) - // MAP_CLEAR is an object-level op from a fresh site ("remote"), so it is not gated by per-entry LWW. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapClear("root", "99", "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } assertTrue(events.size >= 1) + + client.close() } /** @@ -420,23 +463,23 @@ class PathObjectSubscribeTest { */ @Test fun `RTPO19 - child events bubble up to parent subscription`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - root.get("profile").subscribe(PathObjectListener { events.add(it) }) + root.get("profile").subscribe(PathObjectListener { event -> events.add(event) }) - // Self update at "profile" (map entry change). mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), remoteSerial(0), "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - // Child update (nested counter under profile) bubbles up to the "profile" subscription. mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 3, "100", "remote"))), ) pollUntil(5.seconds) { events.size >= 2 } assertTrue(events.size >= 2) + + client.close() } /** @@ -444,47 +487,58 @@ class PathObjectSubscribeTest { */ @Test fun `RTO24c1 - depth filtering formula`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - // Control listener (root, unlimited depth) — also the barrier for seeding below. - val control = mutableListOf() - root.subscribe(PathObjectListener { control.add(it) }) - - // Seed a grandchild object under profile.prefs (path ["profile","prefs","deep"]) BEFORE subscribing - // the profile listener. "deep" is a NEW key on map:prefs, so this MAP_SET applies regardless of - // serial (RTLM9d). Its own map:prefs update (candidate ["profile","prefs"], covered at depth 2) must - // be dispatched BEFORE the profile listener is registered — dispatch is async, so without this - // barrier the seed races past subscribe() and leaks into `events` as a spurious 3rd event. Await it - // via the control listener. + val (client, _, root, mockWs) = setupSyncedChannel("test") + // Seed a grandchild OBJECT under profile.prefs (path ["profile","prefs","deep"]) so the + // grandchild stimulus below can be a COUNTER_INC yielding ONLY that single depth-3 + // candidate. Sent BEFORE subscribing, so it does not fire the listener under test. + // (RTO6 zero-value-creates counter:deep@3000.) mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("map:prefs@1000", "deep", dataObjectId("counter:deep@3000"), "50", "remote"))), ) - pollUntil(5.seconds) { control.size >= 1 } - + // The mock delivers messages asynchronously, so wait for the seed to be applied before + // subscribing — the spec's precondition is that this send is processed BEFORE the + // listener under test is registered ("Sent BEFORE subscribing, so it does not fire the + // listener under test"). Without this wait the seed's MAP_SET dispatch races the + // subscribe and can fire the depth-2 listener via its covered ["profile","prefs"] + // candidate, producing a spurious third event. + pollUntil(5.seconds) { root.at("profile.prefs.deep").asLiveCounter().value() != null } val events = mutableListOf() - // Subscribe at "profile" depth 2: self and one child level covered; grandchild (depth 3) not. - root.get("profile").subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(2)) + // Subscribe at "profile" with depth 2: + // self (profile) -> eventPath=["profile"], 1 - 1 + 1 = 1 <= 2 yes + // child (profile.nested) -> eventPath=["profile","nested_counter"], 2 - 1 + 1 = 2 <= 2 yes + // grandchild (prefs.deep) -> eventPath=["profile","prefs","deep"], 3 - 1 + 1 = 3 > 2 no + root.get("profile").subscribe(PathObjectListener { event -> events.add(event) }, PathObjectSubscriptionOptions(2)) + // Quiescence control: an unlimited-depth root listener that covers the out-of-scope + // grandchild path, so it fires on the grandchild send below. + val control = mutableListOf() + root.subscribe(PathObjectListener { event -> control.add(event) }) - // Self event (profile map update) — covered. + // Self event (profile map update) — first covered candidate is ["profile"]. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), remoteSerial(0), "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - // Child event (nested counter, relativeDepth 2) — covered. + // Child event (nested counter at ["profile","nested_counter"], relativeDepth 2) — covered. mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 3, "100", "remote"))), ) pollUntil(5.seconds) { events.size >= 2 } - // Grandchild event (counter:deep, relativeDepth 3) — NOT covered; await the control instead. + // Grandchild event (counter:deep at ["profile","prefs","deep"], relativeDepth 3) — should + // NOT be received. A COUNTER_INC yields ONLY this single depth-3 candidate. val controlBefore = control.size mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:deep@3000", 1, "101", "remote"))), ) + // Negative-assertion quiescence: the unlimited-depth control covers + // ["profile","prefs","deep"], so await its delivery, THEN assert the depth-2 listener + // did NOT fire on the grandchild. pollUntil(5.seconds) { control.size > controlBefore } assertEquals(2, events.size) + + client.close() } /** @@ -492,25 +546,30 @@ class PathObjectSubscribeTest { */ @Test fun `RTO24c1 - prefix mismatch does not trigger subscription`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val profileEvents = mutableListOf() - root.get("profile").subscribe(PathObjectListener { profileEvents.add(it) }) - // Control listener at root fires on BOTH out-of-scope sends — the quiescence barrier. + root.get("profile").subscribe(PathObjectListener { event -> profileEvents.add(event) }) + // Control listener at root: fires on both out-of-scope sends below, providing a delivery + // to await on the same dispatch before asserting profileEvents is unchanged. val controlEvents = mutableListOf() - root.subscribe(PathObjectListener { controlEvents.add(it) }) + root.subscribe(PathObjectListener { event -> controlEvents.add(event) }) - // Change at "score" — "profile" is not a prefix of "score" (counter_inc from fresh site applies). + // Change at "score" — "profile" is not a prefix of "score". mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), ) + // Change at "name" — "profile" is not a prefix of "name". mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), ) - // Await the control (fires for both sends), so any profile callback would also have run by then. + // QUIESCENCE: await the control listener (fires for both sends) so that any + // profileEvents callback would also have run before we assert it is unchanged. pollUntil(5.seconds) { controlEvents.size >= 2 } assertEquals(0, profileEvents.size) + + client.close() } /** @@ -518,14 +577,18 @@ class PathObjectSubscribeTest { */ @Test fun `RTO24b2a - candidate path construction includes map update keys`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val scoreEvents = mutableListOf() val rootEvents = mutableListOf() - // Child path "score" ([] + key "score") and the root path [] itself. - root.get("score").subscribe(PathObjectListener { scoreEvents.add(it) }) - root.subscribe(PathObjectListener { rootEvents.add(it) }) - - // MAP_SET on root with key "score" — candidates are [] (root) and ["score"] (from the update key). + // Subscribe at the child path "score" (pathToThis=[""] + key "score" = ["score"]). + root.get("score").subscribe(PathObjectListener { event -> scoreEvents.add(event) }) + // Subscribe at root path (pathToThis=[""]). + root.subscribe(PathObjectListener { event -> rootEvents.add(event) }) + + // MAP_SET on root with key "score" — generates candidates: + // 1. pathToThis = [] (root itself) + // 2. [] + "score" = ["score"] (from the map update key) + // Both subscriptions should fire. mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), remoteSerial(0), "remote"))), ) @@ -535,6 +598,8 @@ class PathObjectSubscribeTest { assertEquals(1, scoreEvents.size) assertEquals("score", scoreEvents[0].getObject().path()) assertEquals(1, rootEvents.size) + + client.close() } /** @@ -542,11 +607,10 @@ class PathObjectSubscribeTest { */ @Test fun `RTO24b2c - listener exception does not affect other listeners`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - // First listener throws; its exception must be caught and not stop the second listener. root.subscribe(PathObjectListener { throw RuntimeException("boom") }) - root.subscribe(PathObjectListener { events.add(it) }) + root.subscribe(PathObjectListener { event -> events.add(event) }) mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), @@ -554,6 +618,8 @@ class PathObjectSubscribeTest { pollUntil(5.seconds) { events.size >= 1 } assertEquals(1, events.size) + + client.close() } /** @@ -561,23 +627,20 @@ class PathObjectSubscribeTest { */ @Test fun `RTO24b1 - dispatch via getFullPaths for multi-path objects`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val eventsScore = mutableListOf() val eventsAlias = mutableListOf() - // "alias" is a NEW key (no existing entry), so this MAP_SET applies regardless of serial (RTLM9d). + // "score" already points to counter:score@1000. + // Add a second reference "alias" -> counter:score@1000 so it has two paths. mockWs.sendToClient( - buildObjectMessage( - "test", - listOf(buildMapSet("root", "alias", dataObjectId("counter:score@1000"), "98", "remote")), - ), + buildObjectMessage("test", listOf(buildMapSet("root", "alias", dataObjectId("counter:score@1000"), "98", "remote"))), ) - // Wait for the alias reference to resolve before subscribing. - pollUntil(5.seconds) { root.get("alias").asLiveCounter().value() == 100.0 } - root.get("score").subscribe(PathObjectListener { eventsScore.add(it) }) - root.get("alias").subscribe(PathObjectListener { eventsAlias.add(it) }) + root.get("score").subscribe(PathObjectListener { event -> eventsScore.add(event) }) + root.get("alias").subscribe(PathObjectListener { event -> eventsAlias.add(event) }) + // Increment counter:score@1000 — getFullPaths returns ["score"] and ["alias"]. mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "99", "remote"))), ) @@ -588,6 +651,8 @@ class PathObjectSubscribeTest { assertEquals("score", eventsScore[0].getObject().path()) assertEquals(1, eventsAlias.size) assertEquals("alias", eventsAlias[0].getObject().path()) + + client.close() } /** @@ -595,24 +660,31 @@ class PathObjectSubscribeTest { */ @Test fun `RTO24b2b - subscription fires exactly once per dispatch`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") + val (client, _, root, mockWs) = setupSyncedChannel("test") val events = mutableListOf() - // Root (unlimited depth) covers both candidate paths [] and ["score"]. - root.subscribe(PathObjectListener { events.add(it) }) + // Subscribe at root (unlimited depth) — covers both [] and ["score"]. + root.subscribe(PathObjectListener { event -> events.add(event) }) - // MAP_SET on root with key "score" — candidates [] and ["score"], both covered, but fires ONCE. + // MAP_SET on root with key "score" — candidates are [] and ["score"]. Root subscription + // covers both, but should fire exactly once with the first candidate (pathToThis = []). mockWs.sendToClient( buildObjectMessage("test", listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), remoteSerial(0), "remote"))), ) pollUntil(5.seconds) { events.size >= 1 } - // Quiescence: a second single-candidate dispatch. Awaiting it proves the first (multi-candidate) - // dispatch fired exactly once — otherwise events would already exceed 2. + // QUIESCENCE: a second, single-candidate dispatch acts as the control delivery. Awaiting + // it guarantees any spurious second callback from the first (multi-candidate) dispatch + // would already have run, so events.size == 2 confirms the first dispatch fired exactly + // once. mockWs.sendToClient( buildObjectMessage("test", listOf(buildCounterInc("counter:new@2000", 1, "100", "remote"))), ) pollUntil(5.seconds) { events.size >= 2 } + // Exactly one event per dispatch, even though multiple candidates match: + // one from the multi-candidate MAP_SET + one from the control increment. assertEquals(2, events.size) + + client.close() } } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectTest.kt new file mode 100644 index 000000000..63ade05b0 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PathObjectTest.kt @@ -0,0 +1,515 @@ +package io.ably.lib.liveobjects.uts.unit + +import com.google.gson.JsonParser +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS spec `objects/unit/path_object.md` — `PathObject` read operations + * (`RTPO1`–`RTPO14`): path string representation and dot-escaping, navigation (`get`/`at`), + * value/instance/entries/keys/values/size reads, compaction, and path-resolution failure + * behaviour. + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt`. Typed-SDK + * notes (`objects-mapping.md` §4): the dynamic `value()` splits into per-type `as*().value()` + * reads (never-throwing casts, RTTS5d); `compact()` is not implemented — `compactJson()` is + * the supported equivalent (RTTS3f) — see the `// DEVIATION` comments and `deviations.md`. + */ +class PathObjectTest { + + /** + * @UTS objects/unit/RTPO4/path-string-representation-0 + */ + @Test + fun `RTPO4 - path returns dot-delimited string`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals("", root.path()) + assertEquals("profile", root.get("profile").path()) + assertEquals("profile.email", root.get("profile").asLiveMap().get("email").path()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO4b/path-escapes-dots-0 + */ + @Test + fun `RTPO4b - path escapes dots in segments`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val po = root.get("a.b").asLiveMap().get("c") + + // The segment "a.b" contains a literal dot, escaped as `a\.b` in the path string. + assertEquals("a\\.b.c", po.path()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO5/get-appends-key-0 + */ + @Test + fun `RTPO5 - get returns new PathObject with appended key`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val child = root.get("profile") + val grandchild = child.asLiveMap().get("email") + + assertEquals("profile", child.path()) + assertEquals("profile.email", grandchild.path()) + assertNotSame(root, child) + + client.close() + } + + /** + * @UTS objects/unit/RTPO5b/get-non-string-throws-0 + */ + @Test + fun `RTPO5b - get throws on non-string key`() { + // DEVIATION (see deviations.md): `get` takes a `@NotNull String` key, so the spec's + // `root.get(123)` is rejected at compile time — the invalid-input contract (ErrorInfo + // 40003) for non-String keys is enforced by the type system and cannot be exercised at + // runtime. + } + + /** + * @UTS objects/unit/RTPO6/at-parses-path-0 + */ + @Test + fun `RTPO6 - at parses dot-delimited path`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val po = root.at("profile.email") + + assertEquals("profile.email", po.path()) + assertEquals("alice@example.com", po.asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO6/at-escaped-dots-0 + */ + @Test + fun `RTPO6 - at respects escaped dots`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + // `a\.b.c` parses to segments ["a.b", "c"] — the escaped dot is a literal. + val po = root.at("a\\.b.c") + + assertEquals("a\\.b.c", po.path()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO7/value-counter-0 + */ + @Test + fun `RTPO7 - value returns counter numeric value`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals(100.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO7/value-primitive-0 + */ + @Test + fun `RTPO7 - value returns primitive value`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals("Alice", root.get("name").asString().value()) + assertEquals(30.0, root.get("age").asNumber().value()?.toDouble()) + assertEquals(true, root.get("active").asBoolean().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO7d/value-livemap-null-0 + */ + @Test + fun `RTPO7d - value returns null for InternalLiveMap`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + // The dynamic value() returns null for a LiveMap; in the typed SDK the equivalent read + // is the counter-typed value() on the never-throwing cast (RTTS5d/RTTS6g). + assertNull(root.get("profile").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO7e/value-unresolvable-null-0 + */ + @Test + fun `RTPO7e - value returns null on resolution failure`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("nonexistent").asLiveMap().get("deep").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO8/instance-live-object-0 + */ + @Test + fun `RTPO8 - instance returns Instance for LiveObject`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val counterInst = root.get("score").instance() + assertIs(counterInst) + assertEquals("counter:score@1000", counterInst.asLiveCounter().id) + + val mapInst = root.get("profile").instance() + assertIs(mapInst) + assertEquals("map:profile@1000", mapInst.asLiveMap().id) + + client.close() + } + + /** + * @UTS objects/unit/RTPO8f/instance-primitive-wrapped-0 + */ + @Test + fun `RTPO8f - instance returns Instance for primitive`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val nameInst = root.get("name").instance() + assertIs(nameInst) + // DEVIATION (typed-SDK partition, see deviations.md): primitive Instance sub-types + // expose no id member at all (RTINS3b / RTTS7c), so `name_inst.id() == null` is not + // expressible; assert the wrapped type instead — a STRING instance carries no id by + // construction. + assertEquals(ValueType.STRING, nameInst.type) + assertEquals("Alice", nameInst.asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO9/entries-yields-pairs-0 + */ + @Test + fun `RTPO9 - entries returns array of key PathObject pairs`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val entries = mutableMapOf() + for ((key, pathObj) in root.entries()) { + entries[key] = pathObj.path() + } + + assertEquals("name", entries["name"]) + assertEquals("profile", entries["profile"]) + assertEquals(7, entries.size) + + client.close() + } + + /** + * @UTS objects/unit/RTPO9d/entries-non-map-empty-0 + */ + @Test + fun `RTPO9d - entries returns empty array for non-map`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val entries = root.get("score").asLiveMap().entries() + + assertEquals(0, entries.count()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO10/keys-returns-array-0 + */ + @Test + fun `RTPO10 - keys returns array of key strings`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val keys = root.keys().toList() + + // `keys IS Array`: the Iterable materialises to a list. + assertIs>(keys) + assertEquals(7, keys.size) + assertContains(keys, "name") + assertContains(keys, "profile") + assertContains(keys, "score") + + client.close() + } + + /** + * @UTS objects/unit/RTPO10d/keys-non-map-empty-0 + */ + @Test + fun `RTPO10d - keys returns empty array for non-map`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val keys = root.get("score").asLiveMap().keys().toList() + + assertIs>(keys) + assertEquals(0, keys.size) + + client.close() + } + + /** + * @UTS objects/unit/RTPO11/values-returns-array-0 + */ + @Test + fun `RTPO11 - values returns array of PathObjects`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val vals = root.values().toList() + + assertIs>(vals) + assertEquals(7, vals.size) + // Each element is a PathObject whose path is the key. + val paths = vals.map { it.path() }.toSet() + assertContains(paths, "name") + assertContains(paths, "profile") + assertContains(paths, "score") + + client.close() + } + + /** + * @UTS objects/unit/RTPO11d/values-non-map-empty-0 + */ + @Test + fun `RTPO11d - values returns empty array for non-map`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val vals = root.get("score").asLiveMap().values().toList() + + assertIs>(vals) + assertEquals(0, vals.size) + + client.close() + } + + /** + * @UTS objects/unit/RTPO12/size-count-0 + */ + @Test + fun `RTPO12 - size returns non-tombstoned count`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertEquals(7L, root.size()) + assertEquals(3L, root.get("profile").asLiveMap().size()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO12c/size-non-map-null-0 + */ + @Test + fun `RTPO12c - size returns null for non-map`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("score").asLiveMap().size()) + assertNull(root.get("name").asLiveMap().size()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO13/compact-recursive-0 + */ + @Test + fun `RTPO13 - compact recursively compacts InternalLiveMap tree`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + // DEVIATION (RTTS3f, see deviations.md): compact() is not implemented in ably-java; + // compactJson() is the typed-SDK equivalent. Binary values come back base64-encoded + // (RTPO14b1) instead of raw bytes. + val result = assertNotNull(root.compactJson()).asJsonObject + + assertEquals("Alice", result.get("name").asString) + assertEquals(30.0, result.get("age").asDouble) + assertEquals(true, result.get("active").asBoolean) + assertEquals(100.0, result.get("score").asDouble) + assertEquals(JsonParser.parseString("""{"tags": ["a", "b"]}"""), result.get("data")) + // spec: result["avatar"] IS bytes [1, 2, 3] — compactJson base64-encodes binary values. + assertEquals("AQID", result.get("avatar").asString) + val profile = result.getAsJsonObject("profile") + assertEquals("alice@example.com", profile.get("email").asString) + assertEquals(5.0, profile.get("nested_counter").asDouble) + assertEquals("dark", profile.getAsJsonObject("prefs").get("theme").asString) + + client.close() + } + + /** + * @UTS objects/unit/RTPO13c5/compact-cycle-detection-0 + */ + @Test + fun `RTPO13c5 - compact handles cycles via shared reference`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")), + ), + ) + // Mock delivery is applied asynchronously in ably-java — await the applied op before + // reading (does not weaken any assertion). + pollUntil(5.seconds) { root.at("profile.prefs.back_ref").exists() } + + // DEVIATION (RTTS3f, see deviations.md): compact()'s in-memory identity reuse + // (`result["prefs"]["back_ref"] IS result`) is not expressible over compactJson(); + // the cyclic reference is represented as an { "objectId": ... } marker per RTPO14b2. + val result = assertNotNull(root.get("profile").compactJson()).asJsonObject + + assertEquals( + JsonParser.parseString("""{"objectId": "map:profile@1000"}"""), + result.getAsJsonObject("prefs").get("back_ref"), + ) + + client.close() + } + + /** + * @UTS objects/unit/RTPO13c/compact-counter-0 + */ + @Test + fun `RTPO13c - compact returns number for InternalLiveCounter`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + // DEVIATION (RTTS3f, see deviations.md): compact() -> compactJson(); a counter + // compacts to its numeric value either way. + assertEquals(100.0, assertNotNull(root.get("score").compactJson()).asDouble) + + client.close() + } + + /** + * @UTS objects/unit/RTPO14/compact-json-0 + */ + @Test + fun `RTPO14 - compactJson encodes binary as base64 and cycles as objectId`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")), + ), + ) + // Mock delivery is applied asynchronously in ably-java — await the applied op before + // reading (does not weaken any assertion). + pollUntil(5.seconds) { root.at("profile.prefs.back_ref").exists() } + + val result = assertNotNull(root.get("profile").compactJson()).asJsonObject + + assertEquals( + JsonParser.parseString("""{"objectId": "map:profile@1000"}"""), + result.getAsJsonObject("prefs").get("back_ref"), + ) + + client.close() + } + + /** + * @UTS objects/unit/RTPO3/path-resolution-walk-0 + */ + @Test + fun `RTPO3 - path resolution walks through InternalLiveMaps`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + // The empty path resolves to the root LiveMap, whose dynamic value() is null; typed + // equivalent per RTTS6g — the counter-typed read on the never-throwing cast. + assertNull(root.asLiveCounter().value()) + assertEquals( + "dark", + root.get("profile").asLiveMap().get("prefs").asLiveMap().get("theme").asString().value(), + ) + + client.close() + } + + /** + * @UTS objects/unit/RTPO3a1/intermediate-not-map-0 + */ + @Test + fun `RTPO3a1 - resolution fails if intermediate is not InternalLiveMap`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("score").asLiveMap().get("something").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO3c1/read-null-on-failure-0 + */ + @Test + fun `RTPO3c1 - read operation returns null on resolution failure`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("nonexistent").asString().value()) + assertNull(root.get("nonexistent").instance()) + assertNull(root.get("nonexistent").asLiveMap().size()) + // spec: compact() == null — compactJson() is the typed-SDK equivalent (RTTS3f). + assertNull(root.get("nonexistent").compactJson()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO6b/at-non-string-throws-0 + */ + @Test + fun `RTPO6b - at throws for non-string input`() { + // DEVIATION (see deviations.md): `at` takes a `@NotNull String` path, so the spec's + // `root.at(123)` is rejected at compile time — the invalid-input contract (ErrorInfo + // 40003) for non-String paths is enforced by the type system and cannot be exercised at + // runtime. + } + + /** + * @UTS objects/unit/RTPO7/value-bytes-0 + */ + @Test + fun `RTPO7 - value returns bytes for binary entry`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertContentEquals(byteArrayOf(1, 2, 3), root.get("avatar").asBinary().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTPO14/compact-json-bytes-0 + */ + @Test + fun `RTPO14 - compactJson encodes bytes as base64 string`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + val result = assertNotNull(root.compactJson()).asJsonObject + + assertEquals("AQID", result.get("avatar").asString) + + client.close() + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PublicObjectMessageTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PublicObjectMessageTest.kt new file mode 100644 index 000000000..ec8922a53 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/PublicObjectMessageTest.kt @@ -0,0 +1,378 @@ +package io.ably.lib.liveobjects.uts.unit + +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.message.ObjectOperation +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.liveobjects.message.ObjectsMapSemantics +import io.ably.lib.liveobjects.message.WireCounterCreate +import io.ably.lib.liveobjects.message.WireCounterCreateWithObjectId +import io.ably.lib.liveobjects.message.WireCounterInc +import io.ably.lib.liveobjects.message.WireMapClear +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId +import io.ably.lib.liveobjects.message.WireMapRemove +import io.ably.lib.liveobjects.message.WireMapSet +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectDelete +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperation +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectsMapEntry +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Derived from UTS spec `objects/unit/public_object_message.md` — construction of + * `PublicAPI::ObjectMessage` from an internal ObjectMessage (`PAOM1`–`PAOM3`) and of + * `PublicAPI::ObjectOperation` from an internal ObjectOperation (`PAOOP1`–`PAOOP3`). + * + * Pure data-structure construction, no mocks. The spec's + * `PublicObjectMessage.fromObjectMessage(source, channel)` maps to the module-local + * `buildPublicObjectMessage(wireMessage, channelName)` helper (`objects-mapping.md` §11/§13); + * the source ObjectMessage / ObjectOperation are the typed `Wire*` constructions (§17.10). + */ +class PublicObjectMessageTest { + + /** + * The spec's `PublicObjectOperation.fromObjectOperation(source_operation)`: ably-java has no + * standalone public-operation factory — the PublicAPI::ObjectOperation is derived from the + * enclosing message per PAOM3d (which performs the PAOOP3 construction), so operation-only + * tests wrap the source operation in a minimal ObjectMessage and read `operation` back. + */ + private fun publicOperationFrom(sourceOperation: WireObjectOperation): ObjectOperation = + buildPublicObjectMessage(WireObjectMessage(operation = sourceOperation), "test").operation + + /** + * @UTS objects/unit/PAOM3/construction-all-fields-0 + */ + @Test + fun `PAOM3 - construction copies all fields from source ObjectMessage`() { + val extras = JsonObject().apply { addProperty("key", "value") } + val source = WireObjectMessage( + id = "msg-id-1", + clientId = "client-1", + connectionId = "conn-1", + timestamp = 1_700_000_000_000L, + serial = "01", + serialTimestamp = 1_700_000_001_000L, + siteCode = "site1", + extras = extras, + operation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = "map:abc@1000", + mapSet = WireMapSet(key = "name", value = WireObjectData(string = "Alice")), + ), + ) + + val publicMsg = buildPublicObjectMessage(source, "test-channel") + + assertEquals("msg-id-1", publicMsg.id) + assertEquals("client-1", publicMsg.clientId) + assertEquals("conn-1", publicMsg.connectionId) + assertEquals(1_700_000_000_000L, publicMsg.timestamp) + assertEquals("test-channel", publicMsg.channel) + assertEquals("01", publicMsg.serial) + assertEquals(1_700_000_001_000L, publicMsg.serialTimestamp) + assertEquals("site1", publicMsg.siteCode) + assertEquals(extras, publicMsg.extras) + val operation = assertNotNull(publicMsg.operation) + assertEquals(ObjectOperationAction.MAP_SET, operation.action) + assertEquals("map:abc@1000", operation.objectId) + assertEquals("name", assertNotNull(operation.mapSet).key) + } + + /** + * @UTS objects/unit/PAOM3/construction-optional-fields-missing-0 + */ + @Test + fun `PAOM3 - construction with optional fields missing`() { + val source = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, + objectId = "counter:abc@1000", + counterInc = WireCounterInc(number = 5.0), + ), + ) + + val publicMsg = buildPublicObjectMessage(source, "my-channel") + + assertNull(publicMsg.id) + assertNull(publicMsg.clientId) + assertNull(publicMsg.connectionId) + assertNull(publicMsg.timestamp) + assertEquals("my-channel", publicMsg.channel) + assertNull(publicMsg.serial) + assertNull(publicMsg.serialTimestamp) + assertNull(publicMsg.siteCode) + assertNull(publicMsg.extras) + val operation = assertNotNull(publicMsg.operation) + assertEquals(ObjectOperationAction.COUNTER_INC, operation.action) + } + + /** + * @UTS objects/unit/PAOM3/channel-from-channel-name-0 + */ + @Test + fun `PAOM3b - channel is set from channel name not from ObjectMessage`() { + val source = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.ObjectDelete, + objectId = "counter:abc@1000", + ), + ) + + val publicMsg = buildPublicObjectMessage(source, "different-channel-name") + + assertEquals("different-channel-name", publicMsg.channel) + } + + /** + * @UTS objects/unit/PAOOP3/map-set-copies-fields-0 + */ + @Test + fun `PAOOP3a - MAP_SET operation copies mapSet omits unrelated fields`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = "map:abc@1000", + mapSet = WireMapSet(key = "color", value = WireObjectData(string = "blue")), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.MAP_SET, publicOp.action) + assertEquals("map:abc@1000", publicOp.objectId) + assertEquals("color", assertNotNull(publicOp.mapSet).key) + assertEquals("blue", assertNotNull(publicOp.mapSet).value.string) + assertNull(publicOp.mapCreate) + assertNull(publicOp.mapRemove) + assertNull(publicOp.counterCreate) + assertNull(publicOp.counterInc) + assertNull(publicOp.objectDelete) + assertNull(publicOp.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/map-remove-copies-fields-0 + */ + @Test + fun `PAOOP3a - MAP_REMOVE operation copies mapRemove omits unrelated fields`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.MapRemove, + objectId = "map:abc@1000", + mapRemove = WireMapRemove(key = "old-key"), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.MAP_REMOVE, publicOp.action) + assertEquals("map:abc@1000", publicOp.objectId) + assertEquals("old-key", assertNotNull(publicOp.mapRemove).key) + assertNull(publicOp.mapCreate) + assertNull(publicOp.mapSet) + assertNull(publicOp.counterCreate) + assertNull(publicOp.counterInc) + assertNull(publicOp.objectDelete) + assertNull(publicOp.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/counter-inc-copies-fields-0 + */ + @Test + fun `PAOOP3a - COUNTER_INC operation copies counterInc omits unrelated fields`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.CounterInc, + objectId = "counter:abc@1000", + counterInc = WireCounterInc(number = 42.0), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.COUNTER_INC, publicOp.action) + assertEquals("counter:abc@1000", publicOp.objectId) + assertEquals(42.0, assertNotNull(publicOp.counterInc).number) + assertNull(publicOp.mapCreate) + assertNull(publicOp.mapSet) + assertNull(publicOp.mapRemove) + assertNull(publicOp.counterCreate) + assertNull(publicOp.objectDelete) + assertNull(publicOp.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/object-delete-copies-fields-0 + */ + @Test + fun `PAOOP3a - OBJECT_DELETE operation copies objectDelete omits unrelated fields`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.ObjectDelete, + objectId = "counter:abc@1000", + objectDelete = WireObjectDelete, + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.OBJECT_DELETE, publicOp.action) + assertEquals("counter:abc@1000", publicOp.objectId) + assertNotNull(publicOp.objectDelete) + assertNull(publicOp.mapCreate) + assertNull(publicOp.mapSet) + assertNull(publicOp.mapRemove) + assertNull(publicOp.counterCreate) + assertNull(publicOp.counterInc) + assertNull(publicOp.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/map-clear-copies-fields-0 + */ + @Test + fun `PAOOP3a - MAP_CLEAR operation copies mapClear omits unrelated fields`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.MapClear, + objectId = "map:abc@1000", + mapClear = WireMapClear, + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.MAP_CLEAR, publicOp.action) + assertEquals("map:abc@1000", publicOp.objectId) + assertNotNull(publicOp.mapClear) + assertNull(publicOp.mapCreate) + assertNull(publicOp.mapSet) + assertNull(publicOp.mapRemove) + assertNull(publicOp.counterCreate) + assertNull(publicOp.counterInc) + assertNull(publicOp.objectDelete) + } + + /** + * @UTS objects/unit/PAOOP3/map-create-direct-0 + */ + @Test + fun `PAOOP3b1 - MAP_CREATE with mapCreate directly present`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, + objectId = "map:new@2000", + mapCreate = WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, + entries = mapOf("key1" to WireObjectsMapEntry(data = WireObjectData(string = "val1"))), + ), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.MAP_CREATE, publicOp.action) + assertEquals("map:new@2000", publicOp.objectId) + val mapCreate = assertNotNull(publicOp.mapCreate) + assertEquals(ObjectsMapSemantics.LWW, mapCreate.semantics) + assertEquals("val1", assertNotNull(mapCreate.entries["key1"]?.data).string) + assertNull(publicOp.counterCreate) + } + + /** + * @UTS objects/unit/PAOOP3/map-create-from-with-object-id-0 + */ + @Test + fun `PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId`() { + val derivedMapCreate = WireMapCreate( + semantics = WireObjectsMapSemantics.LWW, + entries = mapOf("x" to WireObjectsMapEntry(data = WireObjectData(number = 10.0))), + ) + // NOTE: the spec's pseudo mapCreateWithObjectId lists { objectId, semantics, entries, + // derivedFrom }; the ably-java wire type (MCRO2) carries { initialValue, nonce } plus the + // local-only retained MapCreate as `derivedFrom` (RTLMV4j5) — the semantics/entries live on + // derivedFrom, and objectId lives on the operation. + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, + objectId = "map:derived@3000", + mapCreateWithObjectId = WireMapCreateWithObjectId( + initialValue = """{"semantics":0,"entries":{"x":{"data":{"number":10}}}}""", + nonce = "nonce-0123456789ab", + derivedFrom = derivedMapCreate, + ), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.MAP_CREATE, publicOp.action) + assertEquals("map:derived@3000", publicOp.objectId) + val mapCreate = assertNotNull(publicOp.mapCreate) + assertEquals(ObjectsMapSemantics.LWW, mapCreate.semantics) + assertEquals(10.0, assertNotNull(mapCreate.entries["x"]?.data).number) + assertNull(publicOp.counterCreate) + } + + /** + * @UTS objects/unit/PAOOP3/counter-create-from-with-object-id-0 + */ + @Test + fun `PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId`() { + val derivedCounterCreate = WireCounterCreate(count = 100.0) + // NOTE: as for PAOOP3b2 above — the wire CounterCreateWithObjectId (CCRO2) carries + // { initialValue, nonce, derivedFrom }; count lives on the retained derivedFrom. + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.CounterCreate, + objectId = "counter:derived@3000", + counterCreateWithObjectId = WireCounterCreateWithObjectId( + initialValue = """{"count":100}""", + nonce = "nonce-0123456789ab", + derivedFrom = derivedCounterCreate, + ), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.COUNTER_CREATE, publicOp.action) + assertEquals("counter:derived@3000", publicOp.objectId) + val counterCreate = assertNotNull(publicOp.counterCreate) + assertEquals(100.0, counterCreate.count) + assertNull(publicOp.mapCreate) + } + + /** + * @UTS objects/unit/PAOOP3/create-payloads-omitted-0 + */ + @Test + fun `PAOOP3b3 PAOOP3c3 - create payloads omitted when neither variant is present`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.MapSet, + objectId = "map:abc@1000", + mapSet = WireMapSet(key = "k", value = WireObjectData(string = "v")), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertNull(publicOp.mapCreate) + assertNull(publicOp.counterCreate) + } + + /** + * @UTS objects/unit/PAOOP3/only-relevant-field-per-action-0 + */ + @Test + fun `PAOOP3 - only the relevant operation field is present per action type`() { + val sourceOperation = WireObjectOperation( + action = WireObjectOperationAction.CounterCreate, + objectId = "counter:new@2000", + counterCreate = WireCounterCreate(count = 50.0), + ) + + val publicOp = publicOperationFrom(sourceOperation) + + assertEquals(ObjectOperationAction.COUNTER_CREATE, publicOp.action) + assertEquals("counter:new@2000", publicOp.objectId) + val counterCreate = assertNotNull(publicOp.counterCreate) + assertEquals(50.0, counterCreate.count) + assertNull(publicOp.mapCreate) + assertNull(publicOp.mapSet) + assertNull(publicOp.mapRemove) + assertNull(publicOp.counterInc) + assertNull(publicOp.objectDelete) + assertNull(publicOp.mapClear) + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/RealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/RealtimeObjectTest.kt new file mode 100644 index 000000000..cfad217db --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/RealtimeObjectTest.kt @@ -0,0 +1,1214 @@ +package io.ably.lib.liveobjects.uts.unit + +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.ROOT_OBJECT_ID +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.liveobjects.path.PathObjectListener +import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent +import io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.state.ObjectStateChange +import io.ably.lib.liveobjects.state.ObjectStateEvent +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.liveobjects.value.livecounter.InternalLiveCounter +import io.ably.lib.liveobjects.value.livemap.InternalLiveMap +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.PublishResult +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.ConnectionDetails +import io.ably.lib.uts.infra.unit.FakeClock +import io.ably.lib.uts.infra.unit.MockEvent +import io.ably.lib.uts.infra.unit.MockWebSocket +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS spec `objects/unit/realtime_object.md` — RealtimeObject behaviour + * (`RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO27`): `get()` semantics, publish / + * publishAndApply (driven through public mutations), sync-state events, access/write API + * preconditions, the shared subscription register, tombstone GC, and the objects data + * lifecycle on channel state transitions. + * + * Public-tier spec: uses only the public API plus the module-local `Helpers.kt` + * (wire-level RTO15 assertions read the typed `Wire*` state off the captured OBJECT + * ProtocolMessages, per the `captured_messages` mapping in `objects-mapping.md` §13). + */ +class RealtimeObjectTest { + + // ------------------------------------------------------------------ + // Per-test mock construction (the spec's inline MockWebSocket setups) + // ------------------------------------------------------------------ + + /** + * The spec's CONNECTED response: `ConnectionDetails { connectionId: "conn-1", connectionKey: + * "key-1", siteCode: "test-site", objectsGCGracePeriod: ... }`. `maxMessageSize` is set for + * the same reason as in `Helpers.kt` (an unset value of 0 makes RTO15d reject every OBJECT + * publish); `maxIdleInterval` keeps the fake-timer tests' 24h virtual advance below the + * transport idle timeout. + */ + private fun connectedMessage( + includeSiteCode: Boolean = true, + gcGracePeriod: Long = 86_400_000L, + ): ProtocolMessage = ProtocolMessage(ProtocolMessage.Action.connected).apply { + connectionId = "conn-1" + connectionDetails = ConnectionDetails { + connectionKey = "key-1" + if (includeSiteCode) siteCode = SITE_CODE + objectsGCGracePeriod = gcGracePeriod + maxMessageSize = 65_536 + maxIdleInterval = 864_000_000L + } + } + + private fun attachedMessage( + channelName: String?, + channelSerial: String?, + hasObjects: Boolean = true, + modeFlags: List = emptyList(), + ): ProtocolMessage = ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = channelName + this.channelSerial = channelSerial + if (hasObjects) setFlag(ProtocolMessage.Flag.has_objects) + modeFlags.forEach { setFlag(it) } + } + + /** + * A per-test MockWebSocket mirroring the spec's inline mocks: CONNECTED on connect, [onAttach] + * for ATTACH, optional [onObject] for OBJECT publishes and — like the shared mock in + * `helpers/standard_test_pool.md` — an outbound DETACH is answered with DETACHED. + */ + private fun buildMockWebSocket( + connected: ProtocolMessage, + onAttach: (MockWebSocket, ProtocolMessage) -> Unit, + onObject: ((MockWebSocket, ProtocolMessage) -> Unit)? = null, + ): MockWebSocket { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { conn -> conn.respondWithSuccess(connected) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> onAttach(mockWs, msg) + ProtocolMessage.Action.`object` -> onObject?.invoke(mockWs, msg) + ProtocolMessage.Action.detach -> mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { this.channel = msg.channel }, + ) + else -> Unit + } + } + } + return mockWs + } + + /** ATTACH → ATTACHED (+ optional granted-mode flags) followed by the standard-pool sync. */ + private fun attachedWithSync( + channelSerial: String = "sync1:", + modeFlags: List = emptyList(), + ): (MockWebSocket, ProtocolMessage) -> Unit = { mockWs, msg -> + mockWs.sendToClient(attachedMessage(msg.channel, channelSerial, modeFlags = modeFlags)) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel!!, channelSerial, STANDARD_POOL_OBJECTS)) + } + + /** The shared harness's OBJECT auto-ACK: one `ack_serial(msgSerial, i)` per state entry. */ + private val ackPerState: (MockWebSocket, ProtocolMessage) -> Unit = { mockWs, msg -> + val serials = (msg.state?.indices ?: IntRange.EMPTY).map { ackSerial(msg.msgSerial, it) } + mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) + } + + private fun newClient( + mockWs: MockWebSocket, + echo: Boolean = true, + fakeClock: FakeClock? = null, + ): AblyRealtime = TestRealtimeClient { + key = "fake:key" + echoMessages = echo + install(mockWs) + fakeClock?.let { enableFakeTimers(it) } + } + + private fun AblyRealtime.objectsChannel( + name: String, + modes: Array = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish), + ): Channel = channels.get(name, ChannelOptions().apply { this.modes = modes }) + + /** + * The spec's client-side detach against the shared synced-channel harness: the + * `setup_synced_channel` mock (`Helpers.kt`, matching `helpers/standard_test_pool.md`) + * answers the outbound DETACH with DETACHED, so this just detaches and awaits the state. + */ + private suspend fun detachClientSide(channel: Channel) { + channel.detach() + awaitChannelState(channel, ChannelState.detached) + } + + /** + * Awaits the SYNCING transition after injecting an ATTACHED — the mock delivers messages + * asynchronously, so the spec's implied ordering (the ATTACHED is processed before the next + * step) is enforced via the public sync-state event. + */ + private suspend fun sendAttachedAndAwaitSyncing(channel: Channel, mockWs: MockWebSocket, channelSerial: String) { + val syncing = AtomicInteger(0) + val sub = channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { syncing.incrementAndGet() }) + mockWs.sendToClient(attachedMessage(channel.name, channelSerial)) + pollUntil(5.seconds) { syncing.get() >= 1 } + sub.unsubscribe() + } + + /** The fake-timers variant of the spec's `{ client, channel, root, mock_ws }` tuple. */ + private data class FakeTimersChannel( + val client: AblyRealtime, + val channel: Channel, + val root: LiveMapPathObject, + val mockWs: MockWebSocket, + val fakeClock: FakeClock, + ) + + /** + * The spec's `enable_fake_timers()` + `setup_synced_channel(...)`: the module `Helpers.kt` + * setup has no fake-timer hook, so an equivalent local harness (same CONNECTED / ATTACH / + * OBJECT behaviour as the shared mock) installs the [FakeClock] on the client. + */ + private suspend fun setupSyncedChannelWithFakeTimers( + channelName: String, + gcGracePeriod: Long = 86_400_000L, + ): FakeTimersChannel { + val fakeClock = FakeClock() + val mockWs = buildMockWebSocket( + connected = connectedMessage(gcGracePeriod = gcGracePeriod), + onAttach = attachedWithSync(), + onObject = ackPerState, + ) + val client = newClient(mockWs, fakeClock = fakeClock) + val channel = client.objectsChannel(channelName) + val root = channel.`object`.get().await() + return FakeTimersChannel(client, channel, root, mockWs, fakeClock) + } + + // ------------------------------------------------------------------ + // Tests + // ------------------------------------------------------------------ + + /** + * @UTS objects/unit/RTO23/get-returns-path-object-0 + */ + @Test + fun `RTO23 - get returns PathObject wrapping root`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + assertIs(root) + assertEquals("", root.path()) // the spec's `root.path == []` — the root path is empty + + client.close() + } + + /** + * @UTS objects/unit/RTO23a/get-requires-subscribe-mode-0 + */ + @Test + fun `RTO23a - get requires OBJECT_SUBSCRIBE mode`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = { mock, msg -> mock.sendToClient(attachedMessage(msg.channel, "sync1:")) }, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test", modes = arrayOf(ChannelMode.object_publish)) + + val error = assertFailsWith { channel.`object`.get().await() } + + assertEquals(40024, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTO23e/get-reattaches-detached-0 + */ + @Test + fun `RTO23e - get re-attaches a DETACHED channel`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(), + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test", modes = arrayOf(ChannelMode.object_subscribe)) + + // Attach and sync first, then detach. + channel.`object`.get().await() + channel.detach() + awaitChannelState(channel, ChannelState.detached) + + // get() on a DETACHED channel triggers ensure-active-channel (RTL33b) -> implicit + // re-attach -> resolves. + val root = channel.`object`.get().await() + + assertIs(root) + assertEquals("", root.path()) + assertEquals(ChannelState.attached, channel.state) + + client.close() + } + + /** + * @UTS objects/unit/RTO23c/get-waits-for-synced-0 + */ + @Test + fun `RTO23c - get waits for SYNCED state`() = runTest { + val attachSent = AtomicInteger(0) + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = { mock, msg -> + attachSent.incrementAndGet() + mock.sendToClient(attachedMessage(msg.channel, "sync1:cursor")) + }, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + + val getFuture = channel.`object`.get() + + pollUntil(5.seconds) { attachSent.get() >= 1 } + + mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) + + val root = getFuture.await() + + assertIs(root) + assertEquals("", root.path()) + + client.close() + } + + /** + * @UTS objects/unit/RTO15/publish-sends-object-pm-0 + */ + @Test + fun `RTO15 - publish sends OBJECT ProtocolMessage`() = runTest { + // The shared synced-channel harness captures every outgoing OBJECT publish in the mock's + // event log (the spec's `captured_messages`); its auto-ACK serial scheme replaces the + // spec's inline "serial-0" ACK — observationally equivalent for the wire assertions here. + val (client, _, root, mockWs) = setupSyncedChannel("test") + + // Drive the internal publish (RTO15) through a public mutation — only the observable + // wire behaviour is asserted here. + root.get("score").asLiveCounter().increment(5).await() + + val captured = mockWs.capturedObjectMessages() + assertEquals(1, captured.size) + assertEquals(ProtocolMessage.Action.`object`, captured[0].action) // RTO15e1 + assertEquals("test", captured[0].channel) // RTO15e2 + val state = assertNotNull(captured[0].state) + assertEquals(1, state.size) + // RTO15e3 - the state entry is the encoded ObjectMessage for the driven mutation + val operation = assertNotNull((state[0] as WireObjectMessage).operation) + assertEquals(WireObjectOperationAction.CounterInc, operation.action) + assertEquals("counter:score@1000", operation.objectId) + assertEquals(5.0, assertNotNull(operation.counterInc).number) + + client.close() + } + + /** + * @UTS objects/unit/RTO20/publish-and-apply-local-0 + */ + @Test + fun `RTO20 - publishAndApply applies locally on ACK`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20c/missing-site-code-0 + */ + @Test + fun `RTO20c - publishAndApply logs error when siteCode missing`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(includeSiteCode = false), + onAttach = attachedWithSync(), + onObject = { mock, msg -> mock.sendToClient(buildAckMessage(msg.msgSerial, listOf("serial-0"))) }, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + val root = channel.`object`.get().await() + + root.get("score").asLiveCounter().increment(10).await() + + // The ACK-based local apply is skipped (siteCode unavailable) — value unchanged. + assertEquals(100.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20d1/null-serial-skipped-0 + */ + @Test + fun `RTO20d1 - null serial in PublishResult is skipped`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(), + onObject = { mock, msg -> + // build_ack_message(msg.msgSerial, [null]) — a PublishResult whose single serial + // is null. + mock.sendToClient( + ProtocolMessage(ProtocolMessage.Action.ack).apply { + msgSerial = msg.msgSerial + count = 1 + res = arrayOf(PublishResult(arrayOfNulls(1))) + }, + ) + }, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + val root = channel.`object`.get().await() + + root.get("score").asLiveCounter().increment(10).await() + + assertEquals(100.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20e/waits-for-synced-0 + */ + @Test + fun `RTO20e - publishAndApply waits for SYNCED during SYNCING`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + val incFuture = root.get("score").asLiveCounter().increment(10) + + // Per RTO20e the write must WAIT for the sync to reach SYNCED: while still SYNCING the + // increment must not have applied yet. + assertFalse(incFuture.isDone) + assertEquals(100.0, root.get("score").asLiveCounter().value()) + + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + + incFuture.await() + + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20e1/fails-on-channel-detached-0 + */ + @Test + fun `RTO20e1 - publishAndApply fails when channel enters DETACHED during sync wait`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + val incFuture = root.get("score").asLiveCounter().increment(10) + + // The publish and its ACK complete against the mock; publishAndApply parks in the RTO20e + // wait for SYNCED. + assertFalse(incFuture.isDone) + + // A client-side detach then moves the channel to DETACHED. + detachClientSide(channel) + + val error = assertFailsWith { incFuture.await() } + + assertEquals(92008, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTO20e1/fails-on-channel-failed-0 + */ + @Test + fun `RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + val incFuture = root.get("score").asLiveCounter().increment(10) + + // The publish and its ACK complete against the mock; publishAndApply parks in the RTO20e + // wait for SYNCED. + assertFalse(incFuture.isDone) + + // Then the channel ERROR moves the channel to FAILED. + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + this.channel = "test" + this.error = ErrorInfo("Channel failed", 400, 90000) + }, + ) + + val error = assertFailsWith { incFuture.await() } + + assertEquals(92008, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTO17/sync-state-events-0 + */ + @Test + fun `RTO17 RTO18 - sync state events`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = { mock, msg -> mock.sendToClient(attachedMessage(msg.channel, "sync1:cursor")) }, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + + val events = mutableListOf() + channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) + + val getFuture = channel.`object`.get() + + pollUntil(5.seconds) { events.size >= 1 } + + mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) + + getFuture.await() + + // events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"] + val iterator = events.iterator() + assertEquals("SYNCING", iterator.next()) + assertEquals("SYNCED", iterator.next()) + + client.close() + } + + /** + * @UTS objects/unit/RTO18d/duplicate-listener-0 + * + * ADAPTED (see deviations.md): RTO18d's default asserts the RTE4 reference behaviour — the same + * listener registered twice fires **twice**. ably-java's core `EventEmitter` keys per-event + * listeners by instance, so a duplicate registration de-duplicates and the listener fires **once**. + * RTO18d's OPTIONAL note sanctions SDKs that de-duplicate asserting `call_count == 1`, which this + * test pins (a regression that lost dedup would fire twice in the same emission and fail it). + */ + @Test + fun `RTO18d - duplicate listener registered twice fires once (ably-java de-duplicates)`() = runTest { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + val callCount = AtomicInteger(0) + val listener = ObjectStateChange.Listener { callCount.incrementAndGet() } + channel.`object`.on(ObjectStateEvent.SYNCED, listener) + channel.`object`.on(ObjectStateEvent.SYNCED, listener) + + mockWs.sendToClient(attachedMessage("test", "sync2:cursor")) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + + // ably-java de-duplicates → the SYNCED emission invokes the (single) registration exactly once + pollUntil(5.seconds) { callCount.get() >= 1 } + + assertEquals(1, callCount.get()) + + client.close() + } + + /** + * @UTS objects/unit/RTO19/off-deregisters-0 + */ + @Test + fun `RTO19 - off deregisters listener`() = runTest { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + val callCount = AtomicInteger(0) + val listener = ObjectStateChange.Listener { callCount.incrementAndGet() } + val sub = channel.`object`.on(ObjectStateEvent.SYNCED, listener) + // The spec's `sub.off()` — the ably-java Subscription method is unsubscribe(). + sub.unsubscribe() + // NOTE: negative-assertion quiescence (helpers/standard_test_pool.md) — a still-subscribed + // control listener provides a delivery to await on the same SYNCED emission before + // asserting the deregistered listener did not fire. + val control = AtomicInteger(0) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { control.incrementAndGet() }) + + mockWs.sendToClient(attachedMessage("test", "sync2:cursor")) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + pollUntil(5.seconds) { control.get() >= 1 } + + assertEquals(0, callCount.get()) + + client.close() + } + + /** + * @UTS objects/unit/RTO2/mode-enforcement-0 + */ + @Test + fun `RTO2 - channel mode enforcement`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + // ATTACHED grants only OBJECT_SUBSCRIBE (the spec's `modes: ["OBJECT_SUBSCRIBE"]`). + onAttach = attachedWithSync(modeFlags = listOf(ProtocolMessage.Flag.object_subscribe)), + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + val root = channel.`object`.get().await() + + val error = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } + + assertEquals(40024, error.errorInfo.code) + + client.close() + } + + /** + * @UTS objects/unit/RTO23e/get-rejects-failed-0 + */ + @Test + fun `RTO23e - get on a FAILED channel rejects with 90001`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = { mock, msg -> + mock.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + this.channel = msg.channel + this.error = ErrorInfo("Channel error", 400, 90000) + }, + ) + }, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test", modes = arrayOf(ChannelMode.object_subscribe)) + + // Trigger attach which will fail, putting channel into FAILED state. + channel.attach() + awaitChannelState(channel, ChannelState.failed) + + val error = assertFailsWith { channel.`object`.get().await() } + + assertEquals(90001, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO25a/access-requires-subscribe-mode-0 + */ + @Test + fun `RTO25a - access API precondition requires OBJECT_SUBSCRIBE mode`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(modeFlags = listOf(ProtocolMessage.Flag.object_publish)), + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test", modes = arrayOf(ChannelMode.object_publish)) + + val error = assertFailsWith { channel.`object`.get().await() } + + assertEquals(40024, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO25b/access-throws-detached-0 + */ + @Test + fun `RTO25b - access API precondition throws on DETACHED channel`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + // Detach the channel client-side after sync. + detachClientSide(channel) + + val error = assertFailsWith { root.keys() } + + assertEquals(90001, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO25b/access-throws-failed-0 + */ + @Test + fun `RTO25b - access API precondition throws on FAILED channel`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + // Force channel to FAILED state. + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + this.channel = "test" + this.error = ErrorInfo("Channel error", 400, 90000) + }, + ) + awaitChannelState(channel, ChannelState.failed) + + val error = assertFailsWith { root.keys() } + + assertEquals(90001, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO26a/write-requires-publish-mode-0 + */ + @Test + fun `RTO26a - write API precondition requires OBJECT_PUBLISH mode`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(modeFlags = listOf(ProtocolMessage.Flag.object_subscribe)), + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test", modes = arrayOf(ChannelMode.object_subscribe)) + val root = channel.`object`.get().await() + + val error = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } + + assertEquals(40024, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO26b/write-throws-detached-0 + */ + @Test + fun `RTO26b - write API precondition throws on DETACHED channel`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + // Detach the channel client-side after sync. + detachClientSide(channel) + + val error = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } + + assertEquals(90001, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO26b/write-throws-failed-0 + */ + @Test + fun `RTO26b - write API precondition throws on FAILED channel`() = runTest { + val (client, channel, root, mockWs) = setupSyncedChannel("test") + + // Force channel to FAILED state. + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + this.channel = "test" + this.error = ErrorInfo("Channel error", 400, 90000) + }, + ) + awaitChannelState(channel, ChannelState.failed) + + val error = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } + + assertEquals(90001, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO26c/write-throws-echo-disabled-0 + */ + @Test + fun `RTO26c - write API precondition throws when echoMessages is false`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(), + ) + val client = newClient(mockWs, echo = false) + val channel = client.objectsChannel("test") + val root = channel.`object`.get().await() + + val error = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } + + assertEquals(40000, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO24a/single-register-instance-0 + */ + @Test + fun `RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + val eventsRoot = mutableListOf() + val eventsScore = mutableListOf() + + // Subscribe via root PathObject at path []. + root.subscribe(PathObjectListener { event -> eventsRoot.add(event) }) + + // Subscribe via a deeper PathObject at path ["score"]. + root.get("score").subscribe(PathObjectListener { event -> eventsScore.add(event) }) + + // Trigger an update on the score counter. siteCode "remote" is absent from the pool's + // siteTimeserials, so the op passes the newness check (RTLO4a) regardless of serial + // ordering. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "t:1", "remote"))), + ) + pollUntil(5.seconds) { eventsScore.size >= 1 } + pollUntil(5.seconds) { eventsRoot.size >= 1 } + + // Both subscriptions are managed by the same register and both fire. + assertTrue(eventsRoot.size >= 1) + assertTrue(eventsScore.size >= 1) + + client.close() + } + + /** + * @UTS objects/unit/RTO24c1/coverage-prefix-depth-0 + */ + @Test + fun `RTO24c1 - subscription coverage prefix match with depth constraint`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + val shallowEvents = mutableListOf() + val deepEvents = mutableListOf() + + // Subscribe at root with depth 1 — per RTO24c2b this covers ONLY root's own path ([]), + // NOT its children (a child like ["score"] is relativeDepth 1-0+1 = 2 > 1). + root.subscribe(PathObjectListener { event -> shallowEvents.add(event) }, PathObjectSubscriptionOptions(1)) + + // Subscribe at root with no depth limit — covers everything. + root.subscribe(PathObjectListener { event -> deepEvents.add(event) }) + + // Update root itself (a MAP_SET on root — candidate path [] is covered by depth 1). + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), + ) + pollUntil(5.seconds) { deepEvents.size >= 1 } + + // Update a child of root (path ["score"], relativeDepth 2) — NOT covered by depth 1, + // covered by deep. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "t:2", "remote"))), + ) + pollUntil(5.seconds) { deepEvents.size >= 2 } + + // Negative-assertion quiescence: the shallow listener fired exactly once on the FIRST + // dispatch (the root self-update at []) and must NOT fire on the second (child ["score"]) + // dispatch. The deep listener is the control that fires on both; poll the shallow listener + // too so its count isn't racing. + pollUntil(5.seconds) { shallowEvents.size >= 1 } + + // Shallow subscription (depth 1) only sees the root self-update, not the child update. + assertEquals(1, shallowEvents.size) + // Deep subscription (no depth limit) sees both updates. + assertTrue(deepEvents.size >= 2) + + client.close() + } + + /** + * @UTS objects/unit/RTO10/gc-tombstoned-objects-0 + */ + @Test + fun `RTO10 - GC removes tombstoned objects past grace period`() = runTest { + val (client, _, root, mockWs, fakeClock) = setupSyncedChannelWithFakeTimers("test") + + // Tombstone stamped "now": only the ADVANCE_TIME below makes it GC-eligible. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildObjectDelete("counter:score@1000", "99", "site1", fakeClock.currentTimeMillis())), + ), + ) + + fakeClock.advance(86_400_000L + 300_000L) + + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == null } + assertNull(root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO10c1b1/gc-root-never-removed-0 + */ + @Test + fun `RTO10c1b1 - GC never removes the root object`() = runTest { + val (client, _, root, mockWs, fakeClock) = setupSyncedChannelWithFakeTimers("test") + + // Rogue OBJECT_DELETE targeting the root object: rejected per RTLO4e10. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildObjectDelete("root", remoteSerial(0), "remote", fakeClock.currentTimeMillis())), + ), + ) + + assertEquals("Alice", root.get("name").asString().value()) // root not tombstoned, data untouched + + fakeClock.advance(86_400_000L + 300_000L) + + // root must still be live: a subsequent operation still applies to the same root object + // the client holds. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(1), "remote"))), + ) + pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" } + + assertEquals("Bob", root.get("name").asString().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20/echo-dedup-0 + */ + @Test + fun `RTO20 - echo deduplication via appliedOnAckSerials`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + val scoreAfterApply = root.get("score").asLiveCounter().value() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, ackSerial(0, 0), SITE_CODE))), + ) + val scoreAfterEcho = root.get("score").asLiveCounter().value() + + assertEquals(110.0, scoreAfterApply) + assertEquals(110.0, scoreAfterEcho) + + client.close() + } + + /** + * @UTS objects/unit/RTO20f/ack-no-site-timeserials-update-0 + */ + @Test + fun `RTO20f - apply-on-ACK does not update siteTimeserials`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + // Send inbound COUNTER_INC from siteCode SITE_CODE with serial below_ack_serial(9): a + // serial that is NOT the apply-on-ACK serial (so RTO9a3 echo dedup does not discard it) + // yet sorts below ack_serial(0, 0). If LOCAL incorrectly set + // siteTimeserials[SITE_CODE] = "t:1:0", this fails the newness check and value stays 110; + // if LOCAL correctly left siteTimeserials untouched, SITE_CODE has no entry and the op + // applies, reaching 120. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, belowAckSerial(9), SITE_CODE))), + ) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 120.0 } + + assertEquals(120.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20/ack-after-echo-no-double-apply-0 + */ + @Test + fun `RTO20 - ACK after echo does not double-apply`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannelNoAck("test") + + val incFuture = root.get("score").asLiveCounter().increment(10) + + // Wait for the publish to reach the transport before injecting the echo/ACK — an ACK + // that arrives while no message is pending on the connection is discarded, and incFuture + // would never complete. + pollUntil(5.seconds) { mockWs.capturedObjectMessages().size >= 1 } + + // Send the echo BEFORE the ACK. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, ackSerial(0, 0), SITE_CODE))), + ) + + // Now send the ACK. + mockWs.sendToClient(buildAckMessage(0, listOf(ackSerial(0, 0)))) + + incFuture.await() + + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0 + */ + @Test + fun `RTO5c9 RTO20 - appliedOnAckSerials cleared on re-sync`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + // Trigger re-sync — appliedOnAckSerials should be cleared per RTO5c9. (The mock delivers + // asynchronously, so the spec's post-resync `== 100` assertion is awaited.) + mockWs.sendToClient(attachedMessage("test", "sync2:cursor")) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 100.0 } + assertEquals(100.0, root.get("score").asLiveCounter().value()) + + // Replay the same serial (ack_serial(0, 0)) that was used for apply-on-ACK. If + // appliedOnAckSerials was cleared, this applies normally. If NOT cleared, dedup (RTO9a3) + // would reject it and score stays 100. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, ackSerial(0, 0), SITE_CODE))), + ) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 110.0 } + + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO20/subscription-fires-on-ack-apply-0 + */ + @Test + fun `RTO20 - subscription fires on apply-on-ACK`() = runTest { + val (client, _, root, _) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("score").subscribe(PathObjectListener { event -> events.add(event) }) + + root.get("score").asLiveCounter().increment(10).await() + + pollUntil(5.seconds) { events.size >= 1 } + assertTrue(events.size >= 1) + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO23/get-implicit-attach-0 + */ + @Test + fun `RTO23 - get implicitly attaches channel`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(), + onObject = ackPerState, + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + + assertEquals(ChannelState.initialized, channel.state) + + val root = channel.`object`.get().await() + + assertIs(root) + assertEquals("", root.path()) + assertEquals(ChannelState.attached, channel.state) + + client.close() + } + + /** + * @UTS objects/unit/RTO23d/get-resolves-immediately-synced-0 + */ + @Test + fun `RTO23d - get resolves immediately when already SYNCED`() = runTest { + val (client, channel, _, _) = setupSyncedChannel("test") + + val root2 = channel.`object`.get().await() + + assertIs(root2) + assertEquals("", root2.path()) + + client.close() + } + + /** + * @UTS objects/unit/RTO10b1/gc-grace-period-source-0 + */ + @Test + fun `RTO10b1 - GC grace period from ConnectionDetails`() = runTest { + val (client, _, root, mockWs, fakeClock) = setupSyncedChannelWithFakeTimers("test", gcGracePeriod = 5000L) + + // Tombstone stamped "now": after ADVANCE_TIME(6000) it is eligible under the 5000ms + // server-provided grace but NOT under the 24h default, so this test fails if the + // implementation ignores ConnectionDetails.objectsGCGracePeriod. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildObjectDelete("counter:score@1000", "99", "site1", fakeClock.currentTimeMillis())), + ), + ) + + // Short grace period (5000ms) — advance past it. + fakeClock.advance(5000L + 1000L) + + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == null } + assertNull(root.get("score").asLiveCounter().value()) + + client.close() + } + + /** + * @UTS objects/unit/RTO17-RTO18/sync-event-sequences-0 + */ + @Test + fun `RTO17 RTO18 - sync event sequences for all state transitions`() = runTest { + // Scenario "initial attach": a genuine FIRST attach can only be observed on a fresh, + // NON-synced channel with the SYNCING/SYNCED listeners registered BEFORE attach(). + run { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(), + ) + val client = newClient(mockWs) + val channel = client.objectsChannel("test") + val events = mutableListOf() + channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) + + channel.attach() + pollUntil(5.seconds) { events.size >= 2 } + + assertEquals(listOf("SYNCING", "SYNCED"), events.toList()) + client.close() + } + + // Scenario "re-sync on new ATTACHED". + run { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) + + mockWs.sendToClient(attachedMessage("test", "sync3:cursor")) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync3:", STANDARD_POOL_OBJECTS)) + pollUntil(5.seconds) { events.size >= 2 } + + assertEquals(listOf("SYNCING", "SYNCED"), events.toList()) + client.close() + } + + // Scenario "ATTACHED without HAS_OBJECTS": RTO4c transitions the (currently SYNCED) sync + // state to SYNCING for ANY ATTACHED → emits SYNCING; RTO4b (no HAS_OBJECTS) then completes + // the sync immediately via RTO4b4 → emits SYNCED. + run { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) + + mockWs.sendToClient(attachedMessage("test", "sync4:", hasObjects = false)) + pollUntil(5.seconds) { events.size >= 2 } + + assertEquals(listOf("SYNCING", "SYNCED"), events.toList()) + client.close() + } + + // NOTE: no "re-attach after detach" scenario — see the spec (realtime_object.md): at the + // objects layer a detach emits no sync events and the re-attach drives the same onAttached + // path as "re-sync on new ATTACHED" above, so it is redundant here. + } + + /** + * @UTS objects/unit/RTO27/clears-objects-data-on-detached-failed-0 + * + * White-box mapping (objects-mapping.md §17): the abstract `channel.object` is the internal + * [DefaultRealtimeObject], `channel.object.processChannelState(state)` → + * [DefaultRealtimeObject.handleStateChange] `(state, false)`, and `channel.object.objectsPool` → + * [DefaultRealtimeObject.objectsPool]. `handleStateChange` launches on the internal sequential + * scope, so an empty [DefaultRealtimeObject.asyncFuture] is awaited to flush it (deviation S-2). + */ + @Test + fun `RTO27 - DETACHED and FAILED clear objects data without emitting`() = runTest { + for (state in listOf(ChannelState.detached, ChannelState.failed)) { + val (client, channel, _, _) = setupSyncedChannel("test") + val ro = channel.`object` as DefaultRealtimeObject + val pool = ro.objectsPool + val root = pool.get(ROOT_OBJECT_ID) as InternalLiveMap + val scoreCounter = pool.get("counter:score@1000") as InternalLiveCounter + val profileMap = pool.get("map:profile@1000") as InternalLiveMap // nested map + + // Sanity: the synced pool carries the standard-pool data. + assertTrue(root.data.containsKey("name"), "precondition: root has \"name\" ($state)") + assertEquals(100.0, scoreCounter.value(), "precondition: counter value is 100 ($state)") + assertTrue(profileMap.data.containsKey("email"), "precondition: nested map has \"email\" ($state)") + + // The clear must NOT emit update events (clearObjectsData(false)). + val updates = AtomicInteger(0) + root.subscribe(InstanceListener { updates.incrementAndGet() }) + + ro.handleStateChange(state, false) + ro.asyncFuture { }.await() // flush the sequential scope + + // RTO27a1: EVERY object's data is cleared — root, the counter, AND the nested map + // (checked independently via the pool so a nested-object regression isn't hidden by the + // root clear); the objects themselves remain in the pool. + assertTrue(root.data.isEmpty(), "root data must be cleared on $state") + assertNotNull(pool.get("counter:score@1000"), "counter must remain in the pool on $state") + assertEquals(0.0, scoreCounter.value(), "counter data must be cleared on $state") + assertNotNull(pool.get("map:profile@1000"), "nested map must remain in the pool on $state") + assertTrue(profileMap.data.isEmpty(), "nested map data must be cleared on $state") + assertEquals(0, updates.get(), "no update events must be emitted on $state") + + client.close() + } + } + + /** + * @UTS objects/unit/RTO27/retains-objects-data-on-suspended-0 + */ + @Test + fun `RTO27 - SUSPENDED retains objects data`() = runTest { + val (client, channel, _, _) = setupSyncedChannel("test") + val ro = channel.`object` as DefaultRealtimeObject + val pool = ro.objectsPool + val root = pool.get(ROOT_OBJECT_ID) as InternalLiveMap + val scoreCounter = pool.get("counter:score@1000") as InternalLiveCounter + val profileMap = pool.get("map:profile@1000") as InternalLiveMap // nested map + + assertTrue(root.data.containsKey("name"), "precondition: root has \"name\"") + assertEquals(100.0, scoreCounter.value(), "precondition: counter value is 100") + assertTrue(profileMap.data.containsKey("email"), "precondition: nested map has \"email\"") + + ro.handleStateChange(ChannelState.suspended, false) + ro.asyncFuture { }.await() // flush the sequential scope + + // RTO27b: data retained unchanged — root, the counter, and the nested map. + assertTrue(root.data.containsKey("name"), "root data must be retained on SUSPENDED") + assertEquals(100.0, scoreCounter.value(), "counter data must be retained on SUSPENDED") + assertTrue(profileMap.data.containsKey("email"), "nested map data must be retained on SUSPENDED") + + client.close() + } +} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ValueTypesTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ValueTypesTest.kt new file mode 100644 index 000000000..c46d4b0bb --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ValueTypesTest.kt @@ -0,0 +1,363 @@ +package io.ably.lib.liveobjects.uts.unit + +import com.google.gson.JsonParser +import io.ably.lib.liveobjects.DefaultRealtimeObject +import io.ably.lib.liveobjects.message.WireMapCreate +import io.ably.lib.liveobjects.message.WireObjectData +import io.ably.lib.liveobjects.message.WireObjectMessage +import io.ably.lib.liveobjects.message.WireObjectOperationAction +import io.ably.lib.liveobjects.message.WireObjectsMapSemantics +import io.ably.lib.liveobjects.unit.getMockAblyClientAdapter +import io.ably.lib.liveobjects.value.LiveCounter +import io.ably.lib.liveobjects.value.LiveMap +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.liveobjects.value.livecounter.DefaultLiveCounter +import io.ably.lib.liveobjects.value.livemap.DefaultLiveMap +import io.ably.lib.types.AblyException +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Derived from UTS spec `objects/unit/value_types.md` — the `LiveCounter` / `LiveMap` creation + * value types (`RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4`): immutable blueprints created via the + * static `create` factories, evaluated into v6 `*CreateWithObjectId` ObjectMessages. + * + * The `create` surface is public API (`objects-mapping.md` §6); the evaluation half is + * internal/wire-level (§13), so the spec's `evaluate(vt)` maps to the internal evaluation entry + * points ([DefaultLiveCounter.createCounterCreateMessage] / [DefaultLiveMap.createMapCreateMessages], + * RTLCV4h / RTLMV4k) called against a §17.1 `DefaultRealtimeObject`. Several "invalid input" + * cases are rejected at compile time by the typed signatures — see the T-4/T-6 entries in + * `deviations.md`. + */ +class ValueTypesTest { + + private lateinit var ro: DefaultRealtimeObject + + @BeforeTest + fun setUp() { + // §17.1 instantiation — evaluation needs a DefaultRealtimeObject for the RTO14/RTO16 + // objectId derivation. Teardown per S-4 (see deviations.md). + ro = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + } + + @AfterTest + fun tearDown() { + unmockkAll() + ro.objectsPool.dispose() + } + + /** + * The spec's `evaluate(vt)` for a counter value type — the internal RTLCV4 evaluation + * (`createCounterCreateMessage`), which produces the single COUNTER_CREATE message (RTLCV4h). + */ + private suspend fun evaluate(vt: LiveCounter): List = + listOf((vt as DefaultLiveCounter).createCounterCreateMessage(ro)) + + /** + * The spec's `evaluate(vt)` for a map value type — the internal RTLMV4 evaluation + * (`createMapCreateMessages`), returning `[nested creates..., MAP_CREATE]` depth-first (RTLMV4k). + */ + private suspend fun evaluate(vt: LiveMap): List = + (vt as DefaultLiveMap).createMapCreateMessages(ro) + + /** + * @UTS objects/unit/RTLCV3/create-with-count-0 + */ + @Test + fun `RTLCV3 - LiveCounter create with initial count`() { + val vt = LiveCounter.create(42) + + assertIs(vt) + // DEVIATION T-6: the retained count has no public accessor (RTLCV3d) — asserted via the + // module-internal DefaultLiveCounter.initialCount. See deviations.md. + assertEquals(42.0, (vt as DefaultLiveCounter).initialCount.toDouble()) + } + + /** + * @UTS objects/unit/RTLCV3/create-default-zero-0 + */ + @Test + fun `RTLCV3 - LiveCounter create defaults to 0`() { + val vt = LiveCounter.create() + + // DEVIATION T-6: retained count read via the module-internal field — see deviations.md. + assertEquals(0.0, (vt as DefaultLiveCounter).initialCount.toDouble()) + } + + /** + * @UTS objects/unit/RTLCV3c/no-validation-at-create-0 + */ + @Test + fun `RTLCV3c - no validation at creation time`() { + // DEVIATION T-4: the spec's invalid input `LiveCounter.create("not_a_number")` is not + // expressible — `create(@NotNull Number)` rejects a String at compile time. The + // runtime-reachable invalid input (a non-finite Number, RTLCV4a) exercises the same + // "no validation at creation" behaviour. See deviations.md. + val vt = LiveCounter.create(Double.NaN) + + assertIs(vt) // does not throw + } + + /** + * @UTS objects/unit/RTLCV4/evaluate-generates-message-0 + */ + @Test + fun `RTLCV4 - evaluation generates COUNTER_CREATE ObjectMessage`() = runTest { + val vt = LiveCounter.create(42) + val messages = evaluate(vt) + + assertEquals(1, messages.size) + val msg = messages[0] + val operation = assertNotNull(msg.operation) + assertEquals(WireObjectOperationAction.CounterCreate, operation.action) + assertTrue(operation.objectId.startsWith("counter:")) + assertTrue(operation.objectId.contains("@")) + val counterCreateWithObjectId = assertNotNull(operation.counterCreateWithObjectId) + assertNotNull(counterCreateWithObjectId.nonce) + assertTrue(counterCreateWithObjectId.nonce.length >= 16) + assertNotNull(counterCreateWithObjectId.initialValue) + } + + /** + * @UTS objects/unit/RTLCV4g5/retains-local-counter-create-0 + */ + @Test + fun `RTLCV4g5 - evaluation retains local CounterCreate`() = runTest { + val vt = LiveCounter.create(42) + val messages = evaluate(vt) + + val msg = messages[0] + // The retained CounterCreate (RTLCV4g5, spec `operation.counterCreate`) is carried as the + // local-only `derivedFrom` on the wire CounterCreateWithObjectId (@Transient, CCRO2). + val retained = assertNotNull(assertNotNull(msg.operation).counterCreateWithObjectId?.derivedFrom) + assertEquals(42.0, retained.count) + } + + /** + * @UTS objects/unit/RTLCV4a/evaluate-validates-count-0 + */ + @Test + fun `RTLCV4a - evaluation validates count type`() = runTest { + // DEVIATION T-4: the spec's `LiveCounter.create("not_a_number")` input is compile-time + // blocked; the runtime-reachable RTLCV4a input ("not a Number OR not finite") is a + // non-finite Number, which the same validation rejects at evaluation time. See deviations.md. + val vt = LiveCounter.create(Double.NaN) + + val error = assertFailsWith { evaluate(vt) } + + assertEquals(40003, error.errorInfo.code) + } + + /** + * @UTS objects/unit/RTLCV4/evaluate-zero-count-0 + */ + @Test + fun `RTLCV4 - evaluation with count 0`() = runTest { + val vt = LiveCounter.create(0) + val messages = evaluate(vt) + + val msg = messages[0] + val retained = assertNotNull(assertNotNull(msg.operation).counterCreateWithObjectId?.derivedFrom) + assertEquals(0.0, retained.count) + } + + /** + * @UTS objects/unit/RTLMV3/create-with-entries-0 + */ + @Test + fun `RTLMV3 - LiveMap create with entries`() { + val vt = LiveMap.create( + mapOf( + "name" to LiveMapValue.of("Alice"), + "age" to LiveMapValue.of(30), + ), + ) + + assertIs(vt) + // DEVIATION T-6: the retained entries have no public accessor (RTLMV3d) — asserted via + // the module-internal DefaultLiveMap.entries. See deviations.md. + val entries = (vt as DefaultLiveMap).entries + assertEquals("Alice", assertNotNull(entries["name"]).asString) + assertEquals(30.0, assertNotNull(entries["age"]).asNumber.toDouble()) + } + + /** + * @UTS objects/unit/RTLMV3/create-no-entries-0 + */ + @Test + fun `RTLMV3 - LiveMap create with no entries`() { + val vt = LiveMap.create() + + assertIs(vt) + } + + /** + * @UTS objects/unit/RTLMV4/evaluate-generates-message-0 + */ + @Test + fun `RTLMV4 - evaluation generates MAP_CREATE ObjectMessage`() = runTest { + val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice"))) + val messages = evaluate(vt) + + assertEquals(1, messages.size) + val msg = messages[0] + val operation = assertNotNull(msg.operation) + assertEquals(WireObjectOperationAction.MapCreate, operation.action) + assertTrue(operation.objectId.startsWith("map:")) + val mapCreateWithObjectId = assertNotNull(operation.mapCreateWithObjectId) + assertTrue(mapCreateWithObjectId.nonce.length >= 16) + assertNotNull(mapCreateWithObjectId.initialValue) + } + + /** + * @UTS objects/unit/RTLMV4j5/retains-local-map-create-0 + */ + @Test + fun `RTLMV4j5 - evaluation retains local MapCreate`() = runTest { + val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice"))) + val messages = evaluate(vt) + + val msg = messages[0] + // The retained MapCreate (RTLMV4j5, spec `operation.mapCreate`) is carried as the + // local-only `derivedFrom` on the wire MapCreateWithObjectId (@Transient, MCRO2). + val retained = assertNotNull(assertNotNull(msg.operation).mapCreateWithObjectId?.derivedFrom) + assertEquals(WireObjectsMapSemantics.LWW, retained.semantics) + assertEquals("Alice", assertNotNull(retained.entries["name"]?.data).string) + } + + /** + * @UTS objects/unit/RTLMV4d/entry-value-types-0 + */ + @Test + fun `RTLMV4d - entry value type mapping`() = runTest { + val jsonArr = JsonParser.parseString("""[1, 2, 3]""").asJsonArray + val jsonObj = JsonParser.parseString("""{"key": "value"}""").asJsonObject + val vt = LiveMap.create( + mapOf( + "str" to LiveMapValue.of("hello"), + "num" to LiveMapValue.of(42), + "bool" to LiveMapValue.of(true), + "json_arr" to LiveMapValue.of(jsonArr), + "json_obj" to LiveMapValue.of(jsonObj), + ), + ) + val messages = evaluate(vt) + + val msg = messages[0] + val entries = assertNotNull(assertNotNull(msg.operation).mapCreateWithObjectId?.derivedFrom).entries + assertEquals("hello", assertNotNull(entries["str"]?.data).string) + assertEquals(42.0, assertNotNull(entries["num"]?.data).number) + assertEquals(true, assertNotNull(entries["bool"]?.data).boolean) + assertEquals(jsonArr, assertNotNull(entries["json_arr"]?.data).json) + assertEquals(jsonObj, assertNotNull(entries["json_obj"]?.data).json) + } + + /** + * @UTS objects/unit/RTLMV4d1/nested-value-types-0 + */ + @Test + fun `RTLMV4d1 RTLMV4d2 - nested value types produce depth-first ObjectMessages`() = runTest { + val innerCounter = LiveCounter.create(10) + val innerMap = LiveMap.create(mapOf("nested_count" to LiveMapValue.of(innerCounter))) + val outer = LiveMap.create(mapOf("child" to LiveMapValue.of(innerMap))) + val messages = evaluate(outer) + + assertEquals(3, messages.size) + assertEquals(WireObjectOperationAction.CounterCreate, assertNotNull(messages[0].operation).action) + assertTrue(assertNotNull(messages[0].operation).objectId.startsWith("counter:")) + assertEquals(WireObjectOperationAction.MapCreate, assertNotNull(messages[1].operation).action) + assertTrue(assertNotNull(messages[1].operation).objectId.startsWith("map:")) + assertEquals(WireObjectOperationAction.MapCreate, assertNotNull(messages[2].operation).action) + assertTrue(assertNotNull(messages[2].operation).objectId.startsWith("map:")) + + val innerCounterId = assertNotNull(messages[0].operation).objectId + val innerMapId = assertNotNull(messages[1].operation).objectId + + val innerMapCreate = assertNotNull(assertNotNull(messages[1].operation).mapCreateWithObjectId?.derivedFrom) + assertEquals(innerCounterId, assertNotNull(innerMapCreate.entries["nested_count"]?.data).objectId) + val outerMapCreate = assertNotNull(assertNotNull(messages[2].operation).mapCreateWithObjectId?.derivedFrom) + assertEquals(innerMapId, assertNotNull(outerMapCreate.entries["child"]?.data).objectId) + } + + /** + * @UTS objects/unit/RTLMV4a/evaluate-validates-entries-0 + */ + @Test + fun `RTLMV4a - evaluation validates entries type`() { + // DEVIATION T-4: `LiveMap.create(null)` is not expressible — `create(@NotNull Map)` rejects null (and any non-Dict) at compile time, so the RTLMV4a runtime + // validation is unreachable in ably-java. No runtime assertion. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4b/evaluate-validates-keys-0 + */ + @Test + fun `RTLMV4b - evaluation validates key types`() { + // DEVIATION T-4: a non-String map key (`{ 123: "value" }`) cannot be constructed against + // `Map` — compile-time blocked, per the spec's own + // language-applicability note. No runtime assertion. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4c/evaluate-validates-values-0 + */ + @Test + fun `RTLMV4c - evaluation validates value types`() { + // DEVIATION T-4: an unsupported entry value (`{ "fn": some_function }`) is not expressible — + // the closed LiveMapValue union has no function/unsupported-type member, so the RTLMV4c + // 40013 validation is unreachable in ably-java. No runtime assertion. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4e2/empty-entries-0 + */ + @Test + fun `RTLMV4e2 - empty entries produces MapCreate with empty entries`() = runTest { + val vt = LiveMap.create() + val messages = evaluate(vt) + + val msg = messages[0] + val retained = assertNotNull(assertNotNull(msg.operation).mapCreateWithObjectId?.derivedFrom) + assertEquals(emptyMap(), retained.entries) + } + + /** + * @UTS objects/unit/RTLMV4d/map-set-all-types-table-0 + */ + @Test + fun `RTLMV4d - table-driven MAP_SET value type mapping`() = runTest { + val scenarios: List Unit>> = listOf( + LiveMapValue.of("hello") to { data -> assertEquals("hello", data.string) }, + LiveMapValue.of(42) to { data -> assertEquals(42.0, data.number) }, + LiveMapValue.of(3.14) to { data -> assertEquals(3.14, data.number) }, + LiveMapValue.of(0) to { data -> assertEquals(0.0, data.number) }, + LiveMapValue.of(-1) to { data -> assertEquals(-1.0, data.number) }, + LiveMapValue.of(true) to { data -> assertEquals(true, data.boolean) }, + LiveMapValue.of(false) to { data -> assertEquals(false, data.boolean) }, + LiveMapValue.of(JsonParser.parseString("""[1, "a", null]""").asJsonArray) to { data -> + assertEquals(JsonParser.parseString("""[1, "a", null]"""), data.json) + }, + LiveMapValue.of(JsonParser.parseString("""{"k": "v"}""").asJsonObject) to { data -> + assertEquals(JsonParser.parseString("""{"k": "v"}"""), data.json) + }, + LiveMapValue.of(byteArrayOf(1, 2, 3)) to { data -> assertEquals("AQID", data.bytes) }, + ) + + for ((input, verify) in scenarios) { + val vt = LiveMap.create(mapOf("test_key" to input)) + val messages = evaluate(vt) + val retained: WireMapCreate = + assertNotNull(assertNotNull(messages[0].operation).mapCreateWithObjectId?.derivedFrom) + verify(assertNotNull(retained.entries["test_key"]?.data)) + } + } +} diff --git a/uts/README.md b/uts/README.md index c771335fa..253a97f95 100644 --- a/uts/README.md +++ b/uts/README.md @@ -88,7 +88,8 @@ three example tests this guide walks through span all three tiers. Each tier folder is further organised **by module** (`realtime`, `liveobjects`, …): `unit//`, `integration/standard//`, and `integration/proxy//`. So a feature's tests sit together by SDK area — the three example tests live at `unit/realtime/`, `integration/standard/realtime/`, and -`integration/proxy/realtime/`. +`integration/proxy/realtime/`. (One exception: the **objects unit** tier lives in the `:liveobjects` +module, not here — see §4.2 "Cross-module exception".) Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)): @@ -173,22 +174,35 @@ what's missing". The reference tests this guide walks through correspond to thes ## 4. The Java Setup: the `uts/` module The `uts/` directory is a **standalone Gradle module** (`include("uts")` in -`settings.gradle.kts`) whose only job is to host UTS-derived tests. It contains *no production code* — -everything lives under `uts/src/test/`. +`settings.gradle.kts`) whose only job is to host UTS-derived tests and the shared test +infrastructure. It contains *no production code* — the tests live under `uts/src/test/`, and the +infrastructure lives in the module's **test-fixtures** source set, `uts/src/testFixtures/`, so +that other Gradle modules can consume it via `testImplementation(testFixtures(project(":uts")))` +(this module's own tests see it automatically). ### 4.1 `uts/build.gradle.kts` ```kotlin -plugins { alias(libs.plugins.kotlin.jvm) } +plugins { + alias(libs.plugins.kotlin.jvm) + `java-test-fixtures` // exports src/testFixtures (the infra) as a consumable variant +} dependencies { + // fixtures (the shared infra) — MUST never depend on :liveobjects (keeps consumers acyclic) + testFixturesApi(project(":java")) // types in fixture signatures + testFixturesApi(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces + testFixturesImplementation(libs.coroutine.core) + testFixturesImplementation(libs.ktor.client.core) // sandbox/proxy control HTTP + testFixturesImplementation(libs.ktor.client.cio) + testImplementation(project(":java")) // the SDK under test - testImplementation(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces + testImplementation(project(":network-client-core")) testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter-params") // @ParameterizedTest / @ValueSource (version from the JUnit BOM) testImplementation(libs.mockk) testImplementation(libs.coroutine.core) // kotlinx.coroutines testImplementation(libs.coroutine.test) // runTest, virtual time - testImplementation(libs.ktor.client.core) // HTTP client for proxy/sandbox control + testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.cio) } @@ -217,43 +231,46 @@ Takeaways: ### 4.2 Directory layout -Everything lives under the `io.ably.lib.uts` package, split cleanly into **infrastructure** (`infra/`, -no `@Test`s) and the **tests** themselves. Tests are organised **by tier, then by module**: `unit/` for +Everything lives under the `io.ably.lib.uts` package, split cleanly into **infrastructure** +(`infra/`, no `@Test`s — in the `testFixtures` source set) and the **tests** themselves (in the +`test` source set). Tests are organised **by tier, then by module**: `unit/` for mocked-transport tests, and `integration/` for real-backend tests — the latter splitting again into `standard/` (direct sandbox, happy-path) and `proxy/` (sandbox through the fault-injecting proxy). Under each, a per-module folder (`realtime`, `liveobjects`, …) holds the actual test classes: ```text -uts/src/test/kotlin/io/ably/lib/uts/ +uts/src/testFixtures/kotlin/io/ably/lib/uts/ # ── shared, consumable by other modules ── +└── infra/ # ── TEST INFRASTRUCTURE (no @Test methods) ── + ├── Utils.kt # awaitState / awaitChannelState / pollUntil (shared) + │ + ├── unit/ # UNIT infra (mocked transports) + │ ├── ClientFactories.kt # TestRealtimeClient / TestRestClient / ClientOptionsBuilder + │ ├── MockWebSocket.kt # fake WS transport + WebSocketMockConfig + CONNECTED_MESSAGE + │ ├── MockWebSocketEngineFactory.kt# plugs the mock into the SDK's WebSocketEngine SPI + │ ├── MockHttpClient.kt # fake HTTP engine + HttpMockConfig + │ ├── MockHttpEngine.kt # plugs the mock into the SDK's HttpEngine SPI + │ ├── MockEvent.kt # sealed log of everything on a mock transport + │ ├── PendingConnection.kt # interface: a connection attempt awaiting a response + │ ├── DefaultPendingConnection.kt # WS implementation of PendingConnection + │ ├── PendingRequest.kt # interface: an in-flight HTTP request awaiting a response + │ ├── DefaultPendingRequest.kt # HTTP implementation of PendingRequest + │ ├── FakeClock.kt # virtual clock + virtual timers (deterministic time) + │ └── Utils.kt # ConnectionDetails { } builder (reflective constructor) + │ + └── integration/ # INTEGRATION infra (real backend) + ├── SandboxApp.kt # provisions/deletes a sandbox app + └── proxy/ + ├── ProxyManager.kt # downloads/launches the uts-proxy binary + └── ProxySession.kt # proxy session: rules, actions, log + connectThroughProxy + +uts/src/test/kotlin/io/ably/lib/uts/ # ── the tests themselves ── ├── deviations.md # the catalogue of SDK-vs-spec divergences │ -├── infra/ # ── TEST INFRASTRUCTURE (no @Test methods) ── -│ ├── Utils.kt # awaitState / awaitChannelState / pollUntil (shared) -│ │ -│ ├── unit/ # UNIT infra (mocked transports) -│ │ ├── ClientFactories.kt # TestRealtimeClient / TestRestClient / ClientOptionsBuilder -│ │ ├── MockWebSocket.kt # fake WS transport + WebSocketMockConfig + CONNECTED_MESSAGE -│ │ ├── MockWebSocketEngineFactory.kt# plugs the mock into the SDK's WebSocketEngine SPI -│ │ ├── MockHttpClient.kt # fake HTTP engine + HttpMockConfig -│ │ ├── MockHttpEngine.kt # plugs the mock into the SDK's HttpEngine SPI -│ │ ├── MockEvent.kt # sealed log of everything on a mock transport -│ │ ├── PendingConnection.kt # interface: a connection attempt awaiting a response -│ │ ├── DefaultPendingConnection.kt # WS implementation of PendingConnection -│ │ ├── PendingRequest.kt # interface: an in-flight HTTP request awaiting a response -│ │ ├── DefaultPendingRequest.kt # HTTP implementation of PendingRequest -│ │ ├── FakeClock.kt # virtual clock + virtual timers (deterministic time) -│ │ └── Utils.kt # ConnectionDetails { } builder (reflective constructor) -│ │ -│ └── integration/ # INTEGRATION infra (real backend) -│ ├── SandboxApp.kt # provisions/deletes a sandbox app -│ └── proxy/ -│ ├── ProxyManager.kt # downloads/launches the uts-proxy binary -│ └── ProxySession.kt # proxy session: rules, actions, log + connectThroughProxy -│ ├── unit/ # ── UNIT TESTS (mock transport) ── · per module -│ ├── realtime/ -│ │ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) -│ └── liveobjects/ # (further modules as coverage grows) +│ └── realtime/ +│ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) +│ # NOTE: objects unit tests are NOT here — the objects +│ # unit tier lives in the :liveobjects module (see below) │ └── integration/ # ── INTEGRATION TESTS (real backend) ── · per module ├── standard/ # direct sandbox: happy-path, no fault injection @@ -273,6 +290,15 @@ top-level `unit/` ↔ `infra/unit/` and `integration/` ↔ `infra/integration/` `runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks key off (§13) — `runUtsIntegrationTests` covers **both** `integration/standard/` and `integration/proxy/`. +> **Cross-module exception — objects unit tier.** The `objects` module's **unit** specs assert on +> internal LiveObjects CRDT state (`InternalLiveMap`/`InternalLiveCounter`/`ObjectsPool`) that is only +> visible to `:liveobjects`'s own test source set, so that tier does **not** live here — it is in +> `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/` (package `io.ably.lib.liveobjects.uts.unit`, +> run by `:liveobjects:runLiveObjectsUnitTests`), consuming this module's infra via the test-fixtures +> variant. The objects **integration** and **proxy** tiers remain here under `integration/`. See +> `.claude/skills/uts-to-kotlin/uts-package-mapping.json` (the `objects.unit` `{root, path}` entry) and +> `MOVE_COMMON_INFRA/`. + --- ## 5. How a Test Reaches the SDK: the hook points @@ -776,6 +802,10 @@ mirror `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests` in the `live # Everything (the default Test task still runs both): ./gradlew :uts:test +# The objects UNIT tier lives in the :liveobjects module (it asserts on internal CRDT state) — run it +# there, not via :uts. See §4.2 "Cross-module exception". +./gradlew :liveobjects:runLiveObjectsUnitTests --tests "io.ably.lib.liveobjects.uts.unit.*" + # Just one test class (works with any of the tasks above): ./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.realtime.ConnectionRecoveryTest" ./gradlew :uts:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime.AuthReauthTest" @@ -919,8 +949,9 @@ sees the control plane; the test never speaks the data plane directly. ## 16. Appendix B: Per-File API Reference -A one-stop table of every Kotlin source file under `uts/src/test/` and the SDK seams they use, so -nothing is left implicit. +A one-stop table of every Kotlin source file under `uts/src/test/` (tests) and +`uts/src/testFixtures/` (the `infra/` tree) and the SDK seams they use, so nothing is left +implicit. ### B.1 Unit-test infrastructure — `io.ably.lib.uts.infra.unit` @@ -977,8 +1008,8 @@ nothing is left implicit. | Proxy control API, rule format, action numbers | [`uts/docs/proxy.md`](https://github.com/ably/specification/blob/main/uts/docs/proxy.md) | | SDK seams | `lib/.../debug/DebugOptions.java`, `lib/.../util/Clock.java` | | Module wiring | `uts/build.gradle.kts`, `settings.gradle.kts` | -| Unit mocks | `uts/.../uts/infra/unit/*` | -| Integration helpers | `uts/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) | -| Async helpers | `uts/.../uts/infra/Utils.kt` (awaits), `…/uts/infra/unit/Utils.kt` (ConnectionDetails builder) | +| Unit mocks | `uts/src/testFixtures/.../uts/infra/unit/*` | +| Integration helpers | `uts/src/testFixtures/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) | +| Async helpers | `uts/src/testFixtures/.../uts/infra/Utils.kt` (awaits), `…/infra/unit/Utils.kt` (ConnectionDetails builder) | | The three example tests | `…/uts/unit/realtime/ConnectionRecoveryTest.kt`, `…/uts/integration/standard/realtime/ChannelHistoryTest.kt`, `…/uts/integration/proxy/realtime/AuthReauthTest.kt` | | Deviations | `uts/.../io/ably/lib/uts/deviations.md` | diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 4eca7f9c4..21a49fc57 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -2,13 +2,25 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat plugins { alias(libs.plugins.kotlin.jvm) + `java-test-fixtures` } dependencies { + // Shared UTS test infra (src/testFixtures/kotlin/io/ably/lib/uts/infra/**) — consumed by this + // module's tests automatically and by other modules via testFixtures(project(":uts")). + // INVARIANT: no testFixtures* configuration may ever depend on :liveobjects — that keeps + // ":liveobjects test -> :uts testFixtures -> :java" acyclic against the testRuntimeOnly below. + // `api` for types that appear in fixture signatures; `implementation` for internals. + testFixturesApi(project(":java")) + testFixturesApi(project(":network-client-core")) + testFixturesImplementation(libs.coroutine.core) + testFixturesImplementation(libs.ktor.client.core) + testFixturesImplementation(libs.ktor.client.cio) + testImplementation(project(":java")) testImplementation(project(":network-client-core")) - // Runtime-only so compile-time stays decoupled from the plugin internals; the LiveObjects test - // helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection. + // Runtime-only so compile-time stays decoupled from the plugin internals; the objects + // integration/proxy tests need the LiveObjects plugin on the runtime classpath. testRuntimeOnly(project(":liveobjects")) testImplementation(kotlin("test")) // @ParameterizedTest / @ValueSource — version managed by the junit-bom on the test classpath. diff --git a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md index 0008085d9..3a9339b69 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md +++ b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md @@ -2,7 +2,15 @@ Deviations from the Ably spec identified during UTS test translation. Each entry records the spec point, what the spec requires, what the SDK actually does, and which test contains the deviation gate. -Entries are grouped by actionability: +**Scope:** this file now holds deviations for the tiers hosted in the `:uts` module — **realtime/rest +(all tiers) and objects integration/proxy**. The **objects unit** tier moved to `:liveobjects`'s own +test source set; its deviations (the typed-SDK / language adaptations and the intentional RTO18d entry +that formerly lived here under groups 3 & 4) are in +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. See "Objects unit-tier +deviations" at the bottom. + +Entries are grouped by actionability (shared taxonomy across both files; only groups with entries in +this file appear as sections below): | Group | Meaning | Action | |---|---|---| @@ -80,266 +88,10 @@ Entries are grouped by actionability: --- -# 3) Expected — typed-SDK / language adaptations (objects) — NOT bugs, no action - -*These exist only because ably-java implements the statically-typed **RTTS** variant of the objects spec -while the UTS assertions are written against ably-js's dynamically-typed API. Each is the documented-correct -translation: RTTS API partitioning (`as*` views, `compact()` opt-out, opaque value types), compile-time -guarantees replacing runtime type errors, or `internal` wire types replacing wire-shape assertions. No SDK -change is wanted; ably-js has no counterpart deviation because JS can express the assertion directly.* - -*Spec-point provenance: the `RTTS*` points and `RTO23f` cited below come from the typed-SDK variant of the -objects spec (ably/specification **PR #491**, unmerged) — they are not in the merged `objects-features.md` -yet, so don't be surprised when a grep of the merged spec misses them.* - -## RTINS12d / RTINS14d / RTINS16c — wrong-type Instance operation throws IllegalStateException, not ErrorInfo 92007 - -**Spec points:** RTINS12d, RTINS14d, RTINS16c -**What the spec requires:** Calling a type-specific operation on an `Instance` wrapping the wrong type fails with `ErrorInfo` code `92007` — `set`/`remove` on a non-LiveMap, `increment`/`decrement` on a non-LiveCounter, `subscribe` on a primitive. -**What the SDK does:** ably-java implements the typed-SDK variant (RTTS): those operations do not exist on the base `Instance` or on the mismatched typed view, so the wrong operation cannot be called at all. The type check happens at the `as*` cast, which fails fast with a plain `IllegalStateException` ("Not a LiveMap/LiveCounter instance") carrying no Ably error code (`DefaultInstance`, RTTS9d). There is no `92007` `AblyException` / `ErrorInfo`. -**Workaround in tests:** Assert `assertFailsWith { … }` on the relevant `asLiveMap()` / `asLiveCounter()` cast instead of an `ErrorInfo` code `92007`. -**Tests affected (InstanceTest.kt):** -- `RTINS12d - set on non-LiveMap throws` (RTINS12d/set-non-map-throws-0) -- `RTINS14d - increment on non-LiveCounter throws` (RTINS14d/increment-non-counter-throws-0) -- `RTINS16c - subscribe on primitive throws` (RTINS16c/subscribe-primitive-throws-0) - ---- - -## RTINS4d / RTINS9c — `value()` on a LiveMap / `size()` on a LiveCounter are not expressible - -**Spec points:** RTINS4d, RTINS9c -**What the spec requires:** The polymorphic `Instance#value()` returns `null` for a LiveMap, and `Instance#size()` returns `null` for a non-LiveMap. -**What the SDK does:** Under the typed-SDK variant (RTTS10) these accessors are partitioned: `value()` exists only on `LiveCounterInstance` / primitive instances, and `size()` only on `LiveMapInstance`. A `LiveMapInstance` has no `value()` and a `LiveCounterInstance` has no `size()`, so the "wrong-type returns null" assertions cannot be written (and the cast to the other view would throw — see above). -**Workaround in tests:** The expressible half of each test is translated (counter `value()`, map `size()`); the not-expressible sub-assertion is dropped with an inline note. -**Tests affected (InstanceTest.kt):** -- `RTINS4 - value returns counter number or primitive` (RTINS4/value-counter-0) — map `value() == null` omitted. -- `RTINS9 - size returns non-tombstoned count` (RTINS9/size-0) — counter `size() == null` omitted. - ---- - -## RTINS10 — `compact()` not implemented; `compactJson()` used instead - -**Spec point:** RTINS10 -**What the spec requires:** `Instance#compact()` returns a recursively-compacted native snapshot (plain map/number/string), e.g. `result["score"] == 100`. -**What the SDK does:** ably-java does not implement `compact()` (RTTS7d — typed SDKs need not). Only `compactJson()` is provided, returning a Gson `JsonObject`/`JsonElement` tree. -**Workaround in tests:** The test calls `compactJson()` and navigates the resulting `JsonObject` (`snapshot.get("score").asInt`, etc.). -**Tests affected (InstanceTest.kt):** -- `RTINS10 - compact recursively compacts` (RTINS10/compact-0) - ---- - -## RTPO5b / RTPO6b — `get(non-string)` / `at(non-string)` failing with 40003 is not expressible - -**Spec points:** RTPO5b, RTPO6b -**What the spec requires:** Calling `PathObject.get(key)` / `LiveMap.at(path)` with a non-String argument fails at runtime with `ErrorInfo` code `40003`. -**What the SDK does:** ably-java is statically typed — `LiveMapPathObject.get(@NotNull String)` and `LiveMapPathObject.at(@NotNull String)` only accept a `String`. A non-string argument is a compile error, never a runtime failure, so there is no code path that returns a `40003` `AblyException` for this input. -**Workaround in tests:** The case is not expressible; the test body documents the omission with an inline note and contains no executable assertion. -**Tests affected (PathObjectTest.kt):** -- `RTPO5b - get throws on non-string key` (RTPO5b/get-non-string-throws-0) -- `RTPO6b - at throws for non-string input` (RTPO6b/at-non-string-throws-0) - ---- - -## RTPO13 / RTPO13c5 / RTPO13c / RTPO3c1 — `compact()` not implemented; `compactJson()` used instead - -**Spec points:** RTPO13, RTPO13c5, RTPO13c, RTPO3c1 -**What the spec requires:** `PathObject#compact()` returns a recursively-compacted native snapshot — plain map/number/string/boolean/bytes values, nested LiveMaps recursed, nested LiveCounters resolved to numbers, raw binary preserved as bytes, and cyclic references reused as the same in-memory object (`result["prefs"]["back_ref"] IS result`). `compact()` returns `null` on resolution failure. -**What the SDK does:** ably-java does not implement `compact()` (RTTS3f — typed SDKs need not). Only `compactJson(): JsonElement?` is provided, returning a Gson tree: binary values are base64-encoded strings (not raw bytes) and cyclic references are emitted as `{ "objectId": ... }` markers (not shared object identity). It returns `null` on resolution failure. -**Workaround in tests:** Each test calls `compactJson()` and navigates the resulting `JsonElement`/`JsonObject`. The binary entry is asserted as its base64 string (`"AQID"`); the cycle is asserted as the `{ "objectId": "map:profile@1000" }` marker instead of object identity; the LiveCounter compacts to its numeric JSON value; the resolution-failure case asserts `compactJson() == null`. -**Tests affected (PathObjectTest.kt):** -- `RTPO13 - compact recursively compacts LiveMap tree` (RTPO13/compact-recursive-0) — base64 for binary. -- `RTPO13c5 - compact handles cycles via shared reference` (RTPO13c5/compact-cycle-detection-0) — objectId marker instead of identity. -- `RTPO13c - compact returns number for LiveCounter` (RTPO13c/compact-counter-0). -- `RTPO3c1 - read operation returns null on resolution failure` (RTPO3c1/read-null-on-failure-0) — `compact()` sub-assertion uses `compactJson()`. - ---- - -## RTPO10 / RTPO10d / RTPO11 / RTPO11d — `keys()` / `values()` "IS Array" is a static-type tautology - -**Spec points:** RTPO10, RTPO10d, RTPO11, RTPO11d -**What the spec requires:** `PathObject.keys()` / `values()` return an array, asserted with `ASSERT keys IS Array` / `ASSERT vals IS Array` (alongside element-count and membership checks). -**What the SDK does:** ably-java is statically typed — `LiveMapPathObject.keys()` returns `Iterable` and `values()` returns `Iterable` (mapping §4). The collection type is guaranteed by the method signature at compile time, so a runtime "is it an array" check is a tautology that cannot fail and adds no coverage. -**Workaround in tests:** The substantive assertions — element count and membership (`size`, `in`) — are translated; the `IS Array` type-tautology line is omitted. -**Tests affected (PathObjectTest.kt):** -- `RTPO10 - keys returns array of key strings` (RTPO10/keys-returns-array-0) — `keys IS Array` omitted; count + membership asserted. -- `RTPO10d - keys returns empty for non-LiveMap` (RTPO10d/keys-non-map-empty-0) — `keys IS Array` omitted; count == 0 asserted. -- `RTPO11 - values returns array of PathObjects` (RTPO11/values-returns-array-0) — `vals IS Array` omitted; count + membership asserted. -- `RTPO11d - values returns empty for non-LiveMap` (RTPO11d/values-non-map-empty-0) — `vals IS Array` omitted; count == 0 asserted. - ---- - -## RTLM20 / RTLM21 — set/remove wire-message-shape assertions are internal; assert observable local effect - -**Spec points:** RTLM20e2, RTLM20e3, RTLM20e6, RTLM20e7b, RTLM20e7c, RTLM20e7d, RTLM20e7e, RTLM20e7f, RTLM20h2, RTLM21e2, RTLM21e5 -**What the spec requires:** `set` / `remove` send an OBJECT ProtocolMessage whose captured wire form is asserted directly — `captured_messages[0].state[0].operation.action == "MAP_SET" / "MAP_REMOVE"`, `operation.objectId == "root"`, `mapSet.key`, `mapSet.value.string / .number / .boolean / .json / .bytes` (base64), `mapRemove.key`. -**What the SDK does:** ably-java's public `LiveMapPathObject.set` / `remove` return a `CompletableFuture`; the bytes that go on the wire are internal `WireObjectMessage` objects in `ProtocolMessage.state` (`Object[]`), inaccessible through the public API (mapping §13). The public-observable consequence is that, once the operation is ACKed and echoed, it applies to the local graph. -**Workaround in tests:** Perform the public write, then assert the equivalent observable effect via a local round-trip read after the auto-ACK echo applies (`root.get(key).as().value()` for set, `getType() == null` for remove), polling for application. The exact wire-message shape is not asserted. -**Tests affected (InternalLiveMapApiTest.kt):** -- `RTLM20 - set sends MAP_SET message` (RTLM20/set-sends-map-set-0) -- `RTLM20 - set with different value types` (RTLM20/set-value-types-0) -- `RTLM20 - set with bytes value type` (RTLM20/set-bytes-value-0) -- `RTLM21 - remove sends MAP_REMOVE message` (RTLM21/remove-sends-map-remove-0) - ---- - -## RTLM20e7g / RTLM20h1 — value-type CREATE-message generation/ordering is internal; assert resolved object - -**Spec points:** RTLM20e7g1, RTLM20e7g2, RTLM20h1, RTLMV4d1, RTLMV4d2 (also RTLCV4 / RTLMV4 evaluation) -**What the spec requires:** Setting a `LiveCounter` / `LiveMap` value type produces an OBJECT whose `state` array contains the generated `*_CREATE` ObjectMessages followed by a `MAP_SET`, in depth-first order, with the `MAP_SET`'s `mapSet.value.objectId` referencing the final CREATE's `objectId` (and `objectId` prefixes `counter:` / `map:`). -**What the SDK does:** The evaluation of a value type into an ordered list of `*_CREATE` wire messages, nonce/objectId derivation, and the cross-referencing objectIds are all internal wire-level concerns (mapping §13) — not reachable through the public typed API. The public-observable consequence is that the new nested object is created and resolvable at the key. -**Workaround in tests:** Perform the public write, then assert the equivalent observable effect: the new value resolves to a `LIVE_COUNTER` / `LIVE_MAP` with its initial value/entries (and, for the nested case, the nested counter and primitive resolve). The CREATE-message count, ordering, and objectId cross-references are not asserted. -**Tests affected (InternalLiveMapApiTest.kt):** -- `RTLM20e7g - set with LiveCounter generates COUNTER_CREATE plus MAP_SET` (RTLM20e7g/set-counter-value-type-0) -- `RTLM20e7g - set with LiveMap generates nested CREATE plus MAP_SET` (RTLM20e7g/set-map-value-type-0) -- `RTLM20h1 - set with nested LiveMap containing LiveCounter` (RTLM20h1/set-nested-value-types-0) - ---- - -## RTLM20 / RTLMV4c — invalid set value types (function / undefined / symbol) not expressible - -**Spec points:** RTLM20e1, RTLMV4c -**What the spec requires:** A table-driven test feeds unsupported runtime values (a function, `undefined`, a symbol) into `set` and expects each to fail with `ErrorInfo` code `40013`. -**What the SDK does:** ably-java's `LiveMapPathObject.set(String, LiveMapValue)` accepts only a `LiveMapValue`, and `LiveMapValue.of(...)` is overloaded solely for the supported types (Boolean, Binary/byte[], Number, String, JsonArray, JsonObject, LiveCounter, LiveMap). There is no overload that accepts a function / undefined / symbol, so these inputs are rejected at compile time (mapping §6) — the runtime `40013` assertion cannot be expressed. -**Workaround in tests:** The test body is a documented no-op explaining the compile-time rejection; no runtime assertion is made. -**Tests affected (InternalLiveMapApiTest.kt):** -- `RTLM20 - invalid set value types` (RTLM20/set-invalid-values-table-0) - ---- - -## RTLC12e2 / RTLC12e3 / RTLC12e5 / RTLC13b — outbound COUNTER_INC wire message is internal - -**Spec points:** RTLC12e2, RTLC12e3, RTLC12e5, RTLC13b -**What the spec requires:** After `increment(n)` / `decrement(n)`, inspect the published OBJECT message's wire form — `captured.state[0].operation.action == "COUNTER_INC"`, `.operation.objectId == "counter:score@1000"`, `.operation.counterInc.number == n` (and `== -15` for decrement, proving decrement negates the amount). -**What the SDK does:** The outbound wire types (`WireObjectMessage` / `WireObjectOperation` / `WireCounterInc`) are `internal` to `:liveobjects` and not part of the public API; there is no public accessor for the message a `LiveCounterPathObject.increment` / `.decrement` publishes. -**Workaround in tests:** The captured outbound `ProtocolMessage` is found in `mockWs.events` (`MessageFromClient` with `action == object`), and its `state[0]` wire object's `operation` / `action` / `objectId` / `counterInc.number` are read by reflection (the same reflection technique `helpers.kt` and `PublicObjectMessageTest.kt` use for internal `:liveobjects` types). Where the spec also provides an observable value outcome (decrement → `value() == 85`), that is asserted directly via the public API. -**Tests affected (InternalLiveCounterApiTest.kt):** -- `RTLC12 - increment sends v6 COUNTER_INC message` (RTLC12/increment-sends-counter-inc-0) -- `RTLC13 - decrement delegates to increment with negated amount` (RTLC13/decrement-negates-0) - ---- - -## RTLC11b1 — LiveCounterUpdate diff (`update.amount`) not exposed on public event - -**Spec point:** RTLC11b1 -**What the spec requires:** Subscribing to a counter `instance` and incrementing it emits an event whose `message.operation.counterInc.number` (the increment amount) equals the applied value (`updates[0].message.operation.counterInc.number == 7`). -**What the SDK does:** ably-java's public `InstanceSubscriptionEvent` carries no internal `LiveCounterUpdate` diff (no `update.amount` accessor — that is the internal RTLO4b update). It does expose the originating public `ObjectMessage` via `getMessage()`, whose `operation.counterInc.number` carries the amount. -**Workaround in tests:** Assert `event.getMessage().operation.counterInc.number == 7.0` (and `operation.action == COUNTER_INC`) instead of an `update.amount` diff field. -**Tests affected (InternalLiveCounterApiTest.kt):** -- `RTLC11 - LiveCounterUpdate emitted on increment` (RTLC11/counter-update-on-inc-0) - ---- - -## RTLC12e1 — non-Number increment amounts are compile errors, not 40003 runtime failures - -**Spec point:** RTLC12e1 -**What the spec requires:** `increment(amount)` throws `ErrorInfo` code `40003` when `amount` is `null`, not a Number, not finite, or NaN — exercised both singly (`increment("not_a_number")`) and as a table (`null`, `NaN`, `±Infinity`, `"10"`, `true`, `[1,2]`, `{n:1}`). -**What the SDK does:** ably-java's `LiveCounterPathObject.increment(@NotNull Number)` accepts only a non-null `Number`. The non-Number rows (`null`, String, Boolean, array, object) are rejected by the type system at compile time, so they cannot be written as runtime assertions. The numeric-but-invalid rows (`NaN`, `+Infinity`, `-Infinity`) are valid `Double` values and remain expressible runtime `40003` assertions. -**Workaround in tests:** The non-Number cases are dropped with an inline note; the non-finite `Double` cases (`NaN`, `±Infinity`) are exercised and asserted to fail with `40003`. The dedicated single-case `increment-non-number-0` test is reduced to a documented placeholder for the same reason. -**Tests affected (InternalLiveCounterApiTest.kt):** -- `RTLC12e1 - increment with non-number throws` (RTLC12e1/increment-non-number-0) — not expressible; placeholder. -- `RTLC12e1 - Table-driven invalid increment amounts` (RTLC12e1/increment-invalid-amounts-table-0) — non-Number rows dropped; non-finite rows exercised. - ---- - -## RTLC12b / RTLC12c / RTLC12d — increment write-preconditions relocated to RTO26; no executable spec content - -**Spec points:** RTLC12b, RTLC12c, RTLC12d -**What the spec requires:** The spec entry `objects/unit/RTLC12b/increment-requires-publish-0` carries no Setup/Test Steps/Assertions — its body states that RTLC12b/c/d (the OBJECT_PUBLISH-mode, channel-state and echoMessages write preconditions) "have been replaced by RTO26" and are "tested separately in `objects/unit/rto26_write_preconditions.md`". -**What the SDK does:** N/A — there is no executable spec content to translate from this entry; the precondition behaviour is covered by the RTO26 spec instead. -**Workaround in tests:** The empty marker entry is intentionally not translated into `InternalLiveCounterApiTest.kt`; the preconditions belong with an RTO26 translation, which is not part of this module's current scope. -**Tests affected (InternalLiveCounterApiTest.kt):** -- `objects/unit/RTLC12b/increment-requires-publish-0` — no corresponding `@Test`; relocated to RTO26, no executable spec content. - ---- - -## RTO15 — `RealtimeObject.publish` and its OBJECT/ACK wire-message assertions are internal, not public - -**Spec point:** RTO15 (RTO15e1, RTO15e2, RTO15e3, RTO15h) -**What the spec requires:** `channel.object.publish([objectMessages])` sends an OBJECT `ProtocolMessage` whose captured wire form is asserted directly — `captured_messages[0].action == OBJECT`, `.channel == "test"`, `.state.length == 1` — and returns a `PublishResult` from the ACK whose `serials == ["serial-0"]`. -**What the SDK does:** ably-java's `RealtimeObject` exposes no public `publish` method — `publish` / `publishAndApply` (RTO15 / RTO20) are marked `internal` in the IDL (mapping §13). The only public mutators are the typed `set` / `remove` / `increment` / `decrement` on the path/instance views, which return `CompletableFuture` (no `PublishResult`). The OBJECT `ProtocolMessage.state` entries are internal `WireObjectMessage` objects (`Object[]`), and the ACK `PublishResult` is consumed internally to drive the local apply — neither is reachable through the public API. -**Workaround in tests:** None expressible against the public surface. The publish-and-apply *effect* (RTO20) is covered observably elsewhere in this file (e.g. `RTO20 - publishAndApply applies locally on ACK` asserts `value() == 110` after a public `increment`). The RTO15 test body is a documented no-op. -**Tests affected (RealtimeObjectTest.kt):** -- `RTO15 - publish sends OBJECT ProtocolMessage` (RTO15/publish-sends-object-pm-0) — not expressible; documented no-op. - ---- - -## RTO23f — `get()` result "IS PathObject" is guaranteed by the `LiveMapPathObject` return type - -**Spec point:** RTO23f -**What the spec requires:** The object returned by `channel.object.get()` is a `PathObject`, asserted with `ASSERT root IS PathObject` (RTO23f — always a `LiveMapPathObject`). -**What the SDK does:** ably-java's `RealtimeObject.get()` is statically typed to return `CompletableFuture`, and `LiveMapPathObject` is a `PathObject` sub-type (mapping §2, §4). The result's PathObject-ness is proven by the return type at compile time, so a runtime `IS PathObject` check is a tautology that cannot fail. -**Workaround in tests:** The observable behaviour around the call (`path() == ""`, channel-state transitions) is asserted; the `IS PathObject` type-tautology line is omitted, with an inline note at the call site. -**Tests affected (RealtimeObjectTest.kt):** -- `RTO23 - get returns PathObject wrapping root` (RTO23/get-returns-path-object-0) — `root IS PathObject` omitted; `path() == ""` asserted. -- `RTO23 - get implicitly attaches channel` (RTO23/get-implicit-attach-0) — `root IS PathObject` omitted; channel state + `path() == ""` asserted. -- `RTO23d - get resolves immediately when already SYNCED` (RTO23d/get-resolves-immediately-synced-0) — `root2 IS PathObject` omitted; `path() == ""` asserted. - ---- - -## RTLCV3 / RTLMV3 — value-type internal count / entries have no public accessor - -**Spec points:** RTLCV3b, RTLCV3a1, RTLMV3b, RTLMV3a1 -**What the spec requires:** A constructed value type exposes its internal blueprint state for inspection — `LiveCounter.create(42).count == 42`, `LiveCounter.create().count == 0`, `LiveMap.create({...}).entries["name"] == "Alice"`. -**What the SDK does:** ably-java's `LiveCounter` / `LiveMap` value types are opaque immutable holders: the initial count / entries are "held internally by the implementation; [they have] no public accessor" (their Javadoc). Only the static `create(...)` factory and the abstract type identity are observable; there is no `count` / `entries` getter. -**Workaround in tests:** Assert construction succeeds and the result `is LiveCounter` / `is LiveMap` (the value-type identity). The internal `count` / `entries` sub-assertions are dropped with an inline note. -**Tests affected (ValueTypesTest.kt):** -- `RTLCV3 - LiveCounter create with initial count` (RTLCV3/create-with-count-0) — `vt.count == 42` omitted. -- `RTLCV3 - LiveCounter create defaults to 0` (RTLCV3/create-default-zero-0) — `vt.count == 0` omitted. -- `RTLMV3 - LiveMap create with entries` (RTLMV3/create-with-entries-0) — `vt.entries[...]` omitted. - ---- - -## RTLCV4 / RTLMV4 — value-type `evaluate()` ObjectMessage generation is internal/wire-level - -**Spec points:** RTLCV4 (RTLCV4b1, RTLCV4c, RTLCV4d, RTLCV4f, RTLCV4g1–g5), RTLMV4 (RTLMV4e1, RTLMV4f, RTLMV4g, RTLMV4i, RTLMV4j1–j5, RTLMV4d1, RTLMV4d2, RTLMV4k, RTLMV4e2) -**What the spec requires:** Calling `evaluate(vt)` on a value type returns the list of generated `ObjectMessage`s and asserts on their internal/wire form — `operation.action == "COUNTER_CREATE"/"MAP_CREATE"`, `operation.objectId` `counter:`/`map:` prefix and `@`-suffixed RTO14 derivation, `counterCreateWithObjectId`/`mapCreateWithObjectId` with a 16+-char `nonce` and a JSON `initialValue`, the retained local `counterCreate`/`mapCreate` (`count == 42`, `semantics == "LWW"`, `entries[k].data.`), depth-first ordering of nested creates with cross-referencing `entries[k].data.objectId`, and `mapCreate.entries == {}` for empty entries. -**What the SDK does:** There is no public `evaluate` on the value types. Evaluation into an ordered list of `*_CREATE` wire messages, nonce / `initialValue` / `objectId` derivation, and the `counterCreateWithObjectId` / `mapCreateWithObjectId` wire forms are all internal/wire-level concerns (mapping §13), not reachable through the public typed API. (`PublicAPI::ObjectOperation` itself carries only the resolved `mapCreate`/`counterCreate`, never a `*WithObjectId` getter, per PAOOP1.) -**Workaround in tests:** Only the public construction is exercised (`create(...)` returns a `LiveCounter`/`LiveMap`). The message-generation, nonce/objectId, `initialValue`, retained-create, ordering and empty-entries assertions are dropped with inline notes. -**Tests affected (ValueTypesTest.kt):** -- `RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage` (RTLCV4/evaluate-generates-message-0) -- `RTLCV4g5 - Evaluation retains local CounterCreate` (RTLCV4g5/retains-local-counter-create-0) -- `RTLCV4 - Evaluation with count 0` (RTLCV4/evaluate-zero-count-0) -- `RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage` (RTLMV4/evaluate-generates-message-0) -- `RTLMV4j5 - Evaluation retains local MapCreate` (RTLMV4j5/retains-local-map-create-0) -- `RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages` (RTLMV4d1/nested-value-types-0) -- `RTLMV4e2 - Empty entries produces MapCreate with empty entries` (RTLMV4e2/empty-entries-0) -- `RTLMV4d - Entry value type mapping` (RTLMV4d/entry-value-types-0) — generated `data.` adapted to public `LiveMapValue` union inspection. -- `RTLMV4d - Table-driven MAP_SET value type mapping` (RTLMV4d/map-set-all-types-table-0) — generated `data[field]` (incl. base64 "AQID") adapted to public `LiveMapValue` union inspection. - ---- - -## RTLCV3c / RTLCV4a / RTLMV4a / RTLMV4b / RTLMV4c — wrong-typed value-type `create` args are compile errors, not runtime 40003/40013 - -**Spec points:** RTLCV3c, RTLCV4a, RTLMV4a, RTLMV4b, RTLMV4c -**What the spec requires:** No validation at creation time (RTLCV3c — `LiveCounter.create("not_a_number")` *succeeds* at create), with validation deferred to evaluation: `LiveCounter.create("not_a_number")` → 40003; `LiveMap.create(null)` → 40003; a non-String key (`{ 123: "value" }`) → 40003; an unsupported value (a function) → 40013. -**What the SDK does:** ably-java's signatures reject all of these at compile time (mapping §6): `LiveCounter.create(@NotNull Number)` rejects a String and rejects null; `LiveMap.create(@NotNull Map)` rejects null, enforces String keys, and the `LiveMapValue` union constructs only from the supported types (Boolean, byte[], Number, String, JsonArray, JsonObject, LiveCounter, LiveMap) — an unsupported value cannot be wrapped. So none of these inputs can be written, and the runtime 40003/40013 failures are not expressible. -**Workaround in tests:** Each test body is a documented no-op explaining the compile-time rejection; no runtime assertion is made. -**Tests affected (ValueTypesTest.kt):** -- `RTLCV3c - no validation at creation time` (RTLCV3c/no-validation-at-create-0) — the deliberately-invalid create arg can't be constructed, so "no error at create" isn't demonstrable either. -- `RTLCV4a - Evaluation validates count type` (RTLCV4a/evaluate-validates-count-0) -- `RTLMV4a - Evaluation validates entries type` (RTLMV4a/evaluate-validates-entries-0) -- `RTLMV4b - Evaluation validates key types` (RTLMV4b/evaluate-validates-keys-0) -- `RTLMV4c - Evaluation validates value types` (RTLMV4c/evaluate-validates-values-0) - ---- - -# 4) Intentional deviation — spec point under review (objects) -## RTO18d — `EventEmitter.on(event, listener)` deduplicates an identical listener instance +# Objects unit-tier deviations — moved -**Spec points:** RTO18d, RTE4 -**What the spec requires:** Registering the **same** listener instance twice for a sync-state event makes -it fire **twice** per emission (RTO18d / RTE4 — registrations are additive). -**What the SDK does:** ably-java's core `EventEmitter.on(event, listener)` stores listeners in a **Map keyed -by the listener instance** (`filters.put(listener, …)`), so a duplicate registration overwrites the first -and the listener fires **once**. This is a long-standing, **deliberate SDK-wide** choice (documented in -`EventEmitter.java`'s own Javadoc as a spec deviation), not a LiveObjects-specific accident. -**Status — intentional deviation (spec point questioned):** the RTO18d requirement is considered dubious — a -listener registered twice runs identical logic, so invoking it twice for one event serves no practical -purpose. ably-java therefore **keeps** the de-duplicating behaviour by design; the spec-correct assertion is -retained behind `RUN_DEVIATIONS` (green by default). No fix is planned unless the spec point is revised. If -compliance were ever required, it should be a **scope-limited** change to the LiveObjects emitters -(`ObjectsStateEmitter`), NOT the core `EventEmitter` — that class backs Connection/Channel/Presence (large -blast radius) and its `off()` would then need to remove *all* matching entries. -**Tests affected (RealtimeObjectTest.kt):** -- `RTO18d - Duplicate listener registered twice fires twice` — env-gated (intentional). +The objects **unit** suite (and its typed-SDK / language-adaptation deviation records, i.e. the former +groups 3 & 4 as they applied to objects) lives in `:liveobjects`'s own test source set: +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. This file keeps only the +deviations for the tiers hosted in `:uts` (realtime/rest, and objects integration/proxy). diff --git a/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md deleted file mode 100644 index b859d2eb8..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md +++ /dev/null @@ -1,286 +0,0 @@ -# Private Deviations — Objects UTS specs that cannot (yet) be translated - -> **Scope.** This file complements [`deviations.md`](./deviations.md). `deviations.md` records -> *per-test* deviations inside tests that **were** translated and compile. This file records the -> opposite: whole UTS spec files from the `objects` module that **could not be translated into the -> `uts` module at all**, why, and what would unblock them. It is written for a human reviewer / the -> LiveObjects implementers — not consumed by any tooling. - ---- - -## 1. Status of all 15 `objects/unit` specs - -| # | UTS spec (`objects/unit/…`) | ably-java test class | Status | Layer it targets | -|---|---|---|---|---| -| 1 | `instance.md` | `InstanceTest` | ✅ Translated | Public view (`Instance`) | -| 2 | `internal_live_counter.md` | `InternalLiveCounterTest` | ⛔ **Blocked** | **Internal CRDT node** | -| 3 | `internal_live_counter_api.md` | `InternalLiveCounterApiTest` | ✅ Translated | Public view | -| 4 | `internal_live_map.md` | `InternalLiveMapTest` | ⛔ **Blocked** | **Internal CRDT node** | -| 5 | `internal_live_map_api.md` | `InternalLiveMapApiTest` | ✅ Translated | Public view | -| 6 | `live_object_subscribe.md` | `LiveObjectSubscribeTest` | ✅ Translated | Public view | -| 7 | `object_id.md` | `ObjectIdTest` | ⛔ **Blocked** | **Internal (object-id gen)** | -| 8 | `objects_pool.md` | `ObjectsPoolTest` | ⛔ **Blocked** | **Internal (`ObjectsPool`)** | -| 9 | `parent_references.md` | `ParentReferencesTest` | ⛔ **Blocked** | **Internal (parent graph)** | -| 10 | `path_object.md` | `PathObjectTest` | ✅ Translated | Public view (`PathObject`) | -| 11 | `path_object_mutations.md` | `PathObjectMutationsTest` | ✅ Translated | Public view | -| 12 | `path_object_subscribe.md` | `PathObjectSubscribeTest` | ✅ Translated | Public view | -| 13 | `public_object_message.md` | `PublicObjectMessageTest` | ✅ Translated | Public message layer | -| 14 | `realtime_object.md` | `RealtimeObjectTest` | ✅ Translated (mixed) | Public `get()` + sync events | -| 15 | `value_types.md` | `ValueTypesTest` | ✅ Translated (mixed) | Public `create` surface | - -**10 translated, 5 blocked.** The 5 blocked specs are the subject of this document. - -> Note: the translated specs that depend on `setupSyncedChannel` (most of the public-view tests) -> compile today but only *run* once the SDK's `OBJECT_SYNC` processing + `RealtimeObject.get()` land. -> That is the same missing engine described below — see [`deviations.md`](./deviations.md) and the -> `helpers.kt` header for the per-test runtime caveat. The blocked specs below are a stronger case: -> they cannot even be *written*. - ---- - -## 2. Why these 5 specs target internals - -The objects spec is layered into three tiers (see the skill's `objects-mapping.md`): - -1. **Creation value types** — the immutable `LiveMap` / `LiveCounter` blueprints you pass *into* `set`. -2. **Public read/write view** — `PathObject` / `Instance`, what user code navigates and subscribes on. -3. **Internal CRDT graph** — the live conflict-free replicated nodes, the object pool, object-id - generation and the parent-reference graph. This is the convergence engine. - -The 10 translated specs live in tiers 1–2. The 5 blocked specs **are** tier 3. They have to assert on -internal state because the behaviour they pin down — last-write-wins arbitration by site-serial, -idempotent re-application, tombstones, create-op merging, garbage collection, object-id derivation — -is **not observable through the public API**. You cannot verify "the second of two concurrent ops -loses by site-serial" with `get()`/`value()`; you have to reach the node's `siteTimeserials` and call -`applyOperation` directly. So the spec is correct to test internals — that is where the hard logic is. - ---- - -## 3. The two blockers (in order of severity) - -### Blocker A — the internal implementation does not exist yet *(primary)* - -`:liveobjects` currently implements the **view** layer only. A symbol search of -`liveobjects/src/main/kotlin/io/ably/lib/liveobjects` confirms the CRDT engine these specs assert on -is absent: - -| Symbol required by the blocked specs | Found in `:liveobjects`? | -|---|---| -| `ObjectsPool` (the live object pool) | ❌ 0 references | -| `generateObjectId` / object-id derivation (`RTO14`) | ❌ 0 references | -| `applyOperation(...)` (apply op to a live node) | ❌ 0 references | -| `replaceData(...)` | ❌ 0 references | -| `createOperationIsMerged` | ❌ 0 references | -| parent-reference graph (`parentRef…`) | ❌ 0 references | -| pool `syncState` | ❌ 0 references | -| `siteTimeserials` | ⚠️ only on the **wire DTO** (`WireObjectState` / `WireObjectsMapEntry`), not on a live CRDT node | - -What *does* exist: `DefaultPathObject`, `DefaultInstance`, the typed `Default*PathObject` / -`Default*Instance` views, the `value/` creation types, and the `message/` + `serialization/` wire layer. -There is **no live `InternalLiveMap` / `InternalLiveCounter` node, no `ObjectsPool`, and no -operation-application engine.** - -**Consequence:** even with perfect cross-module visibility there is nothing to instantiate or assert -against. These tests cannot be authored until the engine is implemented. - -### Blocker B — Kotlin `internal` is not visible across the module boundary *(secondary, applies once A is done)* - -When the engine *is* implemented it will (by the codebase's convention, and because `:liveobjects` -uses `explicitApi()`) be declared `internal` — exactly like the existing `Default*` classes -(`internal class DefaultLiveMap`, `internal class DefaultPathObject`, …). - -Kotlin's `internal` is scoped to a **module** = one compilation unit = one Gradle source set's compile -task. The `:uts` test source set is a *different* module from `:liveobjects`'s `main`. The Kotlin -compiler enforces `internal` across that boundary **regardless of dependency classpath scope**. So -`:uts` test code cannot name those declarations at compile time. - -This is why the existing helper `buildPublicObjectMessage` (in `helpers.kt`) reaches the internal -wire/message classes by **reflection** (`Class.forName(...)`), enabled by the current -`testRuntimeOnly(project(":liveobjects"))` — runtime-only access. Reflection works for a handful of -constructor/field hops but is the wrong tool for whole-CRDT-state assertions (no type safety, brittle, -unreadable). - ---- - -## 4. Per-spec detail - -| Spec | What it asserts on | Required internal symbols | Blocked by | -|---|---|---|---| -| **2 `internal_live_counter.md`** | internal counter node state after applying ops | `InternalLiveCounter` (`.data`, `.siteTimeserials`, `.createOperationIsMerged`, `applyOperation`, `replaceData`) | A + B | -| **4 `internal_live_map.md`** | internal map node state after applying ops | `InternalLiveMap` (`.data`, `.siteTimeserials`, `.isTombstone`, `applyOperation`, `replaceData`) | A + B | -| **7 `object_id.md`** | object-id generation & parsing | `generateObjectId` / object-id type (`RTO14`), `*WithObjectId` derivation | A + B | -| **8 `objects_pool.md`** | the object pool and its sync lifecycle | `ObjectsPool`, `.syncState`, pool entry add/get/clear | A + B | -| **9 `parent_references.md`** | the reverse parent-reference graph | parent-reference tracking on the pool/nodes | A + B | - -> Specs 2 and 4 have public counterparts (`internal_live_counter_api.md` / `internal_live_map_api.md`, both translated) -> that cover the *outcome* of these operations through the public API. Specs 7–9 have **no** public -> counterpart — they are purely internal and have no representation in tiers 1–2. - ---- - -## 5. Solution options - -The ask was to make all of `liveobjects/src/main/kotlin/io/ably/lib/liveobjects` visible to `:uts`, -probably by changing `testRuntimeOnly(project(":liveobjects"))` to `testImplementation(...)`. Here is -the accurate picture, as lead dev. - -### 5.1 Why the `testRuntimeOnly` → `testImplementation` swap alone is *not* sufficient - -```kotlin -// uts/build.gradle.kts — current -testRuntimeOnly(project(":liveobjects")) // runtime classpath only → reflection-only access - -// proposed -testImplementation(project(":liveobjects")) // adds COMPILE classpath too -``` - -`testImplementation` puts `:liveobjects` on the **compile** classpath, which lets `:uts` reference its -**public** API directly (and lets the reflection helpers drop some `Class.forName`). But it does **not** -grant access to `internal` declarations: Kotlin enforces `internal` at the *module* boundary at -compile time, and a dependency-scope change does not cross that boundary. The CRDT engine will be -`internal`, so the swap by itself does not unblock these tests. It is **necessary but not sufficient.** - -### 5.2 The Gradle/Kotlin configs that *can* expose internals - -There is exactly **one** primitive that grants one Kotlin compilation access to another's `internal` -declarations: the compiler flag **`-Xfriend-paths`**. Everything below is either that flag directly, or -a higher-level wrapper around it. - -**(a) `associateWith()` — the *supported* form, but intra-project only.** -The Kotlin Gradle plugin exposes friend-paths through the `associateWith` API on compilations: - -```kotlin -kotlin.target.compilations.getByName("test") - .associateWith(kotlin.target.compilations.getByName("main")) -``` - -This is how a module's own `test` source set sees its `main` internals (the plugin wires it up -automatically), and how you'd give a *custom* source set (e.g. `integrationTest`) the same access. It is -stable and IDE-aware — **but only between compilations of the same Gradle project.** There is no -supported way to `associateWith` a compilation in a *different* project (`:uts` test ↔ `:liveobjects` -main). Source: KTIJ-7662, KT associated-compilations docs. - -**(b) Raw `-Xfriend-paths` across projects — works, but unstable/unsupported.** -You can manually point `:uts`'s test-compile task at `:liveobjects`'s `main` output: -`-Xfriend-paths=…/liveobjects/build/classes/kotlin/main`. Per the Kotlin team this flag has *"no syntax, -no IDE support, and no guarantees of stability — a compiler implementation detail, not a language -feature."* It also hard-couples `:uts` to `:liveobjects`'s internal compile output path. **Not -recommended** for production build config. - -**(c) The future fix (not available yet): `shared internal` (KEEP-0451).** -The Kotlin team is *not* stabilizing `-Xfriend-paths`; instead KEEP-0451 proposes a first-class -`shared internal` visibility modifier — declarations visible to designated dependent modules but not -the general public. When it ships this is the clean answer, but it is a proposal today, not usable. - -### 5.3 Cleanest technical approach — write the internal-graph tests in `:liveobjects`'s own test source set - -> This is the lowest-ceremony option and what the SDK already does for its own internals, but it places -> the tests **outside `uts/unit`**. If keeping them under `uts/unit` is required, prefer §5.4(a) instead. - -`:liveobjects` **already has** a unit-test source set and task: - -```kotlin -// liveobjects/build.gradle.kts (existing) -tasks.register("runLiveObjectsUnitTests") { - filter { includeTestsMatching("io.ably.lib.liveobjects.unit.*") } -} -``` - -Tests placed under `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/…` see **all** of `main`'s -`internal` declarations automatically (the plugin sets the friend-path for a module's own tests). This -is the standard, supported way to test internal Kotlin code. - -**Plan once Blocker A is resolved (engine implemented):** -1. Author specs 2, 4, 7, 8, 9 in `:liveobjects`'s own test source set, e.g. package - `io.ably.lib.liveobjects.unit.uts`, mirroring the `uts` conventions (one `@Test` per spec case, a - `/** @UTS objects/unit/… */` KDoc tag, the `deviations.md` discipline). -2. Specs 7–9 (`object_id`, `objects_pool`, `parent_references`) are pure logic with no network/sync — - they will **run immediately** there, no mock-WebSocket harness needed. -3. Specs 2 and 4 need object state applied to a node; reuse / port the relevant `helpers.kt` builders. -4. Keep `:uts`'s `testRuntimeOnly(project(":liveobjects"))` as-is (reflection helpers stay valid), or - optionally promote to `testImplementation` purely for compile-time access to the **public** API. - -**Trade-off:** these five tests then live outside the `uts` module the skill normally targets. That is -acceptable and correct — they are internal-implementation tests, and the SDK already groups its own -internal tests under `:liveobjects`. The `@UTS` id convention keeps them traceable to the spec. - -### 5.4 Keeping the tests under `uts/unit` — what actually works - -This is the stated preference, so it gets its own analysis. To assert on `:liveobjects` internals from -test code that physically lives in `:uts`, the realistic options are: - -**(a) `java-test-fixtures` bridge — the recommended way to honour the `uts/unit` preference.** -Apply the `java-test-fixtures` plugin to `:liveobjects` and put a thin **inspection/bridge** layer in -`liveobjects/src/testFixtures/kotlin`. Fixture code *belongs to the module*, so it can touch -`:liveobjects` internals; it then re-exposes them as a small **public** API (e.g. -`fun applyAndSnapshot(...): PublicCounterSnapshot`). `:uts` consumes it with: - -```kotlin -// uts/build.gradle.kts -testImplementation(testFixtures(project(":liveobjects"))) -``` - -The **assertions stay in `uts/unit`** (calling the fixture's public API) — your preference is satisfied — -while no raw internal is leaked onto `:uts`'s classpath. Caveat: for the *fixture itself* to see Kotlin -`internal`, its compilation must be associated with `main` (Android exposes -`android.experimental.enableTestFixturesKotlinSupport`; for plain JVM Kotlin verify the testFixtures→main -`associateWith` is wired — it reduces back to §5.2(a), which is supported because it is intra-project). -Cost: you design and maintain the bridge surface. - -**(b) Reflection from `:uts` (status quo, no build change).** -Current `testRuntimeOnly(project(":liveobjects"))` already lets `:uts` reach internals by reflection at -runtime — this is what `buildPublicObjectMessage` does. Keeps tests in `uts/unit` with zero build -changes, but: stringly-typed, no compile-time safety, brittle to refactors, and verbose for whole-CRDT -assertions. Fine for a couple of accessors; poor for five spec files of state assertions. - -> **On `@VisibleForTesting`:** it does **not** change visibility. It is a documentation/lint hint that -> records what the visibility *would* be if not for tests; the actual access is still governed by the -> `public`/`internal` modifier. So "mark it `@VisibleForTesting`" only helps if you *also* make the -> member `public` (e.g. `@VisibleForTesting(otherwise = PRIVATE) public fun …`). It is not, by itself, a -> cross-module visibility mechanism. - ---- - -## 6. The realistic options - -Only **three** approaches are real candidates for our situation. (The mechanisms in §5.2 — -`associateWith`, raw `-Xfriend-paths`, `shared internal` — and the bare dependency-scope swap in §5.1 are -*not* viable on their own; see the note below.) - -| Option | Keeps tests in `uts/unit`? | Compile-safe? | Trade-off | -|---|---|---|---| -| **A. `java-test-fixtures` bridge** (§5.4a) | ✅ yes | ✅ yes | Design a small public snapshot surface in `:liveobjects`'s `testFixtures`. **Best fit for the preference.** | -| **B. Tests in `:liveobjects/src/test`** (§5.3) | ❌ no — live in `:liveobjects` | ✅ yes | Least effort; internals visible by design. The SDK already tests its own internals this way. | -| **C. Reflection from `:uts`** (§5.4b) | ✅ yes | ❌ no | No build change, but stringly-typed and brittle — fine for a few hops, poor for 5 spec files. | - -**Not viable on their own:** the bare `testRuntimeOnly` → `testImplementation` swap (exposes only the -*public* API, not `internal`); `associateWith` (supported but *intra-project* — cannot bridge `:uts` ↔ -`:liveobjects`); raw cross-project `-Xfriend-paths` (unstable/unsupported); `shared internal` / -KEEP-0451 (future proposal, not available yet). - -## 7. Recommendation & sequencing - -1. **Now:** nothing to translate for specs 2, 4, 7, 8, 9 — the internal engine they test is unbuilt - (Blocker A). Leave them blocked; this document is the record. -2. **When the LiveObjects CRDT engine (`ObjectsPool`, internal live nodes + `applyOperation`, - object-id generation, parent references) is implemented**, pick the visibility approach: - - **To honour the `uts/unit` preference (recommended):** the **`java-test-fixtures` bridge** (§5.4a) — - assertions stay in `uts/unit`, a small fixture in `:liveobjects` exposes the needed internal state - as a public snapshot. Compile-safe and supported. - - **If colocation isn't required:** author them in **`:liveobjects/src/test`** (§5.3) — least - ceremony, internals visible by design (the SDK already tests its own internals this way). -3. **Avoid** the bare `testImplementation` swap *as the internal-access mechanism* (it only exposes the - public API) and the manual cross-project `-Xfriend-paths` hack (unsupported, fragile). - ---- - -## 8. References - -- Kotlin associated compilations / `associateWith` (intra-project internal access): - KTIJ-7662, Kotlin Multiplatform "Configure compilations" docs. -- `-Xfriend-paths` is an unstable compiler detail; future `shared internal` modifier: KEEP-0451 - ("Shared Internals" proposal). -- `@VisibleForTesting` is a lint/documentation hint and does not change visibility. -- Gradle `java-test-fixtures`: test-fixtures code has access to the module's internal API and is - consumed via `testImplementation(testFixtures(project(":…")))`; Kotlin support may require - associating the testFixtures compilation with `main`. diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt deleted file mode 100644 index 85f3dce61..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/Helpers.kt +++ /dev/null @@ -1,419 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import io.ably.lib.liveobjects.message.ObjectMessage -import io.ably.lib.liveobjects.path.types.LiveMapPathObject -import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ChannelOptions -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.PublishResult -import io.ably.lib.uts.infra.unit.ConnectionDetails -import io.ably.lib.uts.infra.unit.MockWebSocket -import io.ably.lib.uts.infra.unit.TestRealtimeClient -import kotlinx.coroutines.future.await - -/** - * LiveObjects unit-test helpers — the ably-java translation of the UTS - * `objects/helpers/standard_test_pool.md` (standard test pool, protocol-message / - * object-message builders, and the synced-channel setup) used by every objects - * unit spec. - * - * Status: - * - The builders construct the **wire JSON** form (Gson [JsonObject]) of each object message, then convert - * it to the SDK's internal `WireObjectMessage` (reflectively, via `JsonSerializationKt.toObjectMessage`) - * before placing it in [ProtocolMessage.state] (`Object[]`). The conversion is required because the SDK's - * `@JsonAdapter(ObjectJsonSerializer)` outbound path casts each `state` element to `WireObjectMessage`; - * raw `JsonObject`s would throw `ClassCastException` when the mock serializes the frame. The file still - * compiles against `:java` only (the reflection targets the runtime-only `:liveobjects` dependency). - * - [buildPublicObjectMessage] reaches the implemented message/operation layer in `:liveobjects` by - * reflection (`testRuntimeOnly(project(":liveobjects"))` in build.gradle.kts). - * - [setupSyncedChannel] drives CONNECTED -> ATTACH/ATTACHED(HAS_OBJECTS) -> OBJECT_SYNC over the existing - * [MockWebSocket], then awaits `channel.object.get()` — which now resolves against the implemented SDK - * engine (OBJECT_SYNC processing + `RealtimeObject.get()`), so the generated tests run. - */ - -// --------------------------------------------------------------------------- -// small Gson DSL -// --------------------------------------------------------------------------- - -private fun json(build: JsonObject.() -> Unit): JsonObject = JsonObject().apply(build) -private fun JsonObject.str(key: String, value: String) = addProperty(key, value) -private fun JsonObject.num(key: String, value: Number) = addProperty(key, value) -private fun JsonObject.bool(key: String, value: Boolean) = addProperty(key, value) - -// --------------------------------------------------------------------------- -// Canonical constants (standard_test_pool.md "Canonical Constants") -// --------------------------------------------------------------------------- - -/** The harness `ConnectionDetails` siteCode delivered on CONNECT. */ -const val SITE_CODE: String = "test-site" - -/** - * The fixed apply-on-ACK serial scheme: `ack_serial(msgSerial, i) == "t:${msgSerial + 1}:$i"`, so the - * first publish's first op is `t:1:0`. The value must sort AFTER the standard pool's `t:0` entry - * timeserials under string LWW comparison (RTLM9) — otherwise locally applied MAP_SETs on existing pool - * entries would be rejected as stale. Replay tests that reuse the apply-on-ACK serial MUST reference this. - */ -fun ackSerial(msgSerial: Long?, i: Int): String = "t:${(msgSerial ?: 0) + 1}:$i" - -/** - * Baseline timeserial every standard-pool entry/object is seeded with; every synthetic serial is chosen - * relative to this under lexicographic string LWW comparison (RTLM9e). - */ -const val POOL_SERIAL: String = "t:0" - -/** - * A REMOTE inbound MAP_SET / MAP_REMOVE serial on an EXISTING pool entry: it sorts after [POOL_SERIAL], so it - * wins the per-entry LWW comparison (RTLM9e). A bare number like `"99"` sorts BEFORE `"t:0"` and would be - * rejected as stale. 0-based → `remoteSerial(0) == "t:1"`, `remoteSerial(1) == "t:2"`. (Counter increments and - * other object-level ops from a fresh siteCode compare per-site, not per-entry, so they apply regardless of - * serial value and need NOT use this.) - */ -fun remoteSerial(i: Int): String = "t:${i + 1}" - -/** - * A serial that is NOT an [ackSerial] (so it escapes the RTO9a3 apply-on-ACK echo dedup) yet sorts BELOW the - * first ackSerial (`ackSerial(0, 0) == "t:1:0"`), while still after [POOL_SERIAL]. Used by RTO20f to prove a - * LOCAL apply-on-ACK left siteTimeserials untouched (RTLC7c): had it wrongly recorded - * `siteTimeserials[SITE_CODE] = "t:1:0"`, this lower serial would be rejected by the per-site newness check. - * 0-based → `belowAckSerial(9) == "t:0:9"`. - */ -fun belowAckSerial(i: Int): String = "t:0:$i" - -// --------------------------------------------------------------------------- -// ObjectData (leaf value) wire builders — the `data` of a map entry / mapSet -// --------------------------------------------------------------------------- - -fun dataString(value: String): JsonObject = json { str("string", value) } -fun dataNumber(value: Number): JsonObject = json { num("number", value) } -fun dataBoolean(value: Boolean): JsonObject = json { bool("boolean", value) } -fun dataObjectId(objectId: String): JsonObject = json { str("objectId", objectId) } -fun dataBytes(base64: String): JsonObject = json { str("bytes", base64) } -// Wire format OD4c5: the `json` leaf is a *stringified* JSON value, not a raw element. The SDK's -// WireObjectDataJsonSerializer reads it via `get("json").asString`, so it must be encoded as a string. -fun dataJson(element: JsonElement): JsonObject = json { str("json", element.toString()) } - -// --------------------------------------------------------------------------- -// map / counter state + createOp fragments -// --------------------------------------------------------------------------- - -fun mapEntry(data: JsonObject, timeserial: String = POOL_SERIAL, tombstone: Boolean? = null): JsonObject = json { - add("data", data) - str("timeserial", timeserial) - tombstone?.let { bool("tombstone", it) } -} - -/** - * Wire enum codes — the objects JSON protocol carries `action` / `semantics` as integer codes - * (`WireObjectOperationAction` / `WireObjectsMapSemantics`), not strings. The SDK's Gson decodes them by - * code, so the builders must emit the code for messages to deserialize. - */ -private object Action { - const val MAP_CREATE = 0 - const val MAP_SET = 1 - const val MAP_REMOVE = 2 - const val COUNTER_CREATE = 3 - const val COUNTER_INC = 4 - const val OBJECT_DELETE = 5 - const val MAP_CLEAR = 6 -} -private const val SEMANTICS_LWW = 0 - -fun mapState(entries: Map, semantics: Int = SEMANTICS_LWW): JsonObject = json { - num("semantics", semantics) - add("entries", json { entries.forEach { (k, v) -> add(k, v) } }) -} - -fun counterState(count: Number): JsonObject = json { num("count", count) } - -fun mapCreateOp(semantics: Int = SEMANTICS_LWW, entries: Map = emptyMap()): JsonObject = - json { num("action", Action.MAP_CREATE); add("mapCreate", mapState(entries, semantics)) } - -fun counterCreateOp(count: Number): JsonObject = - json { num("action", Action.COUNTER_CREATE); add("counterCreate", json { num("count", count) }) } - -// --------------------------------------------------------------------------- -// ObjectMessage builders — STATE (for OBJECT_SYNC) and OPERATIONS (for OBJECT) -// --------------------------------------------------------------------------- - -/** `build_object_state` — an ObjectMessage wrapping an ObjectState in its `object` field. */ -fun buildObjectState( - objectId: String, - siteTimeserials: Map, - map: JsonObject? = null, - counter: JsonObject? = null, - tombstone: Boolean? = null, - createOp: JsonObject? = null, -): JsonObject = json { - add( - "object", - json { - str("objectId", objectId) - add("siteTimeserials", json { siteTimeserials.forEach { (k, v) -> str(k, v) } }) - map?.let { add("map", it) } - counter?.let { add("counter", it) } - bool("tombstone", tombstone ?: false) // WireObjectState.tombstone is non-nullable - // The createOp is a WireObjectOperation whose objectId must equal the object's id - // (LiveMapManager.validate / validateObjectId). Inject it so the create validates. - createOp?.let { op -> - if (!op.has("objectId")) op.addProperty("objectId", objectId) - add("createOp", op) - } - }, - ) -} - -/** - * `build_object_message_with_state` — wraps an already-built ObjectState (the inner `object` payload) in - * an ObjectMessage. [buildObjectState] builds the state and wraps it in one step; this is the wrap-only - * form used where a bare ObjectState needs to become an ObjectMessage (e.g. `replaceData`). - */ -fun buildObjectMessageWithState(objectState: JsonObject): JsonObject = json { add("object", objectState) } - -private fun objectMessage( - serial: String?, - siteCode: String?, - serialTimestamp: Long? = null, - operation: JsonObject, -): JsonObject = json { - serial?.let { str("serial", it) } - siteCode?.let { str("siteCode", it) } - serialTimestamp?.let { num("serialTimestamp", it) } - add("operation", operation) -} - -fun buildCounterInc(objectId: String, number: Number, serial: String? = null, siteCode: String? = null): JsonObject = - objectMessage(serial, siteCode, operation = json { - num("action", Action.COUNTER_INC); str("objectId", objectId); add("counterInc", json { num("number", number) }) - }) - -fun buildMapSet(objectId: String, key: String, value: JsonObject, serial: String? = null, siteCode: String? = null): JsonObject = - objectMessage(serial, siteCode, operation = json { - num("action", Action.MAP_SET); str("objectId", objectId) - add("mapSet", json { str("key", key); add("value", value) }) - }) - -fun buildMapRemove(objectId: String, key: String, serial: String? = null, siteCode: String? = null, serialTimestamp: Long? = null): JsonObject = - objectMessage(serial, siteCode, serialTimestamp, operation = json { - num("action", Action.MAP_REMOVE); str("objectId", objectId); add("mapRemove", json { str("key", key) }) - }) - -fun buildMapClear(objectId: String, serial: String? = null, siteCode: String? = null): JsonObject = - objectMessage(serial, siteCode, operation = json { - num("action", Action.MAP_CLEAR); str("objectId", objectId) - }) - -fun buildObjectDelete(objectId: String, serial: String? = null, siteCode: String? = null, serialTimestamp: Long? = null): JsonObject = - objectMessage(serial, siteCode, serialTimestamp, operation = json { - num("action", Action.OBJECT_DELETE); str("objectId", objectId) - }) - -fun buildCounterCreate(objectId: String, counterCreate: JsonObject, serial: String? = null, siteCode: String? = null): JsonObject = - objectMessage(serial, siteCode, operation = json { - num("action", Action.COUNTER_CREATE); str("objectId", objectId); add("counterCreate", counterCreate) - }) - -fun buildMapCreate(objectId: String, mapCreate: JsonObject, serial: String? = null, siteCode: String? = null): JsonObject = - objectMessage(serial, siteCode, operation = json { - num("action", Action.MAP_CREATE); str("objectId", objectId); add("mapCreate", mapCreate) - }) - -// --------------------------------------------------------------------------- -// ProtocolMessage builders -// --------------------------------------------------------------------------- - -/** - * The SDK carries object messages in [ProtocolMessage.state] as an `Object[]` of internal - * `WireObjectMessage` instances (its `@JsonAdapter(ObjectJsonSerializer)` outbound path casts each - * element to `WireObjectMessage`). Our builders produce the **wire JSON** ([JsonObject]) form, so we - * must convert each to a `WireObjectMessage` before placing it in `state` — otherwise the mock's - * outbound serialization (`Serialisation.gson.toJson`) throws `ClassCastException` and the frame is - * never delivered. We reach the internal `JsonSerializationKt.toObjectMessage(JsonObject)` by - * reflection (same runtime-only technique as [buildPublicObjectMessage]). - */ -private val toWireObjectMessageMethod by lazy { - Class.forName("io.ably.lib.liveobjects.serialization.JsonSerializationKt") - .getMethod("toObjectMessage", JsonObject::class.java) -} - -private fun JsonObject.toWireObjectMessage(): Any = - toWireObjectMessageMethod.invoke(null, this) ?: error("toObjectMessage returned null for $this") - -private fun List.asState(): Array = Array(size) { this[it].toWireObjectMessage() } - -fun buildObjectSyncMessage(channel: String, channelSerial: String, objectMessages: List): ProtocolMessage = - ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - this.channel = channel - this.channelSerial = channelSerial - state = objectMessages.asState() - } - -fun buildObjectMessage(channel: String, objectMessages: List): ProtocolMessage = - ProtocolMessage(ProtocolMessage.Action.`object`).apply { - this.channel = channel - state = objectMessages.asState() - } - -fun buildAckMessage(msgSerial: Long?, serials: List): ProtocolMessage = - ProtocolMessage(ProtocolMessage.Action.ack).apply { - this.msgSerial = msgSerial - // `count` is the number of protocol messages acknowledged starting at msgSerial (one OBJECT - // publish per ACK here). ConnectionManager.PendingMessageQueue.ack acks `subList(0, count)`, so - // an unset count (0) would acknowledge nothing and the publish future would hang. The single - // acked message's PublishResult (res[0]) carries the per-object serials. - count = 1 - res = arrayOf(PublishResult(serials.toTypedArray())) - } - -/** - * `build_public_object_message` — constructs a public [ObjectMessage] (PAOM3) from the wire form of an - * object message (as produced by the operation builders above) and a channel name. - * - * ably-java's public `ObjectMessage` / `ObjectOperation` are getter-only interfaces with no public factory - * — the construction (`WireObjectMessage` -> `DefaultObjectMessage`) lives `internal` in `:liveobjects`. - * We reach it by **reflection**, in the same spirit as `infra/unit/Utils.kt` (which reflectively reaches an - * inaccessible `:java` member) — but here the classes are on the *runtime-only* classpath - * (`testRuntimeOnly(project(":liveobjects"))`), so we load them with `Class.forName` rather than flipping - * `isAccessible`. The targeted members compile to plain `public` on the JVM (Kotlin `internal` is not - * name-mangled here), so they are addressable by their declared names: - * - `JsonSerializationKt.toObjectMessage(JsonObject): WireObjectMessage` (Gson, decodes enum codes) - * - `DefaultObjectMessage(WireObjectMessage, String)` - */ -fun buildPublicObjectMessage(objectMessage: JsonObject, channelName: String): ObjectMessage { - val serializationKt = Class.forName("io.ably.lib.liveobjects.serialization.JsonSerializationKt") - val toWire = serializationKt.getMethod("toObjectMessage", JsonObject::class.java) - val wire = toWire.invoke(null, objectMessage) - - val wireClass = Class.forName("io.ably.lib.liveobjects.message.WireObjectMessage") - val defaultMessage = Class.forName("io.ably.lib.liveobjects.message.DefaultObjectMessage") - .getConstructor(wireClass, String::class.java) - .newInstance(wire, channelName) - return defaultMessage as ObjectMessage -} - -// `provision_objects_via_rest(...)` is intentionally not here — it's REST fixture provisioning for -// *integration* tests and belongs in the integration infra, not this unit helper file. - -// --------------------------------------------------------------------------- -// STANDARD_POOL_OBJECTS — the fixed tree shared by all objects unit specs -// --------------------------------------------------------------------------- - -private val SITE = mapOf("aaa" to POOL_SERIAL) - -val STANDARD_POOL_OBJECTS: List = listOf( - buildObjectState( - "root", SITE, - map = mapState( - linkedMapOf( - "name" to mapEntry(dataString("Alice")), - "age" to mapEntry(dataNumber(30)), - "active" to mapEntry(dataBoolean(true)), - "score" to mapEntry(dataObjectId("counter:score@1000")), - "profile" to mapEntry(dataObjectId("map:profile@1000")), - "data" to mapEntry(dataJson(JsonParser.parseString("""{"tags":["a","b"]}"""))), - "avatar" to mapEntry(dataBytes("AQID")), - ), - ), - createOp = mapCreateOp(), - ), - // Matches standard_test_pool.md: this counter's object-state carries the *post-create residual* - // count (0), with the initial value on the createOp. Counter sync is additive (RTLC6c+RTLC6d/RTLC16 - // → data = count + createOp.count; the spec's own RTLC6/replace-data-with-create-op asserts 100+50=150), - // so 0 + 100 = 100 with createOperationIsMerged==true, as every consumer asserts. - // (Was UTS spec issue SI-1: the spec previously declared count:100 AND createOp:100, materialising 200; - // fixed upstream in standard_test_pool.md to count:0 — see uts/SPEC_ISSUES.md.) - buildObjectState("counter:score@1000", SITE, counter = counterState(0), createOp = counterCreateOp(100)), - buildObjectState( - "map:profile@1000", SITE, - map = mapState( - linkedMapOf( - "email" to mapEntry(dataString("alice@example.com")), - "nested_counter" to mapEntry(dataObjectId("counter:nested@1000")), - "prefs" to mapEntry(dataObjectId("map:prefs@1000")), - ), - ), - createOp = mapCreateOp(), - ), - // Matches standard_test_pool.md: residual count 0, the initial 5 carried by the createOp - // (0 + 5 = 5, merged=true). (Was SI-1: spec previously had count:5 + createOp:5 → 10; fixed - // upstream to count:0. See uts/SPEC_ISSUES.md.) - buildObjectState("counter:nested@1000", SITE, counter = counterState(0), createOp = counterCreateOp(5)), - buildObjectState( - "map:prefs@1000", SITE, - map = mapState(linkedMapOf("theme" to mapEntry(dataString("dark")))), - createOp = mapCreateOp(), - ), -) - -// --------------------------------------------------------------------------- -// synced-channel setup -// --------------------------------------------------------------------------- - -/** Result of [setupSyncedChannel] — the spec's `{ client, channel, root, mock_ws }`. */ -data class SyncedChannel( - val client: AblyRealtime, - val channel: Channel, - val root: LiveMapPathObject, - val mockWs: MockWebSocket, -) - -/** `setup_synced_channel` — connected client + channel synced with [STANDARD_POOL_OBJECTS]; auto-ACKs OBJECT publishes. */ -suspend fun setupSyncedChannel(channelName: String): SyncedChannel = setup(channelName, autoAck = true) - -/** `setup_synced_channel_no_ack` — as above but does not ACK OBJECT publishes (for tests that control ACK timing). */ -suspend fun setupSyncedChannelNoAck(channelName: String): SyncedChannel = setup(channelName, autoAck = false) - -private suspend fun setup(channelName: String, autoAck: Boolean): SyncedChannel { - lateinit var mockWs: MockWebSocket - mockWs = MockWebSocket { - onConnectionAttempt = { conn -> - conn.respondWithSuccess( - ProtocolMessage(ProtocolMessage.Action.connected).apply { - connectionId = "conn-1" - connectionDetails = ConnectionDetails { - connectionKey = "conn-key-1" - siteCode = SITE_CODE - objectsGCGracePeriod = 86_400_000L - // Without an explicit maxMessageSize the field defaults to 0, which makes the - // SDK's RTO15d size check reject every OBJECT publish ("size N exceeds 0 bytes"). - maxMessageSize = 65_536 - } - }, - ) - } - onMessageFromClient = { msg -> - when (msg.action) { - ProtocolMessage.Action.attach -> { - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - channel = msg.channel - channelSerial = "sync1:" - setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } - ProtocolMessage.Action.`object` -> if (autoAck) { - val serials = (msg.state?.indices ?: IntRange.EMPTY).map { ackSerial(msg.msgSerial, it) } - mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) - } - else -> Unit - } - } - } - - val client = TestRealtimeClient { - key = "fake:key" - install(mockWs) - } - val channel = client.channels.get( - channelName, - ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, - ) - val root = channel.`object`.get().await() - return SyncedChannel(client, channel, root, mockWs) -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt deleted file mode 100644 index 0028973c1..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt +++ /dev/null @@ -1,370 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import io.ably.lib.liveobjects.Subscription -import io.ably.lib.liveobjects.instance.Instance -import io.ably.lib.liveobjects.instance.InstanceListener -import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent -import io.ably.lib.liveobjects.message.ObjectOperationAction -import io.ably.lib.liveobjects.value.LiveMapValue -import io.ably.lib.uts.infra.pollUntil -import kotlinx.coroutines.future.await -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -/** - * Derived from UTS `objects/unit/instance.md` (RTINS1–RTINS16) — the typed `Instance` view of a resolved - * LiveObject / primitive. - * - * ably-java implements the typed-SDK variant (RTTS), so the spec's single polymorphic `Instance` is - * partitioned: `id`, `value`, `get`, `set`, `subscribe`, … live on `LiveMapInstance` / - * `LiveCounterInstance` / the primitive instances, reached through `as*` casts. Unlike `PathObject`, an - * `Instance` cast **fails fast with `IllegalStateException`** on a type mismatch (RTTS9d). Consequences - * for translation, recorded in `deviations.md`: - * - "wrong-type write/subscribe → ErrorInfo 92007" (RTINS12d/14d/16c) surfaces instead as the `as*` cast - * throwing `IllegalStateException` — there is no typed view on which to even call the wrong method. - * - `value()` on a map / `size()` on a counter (RTINS4d/RTINS9c) are not expressible — those accessors are - * partitioned off the wrong-typed view. - * - `compact()` is not implemented (RTTS7d); `compactJson()` is the supported snapshot (RTINS10). - */ -class InstanceTest { - - /** - * @UTS objects/unit/RTINS3/id-returns-objectid-0 - */ - @Test - fun `RTINS3 - id property returns objectId`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val counterInst = root.get("score").instance() - assertEquals("counter:score@1000", counterInst!!.asLiveCounter().id) - - val mapInst = root.get("profile").instance() - assertEquals("map:profile@1000", mapInst!!.asLiveMap().id) - } - - /** - * @UTS objects/unit/RTINS4/value-counter-0 - */ - @Test - fun `RTINS4 - value returns counter number or primitive`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val counterInst = root.get("score").instance() - assertEquals(100.0, counterInst!!.asLiveCounter().value()) - - // DEVIATION (RTINS4d): spec asserts `map_inst.value() == null`, but ably-java's typed - // LiveMapInstance has no value() accessor (partitioned per RTTS10) — "value() on a map" is not - // expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTINS5/get-wraps-entry-0 - */ - @Test - fun `RTINS5 - get returns Instance wrapping entry value`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val rootInst = root.instance()!!.asLiveMap() - - val nameInst = rootInst.get("name") - assertNotNull(nameInst) // RTINS5c: IS Instance - assertEquals("Alice", nameInst!!.asString().value()) - - val scoreInst = rootInst.get("score") - assertEquals("counter:score@1000", scoreInst!!.asLiveCounter().id) - - val nullInst = rootInst.get("nonexistent") - assertNull(nullInst) - } - - /** - * @UTS objects/unit/RTINS6/entries-yields-instances-0 - */ - @Test - fun `RTINS6 - entries returns key Instance pairs`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val rootInst = root.instance()!!.asLiveMap() - - val entries = mutableMapOf() - for ((key, inst) in rootInst.entries()) { - entries[key] = inst - } - - assertEquals(7, entries.size) - assertNotNull(entries["name"]) // IS Instance - assertEquals("Alice", entries["name"]!!.asString().value()) - } - - /** - * @UTS objects/unit/RTINS9/size-0 - */ - @Test - fun `RTINS9 - size returns non-tombstoned count`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val rootInst = root.instance()!!.asLiveMap() - assertEquals(7L, rootInst.size()) - - // DEVIATION (RTINS9c): spec asserts `counter_inst.size() == null`, but ably-java's typed - // LiveCounterInstance has no size() accessor (partitioned per RTTS10) — not expressible. - // See deviations.md. - } - - /** - * @UTS objects/unit/RTINS10/compact-0 - */ - @Test - fun `RTINS10 - compact recursively compacts`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val rootInst = root.instance()!!.asLiveMap() - - // DEVIATION (RTINS10): ably-java does not implement `compact()` (RTTS7d); `compactJson()` is the - // supported recursively-compacted snapshot. Assertions navigate the JsonObject. See deviations.md. - val snapshot = rootInst.compactJson() - - assertEquals("Alice", snapshot.get("name").asString) - assertEquals(100, snapshot.get("score").asInt) - assertEquals("alice@example.com", snapshot.getAsJsonObject("profile").get("email").asString) - } - - /** - * @UTS objects/unit/RTINS12/set-delegates-0 - */ - @Test - fun `RTINS12 - set delegates to LiveMap set`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val rootInst = root.instance()!!.asLiveMap() - - rootInst.set("name", LiveMapValue.of("Bob")).await() - - assertEquals("Bob", root.get("name").asString().value()) - } - - /** - * @UTS objects/unit/RTINS12d/set-non-map-throws-0 - */ - @Test - fun `RTINS12d - set on non-LiveMap throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val counterInst = root.get("score").instance() - - // DEVIATION (RTINS12d): spec expects `set()` to fail with ErrorInfo 92007. ably-java has no `set` - // on a non-map typed view; the failure surfaces as the `asLiveMap()` cast throwing - // IllegalStateException (RTTS9d). See deviations.md. - assertFailsWith { counterInst!!.asLiveMap() } - } - - /** - * @UTS objects/unit/RTINS13/remove-delegates-0 - */ - @Test - fun `RTINS13 - remove delegates to LiveMap remove`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val rootInst = root.instance()!!.asLiveMap() - - rootInst.remove("name").await() - - assertNull(root.get("name").asString().value()) - } - - /** - * @UTS objects/unit/RTINS14/increment-delegates-0 - */ - @Test - fun `RTINS14 - increment delegates to LiveCounter increment`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - - counterInst.increment(25).await() - - assertEquals(125.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTINS14d/increment-non-counter-throws-0 - */ - @Test - fun `RTINS14d - increment on non-LiveCounter throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val mapInst = root.instance() - - // DEVIATION (RTINS14d): spec expects ErrorInfo 92007; ably-java has no `increment` on a non-counter - // typed view, so the failure surfaces as `asLiveCounter()` throwing IllegalStateException (RTTS9d). - // See deviations.md. - assertFailsWith { mapInst!!.asLiveCounter() } - } - - /** - * @UTS objects/unit/RTINS15/decrement-delegates-0 - */ - @Test - fun `RTINS15 - decrement delegates to LiveCounter decrement`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - - counterInst.decrement(10).await() - - assertEquals(90.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTINS14a/increment-default-0 - */ - @Test - fun `RTINS14a - increment defaults to 1`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - - counterInst.increment().await() - - assertEquals(101.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTINS15a/decrement-default-0 - */ - @Test - fun `RTINS15a - decrement defaults to 1`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - - counterInst.decrement().await() - - assertEquals(99.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTINS16/subscribe-receives-events-0 - */ - @Test - fun `RTINS16 - subscribe receives InstanceSubscriptionEvent`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - val events = mutableListOf() - val sub: Subscription = counterInst.subscribe(InstanceListener { events.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), - ) - pollUntil(5.seconds) { events.size >= 1 } - - assertNotNull(sub) // IS Subscription - assertEquals(1, events.size) - assertNotNull(events[0].getObject()) // IS Instance - assertEquals("counter:score@1000", events[0].getObject().asLiveCounter().id) - } - - /** - * @UTS objects/unit/RTINS16c/subscribe-primitive-throws-0 - */ - @Test - fun `RTINS16c - subscribe on primitive throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val nameInst = root.instance()!!.asLiveMap().get("name") - - // DEVIATION (RTINS16c): spec expects ErrorInfo 92007. ably-java's primitive instances expose no - // `subscribe`; obtaining a subscribable (map/counter) view of a primitive fails fast with - // IllegalStateException (RTTS9d). See deviations.md. - assertFailsWith { nameInst!!.asLiveCounter() } - } - - /** - * @UTS objects/unit/RTINS16e2/subscription-event-message-0 - */ - @Test - fun `RTINS16e2 - InstanceSubscriptionEvent contains ObjectMessage`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val rootInst = root.instance()!!.asLiveMap() - val events = mutableListOf() - rootInst.subscribe(InstanceListener { events.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), - ) - pollUntil(5.seconds) { events.size >= 1 } - - // RTINS16e1: event.object is an Instance wrapping the LiveObject (the root map). - assertNotNull(events[0].getObject()) - assertEquals("root", events[0].getObject().asLiveMap().id) - // RTINS16e2: event.message is the PublicAPI::ObjectMessage derived from the triggering op. - val message = events[0].getMessage() - assertNotNull(message) - assertEquals("test", message!!.channel) - assertEquals(ObjectOperationAction.MAP_SET, message.operation.action) - assertEquals("root", message.operation.objectId) - assertEquals("name", message.operation.mapSet!!.key) - } - - /** - * @UTS objects/unit/RTINS16f/subscribe-returns-subscription-0 - */ - @Test - fun `RTINS16f - subscribe returns Subscription for deregistration`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - val events = mutableListOf() - val sub = counterInst.subscribe(InstanceListener { events.add(it) }) - sub.unsubscribe() - - // Quiescence control (standard_test_pool.md "Negative-assertion quiescence"): a second, - // still-subscribed listener on the same counter instance that WILL fire on the same dispatch. - val controlEvents = mutableListOf() - counterInst.subscribe(InstanceListener { controlEvents.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), - ) - - // Await the control listener; once it has fired, the unsubscribed listener would also have fired - // had it remained subscribed — THEN assert its count is unchanged. - pollUntil(5.seconds) { controlEvents.size >= 1 } - assertEquals(0, events.size) - } - - /** - * @UTS objects/unit/RTINS16g/subscription-follows-identity-0 - */ - @Test - fun `RTINS16g - Instance subscription follows identity not path`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - val events = mutableListOf() - counterInst.subscribe(InstanceListener { events.add(it) }) - - // Re-point root.score at a different counter, then increment the original counter by identity. - mockWs.sendToClient( - buildObjectMessage( - "test", - listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), remoteSerial(0), "remote")), - ), - ) - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "100", "remote"))), - ) - pollUntil(5.seconds) { events.size >= 1 } - - assertTrue(events.size >= 1) - // RTINS16e1: assert the delivered event's object id (not the pre-existing handle's). - assertNotNull(events[0].getObject()) - assertEquals("counter:score@1000", events[0].getObject().asLiveCounter().id) - } - - /** - * @UTS objects/unit/RTINS16h/subscribe-no-side-effects-0 - */ - @Test - fun `RTINS16h - subscribe has no side effects`() = runTest { - val (_, channel, root, _) = setupSyncedChannel("test") - val counterInst = root.get("score").instance()!!.asLiveCounter() - val channelStateBefore = channel.state - - counterInst.subscribe(InstanceListener { }) - - assertEquals(channelStateBefore, channel.state) - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveCounterApiTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveCounterApiTest.kt deleted file mode 100644 index cf8c88b1b..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveCounterApiTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import io.ably.lib.liveobjects.instance.InstanceListener -import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent -import io.ably.lib.liveobjects.message.ObjectOperationAction -import io.ably.lib.types.AblyException -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.uts.infra.pollUntil -import io.ably.lib.uts.infra.unit.MockEvent -import io.ably.lib.uts.infra.unit.MockWebSocket -import kotlinx.coroutines.future.await -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.time.Duration.Companion.seconds - -/** - * Derived from UTS `objects/unit/internal_live_counter_api.md` (RTLC5, RTLC11–RTLC13) — the **public** - * read/write surface of a counter via `PathObject` / `Instance`. (The `internal_` filename prefix is the - * PR-#499 CRDT rename; this spec is still the public-view API test.) - * - * ably-java implements the typed-SDK variant (RTTS): the spec's polymorphic `root.get("score")` is a base - * `PathObject`; counter reads/writes live on `LiveCounterPathObject`, reached via `asLiveCounter()`. So - * `counter.value()` → `root.get("score").asLiveCounter().value(): Double?` (assert `100.0`), and - * `counter.increment(n)` / `.decrement(n)` → `…asLiveCounter().increment(n)` returning - * `CompletableFuture` (`.await()`). - * - * Translation notes (recorded in `deviations.md`): - * - The "increment sends a v6 COUNTER_INC wire message" / "decrement negates the amount" assertions - * (RTLC12e2/e3/e5, RTLC13b) inspect the **outbound wire `ObjectMessage`** (`captured.state[0].operation`). - * That wire form (`WireObjectMessage` / `WireObjectOperation`) is `internal` to `:liveobjects`, so it is - * read by reflection off the captured `ProtocolMessage.state` (same pattern as `Helpers.kt`). The - * observable public-API outcome (counter value after the await) is asserted alongside. - * - RTLC12e1 feeds non-`Number` increment amounts and expects `40003`. ably-java's - * `increment(@NotNull Number)` signature rejects the non-`Number` rows at compile time, so those are not - * expressible; the numeric-but-invalid rows (NaN / ±Infinity) remain real runtime `40003` assertions. - */ -class InternalLiveCounterApiTest { - - /** - * @UTS objects/unit/RTLC5/value-returns-data-0 - */ - @Test - fun `RTLC5 - value returns current counter data`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val counter = root.get("score") - assertEquals(100.0, counter.asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTLC12/increment-sends-counter-inc-0 - */ - @Test - fun `RTLC12 - increment sends v6 COUNTER_INC message`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(25).await() - - // DEVIATION (RTLC12e2/e3/e5): the spec asserts on the outbound wire ObjectMessage - // (captured.state[0].operation.action/objectId/counterInc.number). The wire form is internal to - // :liveobjects; read it by reflection off the captured ProtocolMessage.state. See deviations.md. - val captured = capturedObjectMessages(mockWs) - assertEquals(1, captured.size) - val op = wireOperation(captured[0].state!![0]!!) - assertEquals("CounterInc", wireActionName(op)) - assertEquals("counter:score@1000", wireObjectId(op)) - assertEquals(25.0, wireCounterIncNumber(op)) - } - - /** - * @UTS objects/unit/RTLC12/increment-applies-locally-0 - */ - @Test - fun `RTLC12 - increment applies locally after ACK`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(50).await() - - assertEquals(150.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTLC12e1/increment-non-number-0 - */ - @Test - fun `RTLC12e1 - increment with non-number throws`() = runTest { - setupSyncedChannel("test") - - // DEVIATION (RTLC12e1): spec calls `increment("not_a_number")` and expects ErrorInfo 40003. ably-java's - // `LiveCounterPathObject.increment(@NotNull Number)` rejects a String at compile time, so the case is - // not expressible as a runtime assertion. See deviations.md. - } - - /** - * @UTS objects/unit/RTLC13/decrement-negates-0 - */ - @Test - fun `RTLC13 - decrement delegates to increment with negated amount`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().decrement(15).await() - - // DEVIATION (RTLC13b): the spec asserts the outbound wire counterInc.number == -15 (decrement is an - // alias for increment with a negated amount). Read the internal wire form by reflection; see - // deviations.md. The public value outcome is asserted directly. - val captured = capturedObjectMessages(mockWs) - assertEquals(-15.0, wireCounterIncNumber(wireOperation(captured[0].state!![0]!!))) - assertEquals(85.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTLC11/counter-update-on-inc-0 - */ - @Test - fun `RTLC11 - LiveCounterUpdate emitted on increment`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - val updates = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage( - "test", - listOf(buildCounterInc("counter:score@1000", 7, "99", "remote-site")), - ), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - // RTLC11b1: the spec reads the amount off the triggering message's operation (public ObjectMessage). - val message = updates[0].getMessage()!! - assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) - assertEquals(7.0, message.operation.counterInc!!.number) - } - - /** - * @UTS objects/unit/RTLC12e1/increment-invalid-amounts-table-0 - */ - @Test - fun `RTLC12e1 - Table-driven invalid increment amounts`() = runTest { - // DEVIATION (RTLC12e1): the table feeds null / NaN / ±Infinity / String / Boolean / array / object as - // the increment amount, each expecting ErrorInfo 40003. ably-java's `increment(@NotNull Number)` - // signature makes the non-Number rows (null, String, Boolean, array, object) compile errors, so they - // are not expressible. The numeric-but-invalid rows (NaN, +Infinity, -Infinity) ARE expressible and - // are exercised below. See deviations.md. - val (_, _, root, _) = setupSyncedChannel("test") - val counter = root.get("score").asLiveCounter() - - for (invalid in listOf(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY)) { - val ex = assertFailsWith { counter.increment(invalid).await() } - assertEquals(40003, ex.errorInfo.code) - } - } -} - -// --------------------------------------------------------------------------- -// Reflective access to the outbound wire ObjectMessage (internal to :liveobjects). -// The SDK serializes a published OBJECT operation into ProtocolMessage.state as internal -// WireObjectMessage instances; their operation/action/objectId/counterInc fields are addressable by their -// declared names on the JVM. Mirrors the reflection pattern in Helpers.kt. -// --------------------------------------------------------------------------- - -private fun capturedObjectMessages(mockWs: MockWebSocket): List = - mockWs.events - .filterIsInstance() - .map { it.message } - .filter { it.action == ProtocolMessage.Action.`object` } - -private fun field(target: Any, name: String): Any? = - target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target) - -private fun wireOperation(wireObjectMessage: Any): Any = - field(wireObjectMessage, "operation") ?: error("wire ObjectMessage has no operation") - -private fun wireActionName(wireOperation: Any): String = - (field(wireOperation, "action") as Enum<*>).name - -private fun wireObjectId(wireOperation: Any): String? = - field(wireOperation, "objectId") as String? - -private fun wireCounterIncNumber(wireOperation: Any): Double { - val counterInc = field(wireOperation, "counterInc") ?: error("wire operation has no counterInc") - return (field(counterInc, "number") as Number).toDouble() -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveMapApiTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveMapApiTest.kt deleted file mode 100644 index e739d0ca6..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InternalLiveMapApiTest.kt +++ /dev/null @@ -1,284 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import com.google.gson.JsonObject -import io.ably.lib.liveobjects.ValueType -import io.ably.lib.liveobjects.value.LiveCounter -import io.ably.lib.liveobjects.value.LiveMap -import io.ably.lib.liveobjects.value.LiveMapValue -import io.ably.lib.uts.infra.pollUntil -import kotlinx.coroutines.future.await -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -/** - * Derived from UTS `objects/unit/internal_live_map_api.md` (RTLM5, RTLM10–RTLM12, RTLM20–RTLM21, - * RTLMV4, RTLCV4) — the **public** LiveMap read/write surface (`get` / `size` / `entries` / `keys` / - * `set` / `remove`). (The `internal_` filename prefix is the PR-#499 CRDT rename; this is still the - * public-view API test. RTLM20b/c/d and RTLM21b/c/d have been relocated to RTO26 — see realtime_object.md.) - * - * ably-java implements the typed-SDK variant (RTTS): the root from `setupSyncedChannel` is a - * `LiveMapPathObject`, so `get`/`set`/`remove`/`size`/`entries`/`keys` need no cast; navigated `PathObject`s - * are narrowed with `as*` casts before a typed read. Write values are wrapped in the `LiveMapValue` union - * and the `LiveMap.create` / `LiveCounter.create` value types. - * - * Several spec cases assert on the **wire form of the sent OBJECT ProtocolMessage** (`captured_messages[…] - * .state[…].operation.*`, the COUNTER_CREATE/MAP_CREATE ordering for value-type sets, the MAP_REMOVE wire - * shape) — that is the internal `WireObjectMessage` graph (objects-mapping §13), not the public API. Those - * are translated to the equivalent **observable public effect** (the local round-trip read after the - * auto-ACK echo applies), with the wire-shape sub-assertions recorded as deviations. The table-driven - * invalid-value case (function / undefined / symbol) is rejected at compile time by the `LiveMapValue` - * union, so it is not expressible (deviation, §6). - */ -class InternalLiveMapApiTest { - - /** - * @UTS objects/unit/RTLM5/get-string-value-0 - */ - @Test - fun `RTLM5 - get returns resolved value from LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertEquals("Alice", root.get("name").asString().value()) - assertEquals(30.0, root.get("age").asNumber().value()?.toDouble()) - assertEquals(true, root.get("active").asBoolean().value()) - } - - /** - * @UTS objects/unit/RTLM5/get-nonexistent-key-0 - */ - @Test - fun `RTLM5 - get returns null for non-existent key`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // No entry at this path: getType() is null and a typed read returns null. - assertNull(root.get("nonexistent").getType()) - assertNull(root.get("nonexistent").asString().value()) - } - - /** - * @UTS objects/unit/RTLM5/get-objectid-reference-0 - */ - @Test - fun `RTLM5 - get resolves objectId to LiveObject`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // score -> counter:score@1000 (value 100); spec `value() == 100` on a counter. - assertEquals(100.0, root.get("score").asLiveCounter().value()) - // profile -> map:profile@1000; navigate the nested map and read the email primitive. - assertEquals("alice@example.com", root.get("profile").asLiveMap().get("email").asString().value()) - } - - /** - * @UTS objects/unit/RTLM10/size-non-tombstoned-0 - */ - @Test - fun `RTLM10 - size returns non-tombstoned entry count`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertEquals(7L, root.size()) - } - - /** - * @UTS objects/unit/RTLM11/entries-yields-pairs-0 - */ - @Test - fun `RTLM11 - entries yields key value pairs`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val entries = mutableListOf() - for ((key, _) in root.entries()) { - entries.add(key) - } - - assertTrue("name" in entries) - assertTrue("age" in entries) - assertTrue("active" in entries) - assertTrue("score" in entries) - assertTrue("profile" in entries) - assertTrue("data" in entries) - assertTrue("avatar" in entries) - assertEquals(7, entries.size) - } - - /** - * @UTS objects/unit/RTLM12/keys-0 - */ - @Test - fun `RTLM12 - keys yields only keys`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val keys = root.keys().toList() - - assertEquals(7, keys.size) - assertTrue("name" in keys) - } - - /** - * @UTS objects/unit/RTLM20/set-sends-map-set-0 - */ - @Test - fun `RTLM20 - set sends MAP_SET message`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set("name", LiveMapValue.of("Bob")).await() - - // DEVIATION (RTLM20e2/e3/e6/e7c, RTLM20h2): the spec asserts on the sent OBJECT ProtocolMessage's - // wire shape (operation.action == "MAP_SET", objectId == "root", mapSet.key, mapSet.value.string). - // That is the internal WireObjectMessage graph (§13), not the public API. We assert the equivalent - // observable effect: the MAP_SET applies locally after the ACK echo. See deviations.md. - pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" } - assertEquals("Bob", root.get("name").asString().value()) - } - - /** - * @UTS objects/unit/RTLM20/set-value-types-0 - */ - @Test - fun `RTLM20 - set with different value types`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set("num_key", LiveMapValue.of(42)).await() - root.set("bool_key", LiveMapValue.of(false)).await() - val nested = JsonObject().apply { addProperty("nested", true) } - root.set("json_key", LiveMapValue.of(nested)).await() - - // DEVIATION (RTLM20e7b/d/e): the spec asserts on the sent wire mapSet.value.number/.boolean/.json. - // Those are internal WireObjectMessage fields (§13). We assert the equivalent observable effect: - // each value round-trips through the local graph as the matching typed value. See deviations.md. - pollUntil(5.seconds) { root.get("json_key").getType() == ValueType.JSON_OBJECT } - assertEquals(42.0, root.get("num_key").asNumber().value()?.toDouble()) - assertEquals(false, root.get("bool_key").asBoolean().value()) - assertEquals(nested, root.get("json_key").asJsonObject().value()) - } - - /** - * @UTS objects/unit/RTLM20e7g/set-counter-value-type-0 - */ - @Test - fun `RTLM20e7g - set with LiveCounter generates COUNTER_CREATE plus MAP_SET`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set("new_counter", LiveMapValue.of(LiveCounter.create(50))).await() - - // DEVIATION (RTLM20e7g1/e7g2, RTLM20h1): the spec asserts the sent OBJECT carries a COUNTER_CREATE - // (objectId starts "counter:") followed by a MAP_SET whose value.objectId references it. That - // ordered wire-message generation (RTLCV4) is internal (§13). We assert the equivalent observable - // effect: a new LiveCounter is created and reachable at the key with its initial value. - pollUntil(5.seconds) { root.get("new_counter").getType() == ValueType.LIVE_COUNTER } - assertEquals(ValueType.LIVE_COUNTER, root.get("new_counter").getType()) - assertEquals(50.0, root.get("new_counter").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTLM20e7g/set-map-value-type-0 - */ - @Test - fun `RTLM20e7g - set with LiveMap generates nested CREATE plus MAP_SET`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set("nested_map", LiveMapValue.of(LiveMap.create(mapOf("key1" to LiveMapValue.of("value1"))))).await() - - // DEVIATION (RTLM20e7g1/e7g2, RTLM20h1): the spec asserts the sent OBJECT carries a MAP_CREATE - // (objectId starts "map:") followed by MAP_SET referencing it. That wire-message generation (RTLMV4) - // is internal (§13). We assert the equivalent observable effect: a new LiveMap is created at the key - // with its initial entry. See deviations.md. - pollUntil(5.seconds) { root.get("nested_map").getType() == ValueType.LIVE_MAP } - assertEquals(ValueType.LIVE_MAP, root.get("nested_map").getType()) - assertEquals("value1", root.get("nested_map").asLiveMap().get("key1").asString().value()) - } - - /** - * @UTS objects/unit/RTLM20h1/set-nested-value-types-0 - */ - @Test - fun `RTLM20h1 - set with nested LiveMap containing LiveCounter`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set( - "stats", - LiveMapValue.of( - LiveMap.create( - mapOf( - "count" to LiveMapValue.of(LiveCounter.create(0)), - "label" to LiveMapValue.of("test"), - ), - ), - ), - ).await() - - // DEVIATION (RTLM20h1, RTLMV4d1/d2): the spec asserts the sent OBJECT carries COUNTER_CREATE, - // MAP_CREATE, MAP_SET in depth-first order with cross-referencing objectIds. That ordered - // wire-message generation is internal (§13). We assert the equivalent observable effect: the nested - // map and its nested counter / primitive resolve locally. See deviations.md. - pollUntil(5.seconds) { root.get("stats").getType() == ValueType.LIVE_MAP } - val stats = root.get("stats").asLiveMap() - assertEquals(0.0, stats.get("count").asLiveCounter().value()) - assertEquals("test", stats.get("label").asString().value()) - } - - /** - * @UTS objects/unit/RTLM21/remove-sends-map-remove-0 - */ - @Test - fun `RTLM21 - remove sends MAP_REMOVE message`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.remove("name").await() - - // DEVIATION (RTLM21e2/e5): the spec asserts the sent wire operation.action == "MAP_REMOVE", - // objectId == "root", mapRemove.key == "name". That is the internal WireObjectMessage graph (§13). - // We assert the equivalent observable effect: the MAP_REMOVE applies locally after the ACK echo, so - // the key no longer resolves. See deviations.md. - pollUntil(5.seconds) { root.get("name").getType() == null } - assertNull(root.get("name").asString().value()) - assertNull(root.get("name").getType()) - } - - /** - * @UTS objects/unit/RTLM20/set-applies-locally-0 - */ - @Test - fun `RTLM20 - set applies locally after ACK`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set("name", LiveMapValue.of("Bob")).await() - - pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" } - assertEquals("Bob", root.get("name").asString().value()) - } - - /** - * @UTS objects/unit/RTLM20/set-invalid-values-table-0 - */ - @Test - fun `RTLM20 - invalid set value types`() = runTest { - setupSyncedChannel("test") - - // DEVIATION (RTLM20e1, RTLMV4c): the spec feeds unsupported values (a function, `undefined`, a - // symbol) into set and expects ErrorInfo 40013. ably-java's `set` takes a `LiveMapValue`, and - // `LiveMapValue.of(...)` is overloaded solely for the supported types (Boolean/Binary/Number/String/ - // JsonArray/JsonObject/LiveCounter/LiveMap) — a function / undefined / symbol cannot be constructed, - // so these cases are rejected at compile time and are not expressible (§6). See deviations.md. - } - - /** - * @UTS objects/unit/RTLM20/set-bytes-value-0 - */ - @Test - fun `RTLM20 - set with bytes value type`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val bytes = byteArrayOf(1, 2, 3) - root.set("binary_data", LiveMapValue.of(bytes)).await() - - // DEVIATION (RTLM20e7f): the spec asserts the sent wire mapSet.value.bytes == "AQID" (base64). That - // is the internal WireObjectMessage encoding (§13). We assert the equivalent observable effect: the - // binary value round-trips through the local graph byte-for-byte. See deviations.md. - pollUntil(5.seconds) { root.get("binary_data").getType() == ValueType.BINARY } - assertEquals(bytes.toList(), root.get("binary_data").asBinary().value()?.toList()) - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt deleted file mode 100644 index 268c2d2af..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt +++ /dev/null @@ -1,303 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import com.google.gson.JsonObject -import io.ably.lib.liveobjects.Subscription -import io.ably.lib.liveobjects.instance.InstanceListener -import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent -import io.ably.lib.liveobjects.message.ObjectOperationAction -import io.ably.lib.uts.infra.pollUntil -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.seconds - -/** - * Derived from UTS `objects/unit/live_object_subscribe.md` - * (RTLO4b, RTLO4b3, RTLO4b4c1, RTLO4b4c3a, RTLO4b4c3c, RTLO4b4d, RTLO4b4e, RTLO4b6, RTLO4b7) — the public - * `LiveObject#subscribe` surface, exercised via `Instance#subscribe` (RTINS16). - * - * ably-java subscribes through the public `instance.subscribe(...)`, delivering an - * `InstanceSubscriptionEvent` that exposes only `getObject()` + `getMessage()` (mapping §8). The internal - * `LiveObjectUpdate` diff fields (`update` / `noop` / `tombstone`) are **not** public, so: - * - "listener fired N times" / "returns a `Subscription`" translate directly; - * - the *noop* case (RTLO4b4c1) is observable only as **suppressed delivery** — proven with the - * negative-assertion quiescence barrier (a follow-up observable message awaited via a separate control - * listener), exactly as the spec now frames it; - * - the *tombstone* case (RTLO4b4c3c / RTLO4b4e) is identified via the public - * `message.operation.action == OBJECT_DELETE`, not an internal `tombstone` flag. - * - * See `deviations.md` for the per-test notes. (The former SI-2 stale-serial issue — inbound map serials - * sorting below the pool baseline `"t:0"` — was fixed upstream in the spec; the affected tests now use - * `"t:1"` and run faithfully.) - */ -class LiveObjectSubscribeTest { - - /** - * @UTS objects/unit/RTLO4b/subscribe-receives-updates-0 - */ - @Test - fun `RTLO4b - subscribe registers listener for data updates`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - val sub: Subscription = instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - assertNotNull(sub) // IS Subscription - assertEquals(1, updates.size) - } - - /** - * @UTS objects/unit/RTLO4b7/subscribe-returns-subscription-0 - */ - @Test - fun `RTLO4b7 - subscribe returns Subscription with unsubscribe`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val instance = root.get("score").instance()!!.asLiveCounter() - - val sub = instance.subscribe(InstanceListener { }) - - assertNotNull(sub) // IS Subscription - // DEVIATION (RTLO4b7 "sub.unsubscribe IS Function"): a JS-ism. In ably-java `unsubscribe()` is a - // method declared on the `Subscription` interface, guaranteed at compile time — the runtime - // "is it a function" check is a static-type tautology. We invoke it to show it exists. See deviations.md. - sub.unsubscribe() - } - - /** - * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0 - */ - @Test - fun `RTLO4b7 - unsubscribe stops delivery`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val control = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - val sub = instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - sub.unsubscribe() - - // Negative-assertion quiescence (standard_test_pool.md): a control listener that WILL fire on the - // same dispatch as the message under test; await it, then assert `updates` is unchanged. - instance.subscribe(InstanceListener { control.add(it) }) - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "02", "remote"))), - ) - pollUntil(5.seconds) { control.size >= 1 } - - assertEquals(1, updates.size) - } - - /** - * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0 - */ - @Test - fun `RTLO4b7 - unsubscribe is idempotent`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val instance = root.get("score").instance()!!.asLiveCounter() - val sub = instance.subscribe(InstanceListener { }) - - // No error thrown — both calls complete without error. - sub.unsubscribe() - sub.unsubscribe() - } - - /** - * @UTS objects/unit/RTLO4b4c1/noop-no-trigger-0 - */ - @Test - fun `RTLO4b4c1 - noop update does not trigger listener`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val control = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - instance.subscribe(InstanceListener { updates.add(it) }) - - // "01" — a real increment, fires the listener. - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - // "02" — a COUNTER_INC with NO `number` field: the real RTLC9h/RTLO4b4c1 noop branch (a - // `number: 0` would exist per RTLC9g and produce a non-noop update). No Helpers builder produces - // an empty counterInc, so build the raw wire ObjectMessage inline (action code 4 == COUNTER_INC). - val noopMsg = JsonObject().apply { - addProperty("serial", "02") - addProperty("siteCode", "remote") - add( - "operation", - JsonObject().apply { - addProperty("action", 4) // COUNTER_INC wire code (Helpers Action.COUNTER_INC) - addProperty("objectId", "counter:score@1000") - add("counterInc", JsonObject()) // empty -> no `number` -> noop - }, - ) - } - mockWs.sendToClient(buildObjectMessage("test", listOf(noopMsg))) - - // Negative-assertion quiescence: drive a follow-up "03" increment awaited via a SEPARATE control - // listener. "03" is dispatched after the noop "02" on the same channel, so once the control fires - // the noop has certainly been processed. Kept separate so it does not inflate `updates`. - val controlSub = instance.subscribe(InstanceListener { control.add(it) }) - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 3, "03", "remote"))), - ) - pollUntil(5.seconds) { control.size >= 1 } - controlSub.unsubscribe() - - // The noop "02" produced no update; the listener fired only for "01" and "03". - assertEquals(2, updates.size) - } - - /** - * @UTS objects/unit/RTLO4b6/subscribe-no-side-effects-0 - */ - @Test - fun `RTLO4b6 - subscribe has no side effects`() = runTest { - val (_, channel, root, _) = setupSyncedChannel("test") - val stateBefore = channel.state - val instance = root.get("score").instance()!!.asLiveCounter() - - instance.subscribe(InstanceListener { }) - - assertEquals(stateBefore, channel.state) - } - - /** - * @UTS objects/unit/RTLO4b/subscribe-map-update-0 - */ - @Test - fun `RTLO4b - subscribe on LiveMap receives update`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val instance = root.instance()!!.asLiveMap() - instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - // RTLO4b: a MAP_SET on an existing key emits one LiveMapUpdate (key -> "updated"). - assertEquals(1, updates.size) - } - - /** - * @UTS objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0 - */ - @Test - fun `RTLO4b4c3c - tombstone deregisters all listeners`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updatesA = mutableListOf() - val updatesB = mutableListOf() - val control = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - instance.subscribe(InstanceListener { updatesA.add(it) }) - instance.subscribe(InstanceListener { updatesB.add(it) }) - - // OBJECT_DELETE tombstones the counter; both listeners receive the tombstone update first. - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))), - ) - pollUntil(5.seconds) { updatesA.size >= 1 } - pollUntil(5.seconds) { updatesB.size >= 1 } - - assertEquals(1, updatesA.size) - assertEquals(ObjectOperationAction.OBJECT_DELETE, updatesA[0].getMessage()!!.operation.action) - assertEquals(1, updatesB.size) - assertEquals(ObjectOperationAction.OBJECT_DELETE, updatesB[0].getMessage()!!.operation.action) - - // Quiescence via a SEPARATE LIVE object (a tombstoned object ignores further ops, RTLC7e, so it - // can't be a control): subscribe a control on map:profile@1000 and drive an observable update on - // it AFTER the message under test. Messages process in order, so once control fires "51" is done. - val controlInst = root.get("profile").instance()!!.asLiveMap() - controlInst.subscribe(InstanceListener { control.add(it) }) - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 3, "51", "remote"))), - ) - mockWs.sendToClient( - buildObjectMessage( - "test", - listOf(buildMapSet("map:profile@1000", "quiescence_probe", dataString("x"), "52", "remote")), - ), - ) - pollUntil(5.seconds) { control.size >= 1 } - - // Control delivered, so any still-registered original listener would also have run. - assertEquals(1, updatesA.size) - assertEquals(1, updatesB.size) - } - - /** - * @UTS objects/unit/RTLO4b4d/update-has-object-message-0 - */ - @Test - fun `RTLO4b4d - event message populated from source ObjectMessage`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - assertEquals(1, updates.size) - val message = updates[0].getMessage() - assertNotNull(message) - assertEquals("99", message!!.serial) - assertEquals("remote", message.siteCode) - assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) - assertEquals("counter:score@1000", message.operation.objectId) - } - - /** - * @UTS objects/unit/RTLO4b4e/tombstone-flag-true-0 - */ - @Test - fun `RTLO4b4e - tombstone update identified by OBJECT_DELETE action`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - assertEquals(1, updates.size) - assertEquals(ObjectOperationAction.OBJECT_DELETE, updates[0].getMessage()!!.operation.action) - } - - /** - * @UTS objects/unit/RTLO4b4e/tombstone-flag-false-0 - */ - @Test - fun `RTLO4b4e - normal update carries non-tombstone action`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - val updates = mutableListOf() - val instance = root.get("score").instance()!!.asLiveCounter() - instance.subscribe(InstanceListener { updates.add(it) }) - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), - ) - pollUntil(5.seconds) { updates.size >= 1 } - - assertEquals(1, updates.size) - assertEquals(ObjectOperationAction.COUNTER_INC, updates[0].getMessage()!!.operation.action) - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt deleted file mode 100644 index 54ff69099..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt +++ /dev/null @@ -1,194 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import io.ably.lib.liveobjects.value.LiveMapValue -import io.ably.lib.types.AblyException -import kotlinx.coroutines.future.await -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull - -/** - * Derived from UTS `objects/unit/path_object_mutations.md` (RTPO15–RTPO18, RTPO3c2) — the public - * `PathObject` write surface (set / remove / increment / decrement). - * - * ably-java implements the typed-SDK variant (RTTS): the base `PathObject` has no write methods; writes - * live on `LiveMapPathObject` (`set`/`remove`, reached via `asLiveMap()`) and `LiveCounterPathObject` - * (`increment`/`decrement`, via `asLiveCounter()`). `PathObject` `as*` casts never throw (RTTS5d), so a - * write on the wrong-typed view is expressed by casting to the needed view and asserting the **operation** - * throws `AblyException` 92007 (mapping §7). Writes on an unresolvable path throw 92005/400 (RTPO3c2). - * Values are wrapped in the `LiveMapValue` union; counter `value()` is a `Double`. All mutators return - * `CompletableFuture` and apply locally on ACK, so a read straight after `await()` reflects the write. - */ -class PathObjectMutationsTest { - - /** - * @UTS objects/unit/RTPO15/set-delegates-to-map-0 - */ - @Test - fun `RTPO15 - set delegates to LiveMap set`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.set("name", LiveMapValue.of("Bob")).await() - - assertEquals("Bob", root.get("name").asString().value()) - } - - /** - * @UTS objects/unit/RTPO15/set-nested-path-0 - */ - @Test - fun `RTPO15 - set on nested path`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("profile").asLiveMap().set("email", LiveMapValue.of("bob@example.com")).await() - - assertEquals( - "bob@example.com", - root.get("profile").asLiveMap().get("email").asString().value(), - ) - } - - /** - * @UTS objects/unit/RTPO15d/set-non-map-throws-0 - */ - @Test - fun `RTPO15d - set on non-LiveMap throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // score resolves to a counter; asLiveMap() never throws (RTTS5d), the set operation surfaces 92007. - val ex = assertFailsWith { - root.get("score").asLiveMap().set("key", LiveMapValue.of("value")).await() - } - assertEquals(92007, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTPO16/remove-delegates-to-map-0 - */ - @Test - fun `RTPO16 - remove delegates to LiveMap remove`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.remove("name").await() - - assertNull(root.get("name").asString().value()) - } - - /** - * @UTS objects/unit/RTPO16d/remove-non-map-throws-0 - */ - @Test - fun `RTPO16d - remove on non-LiveMap throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val ex = assertFailsWith { - root.get("score").asLiveMap().remove("key").await() - } - assertEquals(92007, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTPO17/increment-delegates-to-counter-0 - */ - @Test - fun `RTPO17 - increment delegates to LiveCounter increment`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(25).await() - - assertEquals(125.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTPO17/increment-default-amount-0 - */ - @Test - fun `RTPO17 - increment defaults to 1`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment().await() - - assertEquals(101.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTPO17d/increment-non-counter-throws-0 - */ - @Test - fun `RTPO17d - increment on non-LiveCounter throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // root is a map; asLiveCounter() never throws (RTTS5d), the increment operation surfaces 92007. - val ex = assertFailsWith { - root.asLiveCounter().increment(5).await() - } - assertEquals(92007, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTPO18/decrement-delegates-to-counter-0 - */ - @Test - fun `RTPO18 - decrement delegates to LiveCounter decrement`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().decrement(10).await() - - assertEquals(90.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTPO18/decrement-default-amount-0 - */ - @Test - fun `RTPO18 - decrement defaults to 1`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().decrement().await() - - assertEquals(99.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTPO18d/decrement-non-counter-throws-0 - */ - @Test - fun `RTPO18d - decrement on non-LiveCounter throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val ex = assertFailsWith { - root.asLiveCounter().decrement(5).await() - } - assertEquals(92007, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTPO3c2/set-unresolvable-throws-0 - */ - @Test - fun `RTPO3c2 - set on unresolvable path throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val ex = assertFailsWith { - root.get("nonexistent").asLiveMap().get("deep").asLiveMap().set("key", LiveMapValue.of("value")).await() - } - assertEquals(92005, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTPO3c2/increment-unresolvable-throws-0 - */ - @Test - fun `RTPO3c2 - increment on unresolvable path throws`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val ex = assertFailsWith { - root.get("nonexistent").asLiveCounter().increment(5).await() - } - assertEquals(92005, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt deleted file mode 100644 index 1ce1c51a2..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt +++ /dev/null @@ -1,451 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import com.google.gson.JsonParser -import io.ably.lib.liveobjects.ValueType -import io.ably.lib.uts.infra.pollUntil -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -/** - * Derived from UTS `objects/unit/path_object.md` (RTPO1–RTPO14) — the **public** read/navigation surface - * of `PathObject`. - * - * ably-java implements the typed-SDK variant (RTTS): the base `PathObject` exposes only the type-agnostic - * methods (`path`, `instance`, `compactJson`, `getType`); everything type-specific is reached via an `as*` - * cast (mapping §4). The root from `setupSyncedChannel` is a `LiveMapPathObject`, so `get`/`at`/`entries`/ - * `keys`/`values`/`size` need no cast on the root; a *navigated* `PathObject` needs `asLiveMap()` first. - * `PathObject` casts never throw (RTTS5d) — a wrong-typed read returns `null`/empty. - * - * Deviations (recorded in `deviations.md`): - * - RTPO5b/RTPO6b: `get`/`at` with a non-`String` argument (spec `40003`) is a compile error in the - * statically-typed API, so not expressible as a runtime assertion. - * - RTPO13/RTPO13c5/RTPO13c/RTPO3c1: `compact()` is not implemented (RTTS3f); `compactJson()` is used — - * binary is a base64 string, cycles are `{ "objectId": … }` markers (not shared identity), resolution - * failure yields `compactJson() == null`. - * - RTPO10/RTPO10d/RTPO11/RTPO11d: the `keys()/values() IS Array` line is a static-type tautology - * (`Iterable<…>` guaranteed by the signature); only count + membership are asserted. - * - * Number normalisation (mapping §4): counter `value()` is `Double` (assert `100.0`); primitive - * `asNumber().value()` is a boxed `Number` (normalise via `?.toDouble()`); `size()` is `Long` (assert `7L`). - */ -class PathObjectTest { - - /** - * @UTS objects/unit/RTPO4/path-string-representation-0 - */ - @Test - fun `RTPO4 - path returns dot-delimited string`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertEquals("", root.path()) - assertEquals("profile", root.get("profile").path()) - assertEquals("profile.email", root.get("profile").asLiveMap().get("email").path()) - } - - /** - * @UTS objects/unit/RTPO4b/path-escapes-dots-0 - */ - @Test - fun `RTPO4b - path escapes dots in segments`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val po = root.get("a.b").asLiveMap().get("c") - assertEquals("a\\.b.c", po.path()) - } - - /** - * @UTS objects/unit/RTPO5/get-appends-key-0 - */ - @Test - fun `RTPO5 - get returns new PathObject with appended key`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val child = root.get("profile") - val grandchild = child.asLiveMap().get("email") - - assertEquals("profile", child.path()) - assertEquals("profile.email", grandchild.path()) - assertTrue(child !== root) // RTPO5: child IS NOT root - } - - /** - * @UTS objects/unit/RTPO5b/get-non-string-throws-0 - */ - @Test - fun `RTPO5b - get throws on non-string key`() = runTest { - setupSyncedChannel("test") - - // DEVIATION (RTPO5b): spec calls `get(123)` expecting ErrorInfo 40003. ably-java's - // `LiveMapPathObject.get(@NotNull String)` only accepts a String — a non-string argument is a - // compile error, never a runtime failure, so this case is not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTPO6/at-parses-path-0 - */ - @Test - fun `RTPO6 - at parses dot-delimited path`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val po = root.at("profile.email") - assertEquals("profile.email", po.path()) - assertEquals("alice@example.com", po.asString().value()) - } - - /** - * @UTS objects/unit/RTPO6/at-escaped-dots-0 - */ - @Test - fun `RTPO6 - at respects escaped dots`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val po = root.at("a\\.b.c") - assertEquals("a\\.b.c", po.path()) - } - - /** - * @UTS objects/unit/RTPO7/value-counter-0 - */ - @Test - fun `RTPO7 - value returns counter numeric value`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertEquals(100.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTPO7/value-primitive-0 - */ - @Test - fun `RTPO7 - value returns primitive value`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertEquals("Alice", root.get("name").asString().value()) - assertEquals(30.0, root.get("age").asNumber().value()?.toDouble()) - assertEquals(true, root.get("active").asBoolean().value()) - } - - /** - * @UTS objects/unit/RTPO7d/value-livemap-null-0 - */ - @Test - fun `RTPO7d - value returns null for LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // Typed SDK (mapping §4): there is no polymorphic value(); a map resolves to no counter/primitive - // value, so each typed read returns null. - assertNull(root.get("profile").asLiveCounter().value()) - assertNull(root.get("profile").asNumber().value()) - } - - /** - * @UTS objects/unit/RTPO7e/value-unresolvable-null-0 - */ - @Test - fun `RTPO7e - value returns null on resolution failure`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertNull(root.get("nonexistent").asLiveMap().get("deep").asString().value()) - } - - /** - * @UTS objects/unit/RTPO8/instance-live-object-0 - */ - @Test - fun `RTPO8 - instance returns Instance for LiveObject`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val counterInst = root.get("score").instance() - assertNotNull(counterInst) - assertEquals("counter:score@1000", counterInst!!.asLiveCounter().id) - - val mapInst = root.get("profile").instance() - assertNotNull(mapInst) - assertEquals("map:profile@1000", mapInst!!.asLiveMap().id) - } - - /** - * @UTS objects/unit/RTPO8f/instance-primitive-wrapped-0 - */ - @Test - fun `RTPO8f - instance returns Instance for primitive`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val nameInst = root.get("name").instance() - assertNotNull(nameInst) - // Typed SDK (RTINS3b/RTTS10c): primitive instances are anonymous - no id member exists, - // so the spec's `name_inst.id() == null` assertion is enforced at compile time. - assertEquals(ValueType.STRING, nameInst!!.getType()) - assertEquals("Alice", nameInst.asString().value()) - } - - /** - * @UTS objects/unit/RTPO9/entries-yields-pairs-0 - */ - @Test - fun `RTPO9 - entries returns key PathObject pairs`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val entries = mutableMapOf() - for ((key, pathObj) in root.entries()) { - entries[key] = pathObj.path() - } - - assertEquals("name", entries["name"]) - assertEquals("profile", entries["profile"]) - assertEquals(7, entries.size) - } - - /** - * @UTS objects/unit/RTPO9d/entries-non-map-empty-0 - */ - @Test - fun `RTPO9d - entries returns empty for non-LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val entries = root.get("score").asLiveMap().entries().toList() - assertEquals(0, entries.size) - } - - /** - * @UTS objects/unit/RTPO10/keys-returns-array-0 - */ - @Test - fun `RTPO10 - keys returns array of key strings`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val keys = root.keys().toList() - - // DEVIATION (RTPO10): the `keys IS Array` line is a static-type tautology (keys() returns - // Iterable); only count + membership are asserted. See deviations.md. - assertEquals(7, keys.size) - assertTrue("name" in keys) - assertTrue("profile" in keys) - assertTrue("score" in keys) - } - - /** - * @UTS objects/unit/RTPO10d/keys-non-map-empty-0 - */ - @Test - fun `RTPO10d - keys returns empty for non-LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // DEVIATION (RTPO10d): `keys IS Array` tautology omitted. See deviations.md. - val keys = root.get("score").asLiveMap().keys().toList() - assertEquals(0, keys.size) - } - - /** - * @UTS objects/unit/RTPO11/values-returns-array-0 - */ - @Test - fun `RTPO11 - values returns array of PathObjects`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val vals = root.values().toList() - - // DEVIATION (RTPO11): `vals IS Array` tautology omitted. See deviations.md. - assertEquals(7, vals.size) - val paths = vals.map { it.path() }.toSet() - assertTrue("name" in paths) - assertTrue("profile" in paths) - assertTrue("score" in paths) - } - - /** - * @UTS objects/unit/RTPO11d/values-non-map-empty-0 - */ - @Test - fun `RTPO11d - values returns empty for non-LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // DEVIATION (RTPO11d): `vals IS Array` tautology omitted. See deviations.md. - val vals = root.get("score").asLiveMap().values().toList() - assertEquals(0, vals.size) - } - - /** - * @UTS objects/unit/RTPO12/size-count-0 - */ - @Test - fun `RTPO12 - size returns non-tombstoned count`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertEquals(7L, root.size()) - assertEquals(3L, root.get("profile").asLiveMap().size()) - } - - /** - * @UTS objects/unit/RTPO12c/size-non-map-null-0 - */ - @Test - fun `RTPO12c - size returns null for non-LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertNull(root.get("score").asLiveMap().size()) - assertNull(root.get("name").asLiveMap().size()) - } - - /** - * @UTS objects/unit/RTPO13/compact-recursive-0 - */ - @Test - fun `RTPO13 - compact recursively compacts LiveMap tree`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // DEVIATION (RTPO13): ably-java implements only `compactJson()` (RTTS3f). Assertions navigate the - // JsonObject; binary is a base64 string (RTPO14b1). See deviations.md. - val result = root.compactJson()!!.asJsonObject - - assertEquals("Alice", result.get("name").asString) - assertEquals(30, result.get("age").asInt) - assertEquals(true, result.get("active").asBoolean) - assertEquals(100, result.get("score").asInt) - assertEquals(JsonParser.parseString("""{"tags":["a","b"]}"""), result.get("data")) - assertEquals("AQID", result.get("avatar").asString) // DEVIATION: binary as base64 - assertEquals("alice@example.com", result.getAsJsonObject("profile").get("email").asString) - assertEquals(5, result.getAsJsonObject("profile").get("nested_counter").asInt) - assertEquals("dark", result.getAsJsonObject("profile").getAsJsonObject("prefs").get("theme").asString) - } - - /** - * @UTS objects/unit/RTPO13c5/compact-cycle-detection-0 - */ - @Test - fun `RTPO13c5 - compact handles cycles via shared reference`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - // New key "back_ref" on map:prefs (no existing entry → applies regardless of serial, RTLM9d). - mockWs.sendToClient( - buildObjectMessage( - "test", - listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")), - ), - ) - pollUntil(5.seconds) { - root.get("profile").asLiveMap().get("prefs").asLiveMap().get("back_ref").getType() != null - } - - // DEVIATION (RTPO13c5): spec asserts `result["prefs"]["back_ref"] IS result` (shared object - // identity). `compactJson()` emits cycles as an `{ "objectId": … }` marker, not shared identity. - // See deviations.md. - val result = root.get("profile").compactJson()!!.asJsonObject - assertEquals( - "map:profile@1000", - result.getAsJsonObject("prefs").getAsJsonObject("back_ref").get("objectId").asString, - ) - } - - /** - * @UTS objects/unit/RTPO13c/compact-counter-0 - */ - @Test - fun `RTPO13c - compact returns number for LiveCounter`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // DEVIATION (RTPO13c): `compact()` → `compactJson()`; a counter compacts to its numeric JSON value. - assertEquals(100, root.get("score").compactJson()!!.asInt) - } - - /** - * @UTS objects/unit/RTPO14/compact-json-0 - */ - @Test - fun `RTPO14 - compactJson encodes cycles as objectId`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - mockWs.sendToClient( - buildObjectMessage( - "test", - listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")), - ), - ) - pollUntil(5.seconds) { - root.get("profile").asLiveMap().get("prefs").asLiveMap().get("back_ref").getType() != null - } - - val result = root.get("profile").compactJson()!!.asJsonObject - assertEquals( - JsonParser.parseString("""{"objectId":"map:profile@1000"}"""), - result.getAsJsonObject("prefs").get("back_ref"), - ) - } - - /** - * @UTS objects/unit/RTPO3/path-resolution-walk-0 - */ - @Test - fun `RTPO3 - path resolution walks through LiveMaps`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertNull(root.asLiveCounter().value()) // root is a map → no scalar value - assertEquals( - "dark", - root.get("profile").asLiveMap().get("prefs").asLiveMap().get("theme").asString().value(), - ) - } - - /** - * @UTS objects/unit/RTPO3a1/intermediate-not-map-0 - */ - @Test - fun `RTPO3a1 - resolution fails if intermediate is not LiveMap`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertNull(root.get("score").asLiveMap().get("something").asString().value()) - } - - /** - * @UTS objects/unit/RTPO3c1/read-null-on-failure-0 - */ - @Test - fun `RTPO3c1 - read operation returns null on resolution failure`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertNull(root.get("nonexistent").asString().value()) - assertNull(root.get("nonexistent").instance()) - assertNull(root.get("nonexistent").asLiveMap().size()) - // DEVIATION (RTPO3c1): `compact()` → `compactJson()`; resolution failure yields null. - assertNull(root.get("nonexistent").compactJson()) - } - - /** - * @UTS objects/unit/RTPO6b/at-non-string-throws-0 - */ - @Test - fun `RTPO6b - at throws for non-string input`() = runTest { - setupSyncedChannel("test") - - // DEVIATION (RTPO6b): spec calls `at(123)` expecting ErrorInfo 40003. ably-java's - // `LiveMapPathObject.at(@NotNull String)` only accepts a String — a non-string argument is a - // compile error, not a runtime failure, so this case is not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTPO7/value-bytes-0 - */ - @Test - fun `RTPO7 - value returns bytes for binary entry`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - assertContentEquals(byteArrayOf(1, 2, 3), root.get("avatar").asBinary().value()) - } - - /** - * @UTS objects/unit/RTPO14/compact-json-bytes-0 - */ - @Test - fun `RTPO14 - compactJson encodes bytes as base64 string`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - val result = root.compactJson()!!.asJsonObject - assertEquals("AQID", result.get("avatar").asString) - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt deleted file mode 100644 index 8ae50499b..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt +++ /dev/null @@ -1,382 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import com.google.gson.JsonObject -import io.ably.lib.liveobjects.message.ObjectMessage -import io.ably.lib.liveobjects.message.ObjectOperationAction -import io.ably.lib.liveobjects.message.ObjectsMapSemantics -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -/** - * Derived from UTS `objects/unit/public_object_message.md` (PAOM1–3, PAOOP1–3) — construction of the - * public-facing `ObjectMessage` (PAOM3) and `ObjectOperation` (PAOOP3) from a source wire object message. - * - * Pure data-structure construction, no mocks. The spec's `PublicObjectMessage.fromObjectMessage(source, - * channel)` / `PublicObjectOperation.fromObjectOperation(op)` (PAOM3 / PAOOP3) are `internal` in - * `:liveobjects`; [buildPublicObjectMessage] (in `Helpers.kt`) reaches them by reflection, so the source is - * built with the wire op builders (`buildMapSet`, `buildCounterInc`, …) and the public getters are asserted - * on the result. Spec `op.action == "MAP_SET"` (string tag) becomes the `ObjectOperationAction` enum - * constant; `op.mapCreate == null` becomes `assertNull`; getters read as Kotlin properties. - * - * PR #499 renamed the retained-create field `_derivedFrom` → `derivedFrom` (local-only per RTLCV4g5 / - * RTLMV4j5, not a wire field). PAOOP1: the public `ObjectOperation` carries only the resolved - * `mapCreate`/`counterCreate` (no `*WithObjectId` getter); PAOOP3b2/c2 resolution from the derived create is - * verified via [publicMessageWithDerivedCreate]. - */ -class PublicObjectMessageTest { - - /** - * @UTS objects/unit/PAOM3/construction-all-fields-0 - */ - @Test - fun `PAOM3 - construction copies all fields from source ObjectMessage`() { - val extras = JsonObject().apply { addProperty("key", "value") } - // MAP_SET operation + every optional top-level field. The op builders cover serial/siteCode/the - // operation; the remaining top-level fields aren't builder params, so augment the wire JSON directly. - val source = buildMapSet("map:abc@1000", "name", dataString("Alice"), serial = "01", siteCode = "site1").apply { - addProperty("id", "msg-id-1") - addProperty("clientId", "client-1") - addProperty("connectionId", "conn-1") - addProperty("timestamp", 1700000000000L) - addProperty("serialTimestamp", 1700000001000L) - add("extras", extras) - } - - val publicMsg = buildPublicObjectMessage(source, "test-channel") - - assertEquals("msg-id-1", publicMsg.id) - assertEquals("client-1", publicMsg.clientId) - assertEquals("conn-1", publicMsg.connectionId) - assertEquals(1700000000000L, publicMsg.timestamp) - assertEquals("test-channel", publicMsg.channel) - assertEquals("01", publicMsg.serial) - assertEquals(1700000001000L, publicMsg.serialTimestamp) - assertEquals("site1", publicMsg.siteCode) - assertEquals(extras, publicMsg.extras) - assertNotNull(publicMsg.operation) - assertEquals(ObjectOperationAction.MAP_SET, publicMsg.operation.action) - assertEquals("map:abc@1000", publicMsg.operation.objectId) - assertEquals("name", publicMsg.operation.mapSet!!.key) - } - - /** - * @UTS objects/unit/PAOM3/construction-optional-fields-missing-0 - */ - @Test - fun `PAOM3 - construction with optional fields missing`() { - // Only the required operation present; all optional top-level fields absent. - val source = buildCounterInc("counter:abc@1000", 5) - - val publicMsg = buildPublicObjectMessage(source, "my-channel") - - assertNull(publicMsg.id) - assertNull(publicMsg.clientId) - assertNull(publicMsg.connectionId) - assertNull(publicMsg.timestamp) - assertEquals("my-channel", publicMsg.channel) - assertNull(publicMsg.serial) - assertNull(publicMsg.serialTimestamp) - assertNull(publicMsg.siteCode) - assertNull(publicMsg.extras) - assertNotNull(publicMsg.operation) - assertEquals(ObjectOperationAction.COUNTER_INC, publicMsg.operation.action) - } - - /** - * @UTS objects/unit/PAOM3/channel-from-channel-name-0 - */ - @Test - fun `PAOM3b - channel is set from channel name not from ObjectMessage`() { - val source = buildObjectDelete("counter:abc@1000") - - val publicMsg = buildPublicObjectMessage(source, "different-channel-name") - - assertEquals("different-channel-name", publicMsg.channel) - } - - /** - * @UTS objects/unit/PAOOP3/map-set-copies-fields-0 - */ - @Test - fun `PAOOP3a - MAP_SET operation copies mapSet, omits unrelated fields`() { - val source = buildMapSet("map:abc@1000", "color", dataString("blue")) - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.MAP_SET, op.action) - assertEquals("map:abc@1000", op.objectId) - assertEquals("color", op.mapSet!!.key) - assertEquals("blue", op.mapSet!!.value.string) - assertNull(op.mapCreate) - assertNull(op.mapRemove) - assertNull(op.counterCreate) - assertNull(op.counterInc) - assertNull(op.objectDelete) - assertNull(op.mapClear) - } - - /** - * @UTS objects/unit/PAOOP3/map-remove-copies-fields-0 - */ - @Test - fun `PAOOP3a - MAP_REMOVE operation copies mapRemove, omits unrelated fields`() { - val source = buildMapRemove("map:abc@1000", "old-key") - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.MAP_REMOVE, op.action) - assertEquals("map:abc@1000", op.objectId) - assertEquals("old-key", op.mapRemove!!.key) - assertNull(op.mapCreate) - assertNull(op.mapSet) - assertNull(op.counterCreate) - assertNull(op.counterInc) - assertNull(op.objectDelete) - assertNull(op.mapClear) - } - - /** - * @UTS objects/unit/PAOOP3/counter-inc-copies-fields-0 - */ - @Test - fun `PAOOP3a - COUNTER_INC operation copies counterInc, omits unrelated fields`() { - val source = buildCounterInc("counter:abc@1000", 42) - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.COUNTER_INC, op.action) - assertEquals("counter:abc@1000", op.objectId) - assertEquals(42.0, op.counterInc!!.number) - assertNull(op.mapCreate) - assertNull(op.mapSet) - assertNull(op.mapRemove) - assertNull(op.counterCreate) - assertNull(op.objectDelete) - assertNull(op.mapClear) - } - - /** - * @UTS objects/unit/PAOOP3/object-delete-copies-fields-0 - */ - @Test - fun `PAOOP3a - OBJECT_DELETE operation copies objectDelete, omits unrelated fields`() { - // The spec's source carries `objectDelete: {}` (an empty marker). The `buildObjectDelete` helper - // sets only action+objectId, so add the marker body here — otherwise the wire op's `objectDelete` - // stays null and `getObjectDelete()` (which is `operation.objectDelete?.let { DefaultObjectDelete }`) - // returns null. This mirrors the spec's source exactly. - val source = buildObjectDelete("counter:abc@1000").apply { - getAsJsonObject("operation").add("objectDelete", JsonObject()) - } - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.OBJECT_DELETE, op.action) - assertEquals("counter:abc@1000", op.objectId) - assertNotNull(op.objectDelete) - assertNull(op.mapCreate) - assertNull(op.mapSet) - assertNull(op.mapRemove) - assertNull(op.counterCreate) - assertNull(op.counterInc) - assertNull(op.mapClear) - } - - /** - * @UTS objects/unit/PAOOP3/map-clear-copies-fields-0 - */ - @Test - fun `PAOOP3a - MAP_CLEAR operation copies mapClear, omits unrelated fields`() { - // As OBJECT_DELETE above: the spec's source carries `mapClear: {}`; add the empty marker so the - // wire op's `mapClear` is non-null and `getMapClear()` returns the DefaultMapClear marker. - val source = buildMapClear("map:abc@1000").apply { - getAsJsonObject("operation").add("mapClear", JsonObject()) - } - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.MAP_CLEAR, op.action) - assertEquals("map:abc@1000", op.objectId) - assertNotNull(op.mapClear) - assertNull(op.mapCreate) - assertNull(op.mapSet) - assertNull(op.mapRemove) - assertNull(op.counterCreate) - assertNull(op.counterInc) - assertNull(op.objectDelete) - } - - /** - * @UTS objects/unit/PAOOP3/map-create-direct-0 - */ - @Test - fun `PAOOP3b1 - MAP_CREATE with mapCreate directly present`() { - val source = buildMapCreate( - "map:new@2000", - mapState(linkedMapOf("key1" to mapEntry(dataString("val1")))), - ) - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.MAP_CREATE, op.action) - assertEquals("map:new@2000", op.objectId) - assertNotNull(op.mapCreate) - assertEquals(ObjectsMapSemantics.LWW, op.mapCreate!!.semantics) - assertEquals("val1", op.mapCreate!!.entries["key1"]!!.data!!.string) - assertNull(op.counterCreate) - } - - /** - * @UTS objects/unit/PAOOP3/map-create-from-with-object-id-0 - */ - @Test - fun `PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId`() { - // The source carries mapCreateWithObjectId (no direct mapCreate); the public op must resolve - // mapCreate from the MapCreate the WithObjectId variant was derived from. In ably-java that derived - // form (WireMapCreateWithObjectId.derivedFrom) is @Transient/outbound-only and never arrives over the - // wire, so it can't be carried by the wire-JSON helper — reconstruct it reflectively (see - // publicMessageWithDerivedCreate). - val withObjectId = JsonObject().apply { - add( - "operation", - JsonObject().apply { - addProperty("action", 0) // MAP_CREATE wire code - addProperty("objectId", "map:derived@3000") - add( - "mapCreateWithObjectId", - JsonObject().apply { - addProperty("initialValue", "stub-initial-value") - addProperty("nonce", "stub-nonce") - }, - ) - }, - ) - } - val derived = buildMapCreate( - "map:derived@3000", - mapState(linkedMapOf("x" to mapEntry(dataNumber(10)))), - ) - - val op = publicMessageWithDerivedCreate(withObjectId, derived, "test-channel").operation - - assertEquals(ObjectOperationAction.MAP_CREATE, op.action) - assertEquals("map:derived@3000", op.objectId) - assertNotNull(op.mapCreate) - assertEquals(ObjectsMapSemantics.LWW, op.mapCreate!!.semantics) - assertEquals(10.0, op.mapCreate!!.entries["x"]!!.data!!.number) - assertNull(op.counterCreate) - } - - /** - * @UTS objects/unit/PAOOP3/counter-create-from-with-object-id-0 - */ - @Test - fun `PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId`() { - // As PAOOP3b2 but for the counter variant — counterCreate resolved from the derived CounterCreate. - val withObjectId = JsonObject().apply { - add( - "operation", - JsonObject().apply { - addProperty("action", 3) // COUNTER_CREATE wire code - addProperty("objectId", "counter:derived@3000") - add( - "counterCreateWithObjectId", - JsonObject().apply { - addProperty("initialValue", "stub-initial-value") - addProperty("nonce", "stub-nonce") - }, - ) - }, - ) - } - val derived = buildCounterCreate("counter:derived@3000", counterState(100)) - - val op = publicMessageWithDerivedCreate(withObjectId, derived, "test-channel").operation - - assertEquals(ObjectOperationAction.COUNTER_CREATE, op.action) - assertEquals("counter:derived@3000", op.objectId) - assertNotNull(op.counterCreate) - assertEquals(100.0, op.counterCreate!!.count) - assertNull(op.mapCreate) - } - - /** - * @UTS objects/unit/PAOOP3/create-payloads-omitted-0 - */ - @Test - fun `PAOOP3b3, PAOOP3c3 - create payloads omitted when neither variant is present`() { - val source = buildMapSet("map:abc@1000", "k", dataString("v")) - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertNull(op.mapCreate) - assertNull(op.counterCreate) - } - - /** - * @UTS objects/unit/PAOOP3/only-relevant-field-per-action-0 - */ - @Test - fun `PAOOP3 - only the relevant operation field is present per action type`() { - val source = buildCounterCreate("counter:new@2000", counterState(50)) - - val op = buildPublicObjectMessage(source, "test-channel").operation - - assertEquals(ObjectOperationAction.COUNTER_CREATE, op.action) - assertEquals("counter:new@2000", op.objectId) - assertNotNull(op.counterCreate) - assertEquals(50.0, op.counterCreate!!.count) - assertNull(op.mapCreate) - assertNull(op.mapSet) - assertNull(op.mapRemove) - assertNull(op.counterInc) - assertNull(op.objectDelete) - assertNull(op.mapClear) - } -} - -/** - * Builds a public [ObjectMessage] whose operation carries a `*CreateWithObjectId` variant resolved to its - * derived `MapCreate` / `CounterCreate` (PAOOP3b2 / PAOOP3c2). - * - * Why this exists: `WireMapCreateWithObjectId.derivedFrom` / `WireCounterCreateWithObjectId.derivedFrom` are - * `@Transient` — populated only when the SDK constructs an outbound create operation locally, never - * deserialized from the wire. `buildPublicObjectMessage`'s wire-JSON path therefore cannot carry it. This - * helper reconstructs it: it deserializes [withObjectIdMessage] to its internal `WireObjectMessage`, - * manufactures the derived `WireMapCreate` / `WireCounterCreate` by deserializing [derivedCreateMessage] - * (a normal direct-create message), grafts it onto the WithObjectId variant's `derivedFrom` field, then - * builds the public message via the same `DefaultObjectMessage(WireObjectMessage, String)` constructor that - * `buildPublicObjectMessage` uses. All access is by reflection because the wire/Default types are `internal` - * to `:liveobjects` (runtime-only on the uts classpath). - */ -private fun publicMessageWithDerivedCreate( - withObjectIdMessage: JsonObject, - derivedCreateMessage: JsonObject, - channelName: String, -): ObjectMessage { - val serializationKt = Class.forName("io.ably.lib.liveobjects.serialization.JsonSerializationKt") - val toWire = serializationKt.getMethod("toObjectMessage", JsonObject::class.java) - val mainWire = toWire.invoke(null, withObjectIdMessage) - val derivedWire = toWire.invoke(null, derivedCreateMessage) - - val operationField = mainWire.javaClass.getDeclaredField("operation").apply { isAccessible = true } - val mainOp = operationField.get(mainWire) - val derivedOp = operationField.get(derivedWire) - - fun graft(withObjectIdFieldName: String, derivedCreateFieldName: String) { - val withObjectId = mainOp.javaClass.getDeclaredField(withObjectIdFieldName) - .apply { isAccessible = true }.get(mainOp) ?: return - val derivedCreate = derivedOp.javaClass.getDeclaredField(derivedCreateFieldName) - .apply { isAccessible = true }.get(derivedOp) - withObjectId.javaClass.getDeclaredField("derivedFrom") - .apply { isAccessible = true }.set(withObjectId, derivedCreate) - } - graft("mapCreateWithObjectId", "mapCreate") - graft("counterCreateWithObjectId", "counterCreate") - - val wireClass = Class.forName("io.ably.lib.liveobjects.message.WireObjectMessage") - return Class.forName("io.ably.lib.liveobjects.message.DefaultObjectMessage") - .getConstructor(wireClass, String::class.java) - .newInstance(mainWire, channelName) as ObjectMessage -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt deleted file mode 100644 index a333ca87e..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt +++ /dev/null @@ -1,847 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import io.ably.lib.liveobjects.path.PathObjectListener -import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent -import io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions -import io.ably.lib.liveobjects.state.ObjectStateChange -import io.ably.lib.liveobjects.state.ObjectStateEvent -import io.ably.lib.liveobjects.value.LiveMapValue -import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.AblyException -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ChannelOptions -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.uts.infra.awaitChannelState -import io.ably.lib.uts.infra.pollUntil -import io.ably.lib.uts.infra.unit.ConnectionDetails -import io.ably.lib.uts.infra.unit.FakeClock -import io.ably.lib.uts.infra.unit.MockEvent -import io.ably.lib.uts.infra.unit.MockWebSocket -import io.ably.lib.uts.infra.unit.TestRealtimeClient -import kotlinx.coroutines.future.await -import kotlinx.coroutines.test.runTest -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -/** - * Derived from UTS `objects/unit/realtime_object.md` (RTO2, RTO10, RTO15, RTO17–RTO20, RTO22–RTO26) — the - * `channel.object` entry point (`RealtimeObject`): `get()`, sync-state events, publish/apply, GC, and the - * RTO25 (access) / RTO26 (write) preconditions that PR #499 consolidated into this file. - * - * Mapping notes: - * - `channel.object` is a Java field named `object` (a Kotlin keyword) → `` channel.`object` ``. - * - `get()` → `CompletableFuture`; `RTO23e` runs ensure-active-channel (RTL33): a DETACHED - * channel is re-attached and `get()` resolves; a FAILED channel rejects `90001`. Access methods (RTO25b) - * still throw `90001` on DETACHED/FAILED — a separate check. - * - Deviations (see `deviations.md`): `RTO15` publish + its OBJECT/ACK wire assertions are `internal` - * (no public `publish`) → not expressible, the apply effect is covered observably by RTO20; `RTO23f` - * "IS PathObject" is a static-type tautology → assert `path() == ""` instead. - */ -class RealtimeObjectTest { - - /** - * @UTS objects/unit/RTO23/get-returns-path-object-0 - */ - @Test - fun `RTO23 - get returns PathObject wrapping root`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - // DEVIATION (RTO23f): "root IS PathObject" is a static-type tautology (get() returns - // LiveMapPathObject). Assert the observable RTO23d property instead: the root's path is empty. - assertEquals("", root.path()) - } - - /** - * @UTS objects/unit/RTO23a/get-requires-subscribe-mode-0 - */ - @Test - fun `RTO23a - get requires OBJECT_SUBSCRIBE mode`() = runTest { - val (_, channel, _) = objectsClient( - requestedModes = arrayOf(ChannelMode.object_publish), - sendSyncOnAttach = false, - ) - val ex = assertFailsWith { channel.`object`.get().await() } - assertEquals(40024, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTO23e/get-reattaches-detached-0 - */ - @Test - fun `RTO23e - get re-attaches a DETACHED channel`() = runTest { - val (_, channel, _) = objectsClient( - requestedModes = arrayOf(ChannelMode.object_subscribe), - handleDetach = true, - ) - - channel.`object`.get().await() - channel.detach() - awaitChannelState(channel, ChannelState.detached, 10.seconds) - - // get() on a DETACHED channel triggers ensure-active-channel (RTL33b) -> implicit re-attach -> resolves - val root = channel.`object`.get().await() - - assertEquals("", root.path()) - assertEquals(ChannelState.attached, channel.state) - } - - /** - * @UTS objects/unit/RTO23c/get-waits-for-synced-0 - */ - @Test - fun `RTO23c - get waits for SYNCED state`() = runTest { - val attachSent = AtomicBoolean(false) - val (_, channel, mockWs) = objectsClient( - attachedSerial = "sync1:cursor", - sendSyncOnAttach = false, - onAttach = { attachSent.set(true) }, - ) - - val getFuture = channel.`object`.get() - pollUntil(5.seconds) { attachSent.get() } - - mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) - - val root = getFuture.await() - assertEquals("", root.path()) - } - - /** - * @UTS objects/unit/RTO15/publish-sends-object-pm-0 - */ - @Test - fun `RTO15 - publish sends OBJECT ProtocolMessage`() = runTest { - // DEVIATION (RTO15): `channel.object.publish([...])` and its OBJECT/ACK wire + PublishResult - // assertions are `internal` in ably-java — there is no public `publish` (RTO15/RTO20 are marked - // internal in the IDL). Not expressible via the public surface; the publish-and-apply *effect* - // (RTO20) is covered observably by `RTO20 - publishAndApply applies locally on ACK`. See deviations.md. - } - - /** - * @UTS objects/unit/RTO20/publish-and-apply-local-0 - */ - @Test - fun `RTO20 - publishAndApply applies locally on ACK`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(10).await() - - assertEquals(110.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20c/missing-site-code-0 - */ - @Test - fun `RTO20c - publishAndApply logs error when siteCode missing`() = runTest { - val (_, channel, _) = objectsClient(siteCode = null) - val root = channel.`object`.get().await() - - // RTO20c1: no siteCode in ConnectionDetails => operation is NOT applied locally on ACK (logged). - root.get("score").asLiveCounter().increment(10).await() - - assertEquals(100.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20d1/null-serial-skipped-0 - */ - @Test - fun `RTO20d1 - null serial in PublishResult is skipped`() = runTest { - val (_, channel, _) = objectsClient(ackWithNullSerial = true) - val root = channel.`object`.get().await() - - // RTO20d1: a null serial in the ACK's PublishResult => that ObjectMessage is skipped (not applied). - root.get("score").asLiveCounter().increment(10).await() - - assertEquals(100.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20e/waits-for-synced-0 - */ - @Test - fun `RTO20e - publishAndApply waits for SYNCED during SYNCING`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - // Start a new (incomplete) sync so the objects state is SYNCING. - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = "test"; channelSerial = "sync2:cursor"; setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - - val incFuture = root.get("score").asLiveCounter().increment(10) - - // RTO20e: while still SYNCING the write must not have applied yet. - assertFalse(incFuture.isDone) - assertEquals(100.0, root.get("score").asLiveCounter().value()) - - mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) - incFuture.await() - - assertEquals(110.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20e1/fails-on-channel-detached-0 - */ - @Test - fun `RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait`() = runTest { - // DEVIATION (test stimulus, not SDK behaviour): the spec injects DETACHED to trigger RTO20e1, but an - // unsolicited DETACHED while ATTACHED triggers an automatic re-attach (RTL13a) rather than leaving the - // channel DETACHED, so the sync-wait never observes the state change. RTO20e1 covers - // DETACHED/SUSPENDED/FAILED; we exercise the 92008 path via a channel ERROR (FAILED) — the same - // adaptation ably-js uses and that the spec adopted for the proxy tier (specification#501). - val (_, channel, root, mockWs) = setupSyncedChannel("test") - - // Put objects into SYNCING via a re-attach with HAS_OBJECTS (no OBJECT_SYNC follows). - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = "test"; channelSerial = "sync2:cursor"; setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - - val incFuture = root.get("score").asLiveCounter().increment(10) - - // Ensure the OBJECT publish has actually been sent (so the ACK -> applyAckResult wait is engaged) - // before we fail the channel; otherwise the publish could observe FAILED first and fail differently. - pollUntil(5.seconds) { - mockWs.events.filterIsInstance() - .any { it.message.action == ProtocolMessage.Action.`object` } - } - - // Channel ERROR -> FAILED while the write is waiting for SYNCED (RTO20e1). - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.error).apply { - this.channel = "test"; error = ErrorInfo("Channel failed", 400, 90000) - }, - ) - awaitChannelState(channel, ChannelState.failed, 10.seconds) - - val ex = assertFailsWith { incFuture.await() } - assertEquals(92008, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTO17/sync-state-events-0 - */ - @Test - fun `RTO17, RTO18 - Sync state events`() = runTest { - val (_, channel, mockWs) = objectsClient(attachedSerial = "sync1:cursor", sendSyncOnAttach = false) - - val events = mutableListOf() - channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) - channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) - - val getFuture = channel.`object`.get() - pollUntil(5.seconds) { events.size >= 1 } - - mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) - getFuture.await() - - assertEquals(listOf("SYNCING", "SYNCED"), events) - } - - /** - * @UTS objects/unit/RTO18d/duplicate-listener-0 - */ - @Test - fun `RTO18d - Duplicate listener registered twice fires twice`() = runTest { - // DEVIATION (RTO18d): ably-java's EventEmitter registers `on(event, listener)` in a Map keyed by the - // listener instance (`filters.put(listener, ...)`), so the SAME listener registered twice is stored - // once and fires ONCE per event — not twice as RTO18d/RTE4 require. Spec-correct assertion (== 2) - // kept, env-gated. See deviations.md. - if (System.getenv("RUN_DEVIATIONS") == null) return@runTest - - val (_, channel, _, mockWs) = setupSyncedChannel("test") - val callCount = AtomicInteger(0) - val listener = ObjectStateChange.Listener { callCount.incrementAndGet() } - channel.`object`.on(ObjectStateEvent.SYNCED, listener) - channel.`object`.on(ObjectStateEvent.SYNCED, listener) - - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = "test"; channelSerial = "sync2:cursor"; setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) - - pollUntil(5.seconds) { callCount.get() >= 2 } - assertEquals(2, callCount.get()) - } - - /** - * @UTS objects/unit/RTO19/off-deregisters-0 - */ - @Test - fun `RTO19 - off deregisters listener`() = runTest { - val (_, channel, _, mockWs) = setupSyncedChannel("test") - val callCount = AtomicInteger(0) - val sub = channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { callCount.incrementAndGet() }) - sub.unsubscribe() - - // Quiescence control (standard_test_pool.md): a still-registered listener that WILL fire on the - // same re-sync dispatch, so once it has fired the deregistered one would also have fired if still on. - val controlCount = AtomicInteger(0) - channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { controlCount.incrementAndGet() }) - - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = "test"; channelSerial = "sync2:cursor"; setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) - - pollUntil(5.seconds) { controlCount.get() >= 1 } - assertEquals(0, callCount.get()) - } - - /** - * @UTS objects/unit/RTO2/mode-enforcement-0 - */ - @Test - fun `RTO2 - Channel mode enforcement`() = runTest { - // Requested both modes, but ATTACHED grants only OBJECT_SUBSCRIBE (RTO2a checks granted modes). - val (_, channel, _) = objectsClient( - grantedFlags = listOf(ProtocolMessage.Flag.object_subscribe), - ) - val root = channel.`object`.get().await() - - val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } - assertEquals(40024, ex.errorInfo.code) - } - - /** - * @UTS objects/unit/RTO23e/get-rejects-failed-0 - */ - @Test - fun `RTO23e - get on a FAILED channel rejects with 90001`() = runTest { - val (_, channel, _) = objectsClient( - requestedModes = arrayOf(ChannelMode.object_subscribe), - failOnAttach = true, - ) - - channel.attach() - awaitChannelState(channel, ChannelState.failed, 10.seconds) - - val ex = assertFailsWith { channel.`object`.get().await() } - assertEquals(90001, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO25a/access-requires-subscribe-mode-0 - */ - @Test - fun `RTO25a - Access API precondition requires OBJECT_SUBSCRIBE mode`() = runTest { - val (_, channel, _) = objectsClient( - requestedModes = arrayOf(ChannelMode.object_publish), - grantedFlags = listOf(ProtocolMessage.Flag.object_publish), - ) - val ex = assertFailsWith { channel.`object`.get().await() } - assertEquals(40024, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO25b/access-throws-detached-0 - */ - @Test - fun `RTO25b - Access API precondition throws on DETACHED channel`() = runTest { - // A server-initiated DETACHED auto-reattaches per RTL13, so it never settles in DETACHED; drive a - // stable DETACHED via an explicit detach (the objectsClient mock answers DETACH with DETACHED). - // The precondition state under test (channel DETACHED) is identical either way. - val (_, channel, _) = objectsClient(handleDetach = true) - val root = channel.`object`.get().await() - - channel.detach() - awaitChannelState(channel, ChannelState.detached, 10.seconds) - - val ex = assertFailsWith { root.keys().toList() } - assertEquals(90001, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO25b/access-throws-failed-0 - */ - @Test - fun `RTO25b - Access API precondition throws on FAILED channel`() = runTest { - val (_, channel, root, mockWs) = setupSyncedChannel("test") - - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.error).apply { - this.channel = "test"; error = ErrorInfo("Channel error", 400, 90000) - }, - ) - awaitChannelState(channel, ChannelState.failed, 10.seconds) - - val ex = assertFailsWith { root.keys().toList() } - assertEquals(90001, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO26a/write-requires-publish-mode-0 - */ - @Test - fun `RTO26a - Write API precondition requires OBJECT_PUBLISH mode`() = runTest { - val (_, channel, _) = objectsClient( - requestedModes = arrayOf(ChannelMode.object_subscribe), - grantedFlags = listOf(ProtocolMessage.Flag.object_subscribe), - ) - val root = channel.`object`.get().await() - - val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } - assertEquals(40024, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO26b/write-throws-detached-0 - */ - @Test - fun `RTO26b - Write API precondition throws on DETACHED channel`() = runTest { - // As RTO25b: a server DETACHED auto-reattaches (RTL13), so drive a stable DETACHED via explicit - // detach. The write precondition state under test (channel DETACHED) is identical. - val (_, channel, _) = objectsClient(handleDetach = true) - val root = channel.`object`.get().await() - - channel.detach() - awaitChannelState(channel, ChannelState.detached, 10.seconds) - - val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } - assertEquals(90001, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO26b/write-throws-failed-0 - */ - @Test - fun `RTO26b - Write API precondition throws on FAILED channel`() = runTest { - val (_, channel, root, mockWs) = setupSyncedChannel("test") - - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.error).apply { - this.channel = "test"; error = ErrorInfo("Channel error", 400, 90000) - }, - ) - awaitChannelState(channel, ChannelState.failed, 10.seconds) - - val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } - assertEquals(90001, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO26c/write-throws-echo-disabled-0 - */ - @Test - fun `RTO26c - Write API precondition throws when echoMessages is false`() = runTest { - val (_, channel, _) = objectsClient(echoMessages = false) - val root = channel.`object`.get().await() - - val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } - assertEquals(40000, ex.errorInfo.code) - assertEquals(400, ex.errorInfo.statusCode) - } - - /** - * @UTS objects/unit/RTO24a/single-register-instance-0 - */ - @Test - fun `RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - val eventsRoot = mutableListOf() - val eventsScore = mutableListOf() - root.subscribe(PathObjectListener { eventsRoot.add(it) }) - root.get("score").subscribe(PathObjectListener { eventsScore.add(it) }) - - // siteCode "remote" is absent from the pool's siteTimeserials; "t:1" sorts after "t:0" (RTLM9). - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "t:1", "remote"))), - ) - pollUntil(5.seconds) { eventsScore.size >= 1 } - - assertTrue(eventsRoot.size >= 1) - assertTrue(eventsScore.size >= 1) - } - - /** - * @UTS objects/unit/RTO24c1/coverage-prefix-depth-0 - */ - @Test - fun `RTO24c1 - Subscription coverage prefix match with depth constraint`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - val shallowEvents = mutableListOf() - val deepEvents = mutableListOf() - // depth 1 covers ONLY root's own path ([]) per RTO24c2b, not its children. - root.subscribe(PathObjectListener { shallowEvents.add(it) }, PathObjectSubscriptionOptions(1)) - root.subscribe(PathObjectListener { deepEvents.add(it) }) - - // Update root itself (path [] — covered by depth 1). - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), remoteSerial(0), "remote"))), - ) - pollUntil(5.seconds) { deepEvents.size >= 1 } - - // Update a child of root (path ["score"], relativeDepth 2 — NOT covered by depth 1). - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "t:2", "remote"))), - ) - pollUntil(5.seconds) { deepEvents.size >= 2 } - - // Quiescence: the deep listener firing twice means the shallow listener has had both dispatches. - pollUntil(5.seconds) { shallowEvents.size >= 1 } - assertEquals(1, shallowEvents.size) - assertTrue(deepEvents.size >= 2) - } - - /** - * @UTS objects/unit/RTO10/gc-tombstoned-objects-0 - */ - @Test - fun `RTO10 - GC removes tombstoned objects past grace period`() = runTest { - val fakeClock = FakeClock() - val (_, channel, mockWs) = objectsClient(fakeClock = fakeClock, gcGracePeriod = 86_400_000L) - val root = channel.`object`.get().await() - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "99", "site1", 1000))), - ) - pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == null } - - fakeClock.advance(86_400_000L + 300_000L) - - assertNull(root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20/echo-dedup-0 - */ - @Test - fun `RTO20 - Echo deduplication via appliedOnAckSerials`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(10).await() - val afterApply = root.get("score").asLiveCounter().value() - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, ackSerial(0, 0), SITE_CODE))), - ) - val afterEcho = root.get("score").asLiveCounter().value() - - assertEquals(110.0, afterApply) - assertEquals(110.0, afterEcho) - } - - /** - * @UTS objects/unit/RTO20f/ack-no-site-timeserials-update-0 - */ - @Test - fun `RTO20f - Apply-on-ACK does not update siteTimeserials`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(10).await() - assertEquals(110.0, root.get("score").asLiveCounter().value()) - - // Inbound COUNTER_INC from SITE_CODE with serial "t:0:9": deliberately NOT the apply-on-ACK serial - // ackSerial(0,0)="t:1:0" (so RTO9a3 echo dedup does not discard it) yet it sorts BELOW "t:1:0". If - // the LOCAL apply had wrongly written siteTimeserials[SITE_CODE]="t:1:0", the RTLC per-site newness - // check would reject this as stale (value stays 110). Since LOCAL correctly leaves siteTimeserials - // untouched (RTLC7c), SITE_CODE has no prior entry, so it applies and the value reaches 120. - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, belowAckSerial(9), SITE_CODE))), - ) - pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 120.0 } - - assertEquals(120.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20/ack-after-echo-no-double-apply-0 - */ - @Test - fun `RTO20 - ACK after echo does not double-apply`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannelNoAck("test") - - val incFuture = root.get("score").asLiveCounter().increment(10) - - // Wait for the publish to reach the transport before injecting the echo/ACK - an ACK - // arriving while no message is pending on the connection is silently discarded - // (ConnectionManager PendingMessages.ack) and incFuture would never complete. - pollUntil(5.seconds) { - mockWs.events.filterIsInstance() - .any { it.message.action == ProtocolMessage.Action.`object` } - } - - // Echo BEFORE the ACK. - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, ackSerial(0, 0), "test-site"))), - ) - // Then the ACK. - mockWs.sendToClient(buildAckMessage(0, listOf(ackSerial(0, 0)))) - - incFuture.await() - assertEquals(110.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0 - */ - @Test - fun `RTO5c9, RTO20 - appliedOnAckSerials cleared on re-sync`() = runTest { - val (_, _, root, mockWs) = setupSyncedChannel("test") - - root.get("score").asLiveCounter().increment(10).await() - assertEquals(110.0, root.get("score").asLiveCounter().value()) - - // Re-sync — appliedOnAckSerials should be cleared per RTO5c9; score resets to the pool value (100). - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = "test"; channelSerial = "sync2:cursor"; setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) - pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 100.0 } - - // Replay the same serial used for apply-on-ACK: if serials were cleared it applies normally -> 110. - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, ackSerial(0, 0), SITE_CODE))), - ) - pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 110.0 } - - assertEquals(110.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO20/subscription-fires-on-ack-apply-0 - */ - @Test - fun `RTO20 - Subscription fires on apply-on-ACK`() = runTest { - val (_, _, root, _) = setupSyncedChannel("test") - val events = mutableListOf() - root.get("score").subscribe(PathObjectListener { events.add(it) }) - - root.get("score").asLiveCounter().increment(10).await() - - pollUntil(5.seconds) { events.size >= 1 } - assertTrue(events.size >= 1) - assertEquals(110.0, root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO23/get-implicit-attach-0 - */ - @Test - fun `RTO23 - get implicitly attaches channel`() = runTest { - val (_, channel, _) = objectsClient() - - assertEquals(ChannelState.initialized, channel.state) - val root = channel.`object`.get().await() - - // DEVIATION (RTO23f): "root IS PathObject" tautology -> assert path() + the implicit attach effect. - assertEquals("", root.path()) - assertEquals(ChannelState.attached, channel.state) - } - - /** - * @UTS objects/unit/RTO23d/get-resolves-immediately-synced-0 - */ - @Test - fun `RTO23d - get resolves immediately when already SYNCED`() = runTest { - val (_, channel, _, _) = setupSyncedChannel("test") - - val root2 = channel.`object`.get().await() - - // DEVIATION (RTO23f): "root2 IS PathObject" tautology -> assert path() == "". - assertEquals("", root2.path()) - } - - /** - * @UTS objects/unit/RTO10b1/gc-grace-period-source-0 - */ - @Test - fun `RTO10b1 - GC grace period from ConnectionDetails`() = runTest { - val fakeClock = FakeClock() - val (_, channel, mockWs) = objectsClient(fakeClock = fakeClock, gcGracePeriod = 5000L) - val root = channel.`object`.get().await() - - mockWs.sendToClient( - buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "99", "site1", 1000))), - ) - pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == null } - - fakeClock.advance(5000L + 1000L) - - assertNull(root.get("score").asLiveCounter().value()) - } - - /** - * @UTS objects/unit/RTO17-RTO18/sync-event-sequences-0 - */ - @Test - fun `RTO17, RTO18 - Sync event sequences for all state transitions`() = runTest { - // Scenario 1: initial attach — needs a fresh, non-synced channel with listeners wired BEFORE attach. - run { - val (_, channel, mockWs) = objectsClient(attachedSerial = "sync1:", sendSyncOnAttach = true, autoConnect = false) - val events = mutableListOf() - channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) - channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) - channel.attach() - pollUntil(5.seconds) { events.size >= 2 } - assertEquals(listOf("SYNCING", "SYNCED"), events) - (mockWs) // referenced - } - - // Scenario 2: re-attach after detach. A server-initiated DETACHED auto-reattaches (RTL13): the SDK - // re-sends ATTACH and the (setupSyncedChannel) mock answers ATTACHED + OBJECT_SYNC, producing one - // SYNCING->SYNCED cycle. Driving a manual ATTACHED here too would double the cycle. - run { - val (_, channel, _, mockWs) = setupSyncedChannel("test") - val events = mutableListOf() - channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) - channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) - mockWs.sendToClient(ProtocolMessage(ProtocolMessage.Action.detached).apply { this.channel = "test" }) - pollUntil(5.seconds) { events.size >= 2 } - assertEquals(listOf("SYNCING", "SYNCED"), events) - } - - // Scenario 3: re-sync on new ATTACHED. - run { - val (_, channel, _, mockWs) = setupSyncedChannel("test") - val events = mutableListOf() - channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) - channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = "test"; channelSerial = "sync3:cursor"; setFlag(ProtocolMessage.Flag.has_objects) - }, - ) - mockWs.sendToClient(buildObjectSyncMessage("test", "sync3:", STANDARD_POOL_OBJECTS)) - pollUntil(5.seconds) { events.size >= 2 } - assertEquals(listOf("SYNCING", "SYNCED"), events) - } - - // Scenario 4: ATTACHED without HAS_OBJECTS — RTO4c emits SYNCING, RTO4b completes it -> SYNCED. - run { - val (_, channel, _, mockWs) = setupSyncedChannel("test") - val events = mutableListOf() - channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) - channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { this.channel = "test"; channelSerial = "sync4:" }, - ) - pollUntil(5.seconds) { events.size >= 2 } - assertEquals(listOf("SYNCING", "SYNCED"), events) - } - } -} - -// --------------------------------------------------------------------------- -// Local mock-client builder for the RealtimeObject precondition / lifecycle cases that need channel -// options or ConnectionDetails that setupSyncedChannel's fixed happy-path setup doesn't cover -// (custom modes, granted-mode flags, missing siteCode, echoMessages=false, withheld sync, DETACH/FAIL -// handling, a fake clock, or a null ACK serial). Mirrors Helpers.setup. -// --------------------------------------------------------------------------- - -private fun connectedMessage(siteCode: String?, gcGracePeriod: Long) = - ProtocolMessage(ProtocolMessage.Action.connected).apply { - connectionId = "conn-1" - connectionDetails = ConnectionDetails { - connectionKey = "key-1" - if (siteCode != null) this.siteCode = siteCode - objectsGCGracePeriod = gcGracePeriod - maxMessageSize = 65_536 - } - } - -private fun objectsClient( - requestedModes: Array = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish), - grantedFlags: List = emptyList(), - siteCode: String? = SITE_CODE, - gcGracePeriod: Long = 86_400_000L, - echoMessages: Boolean = true, - attachedSerial: String = "sync1:", - sendSyncOnAttach: Boolean = true, - autoAck: Boolean = true, - ackWithNullSerial: Boolean = false, - handleDetach: Boolean = false, - failOnAttach: Boolean = false, - autoConnect: Boolean = true, - fakeClock: FakeClock? = null, - onAttach: (() -> Unit)? = null, -): Triple { - lateinit var mockWs: MockWebSocket - mockWs = MockWebSocket { - onConnectionAttempt = { conn -> conn.respondWithSuccess(connectedMessage(siteCode, gcGracePeriod)) } - onMessageFromClient = { msg -> - when (msg.action) { - ProtocolMessage.Action.attach -> { - onAttach?.invoke() - if (failOnAttach) { - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.error).apply { - this.channel = msg.channel; error = ErrorInfo("Channel error", 400, 90000) - }, - ) - } else { - mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.attached).apply { - this.channel = msg.channel; channelSerial = attachedSerial - setFlag(ProtocolMessage.Flag.has_objects) - grantedFlags.forEach { setFlag(it) } - }, - ) - if (sendSyncOnAttach) { - // Derive the sync id from attachedSerial so a caller overriding it (e.g. - // "sync2:cursor") gets a matching OBJECT_SYNC instead of a mismatched "sync1:". - val syncId = attachedSerial.substringBefore(":") + ":" - mockWs.sendToClient(buildObjectSyncMessage(msg.channel, syncId, STANDARD_POOL_OBJECTS)) - } - } - } - ProtocolMessage.Action.detach -> if (handleDetach) { - mockWs.sendToClient(ProtocolMessage(ProtocolMessage.Action.detached).apply { this.channel = msg.channel }) - } - ProtocolMessage.Action.`object` -> when { - ackWithNullSerial -> mockWs.sendToClient( - ProtocolMessage(ProtocolMessage.Action.ack).apply { - msgSerial = msg.msgSerial; count = 1; res = arrayOf(io.ably.lib.types.PublishResult(arrayOfNulls(1))) - }, - ) - autoAck -> { - val serials = (msg.state?.indices ?: IntRange.EMPTY).map { ackSerial(msg.msgSerial, it) } - mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) - } - else -> Unit - } - else -> Unit - } - } - } - val client = TestRealtimeClient { - key = "fake:key" - this.echoMessages = echoMessages - this.autoConnect = autoConnect - fakeClock?.let { enableFakeTimers(it) } - install(mockWs) - } - val channel = client.channels.get("test", ChannelOptions().apply { modes = requestedModes }) - if (!autoConnect) client.connect() - return Triple(client, channel, mockWs) -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt deleted file mode 100644 index 1637ea811..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt +++ /dev/null @@ -1,329 +0,0 @@ -package io.ably.lib.uts.unit.liveobjects - -import com.google.gson.JsonArray -import com.google.gson.JsonNull -import com.google.gson.JsonObject -import com.google.gson.JsonPrimitive -import io.ably.lib.liveobjects.value.LiveCounter -import io.ably.lib.liveobjects.value.LiveMap -import io.ably.lib.liveobjects.value.LiveMapValue -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * Derived from UTS `objects/unit/value_types.md` (RTLCV1–RTLCV4, RTLMV1–RTLMV4) — the immutable - * creation value types `LiveCounter` / `LiveMap` obtained from the static `LiveCounter.create(...)` / - * `LiveMap.create(...)` factories and the `LiveMapValue` union (mapping §6). - * - * This is a MIXED spec; almost all of its assertions land on internal/wire-level state that ably-java's - * typed-SDK public API does not expose (recorded in `deviations.md`): - * - The value type's internal blueprint (`vt.count`, `vt.entries[...]`) has **no public accessor** — - * `LiveCounter`/`LiveMap` are opaque holders. So only construction and the abstract type identity - * (`is LiveCounter` / `is LiveMap`) are observable (RTLCV3/RTLMV3). - * - The `evaluate(vt)` → `ObjectMessage` generation half (COUNTER_CREATE / MAP_CREATE, nonce / - * `initialValue` / `objectId` derivation, the `*WithObjectId` wire forms, depth-first ordering, - * empty-entries) is internal/wire-level (mapping §13) — there is no public `evaluate` (RTLCV4/RTLMV4). - * - Wrong-typed `create` args (`LiveCounter.create("not_a_number")`, `LiveMap.create(null)`, non-String - * keys, unsupported value types) are rejected at **compile time** by the `create(Number)` / - * `create(Map)` signatures and the `LiveMapValue` union — not expressible as - * runtime `40003`/`40013` assertions (RTLCV4a/RTLMV4a/b/c). - * - * What IS faithfully translatable is the public `LiveMapValue` union surface (§6): the - * entry-value-type-mapping cases (RTLMV4d) are adapted to assert on that public union. Pure construction — - * no mocks — so these run today. - */ -class ValueTypesTest { - - /** - * @UTS objects/unit/RTLCV3/create-with-count-0 - */ - @Test - fun `RTLCV3 - LiveCounter create with initial count`() = runTest { - val vt = LiveCounter.create(42) - - assertTrue(vt is LiveCounter) // RTLCV3b: returns the LiveCounter value type - - // DEVIATION (RTLCV3b): spec asserts `vt.count == 42`, but the value type's internal count has no - // public accessor in ably-java (opaque immutable holder). Not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLCV3/create-default-zero-0 - */ - @Test - fun `RTLCV3 - LiveCounter create defaults to 0`() = runTest { - val vt = LiveCounter.create() - - assertTrue(vt is LiveCounter) - - // DEVIATION (RTLCV3a1): spec asserts the omitted-count default `vt.count == 0`, but there is no - // public accessor for the internal count. Not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLCV3c/no-validation-at-create-0 - */ - @Test - fun `RTLCV3c - no validation at creation time`() = runTest { - // DEVIATION (RTLCV3c): the spec passes a non-Number (`LiveCounter.create("not_a_number")`) to show - // creation performs no validation. ably-java's `create(@NotNull Number)` cannot accept a String at - // compile time, so that exact input is not expressible. The spirit — no validation at create time — - // is still exercised with a numerically-invalid (but type-valid) NaN, which `create` accepts without - // throwing (validation is deferred to the internal evaluation, §13). See deviations.md. - val vt = LiveCounter.create(Double.NaN) - assertTrue(vt is LiveCounter) // does not throw at creation - } - - /** - * @UTS objects/unit/RTLCV4/evaluate-generates-message-0 - */ - @Test - fun `RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage`() = runTest { - // DEVIATION (RTLCV4): the spec calls the internal `evaluate(vt)` and asserts on the generated - // COUNTER_CREATE ObjectMessage (action, objectId prefix/derivation, nonce length, - // counterCreateWithObjectId.initialValue). There is no public `evaluate`; message generation, - // nonce/objectId derivation and the `*WithObjectId` wire form are internal/wire-level (§13). Only - // the public construction is exercised here. See deviations.md. - val vt = LiveCounter.create(42) - assertTrue(vt is LiveCounter) - } - - /** - * @UTS objects/unit/RTLCV4g5/retains-local-counter-create-0 - */ - @Test - fun `RTLCV4g5 - Evaluation retains local CounterCreate`() = runTest { - // DEVIATION (RTLCV4g5): asserts the evaluated message retains the local `counterCreate` - // (`counterCreate.count == 42`) alongside `counterCreateWithObjectId`. Both the evaluation and the - // retained CounterCreate are internal/wire-level — not reachable through the public value type. - // See deviations.md. - val vt = LiveCounter.create(42) - assertTrue(vt is LiveCounter) - } - - /** - * @UTS objects/unit/RTLCV4a/evaluate-validates-count-0 - */ - @Test - fun `RTLCV4a - Evaluation validates count type`() = runTest { - // DEVIATION (RTLCV4a): spec passes a non-Number (`LiveCounter.create("not_a_number")`) and expects - // evaluation to fail with 40003. ably-java's `LiveCounter.create(@NotNull Number)` rejects a String - // at compile time (§6), so the runtime 40003 assertion is not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLCV4/evaluate-zero-count-0 - */ - @Test - fun `RTLCV4 - Evaluation with count 0`() = runTest { - // DEVIATION (RTLCV4): asserts the evaluated message's `counterCreate.count == 0`. The evaluation and - // generated CounterCreate are internal/wire-level (§13). Only the public construction with count 0 - // is exercised. See deviations.md. - val vt = LiveCounter.create(0) - assertTrue(vt is LiveCounter) - } - - /** - * @UTS objects/unit/RTLMV3/create-with-entries-0 - */ - @Test - fun `RTLMV3 - LiveMap create with entries`() = runTest { - val vt = LiveMap.create( - mapOf( - "name" to LiveMapValue.of("Alice"), - "age" to LiveMapValue.of(30), - ), - ) - - assertTrue(vt is LiveMap) // RTLMV3b: returns the LiveMap value type - - // DEVIATION (RTLMV3b): spec asserts `vt.entries["name"] == "Alice"` / `vt.entries["age"] == 30`, - // but the value type's internal entries have no public accessor (opaque immutable holder). Not - // expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLMV3/create-no-entries-0 - */ - @Test - fun `RTLMV3 - LiveMap create with no entries`() = runTest { - val vt = LiveMap.create() - - assertTrue(vt is LiveMap) // RTLMV3a1: omitted entries still returns a LiveMap value type - } - - /** - * @UTS objects/unit/RTLMV4/evaluate-generates-message-0 - */ - @Test - fun `RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage`() = runTest { - // DEVIATION (RTLMV4): spec calls internal `evaluate(vt)` and asserts on the generated MAP_CREATE - // ObjectMessage (action, objectId `map:` prefix, nonce length, mapCreateWithObjectId.initialValue). - // There is no public `evaluate`; message generation and the `*WithObjectId` wire form are - // internal/wire-level (§13). Only the public construction is exercised. See deviations.md. - val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice"))) - assertTrue(vt is LiveMap) - } - - /** - * @UTS objects/unit/RTLMV4j5/retains-local-map-create-0 - */ - @Test - fun `RTLMV4j5 - Evaluation retains local MapCreate`() = runTest { - // DEVIATION (RTLMV4j5): asserts the evaluated message retains the local `mapCreate` - // (`mapCreate.semantics == "LWW"`, `mapCreate.entries["name"].data.string == "Alice"`) alongside - // `mapCreateWithObjectId`. Evaluation and the retained MapCreate are internal/wire-level. - // See deviations.md. - val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice"))) - assertTrue(vt is LiveMap) - } - - /** - * @UTS objects/unit/RTLMV4d/entry-value-types-0 - * - * The spec asserts the value-type → `data.*` field mapping on the generated `MapCreate` entries - * (internal/wire-level). Adapted to assert the public `LiveMapValue` union surface (mapping §6): each - * input wraps to a `LiveMapValue` whose `is*` discriminant and `getAs*` accessor match. - */ - @Test - fun `RTLMV4d - Entry value type mapping`() = runTest { - val str = LiveMapValue.of("hello") - assertTrue(str.isString) - assertEquals("hello", str.asString) // RTLMV4d4: String -> data.string - - val num = LiveMapValue.of(42) - assertTrue(num.isNumber) - assertEquals(42, num.asNumber.toInt()) // RTLMV4d5: Number -> data.number - - val bool = LiveMapValue.of(true) - assertTrue(bool.isBoolean) - assertEquals(true, bool.asBoolean) // RTLMV4d6: Boolean -> data.boolean - - val jsonArr = JsonArray().apply { add(1); add(2); add(3) } - val arrValue = LiveMapValue.of(jsonArr) - assertTrue(arrValue.isJsonArray) - assertEquals(jsonArr, arrValue.asJsonArray) // RTLMV4d3: JsonArray -> data.json - - val jsonObj = JsonObject().apply { add("key", JsonPrimitive("value")) } - val objValue = LiveMapValue.of(jsonObj) - assertTrue(objValue.isJsonObject) - assertEquals(jsonObj, objValue.asJsonObject) // RTLMV4d3: JsonObject -> data.json - - val bytes = byteArrayOf(1, 2, 3) - val binValue = LiveMapValue.of(bytes) - assertTrue(binValue.isBinary) - assertContentEquals(bytes, binValue.asBinary) // RTLMV4d7: Binary -> data.bytes - - // DEVIATION (RTLMV4d): the spec inspects the generated `MapCreate.entries[k].data.` - // (internal/wire-level). The public union inspection above is the equivalent observable surface; - // the generated message itself is not reachable. See deviations.md. - } - - /** - * @UTS objects/unit/RTLMV4d1/nested-value-types-0 - */ - @Test - fun `RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages`() = runTest { - // DEVIATION (RTLMV4d1/RTLMV4d2/RTLMV4k): the spec evaluates a nested value-type tree and asserts on - // the generated, depth-first-ordered COUNTER_CREATE/MAP_CREATE messages, their `objectId` prefixes, - // and the cross-referencing `entries[k].data.objectId`. Evaluation, objectId derivation and message - // ordering are internal/wire-level (§13). Only the public nested construction is exercised. - // See deviations.md. - val innerCounter = LiveCounter.create(10) - val innerMap = LiveMap.create(mapOf("nested_count" to LiveMapValue.of(innerCounter))) - val outer = LiveMap.create(mapOf("child" to LiveMapValue.of(innerMap))) - - assertTrue(outer is LiveMap) - } - - /** - * @UTS objects/unit/RTLMV4a/evaluate-validates-entries-0 - */ - @Test - fun `RTLMV4a - Evaluation validates entries type`() = runTest { - // DEVIATION (RTLMV4a): spec passes `LiveMap.create(null)` and expects evaluation to fail with 40003. - // ably-java's `LiveMap.create(@NotNull Map)` rejects null at compile time (§6), - // so the runtime 40003 assertion is not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLMV4b/evaluate-validates-keys-0 - */ - @Test - fun `RTLMV4b - Evaluation validates key types`() = runTest { - // DEVIATION (RTLMV4b): spec passes a non-String key (`{ 123: "value" }`) and expects 40003. - // ably-java's `create(Map)` enforces String keys at compile time (§6); a - // non-String key cannot be constructed. Not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLMV4c/evaluate-validates-values-0 - */ - @Test - fun `RTLMV4c - Evaluation validates value types`() = runTest { - // DEVIATION (RTLMV4c): spec passes an unsupported value (a function) and expects 40013. ably-java's - // `LiveMapValue` union only constructs from the supported types (Boolean, byte[], Number, String, - // JsonArray, JsonObject, LiveCounter, LiveMap), so an unsupported value is rejected at compile time - // (§6). Not expressible. See deviations.md. - } - - /** - * @UTS objects/unit/RTLMV4e2/empty-entries-0 - */ - @Test - fun `RTLMV4e2 - Empty entries produces MapCreate with empty entries`() = runTest { - // DEVIATION (RTLMV4e2): asserts the evaluated message's `mapCreate.entries == {}` for a no-entries - // value type. Evaluation and the generated MapCreate are internal/wire-level (§13). Only the public - // empty construction is exercised. See deviations.md. - val vt = LiveMap.create() - assertTrue(vt is LiveMap) - } - - /** - * @UTS objects/unit/RTLMV4d/map-set-all-types-table-0 - * - * Spec table asserts every supported value type maps to the correct generated `data.*` field. Adapted - * to the public `LiveMapValue` union (mapping §6): each input wraps and is inspected via its `is*` - * discriminant + `getAs*` accessor. The generated `MapCreate` `data` is internal. - */ - @Test - fun `RTLMV4d - Table-driven MAP_SET value type mapping`() = runTest { - LiveMapValue.of("hello").let { - assertTrue(it.isString) - assertEquals("hello", it.asString) - } - listOf(42, 3.14, 0, -1).forEach { n -> - val v = LiveMapValue.of(n) - assertTrue(v.isNumber) - assertEquals(n.toDouble(), v.asNumber.toDouble()) - } - listOf(true, false).forEach { b -> - val v = LiveMapValue.of(b) - assertTrue(v.isBoolean) - assertEquals(b, v.asBoolean) - } - val arr = JsonArray().apply { add(1); add("a"); add(JsonNull.INSTANCE) } - LiveMapValue.of(arr).let { - assertTrue(it.isJsonArray) - assertEquals(arr, it.asJsonArray) - } - val obj = JsonObject().apply { add("k", JsonPrimitive("v")) } - LiveMapValue.of(obj).let { - assertTrue(it.isJsonObject) - assertEquals(obj, it.asJsonObject) - } - val bytes = byteArrayOf(1, 2, 3) - LiveMapValue.of(bytes).let { - assertTrue(it.isBinary) - assertContentEquals(bytes, it.asBinary) - } - - // DEVIATION (RTLMV4d): the spec asserts on the generated `MapCreate.entries["test_key"].data[field]` - // (internal/wire-level), including the base64 "AQID" encoding of the binary. The public union - // inspection above is the equivalent observable surface; the generated message and its base64 - // encoding are not reachable. See deviations.md. - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/Utils.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/Utils.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/Utils.kt b/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/Utils.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/Utils.kt rename to uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/Utils.kt From 8da2152d4a32b0e203ccffc792abc9cba648be84 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 31 Jul 2026 14:44:29 +0530 Subject: [PATCH 2/2] liveobjects: tag handleStateChange with RTO4 + RTO27 spec comments The channel-state handler routes the ATTACHED transition to the sync lifecycle (RTO4) and all other states per RTO27, so add a method KDoc tagging both spec points, inline RTO27a/RTO27a1/RTO27a2 tags on the DETACHED/FAILED clear (with SUSPENDED excluded and retained per RTO27b), and an RTO27b tag on the else branch. Comment/doc only; no behaviour change. Mirrors the ably-js actOnChannelState tags. --- .../lib/liveobjects/DefaultRealtimeObject.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt index 1331d046d..37a3df5e5 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt @@ -261,6 +261,14 @@ internal class DefaultRealtimeObject( } } + /** + * Dispatches channel state changes to the objects data lifecycle handlers: the + * [ChannelState.attached] transition is handled per RTO4 (the sync lifecycle), and every + * other state per RTO27. + * + * @spec RTO4 - handling of the ATTACHED transition + * @spec RTO27 - manage the stored objects data across non-ATTACHED transitions + */ internal fun handleStateChange(state: ChannelState, hasObjects: Boolean) { sequentialScope.launch { when (state) { @@ -302,14 +310,16 @@ internal class DefaultRealtimeObject( cause = errorReason?.let { AblyException.fromErrorInfo(it) } ) objectsManager.failSyncWaiters(error) // RTO20e1 + // RTO27a - on DETACHED/FAILED the actual current state of the objects data can no longer + // be known, so clear it without emitting update events; SUSPENDED is excluded here and + // retains its data unchanged per RTO27b, since the connection may still recover if (state != ChannelState.suspended) { - // do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states - objectsPool.clearObjectsData(false) - objectsManager.clearSyncObjectsPool() + objectsPool.clearObjectsData(false) // RTO27a1 + objectsManager.clearSyncObjectsPool() // RTO27a2 } } else -> { - // No action needed for other states + // RTO27b - all other states (INITIALIZED, ATTACHING, DETACHING) retain the objects data unchanged } } }