diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index 59b142f46..4c00a1594 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -1,13 +1,14 @@ --- -description: "Translate the UTS pseudocode test specs in a whole module directory into runnable Kotlin tests in the ably-java uts module. Takes a UTS module directory (e.g. .../specification/uts/objects), validates its structure, resolves the target ably-java module, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Kotlin test per spec. Usage: /uts-to-kotlin " +description: "Translate the UTS pseudocode test specs in a whole module directory into runnable Kotlin tests in the owning ably-java module (:java for realtime/rest, :liveobjects for objects; :uts hosts the shared infra + smoke examples). Takes a UTS module directory (e.g. /uts/objects), validates its structure, resolves the target ably-java module, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Kotlin test per spec. Usage: /uts-to-kotlin " allowed-tools: Bash, Read, Edit, Write, WebFetch --- Translate the UTS pseudocode test specs under the **module directory** `$ARGUMENTS` into runnable Kotlin -tests in the ably-java `uts` module. +tests in the owning ably-java module (`:java` for realtime/rest, `:liveobjects` for objects; `:uts` hosts +the shared infra + smoke examples). -`$ARGUMENTS` is a UTS *module* directory — a directory sitting directly under `.../specification/uts/`, -e.g. `/Users/sachinsh/ably-specification/specification/uts/objects`. Its name (`objects`, `realtime`, +`$ARGUMENTS` is a UTS *module* directory — a directory sitting directly under the spec repo's `uts/`, +e.g. `/uts/objects`. Its name (`objects`, `realtime`, `rest`, …) is the **source module**. A module directory holds many spec files, organised into tiers (`unit/`, `integration/`, and `integration/proxy/`). @@ -31,7 +32,9 @@ bundled script does them — that keeps selection byte-for-byte deterministic in to re-eyeball regexes, join paths, and hand-convert `snake_case` → `PascalCase` each run. > **If `$ARGUMENTS` is empty or blank**, stop and show: `Usage: /uts-to-kotlin ` -> — with the example `/uts-to-kotlin /Users/sachinsh/ably-specification/specification/uts/objects`. +> — with the example `/uts-to-kotlin /uts/objects` +> (the path to a module directory inside a local clone of +> [`ably/specification`](https://github.com/ably/specification)). ## Step A — Resolve the module @@ -44,9 +47,10 @@ python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py "" It prints one JSON object. **If `ok` is `false`, relay `message` to the user and stop** — error codes: `NOT_A_UTS_MODULE_PATH` (not a `.../uts/` directory), `DIR_NOT_FOUND`, `NO_TIER_DIRS` (no `unit/` -or `integration/`). On success it gives `sourceModule`, `mapped`, `testRoot`, `translationNotes`, and a +or `integration/`). On success it gives `sourceModule`, `mapped`, `translationNotes`, and a `tiers` object with one entry per tier (`unit` / `integration` / `proxy`), each carrying `present`, -`sourceDir`, `targetDir`, `package`, and `specs` (a list of `{file, className}`). Everything downstream +`sourceDir`, `targetDir`, `package`, `module` (the owning Gradle module — `:java` / `:liveobjects` / +`:uts`), and `specs` (a list of `{file, className}`). Everything downstream reads from this output — treat it as the single source of truth and don't recompute paths or names by hand. `translationNotes` is the path to a per-module ably-js → ably-java type/interface map when the module @@ -59,6 +63,16 @@ 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. +Each tier's mapping value is **one repo-root-relative path** (never a machine-absolute `/Users/...` path). +The resolver derives everything from it: `targetDir` is the path, `package` is the path after +`src/test/kotlin/` with `/` → `.`, and `module` is the owning Gradle module from the path's first segment +(`lib/` → `:java`, `liveobjects/` → `:liveobjects`, `uts/` → `:uts`). Use the resolver's `targetDir` / +`package` / `module` output directly — don't recompute. The `objects` entry is **hand-maintained**: its +tiers live in `:liveobjects`'s own test source set (its specs assert on `:liveobjects` internals only +visible to that module's own tests) under `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{unit, +integration,proxy}` — a sibling layout that differs from the `--create` template below, so `--create` never +regenerates it. + - **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. @@ -70,9 +84,13 @@ don't always match (e.g. `objects` → `liveobjects`), which is why it's explici python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py "" --create ``` - This adds `unit/`, `integration/standard/`, and `integration/proxy/` under - `packages` and re-prints the resolved output. (`` must be a simple module base name — letters, - digits, underscore; the script returns `BAD_TARGET_NAME` otherwise, so just ask again.) + This adds full `lib/`-rooted (`:java`) paths under `packages` — + `lib/src/test/kotlin/io/ably/lib/uts/unit/`, `.../integration/standard/`, and + `.../integration/proxy/` — and re-prints the resolved output. **`--create` only scaffolds + `:java`-hosted modules;** a module whose tiers live in another Gradle module (like `objects` → + `:liveobjects`, a sibling `uts/{unit,integration,proxy}` layout) still needs a hand-edit afterwards. + (`` must be a simple module base name — letters, digits, underscore; the script returns + `BAD_TARGET_NAME` otherwise, so just ask again.) ## Step C — Choose the tier @@ -154,8 +172,18 @@ integration tests** section; **proxy** → the proxy subsections of the **Integr > and a file-map of every infra helper with its public surface (Appendix B). Skim it for the *why* and > 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/`: +> +> README §9–§11's walkthroughs use the `:uts` infra smoke tests (`UnitInfraSmokeTest` / +> `IntegrationInfraSmokeTest` / `ProxyInfraSmokeTest`) as examples — treat them as the **structural** +> template only (client wiring, guarded awaits, teardown). They are deliberately NOT spec-derived: no +> `@UTS` marker, several teaching points folded into each method. Every derived test still needs one +> `@Test` with a `@UTS ` KDoc per spec Test ID — never copy the smokes' marker-less +> multi-point-per-method shape. + +Infrastructure lives in `:uts`'s **main** source set — +`uts/src/main/kotlin/io/ably/lib/uts/infra/` — so other Gradle modules can consume it via +`testImplementation(project(":uts"))`. Kotlin packages are `io.ably.lib.uts.infra.*`, 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`. @@ -163,6 +191,17 @@ 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). +**Module-local helpers.** Every module's tiers live in that module's own test source set (the resolver's +`targetDir` / `module`), so also read any module-local helpers alongside the target — they sit in the +tier's `targetDir`. For **objects/unit** (module `:liveobjects`) that's +`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. @@ -353,6 +392,12 @@ 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. +The `package` is always the resolver's `package` for the chosen tier (Step 2), and the +`io.ably.lib.uts.infra.*` imports stay valid whatever module the tier lands in. For **objects/unit** the +same scaffold applies with the resolver's `io.ably.lib.liveobjects.uts.unit` package, and channel/objects +setup goes through the module-local helpers (`setupSyncedChannel` etc. from the same package — see Step 3) +rather than raw `MockWebSocket` wiring. + ```kotlin package // the resolver's package for the chosen tier (Step 2) @@ -407,10 +452,15 @@ class { ## Step 5 — Compile +Compile the module the resolver reports for the chosen tier (its `module` field — Step A): + ```bash -./gradlew :uts:compileTestKotlin +./gradlew :java:compileTestKotlin # realtime / rest tiers (module :java) +./gradlew :liveobjects:compileTestKotlin # objects tiers (module :liveobjects) ``` +(`:uts` is no longer a spec-test compile target — it holds only the shared infra + smoke tests.) + Fix any compilation errors and recompile until clean. Common issues: - Missing imports - Method names differ from what you read in the mock files (use the exact names from Step 3) @@ -428,18 +478,24 @@ 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 +Use the per-tier task of the module the resolver reports for the tier (its `module` field), and the resolver's `package` + the spec's `className` for the `--tests` filter: ```bash -# unit tier → io.ably.lib.uts.unit.* -./gradlew :uts:runUtsUnitTests --tests "." +# realtime / rest unit (module :java) → io.ably.lib.uts.unit.* +./gradlew :java:runUtsUnitTests --tests "." + +# realtime / rest integration + proxy (module :java) → io.ably.lib.uts.integration.* +./gradlew :java:runUtsIntegrationTests --tests "." + +# objects unit (module :liveobjects) → io.ably.lib.liveobjects.uts.unit.* +./gradlew :liveobjects:runLiveObjectsUnitTests --tests "." -# integration / proxy → io.ably.lib.uts.integration.* -./gradlew :uts:runUtsIntegrationTests --tests "." +# objects integration + proxy (module :liveobjects) → io.ably.lib.liveobjects.uts.{integration,proxy}.* +./gradlew :liveobjects:runLiveObjectsIntegrationTests --tests "." ``` -(`./gradlew :uts:test` still runs all tiers — unit, standard, and proxy.) +(`./gradlew :uts:test` now runs only the infra smoke tests — see `uts/README.md`.) Handle test failures using this decision tree (the **Required reading** doc you fetched up front has the full detail): @@ -452,7 +508,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 @@ -460,7 +516,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 /** @@ -475,7 +531,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 @@ -500,8 +556,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: +`lib/src/test/kotlin/io/ably/lib/uts/deviations.md` for realtime/rest tiers (module `:java`), 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*. @@ -587,7 +645,7 @@ For each test case, verify: Deviations are discovered by running, so this check applies in evaluate mode. For any place where the generated test diverges from the spec pseudocode (adapted assertion, env-gated skip, or omitted step): - [ ] A `// DEVIATION:` comment explains why -- [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/uts/deviations.md` +- [ ] The deviation is recorded in `lib/src/test/kotlin/io/ably/lib/uts/deviations.md` If you find gaps during this review, fix them, then **re-run the audit script** until `missingInKotlin` / `orphanInKotlin` are empty and every `perTest` entry reconciles, and re-run Step 5 (compile) — and, in @@ -666,7 +724,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/main/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..472bf757d 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 @@ -400,8 +401,9 @@ delivered to subscription listeners) map to ably-java interfaces with getters (p > obtain an `ObjectMessage` from a subscription event (`event.getMessage()`, §8); there is no public > factory. The spec's explicit construction-from-wire (`PublicObjectMessage.fromObjectMessage(source, > channel)` / `PublicObjectOperation.fromObjectOperation(op)`, `PAOM3`/`PAOOP3`, in -> `public_object_message.md`) is `internal` to `:liveobjects` — but the unit helpers expose it **by -> reflection** as `buildPublicObjectMessage(wireJson, channelName)` (§13). So `public_object_message.md` is +> `public_object_message.md`) is `internal` to `:liveobjects` — but the unit helpers expose it as +> `buildPublicObjectMessage(wireMessage, channelName)` (§13), a direct `toPublicMessage` call (no reflection, +> since `:liveobjects`'s internals are visible to its own tests). So `public_object_message.md` is > translatable: build the source with the op builders (`buildMapSet(...)`, `buildCounterInc(...)`, …) and > assert the public getters on the result. @@ -503,9 +505,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 `:liveobjects`). - `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. @@ -513,28 +516,31 @@ Several **unit** specs assert on the **internal CRDT graph**, not the public API is public and maps via §2/§12, but `publish` / `publishAndApply` (`RTO15`/`RTO20`, marked `internal` in the IDL) and the OBJECT/ACK wire assertions are internal. - `public_object_message.md` — **translatable** via the `buildPublicObjectMessage` helper (below), which - reflectively performs the `PAOM3`/`PAOOP3` construction (`WireObjectMessage` → `DefaultObjectMessage`) + 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, **all objects tiers +translate into `:liveobjects`'s own test source set** — +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{unit,integration,proxy}/` — so the unit tier +sees module internals directly, and the integration/proxy tiers reach the plugin through the same module. +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 +548,43 @@ 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.*` infra helpers, +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) | +| `process_pending_events()` | `` (channel.`object` as DefaultRealtimeObject).asyncFuture { }.await() `` — flushes the objects sequential scope (single-lane FIFO); the empty block cannot run until previously dispatched work has completed or suspended at its wait point | `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). @@ -573,10 +598,11 @@ wire `action` / `semantics` are integer enum codes — the builders emit the cod Some objects **integration** specs (tier `integration/standard`) seed object state over REST *before* the realtime client connects, via the spec's `## REST Fixture Provisioning` helper `provision_objects_via_rest`. Its ably-java translation lives in -`uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt` (package -`io.ably.lib.uts.integration.standard.liveobjects`) — **call it; don't hand-roll the REST request or payload -JSON.** (Currently only `objects/integration/RTPO15` uses it.) Unlike the unit helpers (§13), this needs no -reflection and no `:liveobjects` dependency — it compiles and runs against `:java`'s public `AblyRest`. +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt` (package +`io.ably.lib.liveobjects.uts.integration`) — **call it; don't hand-roll the REST request or payload +JSON.** (Currently only `objects/integration/RTPO15` uses it.) It lives inside `:liveobjects`'s own tests +now (like the whole objects suite), but still uses only the public `AblyRest` — the "public API only for +REST provisioning" convention survives even though the module's internals are visible. | Spec helper / operation shape | integration `Helpers.kt` | |---|---| @@ -674,4 +700,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. +Like the rest of the objects suite, 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..773f839bf 100644 --- a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py @@ -5,8 +5,11 @@ directory (a directory directly under .../specification/uts/), it: - validates the path and the module's tier structure, - - reads uts-package-mapping.json (next to this script's skill dir), - - resolves, per tier, the target output directory and Kotlin package, and + - reads uts-package-mapping.json (next to this script's skill dir), where each + tier value is ONE repo-root-relative path (never machine-absolute), + - resolves, per tier, the target output directory, Kotlin package (the path + after 'src/test/kotlin/'), and owning Gradle module (from the path's first + segment), and - lists the candidate spec files with their derived Kotlin class names. Doing this in code (rather than asking the model to eyeball regexes, join @@ -31,6 +34,10 @@ SKILL_DIR = Path(__file__).resolve().parent.parent MAPPING = SKILL_DIR / "uts-package-mapping.json" TIERS = ("unit", "integration", "proxy") +# Owning Gradle module, keyed by a target dir's first path segment. The `lib` -> `:java` +# pair is the load-bearing non-obvious mapping (the `:java` module's build file wires +# `../lib/src/...` srcDirs). +MODULE_BY_PREFIX = {"lib": ":java", "liveobjects": ":liveobjects", "uts": ":uts"} def fail(code, message): @@ -88,7 +95,9 @@ def main(): "--create", metavar="NAME", help="add a mapping for this source module using NAME as the ably-java " - "module base name, then resolve", + "module base name, then resolve. Scaffolds full lib/-rooted (:java) paths " + "only — a module whose tiers live in another Gradle module (like objects " + "-> :liveobjects) still needs a hand-edit afterwards.", ) args = ap.parse_args() @@ -114,7 +123,6 @@ def main(): fail("MAPPING_NOT_FOUND", f"mapping file not found at {MAPPING}") data = json.loads(MAPPING.read_text(encoding="utf-8")) packages = data.setdefault("packages", {}) - test_root = data.get("testRoot", "") if args.create: target = args.create @@ -123,10 +131,14 @@ def main(): f"--create target {target!r} must be a simple module base name " f"(letters/digits/underscore, e.g. 'liveobjects') so it forms a " f"valid path and Kotlin package.") + # Full repo-root-relative paths using the realtime/`lib` (:java) template. + # A module whose tiers live in another Gradle module (like objects -> + # :liveobjects) still needs a hand-edit — --create only scaffolds :java-hosted modules. + base = "lib/src/test/kotlin/io/ably/lib/uts" new_entry = { - "unit": f"unit/{target}", - "integration": f"integration/standard/{target}", - "proxy": f"integration/proxy/{target}", + "unit": f"{base}/unit/{target}", + "integration": f"{base}/integration/standard/{target}", + "proxy": f"{base}/integration/proxy/{target}", } # preserve a hand-maintained "notes" pointer when re-creating an existing entry notes = packages.get(source_module, {}).get("notes") @@ -162,12 +174,18 @@ 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 ONE repo-root-relative path (never machine-absolute); the + # owning module comes from its first path segment (MODULE_BY_PREFIX). + target_dir = entry.get(tier) if mapped else None + module = ( + MODULE_BY_PREFIX.get(target_dir.split("/", 1)[0]) if target_dir else None + ) tiers_out[tier] = { "present": src[tier].is_dir(), "sourceDir": str(src[tier]), "targetDir": target_dir, "package": package_for(target_dir) if target_dir else None, + "module": module, "specs": [{"file": str(p), "className": class_name(p)} for p in specs[tier]], } @@ -175,7 +193,6 @@ def main(): "ok": True, "sourceModule": source_module, "mapped": mapped, - "testRoot": test_root, "translationNotes": translation_notes, "tiers": tiers_out, }, indent=2)) diff --git a/.claude/skills/uts-to-kotlin/uts-package-mapping.json b/.claude/skills/uts-to-kotlin/uts-package-mapping.json index 6e3c8172a..f334189e1 100644 --- a/.claude/skills/uts-to-kotlin/uts-package-mapping.json +++ b/.claude/skills/uts-to-kotlin/uts-package-mapping.json @@ -1,22 +1,21 @@ { - "_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.", - "testRoot": "uts/src/test/kotlin/io/ably/lib/uts", + "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test directory per tier. Each tier value is ONE repo-root-relative path (never machine-absolute); the Kotlin package is the path after 'src/test/kotlin/' with '/' -> '.'; the owning Gradle module is the path's first segment (lib/ -> :java, liveobjects/ -> :liveobjects, uts/ -> :uts). Every tier path MUST keep a module segment after the tier (e.g. 'unit/realtime', never bare 'unit') so a derived package can never collide with a :uts smoke package (io.ably.lib.uts.unit / .integration.standard / .integration.proxy) — an invariant currently held only by construction. 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).", "packages": { "realtime": { - "unit": "unit/realtime", - "integration": "integration/standard/realtime", - "proxy": "integration/proxy/realtime" + "unit": "lib/src/test/kotlin/io/ably/lib/uts/unit/realtime", + "integration": "lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime", + "proxy": "lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime" }, "objects": { - "unit": "unit/liveobjects", - "integration": "integration/standard/liveobjects", - "proxy": "integration/proxy/liveobjects", + "unit": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit", + "integration": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration", + "proxy": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy", "notes": "references/objects-mapping.md" }, "rest": { - "unit": "unit/rest", - "integration": "integration/standard/rest", - "proxy": "integration/proxy/rest" + "unit": "lib/src/test/kotlin/io/ably/lib/uts/unit/rest", + "integration": "lib/src/test/kotlin/io/ably/lib/uts/integration/standard/rest", + "proxy": "lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/rest" } } } diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a2fe3a2bb..36fb02df5 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,4 +23,4 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :java:runUtsUnitTests :uts:runUtsUnitTests diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index df5e399a8..bbe3a5423 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -143,4 +143,4 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew :uts:runUtsIntegrationTests + - run: ./gradlew :java:runUtsIntegrationTests :uts:runUtsIntegrationTests diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e935db93..177bd0515 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.6.1" junit = "4.13.2" +junit-jupiter = "5.10.1" # matches what kotlin-test-junit5:2.1.10 transitively pins (verified) gson = "2.9.0" msgpack = "0.9.11" java-websocket = "1.5.3" @@ -40,6 +41,10 @@ java-websocket = { group = "org.java-websocket", name = "Java-WebSocket", versio navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } vcdiff-core = { group = "com.davidehrmann.vcdiff", name = "vcdiff-core", version.ref = "vcdiff" } junit = { group = "junit", name = "junit", version.ref = "junit" } +junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit-jupiter" } +junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter" } +junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params" } +junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine" } hamcrest-all = { group = "org.hamcrest", name = "hamcrest-all", version.ref = "hamcrest" } nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" } nanohttpd-nanolets = { group = "org.nanohttpd", name = "nanohttpd-nanolets", version.ref = "nanohttpd" } diff --git a/java/build.gradle.kts b/java/build.gradle.kts index bf1c48df9..08a9fc01e 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -1,4 +1,5 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.build.config) @@ -6,6 +7,7 @@ plugins { alias(libs.plugins.test.retry) checkstyle `java-library` + alias(libs.plugins.kotlin.jvm) // NEW — test-only usage; see stdlib guardrail (step 4) } java { @@ -28,6 +30,31 @@ dependencies { runtimeOnly(project(":network-client-default")) } testImplementation(libs.bundles.tests) + + // The UTS test toolkit — the whole JUnit 5 + kotlin.test-junit5 + coroutines stack arrives + // transitively via :uts's exported (`api`) toolkit, so consumers declare only this one edge. + // The UTS Kotlin suites run via the runUts* tasks only (JUnit4 tasks don't discover Jupiter + // classes and vice versa). Deliberately NO junit-vintage-engine here: unlike :liveobjects, the + // legacy JUnit4 tests stay on the JUnit4 runner, never the platform. + testImplementation(project(":uts")) +} + +// kotlin-stdlib guardrail (invariant I5): the Kotlin plugin auto-adds kotlin-stdlib to the module's +// main dependency scope, which would leak into :java's published POM/runtime. The leak comes from the +// PLUGIN, NOT from the testImplementation(project(":uts")) dependency — test scopes never enter the +// POM (verified: removing the :uts dep leaves the leak identical). :java is Kotlin-free at +// runtime, so strip it from the main artifact scopes. kotlin-stdlib still reaches the TEST classpath +// transitively (via :uts's kotlin-test-junit5), so the UTS Kotlin suites compile and run. +// Verified empirically on Kotlin 2.1.10: the plugin adds stdlib lazily (it does not appear in any +// declared `(n)` view but resolves top-level onto compile/runtimeClasspath), so the removeIf must +// cover the base scopes the outgoing variants (apiElements/runtimeElements) and classpaths inherit +// from. Test scopes are untouched. +listOf("api", "implementation", "runtimeOnly").forEach { cfg -> + configurations.named(cfg) { + withDependencies { + removeIf { it.group == "org.jetbrains.kotlin" && it.name.startsWith("kotlin-stdlib") } + } + } } buildConfig { @@ -47,7 +74,15 @@ sourceSets { java { srcDirs("src/test/java", "../lib/src/test/java") } + kotlin { + srcDirs("src/test/kotlin", "../lib/src/test/kotlin") // NEW — UTS Kotlin suites only + } } + // main gets NO kotlin srcDir — :java main stays pure Java. +} + +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } // match sourceCompatibility 1.8 } tasks.checkstyleMain.configure { @@ -103,9 +138,47 @@ as it only contains the REST and Realtime suites. tasks.register("runUnitTests") { filter { excludeTestsMatching("io.ably.lib.test.*") + excludeTestsMatching("io.ably.lib.uts.*") // UTS Jupiter suites run via runUts* tasks only } jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") beforeTest(closureOf { logger.lifecycle("-> $this") }) outputs.upToDateWhen { false } } + +// UTS realtime suites (Kotlin, JUnit Jupiter). These are the only :java tasks on the JUnit Platform; +// the legacy JUnit4 tasks above never see the Jupiter classes (no vintage engine on the classpath), +// and these never see the JUnit4 classes. --add-opens is set per-task (not withType), so these new +// tasks must declare it explicitly. +tasks.register("runUtsUnitTests") { + useJUnitPlatform() + filter { + includeTestsMatching("io.ably.lib.uts.unit.*") + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} + +tasks.register("runUtsIntegrationTests") { + useJUnitPlatform() + filter { + includeTestsMatching("io.ably.lib.uts.integration.*") + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } + + // Gradle does not forward -D system properties to the forked test JVM, so propagate the + // local uts-proxy override explicitly (invariant I6; AuthReauthTest launches the proxy). + // Accepts either `-Duts.proxy.localPath=...` on the Gradle invocation or the + // `UTS_PROXY_LOCAL_PATH` environment variable. See ProxyManager. + systemProperty( + "uts.proxy.localPath", + providers.systemProperty("uts.proxy.localPath") + .orElse(providers.environmentVariable("UTS_PROXY_LOCAL_PATH")) + .getOrElse(""), + ) +} diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java index ca05e0e28..ffcdc73d0 100644 --- a/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterCreate.java @@ -1,6 +1,6 @@ package io.ably.lib.liveobjects.message; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Payload of a {@link ObjectOperationAction#COUNTER_CREATE} operation, describing the @@ -15,7 +15,8 @@ public interface CounterCreate { * *

Spec: CCR2a * - * @return the initial counter value + * @return the initial counter value, or {@code null} if absent from the operation + * (such an operation marks the create as merged without changing the value, per RTLC16d) */ - @NotNull Double getCount(); + @Nullable Double getCount(); } diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java index 88fe59174..8b8aafc70 100644 --- a/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java +++ b/lib/src/main/java/io/ably/lib/liveobjects/message/CounterInc.java @@ -1,6 +1,6 @@ package io.ably.lib.liveobjects.message; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Payload of a {@link ObjectOperationAction#COUNTER_INC} operation, describing an amount @@ -16,7 +16,8 @@ public interface CounterInc { * *

Spec: CIN2a * - * @return the increment amount (may be negative) + * @return the increment amount (may be negative), or {@code null} if absent from the + * operation (such an operation is applied as a no-op, per RTLC9h) */ - @NotNull Double getNumber(); + @Nullable Double getNumber(); } diff --git a/lib/src/test/kotlin/io/ably/lib/uts/deviations.md b/lib/src/test/kotlin/io/ably/lib/uts/deviations.md new file mode 100644 index 000000000..c5a2c262b --- /dev/null +++ b/lib/src/test/kotlin/io/ably/lib/uts/deviations.md @@ -0,0 +1,99 @@ +# SDK Deviations + +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. + +**Scope:** this file now lives alongside the realtime UTS suites in the `:java` module +(`lib/src/test/kotlin/io/ably/lib/uts/`) and holds deviations for the **realtime/rest tiers** it +hosts. All **objects** tiers (unit, integration and proxy) moved to `:liveobjects`'s own test source +set alongside the tests they document; their deviations (the typed-SDK / language adaptations, the +intentional RTO18d entry, and any objects integration/proxy entries) are in +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. For the shared UTS infra these +suites consume, the tier smoke examples, and the `RUN_DEVIATIONS` mechanism, see `uts/README.md`. + +Entries are grouped by actionability (shared taxonomy across both files; only groups with entries in +this file appear as sections below): + +| Group | Meaning | Action | +|---|---|---| +| **1) Genuine SDK bugs — open** | runtime behaviour differs from the spec; ably-js is compliant | fix the SDK | +| **2) Shared gap — open in both SDKs** | ably-java and ably-js deviate the same way | optional joint fix (spec is ahead of both) | +| **3) Expected — typed-SDK / language adaptations** | not bugs: RTTS API partitioning, compile-time guarantees, internal-wire visibility | none — correct as documented | +| **4) Intentional deviation** | deliberate SDK design choice; the spec point itself is questioned | none unless the spec is revised | + +> **Recently fixed and removed from this file:** RTO23e (`get()` now re-attaches a DETACHED channel — +> mode-only check + ensure-active-channel) and RTO20e/RTO20e1 (event-driven `once(SYNCED)` waiters + +> `failSyncWaiters` replace the orphan-prone shared deferred). Their spec-correct tests are un-gated and +> pass by default. +> +> One **test-stimulus adaptation** (not an SDK deviation) remains inline in `RealtimeObjectTest.kt`: the +> RTO20e1 test drives the 92008 path via channel ERROR → FAILED instead of the spec's DETACHED stimulus — +> an unsolicited DETACHED auto-reattaches (RTL13a) and never settles, so the spec's stimulus is +> unobservable. Same adaptation ably-js uses and the spec adopted for the proxy tier (specification#501). + +--- + +# 1) Genuine SDK bugs — open (realtime module) + +*Runtime behaviour differs from the spec and ably-js is compliant — real bugs, pending an SDK fix.* + +> ⚠ **RTL13b / RTL13c:** the channel-state UTS tests these two entries cite are **not currently part of the +> uts suite** (no `RTL13*` tests or gates exist in `unit/realtime/` — only `ConnectionRecoveryTest.kt` is +> translated). The entries are retained as confirmed SDK gaps (cross-checked against ably-js in +> `ABLY-JS-JAVA-DEVIATIONS-COMPARISON.md`); re-verify them when the channels module translation lands. + +## RTL13b — ATTACHING → SUSPENDED via `realtimeRequestTimeout` not implemented + +**Spec point:** RTL13b +**What the spec requires:** If a channel's reattach request (triggered by RTL13a) does not receive a response within `realtimeRequestTimeout`, the channel must transition from ATTACHING to SUSPENDED and schedule a retry after `channelRetryTimeout`. +**What the SDK does:** The channel remains in ATTACHING indefinitely when no server response arrives. The `realtimeRequestTimeout` timer is not applied to channel attach requests; only a server-sent DETACH/ERROR while ATTACHING causes the ATTACHING → SUSPENDED transition. +**Workaround in tests:** Tests that need a SUSPENDED state set up via failed reattach instead use server-sent DETACHED while ATTACHING (RTL13b's second condition, which IS implemented) to drive the channel to SUSPENDED. +**Tests affected:** +- `RTL13a - server DETACHED on SUSPENDED channel triggers immediate reattach` (RTL13a/suspended-reattach-triggered-1) — setup path changed +- `RTL13b - failed reattach transitions to SUSPENDED with automatic retry` (RTL13b/failed-reattach-suspended-retry-0) — mock sends DETACHED instead of withholding response +- `RTL13b - repeated failures cycle SUSPENDED to ATTACHING indefinitely` (RTL13b/repeated-failure-cycle-2) — mock sends DETACHED instead of withholding response +- `RTL13c - automatic retry cancelled when connection is no longer CONNECTED` (RTL13c/retry-cancelled-disconnected-0) — setup path changed + +--- + +## RTL13c — channelRetryTimeout not cancelled when connection leaves CONNECTED + +**Spec point:** RTL13c +**What the spec requires:** When the connection is no longer CONNECTED, any pending automatic channel reattach timer (channelRetryTimeout) must be cancelled. The channel should remain SUSPENDED without attempting to reattach until the connection is restored. +**What the SDK does:** The channelRetryTimeout fires regardless of connection state. When it fires while disconnected, the channel transitions to ATTACHING even though there is no active connection, and no ATTACH message can be sent. +**Tests affected:** +- `RTL13c - automatic retry cancelled when connection is no longer CONNECTED` (RTL13c/retry-cancelled-disconnected-0) — the assertions `assertEquals(attachCountAfterDisconnect, attachCount)` and `assertEquals(ChannelState.suspended, channel.state)` are gated behind `RUN_DEVIATIONS`. + +--- + +## RTN16g2 — Fatal ERROR must be sent without closing the transport + +**Spec point:** RTN16g2 +**What the spec requires:** Trigger FAILED state by sending a fatal ERROR message followed by closing the WebSocket (`send_to_client_and_close`), using error code 50000/statusCode 500. +**What the SDK does (two issues):** +1. Error code 50000/statusCode 500 is not treated as fatal by `isFatalError()` (requires code 40000–49999 or statusCode < 500), so FAILED is never reached with the spec's values. +2. Sending `close(1000)` after the ERROR dispatches a synchronous `DISCONNECTED` action that races with and preempts the async `FAILED` transition triggered by the ERROR message. +**Workaround in tests:** Use `sendToClient` (no close frame) with code 40000/statusCode 400. The SDK's own FAILED-state handler calls `clearTransport()`, so the explicit close is not needed. +**Tests affected:** +- `RTN16g2 - createRecoveryKey returns null in inactive states and before first connect` (RTN16g2/recovery-key-null-inactive-0) — error code and send method changed. + +--- + +## RTN16f — msgSerial not initialised from recovery key on connect + +**Spec point:** RTN16f +**What the spec requires:** When instantiated with the `recover` option, the SDK initialises its internal `msgSerial` counter to the value stored in the recovery key, so the first published message carries that serial. +**What the SDK does:** `ConnectionManager.onConnected()` resets `msgSerial` to 0 whenever `connection.id` is null on the fresh client (line 1316), even when the `recover` option is set. The recovered serial is discarded. +**Workaround in tests:** The spec-correct assertion (`assertEquals(42L, msgSerial)`) is gated behind `RUN_DEVIATIONS`. A regression guard assertion (`assertEquals(0L, msgSerial)`) runs by default to catch any unintentional change to the SDK's actual behaviour. +**Tests affected:** +- `RTN16f - recover option initializes msgSerial from recoveryKey` (RTN16f/recover-initializes-msgserial-0) — `assertEquals(42L, ...)` gated; `assertEquals(0L, ...)` added as regression guard. + +--- + + +# Objects deviations — moved + +All objects tiers (**unit, integration and proxy**) live in `:liveobjects`'s own test source set, +and their deviation records (the typed-SDK / language adaptations, the former groups 3 & 4 as they +applied to objects, and any objects integration/proxy entries) are in +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. This file keeps only the +deviations for the realtime/rest tiers hosted in `:java`. diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt similarity index 98% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt index 613bd3b5a..9ae78cbd5 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt +++ b/lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt @@ -120,7 +120,7 @@ class AuthReauthTest { it.type == "ws_frame" && it.direction == "client_to_server" && it.message?.get("action")?.asInt == 17 && - it.message.get("auth")?.isJsonNull == false + it.message?.get("auth")?.isJsonNull == false } assertTrue( diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt similarity index 97% rename from uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt index e39bc68b4..c5898fc6d 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt +++ b/lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt @@ -12,6 +12,7 @@ import io.ably.lib.uts.infra.pollUntil import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference import kotlin.test.* import kotlin.time.Duration.Companion.seconds @@ -322,10 +323,12 @@ class ConnectionRecoveryTest { */ @Test fun `RTN16f1 - Malformed recoveryKey logs error and connects normally`() = runTest { - var capturedQueryParams: Map? = null + // Written on the SDK transport thread and read from the coroutine dispatcher, so use an + // AtomicReference to publish it safely across threads (avoids a visibility race). + val capturedQueryParams = AtomicReference?>(null) val mock = MockWebSocket { onConnectionAttempt = { conn -> - capturedQueryParams = conn.queryParams + capturedQueryParams.set(conn.queryParams) conn.respondWithSuccess(ProtocolMessage().apply { action = ProtocolMessage.Action.connected connectionId = "fresh-conn" @@ -349,8 +352,8 @@ class ConnectionRecoveryTest { assertEquals(ConnectionState.connected, client.connection.state) assertEquals("fresh-conn", client.connection.id) assertEquals("fresh-key", client.connection.key) - assertNull(capturedQueryParams!!["recover"]) - assertNull(capturedQueryParams!!["resume"]) + assertNull(capturedQueryParams.get()!!["recover"]) + assertNull(capturedQueryParams.get()!!["resume"]) assertEquals(1, mock.events.filterIsInstance().size) client.close() diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index 9d6ad9420..58c19a9a7 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -16,29 +16,42 @@ dependencies { implementation(libs.coroutine.core) testImplementation(project(":java")) - testImplementation(kotlin("test")) - testImplementation(libs.bundles.kotlin.tests) + testImplementation(libs.bundles.kotlin.tests) // keeps the JUnit4 API for the ~9 org.junit.* files + // Shared UTS test infra + the exported (`api`) test toolkit: JUnit 5 (api+params+engine via the + // aggregator), the kotlin.test Jupiter binding and coroutines all arrive transitively from :uts, + // so the wrapper kotlin.test / bom / jupiter-params lines are no longer declared here. The + // deterministic kotlin-test-junit5 binding (not the unpinned wrapper) also comes from :uts. + testImplementation(project(":uts")) + testRuntimeOnly(libs.junit.vintage.engine) // runs the ~9 legacy JUnit4 files under the platform } tasks.withType().configureEach { - testLogging { - exceptionFormat = TestExceptionFormat.FULL - } + useJUnitPlatform() // NEW + testLogging { exceptionFormat = TestExceptionFormat.FULL } jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") beforeTest(closureOf { logger.lifecycle("-> $this") }) outputs.upToDateWhen { false } + systemProperty( // NEW (I6) — copied verbatim from uts/build.gradle.kts + "uts.proxy.localPath", + providers.systemProperty("uts.proxy.localPath") + .orElse(providers.environmentVariable("UTS_PROXY_LOCAL_PATH")) + .getOrElse(""), + ) } 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) } } tasks.register("runLiveObjectsIntegrationTests") { filter { includeTestsMatching("io.ably.lib.liveobjects.integration.*") + includeTestsMatching("io.ably.lib.liveobjects.uts.integration.*") // NEW + includeTestsMatching("io.ably.lib.liveobjects.uts.proxy.*") // NEW // Exclude the base integration test class excludeTestsMatching("io.ably.lib.liveobjects.integration.setup.IntegrationTest") } 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..5827334b7 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. @@ -198,6 +198,7 @@ internal class DefaultRealtimeObject( } syntheticMessages.add(msg.copy(serial = serial, siteCode = siteCode)) // RTO20d2a, RTO20d2b, RTO20d3 } + // RTO20d4 - empty synthetic list (e.g. all serials null per RTO20d1): nothing to apply, so complete successfully without the RTO20e wait if (syntheticMessages.isEmpty()) return // RTO20e, RTO20f - dispatch to sequential scope for ordering. This hop is also the thread-safety @@ -261,6 +262,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) { @@ -290,26 +299,23 @@ internal class DefaultRealtimeObject( ChannelState.detached, ChannelState.suspended, ChannelState.failed -> { + // RTO23c1, RTO20e1 - pass only state + errorReason; each parked waiter (get vs publishAndApply) builds its own 92008 error with its caller-specific prefix in failSyncWaiters val errorReason = try { adapter.getChannel(channelName).reason } catch (e: Exception) { null } - val error = ablyException( - "publishAndApply could not be applied locally: channel entered $state whilst waiting for objects sync", - ObjectErrorCode.PublishAndApplyFailedDueToChannelState, - ObjectHttpStatusCode.BadRequest, - cause = errorReason?.let { AblyException.fromErrorInfo(it) } - ) - objectsManager.failSyncWaiters(error) // RTO20e1 + objectsManager.failSyncWaiters(state, errorReason) // RTO23c1, 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 } } } 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..157b37c12 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). @@ -55,6 +56,10 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject */ internal fun handleObjectSyncMessages(wireObjectMessages: List, syncChannelSerial: String?) { val syncTracker = ObjectsSyncTracker(syncChannelSerial) + if (syncTracker.isMalformed) { + // RTO5a6 - a malformed channelSerial (no ':' separator) is handled as absent (RTO5a5): warn and fall through so null syncId/syncCursor makes the flow apply the messages and end the sync like a single-message sync + Log.w(tag, "OBJECT_SYNC channelSerial is malformed (missing ':' separator); treating as absent per RTO5a6: $syncChannelSerial") + } val isNewSync = syncTracker.hasSyncStarted(currentSyncId) if (isNewSync) { // RTO5a2 - new sync sequence started @@ -112,7 +117,8 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject // MUST run on the sequential scope: the state check + waiter registration in awaitSyncCompletion // is atomic only there (same lost-wakeup hazard as ensureSynced). if (realtimeObjects.state != ObjectsState.Synced) { - awaitSyncCompletion() // suspends until SYNCED (RTO20e); throws 92008 on channel state change (RTO20e1) + // RTO20e1 - the publishAndApply-specific failure message prefix; get()'s wait uses its own (RTO23c1). + awaitSyncCompletion("the operation could not be applied locally") // suspends until SYNCED (RTO20e); throws 92008 on channel state change (RTO20e1) } applyObjectMessages(messages, ObjectsOperationSource.LOCAL) // RTO20f } @@ -206,7 +212,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 +250,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/ObjectsState.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsState.kt index 167f75788..01cb056a4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsState.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsState.kt @@ -2,7 +2,9 @@ package io.ably.lib.liveobjects import io.ably.lib.liveobjects.state.ObjectStateChange import io.ably.lib.liveobjects.state.ObjectStateEvent +import io.ably.lib.realtime.ChannelState import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo import io.ably.lib.util.EventEmitter import io.ably.lib.util.Log import kotlinx.coroutines.* @@ -65,11 +67,25 @@ internal abstract class ObjectsStateCoordinator : ObjectStateChange, HandlesObje private val externalObjectStateEmitter = ObjectsStateEmitter() /** - * Pending publishAndApply waiters (RTO20e): each suspends until SYNCED, and is failed (RTO20e1) — rather - * than orphaned — if the channel enters DETACHED/SUSPENDED/FAILED while waiting. All access happens on the - * single sequential scope (see [DefaultRealtimeObject]), so no additional synchronization is required. + * Pending sync waiters: both get() (RTO23c/RTO23c1) and publishAndApply (RTO20e/RTO20e1) park here until + * SYNCED. Each suspends until SYNCED, and is failed — rather than orphaned — if the channel enters + * DETACHED/SUSPENDED/FAILED while waiting. Each waiter carries its own [SyncWaiter.failureDescription] + * message prefix so [failSyncWaiters] builds the caller-specific 92008 error (RTO23c1's "object could not + * be retrieved" vs RTO20e1's "operation could not be applied locally"). All access happens on the single + * sequential scope (see [DefaultRealtimeObject]), so no additional synchronization is required. */ - private val pendingSyncWaiters = mutableSetOf>() + private val pendingSyncWaiters = mutableSetOf() + + /** + * A parked sync waiter. Its [deferred] resolves on SYNCED, or is failed by [failSyncWaiters] with an error + * whose message begins with [failureDescription] — the caller-specific RTO23c1 (get) / RTO20e1 + * (publishAndApply) prefix. Mirrors ably-js's shared `_waitForSyncedOrChannelFailure(failureDescription)` + * helper, where the only per-caller difference is that prefix. + */ + private class SyncWaiter( + val failureDescription: String, + val deferred: CompletableDeferred = CompletableDeferred(), + ) override fun on(event: ObjectStateEvent, listener: ObjectStateChange.Listener): Subscription { externalObjectStateEmitter.on(event, listener) @@ -90,66 +106,72 @@ internal abstract class ObjectsStateCoordinator : ObjectStateChange, HandlesObje } override suspend fun ensureSynced(currentState: ObjectsState) { - // MUST be called on the sequential scope: the state check and the once(SYNCED) registration - // below are atomic only because SYNCED transitions run on that same scope. Off it, a SYNCED - // fired between the check and once() would be lost and this would suspend forever. + // MUST be called on the sequential scope: the state check and the waiter registration in + // awaitSyncCompletion below are atomic only because SYNCED transitions run on that same scope. + // Off it, a SYNCED fired between the check and the registration would be lost and this would + // suspend forever. if (currentState != ObjectsState.Synced) { - val deferred = CompletableDeferred() - val syncedListener = ObjectStateChange.Listener { - Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") - deferred.complete(Unit) - } - internalObjectStateEmitter.once(ObjectStateEvent.SYNCED, syncedListener) - try { - deferred.await() - } finally { - // off() the one-shot on either path (same cleanup pattern as awaitSyncCompletion) so it never - // lingers if the waiting coroutine is cancelled (e.g. dispose while a get() awaits sync). - internalObjectStateEmitter.off(ObjectStateEvent.SYNCED, syncedListener) - } + // RTO23c1 - route get()'s wait through the same [pendingSyncWaiters] machinery publishAndApply uses (RTO20e/RTO20e1) so [failSyncWaiters] fails it with the 92008 error instead of orphaning it on DETACHED/SUSPENDED/FAILED + awaitSyncCompletion("the object could not be retrieved") } } /** * Suspends until objects transition to SYNCED (via a one-shot SYNCED listener), or throws if the channel - * leaves a usable state while waiting ([failSyncWaiters], RTO20e1). Unlike [ensureSynced], the waiter is - * tracked in [pendingSyncWaiters] so it can be failed rather than orphaned across re-syncs (the SYNCED - * event resolves whichever sync ultimately completes, regardless of how many sync cycles occur while - * waiting). + * leaves a usable state while waiting ([failSyncWaiters], RTO20e1/RTO23c1). Shared by get() (RTO23c) and + * publishAndApply (RTO20e); the two callers differ only in [failureDescription], the message prefix used to + * build the failure error (see [SyncWaiter]). Unlike [ensureSynced], the waiter is tracked in + * [pendingSyncWaiters] so it can be failed rather than orphaned across re-syncs (the SYNCED event resolves + * whichever sync ultimately completes, regardless of how many sync cycles occur while waiting). * - * Spec: RTO20e, RTO20e1 + * @param failureDescription caller-specific prefix for the 92008 error message if the wait is failed — + * "the object could not be retrieved" for get() (RTO23c1) or "the operation could not be applied locally" + * for publishAndApply (RTO20e1). + * + * Spec: RTO20e, RTO20e1, RTO23c, RTO23c1 */ - protected suspend fun awaitSyncCompletion() { - val deferred = CompletableDeferred() - pendingSyncWaiters.add(deferred) - // Keep a reference to the one-shot listener so it can be removed on either resolution path (mirrors - // ably-js publishAndApply's cleanup()). The once() semantics already drop it when SYNCED fires, but we - // off() it explicitly in finally so it never lingers when the wait ends via failSyncWaiters (RTO20e1). + protected suspend fun awaitSyncCompletion(failureDescription: String) { + val waiter = SyncWaiter(failureDescription) + pendingSyncWaiters.add(waiter) + // off() the one-shot in finally (not just once()'s auto-drop on SYNCED) so it never lingers when the wait ends via failSyncWaiters (RTO20e1/RTO23c1) val syncedListener = ObjectStateChange.Listener { - Log.v(tag, "Objects state changed to SYNCED, resuming pending publishAndApply") - deferred.complete(Unit) + Log.v(tag, "Objects state changed to SYNCED, resuming parked sync waiter") + waiter.deferred.complete(Unit) } internalObjectStateEmitter.once(ObjectStateEvent.SYNCED, syncedListener) try { - deferred.await() + waiter.deferred.await() } finally { - pendingSyncWaiters.remove(deferred) + pendingSyncWaiters.remove(waiter) internalObjectStateEmitter.off(ObjectStateEvent.SYNCED, syncedListener) } } /** - * Fails every pending [awaitSyncCompletion] waiter — called when the channel enters - * DETACHED/SUSPENDED/FAILED while a publishAndApply is waiting for SYNCED. Matches ably-js, which only - * catches channel-state transitions that fire after a waiter has started waiting (once() semantics). + * Fails every pending [awaitSyncCompletion] waiter — called from [DefaultRealtimeObject.handleStateChange] + * when the channel enters DETACHED/SUSPENDED/FAILED while a get() or publishAndApply is waiting for SYNCED. + * The failure site supplies only the [state] context and the channel's [reason]; each waiter then builds + * its own AblyException, prefixing its caller-specific [SyncWaiter.failureDescription] (RTO23c1 vs RTO20e1) + * onto the shared 92008 / statusCode 400 / cause=reason error that both spec points mandate identically. + * Mirrors ably-js, which only catches channel-state transitions that fire after a waiter has started + * waiting (once() semantics). * - * Spec: RTO20e1 + * Spec: RTO20e1, RTO23c1 */ - fun failSyncWaiters(error: AblyException) { + fun failSyncWaiters(state: ChannelState, reason: ErrorInfo?) { if (pendingSyncWaiters.isEmpty()) return val waiters = pendingSyncWaiters.toList() pendingSyncWaiters.clear() - waiters.forEach { it.completeExceptionally(error) } + val cause = reason?.let { AblyException.fromErrorInfo(it) } + waiters.forEach { waiter -> + val error = ablyException( + "${waiter.failureDescription}: channel entered $state whilst waiting for objects sync", + ObjectErrorCode.PublishAndApplyFailedDueToChannelState, + ObjectHttpStatusCode.BadRequest, + cause = cause, + ) + waiter.deferred.completeExceptionally(error) + } } override fun disposeObjectsStateListeners() = offAll() diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsSyncTracker.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsSyncTracker.kt index 14ec73586..32deb5522 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsSyncTracker.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/ObjectsSyncTracker.kt @@ -4,14 +4,27 @@ package io.ably.lib.liveobjects * @spec RTO5 - SyncTracker class for tracking objects sync status */ internal class ObjectsSyncTracker(syncChannelSerial: String?) { - private val syncSerial: String? = syncChannelSerial + private val syncSerial: String? internal val syncId: String? internal val syncCursor: String? + /** + * RTO5a6 - true when the channelSerial is present (non-empty) but malformed: it does not contain + * the `:` separator required by RTO5a1 and so cannot be split into a `` and a + * ``. Per RTO5a6 the caller handles such a serial as if it were absent (RTO5a5) and + * should log a warning; the flag exists so the caller can emit that warning. + * + * An absent/empty channelSerial (RTO5a5, a valid single-message sync) is NOT malformed. + */ + internal val isMalformed: Boolean + init { val parsed = parseSyncChannelSerial(syncChannelSerial) - syncId = parsed.first - syncCursor = parsed.second + syncId = parsed.syncId + syncCursor = parsed.syncCursor + isMalformed = parsed.isMalformed + // RTO5a6 - normalize a malformed serial to null so hasSyncStarted/hasSyncEnded take the same absent-serial branch as RTO5a5; isMalformed is kept only for the caller's warning + syncSerial = if (parsed.isMalformed) null else syncChannelSerial } /** @@ -20,7 +33,10 @@ internal class ObjectsSyncTracker(syncChannelSerial: String?) { * @param prevSyncId The previously stored sync ID * @return true if a new sync sequence has started, false otherwise * - * Spec: RTO5a5, RTO5a2 + * A malformed serial is normalized to a null [syncSerial] in `init`, so it takes the same + * absent-serial branch here (RTO5a6 -> RTO5a5). + * + * Spec: RTO5a5, RTO5a2, RTO5a6 */ internal fun hasSyncStarted(prevSyncId: String?): Boolean { return syncSerial.isNullOrEmpty() || prevSyncId != syncId @@ -31,32 +47,47 @@ internal class ObjectsSyncTracker(syncChannelSerial: String?) { * * @return true if the sync sequence has ended, false otherwise * - * Spec: RTO5a5, RTO5a4 + * A malformed serial is normalized to a null [syncSerial] in `init`, so it takes the same + * absent-serial branch here (RTO5a6 -> RTO5a5). + * + * Spec: RTO5a5, RTO5a4, RTO5a6 */ internal fun hasSyncEnded(): Boolean { return syncSerial.isNullOrEmpty() || syncCursor.isNullOrEmpty() } companion object { + /** Parsed form of a `channelSerial`, distinguishing absent (RTO5a5) from malformed (RTO5a6). */ + private data class ParsedSyncChannelSerial( + val syncId: String?, + val syncCursor: String?, + val isMalformed: Boolean, + ) + /** * Parses sync channel serial to extract syncId and syncCursor. * * @param syncChannelSerial The sync channel serial to parse - * @return Pair of syncId and syncCursor, both null if parsing fails + * @return the parsed syncId/syncCursor, plus [ParsedSyncChannelSerial.isMalformed] flagging the + * RTO5a6 present-but-unparseable case (distinct from the RTO5a5 absent-serial case) */ - private fun parseSyncChannelSerial(syncChannelSerial: String?): Pair { + private fun parseSyncChannelSerial(syncChannelSerial: String?): ParsedSyncChannelSerial { + // RTO5a5 - an absent/empty channelSerial is valid (self-contained sync), not malformed if (syncChannelSerial.isNullOrEmpty()) { - return Pair(null, null) + return ParsedSyncChannelSerial(syncId = null, syncCursor = null, isMalformed = false) } // RTO5a1 - syncChannelSerial is a two-part identifier: : val match = Regex("^([\\w-]+):(.*)$").find(syncChannelSerial) return if (match != null) { - val syncId = match.groupValues[1] - val syncCursor = match.groupValues[2] - Pair(syncId, syncCursor) + ParsedSyncChannelSerial( + syncId = match.groupValues[1], + syncCursor = match.groupValues[2], + isMalformed = false, + ) } else { - Pair(null, null) + // RTO5a6 - present but lacks the `:` separator to split into :; flag it so the caller warns and treats it as absent + ParsedSyncChannelSerial(syncId = null, syncCursor = null, isMalformed = true) } } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt index d206b37fe..b085e8a7e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt @@ -92,12 +92,12 @@ internal class DefaultMapRemove(private val mapRemove: WireMapRemove) : MapRemov /** Spec: CCR2 */ internal class DefaultCounterCreate(private val counterCreate: WireCounterCreate) : CounterCreate { - override fun getCount(): Double = counterCreate.count + override fun getCount(): Double? = counterCreate.count } /** Spec: CIN2 */ internal class DefaultCounterInc(private val counterInc: WireCounterInc) : CounterInc { - override fun getNumber(): Double = counterInc.number + override fun getNumber(): Double? = counterInc.number } /** Spec: ODE2 - no attributes */ diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt index f7abaf431..a004a9aeb 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt @@ -70,12 +70,12 @@ internal data class WireMapRemove( /** Spec: CCR2 */ internal data class WireCounterCreate( - val count: Double, // CCR2a + val count: Double?, // CCR2a - may be absent on the wire (RTLC16d) ) /** Spec: CIN2 */ internal data class WireCounterInc( - val number: Double, // CIN2a + val number: Double?, // CIN2a - may be absent on the wire (RTLC9h) ) /** Spec: ODE2 - no attributes */ @@ -166,7 +166,9 @@ internal fun WireObjectMessage.size(): Int { val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 - val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d + // Spec: OM3d - extras is measured as "the string length of its JSON representation" (docs + // verbatim), i.e. the UTF-16 code-unit count of the JSON string, not its UTF-8 byte length. + val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 return clientIdSize + operationSize + objectStateSize + extrasSize } @@ -254,7 +256,7 @@ private fun WireCounterCreateWithObjectId.size(): Int { private fun WireObjectsMap.size(): Int { // Calculate the size of all map entries in the map property val entriesSize = entries?.entries?.sumOf { - it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 + it.key.byteSize + it.value.size() // Spec: OMP4a1, OMP4a2 (key measured as its UTF-8 byte length) } ?: 0 return entriesSize @@ -287,7 +289,7 @@ private fun WireObjectData.size(): Int { number?.let { return 8 } // Spec: OD3d boolean?.let { return 1 } // Spec: OD3b bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c - json?.let { return it.toString().byteSize } // Spec: OD3e + json?.let { return it.toString().byteSize } // Spec: OD3g (UTF-8 byte length of JSON-encoded string) return 0 } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt index 18d5c0701..85e4a6270 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt @@ -521,9 +521,11 @@ private fun readMapRemove(unpacker: MessageUnpacker): WireMapRemove { * Write WireCounterCreate to MessagePacker */ private fun WireCounterCreate.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("count") - packer.packDouble(count) + packer.packMapHeader(if (count != null) 1 else 0) + if (count != null) { + packer.packString("count") + packer.packDouble(count) + } } /** @@ -542,16 +544,19 @@ private fun readCounterCreate(unpacker: MessageUnpacker): WireCounterCreate { else -> unpacker.skipValue() } } - return WireCounterCreate(count = count ?: throw objectStateError("Missing 'count' in WireCounterCreate payload")) + // count may legitimately be absent (RTLC16d) - the apply path treats it as a noop + return WireCounterCreate(count = count) } /** * Write WireCounterInc to MessagePacker */ private fun WireCounterInc.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("number") - packer.packDouble(number) + packer.packMapHeader(if (number != null) 1 else 0) + if (number != null) { + packer.packString("number") + packer.packDouble(number) + } } /** @@ -570,7 +575,8 @@ private fun readCounterInc(unpacker: MessageUnpacker): WireCounterInc { else -> unpacker.skipValue() } } - return WireCounterInc(number = number ?: throw objectStateError("Missing 'number' in WireCounterInc payload")) + // number may legitimately be absent (RTLC9h) - the apply path treats it as a noop + return WireCounterInc(number = number) } /** 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 970198208..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 @@ -138,12 +138,14 @@ internal abstract class BaseRealtimeObject( if (!canApplyOperation(msgSiteCode, msgTimeSerial)) { // RTLC7b, RTLM15b - Log.v( - tag, - "Skipping ${wireObjectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + - "objectId=$objectId" - ) - return false // RTLC7b / RTLM15b + if (!msgTimeSerial.isNullOrEmpty() && !msgSiteCode.isNullOrEmpty()) { + Log.v( + tag, + "Skipping ${wireObjectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + + "objectId=$objectId" + ) + } + return ObjectUpdate.NoOp // RTLC7b / RTLM15b } // RTLC7c / RTLM15c - only update siteTimeserials for CHANNEL source if (source == ObjectsOperationSource.CHANNEL) { @@ -152,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 } @@ -163,11 +165,11 @@ internal abstract class BaseRealtimeObject( * @spec RTLO4a - Serial comparison logic for LiveMap/LiveCounter operations */ internal fun canApplyOperation(siteCode: String?, timeSerial: String?): Boolean { - if (timeSerial.isNullOrEmpty()) { - throw objectError("Invalid serial: $timeSerial") // RTLO4a3 - } - if (siteCode.isNullOrEmpty()) { - throw objectError("Invalid site code: $siteCode") // RTLO4a3 + if (timeSerial.isNullOrEmpty() || siteCode.isNullOrEmpty()) { + // RTLO4a3 - log a warning and refuse to apply; must not throw, as a throw would abort + // every sibling operation in the same ProtocolMessage batch + Log.w(tag, "Invalid serial values on object operation message; serial=$timeSerial, siteCode=$siteCode; objectId=$objectId") + return false // RTLO4a3 } val existingSiteSerial = siteTimeserials[siteCode] // RTLO4a4 return existingSiteSerial == null || timeSerial > existingSiteSerial // RTLO4a5, RTLO4a6 @@ -182,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 } @@ -245,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 b3f7eab59..e36c0e606 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,30 +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 { - throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}") + // 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") + 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 } } } @@ -100,14 +103,14 @@ internal class LiveCounterManager(private val liveCounter: InternalLiveCounter): */ private fun applyCounterInc(wireCounterInc: WireCounterInc, message: WireObjectMessage): ObjectUpdate { val amount = wireCounterInc.number + ?: return noOpCounterUpdate // RTLC9h - no number means a noop, not a zero increment val previousValue = liveCounter.data.get() liveCounter.data.set(previousValue + amount) // RTLC9f return ObjectUpdate.CounterUpdate(amount, message) // RTLC9g } internal fun calculateUpdateFromDataDiff(prevData: Double, newData: Double): ObjectUpdate { - // A zero delta means the value did not change (e.g. clearing an already-zero counter). - // Return the no-op update so notifyUpdated() short-circuits and no event is emitted. Spec: RTLC14b + // RTLC14c - exception to RTLC14b: if newData == prevData the delta is 0 (no change), so return a no-op update (per RTLO4b4b) instead of a LiveCounterUpdate of amount newData - prevData return if (newData == prevData) noOpCounterUpdate else ObjectUpdate.CounterUpdate(newData - prevData) } @@ -121,10 +124,14 @@ internal class LiveCounterManager(private val liveCounter: InternalLiveCounter): // which we're going to add now. val count = operation.counterCreateWithObjectId?.derivedFrom?.count ?: operation.counterCreate?.count - ?: 0.0 + // RTLC16b is unconditional and must precede the RTLC16d noop return, so that RTLC8b's + // duplicate-create skip engages even for a create op that carried no count + liveCounter.createOperationIsMerged = true // RTLC16b + if (count == null) { + return noOpCounterUpdate // RTLC16d - no count means a noop, not a zero-amount update + } val previousValue = liveCounter.data.get() liveCounter.data.set(previousValue + count) // RTLC16a - liveCounter.createOperationIsMerged = true // RTLC16b return ObjectUpdate.CounterUpdate(count, message) // RTLC16c } 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 d3b6f178c..72d915975 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,44 +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 { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") + // 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") + 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 { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") + Log.w(tag, "No payload found for ${operation.action} op for LiveMap objectId=${objectId}, skipping") + 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 } } } @@ -401,9 +405,7 @@ internal class LiveMapManager(private val liveMap: InternalLiveMap): LiveMapChan } } - // An empty diff means nothing actually changed (e.g. clearing an already-empty root - // map on a channel with no objects). Return the no-op update so notifyUpdated() - // short-circuits and no change event is emitted. Spec: RTLM22b/RTO4b. + // RTLM22c - exception to RTLM22b: if the computed update has no changed keys (empty), no key changed, so return a no-op update (per RTLO4b4b) instead of a MapUpdate return if (update.isEmpty()) noOpMapUpdate else ObjectUpdate.MapUpdate(update) } 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/unit/ObjectMessageSizeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt index 8449a603c..52725680d 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/ObjectMessageSizeTest.kt @@ -158,6 +158,30 @@ class ObjectMessageSizeTest { assertEquals(7, objectMessage.size()) } + @Test + fun testObjectMapStateEntryKeyUnicodeSizeIsUtf8() = runTest { + // OMP4a1: map-state entry keys are measured as their UTF-8 byte length, consistent with the + // MapCreate/MapSet/MapRemove operation keys. "你😊" -> 7 UTF-8 bytes (not 3 UTF-16 code units); + // the entry's data string "v" -> 1 byte. + val objectMessage = WireObjectMessage( + objectState = WireObjectState( + objectId = "", + siteTimeserials = mapOf(), + tombstone = false, + map = WireObjectsMap( + entries = mapOf( + "你😊" to WireObjectsMapEntry( + data = WireObjectData( + string = "v" // Size: 1 byte + ) + ) + ) + ) + ) + ) + assertEquals(8, objectMessage.size()) + } + @Test fun testObjectMessageSizeAboveLimit() = runTest { val mockAdapter = getMockAblyClientAdapter() 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..415c605ef --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md @@ -0,0 +1,24 @@ +# UTS objects suites (`:liveobjects`) + +Skill-generated tests for the UTS `objects` specs (`/uts-to-kotlin`), one class per spec. All three +objects tiers live here in `:liveobjects`'s own test source set: + +| Tier | Package | Run with | +|---|---|---| +| unit | `io.ably.lib.liveobjects.uts.unit` | `./gradlew :liveobjects:runLiveObjectsUnitTests` | +| integration (direct sandbox) | `io.ably.lib.liveobjects.uts.integration` | `./gradlew :liveobjects:runLiveObjectsIntegrationTests` | +| proxy | `io.ably.lib.liveobjects.uts.proxy` | `./gradlew :liveobjects:runLiveObjectsIntegrationTests` | + +(`runLiveObjectsIntegrationTests` covers **both** the `integration` and `proxy` packages.) + +- These suites live 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 + the tier's `Helpers.kt`; only the + five internal-graph unit 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` infra (`io.ably.lib.uts.infra.*`, consumed via + `testImplementation(project(":uts"))`); message builders are the typed `Wire*` constructions in each + tier's `Helpers.kt` (`unit/Helpers.kt`, `integration/Helpers.kt`). +- Deviations for all three tiers are recorded in [`deviations.md`](deviations.md) (same discipline as + the realtime/rest tiers' `:java`-hosted `lib/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..3223ad755 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md @@ -0,0 +1,141 @@ +# Deviations — UTS objects suites (`io.ably.lib.liveobjects.uts.*`) + +> **Scope:** this file holds deviations for **all three objects tiers** now hosted in `:liveobjects` — +> objects **unit** (`io.ably.lib.liveobjects.uts.unit`), **integration** +> (`io.ably.lib.liveobjects.uts.integration`) and **proxy** (`io.ably.lib.liveobjects.uts.proxy`). +> The integration/proxy tiers moved here from `:uts` in Phase 3 alongside the tests they document. +> +> Records every place a generated test deviates from its UTS spec, using the manual's +> **Recording deviations** entry format. Structural deviation vocabulary for these suites: +> S-1…S-4 in `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17.9. +> (The realtime/rest tiers keep their own file, `:java`-hosted, at +> `lib/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/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt index ebf568fe9..f90442881 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.standard.liveobjects +package io.ably.lib.liveobjects.uts.integration import com.google.gson.JsonArray import com.google.gson.JsonElement diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsLifecycleTest.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsLifecycleTest.kt index f2a7df73f..ea3456aa2 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsLifecycleTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.standard.liveobjects +package io.ably.lib.liveobjects.uts.integration import io.ably.lib.liveobjects.path.PathObject import io.ably.lib.liveobjects.path.PathObjectListener diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsSyncTest.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsSyncTest.kt index f3eecf7e9..8c7b90bff 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsSyncTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.standard.liveobjects +package io.ably.lib.liveobjects.uts.integration import io.ably.lib.liveobjects.path.PathObject import io.ably.lib.liveobjects.value.LiveMapValue diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy/ObjectsFaultsTest.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy/ObjectsFaultsTest.kt index eaf408caf..a72bf50f5 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy/ObjectsFaultsTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.proxy.liveobjects +package io.ably.lib.liveobjects.uts.proxy import io.ably.lib.liveobjects.path.PathObject import io.ably.lib.liveobjects.value.LiveMapValue 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..71d440aad --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt @@ -0,0 +1,409 @@ +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 lives with this module's integration tier +// (io.ably.lib.liveobjects.uts.integration.Helpers). + +// --------------------------------------------------------------------------- +// 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..4c02370a7 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveCounterTest.kt @@ -0,0 +1,571 @@ +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/RTLO5/tombstone-zero-value-counter-emits-update-0 + */ + @Test + fun `RTLO5 - OBJECT_DELETE on an already-zero counter still emits a non-noop tombstone update`() { + // RTLC14c: the tombstone diff (previousData 0, newData 0) is a zero delta, but the zero-delta + // noop exception must NOT be applied for a tombstone diff - the update is still delivered to + // drive the RTLO4b4c3c listener teardown. Complements object-delete-tombstones-0 (populated). + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(0.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) // counter.isTombstone == true + assertEquals(0.0, counter.data.get()) // counter.data == 0 + // update.noop == false: a typed CounterUpdate (RTLO4e5/RTLC14c carve-out), not a NoOp + val counterUpdate = assertIs(update) + assertFalse(counterUpdate.noOp) // update.noop == false + assertTrue(counterUpdate.tombstone) // update.tombstone == true (RTLO4e6) + assertEquals(0.0, counterUpdate.amount) // update.update.amount == 0 + assertEquals(msg, counterUpdate.objectMessage) // update.objectMessage == msg (RTLO4e7) + } + + /** + * @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/RTLC14c/zero-delta-diff-is-noop-0 + */ + @Test + fun `RTLC14c - zero-delta diff is a no-op`() { + // RTLC14c: as an exception to RTLC14b, when newData equals previousData the computed delta + // is 0, so the diff returns a no-op update (RTLO4b4b) - never delivered to subscribers + // (RTLO4b4c1); at the internal tier the flake-free proxy for "no event fires" is the no-op. + val counter = InternalLiveCounter.zeroValue("counter:abc@1000", ro) + counter.data.set(100.0) + + val stateMsg = buildObjectState("counter:abc@1000", mapOf("site1" to "01"), counter = counterState(100)) + val update = counter.applyObjectSync(stateMsg) + + assertIs(update) // update.noop == true + assertEquals(100.0, counter.data.get()) + } + + /** + * @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..383c30586 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/InternalLiveMapTest.kt @@ -0,0 +1,979 @@ +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/RTLO5/tombstone-empty-map-emits-update-0 + */ + @Test + fun `RTLO5 - OBJECT_DELETE on a map with no non-tombstoned entries still emits a non-noop tombstone update`() { + // Uses a non-root map: an OBJECT_DELETE targeting root is rejected per RTLO4e10. + // RTLM22c: every entry is already tombstoned, so the tombstone diff (RTLM22b considers only + // non-tombstoned entries) has no changed keys - but the empty-diff noop exception must NOT be + // applied for a tombstone diff; the empty update is still delivered to drive the RTLO4b4c3c + // listener teardown. Complements object-delete-tombstones-map-0 (live entries). + val map = InternalLiveMap.zeroValue("map:test@1000", ro) + map.data["name"] = LiveMapEntry(isTombstoned = true, tombstonedAt = 1_600_000_000_000L, timeserial = "01", data = dataString("Alice")) + map.data["age"] = LiveMapEntry(isTombstoned = true, tombstonedAt = 1_600_000_000_000L, 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) // map.isTombstone == true + assertTrue(map.data.isEmpty()) // map.data == {} + // update.noop == false: a typed MapUpdate (RTLO4e5/RTLM22c carve-out), not a NoOp + val mapUpdate = assertIs(update) + assertFalse(mapUpdate.noOp) // update.noop == false + assertTrue(mapUpdate.tombstone) // update.tombstone == true (RTLO4e6) + assertEquals(emptyMap(), mapUpdate.update) // update.update == {} + assertEquals(msg, mapUpdate.objectMessage) // update.objectMessage == msg (RTLO4e7) + } + + /** + * @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/RTLM22c/empty-diff-is-noop-0 + */ + @Test + fun `RTLM22c - empty diff is a no-op`() { + // RTLM22c: as an exception to RTLM22b, the non-tombstoned entries before and after are + // identical under the RTLM22b comparison (same key `name`, same `data`; only `timeserial` + // differs, which is not compared), so no key changed and the diff returns a no-op update + // (RTLO4b4b), never delivered to subscribers (RTLO4b4c1). + val map = InternalLiveMap.zeroValue("root", ro) + map.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("alice")) + + val stateMsg = buildObjectState( + "root", mapOf("site1" to "02"), + map = mapState(entries = mapOf("name" to mapEntry(dataString("alice"), timeserial = "02"))), + ) + val update = map.applyObjectSync(stateMsg) + + assertIs(update) // update.noop == true + assertEquals(dataString("alice"), map.data["name"]?.data) + } + + /** + * @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..e95529fdc --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/LiveObjectSubscribeTest.kt @@ -0,0 +1,395 @@ +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/RTLO4b4c3c/tombstone-zero-value-counter-tears-down-0 + */ + @Test + fun `RTLO4b4c3c - tombstone update on an already-zero counter still fires listeners then deregisters`() = runTest { + val (client, _, root, mockWs) = setupSyncedChannel("test") + val updatesA = mutableListOf() + val updatesB = mutableListOf() + val control = mutableListOf() + val instance = assertNotNull(root.get("score").instance()).asLiveCounter() + + // Drive the counter (100 in the standard pool) down to 0 BEFORE registering the listeners + // under test, so they observe only the tombstone. poll_until(value() == 0) is the quiescence + // barrier that the increment has been applied before we subscribe, so the "-100" update is + // not seen by them. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", -100, "40", "remote"))), + ) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 0.0 } + + instance.subscribe(InstanceListener { event -> updatesA.add(event) }) + instance.subscribe(InstanceListener { event -> updatesB.add(event) }) + + // OBJECT_DELETE tombstones the already-zero counter (zero-delta diff, RTLC14c → NOT a + // no-op). 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 received the tombstone update even though the counter data did not change + // (0 → 0). + 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) + + // Prove deregistration. As in the populated teardown case, a tombstoned object ignores + // further ops (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, the follow-up "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: the + // tombstone deregistered them per RTLO4b4c3c. + 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..4bc73fe01 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/ObjectsPoolTest.kt @@ -0,0 +1,890 @@ +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, target: DefaultRealtimeObject = ro) { + target.objectsManager.clearBufferedObjectOperations() // RTO4d + target.objectsManager.startNewSync(null) // RTO4c + if (!hasObjects) { + target.objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2, RTO4b2a + target.objectsManager.clearSyncObjectsPool() // RTO4b3 + target.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) + + /** RTO5a5 - an OBJECT_SYNC with no channelSerial (the single-message sync). */ + private fun processObjectSyncNoSerial(messages: List) = + ro.objectsManager.handleObjectSyncMessages(messages, null) + + /** 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/RTO4b2a/reset-of-empty-root-emits-no-update-0 + */ + @Test + fun `RTO4b2a - ATTACHED without HAS_OBJECTS on an already-empty root emits no update`() { + // Complements RTO4b/attached-no-objects-synced-0 (populated root). Here root is already + // empty, so the RTO4b2 reset removes no keys: the LiveMapUpdate has no changed keys and, per + // RTLM22c/RTLO4b4b, collapses to a no-op that must NOT be delivered to root subscribers. + ro.objectsPool.set("counter:abc@1000", InternalLiveCounter.zeroValue("counter:abc@1000", ro)) + // root is already empty (zero-value InternalLiveMap per RTLM4c) - nothing to seed. + assertTrue(rootMap().data.isEmpty()) + + val updates = subscribeRootEvents() + processAttached(hasObjects = false) + + assertEquals(ObjectsState.Synced, ro.state) + assertNull(ro.objectsPool.get("counter:abc@1000")) // RTO4b1: non-root objects are still removed + assertNotNull(ro.objectsPool.get("root")) + assertTrue(rootMap().data.isEmpty()) + // RTO4b2a: no keys were removed, so the empty update collapses to a no-op and is not delivered. + assertEquals(0, updates.size) + + // Liveness control: prove the subscription wiring is live — a reset that DOES remove a key + // still emits, so updates.size == 0 above reflects the empty-root collapse and not a dead + // subscription. Mirrors RTO4b/attached-no-objects-synced-0 on a SECOND pool. Emission at the + // ObjectsPool tier is synchronous (see that case), so no polling is required. + val ro2 = DefaultRealtimeObject("test", getMockAblyClientAdapter()) + try { + val root2 = ro2.objectsPool.get(ROOT_OBJECT_ID) as InternalLiveMap + root2.data["name"] = LiveMapEntry(timeserial = "01", data = dataString("Alice")) + val control = mutableListOf() + root2.subscribe(InstanceListener { event -> control.add(event) }) + + processAttached(hasObjects = false, target = ro2) + + assertTrue(control.size >= 1) + // DEVIATION S-1 (see deviations.md): control[0].update == { "name": "removed" } is not + // exposed on the subscription event — the cleared root2 data below covers the removal. + assertTrue(root2.data.isEmpty()) + } finally { + ro2.objectsPool.dispose() // DEVIATION S-4: dispose the second pool's GC coroutine + } + } + + /** + * @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/RTO5a6/malformed-channel-serial-treated-as-absent-0 + * + * A present-but-malformed channelSerial (no `:` separator, so it cannot be split into + * `:`) is handled as if the channelSerial were absent (RTO5a5): its + * messages are applied and the sync ends (SYNCED). A warning is logged. + */ + @Test + fun `RTO5a6 - malformed channelSerial is treated as absent, applying data and ending sync`() { + processAttached(hasObjects = true) + assertEquals(ObjectsState.Syncing, ro.state) + + // "malformedserialnocolon" lacks the ':' separator, so it cannot be parsed per RTO5a1; RTO5a6 + // says to treat it as absent (RTO5a5). + processObjectSync( + "malformedserialnocolon", + listOf( + buildObjectState("counter:new@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(99)), + ), + ) + + // Treated as absent (RTO5a5): the message was applied and the sync ended (SYNCED). + assertEquals(ObjectsState.Synced, ro.state) + assertNotNull(ro.objectsPool.get("counter:new@1000")) + } + + /** + * @UTS objects/unit/RTO5a5/absent-channel-serial-0 + * + * An OBJECT_SYNC with no channelSerial is a valid single-message sync: its data is applied and the + * sync ends (SYNCED). This is the baseline the RTO5a6 malformed case defers to. + */ + @Test + fun `RTO5a5 - absent channelSerial applies data and ends sync`() { + processAttached(hasObjects = true) + + processObjectSyncNoSerial( + listOf( + buildObjectState("counter:new@1000", mapOf("aaa" to POOL_SERIAL), counter = counterState(99)), + ), + ) + + assertEquals(ObjectsState.Synced, ro.state) + 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 57% 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..e641eb5e6 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,25 @@ 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 } + // Await the seed's observable effect before subscribing — the SDK applies inbound messages + // asynchronously, so without this the MAP_SET dispatch races the subscribes below and the + // alias listener can also see the seed event (2 instead of 1). Same pattern as the depth + // tests in this file. + pollUntil(5.seconds) { root.get("alias").exists() } - 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 +656,8 @@ class PathObjectSubscribeTest { assertEquals("score", eventsScore[0].getObject().path()) assertEquals(1, eventsAlias.size) assertEquals("alias", eventsAlias[0].getObject().path()) + + client.close() } /** @@ -595,24 +665,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..ca6e721e9 --- /dev/null +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/RealtimeObjectTest.kt @@ -0,0 +1,1365 @@ +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/RTO20d4/empty-synthetic-list-skips-sync-wait-0 + */ + @Test + fun `RTO20d4 - empty synthetic list skips the RTO20e sync wait`() = runTest { + val mockWs = buildMockWebSocket( + connected = connectedMessage(), + onAttach = attachedWithSync(), + onObject = { mock, msg -> + // build_ack_message(msg.msgSerial, [null]) - the single serial is null, so per + // RTO20d1 every synthetic ObjectMessage is skipped and the synthetic list is empty. + 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() + + // Move the objects sync state back to SYNCING so a normal publishAndApply would park in the + // RTO20e wait for SYNCED (cf. the RTO20e waits-for-synced case). No sync-completing message + // is ever sent: if the RTO20e wait were performed this future would never resolve. + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + // The synthetic list is empty, so there is nothing to apply locally and publishAndApply + // completes without the RTO20e wait. + root.get("score").asLiveCounter().increment(10).await() + + // Resolution despite the channel never reaching SYNCED proves the RTO20e wait was skipped; + // nothing was applied locally, so the local value is unchanged. + 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. + + // process_pending_events(): flush the sequential scope so increment()'s publishAndApply + // has parked in the RTO20e sync wait before the injected DETACHED. + (channel.`object` as DefaultRealtimeObject).asyncFuture { }.await() + 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. + + // process_pending_events(): flush the sequential scope so increment()'s publishAndApply + // has parked in the RTO20e sync wait before the injected ERROR->FAILED. + (channel.`object` as DefaultRealtimeObject).asyncFuture { }.await() + 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/RTO23c1/fails-on-channel-detached-0 + * + * RTO23c1 mirrors RTO20e1 (above): a get() parked in the RTO23c wait for SYNCED must be failed — + * not orphaned — with the 92008 error when the channel enters DETACHED while waiting. + */ + @Test + fun `RTO23c1 - get fails when channel enters DETACHED during sync wait`() = runTest { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + + // Force a re-sync so the objects state is SYNCING again, then get() must park in the RTO23c wait. + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + val getFuture = channel.`object`.get() + + // process_pending_events(): flush the sequential scope so get() has parked its sync waiter + // before the injected DETACHED. + (channel.`object` as DefaultRealtimeObject).asyncFuture { }.await() + + // While still SYNCING the get() cannot complete — it is parked waiting for SYNCED. + assertFalse(getFuture.isDone) + + // A client-side detach then moves the channel to DETACHED. + detachClientSide(channel) + + val error = assertFailsWith { getFuture.await() } + + assertEquals(92008, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO23c1/fails-on-channel-suspended-0 + * + * RTO23c1 — the SUSPENDED case, driven directly through the objects channel-state handler + * (as RTO27 does) since the shared mock has no SUSPENDED trigger. + */ + @Test + fun `RTO23c1 - get fails when channel enters SUSPENDED during sync wait`() = runTest { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + val ro = channel.`object` as DefaultRealtimeObject + + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + val getFuture = channel.`object`.get() + + // process_pending_events(): flush the sequential scope so get() has parked its sync waiter + // before the injected SUSPENDED. + ro.asyncFuture { }.await() + assertFalse(getFuture.isDone) + + // RTO27b SUSPENDED retains objects data but RTO23c1 still fails any in-flight get() sync wait. + ro.handleStateChange(ChannelState.suspended, false) + ro.asyncFuture { }.await() // flush the sequential scope + + val error = assertFailsWith { getFuture.await() } + + assertEquals(92008, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + + client.close() + } + + /** + * @UTS objects/unit/RTO23c1/fails-on-channel-failed-0 + * + * RTO23c1 — the FAILED case additionally asserts the `cause`: it must be set to the channel's + * errorReason (here the injected FAILED error) when that reason is present. + */ + @Test + fun `RTO23c1 - get fails with cause when channel enters FAILED during sync wait`() = runTest { + val (client, channel, _, mockWs) = setupSyncedChannel("test") + + sendAttachedAndAwaitSyncing(channel, mockWs, "sync2:cursor") + + val getFuture = channel.`object`.get() + + // process_pending_events(): flush the sequential scope so get() has parked its sync waiter + // before the injected ERROR->FAILED. + (channel.`object` as DefaultRealtimeObject).asyncFuture { }.await() + assertFalse(getFuture.isDone) + + // A channel ERROR moves the channel to FAILED and sets its errorReason. + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + this.channel = "test" + this.error = ErrorInfo("Channel failed", 400, 90000) + }, + ) + + val error = assertFailsWith { getFuture.await() } + + assertEquals(92008, error.errorInfo.code) + assertEquals(400, error.errorInfo.statusCode) + // RTO23c1 - cause is set to the channel's errorReason. + val cause = assertIs(error.cause) + assertEquals(90000, cause.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 462fd87a0..078e73045 100644 --- a/uts/README.md +++ b/uts/README.md @@ -2,8 +2,9 @@ > A practical, end-to-end explanation of the **Universal Test Specification (UTS)** and how it is > realised in the `ably-java` repository. Written for a developer who has never touched UTS before -> and needs to understand *what it is*, *why it exists*, and *exactly how the Java/Kotlin code under -> `uts/` makes the unit, direct-sandbox, and proxy-integration tests work*. +> and needs to understand *what it is*, *why it exists*, and *exactly how the shared Kotlin +> infrastructure in the `:uts` module — plus the reference smoke tests that exercise it — makes the +> unit, direct-sandbox, and proxy-integration tiers work*. --- @@ -17,9 +18,9 @@ 6. [Unit-Test Infrastructure (mocked transports)](#6-unit-test-infrastructure-mocked-transports) 7. [Proxy-Integration Infrastructure (real backend + fault injection)](#7-proxy-integration-infrastructure-real-backend--fault-injection) 8. [Shared Async Helpers](#8-shared-async-helpers) -9. [Walkthrough: the Unit Test (`ConnectionRecoveryTest`)](#9-walkthrough-the-unit-test-connectionrecoverytest) -10. [Walkthrough: the Direct-Sandbox Integration Test (`ChannelHistoryTest`)](#10-walkthrough-the-direct-sandbox-integration-test-channelhistorytest) -11. [Walkthrough: the Proxy Test (`AuthReauthTest`)](#11-walkthrough-the-proxy-test-authreauthtest) +9. [Walkthrough: the Unit Smoke Test (`UnitInfraSmokeTest`)](#9-walkthrough-the-unit-smoke-test-unitinfrasmoketest) +10. [Walkthrough: the Direct-Sandbox Smoke Test (`IntegrationInfraSmokeTest`)](#10-walkthrough-the-direct-sandbox-smoke-test-integrationinfrasmoketest) +11. [Walkthrough: the Proxy Smoke Test (`ProxyInfraSmokeTest`)](#11-walkthrough-the-proxy-smoke-test-proxyinfrasmoketest) 12. [Deviations: when the SDK disagrees with the spec](#12-deviations-when-the-sdk-disagrees-with-the-spec) 13. [How to Run the Tests](#13-how-to-run-the-tests) 14. [Quick Reference / Cheat-Sheet](#14-quick-reference--cheat-sheet) @@ -54,8 +55,8 @@ UTS fixes this by separating **what to test** from **how to test it in a given l ▼ ┌──────────────────────────────┐ │ Derived tests │ ← concrete, runnable tests in the SDK's language - │ (this repo: Kotlin in uts/) │ e.g. ConnectionRecoveryTest.kt - └──────────────────────────────┘ + │ (Kotlin, in ably-java) │ spec suites: :java / :liveobjects; + └──────────────────────────────┘ shared infra + smoke examples: :uts ``` Three concepts you will see constantly: @@ -64,8 +65,8 @@ Three concepts you will see constantly: |------|---------| | **Spec point** | A tagged requirement in the features spec, e.g. `RTN16g`, `RTN22`, `RTL4f`. Test names embed these. | | **UTS spec** | A markdown file of portable pseudocode describing the setup, steps, and assertions for one feature. The *source of truth for what to test.* | -| **Derived test** | A faithful translation of a UTS spec into a real test in a specific SDK/language. This is what lives in `ably-java/uts/`. | -| **Deviation** | A documented case where the SDK's actual behaviour diverges from the spec. Recorded in `deviations.md`. | +| **Derived test** | A faithful translation of a UTS spec into a real test in a specific SDK/language. These live in the owning module's test source set (`:java` for realtime/rest, `:liveobjects` for objects); the shared infra they use lives in `:uts`. | +| **Deviation** | A documented case where the SDK's actual behaviour diverges from the spec. Recorded in the owning module's `deviations.md` (`:java` and `:liveobjects` — see §12). | The golden rule (from [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md)): **translate the UTS spec faithfully** — same structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a @@ -81,14 +82,16 @@ three example tests this guide walks through span all three tiers. | Tier | Transport | Backend | Purpose | Example in this repo | |------|-----------|---------|---------|----------------------| -| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/realtime/ConnectionRecoveryTest.kt` | -| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard/realtime/ChannelHistoryTest.kt` | -| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/realtime/AuthReauthTest.kt` | +| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/UnitInfraSmokeTest.kt` | +| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard/IntegrationInfraSmokeTest.kt` | +| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/ProxyInfraSmokeTest.kt` | -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/`. +The three examples above are `:uts`'s own **tier smoke tests** (§9–§11) — the reference shapes this +guide walks through. The real, spec-derived suites live **by module**, in the module that owns the +code under test: realtime/rest under `:java` (`lib/src/test/kotlin/io/ably/lib/uts/…`), objects under +`:liveobjects` (`liveobjects/.../uts/…`). `:uts`'s own test tree holds only the tier smoke examples; +so a feature's tests always sit with the SDK code they exercise (see §4.2 "Where every module's UTS +tests live"). Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)): @@ -103,7 +106,7 @@ Key principles (from [`integration-testing.md`](https://github.com/ably/specific understands **text** WebSocket frames and so can't inspect or modify binary msgpack. The tests therefore force JSON regardless of SDK support ([`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) §Protocol Variants, - [`helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md)). + [`docs/proxy.md`](https://github.com/ably/specification/blob/main/uts/docs/proxy.md)). --- @@ -156,15 +159,18 @@ segregation exists because proxy tests have different infra needs, CI cadence, a A big table mapping every features-spec group (`RSC`, `RTN`, `RTL`, `RTP`, …) to the UTS specs that cover it, with a per-tier summary (`unit:✓ proxy:✓`). This is the tracker for "what's done and what's missing". The reference tests this guide walks through correspond to these rows: -- `RTN16` (connection recovery) → unit spec `connection_recovery_test.md` → **`ConnectionRecoveryTest.kt`**. +- `RTN16` (connection recovery) → unit spec `connection_recovery_test.md` → + **`ConnectionRecoveryTest.kt`** (`:java`, `lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/`). - `RTL10d` (channel history) → direct-sandbox spec - `realtime/integration/channel_history_test.md` → **`ChannelHistoryTest.kt`**. + `realtime/integration/channel_history_test.md` → **`ChannelHistoryTest.kt`** + (`:java`, `.../integration/standard/realtime/`). - `RTN22` / `RTC8a` (server-initiated re-auth) → proxy spec - `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`**. + `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`** + (`:java`, `.../integration/proxy/realtime/`). > There is also a fifth, *referenced* spec: -> [`realtime/integration/helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md) -> (in the spec repo under `uts/realtime/integration/helpers/`). It defines the proxy's control API, rule format, +> [`docs/proxy.md`](https://github.com/ably/specification/blob/main/uts/docs/proxy.md) +> (in the spec repo under `uts/docs/`). It defines the proxy's control API, rule format, > action types, and the **protocol message action-number table** (CONNECTED=4, ATTACH=10, AUTH=17, > …). The Kotlin `ProxySession` is the client for exactly that API. @@ -172,106 +178,163 @@ 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/`. +The `uts/` directory is a **standalone Gradle module** (`include("uts")` in `settings.gradle.kts`) +that is the repo's **shared UTS test-support library** plus a small set of reference examples. Its +**main** source set *is* the shared test infrastructure — `uts/src/main/kotlin/io/ably/lib/uts/infra/` +— so any other Gradle module consumes it with a plain `testImplementation(project(":uts"))` (this +module's own tests see it automatically). Its **test** source set holds only the three tier **smoke +tests** (§9–§11): the permanent acceptance gate for the infra and the worked examples this guide +teaches from. + +Two things this means — and a correction to how the module used to be described. First, the infra is +**this module's main/production code**, not a `testFixtures` variant and not test-only: `:uts`'s main +artifact *is* the infra. Second, `:uts` does **not** host the spec-derived UTS suites — those live in +their owning modules (`:java` for realtime/rest, `:liveobjects` for objects; see §4.2). `:uts` keeps +only the infra and the tier smoke examples. ### 4.1 `uts/build.gradle.kts` ```kotlin -plugins { alias(libs.plugins.kotlin.jvm) } +plugins { + `java-library` // provides the `api` configuration (kotlin.jvm alone + // applies only the plain `java` plugin) + alias(libs.plugins.kotlin.jvm) // Kotlin/JVM — `java-test-fixtures` is REMOVED +} + +java { + // Declare Java-8 outgoing variants so :java's 8-requesting configurations can consume + // project(":uts"). + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} dependencies { - testImplementation(project(":java")) // the SDK under test - testImplementation(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces - 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.cio) + // The shared UTS infra (src/main/kotlin/io/ably/lib/uts/infra/**) — this module's main artifact, + // consumed elsewhere via testImplementation(project(":uts")). `api` for types that appear in + // infra signatures; `implementation` for internals. Invariant I1: :uts never depends on + // :liveobjects. + api(project(":java")) // the SDK + its types (DebugOptions, ProtocolMessage, …) + api(project(":network-client-core")) // HttpEngine / WebSocketEngine SPIs the mocks implement + implementation(libs.ktor.client.core) // proxy infra uses ktor internally — must NOT leak to consumers + implementation(libs.ktor.client.cio) + + // The UTS test toolkit — exported (api) so any module consuming the infra via + // testImplementation(project(":uts")) transitively gets JUnit 5, the kotlin.test Jupiter binding, + // and coroutines (runTest etc.). :uts's own smoke tests inherit it from main's api — nothing to declare. + api(platform(libs.junit.bom)) + api(libs.junit.jupiter) + api(libs.junit.jupiter.params) // @ParameterizedTest / @ValueSource + api(kotlin("test-junit5")) + api(libs.coroutine.core) + api(libs.coroutine.test) // runTest, virtual time } tasks.withType().configureEach { - useJUnitPlatform() // JUnit 5 + useJUnitPlatform() // JUnit 5 jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") - // Propagate a local proxy build override (see ProxyManager): - systemProperty("uts.proxy.localPath", /* -Duts.proxy.localPath=… or $UTS_PROXY_LOCAL_PATH */ …) + // Propagate a local proxy-build override (see ProxyManager): -Duts.proxy.localPath=… or + // $UTS_PROXY_LOCAL_PATH. + systemProperty("uts.proxy.localPath", …) } + +tasks.register("runUtsUnitTests") { filter { includeTestsMatching("io.ably.lib.uts.unit.*") } } +tasks.register("runUtsIntegrationTests") { filter { includeTestsMatching("io.ably.lib.uts.integration.*") } } + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } } ``` Takeaways: -- Tests are **Kotlin + JUnit 5**, using **kotlinx.coroutines** for async control and **Ktor** as the - HTTP client that talks to the sandbox REST API and the proxy control API. -- `junit-jupiter-params` adds **`@ParameterizedTest`** — used by integration specs to run their - `json` / `msgpack` protocol variants as a single test parameterised on `useBinaryProtocol` (see §10.3). - Its version is managed by the JUnit 5 BOM that `kotlin("test")` brings onto the test classpath, so no - explicit version is pinned. -- It depends on `:java` (the SDK) and `:network-client-core` (the pluggable transport interfaces the - mocks implement). -- The `--add-opens java.base/java.time` and `java.base/java.lang` flags grant reflective access into - those JDK packages for the test runtime. They mirror the same flags set in `java/build.gradle.kts` - for the SDK's own test module (which additionally opens `java.net` and `java.lang.reflect`). +- `:uts` is a `java-library` + `kotlin.jvm` module. `java-test-fixtures` is **gone** — the infra is + plain `src/main`, so consumers use `testImplementation(project(":uts"))` (no `testFixtures(...)` + wrapper). `java-library` is what now supplies the `api` configuration. +- The module compiles to **Java 8** bytecode (source/target + `jvmTarget = JVM_1_8`), so `:java` + (which requests Java-8 variants) can consume it. **mockk is not a dependency** — the infra imports + no test library at all. +- It depends on `:java` (the SDK under test) and `:network-client-core` (the pluggable transport SPIs + the mocks implement), both via `api` because they appear in infra signatures. +- Tests are **Kotlin + JUnit 5**, using **kotlinx.coroutines** for async control and **Ktor** for the + sandbox REST API and proxy control API. `junit-jupiter-params` (version from the JUnit BOM) adds + **`@ParameterizedTest`** for the protocol-variant integration tests (§10.3). +- `runUtsUnitTests` / `runUtsIntegrationTests` are package-filtered `Test` tasks (§13). The + `--add-opens java.base/java.time` and `java.base/java.lang` flags grant the test runtime reflective + access into those JDK packages, mirroring `java/build.gradle.kts`. - A system property carries an optional path to a **locally built** proxy binary (so you can test against an unreleased proxy). ### 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 -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: +Everything lives under the `io.ably.lib.uts` package, split cleanly between the **main** source set — +the shared **infrastructure** (`infra/`, no `@Test`s) — and the **test** source set — the three tier +**smoke tests**. The infra is organised by tier: `infra/unit/` for mocked transports, and +`infra/integration/` for real-backend helpers — the latter with an `infra/integration/proxy/` +sub-package for the fault-injecting proxy — plus one shared `infra/Utils.kt` serving every tier: ```text -uts/src/test/kotlin/io/ably/lib/uts/ -├── 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) -│ -└── integration/ # ── INTEGRATION TESTS (real backend) ── · per module - ├── standard/ # direct sandbox: happy-path, no fault injection - │ ├── realtime/ - │ │ └── ChannelHistoryTest.kt # ← the DIRECT-SANDBOX test (RTL10d) - │ └── liveobjects/ - └── proxy/ # sandbox through the fault-injecting uts-proxy - ├── realtime/ - │ └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) - └── liveobjects/ +uts/src/main/kotlin/io/ably/lib/uts/ # ── shared infra, consumed via testImplementation(project(":uts")) ── +└── 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 tier SMOKE TESTS (infra acceptance + worked examples) ── +├── unit/ +│ └── UnitInfraSmokeTest.kt # ← UNIT smoke: mock WS + HTTP + FakeClock (§9) +└── integration/ + ├── standard/ + │ └── IntegrationInfraSmokeTest.kt # ← DIRECT-SANDBOX smoke: SandboxApp (§10) + └── proxy/ + └── ProxyInfraSmokeTest.kt # ← PROXY smoke: ProxyManager + ProxySession (§11) ``` -The mental model: **`infra/unit/` powers the unit tests, `infra/integration/` powers both integration -kinds (`standard` + `proxy`), and `infra/Utils.kt` serves all of them.** Every tier is sub-divided **by -module** (`realtime`, `liveobjects`, …) so a feature's tests sit together regardless of SDK area. The -top-level `unit/` ↔ `infra/unit/` and `integration/` ↔ `infra/integration/` pairing is what the -`runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks key off (§13) — `runUtsIntegrationTests` -covers **both** `integration/standard/` and `integration/proxy/`. +The mental model: **`infra/unit/` powers the unit tier, `infra/integration/` powers both integration +kinds (`standard` + `proxy`), and `infra/Utils.kt` serves all of them.** The top-level `unit/` ↔ +`infra/unit/` and `integration/` ↔ `infra/integration/` pairing is what the `runUtsUnitTests` / +`runUtsIntegrationTests` Gradle tasks key off (§13) — `runUtsIntegrationTests` covers **both** +`integration/standard/` and `integration/proxy/`. + +#### Where every module's UTS tests live + +The infra is shared, but the actual UTS suites live **in the module that owns the code under test**; +`:uts` itself holds only the infra and one smoke example per tier: + +| Module | UTS tests | Location | Run with | +|---|---|---|---| +| `:java` | realtime (and future rest) — unit, integration, proxy | `lib/src/test/kotlin/io/ably/lib/uts/{unit, integration/standard, integration/proxy}/realtime/` | `:java:runUtsUnitTests` / `:java:runUtsIntegrationTests` | +| `:liveobjects` | objects — unit, integration, proxy | `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{unit, integration, proxy}/` | `:liveobjects:runLiveObjectsUnitTests` / `:liveobjects:runLiveObjectsIntegrationTests` | +| `:uts` | **none** — shared infra + one smoke test per tier | `uts/src/main/.../infra/**` + `uts/src/test/.../{unit, integration/standard, integration/proxy}/` | `:uts:runUtsUnitTests` / `:uts:runUtsIntegrationTests` | + +Every consuming module gets the infra via `testImplementation(project(":uts"))`. The objects suites +additionally reach `:liveobjects`-internal CRDT state (`InternalLiveMap`/`InternalLiveCounter`/ +`ObjectsPool`), which is why they live in `:liveobjects`'s own test source set — including the objects +**unit** tier, whose internal-graph specs cannot be expressed from outside that module. See +`.claude/skills/uts-to-kotlin/uts-package-mapping.json` and `MOVE_COMMON_INFRA/`. + +> **Future work — publishing `:uts` for out-of-repo consumers.** Promoting the infra into `:uts`'s +> own main source set (rather than a separate `:test-support` module) leaves it *publishable later +> without restructuring* — but publishing is a deliberate commitment, not a default. The trigger is a +> consumer outside this repo, in practice the **Chat SDK**: if Chat lands in-repo it just consumes +> `testImplementation(project(":uts"))` like every other module and nothing changes; if Chat is a +> separate repo, decide *deliberately* between publishing `:uts` as a versioned artifact, source-copying +> the infra, or a shared git submodule. Publishing turns test infra into a maintained artifact with a +> release cadence and compatibility expectations, so that choice (and the release-train / API-hygiene +> checklist it implies) stays gated and unscheduled until the trigger fires. --- @@ -409,9 +472,16 @@ responses back — all without a socket. ### 6.4 `FakeClock` — deterministic time `FakeClock` implements the SDK's `Clock`. Time is frozen until you call `advance(ms)`; on each -advance it fires any due virtual timers **synchronously**, and wakes any `waitOn` sleepers. This is +advance it fires any due virtual timers **synchronously**, and wakes any `waitOn` sleepers. `advance` +runs due work **to quiescence** — it re-scans until a full pass fires nothing, so cascades due within +the advanced interval (a zero-delay reschedule, or a timer created by fired work) also run in that same +`advance` (the spec run-to-quiescence Guarantee; see §9.3). This is how the unit test drives reconnection backoff and `connectionStateTtl` expiry **without real -sleeping**: +sleeping**. Caveat: `waitOn(target, timeout)` still performs a real `target.wait(timeout)`, so a +sleeper also wakes once `timeout` ms of wall-clock elapse — `advance()` makes it wake *sooner*, but is +not a hard gate (the **advisory** model of the spec's `mock_websocket.md` §Fake-time semantics). +Drive transitions by owning the resulting attempt (`awaitConnectionAttempt()`), never +by asserting a wait has *not* yet returned. ```kotlin val fakeClock = FakeClock() val client = TestRealtimeClient { enableFakeTimers(fakeClock); … } @@ -529,92 +599,154 @@ technique used by `liveobjects/.../TestUtils.kt`). See Appendix B.1. --- -## 9. Walkthrough: the Unit Test (`ConnectionRecoveryTest`) - -**File:** `uts/.../uts/unit/realtime/ConnectionRecoveryTest.kt` (package `io.ably.lib.uts.unit.realtime`) -**Tier:** Unit (mocked WebSocket, no network). -**Spec area:** RTN16 — connection recovery via the `recover` option and `createRecoveryKey()`. - -It contains six tests; each carries an `@UTS realtime/unit/RTN16…/…` tag. Here's what each proves and -the technique it uses: - -### 9.1 `RTN16g, RTN16g1` — recovery-key structure (incl. Unicode) -Connects (mock returns CONNECTED with a known key), attaches two channels — one ASCII, one Unicode -(`channel-éàü-世界`) — feeding each an `ATTACHED` with a `channelSerial` via `sendToClient`. Then calls -`connection.createRecoveryKey()`, decodes it with `RecoveryKeyContext.decode`, and asserts the -connection key, `msgSerial == 0`, and both channel serials survive — including a full -**encode→decode round-trip** to prove the Unicode name isn't corrupted (RTN16g1). -*Technique: callback-style `onConnectionAttempt`, `sendToClient` for ATTACHED, `awaitChannelState`.* - -### 9.2 `RTN16g2` — `createRecoveryKey()` returns null in inactive states -The most elaborate test — it walks the connection through **five** states and asserts the key is null -in each inactive one: -- **INITIALIZED** (before connect) → null. -- **CONNECTED** → non-null (sanity). -- **CLOSING / CLOSED** → null (close nulls the key immediately). -- **FAILED** → null. *(Contains a documented **deviation** — see §12: the spec's fatal error - code 50000/500 isn't treated as fatal by the SDK, and `send_to_client_and_close` races the FAILED - transition; the test uses code 40000/400 and plain `sendToClient`.)* -- **SUSPENDED** → null. Built with a `FakeClock`: connect succeeds, then `simulateDisconnect()`, - then a coroutine **refuses every reconnection attempt** while `fakeClock.advance(2.seconds)` loops - until the short `connectionStateTtl` (800 ms) expires and the client gives up to SUSPENDED. -*Technique: this is the textbook example of **await-style** mocking — the first connection succeeds -via `awaitConnectionAttempt()`, but reconnections need the *refused* response, so a separate -`refuseJob` coroutine drives them; mixing this with fake timers gives deterministic SUSPENDED.* - -### 9.3 `RTN16k` — `recover` adds the `recover` query param -Constructs the client with `recover = `, captures `conn.queryParams` on each connection -attempt, then `simulateDisconnect()` and reconnect. Asserts the **first** attempt carries -`recover=` (and no `resume`), while the **second** (post-reconnect) carries `resume=` -(and no `recover`) — i.e. recover is a one-shot bootstrap, subsequent reconnections use resume. - -### 9.4 `RTN16f` — `recover` initialises `msgSerial` *(env-gated deviation)* -Asserts the recovered `msgSerial` (42) is preserved. The SDK resets it to 0, so the spec-correct -assertion `assertEquals(42L, …)` runs only under `RUN_DEVIATIONS`; otherwise a regression-guard -`assertEquals(0L, …)` runs. (See §12.) - -### 9.5 `RTN16f1` — malformed `recover` key degrades gracefully -`recover = "this-is-not-valid-json!!!"`. Asserts the client still connects normally with a fresh -identity, **no** `recover`/`resume` query params, and exactly one connection attempt — i.e. a bad key -is logged and ignored, not fatal. - -### 9.6 `RTN16j` — `recover` instantiates channels with their serials (RTN16i too) -Recovery key carries three channels (incl. Unicode). Asserts each `channels.get(name).properties. -channelSerial` matches the key, that the channels are **NOT auto-attached** (state INITIALIZED — -RTN16i), and that a manual `attach()` sends an ATTACH frame carrying the recovered serial (verified -via `awaitNextMessageFromClient()`). - -**What this test teaches about the infra:** callback vs await styles side by side, `FakeClock`-driven -SUSPENDED, `sendToClient` for server frames, `events`/`awaitNextMessageFromClient` for inspecting -client output, and the env-gated deviation pattern. +## 9. Walkthrough: the Unit Smoke Test (`UnitInfraSmokeTest`) + +**File:** `uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt` (package `io.ably.lib.uts.unit`) +**Tier:** Unit (mocked WebSocket + mocked HTTP, no network). +**Purpose:** the permanent acceptance test for the unit-tier infra — it drives a real SDK through +`MockWebSocket`, `MockHttpClient` and `FakeClock` end-to-end. It carries **no** `@UTS` marker (it is +not derived from a spec) and must never trip the spec-parity tooling; it is the reference shape a +future unit-tier UTS test should take. + +> The real spec-derived unit suites this pattern scales to live in `:java` +> (`lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/`, e.g. `ConnectionRecoveryTest`) and +> `:liveobjects` — see §13. + +It has **three** `@Test` methods: two end-to-end transport tests (§9.1, §9.2) that between them exercise +every teaching point of §5–§8, plus a focused `FakeClock` run-to-quiescence acceptance test (§9.3). + +### 9.1 `unit infra drives the full mock-WebSocket connection lifecycle` — await style throughout +One long **await-style** test that walks the SDK through the whole transport lifecycle: + +1. **Connect (await style).** A `launch`ed coroutine calls `awaitConnectionAttempt()`, captures the + `PendingConnection`, then answers `respondWithSuccess(CONNECTED_MESSAGE)`: + ```kotlin + val firstConnection = CompletableDeferred() + launch { + val conn = mock.awaitConnectionAttempt() + firstConnection.complete(conn) + conn.respondWithSuccess(CONNECTED_MESSAGE) + } + client.connect() + awaitState(client, ConnectionState.connected) + ``` + It then asserts the captured connection's **query params** (`format == "json"`, `key` present — + the same technique a `recover`/`resume` test uses), that `CONNECTED_MESSAGE`'s `test-connection-id` + reached `client.connection.id`, and the event ordering (`events[0] is ConnectionAttempt`, + `events[1] is ConnectionEstablished`). +2. **Server-initiated ATTACHED with a Unicode round-trip.** It attaches a channel whose name carries + Unicode (`smoke-üñîçöðé-…`), asserts the **outbound** ATTACH frame via `awaitNextMessageFromClient()`, + then feeds an ATTACHED back with `sendToClient` and checks the `channelSerial` round-tripped in: + ```kotlin + ch.attach() + val attachFrame = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.attach, attachFrame.action) + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = channelName + channelSerial = "serial-1" + }) + awaitChannelState(ch, ChannelState.attached) + ``` +3. **Publish**, asserting the full MESSAGE frame (`action`, `channel`, `messages[0].name`/`data`) again + via `awaitNextMessageFromClient()`. +4. **Disconnect.** `simulateDisconnect()`, await DISCONNECTED, and assert the drop was recorded. Note + we do **not** snapshot the `ConnectionAttempt` count here: `FakeClock.waitOn(target, timeout)` does a + real `target.wait(timeout)`, so the disconnected-retry fires on its own after ~`disconnectedRetryTimeout` + ms of wall-clock even without an `advance()`. `advance()` only wins that race sooner — it is not a + hard gate — so a "still exactly one attempt" assertion would be racy on a loaded runner. Ownership + of attempt #2 belongs to the next step, which gates on it deterministically. +5. **FakeClock-driven reconnect.** A coroutine loops `fakeClock.advance(2.seconds)` then answers the + next attempt (received via the buffered `awaitConnectionAttempt()`, so it cannot be missed) with a + short-TTL CONNECTED; the test awaits CONNECTED again and asserts a second `ConnectionAttempt`. +6. **Refuse → SUSPENDED (the centrepiece).** After another `simulateDisconnect()`, a `refuseJob` + coroutine advances the clock and `respondWithRefused()`s every reconnection attempt until the short + `connectionStateTtl` (800 ms, from the short-lived CONNECTED) expires and the client gives up to + SUSPENDED; the test asserts `connection.createRecoveryKey()` is null in SUSPENDED. + +*Why await-style throughout?* The initial connect, the FakeClock reconnect, and the refuse branch each +need a **different** answer per attempt — a single `onConnectionAttempt` callback answers every attempt +uniformly, and the two styles cannot be mixed on one mock. (This is the same reason `:java`'s +`ConnectionRecoveryTest` is await-style.) *Technique on show: await-style `awaitConnectionAttempt` / +`awaitNextMessageFromClient`, `sendToClient` for server frames, `events` for assertions, `FakeClock` +for deterministic backoff, and a Unicode channel-name round-trip.* + +### 9.2 `unit infra serves a token-auth HTTP request through the mock engine` — callback WS + HTTP mock +The second test finally gives §6.3's `MockHttpClient` a worked example, and demonstrates the +**callback style** on the WebSocket side (every attempt is answered identically, so a callback is the +right tool): +```kotlin +val mockWs = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } } +val mockHttp = MockHttpClient { onConnectionAttempt = { it.respondWithSuccess() } } +val client = TestRealtimeClient { + authUrl = "https://auth.example.test/token" + install(mockWs) + install(mockHttp) + autoConnect = false +} +``` +The auth HTTP request is then handled **await style** — a `launch`ed coroutine `awaitRequest()`s it, +asserts the outbound request shape, and feeds a canned `TokenDetails` JSON back: +```kotlin +launch { + val request = mockHttp.awaitRequest() + captured.complete(request.method to request.url.path) + request.respondWith(200, tokenJson, mapOf("Content-Type" to "application/json")) +} +client.connect() +awaitState(client, ConnectionState.connected) +``` +It asserts the request was a `GET /token` and that the SDK reached CONNECTED with the fetched token. + +> ⚠️ **Trap (documented inline in the test):** `MockEvent.HttpRequest` is declared in the `MockEvent` +> sealed class but is **never emitted** by the HTTP mock — asserting on +> `events.filterIsInstance()` would silently pass on an empty list. Assert via +> `MockHttpClient.awaitRequest()` / the `PendingRequest` instead (as this test does). + +**What these two tests teach about the infra:** callback vs await styles side by side (WS callback in +§9.2, WS await throughout §9.1), `FakeClock`-driven reconnect and SUSPENDED, `sendToClient` for server +frames, `events` / `awaitNextMessageFromClient` for inspecting client output, and the full HTTP-mock +connect→request two-phase flow. The `RUN_DEVIATIONS` env-gated deviation pattern is **not** here (the +smoke tests carry no deviations) — that teaching lives in §12. + +### 9.3 `FakeClock advance runs cascaded work to quiescence in one call` — the run-to-quiescence Guarantee +A focused, SDK-free test that pins the `FakeClock` contract §6.4 depends on: a single `advance(ms)` runs +**all** work due within the advanced interval, including cascades. It schedules a task that reschedules +itself at zero delay and a task that creates a brand-new timer mid-advance, then asserts one `advance` +fires the whole cascade (not just the first pass). This is the infra-level guarantee the reconnect/backoff +walkthroughs in §9.1 rely on; it exercises `FakeClock` directly because the Guarantee is about `advance` +alone reaching quiescence. --- -## 10. Walkthrough: the Direct-Sandbox Integration Test (`ChannelHistoryTest`) +## 10. Walkthrough: the Direct-Sandbox Smoke Test (`IntegrationInfraSmokeTest`) -**File:** `uts/.../uts/integration/standard/realtime/ChannelHistoryTest.kt` (package `io.ably.lib.uts.integration.standard.realtime`) +**File:** `uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt` (package `io.ably.lib.uts.integration.standard`) **Tier:** Direct-sandbox integration (real network, real Ably sandbox, **no** proxy, **no** fault injection). -**Spec point:** RTL10d — messages published by one realtime client are retrievable from a *separate* -client's `history()`. +**Purpose:** the permanent acceptance test for the middle-tier infra — `SandboxApp` + +`TestRealtimeClient`/`TestRestClient` wired straight to the sandbox. No `@UTS` marker. + +> The real spec-derived direct-sandbox suites this pattern scales to live in `:java` +> (`lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/`, e.g. `ChannelHistoryTest`) +> and `:liveobjects` — see §13. -This is the reference for the **middle tier**. Like a proxy test it talks to the real backend, but it -connects *straight* to `SandboxApp.sandboxHost` — there is no `ProxyManager`, no `ProxySession`, and no -`connectThroughProxy` wiring. It's the shape every happy-path interop spec -(connect/publish/subscribe/presence) follows. +It talks to the real backend but connects *straight* to `SandboxApp.sandboxHost` — no `ProxyManager`, +no `ProxySession`, no `connectThroughProxy`. It's the shape every happy-path interop spec +(connect/publish/subscribe/history) follows. ### 10.1 Suite setup/teardown -Same `@TestInstance(PER_CLASS)` + `runBlocking` pattern as the proxy test, but provisioning **`SandboxApp` -only** — no `ProxyManager.ensureProxy()`: +`@TestInstance(PER_CLASS)` + `runBlocking`, provisioning **`SandboxApp` only** — no +`ProxyManager.ensureProxy()`: ```kotlin @BeforeAll fun setUpAll() = runBlocking { app = SandboxApp.create() } @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() } ``` -### 10.2 The client — wired straight to the sandbox -A tiny `newClient` helper points the **real** transport at the sandbox host (no proxy in between). Setting +### 10.2 The clients — wired straight to the sandbox +Two tiny helpers point the **real** transports at the sandbox host (no proxy in between). Setting explicit hosts auto-disables fallback hosts (REC2c2), so there's nothing else to configure: ```kotlin -private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { +private fun newRealtimeClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { key = app.defaultKey realtimeHost = SandboxApp.sandboxHost // sandbox.realtime.ably-nonprod.net restHost = SandboxApp.sandboxHost @@ -622,8 +754,8 @@ private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeCl autoConnect = false } ``` -(`TestRealtimeClient` is the same builder the unit tests use — it just isn't fed any mocks here, so it -drives the SDK's real network transport instead of a `MockWebSocket`.) +(`TestRealtimeClient`/`TestRestClient` are the same builders the unit tests use — here fed no mocks, so +they drive the SDK's real network transport instead of a `MockWebSocket`.) ### 10.3 Protocol variants — the `@ParameterizedTest` pattern The spec declares a `PROTOCOL` dimension (`json` / `msgpack`) and says *each test runs once per variant*. @@ -632,7 +764,7 @@ module depends on `junit-jupiter-params` (§4.1): ```kotlin @ParameterizedTest(name = "useBinaryProtocol={0}") @ValueSource(booleans = [false, true]) // false = JSON, true = msgpack -fun `RTL10d - history contains messages published by another client`(useBinaryProtocol: Boolean) = runTest { +fun `sandbox infra works end to end`(useBinaryProtocol: Boolean) = runTest { … } ``` @@ -640,17 +772,26 @@ A plain `@Test` test (no protocol dimension) stays a `@Test` — reach for `@Par spec actually declares variants. ### 10.4 The scenario — real publish, real history -Two independent clients on the same app: the publisher's *confirmed* messages must appear in the -subscriber's history. The integration-specific techniques on show: +One realtime client and one REST client on the same app: the publisher's *confirmed* messages must +appear in the REST `history()`. The integration-specific techniques on show: +- **A recorded state sequence, not just the final state.** `client.connection.on { states.add(it.current) }` + is registered *before* connect, then the test asserts `states.contains(connecting)` and + `states.last() == connected`. - **Awaiting a publish ack.** Realtime publish is fire-and-forget, so to honour the spec's `AWAIT publish` - the test wraps the (non-deprecated) `publish(name, data, Callback)` overload in a - `suspendCancellableCoroutine` (`awaitPublish`), resuming on `onSuccess` and failing on `onError`. This is - the integration analogue of the unit test's `awaitNextMessageFromClient()`. -- **`AWAIT attach()`** → `attach()` then `awaitChannelState(channel, ChannelState.attached)`. + an `awaitPublish` extension wraps the `publish(name, data, Callback)` overload in a + `suspendCancellableCoroutine`, resuming on `onSuccess` and failing on `onError`. The test publishes + **three** messages, each ack-awaited. This is the integration analogue of the unit test's + `awaitNextMessageFromClient()`. +- **`AWAIT attach()`** → `attach()` then `awaitChannelState(channel, ChannelState.attached, 15.seconds)`. - **Polling real REST state.** `history()` is a blocking REST call against the sandbox and the message - store is eventually-consistent, so the test - `pollUntil(10.seconds, 500.milliseconds) { subChannel.history(null).items().size == 3 }` — never a fixed - sleep (the same anti-flake rule as the other tiers). + store is eventually-consistent, so — never a fixed sleep (the same anti-flake rule as the other tiers): + ```kotlin + pollUntil(15.seconds, 500.milliseconds) { + val result = rest.channels.get(channelName).history(null) + history = result + result.items().size == 3 + } + ``` - **Order assertion.** History defaults to newest-first, so `items[0]` is `event3` … `items[2]` is `event1`. **What this test teaches about the infra:** `SandboxApp`-only provisioning, the direct-sandbox client @@ -660,18 +801,22 @@ REST `history()` call. --- -## 11. Walkthrough: the Proxy Test (`AuthReauthTest`) +## 11. Walkthrough: the Proxy Smoke Test (`ProxyInfraSmokeTest`) -**File:** `uts/.../uts/integration/proxy/realtime/AuthReauthTest.kt` (package `io.ably.lib.uts.integration.proxy.realtime`) +**File:** `uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt` (package `io.ably.lib.uts.integration.proxy`) **Tier:** Proxy integration (real sandbox + uts-proxy). -**Spec points:** RTN22 (server-initiated re-authentication) and RTC8a (the client sends an AUTH -frame with renewed auth details). Unit-test counterparts: `server_initiated_reauth_test.md`, -`realtime_authorize.md`. +**Purpose:** the permanent acceptance test for the proxy infra — `ProxyManager` + `ProxySession` + +`SandboxApp` + client wiring through the proxy, exercising **both** fault-injection styles. No `@UTS` +marker. + +> The real spec-derived proxy suites this pattern scales to live in `:java` +> (`lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/`, e.g. `AuthReauthTest`) and +> `:liveobjects` — see §13. ### 11.1 Suite setup/teardown ```kotlin @TestInstance(TestInstance.Lifecycle.PER_CLASS) // one instance, so @BeforeAll can be non-static -class AuthReauthTest { +class ProxyInfraSmokeTest { @BeforeAll fun setUpAll() = runBlocking { ProxyManager.ensureProxy() // download+launch proxy if needed app = SandboxApp.create() // provision a real sandbox app @@ -679,81 +824,106 @@ class AuthReauthTest { @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() } } ``` +It has **two** `@Test` methods, one per fault-injection style. -### 11.2 The test, step by step -1. **Create a session with no rules** — the fault will be injected *imperatively* later (late - injection — the connect handshake runs against the real server unmodified): - ```kotlin - val session = ProxySession.create(rules = emptyList()) - ``` -2. **Auth via `authCallback`** — the spec generates a JWT from the sandbox key; the idiomatic - ably-java equivalent is a locally-signed `TokenRequest` from the same key (no external JWT - library). A counter records how many times the callback is invoked: - ```kotlin - val tokenSigner = AblyRest(app.defaultKey) - val authCallback = Auth.TokenCallback { params -> - authCallbackCount.incrementAndGet() - tokenSigner.auth.createTokenRequest(params, null) - } - ``` -3. **Build the client through the proxy** and connect (JSON stays on so the proxy can inspect - frames): - ```kotlin - val client = TestRealtimeClient { - this.authCallback = authCallback - connectThroughProxy(session) - autoConnect = false - } - client.connect() - awaitState(client, ConnectionState.connected, 15.seconds) - ``` -4. **Snapshot identity** — `connection.id` and the callback count, and assert the callback already - ran ≥ 1 (initial auth). -5. **Start recording state changes**, then **inject a server-initiated AUTH** (protocol action 17) - imperatively — simulating Ably asking the client to re-authenticate: - ```kotlin - session.triggerAction(mapOf("type" to "inject_to_client", - "message" to mapOf("action" to 17))) - ``` -6. **Wait for the re-auth round-trip** with `pollUntil { stateChanges.size > 1 }` (real network, so - poll — don't sleep). -7. **Assertions** prove RTN22 + RTC8a: - - `authCallback` was invoked **again** (count incremented) → re-auth was triggered. - - Connection is still **CONNECTED** and `connection.id` is **unchanged** → re-auth does not - reconnect. - - **No** transitions away from CONNECTED were recorded. - - The **proxy event log** contains a client→server **AUTH frame (action 17) carrying non-null - `auth` details** (RTC8a) — verified by filtering `session.getLog()`. -8. **Nested teardown** in `finally`: close the client and wait for CLOSED, then always close the - session and the token signer. - -**What this test teaches about the infra:** `ProxyManager.ensureProxy` + `SandboxApp` setup, -`connectThroughProxy`, **late imperative fault injection** via `triggerAction`, real-network waiting -with `pollUntil`, and **proxy-log assertions** as the primary verification (`getLog()` → -filter by `type`/`direction`/`message.action`). +### 11.2 Late imperative injection — `triggerAction` +The first test creates a **rule-less pass-through** session, authenticates through the proxy (basic key +auth is TLS-only, so a token is signed locally by an `AblyRest(app.defaultKey)` in the `authCallback`), +and connects: +```kotlin +val session = ProxySession.create(rules = emptyList()) +``` +Once CONNECTED, it proves the **typed proxy log** — the handshake recorded a `ws_connect` and a +server→client CONNECTED frame (protocol action 4): +```kotlin +val log = session.getLog() +assertTrue(log.any { it.type == "ws_connect" }) +assertTrue( + log.any { + it.type == "ws_frame" && + it.direction == "server_to_client" && + it.message?.get("action")?.asInt == 4 + }, +) +``` +Only *after* the real handshake does it inject the fault — the **late imperative** way, firing an +action on the live connection right now, then observing DISCONNECTED and recovery: +```kotlin +session.triggerAction(mapOf("type" to "disconnect")) +pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } +awaitState(client, ConnectionState.connected, 20.seconds) +``` + +### 11.3 Declarative-rule injection — `wsFrameToClientRule` +The second test uses the *other* style — a **declarative rule** supplied at session creation that +rewrites the first ATTACHED frame (protocol action 11) into a disconnect, one-shot (`times = 1`): +```kotlin +val session = ProxySession.create( + rules = listOf( + wsFrameToClientRule(action = mapOf("type" to "disconnect"), messageAction = 11, times = 1), + ), +) +``` +It connects, attaches a channel, the rule fires on the ATTACHED so the client observes a DISCONNECTED +transition, then recovers (reconnects and the channel re-attaches once the one-shot rule is spent): +```kotlin +channel.attach() +pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } +awaitState(client, ConnectionState.connected, 20.seconds) +awaitChannelState(channel, ChannelState.attached, 20.seconds) +``` +Rules evaluate for *every* matching frame (until `times` is exhausted), so a declarative rule is the +right tool when the fault must land on a frame the test can't easily await; the imperative +`triggerAction` is the right tool for a fault at a precise moment on an already-live connection. + +### 11.4 Teardown +Both tests tear down in a nested `finally`: close the client, then always `session.close()` (`DELETE +/sessions/{id}`) and the token signer. + +**What these tests teach about the infra:** `ProxyManager.ensureProxy` + `SandboxApp` setup, +`connectThroughProxy`, **both** fault-injection styles (declarative `wsFrameToClientRule` at creation +and late imperative `triggerAction`), real-network waiting with `pollUntil`, and **proxy-log +assertions** as the primary verification (`getLog()` → filter by `type`/`direction`/`message.action`). --- ## 12. Deviations: when the SDK disagrees with the spec -`uts/.../io/ably/lib/uts/deviations.md` is the single catalogue of every place the ably-java SDK behaves -differently from the features spec, discovered during translation. Each entry records: the **spec -point**, **what the spec requires**, **what the SDK does**, the **root cause** (file/function, where -known), the **workaround in tests**, and the **affected tests**. +Deviations live **with their tests**, not in `:uts` (whose smoke tests carry none by design). There are +two catalogues: + +- `lib/src/test/kotlin/io/ably/lib/uts/deviations.md` — the **realtime/rest** tiers, hosted in `:java`. +- `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md` — **all three objects tiers** + (unit, integration, proxy), hosted in `:liveobjects`. + +Each entry records the **spec point**, **what the spec requires**, **what the SDK does**, the **root +cause** (file/function, where known), the **workaround in tests**, and the **affected tests**. The mechanism (from [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md)): the test keeps the **spec-correct** assertion but gates it behind the `RUN_DEVIATIONS` env var, with a regression-guard assertion for the SDK's actual behaviour running by default. Normal runs stay green; `RUN_DEVIATIONS=1` turns the failing assertions -on so the gap is reproducible and the test flips automatically once the SDK is fixed. +on so the gap is reproducible and the test flips automatically once the SDK is fixed. In code (from +`:java`'s `ConnectionRecoveryTest`, RTN16f): +```kotlin +if (System.getenv("RUN_DEVIATIONS") != null) { + assertEquals(42L, currentRecoveryKey.msgSerial) // spec-correct: recover preserves msgSerial +} else { + assertEquals(0L, currentRecoveryKey.msgSerial) // regression guard: the SDK's actual behaviour +} +``` +Run it with `RUN_DEVIATIONS=1 ./gradlew :java:runUtsUnitTests --tests "*ConnectionRecoveryTest*"` (§13). -Current entries relevant to the walkthrough tests: +Representative entries from the realtime catalogue (`lib/.../deviations.md`): | Spec point | Gist | Touches | |------------|------|---------| -| **RTN16f** | SDK resets `msgSerial` to 0 on connect even with `recover`; spec says preserve it (42). | `ConnectionRecoveryTest` (§9.4) — `assertEquals(42L,…)` gated, `assertEquals(0L,…)` default guard. | -| **RTN16g2** | Spec's fatal error 50000/500 isn't fatal to the SDK (`isFatalError()` needs code 40000–49999 or status < 500); also `send_to_client_and_close` races the FAILED transition. | `ConnectionRecoveryTest` (§9.2) — uses 40000/400 + plain `sendToClient`. | -| **RTL13b** | `ATTACHING → SUSPENDED` via `realtimeRequestTimeout` not implemented for channel attach. | various channel tests (not the walkthroughs here). | -| **RTL13c** | `channelRetryTimeout` not cancelled when the connection leaves CONNECTED. | various channel tests; assertions gated behind `RUN_DEVIATIONS`. | +| **RTN16f** | SDK resets `msgSerial` to 0 on connect even with `recover`; spec says preserve it (42). | `ConnectionRecoveryTest` (`:java`) — `assertEquals(42L,…)` gated, `assertEquals(0L,…)` default guard. | +| **RTN16g2** | Spec's fatal error 50000/500 isn't fatal to the SDK (`isFatalError()` needs code 40000–49999 or status < 500); also `send_to_client_and_close` races the FAILED transition. | `ConnectionRecoveryTest` (`:java`) — uses 40000/400 + plain `sendToClient`. | +| **RTL13b / RTL13c** | Channel-state reattach / `channelRetryTimeout` gaps. Retained as confirmed SDK gaps; the citing channel tests aren't in the suite yet (only `ConnectionRecoveryTest` is translated). | pending the channels-module translation. | + +The objects catalogue additionally records the typed-SDK / language adaptations (RTTS API +partitioning, compile-time-forbidden inputs, internal-wire visibility) and the intentional RTO18d +listener-dedup divergence — see `liveobjects/.../deviations.md`. > These deviations are **valuable output**, not failures — each one is a precise, reproducible bug > report the SDK team can act on, and the gated test becomes the acceptance test for the fix. @@ -762,40 +932,56 @@ Current entries relevant to the walkthrough tests: ## 13. How to Run the Tests -There are two custom Gradle tasks (registered in `uts/build.gradle.kts`), filtered by package — they -mirror `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests` in the `liveobjects` module: +Each module registers package-filtered Gradle tasks. `:uts` registers `runUtsUnitTests` / +`runUtsIntegrationTests` (in `uts/build.gradle.kts`); `:java` registers the same-named tasks for its +realtime suites; `:liveobjects` registers `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests`. + +| What | Command | +|---|---| +| `:uts` smoke (unit, offline) | `./gradlew :uts:runUtsUnitTests` | +| `:uts` smoke (integration + proxy) | `./gradlew :uts:runUtsIntegrationTests` | +| realtime UTS unit | `./gradlew :java:runUtsUnitTests` | +| realtime UTS integration + proxy | `./gradlew :java:runUtsIntegrationTests` | +| objects UTS unit | `./gradlew :liveobjects:runLiveObjectsUnitTests` | +| objects UTS integration + proxy | `./gradlew :liveobjects:runLiveObjectsIntegrationTests` | + +Each `runUts*` / `runLiveObjects*` task is package-filtered (`io.ably.lib.uts.unit.*` / +`io.ably.lib.uts.integration.*` for `:uts` and `:java`; `io.ably.lib.liveobjects.uts.*` for +`:liveobjects`). The `…IntegrationTests` tasks cover **both** the direct-sandbox +(`integration/standard/`) and proxy (`integration/proxy/`) tiers — proxy tests additionally +download/launch the uts-proxy. ```bash -# Unit tests only — io.ably.lib.uts.unit.* (fast, no network). This is the PR gate. -./gradlew :uts:runUtsUnitTests - -# Integration tests only — io.ably.lib.uts.integration.* (real sandbox; covers both -# integration/standard/ and integration/proxy/ — proxy tests also download/launch the uts-proxy). -./gradlew :uts:runUtsIntegrationTests - -# Everything (the default Test task still runs both): +# All :uts smoke tests (every tier), or one class: ./gradlew :uts:test +./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.UnitInfraSmokeTest" +./gradlew :java:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime.AuthReauthTest" -# 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" - -# Turn on the spec-correct (currently failing) deviation assertions: -RUN_DEVIATIONS=1 ./gradlew :uts:runUtsUnitTests --tests "*ConnectionRecoveryTest*" +# Turn on the spec-correct (currently failing) deviation assertions (§12): +RUN_DEVIATIONS=1 ./gradlew :java:runUtsUnitTests --tests "*ConnectionRecoveryTest*" # Run proxy tests against a locally built proxy instead of a GitHub release: ./gradlew :uts:runUtsIntegrationTests -Duts.proxy.localPath=/path/to/uts-proxy # or .tar.gz # (equivalently: export UTS_PROXY_LOCAL_PATH=/path/to/uts-proxy) ``` -**Where CI runs them:** `runUtsUnitTests` is part of the `check.yml` gate (alongside -`runLiveObjectsUnitTests`); `runUtsIntegrationTests` runs in the `check-uts` job of -`integration-test.yml` (alongside `check-liveobjects`). +> **Unqualified task names collide — intentionally.** `./gradlew runUtsUnitTests` (no module prefix) +> runs the task in **both** `:uts` and `:java` (Gradle matches the task name across every project that +> declares it). That's intended and harmless — both unit tiers are offline — and mirrors the existing +> `runUnitTests` precedent (registered in both `:java` and `:pubsub-adapter`). Qualify with `:uts:` / +> `:java:` to target one. The `RUN_DEVIATIONS`, `-Duts.proxy.localPath` and `UTS_PROXY_LOCAL_PATH` +> knobs are valid for **all three** modules. + +**Where CI runs them:** `check.yml` runs +`… runUnitTests runLiveObjectsUnitTests :java:runUtsUnitTests :uts:runUtsUnitTests` as the PR gate +(all offline unit tiers); `integration-test.yml`'s `check-uts` job runs +`:java:runUtsIntegrationTests :uts:runUtsIntegrationTests`, and its `check-liveobjects` job runs +`runLiveObjectsIntegrationTests`. Notes: - `ProxyManager` **advises** running proxy suites single-fork (`maxParallelForks = 1`) because they share the control port (10100). This is not currently set in `uts/build.gradle.kts`; it isn't - exercised yet because there is only one proxy test class. + exercised yet because there is only one proxy smoke class. - Proxy/sandbox tests need outbound network (sandbox + GitHub releases on first run; the binary is then cached under `~/.cache/uts-proxy/`). - Before pushing, run the project's static-analysis gate (from `CLAUDE.md`): @@ -919,8 +1105,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/main/` (the `infra/` tree) and +`uts/src/test/` (the three tier smoke tests) and the SDK seams they use, so nothing is left +implicit. ### B.1 Unit-test infrastructure — `io.ably.lib.uts.infra.unit` @@ -952,17 +1139,18 @@ nothing is left implicit. | File | Key public surface | Role | |------|--------------------|------| | `infra/Utils.kt` | `awaitState(client,target,timeout=5s)`, `awaitChannelState(channel,target,timeout=5s)`, `pollUntil(timeout=15s,interval=100ms){ }` | Shared wall-clock coroutine waits (package `io.ably.lib.uts.infra`); listener registered before state check. | -| `unit/realtime/ConnectionRecoveryTest.kt` | 6 `@Test`s: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16j | Unit tier (`io.ably.lib.uts.unit.realtime`) — connection recovery (mocked WS, FakeClock, env-gated deviations). | -| `integration/standard/realtime/ChannelHistoryTest.kt` | 1 `@ParameterizedTest` (RTL10d) × {JSON, msgpack} | Direct-sandbox tier (`io.ably.lib.uts.integration.standard.realtime`) — cross-client history durability (`SandboxApp`, no proxy; awaited publish + `pollUntil` on `history()`). | -| `integration/proxy/realtime/AuthReauthTest.kt` | 1 `@Test` (two `@UTS`: RTN22, RTC8a) | Integration tier (`io.ably.lib.uts.integration.proxy.realtime`) — server-initiated re-authentication. | -| `deviations.md` | RTN16f, RTN16g2, RTL13b, RTL13c | Catalogue of SDK-vs-spec divergences. | - -> **Coverage note:** this guide walks through **one reference test per tier** — `ConnectionRecoveryTest` -> (unit, §9), `ChannelHistoryTest` (direct-sandbox, §10), and `AuthReauthTest` (proxy, §11). The `uts/` -> module additionally carries LiveObjects tests across the same tiers, and the infrastructure under -> `infra/unit/` and `infra/integration/` is built out beyond what any single test exercises (full HTTP -> mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader UTS coverage -> catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md). +| `unit/UnitInfraSmokeTest.kt` | 3 `@Test`s: full mock-WS lifecycle (await style), token-auth via mock HTTP (callback WS), FakeClock run-to-quiescence | Unit-tier infra acceptance (`io.ably.lib.uts.unit`) — MockWebSocket/MockHttpClient/FakeClock end-to-end. **No** `@UTS`. | +| `integration/standard/IntegrationInfraSmokeTest.kt` | 1 `@ParameterizedTest` × {JSON, msgpack} | Direct-sandbox infra acceptance (`io.ably.lib.uts.integration.standard`) — SandboxApp + realtime/REST round-trip, awaited publish + `pollUntil` on `history()`. **No** `@UTS`. | +| `integration/proxy/ProxyInfraSmokeTest.kt` | 2 `@Test`s: late imperative disconnect, declarative ws-frame rule | Proxy infra acceptance (`io.ably.lib.uts.integration.proxy`) — ProxyManager + ProxySession, both fault-injection styles, proxy-log asserts. **No** `@UTS`. | + +> **Coverage note:** this guide walks through the **three tier smoke tests** — `UnitInfraSmokeTest` +> (unit, §9), `IntegrationInfraSmokeTest` (direct-sandbox, §10), and `ProxyInfraSmokeTest` (proxy, §11) +> — the permanent acceptance gate for the shared infra and the worked examples the walkthroughs teach +> from. The infra under `infra/unit/` and `infra/integration/` is built out beyond what the smokes +> exercise (full HTTP mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader +> UTS coverage catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md). The real spec-derived suites that consume this +> infra live in `:java` (`lib/src/test/kotlin/io/ably/lib/uts/…`) and `:liveobjects` +> (`liveobjects/.../uts/…`) — see §13. --- @@ -974,11 +1162,11 @@ nothing is left implicit. | Translating specs, deviation patterns, decision tree | [`uts/docs/writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) | | Integration/proxy policy, late fault injection, tiers | [`uts/docs/integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md) | | Coverage matrix | [`uts/docs/completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md) | -| Proxy control API, rule format, action numbers | [`uts/realtime/integration/helpers/proxy.md`](https://github.com/ably/specification/blob/main/uts/realtime/integration/helpers/proxy.md) | +| 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) | -| 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` | +| Unit mocks | `uts/src/main/.../uts/infra/unit/*` | +| Integration helpers | `uts/src/main/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) | +| Async helpers | `uts/src/main/.../uts/infra/Utils.kt` (awaits), `…/infra/unit/Utils.kt` (ConnectionDetails builder) | +| The three tier smoke tests | `uts/src/test/.../uts/unit/UnitInfraSmokeTest.kt`, `…/uts/integration/standard/IntegrationInfraSmokeTest.kt`, `…/uts/integration/proxy/ProxyInfraSmokeTest.kt` | +| Deviations | `lib/src/test/kotlin/io/ably/lib/uts/deviations.md` (realtime/rest, `:java`), `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md` (objects, `:liveobjects`) | diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 4eca7f9c4..5512c00e1 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -1,23 +1,48 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat plugins { - alias(libs.plugins.kotlin.jvm) + `java-library` // NEW — provides the `api` configuration. Previously + // arrived transitively via `java-test-fixtures`; + // kotlin.jvm alone applies only the plain `java` plugin. + alias(libs.plugins.kotlin.jvm) // `java-test-fixtures` REMOVED +} + +java { + // Declare Java-8 outgoing variants (org.gradle.jvm.version=8) so :java's 8-requesting resolvable + // configurations can consume project(":uts"). Only possible now that Phase 3 removed the + // :liveobjects (Java-21) test edge that previously forced this module's classpaths back to 21. + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } dependencies { - 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. - testRuntimeOnly(project(":liveobjects")) - testImplementation(kotlin("test")) - // @ParameterizedTest / @ValueSource — version managed by the junit-bom on the test classpath. - testImplementation("org.junit.jupiter:junit-jupiter-params") - testImplementation(libs.mockk) - testImplementation(libs.coroutine.core) - testImplementation(libs.coroutine.test) - testImplementation(libs.ktor.client.core) - testImplementation(libs.ktor.client.cio) + // The shared UTS test infra (src/main/kotlin/io/ably/lib/uts/infra/**) — this module's main + // artifact. Consumed by other modules via testImplementation(project(":uts")). + // INVARIANT (I1): :uts main (and its test config) has no :liveobjects dependency at all — the + // objects tiers that needed the runtime plugin moved to :liveobjects. + // `api` for types that appear in infra signatures; `implementation` for internals. + api(project(":java")) + api(project(":network-client-core")) + // ktor stays implementation — the proxy infra uses it internally; it must NOT leak to consumers. + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + + // The UTS test toolkit — exported (api) so any module consuming this infra via + // testImplementation(project(":uts")) transitively gets the full stack needed to + // WRITE and RUN UTS tests: JUnit 5 (api+params+engine via the aggregator), the + // kotlin.test Jupiter binding, and coroutines (runTest etc.). Consumers declare + // only the project dependency. + api(platform(libs.junit.bom)) + api(libs.junit.jupiter) + api(libs.junit.jupiter.params) + api(kotlin("test-junit5")) + api(libs.coroutine.core) // promote from implementation (suspend-heavy public API anyway) + api(libs.coroutine.test) + + // :uts's own smoke tests inherit the whole toolkit transitively from main's `api` above + // (a module's test classpath sees its own main api/implementation deps) — nothing to declare. + // Verified: the three smoke tests import only kotlin.test, JUnit Jupiter, coroutines and the + // infra itself; they use ktor only through the infra, never directly. } tasks.withType().configureEach { @@ -52,3 +77,7 @@ tasks.register("runUtsIntegrationTests") { includeTestsMatching("io.ably.lib.uts.integration.*") } } + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/Utils.kt similarity index 81% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/Utils.kt index 31fa5901d..1dfc39e36 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/Utils.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/Utils.kt @@ -28,11 +28,18 @@ suspend fun awaitState( withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { suspendCancellableCoroutine { cont -> - val listener = ConnectionStateListener { change -> - if (change.current == target && cont.isActive) cont.resume(Unit) + lateinit var listener: ConnectionStateListener + listener = ConnectionStateListener { change -> + if (change.current == target && cont.isActive) { + client.connection.off(listener) + cont.resume(Unit) + } } client.connection.on(listener) - if (client.connection.state == target && cont.isActive) cont.resume(Unit) + if (client.connection.state == target && cont.isActive) { + client.connection.off(listener) + cont.resume(Unit) + } cont.invokeOnCancellation { client.connection.off(listener) } } } @@ -80,11 +87,18 @@ suspend fun awaitChannelState( withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { suspendCancellableCoroutine { cont -> - val listener = ChannelStateListener { change -> - if (change.current == target && cont.isActive) cont.resume(Unit) + lateinit var listener: ChannelStateListener + listener = ChannelStateListener { change -> + if (change.current == target && cont.isActive) { + channel.off(listener) + cont.resume(Unit) + } } channel.on(listener) - if (channel.state == target && cont.isActive) cont.resume(Unit) + if (channel.state == target && cont.isActive) { + channel.off(listener) + cont.resume(Unit) + } cont.invokeOnCancellation { channel.off(listener) } } } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt similarity index 89% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt index 3035d05a4..45cdb9b51 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt @@ -10,6 +10,7 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import java.util.* +import kotlinx.coroutines.CancellationException private val client = HttpClient(CIO) { install(HttpRequestRetry) { @@ -93,7 +94,11 @@ class SandboxApp private constructor( contentType(ContentType.Application.Json) setBody(loadAppCreationJson().toString()) } - val body = JsonParser.parseString(response.bodyAsText()).asJsonObject + val responseBody = response.bodyAsText() + check(response.status.isSuccess()) { + "sandbox app provisioning failed: HTTP ${response.status} — $responseBody" + } + val body = JsonParser.parseString(responseBody).asJsonObject val keys = body["keys"].asJsonArray.map { it.asJsonObject["keyStr"].asString } return SandboxApp( appId = body["appId"].asString, @@ -104,7 +109,8 @@ class SandboxApp private constructor( } /** - * Deletes the provisioned app. Errors are ignored so teardown never masks a test failure. + * Deletes the provisioned app. Errors are ignored so teardown never masks a test failure; + * [CancellationException] is rethrown so cooperative cancellation is preserved. */ suspend fun delete() { runCatching { @@ -112,6 +118,6 @@ class SandboxApp private constructor( client.delete("$sandboxBaseUrl/apps/$appId") { header(HttpHeaders.Authorization, "Basic $basic") } - } + }.onFailure { if (it is CancellationException) throw it } } } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt b/uts/src/main/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/main/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/main/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/main/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/main/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/main/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/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt similarity index 81% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt index 3cfd6df53..ba7f0470b 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt @@ -31,7 +31,11 @@ internal class DefaultPendingConnection( onConnected(listener) // Async delivery per spec: the library must store the WS reference before processing CONNECTED. val encoded = Serialisation.gson.toJson(message) - deliveryExecutor.submit { listener.onMessage(encoded) } + // Use execute so a thrown delivery exception reaches the thread's uncaught handler + // instead of being swallowed in a discarded Future. + deliveryExecutor.execute { listener.onMessage(encoded) } + // One-shot delivery: release the daemon thread once the single message is queued. + deliveryExecutor.shutdown() } override fun respondWithRefused() = listener.onError(IOException("Connection refused to $host:$port")) diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt similarity index 72% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt index e77ab518d..4eb6f48c7 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt @@ -4,6 +4,7 @@ import io.ably.lib.network.FailedConnectionException import io.ably.lib.network.HttpBody import io.ably.lib.network.HttpRequest import io.ably.lib.network.HttpResponse +import io.ably.lib.util.Serialisation import kotlin.time.Duration import kotlinx.coroutines.CompletableDeferred import java.net.SocketTimeoutException @@ -20,14 +21,18 @@ internal class DefaultPendingRequest( override fun respondWith(status: Int, body: Any, headers: Map) { val bytes = when (body) { is ByteArray -> body - else -> body.toString().toByteArray(Charsets.UTF_8) + is String -> body.toByteArray(Charsets.UTF_8) + else -> Serialisation.gson.toJson(body).toByteArray(Charsets.UTF_8) } + // Derive the body content-type from a case-insensitive Content-Type header, defaulting to json. + val contentType = headers.entries.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) } + ?.value ?: "application/json" deferred.complete( HttpResponse.builder() .code(status) .message("") - .body(HttpBody("application/json", bytes)) - .headers(emptyMap()) + .body(HttpBody(contentType, bytes)) + .headers(headers.mapValues { listOf(it.value) }) .build() ) } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt similarity index 52% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt index 0b0c50d2d..8527c836c 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt @@ -4,6 +4,7 @@ import io.ably.lib.util.Clock import io.ably.lib.util.AblyTimer import io.ably.lib.util.TimerInstance import java.util.TimerTask +import java.util.concurrent.ConcurrentHashMap import kotlin.time.Duration /** @@ -14,7 +15,10 @@ import kotlin.time.Duration */ class FakeClock(initialTimeMs: Long = 0L) : Clock { @Volatile private var time = initialTimeMs - private val timers = mutableMapOf() + // SDK transport/channel threads call newTimer/schedule/cancel concurrently with the test thread's + // advance(), so the timer registry and each timer's task list must be thread-safe (a plain + // HashMap/ArrayList here races into ConcurrentModificationException / lost tasks on a loaded runner). + private val timers = ConcurrentHashMap() private val waiters = mutableListOf() override fun currentTimeMillis() = time @@ -38,7 +42,14 @@ class FakeClock(initialTimeMs: Long = 0L) : Clock { /** Advance virtual time by [ms] milliseconds, firing any timers that become due. */ fun advance(ms: Long) { time += ms - timers.values.forEach { it.fireDue(time) } + // Run to quiescence (the spec run to quiescence Guarantee): re-scan all timers — + // snapshotting each round so a timer registered mid-advance is picked up — until a full pass + // fires nothing, so cascades due within the interval (a zero-delay reschedule, or a timer + // created by fired work) also run. The map{}.any{} form is deliberately non-short-circuiting: + // every timer gets a fireDue pass each round before we decide whether to loop again. + do { + val firedAny = timers.values.toList().map { it.fireDue(time) }.any { it } + } while (firedAny) val due = synchronized(waiters) { waiters.filter { it.fireAt <= time }.also { waiters.removeIf { it.fireAt <= time } @@ -59,25 +70,42 @@ class FakeClock(initialTimeMs: Long = 0L) : Clock { fun pendingTaskCount(timerName: String) = timers[timerName]?.pendingCount ?: 0 inner class FakeAblyTimer(val name: String) : AblyTimer { + // Guarded by `synchronized(pending)`: schedule/cancel run on SDK threads while fireDue runs on + // the advancing test thread. private val pending = mutableListOf() - val pendingCount get() = pending.size + val pendingCount get() = synchronized(pending) { pending.size } override fun schedule(task: TimerTask, delayMs: Long): TimerInstance { val s = Scheduled(task, time + delayMs) - pending += s - pending.sortBy { it.fireAt } - return TimerInstance { task.cancel(); pending -= s } + synchronized(pending) { + pending += s + pending.sortBy { it.fireAt } + } + return TimerInstance { + task.cancel() + synchronized(pending) { pending -= s } + } } override fun cancel() { - pending.forEach { it.task.cancel() } - pending.clear() + synchronized(pending) { + pending.forEach { it.task.cancel() } + pending.clear() + } } - fun fireDue(now: Long) { - val due = pending.filter { it.fireAt <= now } - pending -= due.toSet() + /** Fires all tasks now due; returns whether it fired anything (drives advance's quiescence loop). */ + fun fireDue(now: Long): Boolean { + // Select + remove under the lock, then run tasks OUTSIDE it: a task may re-enter + // schedule/cancel (e.g. the activity timer rescheduling itself), which would otherwise + // deadlock or race. + val due = synchronized(pending) { + val d = pending.filter { it.fireAt <= now } + pending -= d.toSet() + d + } due.forEach { it.task.run() } + return due.isNotEmpty() } } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt b/uts/src/main/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/main/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/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt similarity index 93% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt index 0397cf6a9..7f498710a 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt @@ -26,8 +26,8 @@ class HttpMockConfig { * Fake HTTP engine for SDK unit tests. Install via [installOn] or `TestRestClient`/`TestRealtimeClient`. */ class MockHttpClient(private val config: HttpMockConfig = HttpMockConfig()) { - private var _pendingConnections = Channel(Channel.UNLIMITED) - private var _pendingRequests = Channel(Channel.UNLIMITED) + @Volatile private var _pendingConnections = Channel(Channel.UNLIMITED) + @Volatile private var _pendingRequests = Channel(Channel.UNLIMITED) val engine: HttpEngine = MockHttpEngine( onConnect = { conn -> diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt b/uts/src/main/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/main/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/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt similarity index 95% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt index e57227bf7..6489a5bf5 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt @@ -46,9 +46,9 @@ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { /** Snapshot of all events recorded since construction (or last [reset]). */ val events: List get() = _events.toList() - private var _pendingConnections = Channel(Channel.UNLIMITED) - private var _messagesFromClient = Channel(Channel.UNLIMITED) - private var _clientCloseEvents = Channel(Channel.UNLIMITED) + @Volatile private var _pendingConnections = Channel(Channel.UNLIMITED) + @Volatile private var _messagesFromClient = Channel(Channel.UNLIMITED) + @Volatile private var _clientCloseEvents = Channel(Channel.UNLIMITED) @Volatile private var activeListener: WebSocketListener? = null @@ -98,6 +98,8 @@ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { onClientClose = { code, reason -> val event = MockEvent.ClientClose(code, reason) _events.add(event) + // Match the other close paths: null the listener so post-close sendToClient fails fast. + activeListener = null _clientCloseEvents.trySend(event) }, ) diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt similarity index 94% rename from uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt index 2a41b518c..28513e455 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt +++ b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt @@ -55,7 +55,10 @@ internal class MockWebSocketClient( onClientClose(code, reason) listener.onClose(code, reason) // drive the SDK } - override fun cancel(code: Int, reason: String) { onClientClose(code, reason) } + override fun cancel(code: Int, reason: String) { + onClientClose(code, reason) + listener.onClose(code, reason) // drive the SDK + } override fun send(message: ByteArray) { onBinaryFrame(message) } override fun send(message: String) { onTextFrame(message) } } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt b/uts/src/main/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/main/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/main/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/main/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/main/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/main/kotlin/io/ably/lib/uts/infra/unit/Utils.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md deleted file mode 100644 index ab0b5b3fa..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md +++ /dev/null @@ -1,386 +0,0 @@ -# SDK Deviations - -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: - -| Group | Meaning | Action | -|---|---|---| -| **1) Genuine SDK bugs — open** | runtime behaviour differs from the spec; ably-js is compliant | fix the SDK | -| **2) Shared gap — open in both SDKs** | ably-java and ably-js deviate the same way | optional joint fix (spec is ahead of both) | -| **3) Expected — typed-SDK / language adaptations** | not bugs: RTTS API partitioning, compile-time guarantees, internal-wire visibility | none — correct as documented | -| **4) Intentional deviation** | deliberate SDK design choice; the spec point itself is questioned | none unless the spec is revised | - -> **Recently fixed and removed from this file:** RTO23e (`get()` now re-attaches a DETACHED channel — -> mode-only check + ensure-active-channel) and RTO20e/RTO20e1 (event-driven `once(SYNCED)` waiters + -> `failSyncWaiters` replace the orphan-prone shared deferred). Their spec-correct tests are un-gated and -> pass by default. -> -> One **test-stimulus adaptation** (not an SDK deviation) remains inline in `RealtimeObjectTest.kt`: the -> RTO20e1 test drives the 92008 path via channel ERROR → FAILED instead of the spec's DETACHED stimulus — -> an unsolicited DETACHED auto-reattaches (RTL13a) and never settles, so the spec's stimulus is -> unobservable. Same adaptation ably-js uses and the spec adopted for the proxy tier (specification#501). - ---- - -# 1) Genuine SDK bugs — open (realtime module) - -*Runtime behaviour differs from the spec and ably-js is compliant — real bugs, pending an SDK fix.* - -> ⚠ **RTL13b / RTL13c:** the channel-state UTS tests these two entries cite are **not currently part of the -> uts suite** (no `RTL13*` tests or gates exist in `unit/realtime/` — only `ConnectionRecoveryTest.kt` is -> translated). The entries are retained as confirmed SDK gaps (cross-checked against ably-js in -> `ABLY-JS-JAVA-DEVIATIONS-COMPARISON.md`); re-verify them when the channels module translation lands. - -## RTL13b — ATTACHING → SUSPENDED via `realtimeRequestTimeout` not implemented - -**Spec point:** RTL13b -**What the spec requires:** If a channel's reattach request (triggered by RTL13a) does not receive a response within `realtimeRequestTimeout`, the channel must transition from ATTACHING to SUSPENDED and schedule a retry after `channelRetryTimeout`. -**What the SDK does:** The channel remains in ATTACHING indefinitely when no server response arrives. The `realtimeRequestTimeout` timer is not applied to channel attach requests; only a server-sent DETACH/ERROR while ATTACHING causes the ATTACHING → SUSPENDED transition. -**Workaround in tests:** Tests that need a SUSPENDED state set up via failed reattach instead use server-sent DETACHED while ATTACHING (RTL13b's second condition, which IS implemented) to drive the channel to SUSPENDED. -**Tests affected:** -- `RTL13a - server DETACHED on SUSPENDED channel triggers immediate reattach` (RTL13a/suspended-reattach-triggered-1) — setup path changed -- `RTL13b - failed reattach transitions to SUSPENDED with automatic retry` (RTL13b/failed-reattach-suspended-retry-0) — mock sends DETACHED instead of withholding response -- `RTL13b - repeated failures cycle SUSPENDED to ATTACHING indefinitely` (RTL13b/repeated-failure-cycle-2) — mock sends DETACHED instead of withholding response -- `RTL13c - automatic retry cancelled when connection is no longer CONNECTED` (RTL13c/retry-cancelled-disconnected-0) — setup path changed - ---- - -## RTL13c — channelRetryTimeout not cancelled when connection leaves CONNECTED - -**Spec point:** RTL13c -**What the spec requires:** When the connection is no longer CONNECTED, any pending automatic channel reattach timer (channelRetryTimeout) must be cancelled. The channel should remain SUSPENDED without attempting to reattach until the connection is restored. -**What the SDK does:** The channelRetryTimeout fires regardless of connection state. When it fires while disconnected, the channel transitions to ATTACHING even though there is no active connection, and no ATTACH message can be sent. -**Tests affected:** -- `RTL13c - automatic retry cancelled when connection is no longer CONNECTED` (RTL13c/retry-cancelled-disconnected-0) — the assertions `assertEquals(attachCountAfterDisconnect, attachCount)` and `assertEquals(ChannelState.suspended, channel.state)` are gated behind `RUN_DEVIATIONS`. - ---- - -## RTN16g2 — Fatal ERROR must be sent without closing the transport - -**Spec point:** RTN16g2 -**What the spec requires:** Trigger FAILED state by sending a fatal ERROR message followed by closing the WebSocket (`send_to_client_and_close`), using error code 50000/statusCode 500. -**What the SDK does (two issues):** -1. Error code 50000/statusCode 500 is not treated as fatal by `isFatalError()` (requires code 40000–49999 or statusCode < 500), so FAILED is never reached with the spec's values. -2. Sending `close(1000)` after the ERROR dispatches a synchronous `DISCONNECTED` action that races with and preempts the async `FAILED` transition triggered by the ERROR message. -**Workaround in tests:** Use `sendToClient` (no close frame) with code 40000/statusCode 400. The SDK's own FAILED-state handler calls `clearTransport()`, so the explicit close is not needed. -**Tests affected:** -- `RTN16g2 - createRecoveryKey returns null in inactive states and before first connect` (RTN16g2/recovery-key-null-inactive-0) — error code and send method changed. - ---- - -## RTN16f — msgSerial not initialised from recovery key on connect - -**Spec point:** RTN16f -**What the spec requires:** When instantiated with the `recover` option, the SDK initialises its internal `msgSerial` counter to the value stored in the recovery key, so the first published message carries that serial. -**What the SDK does:** `ConnectionManager.onConnected()` resets `msgSerial` to 0 whenever `connection.id` is null on the fresh client (line 1316), even when the `recover` option is set. The recovered serial is discarded. -**Workaround in tests:** The spec-correct assertion (`assertEquals(42L, msgSerial)`) is gated behind `RUN_DEVIATIONS`. A regression guard assertion (`assertEquals(0L, msgSerial)`) runs by default to catch any unintentional change to the SDK's actual behaviour. -**Tests affected:** -- `RTN16f - recover option initializes msgSerial from recoveryKey` (RTN16f/recover-initializes-msgserial-0) — `assertEquals(42L, ...)` gated; `assertEquals(0L, ...)` added as regression guard. - ---- - -# 2) Shared gap — open in BOTH SDKs (objects) - -*ably-js has the same documented deviation — the spec is ahead of both implementations. Optional joint fix.* - -## RTLC9h / RTLC16 / RTLO4b4c1 — missing-field counter ops are not treated as no-ops - -**Spec points:** RTLC9h, RTLC16d, RTLO4b4c1 -**What the spec requires:** A `COUNTER_INC` whose `counterInc.number` is **absent** produces a no-op -`LiveObjectUpdate` (`update.noop == true`, RTLC9h), so a subscribed listener must NOT be invoked -(RTLO4b4c1). For the RTLO4b4c1 stimulus (`01` real inc → `02` missing-number noop → `03` real inc), the -listener fires exactly twice (`updates == 2`). -**What the SDK does:** `WireCounterInc.number` is a **non-nullable `Double`** -(`liveobjects/.../message/WireObjectMessage.kt`), so an absent `number` deserialises to `0.0` — -indistinguishable from `number: 0`. `LiveCounterManager.applyCounterInc` unconditionally returns -`ObjectUpdate.CounterUpdate(amount, message)` (RTLC9g) and calls `notifyUpdated`; there is **no** -missing-number/RTLC9h noop branch on the operation path (the only counter noop, RTLC14b via -`calculateUpdateFromDataDiff`, exists on the sync/`replaceData` path, not the op path). So the -missing-number `02` op fires an amount-0 event and the listener is invoked a 3rd time (`updates == 3`). -**Same family — RTLC16 (COUNTER_CREATE with absent `count`):** RTLC16d requires the create-op merge to -return a noop when `counterCreate.count` does not exist; `LiveCounterManager.mergeInitialDataFromCreateOperation` -returns a normal amount-0 `CounterUpdate` instead (`?: 0.0`). Same root cause: the wire types don't track -field presence, so an absent count is indistinguishable from an explicit `0` (which per RTLC16c correctly -yields an amount-0 update, NOT a noop). -**ably-js status:** same deviations (documented in its `deviations.md`) — `_applyCounterInc` applies -`op.number` unconditionally, so a missing number becomes `NaN` and an event fires; and its create-op merge -does the identical `counterCreate?.count ?? 0`, applying an amount-0 update where RTLC16d wants a noop. -**Workaround in tests:** `RTLO4b4c1 - noop update does not trigger listener` is gated behind -`RUN_DEVIATIONS` (early `return@runTest`), keeping the spec-correct `assertEquals(2, updates.size)`. -Repro: `RUN_DEVIATIONS=1 ./gradlew :uts:runUtsUnitTests --tests "*LiveObjectSubscribeTest"`. -**Root cause / fix (SDK):** make `WireCounterInc.number` (and `WireCounterCreate.count`) nullable (or -track field presence) and add the missing-field → noop branches (RTLC9h in `applyCounterInc`, RTLC16d in -`mergeInitialDataFromCreateOperation`) so no event is emitted. To be fixed in both SDKs together. -**Tests affected (LiveObjectSubscribeTest.kt):** -- `RTLO4b4c1 - noop update does not trigger listener` (RTLO4b4c1/noop-no-trigger-0) — env-gated. - -> Note: the internal `LiveObjectUpdate.noop` diff flag is also not exposed on the public -> `InstanceSubscriptionEvent` (only `getObject()`/`getMessage()`, mapping §8); the spec frames noop -> suppression as *the listener not firing*, which is the observable the env-gated test asserts. - ---- - -# 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 - -**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). diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt new file mode 100644 index 000000000..ccb0aef82 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt @@ -0,0 +1,171 @@ +package io.ably.lib.uts.integration.proxy + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.rest.AblyRest +import io.ably.lib.rest.Auth +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.integration.proxy.ProxyManager +import io.ably.lib.uts.infra.integration.proxy.ProxySession +import io.ably.lib.uts.infra.integration.proxy.connectThroughProxy +import io.ably.lib.uts.infra.integration.proxy.wsFrameToClientRule +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.Collections +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Acceptance test for the **full proxy integration infrastructure itself** ([ProxyManager], + * [ProxySession], [SandboxApp] + client wiring through the proxy) — NOT derived from a UTS spec, so + * it carries no `@UTS` marker. It is the permanent teaching example for `uts/README.md` §11 and the + * reference shape a future proxy-tier UTS test should take. + * + * It proves the full chain end-to-end: binary download/launch → sandbox provisioning → a real client + * connecting through the proxy with token auth → typed proxy-log assertions → both fault-injection + * styles (a declarative `ws_frame` rule and a late imperative `triggerAction`) → recovery → teardown. + * + * Needs outbound network (GitHub releases on first run, then the Ably sandbox) and spawns the local + * `uts-proxy` process. Run with: + * ``` + * ./gradlew :uts:runUtsIntegrationTests + * ``` + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProxyInfraSmokeTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + ProxyManager.ensureProxy() + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * Pass-through session: proves the happy path and the typed proxy log, then injects a fault the + * *late imperative* way — [ProxySession.triggerAction] on the live connection. + */ + @Test + fun `proxy pass-through proves the log and a late imperative disconnect`() = runTest { + // Assert provisioning (cocoa parity). + assertTrue(app.defaultKey.startsWith(app.appId + ".")) + + val session = ProxySession.create(rules = emptyList()) + assertTrue(session.proxyPort > 0) + + val tokenSigner = AblyRest(app.defaultKey) + val authCallbackCount = AtomicInteger(0) + val client = TestRealtimeClient { + // Basic key auth is TLS-only, so authenticate through the proxy with a locally-signed + // TokenRequest (README §11 teaching point). + authCallback = Auth.TokenCallback { params -> + authCallbackCount.incrementAndGet() + tokenSigner.auth.createTokenRequest(params, null) + } + connectThroughProxy(session) + autoConnect = false + } + + try { + client.connect() + awaitState(client, ConnectionState.connected, 20.seconds) + assertNotNull(client.connection.id) + assertTrue(authCallbackCount.get() >= 1) + + // Typed proxy-log assertions: the handshake recorded a ws_connect and a server→client + // CONNECTED frame (protocol action 4). + val log = session.getLog() + assertTrue(log.any { it.type == "ws_connect" }) + assertTrue( + log.any { + it.type == "ws_frame" && + it.direction == "server_to_client" && + it.message?.get("action")?.asInt == 4 + }, + ) + + // Late imperative fault: disconnect the live connection, observe DISCONNECTED, recover. + val states = Collections.synchronizedList(mutableListOf()) + client.connection.on { states.add(it.current) } + session.triggerAction(mapOf("type" to "disconnect")) + pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } + awaitState(client, ConnectionState.connected, 20.seconds) + + // The proxy recorded the imperative action. + assertTrue(session.getLog().any { it.type == "action" || it.ruleMatched != null }) + } finally { + try { + client.close() + } finally { + session.close() + runCatching { tokenSigner.close() } + } + } + } + + /** + * Declarative-rule session: a `ws_frame_to_client` rule replaces the first ATTACHED (action 11) + * with a disconnect, so the client observes a DISCONNECTED transition when the rule fires, then + * recovers (reconnects and the channel re-attaches once the one-shot rule is spent). + */ + @Test + fun `proxy declarative rule injects a disconnect on ATTACHED then recovers`() = runTest { + val session = ProxySession.create( + rules = listOf( + wsFrameToClientRule(action = mapOf("type" to "disconnect"), messageAction = 11, times = 1), + ), + ) + + val tokenSigner = AblyRest(app.defaultKey) + val client = TestRealtimeClient { + authCallback = Auth.TokenCallback { params -> + tokenSigner.auth.createTokenRequest(params, null) + } + connectThroughProxy(session) + autoConnect = false + } + + try { + val states = Collections.synchronizedList(mutableListOf()) + client.connection.on { states.add(it.current) } + + client.connect() + awaitState(client, ConnectionState.connected, 20.seconds) + + val channel = client.channels.get("smoke-proxy-rule-${UUID.randomUUID()}") + channel.attach() + + // The rule fires on the ATTACHED frame and disconnects the transport. + pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } + + // Recovery: the connection re-establishes and the channel re-attaches (rule is spent). + awaitState(client, ConnectionState.connected, 20.seconds) + awaitChannelState(channel, ChannelState.attached, 20.seconds) + } finally { + try { + client.close() + } finally { + session.close() + runCatching { tokenSigner.close() } + } + } + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt new file mode 100644 index 000000000..166d913c3 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt @@ -0,0 +1,157 @@ +package io.ably.lib.uts.integration.standard + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.rest.AblyRest +import io.ably.lib.types.AblyException +import io.ably.lib.types.Callback +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Message +import io.ably.lib.types.PaginatedResult +import io.ably.lib.types.PublishResult +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import io.ably.lib.uts.infra.unit.TestRestClient +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.Collections +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Acceptance test for the **direct-sandbox integration infrastructure itself** ([SandboxApp] + + * [TestRealtimeClient] / [TestRestClient] wired straight to the sandbox) — this is + * NOT derived from a UTS spec, so it carries no `@UTS` marker. It is the permanent teaching example + * for `uts/README.md` §10 and the reference shape a future integration-tier UTS test should take. + * + * It proves the middle tier end-to-end: sandbox provisioning → a real realtime client connecting + * straight to the sandbox over TLS (basic key auth, RSA1) → an attach/subscribe/publish round-trip + * → a REST `history()` read of the same messages → teardown. + * + * Runs once per protocol variant (the UTS `PROTOCOL` dimension): `false` = JSON, `true` = msgpack. + * + * Needs outbound network (the Ably sandbox). Run with: + * ``` + * ./gradlew :uts:runUtsIntegrationTests + * ``` + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class IntegrationInfraSmokeTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `sandbox infra works end to end`(useBinaryProtocol: Boolean) = runTest { + // Assert provisioning (cocoa parity). + assertTrue(app.defaultKey.startsWith(app.appId + ".")) + assertTrue(app.keys.isNotEmpty()) + + val channelName = "smoke-int-${UUID.randomUUID()}" + val client = newRealtimeClient(useBinaryProtocol) + val rest = newRestClient(useBinaryProtocol) + try { + // State-transition sequence, not just the final state: collect before connect. + val states = Collections.synchronizedList(mutableListOf()) + client.connection.on { states.add(it.current) } + + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + assertTrue(states.contains(ConnectionState.connecting)) + assertEquals(ConnectionState.connected, states.last()) + + val channel = client.channels.get(channelName) + channel.attach() + awaitChannelState(channel, ChannelState.attached, 15.seconds) + + val received = Collections.synchronizedList(mutableListOf()) + channel.subscribe { received.add(it) } + + // Three publishes, each ack-awaited. + channel.awaitPublish("event1", "data1") + channel.awaitPublish("event2", "data2") + channel.awaitPublish("event3", "data3") + + pollUntil(15.seconds) { received.size == 3 } + assertEquals(listOf("event1", "event2", "event3"), received.map { it.name }) + assertEquals(listOf("data1", "data2", "data3"), received.map { it.data }) + + // REST half of the infra: read history until all three appear, assert newest-first. + var history: PaginatedResult? = null + pollUntil(15.seconds, 500.milliseconds) { + val result = rest.channels.get(channelName).history(null) + history = result + result.items().size == 3 + } + val items = history!!.items() + assertEquals(3, items.size) + assertEquals("event3", items[0].name) + assertEquals("data3", items[0].data) + assertEquals("event2", items[1].name) + assertEquals("data2", items[1].data) + assertEquals("event1", items[2].name) + assertEquals("data1", items[2].data) + } finally { + client.close() + rest.close() + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** A realtime client wired straight to the nonprod sandbox (no proxy). */ + private fun newRealtimeClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + realtimeHost = SandboxApp.sandboxHost + restHost = SandboxApp.sandboxHost + this.useBinaryProtocol = useBinaryProtocol + autoConnect = false + } + + /** A REST client wired straight to the nonprod sandbox (the REST half of the infra). */ + private fun newRestClient(useBinaryProtocol: Boolean): AblyRest = TestRestClient { + key = app.defaultKey + restHost = SandboxApp.sandboxHost + this.useBinaryProtocol = useBinaryProtocol + } + + /** Publishes a message and suspends until the server confirms delivery (or errors). */ + private suspend fun Channel.awaitPublish(name: String, data: Any?): PublishResult = + suspendCancellableCoroutine { cont -> + publish(name, data, object : Callback { + override fun onSuccess(result: PublishResult) { + if (cont.isActive) cont.resume(result) + } + + override fun onError(reason: ErrorInfo) { + if (cont.isActive) cont.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }) + } +} 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/UnitInfraSmokeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt new file mode 100644 index 000000000..3adf397c3 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt @@ -0,0 +1,255 @@ +package io.ably.lib.uts.unit + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.unit.CONNECTED_MESSAGE +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.MockHttpClient +import io.ably.lib.uts.infra.unit.MockWebSocket +import io.ably.lib.uts.infra.unit.PendingConnection +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Acceptance test for the **unit-tier mock-transport infrastructure itself** ([MockWebSocket], + * [MockHttpClient], [FakeClock], [TestRealtimeClient]) — NOT derived from a UTS spec, so it carries + * no `@UTS` marker and must never trip the spec-parity tooling. It is the permanent teaching example + * for `uts/README.md` §9 and the reference shape a future unit-tier UTS test should take. + * + * It proves the mock chain end-to-end: driving a real SDK through a fake WebSocket + * (connect → attach → publish → disconnect → FakeClock-driven reconnect → refuse → SUSPENDED) and a + * fake HTTP engine (token auth over `authUrl`). + * + * Hermetic — no network. Run with: + * ``` + * ./gradlew :uts:runUtsUnitTests + * ``` + */ +class UnitInfraSmokeTest { + + /** + * Full transport lifecycle over the fake WebSocket. Uses the **await style** + * ([MockWebSocket.awaitConnectionAttempt]) throughout: the initial connect, the FakeClock-driven + * reconnect and the refuse→SUSPENDED branch all need per-attempt control, which the callback + * style cannot provide (a single `onConnectionAttempt` handler answers every attempt uniformly, + * and the two styles cannot be mixed on one mock). The callback style is demonstrated in the + * HTTP-auth test below. + */ + @Test + fun `unit infra drives the full mock-WebSocket connection lifecycle`() = runTest { + val fakeClock = FakeClock() + // Unicode channel name for the round-trip (out on the ATTACH frame, in on ATTACHED). + val channelName = "smoke-üñîçöðé-${UUID.randomUUID()}" + val mock = MockWebSocket() + val client = TestRealtimeClient { + autoConnect = false + disconnectedRetryTimeout = 300 + fallbackHosts = emptyArray() + install(mock) + enableFakeTimers(fakeClock) + } + + try { + // 2. Connect (await style). Capture the PendingConnection so we can assert query params. + val firstConnection = CompletableDeferred() + launch { + val conn = mock.awaitConnectionAttempt() + firstConnection.complete(conn) + conn.respondWithSuccess(CONNECTED_MESSAGE) + } + client.connect() + awaitState(client, ConnectionState.connected) + + // Query params on the captured connection (README §9.3's technique). + val conn = firstConnection.await() + assertEquals("json", conn.queryParams["format"]) + assertNotNull(conn.queryParams["key"]) + // ConnectionDetails fixture round-tripped: CONNECTED_MESSAGE's id reached the client. + assertEquals("test-connection-id", client.connection.id) + // Event ordering: attempt, then established. + assertTrue(mock.events[0] is MockEvent.ConnectionAttempt) + assertTrue(mock.events[1] is MockEvent.ConnectionEstablished) + + // 3. Server-initiated ATTACHED, with the outbound ATTACH frame asserted (Unicode out). + val ch = client.channels.get(channelName) + ch.attach() + val attachFrame = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.attach, attachFrame.action) + assertEquals(channelName, attachFrame.channel) + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = channelName + channelSerial = "serial-1" + }) + awaitChannelState(ch, ChannelState.attached) + // channelSerial round-tripped in. + assertEquals("serial-1", ch.properties.channelSerial) + + // 4. Publish — assert the full MESSAGE protocol frame, not just its arrival. + ch.publish("event", "payload") + val publishFrame = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.message, publishFrame.action) + assertEquals(channelName, publishFrame.channel) + assertEquals("event", publishFrame.messages[0].name) + assertEquals("payload", publishFrame.messages[0].data) + + // 5. Disconnect: we reach DISCONNECTED and the drop is recorded. We deliberately do NOT + // snapshot the ConnectionAttempt count here. FakeClock.waitOn(target, timeout) performs a + // real `target.wait(timeout)`, so the disconnected-retry (~disconnectedRetryTimeout ms of + // wall-clock, with backoff/jitter) eventually fires on its own even without an advance() — + // on a loaded CI runner it can beat this line, making a "still exactly 1 attempt" + // assertion inherently racy. advance() only wins the race sooner; it is not a hard gate. + // Ownership of attempt #2 therefore belongs to step 6, which gates on it deterministically + // via awaitConnectionAttempt() (buffered, so it cannot be missed). + mock.simulateDisconnect() + awaitState(client, ConnectionState.disconnected) + assertTrue(mock.events.any { it is MockEvent.Disconnected }) + + // 6. FakeClock-driven reconnect: the advance demonstrably drives the transition. Respond + // with a short-TTL CONNECTED so the SUSPENDED branch below suspends quickly. + val reconnectJob = launch { + fakeClock.advance(2.seconds) + mock.awaitConnectionAttempt().respondWithSuccess(shortLivedConnected()) + } + awaitState(client, ConnectionState.connected) + reconnectJob.join() + assertEquals(2, mock.events.filterIsInstance().size) + + // 7. Await-style refuse branch → SUSPENDED (README §9.2's centerpiece). + mock.simulateDisconnect() + val refuseJob = launch { + repeat(20) { + fakeClock.advance(2.seconds) + mock.awaitConnectionAttempt().respondWithRefused() + if (client.connection.state == ConnectionState.suspended) return@launch + } + } + awaitState(client, ConnectionState.suspended) + refuseJob.cancel() + assertNull(client.connection.createRecoveryKey()) + } finally { + // 8. Teardown always runs. + client.close() + } + } + + /** + * The fake HTTP engine exercised for real via token auth. Uses the **callback style** + * ([io.ably.lib.uts.infra.unit.WebSocketMockConfig.onConnectionAttempt]) for the WebSocket, and + * the **await style** ([MockHttpClient.awaitRequest]) for the auth HTTP request. + * + * Trap: [MockEvent.HttpRequest] is declared but never emitted by the HTTP mock — asserting on + * `events.filterIsInstance()` would silently pass on an empty list. We + * assert via [MockHttpClient.awaitRequest] / the [io.ably.lib.uts.infra.unit.PendingRequest] + * instead. + */ + @Test + fun `unit infra serves a token-auth HTTP request through the mock engine`() = runTest { + val now = System.currentTimeMillis() + // A TokenDetails JSON (the "issued" field makes the SDK treat it as TokenDetails, so no + // second HTTP round-trip to exchange a TokenRequest is needed). + val tokenJson = + """{"token":"fake-token-abc","keyName":"appId.keyId","issued":$now,""" + + """"expires":${now + 3_600_000L},"capability":"{\"*\":[\"*\"]}"}""" + + val mockWs = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } } + // Callback answers the HTTP TCP connect; the request itself is handled await-style below. + val mockHttp = MockHttpClient { onConnectionAttempt = { it.respondWithSuccess() } } + val client = TestRealtimeClient { + authUrl = "https://auth.example.test/token" + install(mockWs) + install(mockHttp) + autoConnect = false + } + + try { + val captured = CompletableDeferred>() + launch { + val request = mockHttp.awaitRequest() + captured.complete(request.method to request.url.path) + request.respondWith(200, tokenJson, mapOf("Content-Type" to "application/json")) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + val (method, path) = captured.await() + assertEquals("GET", method) + assertEquals("/token", path) + assertEquals(ConnectionState.connected, client.connection.state) + } finally { + client.close() + } + } + + /** + * Infra acceptance for the [FakeClock] run-to-quiescence Guarantee: a single [FakeClock.advance] + * runs *all* work due within the advanced interval, including cascades — work scheduled by fired + * work (a zero-delay reschedule) and a timer created mid-advance. Exercises [FakeClock] directly + * (no SDK), which is what the Guarantee is about: `advance` alone must reach quiescence. + */ + @Test + fun `FakeClock advance runs cascaded work to quiescence in one call`() { + val fakeClock = FakeClock() + + var rescheduleRuns = 0 + var newTimerTaskRan = false + + val timer = fakeClock.newTimer("cascade") + // A task that reschedules ITSELF at zero delay a fixed number of times: each reschedule is due + // immediately at the current (already-advanced) virtual time, so a single-pass advance would + // fire only the first. Quiescence requires re-scanning until nothing more fires. + lateinit var task: () -> Unit + task = { + rescheduleRuns++ + if (rescheduleRuns < 3) { + timer.schedule(object : java.util.TimerTask() { + override fun run() = task() + }, 0L) + } + } + timer.schedule(object : java.util.TimerTask() { + override fun run() { + task() + // Also create a BRAND-NEW timer mid-advance whose task is due within the interval — it + // must be picked up by a later re-scan round (advance snapshots timers each round). + val laterTimer = fakeClock.newTimer("cascade-created-mid-advance") + laterTimer.schedule(object : java.util.TimerTask() { + override fun run() { newTimerTaskRan = true } + }, 0L) + } + }, 10L) + + // ONE advance across the 10ms due point must run the initial task, both self-reschedules, and + // the task on the timer created mid-advance. + fakeClock.advance(10L) + + assertEquals(3, rescheduleRuns, "zero-delay reschedules should all fire within one advance") + assertTrue(newTimerTaskRan, "a timer created mid-advance and due in-interval should fire in the same advance") + } + + /** A CONNECTED with a short connectionStateTtl so a subsequent disconnect suspends quickly. */ + private fun shortLivedConnected(): ProtocolMessage = ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "reconnected-id" + connectionDetails = ConnectionDetails { + connectionKey = "reconnected-key" + connectionStateTtl = 800L + maxIdleInterval = 15_000L + } + } +} 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 8a5e7b0a0..000000000 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt +++ /dev/null @@ -1,312 +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 { - // DEVIATION (RTLC9h / RTLO4b4c1): spec says a COUNTER_INC whose `counterInc.number` does not exist - // returns a noop (`update.noop == true`), so the listener must NOT fire — expected updates == 2. - // ably-java's `WireCounterInc.number` is a non-nullable Double (absent -> 0.0, indistinguishable - // from `number: 0`), and `LiveCounterManager.applyCounterInc` always returns a CounterUpdate - // (RTLC9g) and notifies; there is no missing-number noop branch on the operation path. So the - // missing-number "02" op fires an amount-0 event and the listener is invoked a 3rd time - // (updates == 3). Spec-correct assertion gated behind RUN_DEVIATIONS. See deviations.md. - if (System.getenv("RUN_DEVIATIONS") == null) return@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. - } -}