From a77f75a2ee794d60b112cce0ce47e9ecd349647e Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Wed, 13 May 2026 11:12:04 +0100 Subject: [PATCH 01/16] Add UTS test specs for LiveObjects path-based API (~330 tests) Complete portable test suite covering the LiveObjects path-based API: 21 files across unit tests (pure + mock WebSocket), integration tests (sandbox), and proxy integration tests. Covers PathObject, Instance, BatchContext, LiveCounter/LiveMap CRDTs, ObjectsPool sync state machine, value types, subscriptions, and GC. Includes table-driven validation tests, bytes/binary data coverage, and REST fixture provisioning. Co-Authored-By: Claude Opus 4.6 --- uts/objects/PLAN.md | 379 +++++++ uts/objects/helpers/standard_test_pool.md | 322 ++++++ uts/objects/integration/objects_batch_test.md | 201 ++++ uts/objects/integration/objects_gc_test.md | 138 +++ .../integration/objects_lifecycle_test.md | 317 ++++++ uts/objects/integration/objects_sync_test.md | 200 ++++ .../integration/proxy/objects_faults.md | 459 ++++++++ uts/objects/unit/batch.md | 782 ++++++++++++++ uts/objects/unit/instance.md | 524 ++++++++++ uts/objects/unit/live_counter.md | 824 +++++++++++++++ uts/objects/unit/live_counter_api.md | 343 ++++++ uts/objects/unit/live_map.md | 980 ++++++++++++++++++ uts/objects/unit/live_map_api.md | 483 +++++++++ uts/objects/unit/live_object_subscribe.md | 244 +++++ uts/objects/unit/object_id.md | 159 +++ uts/objects/unit/objects_pool.md | 910 ++++++++++++++++ uts/objects/unit/path_object.md | 603 +++++++++++ uts/objects/unit/path_object_mutations.md | 321 ++++++ uts/objects/unit/path_object_subscribe.md | 618 +++++++++++ uts/objects/unit/realtime_object.md | 927 +++++++++++++++++ uts/objects/unit/value_types.md | 451 ++++++++ 21 files changed, 10185 insertions(+) create mode 100644 uts/objects/PLAN.md create mode 100644 uts/objects/helpers/standard_test_pool.md create mode 100644 uts/objects/integration/objects_batch_test.md create mode 100644 uts/objects/integration/objects_gc_test.md create mode 100644 uts/objects/integration/objects_lifecycle_test.md create mode 100644 uts/objects/integration/objects_sync_test.md create mode 100644 uts/objects/integration/proxy/objects_faults.md create mode 100644 uts/objects/unit/batch.md create mode 100644 uts/objects/unit/instance.md create mode 100644 uts/objects/unit/live_counter.md create mode 100644 uts/objects/unit/live_counter_api.md create mode 100644 uts/objects/unit/live_map.md create mode 100644 uts/objects/unit/live_map_api.md create mode 100644 uts/objects/unit/live_object_subscribe.md create mode 100644 uts/objects/unit/object_id.md create mode 100644 uts/objects/unit/objects_pool.md create mode 100644 uts/objects/unit/path_object.md create mode 100644 uts/objects/unit/path_object_mutations.md create mode 100644 uts/objects/unit/path_object_subscribe.md create mode 100644 uts/objects/unit/realtime_object.md create mode 100644 uts/objects/unit/value_types.md diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md new file mode 100644 index 000000000..3cc547856 --- /dev/null +++ b/uts/objects/PLAN.md @@ -0,0 +1,379 @@ +# UTS Test Specs for LiveObjects Path-Based API + +## Context + +The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — specifically the path-based API version on branch `origin/AIT-30/liveobjects-path-based-api-spec` (with batch API additions on `origin/AIT-30/liveobjects-batch-api`). + +An earlier attempt at UTS test specs exists in `uts/test/realtime/unit/objects/` (14 files). It was written against a different spec namespace (PO* vs RTPO*/RTINS*/RTLCV*/RTLMV*), used v5 wire format field names, had apply-on-ACK contradictions, and duplicated setup across files. We're doing a clean rewrite using the correct spec, informed by that earlier work. + +All new test files go in `specification/uts/objects/`. + +## Spec Architecture Summary + +**Internal (not user-facing):** LiveObject, LiveCounter (CRDT counter), LiveMap (LWW map), ObjectsPool (sync state machine), RealtimeObject (channel orchestrator with publishAndApply) + +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), BatchContext (atomic multi-op publish) + +**Wire protocol v6:** `counterInc.number`, `mapSet.{key,value}`, `mapRemove.key`, `mapCreate.{semantics,entries}`, `counterCreateWithObjectId.{nonce,initialValue}`, `mapCreateWithObjectId.{nonce,initialValue}` + +**REST API:** Not specified in objects-features.md. ably-js has REST object tests but those are implementation-specific, not spec'd. No REST test files needed. + +--- + +## File Organization + +### Helper +| File | Purpose | +|------|---------| +| `helpers/standard_test_pool.md` | Shared: standard ObjectsPool fixture, protocol message builders, synced-channel setup pattern | + +### Pure Unit Tests (no mocks) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~28 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~42 | +| `unit/objects_pool.md` | RTO3-9 | ~35 | +| `unit/object_id.md` | RTO14 | ~5 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (consumption generates ObjectMessages with v6 wire format) | ~19 | + +### Mock WebSocket Unit Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-24 (sync events, publish, publishAndApply, mode checks, GC) | ~33 | +| `unit/live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | +| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24 (reads + mutations through channel, echoMessages check) | ~18 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4c (subscribe/unsubscribe on internal LiveObject) | ~8 | +| `unit/path_object.md` | RTPO1-14 (navigation, value, instance, entries, compact, compactJson) | ~33 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2 (set, remove, increment, decrement, error on unresolvable path) | ~12 | +| `unit/path_object_subscribe.md` | RTPO19-21, RTO24 (path subscriptions, depth filtering, path-following semantics, subscribeIterator) | ~20 | +| `unit/instance.md` | RTINS1-18 (id, value, get, entries, size, compact, set, remove, increment, subscribe) | ~26 | +| `unit/batch.md` | RTPO22, RTINS19, RTBC1-16 (batch entry, BatchContext methods, RootBatchContext flush/close) | ~20 | + +### Integration Tests (sandbox) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/objects_lifecycle_test.md` | RTO23, RTPO15, RTPO17 (create objects, mutate via PathObject, read back, REST provisioning) | ~6 | +| `integration/objects_sync_test.md` | RTO4, RTO5, RTO17 (attach, sync sequence, re-attach) | ~4 | +| `integration/objects_batch_test.md` | RTPO22, RTBC12-15 (batch publish, atomic delivery) | ~3 | +| `integration/objects_gc_test.md` | RTO10, RTLM19 (behavioral GC verification with ADVANCE_TIME) | ~2 | + +### Proxy Integration Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/proxy/objects_faults.md` | RTO5a2, RTO7, RTO8, RTO17, RTO20e (sync interruption, mutation buffering during re-sync, server-initiated detach, publish failure on FAILED channel, publish during delayed sync) | ~5 | + +**Totals: ~21 files, ~330 tests** + +--- + +## Helper Spec Design + +### `helpers/standard_test_pool.md` + +**Standard test tree:** +``` +root (LiveMap, objectId: "root") + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap) + +-- "theme" -> string "dark" +``` + +**Builder functions:** +- `build_object_sync_message(channel, channelSerial, objectMessages[])` -> OBJECT_SYNC ProtocolMessage +- `build_object_message(channel, objectMessages[])` -> OBJECT ProtocolMessage +- `build_ack_message(msgSerial, serials[])` -> ACK ProtocolMessage with `res: [{ serials }]` +- `build_counter_inc(objectId, number, serial, siteCode)` -> ObjectMessage +- `build_map_set(objectId, key, value, serial, siteCode)` -> ObjectMessage +- `build_map_remove(objectId, key, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_map_clear(objectId, serial, siteCode)` -> ObjectMessage +- `build_object_delete(objectId, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_counter_create(objectId, counterCreate, serial, siteCode)` -> ObjectMessage +- `build_map_create(objectId, mapCreate, serial, siteCode)` -> ObjectMessage +- `build_object_state(objectId, siteTimeserials, {map?, counter?, tombstone?, createOp?})` -> ObjectMessage wrapping ObjectState + +**Standard synced-channel pattern** (referenced by all mock-WS test files): +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, + channelSerial: "attach-serial-1", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + // Auto-ACK with generated serials + serials = msg.state.map((_, i) => "ack-serial-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + client = Realtime(options: {key: "fake:key", autoConnect: true}) + channel = client.channels.get(channel_name, {modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"]}) + root = AWAIT channel.object.get() + RETURN {client, channel, root, mock_ws} +``` + +--- + +## Pure Unit Test Design + +### `unit/live_counter.md` -- CRDT Counter Data Structure + +Directly construct `LiveCounter`, call `applyOperation()` and `replaceData()`, assert internal state. + +**Key test groups:** +1. **Zero value (RTLC4):** data=0, siteTimeserials={}, createOperationIsMerged=false, isTombstone=false +2. **COUNTER_INC (RTLC9):** adds `counterInc.number` to data; noop when number missing +3. **COUNTER_CREATE (RTLC8/RTLC16):** merges `counterCreate.count`; noop when already merged +4. **Newness check (RTLO4a):** empty siteSerial allows apply; stale serial rejected; empty serial/siteCode logs warning +5. **siteTimeserials (RTLC7c):** CHANNEL source updates map; LOCAL source does not +6. **applyOperation returns bool (RTLC7g):** true on success, false on rejection/tombstone +7. **Tombstone (RTLC7e, RTLO4e, RTLO5):** OBJECT_DELETE tombstones; ops on tombstoned counter rejected +8. **replaceData (RTLC6):** full replacement; tombstone handling; createOp merge; diff calculation +9. **tombstonedAt (RTLO6):** from serialTimestamp if present, else local clock + +### `unit/live_map.md` -- LWW Map Data Structure + +Same pattern. Key additional concerns: + +1. **MAP_SET (RTLM7):** new entry, existing entry update, LWW rejection, clearTimeserial floor (RTLM7h), objectId creates zero-value object (RTLM7g) +2. **MAP_REMOVE (RTLM8):** tombstones entry, sets tombstonedAt via RTLO6, clearTimeserial floor (RTLM8g) +3. **MAP_CLEAR (RTLM24):** sets clearTimeserial, removes entries with serial <= clear serial, preserves newer entries +4. **Entry-level LWW (RTLM9):** 5 serial comparison cases +5. **MAP_CREATE (RTLM16/RTLM23):** merges entries via individual MAP_SET/MAP_REMOVE calls +6. **replaceData (RTLM6):** sets clearTimeserial from ObjectState.map.clearTimeserial (RTLM6i) +7. **get/size/entries (RTLM5/RTLM10/RTLM11):** value resolution, tombstone filtering, objectId reference resolution +8. **GC (RTLM19):** removes tombstoned entries past grace period +9. **Diff (RTLM22):** non-tombstoned entry comparison + +### `unit/objects_pool.md` -- Pool + Sync State Machine + +Directly construct ObjectsPool, call `processAttached()`, `processObjectSync()`, `processObjectMessage()`. + +1. **Initialization (RTO3):** root LiveMap always present +2. **ATTACHED handling (RTO4):** HAS_OBJECTS -> SYNCING; no flag -> clear pool + immediate SYNCED +3. **OBJECT_SYNC sequence (RTO5/RTO5f):** accumulate in SyncObjectsPool; partial merge (RTO5f2a); cursor parsing; new sequence discards old (RTO5a2) +4. **Sync completion (RTO5c):** replace existing (RTO5c1a), create new (RTO5c1b), remove absent (RTO5c2), emit updates (RTO5c7), apply buffered ops (RTO5c6), clear appliedOnAckSerials (RTO5c9), transition to SYNCED (RTO5c8) +5. **Buffering (RTO7/RTO8):** OBJECT messages buffered during SYNCING, applied when SYNCED +6. **Operation application (RTO9):** appliedOnAckSerials dedup (RTO9a3), LOCAL source adds to set (RTO9a2a4), null op warning (RTO9a1), unsupported action warning (RTO9a2b) +7. **Zero-value creation (RTO6):** infer type from objectId prefix +8. **GC (RTO10):** tombstoned objects removed after grace period + +### `unit/object_id.md` -- ObjectId Generation (RTO14) + +Pure function tests: +1. Format: `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}` +2. SHA-256 of UTF-8 `{initialValue}:{nonce}` -> base64url (RFC 4648 s.5) +3. `map` and `counter` type prefixes +4. Deterministic: same inputs -> same objectId +5. Different nonce -> different objectId + +### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType + +Tests the static `create()` factories and consumption procedure. + +**LiveCounterValueType (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +2. `LiveCounter.create()` -> count defaults to 0 +3. Consumption: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during consumption + +**LiveMapValueType (RTLMV1-4):** +1. `LiveMap.create({entries})` -> immutable LiveMapValueType +2. Consumption: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` +3. Nested value types: LiveMapValueType containing LiveCounterValueType -> depth-first ObjectMessage array (inner creates before outer) +4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) + +--- + +## Mock WebSocket Test Design + +### `unit/realtime_object.md` -- Orchestration + +Uses `setup_synced_channel()` from helper. + +**Key tests:** +- **RTO23:** get() requires OBJECT_SUBSCRIBE, throws on DETACHED/FAILED, waits for SYNCED, returns PathObject +- **RTO2:** channel mode enforcement (granted vs requested modes) +- **RTO15/RTO15h:** publish sends OBJECT PM, returns PublishResult from ACK res array +- **RTO20:** publishAndApply: publishes, constructs synthetic messages with siteCode from ConnectionDetails, applies with source=LOCAL, adds to appliedOnAckSerials +- **RTO20c:** fails gracefully when siteCode or serials missing +- **RTO20d1:** null serial in PublishResult (conflated op) is skipped +- **RTO20e:** waits for SYNCED during SYNCING; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED +- **RTO17/RTO18/RTO19:** sync state events, on/off registration +- **RTO10:** GC with fake timers + ADVANCE_TIME + +### `unit/path_object.md` -- Read Operations + +- **RTPO4:** path() string representation with dot escaping +- **RTPO5/RTPO6:** get(key) / at("a.b.c") -- pure navigation, no resolution +- **RTPO7:** value() -- counter returns number, primitive returns value, LiveMap returns null, unresolvable returns null +- **RTPO8:** instance() -- LiveObject returns Instance, primitive returns null +- **RTPO9-11:** entries/keys/values -- yields [key, PathObject] pairs for LiveMap entries +- **RTPO12:** size() -- non-tombstoned entry count +- **RTPO13:** compact() -- recursive, cycle detection with shared object references +- **RTPO14:** compactJson() -- binary as base64, cycles as {objectId: ...} +- **RTPO3:** path resolution (RTPO3a): walk segments through LiveMaps; fail if intermediate not LiveMap + +### `unit/path_object_mutations.md` -- Write Operations + +- **RTPO15:** set(value) -- constructs ObjectMessages, calls publishAndApply +- **RTPO16:** remove() -- constructs MAP_REMOVE ObjectMessage +- **RTPO17:** increment(n) -- constructs COUNTER_INC ObjectMessage +- **RTPO18:** decrement(n) -- delegates to increment(-n) +- **RTPO3c2:** mutation on unresolvable path throws 92007 + +### `unit/path_object_subscribe.md` -- Path-Based Subscriptions + +- **RTPO19:** subscribe returns Subscription, listener receives PathObjectSubscriptionEvent +- **RTPO19b1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19b1d:** non-positive depth throws 40003 +- **RTPO19e:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTPO19f:** child events bubble up to parent subscription +- **RTO24b3:** depth formula: `eventPath.length - subscriptionPath.length + 1 <= depth` +- **RTO24b5:** listener exception caught, doesn't affect other listeners +- **RTPO20:** unsubscribe deregisters + +### `unit/instance.md` -- Identity-Bound Reference + +- **RTINS1:** id property returns objectId +- **RTINS2:** value() -- counter returns number, map returns null +- **RTINS3-5:** get(key), entries(), keys(), values() -- delegate to underlying LiveMap +- **RTINS6:** size() -- non-tombstoned entry count +- **RTINS7:** compact() -- recursive with cycle detection +- **RTINS8:** compactJson() +- **RTINS9-12:** set, remove, increment, decrement -- construct ObjectMessages, call publishAndApply +- **RTINS13-16:** subscribe/unsubscribe with depth filtering +- **RTINS17:** instance follows identity not path -- object replacement at path doesn't affect Instance +- **RTINS18:** operations on tombstoned Instance throw error + +### `unit/live_counter_api.md` -- Counter Through Channel + +- **RTLC5:** value property returns current data +- **RTLC11/RTLC12:** increment/decrement construct correct v6 wire ObjectMessage +- **RTLC12d:** echoMessages=false skips publishAndApply, uses publish +- **RTLC13:** increment with non-number throws 40003 + +### `unit/live_map_api.md` -- Map Through Channel + +- **RTLM5:** get(key) returns resolved value +- **RTLM10/RTLM11:** entries/keys/values iterate non-tombstoned entries +- **RTLM12/RTLM13:** set/remove construct correct v6 wire ObjectMessages +- **RTLM20:** set with LiveCounterValueType/LiveMapValueType consumes value type +- **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply +- **RTLM24:** clear constructs MAP_CLEAR ObjectMessage + +### `unit/live_object_subscribe.md` -- Internal Subscription + +- **RTLO4b:** subscribe(listener) registers on internal LiveObject +- **RTLO4c:** unsubscribe removes listener +- Events fire on applyOperation with update details + +### `unit/batch.md` -- Batch API + +- **RTPO22/RTINS19:** batch entry points -- resolve to LiveObject, create RootBatchContext, execute fn, flush +- **RTPO22c/RTINS19c:** unresolvable path / non-LiveObject throws 92007 +- **RTBC3-11:** read methods delegate to Instance (id, value, get, entries, keys, values, size, compact, compactJson) +- **RTBC4d:** get() wraps result via RootBatchContext#wrapInstance (memoized by objectId -- RTBC16c) +- **RTBC12-15:** write methods (set, remove, increment, decrement) queue message constructors synchronously +- **RTBC16d:** flush executes constructors, publishes all as single array via RTO15 (NOT publishAndApply) +- **RTBC16e:** closed batch throws 40000 on any method call +- **RTBC16f:** RootBatchContext closed after flush regardless of success/failure + +--- + +## Apply-on-ACK Testing Strategy + +The RTO20 publishAndApply flow: +1. Client publishes OBJECT PM +2. Server returns ACK with `res: [{ serials: [...] }]` +3. Client constructs synthetic inbound ObjectMessages (serial + siteCode from ConnectionDetails) +4. Applies via RTO9 with source=LOCAL -> adds serials to `appliedOnAckSerials` +5. When echoed OBJECT PM arrives with same serial -> RTO9a3 deduplicates and removes from set + +**Mock WS handler for mutation tests:** +```pseudo +onMessageFromClient: (msg) => { + IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length-1: + serials.append("ack-" + msg.msgSerial + "-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) +} +``` + +**Tests verify:** +1. After `AWAIT pathObject.set(...)`, local state reflects the change +2. The correct OBJECT PM was sent (v6 wire format) +3. When echo arrives with same serial, no double-application +4. If ACK arrives during SYNCING (RTO20e), publishAndApply waits for SYNCED + +--- + +## Dependency Ordering (write order) + +1. `helpers/standard_test_pool.md` +2. `unit/live_counter.md` -- no dependencies +3. `unit/live_map.md` -- no dependencies +4. `unit/object_id.md` -- no dependencies +5. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +6. `unit/value_types.md` -- uses objectId generation +7. `unit/realtime_object.md` -- uses helper, tests orchestration +8. `unit/live_counter_api.md` -- uses helper +9. `unit/live_map_api.md` -- uses helper +10. `unit/live_object_subscribe.md` -- uses helper +11. `unit/path_object.md` -- uses helper +12. `unit/instance.md` -- uses helper +13. `unit/path_object_mutations.md` -- uses helper +14. `unit/path_object_subscribe.md` -- uses helper +15. `unit/batch.md` -- uses helper, depends on PathObject/Instance concepts +16. `integration/objects_lifecycle_test.md` +17. `integration/objects_sync_test.md` +18. `integration/objects_batch_test.md` +19. `integration/objects_gc_test.md` +20. `integration/proxy/objects_faults.md` + +--- + +## Key Decisions + +| Decision | Rationale | +|----------|-----------| +| Wire format v6 everywhere | Spec branch uses v6 field names; old v5 names are "replaced by" stubs | +| `appliedOnAckSerials` on RealtimeObject (RTO7b), not on pool | Matches spec's placement; cleared at sync completion (RTO5c9) | +| No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | +| `echoMessages` check retained on mutations | Spec retains RTLC12d, RTLM20d, RTLM21d | +| Batch uses RTO15 (publish), NOT RTO20 (publishAndApply) | RTBC16d says "publishes ... using `RealtimeObject#publish`" -- batch does NOT apply locally on ACK | +| LiveObject/LiveMap/LiveCounter marked internal but still unit-tested | Direct testing of CRDT logic is essential; public API tests can't cover all edge cases | +| Test IDs use `objects/unit/` prefix | Matches directory structure, not nested under `realtime/` | +| Behavioral GC testing via ADVANCE_TIME | Verify GC through observable consequences (value becomes null, object recreatable) rather than internal pool state inspection | +| Table-driven tests for input validation | Use FOR loops over scenario arrays (like ably-js forScenarios) to test all invalid/valid type combinations | +| Bytes data type coverage | Standard test pool includes "avatar" bytes entry; compact/compactJson/value tests verify base64 encoding | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md new file mode 100644 index 000000000..e01062903 --- /dev/null +++ b/uts/objects/helpers/standard_test_pool.md @@ -0,0 +1,322 @@ +# Standard Test Pool and Helpers + +Shared fixtures, protocol message builders, and synced-channel setup pattern for all LiveObjects test files. + +## Standard Test Tree + +The standard test pool defines a fixed LiveObjects tree used across test files. All object IDs use short synthetic values for clarity (real servers validate the hash format, but unit tests construct objects directly). + +``` +root (LiveMap, objectId: "root", semantics: LWW) + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap, semantics: LWW) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap, semantics: LWW) + +-- "theme" -> string "dark" +``` + +All map entries have timeserial `"t:0"` and `tombstone: false` unless otherwise noted. +All objects have `siteTimeserials: { "aaa": "t:0" }` and `createOperationIsMerged: true` unless otherwise noted. + +--- + +## STANDARD_POOL_OBJECTS + +An array of `ObjectMessage` instances wrapping `ObjectState` for building OBJECT_SYNC messages. Each object is represented as `build_object_state(...)` using the builders below. + +```pseudo +STANDARD_POOL_OBJECTS = [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "t:0" }, + "age": { data: { number: 30 }, timeserial: "t:0" }, + "active": { data: { boolean: true }, timeserial: "t:0" }, + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "data": { data: { json: {"tags": ["a", "b"]} }, timeserial: "t:0" }, + "avatar": { data: { bytes: "AQID" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "email": { data: { string: "alice@example.com" }, timeserial: "t:0" }, + "nested_counter": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" }, + "prefs": { data: { objectId: "map:prefs@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }), + build_object_state("map:prefs@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "theme": { data: { string: "dark" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +] +``` + +--- + +## Builder Functions + +### Protocol Message Builders + +```pseudo +build_object_sync_message(channel, channelSerial, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT_SYNC, + channel: channel, + channelSerial: channelSerial, + state: objectMessages + ) + +build_object_message(channel, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT, + channel: channel, + state: objectMessages + ) + +build_ack_message(msgSerial, serials[]): + RETURN ProtocolMessage( + action: ACK, + msgSerial: msgSerial, + res: [{ serials: serials }] + ) +``` + +### ObjectMessage Builders (Operations) + +```pseudo +build_counter_inc(objectId, number, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_INC", + objectId: objectId, + counterInc: { number: number } + } + ) + +build_map_set(objectId, key, value, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_SET", + objectId: objectId, + mapSet: { key: key, value: value } + } + ) + +build_map_remove(objectId, key, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "MAP_REMOVE", + objectId: objectId, + mapRemove: { key: key } + } + ) + +build_map_clear(objectId, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CLEAR", + objectId: objectId + } + ) + +build_object_delete(objectId, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "OBJECT_DELETE", + objectId: objectId + } + ) + +build_counter_create(objectId, counterCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_CREATE", + objectId: objectId, + counterCreate: counterCreate + } + ) + +build_map_create(objectId, mapCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CREATE", + objectId: objectId, + mapCreate: mapCreate + } + ) +``` + +### ObjectMessage Builder (State — for OBJECT_SYNC) + +```pseudo +build_object_state(objectId, siteTimeserials, opts): + state = { + objectId: objectId, + siteTimeserials: siteTimeserials + } + IF opts.map IS NOT null: + state.map = opts.map + IF opts.counter IS NOT null: + state.counter = opts.counter + IF opts.tombstone IS NOT null: + state.tombstone = opts.tombstone + IF opts.createOp IS NOT null: + state.createOp = opts.createOp + RETURN ObjectMessage(object: state) +``` + +--- + +## Standard Synced-Channel Setup + +Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. + +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length - 1: + serials.append("ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +### Variant: Setup Without Auto-ACK + +For tests that need to control ACK timing, use this variant that omits the OBJECT message handler: + +```pseudo +setup_synced_channel_no_ack(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +--- + +## REST Fixture Provisioning + +For integration tests that need pre-existing object state before the test client connects, use the REST API to establish fixtures. + +```pseudo +provision_objects_via_rest(api_key, channel_name, operations): + POST https://sandbox-rest.ably.io/channels/{encode_uri_component(channel_name)}/objects + WITH Authorization: Basic {base64(api_key)} + WITH Content-Type: application/json + WITH body: { "messages": operations } +``` diff --git a/uts/objects/integration/objects_batch_test.md b/uts/objects/integration/objects_batch_test.md new file mode 100644 index 000000000..a5805482a --- /dev/null +++ b/uts/objects/integration/objects_batch_test.md @@ -0,0 +1,201 @@ +# Objects Batch Integration Tests + +Spec points: `RTPO22`, `RTBC12`–`RTBC15` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Batch operations end-to-end — multiple mutations in a single publish, atomic +propagation to subscribers. Verifies that batch() groups multiple operations +into a single ProtocolMessage and the server processes and delivers them +correctly to other clients. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTPO22 - Batch set of multiple keys arrives to second client + +**Test ID**: `objects/integration/RTPO22/batch-set-propagates-0` + +**Spec requirement:** batch() groups multiple mutations into a single publish. +All operations are delivered together to subscribers. + +### Setup +```pseudo +channel_name = "objects-batch-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("x", 1) + ctx.set("y", 2) + ctx.set("z", 3) +}) + +poll_until(root_b.get("x").value() == 1, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("x").value() == 1 +ASSERT root_b.get("y").value() == 2 +ASSERT root_b.get("z").value() == 3 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with mixed operations (set + remove + increment) + +**Test ID**: `objects/integration/RTPO22/batch-mixed-ops-0` + +**Spec requirement:** Batch can contain different operation types published atomically. + +### Setup +```pseudo +channel_name = "objects-batch-mixed-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Set up initial state +AWAIT root_a.set("to_remove", "temp") +AWAIT root_a.set("counter", LiveCounter.create(10)) +poll_until(root_b.get("to_remove").value() == "temp", timeout: 10s) +poll_until(root_b.get("counter").value() == 10, timeout: 10s) + +// Batch with mixed operations +AWAIT root_a.batch((ctx) => { + ctx.set("name", "Alice") + ctx.remove("to_remove") + child = ctx.get("counter") + child.increment(5) +}) + +poll_until(root_b.get("name").value() == "Alice", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("name").value() == "Alice" +ASSERT root_b.get("to_remove").value() == null +ASSERT root_b.get("counter").value() == 15 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with LiveCounterValueType creates counter atomically + +**Test ID**: `objects/integration/RTPO22/batch-create-counter-0` + +**Spec requirement:** Batch containing LiveCounterValueType generates COUNTER_CREATE + +MAP_SET in a single publish. The server processes both atomically. + +### Setup +```pseudo +channel_name = "objects-batch-counter-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("batch_counter", LiveCounter.create(99)) + ctx.set("label", "created in batch") +}) + +poll_until(root_b.get("batch_counter").value() == 99, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("batch_counter").value() == 99 +ASSERT root_b.get("label").value() == "created in batch" +ASSERT root_b.get("batch_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` diff --git a/uts/objects/integration/objects_gc_test.md b/uts/objects/integration/objects_gc_test.md new file mode 100644 index 000000000..2d9bc86a2 --- /dev/null +++ b/uts/objects/integration/objects_gc_test.md @@ -0,0 +1,138 @@ +# Objects GC Integration Tests + +Spec points: `RTO10`, `RTLM19` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Behavioral verification of garbage collection for tombstoned objects and tombstoned +map entries. Uses `ADVANCE_TIME` (fake timers) to control timing and verifies GC +through observable API consequences rather than internal pool state inspection. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- These tests use fake timers to control GC timing +- Each test uses a unique channel name + +--- + +## RTO10 - Tombstoned object is GC'd and recreatable + +**Test ID**: `objects/integration/RTO10/tombstoned-object-gc-recreate-0` + +**Spec requirement:** After an object is tombstoned and the GC grace period elapses, +the object is removed from the pool. A new object can then be created at the same +map key. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-object-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Create a counter +AWAIT root.set("counter", LiveCounter.create(42)) +ASSERT root.get("counter").value() == 42 +counter_id = root.get("counter").instance().id() + +// Remove it (tombstones the entry and the object) +AWAIT root.remove("counter") +ASSERT root.get("counter").value() == null + +// Advance past GC grace period +ADVANCE_TIME(86400000 + 300000) + +// Create a new counter at the same key +AWAIT root.set("counter", LiveCounter.create(99)) +``` + +### Assertions +```pseudo +ASSERT root.get("counter").value() == 99 +new_counter_id = root.get("counter").instance().id() +ASSERT new_counter_id != counter_id +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTLM19 - Tombstoned map entry is GC'd, re-settable with old serial + +**Test ID**: `objects/integration/RTLM19/tombstoned-entry-gc-reset-0` + +**Spec requirement:** After a map entry is tombstoned and GC'd, the entry is fully +removed. A subsequent MAP_SET with any serial succeeds because there is no existing +entry to compare against. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-entry-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set then remove a key +AWAIT root.set("ephemeral", "temporary") +ASSERT root.get("ephemeral").value() == "temporary" + +AWAIT root.remove("ephemeral") +ASSERT root.get("ephemeral").value() == null + +// Advance past GC grace period for entries +ADVANCE_TIME(86400000 + 300000) + +// Set the same key again +AWAIT root.set("ephemeral", "revived") +``` + +### Assertions +```pseudo +ASSERT root.get("ephemeral").value() == "revived" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md new file mode 100644 index 000000000..9c440f512 --- /dev/null +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -0,0 +1,317 @@ +# Objects Lifecycle Integration Tests + +Spec points: `RTO23`, `RTPO15`, `RTPO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end lifecycle: connect, sync, create objects via PathObject, mutate, and +verify propagation to a second client. Complements unit tests by verifying real +server sync, mutation delivery, and object creation. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name to avoid interference + +--- + +## RTO23, RTPO15 - Set primitive via PathObject, second client reads it + +**Test ID**: `objects/integration/RTO23-RTPO15/set-primitive-propagates-0` + +**Spec requirement:** PathObject#set delegates to LiveMap#set. The mutation +propagates via the server and a second client sees the updated value. + +### Setup +```pseudo +channel_name = "objects-lifecycle-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Client A sets a value +AWAIT root_a.set("greeting", "hello") + +// Client B subscribes and waits for the update +events_b = [] +root_b.subscribe((event) => events_b.append(event)) +poll_until(root_b.get("greeting").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("greeting").value() == "hello" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveCounterValueType, second client reads counter + +**Test ID**: `objects/integration/RTPO15/set-counter-value-type-0` + +**Spec requirement:** PathObject#set with LiveCounterValueType creates a new counter +on the server. Second client syncs and reads the counter value. + +### Setup +```pseudo +channel_name = "objects-counter-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("my_counter", LiveCounter.create(42)) +poll_until(root_b.get("my_counter").value() == 42, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("my_counter").value() == 42 +ASSERT root_b.get("my_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO17 - Increment counter, second client sees updated value + +**Test ID**: `objects/integration/RTPO17/increment-propagates-0` + +**Spec requirement:** PathObject#increment delegates to LiveCounter#increment. +The server applies the increment and propagates the updated value. + +### Setup +```pseudo +channel_name = "objects-increment-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Create a counter first +AWAIT root_a.set("hits", LiveCounter.create(0)) +poll_until(root_b.get("hits").value() == 0, timeout: 10s) + +// Increment it +AWAIT root_a.get("hits").increment(10) +poll_until(root_b.get("hits").value() == 10, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_a.get("hits").value() == 10 +ASSERT root_b.get("hits").value() == 10 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveMapValueType, second client reads nested map + +**Test ID**: `objects/integration/RTPO15/set-map-value-type-0` + +**Spec requirement:** PathObject#set with LiveMapValueType creates a nested map. +Second client can navigate into the nested map. + +### Setup +```pseudo +channel_name = "objects-map-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("settings", LiveMap.create({ + "theme": "dark", + "fontSize": 14 +})) +poll_until(root_b.get("settings").get("theme").value() == "dark", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("settings").get("theme").value() == "dark" +ASSERT root_b.get("settings").get("fontSize").value() == 14 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO23 - get() waits for sync and returns PathObject + +**Test ID**: `objects/integration/RTO23/get-returns-path-object-0` + +**Spec requirement:** channel.object.get() returns a PathObject pointing to the root +after the sync sequence completes. + +### Setup +```pseudo +channel_name = "objects-get-root-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTPO15 - Client syncs pre-existing data provisioned via REST + +**Test ID**: `objects/integration/RTPO15/rest-provisioned-data-sync-0` + +**Spec requirement:** Data created via the REST API is visible to a realtime client +that connects afterward. + +### Setup +```pseudo +channel_name = "objects-rest-provision-" + random_id() + +// Provision data via REST before any realtime client connects +provision_objects_via_rest(api_key, channel_name, [ + { + operation: { + action: "MAP_SET", + objectId: "root", + mapSet: { key: "provisioned", value: { string: "from_rest" } } + } + } +]) +``` + +### Test Steps +```pseudo +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root.get("provisioned").value() == "from_rest" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md new file mode 100644 index 000000000..7f0721ec2 --- /dev/null +++ b/uts/objects/integration/objects_sync_test.md @@ -0,0 +1,200 @@ +# Objects Sync Integration Tests + +Spec points: `RTO4`, `RTO5`, `RTO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Verify the sync sequence against the real server: attach with HAS_OBJECTS, +receive OBJECT_SYNC, reach SYNCED state. Also tests re-attach behaviour where +the client detaches and re-attaches to verify the pool is re-synced. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTO4, RTO5 - Attach triggers sync, get() resolves after SYNCED + +**Test ID**: `objects/integration/RTO4-RTO5/attach-sync-get-0` + +**Spec requirement:** On ATTACHED with HAS_OBJECTS flag, client transitions to SYNCING, +processes OBJECT_SYNC messages, then transitions to SYNCED. get() waits for SYNCED. + +### Setup +```pseudo +channel_name = "objects-sync-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO5, RTO17 - Two clients sync same channel with pre-existing data + +**Test ID**: `objects/integration/RTO5-RTO17/two-clients-sync-0` + +**Spec requirement:** Both clients complete sync and see the same object pool state. + +### Setup +```pseudo +channel_name = "objects-two-sync-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +// Client A creates data +root_a = AWAIT channel_a.object.get() +AWAIT root_a.set("key1", "value1") + +// Client B attaches and syncs — should see the data +root_b = AWAIT channel_b.object.get() +poll_until(root_b.get("key1").value() == "value1", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("key1").value() == "value1" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO17 - Re-attach re-syncs object pool + +**Test ID**: `objects/integration/RTO17/reattach-resyncs-0` + +**Spec requirement:** On re-attach, the sync state machine restarts and the pool +is re-populated from the server. + +### Setup +```pseudo +channel_name = "objects-reattach-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Detach and re-attach +AWAIT channel.detach() +AWAIT channel.attach() + +// Re-sync should restore data +root = AWAIT channel.object.get() +poll_until(root.get("before_detach").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO4 - Attach without OBJECT_SUBSCRIBE still resolves get() with empty pool + +**Test ID**: `objects/integration/RTO4/attach-subscribe-only-0` + +**Spec requirement:** Channel attached with only OBJECT_SUBSCRIBE mode. Server +sends HAS_OBJECTS, sync completes, root is an empty LiveMap. + +### Setup +```pseudo +channel_name = "objects-subscribe-only-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md new file mode 100644 index 000000000..24069b737 --- /dev/null +++ b/uts/objects/integration/proxy/objects_faults.md @@ -0,0 +1,459 @@ +# Objects Proxy Integration Tests + +Spec points: `RTO5a2`, `RTO7`, `RTO8`, `RTO17`, `RTO20e` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `objects/unit/objects_pool.md` — RTO5a2 (new sync discards old), RTO7/RTO8 (buffering during SYNCING) +- `objects/unit/realtime_object.md` — RTO17 (sync state events), RTO20e (publishAndApply waits for SYNCED/fails on FAILED) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +### Protocol Message Action Numbers (Objects-relevant) + +| Name | Number | +|------|--------| +| ATTACHED | 11 | +| DETACHED | 13 | +| OBJECT | 19 | +| OBJECT_SYNC | 20 | + +--- + +## RTO5a2, RTO17 - Sync interrupted by disconnect, re-syncs on reconnect + +**Test ID**: `objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2 | New sync sequence discards old SyncObjectsPool | +| RTO17 | Sync state transitions: SYNCING → SYNCED, re-triggered on re-attach | + +Tests that when the connection drops mid-OBJECT_SYNC, the client discards +partial sync state and re-syncs cleanly on reconnect. The proxy disconnects +after the first OBJECT_SYNC frame so the sync is never completed, then on +reconnect the client re-attaches and syncs fully. + +### Setup + +```pseudo +channel_name = "objects-sync-interrupt-" + random_id() + +// Disconnect after first OBJECT_SYNC frame +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "disconnect" }, + "times": 1, + "comment": "RTO5a2: Disconnect after first OBJECT_SYNC to interrupt sync" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +// First attach triggers sync; proxy disconnects mid-sync +channel.attach() +AWAIT_STATE client.connection.state == DISCONNECTED + WITH timeout: 15 seconds + +// Client auto-reconnects; re-attach triggers fresh sync +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 30 seconds + +// get() waits for SYNCED — will only resolve if re-sync completes +root = AWAIT channel.object.get() + WITH timeout: 30 seconds +``` + +### Assertions + +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO7, RTO8 - Mutations during re-sync are buffered and applied + +**Test ID**: `objects/proxy/RTO7-RTO8/mutations-buffered-during-resync-0` + +| Spec | Requirement | +|------|-------------| +| RTO7 | Buffer OBJECT messages during SYNCING | +| RTO8 | Apply buffered messages after sync completes | + +Client A publishes mutations while client B is re-syncing after reconnect. +The mutations should be buffered and applied after the sync completes. + +### Setup + +```pseudo +channel_name = "objects-buffer-resync-" + random_id() + +// Client A: direct connection (no proxy), publishes mutations +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set initial data +AWAIT root_a.set("key1", "initial") + +// Client B: through proxy, will be disconnected +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Client B connects and syncs +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds + +root_b = AWAIT channel_b.object.get() + WITH timeout: 15 seconds +poll_until(root_b.get("key1").value() == "initial", timeout: 10s) + +// Disconnect client B +session.trigger_action({ type: "disconnect" }) +AWAIT_STATE client_b.connection.state == DISCONNECTED + WITH timeout: 15 seconds + +// While B is disconnected, A publishes a mutation +AWAIT root_a.set("key1", "updated_during_disconnect") + +// Client B reconnects and re-syncs; the mutation should be visible +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 30 seconds + +root_b = AWAIT channel_b.object.get() + WITH timeout: 15 seconds +poll_until(root_b.get("key1").value() == "updated_during_disconnect", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("key1").value() == "updated_during_disconnect" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` + +--- + +## RTO17 - Server-initiated detach triggers re-sync on re-attach + +**Test ID**: `objects/proxy/RTO17/server-detach-resync-0` + +| Spec | Requirement | +|------|-------------| +| RTO17 | On re-attach, sync state machine restarts from INITIALIZED | + +The proxy injects a DETACHED message for the channel, simulating a server-initiated +detach. After the client automatically re-attaches, it must re-sync the object pool. + +### Setup + +```pseudo +channel_name = "objects-detach-resync-" + random_id() + +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Inject server-initiated DETACHED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 13, + channel: channel_name + } +}) + +// Client should auto-re-attach (RTL13a) +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 30 seconds + +// Re-sync should restore data +root = AWAIT channel.object.get() + WITH timeout: 15 seconds +poll_until(root.get("before_detach").value() == "hello", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +--- + +## RTO20e - publishAndApply fails when channel enters FAILED during SYNCING + +**Test ID**: `objects/proxy/RTO20e/publish-fails-on-channel-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO20e | publishAndApply waits for SYNCED; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED | + +Client sets up a channel with objects, then the proxy injects a channel ERROR +to transition to FAILED. A PathObject mutation (which uses publishAndApply +internally) should fail with error 92008. + +### Setup + +```pseudo +channel_name = "objects-publish-failed-" + random_id() + +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Inject channel ERROR to transition to FAILED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 9, + channel: channel_name, + error: { statusCode: 400, code: 90000, message: "injected error" } + } +}) + +AWAIT_STATE channel.state == ChannelState.failed + WITH timeout: 15 seconds + +// Attempt a mutation — should fail since channel is FAILED +AWAIT root.set("key", "value") FAILS WITH error +``` + +### Assertions + +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO5, RTO7 - Publish during sync, echo arrives after sync completes + +**Test ID**: `objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered OBJECT messages after sync completes | +| RTO7 | Buffer OBJECT messages during SYNCING | + +The proxy delays the OBJECT_SYNC completion so the client stays in SYNCING. +Client A publishes a mutation that arrives as an OBJECT message to client B +while B is still syncing. The mutation must be buffered and applied after +sync completes. + +### Setup + +```pseudo +channel_name = "objects-publish-during-sync-" + random_id() + +// Client A: direct, no proxy +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set up initial data +AWAIT root_a.set("existing", "before") + +// Client B: through proxy with delayed OBJECT_SYNC +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "delay", "delayMs": 3000 }, + "times": 1, + "comment": "Delay first OBJECT_SYNC to keep B in SYNCING state" + }] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Start client B — will be stuck in SYNCING due to delayed OBJECT_SYNC +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds +channel_b.attach() + +// While B is syncing, A publishes a mutation +AWAIT root_a.set("existing", "after") + +// B's get() will resolve once delayed sync completes +root_b = AWAIT channel_b.object.get() + WITH timeout: 30 seconds + +// The mutation from A should be visible (either in sync data or buffered OBJECT) +poll_until(root_b.get("existing").value() == "after", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("existing").value() == "after" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` diff --git a/uts/objects/unit/batch.md b/uts/objects/unit/batch.md new file mode 100644 index 000000000..b53098c35 --- /dev/null +++ b/uts/objects/unit/batch.md @@ -0,0 +1,782 @@ +# Batch API Tests + +Spec points: `RTPO22`, `RTINS19`, `RTBC1`–`RTBC16` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO22 - PathObject#batch resolves path and executes fn + +**Test ID**: `objects/unit/RTPO22/batch-resolves-and-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTPO22c | Resolves path to LiveObject | +| RTPO22d | Creates RootBatchContext wrapping Instance | +| RTPO22e | Executes fn with BatchContext | +| RTPO22f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[0].operation.mapSet.key == "name" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.mapSet.key == "age" +``` + +--- + +## RTPO22c - PathObject#batch on unresolvable path throws 92007 + +**Test ID**: `objects/unit/RTPO22c/batch-unresolvable-throws-0` + +**Spec requirement:** If path does not resolve to LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS19 - Instance#batch resolves and executes fn + +**Test ID**: `objects/unit/RTINS19/batch-instance-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTINS19d | Creates RootBatchContext wrapping Instance | +| RTINS19e | Executes fn with BatchContext | +| RTINS19f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.batch((ctx) => { + ctx.set("name", "Charlie") + ctx.remove("age") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_REMOVE" +``` + +--- + +## RTINS19c - Instance#batch on non-LiveObject throws 92007 + +**Test ID**: `objects/unit/RTINS19c/batch-non-live-object-throws-0` + +**Spec requirement:** If wrapped value is not a LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +AWAIT name_inst.batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC3 - BatchContext#id returns objectId + +**Test ID**: `objects/unit/RTBC3/id-returns-objectid-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_id = null +AWAIT root.batch((ctx) => { + received_id = ctx.id() +}) +``` + +### Assertions +```pseudo +ASSERT received_id == "root" +``` + +--- + +## RTBC5 - BatchContext#value delegates to Instance#value + +**Test ID**: `objects/unit/RTBC5/value-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_value = null +AWAIT root.get("score").batch((ctx) => { + received_value = ctx.value() +}) +``` + +### Assertions +```pseudo +ASSERT received_value == 100 +``` + +--- + +## RTBC4 - BatchContext#get wraps result via wrapInstance + +**Test ID**: `objects/unit/RTBC4/get-wraps-instance-0` + +| Spec | Requirement | +|------|-------------| +| RTBC4c | Delegates to Instance#get | +| RTBC4d | Wraps result via RootBatchContext#wrapInstance | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child_id = null +AWAIT root.batch((ctx) => { + child = ctx.get("score") + child_id = child.id() +}) +``` + +### Assertions +```pseudo +ASSERT child_id == "counter:score@1000" +``` + +--- + +## RTBC4 - BatchContext#get returns null for nonexistent key + +**Test ID**: `objects/unit/RTBC4/get-null-nonexistent-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = "not_null" +AWAIT root.batch((ctx) => { + result = ctx.get("nonexistent") +}) +``` + +### Assertions +```pseudo +ASSERT result == null +``` + +--- + +## RTBC6 - BatchContext#entries yields [key, BatchContext] pairs + +**Test ID**: `objects/unit/RTBC6/entries-yields-pairs-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = [] +AWAIT root.batch((ctx) => { + FOR [key, child] IN ctx.entries(): + keys.append(key) +}) +``` + +### Assertions +```pseudo +ASSERT keys.length == 6 +ASSERT "name" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTBC9 - BatchContext#size delegates to Instance#size + +**Test ID**: `objects/unit/RTBC9/size-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_size = null +AWAIT root.batch((ctx) => { + received_size = ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT received_size == 6 +``` + +--- + +## RTBC10 - BatchContext#compact delegates to Instance#compact + +**Test ID**: `objects/unit/RTBC10/compact-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = null +AWAIT root.batch((ctx) => { + result = ctx.compact() +}) +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +``` + +--- + +## RTBC12 - BatchContext#set queues MAP_SET message + +**Test ID**: `objects/unit/RTBC12/set-queues-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTBC12d | Queues message constructor for MAP_SET | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTBC12c - BatchContext#set on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTBC12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.set("key", "value") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC13 - BatchContext#remove queues MAP_REMOVE message + +**Test ID**: `objects/unit/RTBC13/remove-queues-map-remove-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.remove("name") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTBC14 - BatchContext#increment queues COUNTER_INC message + +**Test ID**: `objects/unit/RTBC14/increment-queues-counter-inc-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.increment(25) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTBC14c - BatchContext#increment on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTBC14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.increment(5) +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC15 - BatchContext#decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTBC15/decrement-negates-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.decrement(10) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.counterInc.number == -10 +``` + +--- + +## RTBC16c - wrapInstance memoizes by objectId + +**Test ID**: `objects/unit/RTBC16c/wrap-instance-memoized-0` + +**Spec requirement:** If a wrapper for that objectId already exists, the existing wrapper is returned. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +same_ref = false +AWAIT root.batch((ctx) => { + child1 = ctx.get("score") + child2 = ctx.get("score") + same_ref = (child1 IS child2) +}) +``` + +### Assertions +```pseudo +ASSERT same_ref == true +``` + +--- + +## RTBC16d - flush publishes via RTO15 (publish, not publishAndApply) + +**Test ID**: `objects/unit/RTBC16d/flush-uses-publish-0` + +**Spec requirement:** Flushes queued messages as a single array via RealtimeObject#publish. + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) + child = ctx.get("score") + child.increment(50) +}) +``` + +### Assertions +```pseudo +// All operations published as a single OBJECT message +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 3 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[2].operation.action == "COUNTER_INC" +``` + +--- + +## RTBC16d - flush with no queued messages does not publish + +**Test ID**: `objects/unit/RTBC16d/flush-empty-no-publish-0` + +**Spec requirement:** If there are no queued messages, no publish is performed. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + // Read-only: no writes queued + ctx.value() + ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 0 +``` + +--- + +## RTBC16e - closed batch throws 40000 on any method call + +**Test ID**: `objects/unit/RTBC16e/closed-batch-throws-0` + +**Spec requirement:** After the batch is closed, any method call must throw 40000. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTBC16e - closed batch read methods also throw 40000 + +**Test ID**: `objects/unit/RTBC16e/closed-batch-read-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.id() FAILS WITH error_id +saved_ctx.value() FAILS WITH error_value +saved_ctx.size() FAILS WITH error_size +``` + +### Assertions +```pseudo +ASSERT error_id.code == 40000 +ASSERT error_value.code == 40000 +ASSERT error_size.code == 40000 +``` + +--- + +## RTPO22g - RootBatchContext closed after flush regardless of success + +**Test ID**: `objects/unit/RTPO22g/closed-after-flush-0` + +**Spec requirement:** The RootBatchContext is closed after flush completes, regardless of success or failure. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx + ctx.set("name", "Bob") +}) + +saved_ctx.set("age", 99) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTPO22b - PathObject#batch requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTPO22b/batch-requires-publish-mode-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md new file mode 100644 index 000000000..221d635e7 --- /dev/null +++ b/uts/objects/unit/instance.md @@ -0,0 +1,524 @@ +# Instance Tests + +Spec points: `RTINS1`–`RTINS19` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTINS3 - id property returns objectId + +**Test ID**: `objects/unit/RTINS3/id-returns-objectid-0` + +| Spec | Requirement | +|------|-------------| +| RTINS3a | LiveObject -> returns objectId | +| RTINS3b | Primitive -> returns null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTINS4 - value() returns counter number or primitive + +**Test ID**: `objects/unit/RTINS4/value-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTINS4a | LiveCounter -> numeric value | +| RTINS4c | LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.value() == 100 + +map_inst = root.instance() +ASSERT map_inst.value() == null +``` + +--- + +## RTINS5 - get() returns Instance wrapping entry value + +**Test ID**: `objects/unit/RTINS5/get-wraps-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTINS5b | LiveMap -> look up key, wrap result in Instance | +| RTINS5c | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Assertions +```pseudo +name_inst = root_inst.get("name") +ASSERT name_inst IS Instance +ASSERT name_inst.value() == "Alice" + +score_inst = root_inst.get("score") +ASSERT score_inst.id() == "counter:score@1000" + +null_inst = root_inst.get("nonexistent") +ASSERT null_inst == null +``` + +--- + +## RTINS6 - entries() yields [key, Instance] pairs + +**Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` + +| Spec | Requirement | +|------|-------------| +| RTINS6a | LiveMap -> [key, Instance] pairs | +| RTINS6b | Non-LiveMap -> empty iterator | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, inst] IN root_inst.entries(): + entries[key] = inst +``` + +### Assertions +```pseudo +ASSERT entries.length == 7 +ASSERT entries["name"] IS Instance +ASSERT entries["name"].value() == "Alice" +``` + +--- + +## RTINS9 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTINS9/size-0` + +| Spec | Requirement | +|------|-------------| +| RTINS9a | LiveMap -> non-tombstoned entry count | +| RTINS9b | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +root_inst = root.instance() +ASSERT root_inst.size() == 7 + +counter_inst = root.get("score").instance() +ASSERT counter_inst.size() == null +``` + +--- + +## RTINS10 - compact() recursively compacts + +**Test ID**: `objects/unit/RTINS10/compact-0` + +**Spec requirement:** Behaves identically to PathObject#compact on the wrapped value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +result = root_inst.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +ASSERT result["profile"]["email"] == "alice@example.com" +``` + +--- + +## RTINS12 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTINS12/set-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS12b | LiveMap -> delegate to LiveMap#set | +| RTINS12c | Non-LiveMap -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTINS12c - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS13 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTINS13/remove-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTINS14 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTINS14/increment-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS14b | LiveCounter -> delegate to increment | +| RTINS14c | Non-LiveCounter -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTINS14c - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +map_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT map_inst.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS15 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTINS15/decrement-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTINS16 - subscribe() receives InstanceSubscriptionEvent + +**Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16c | Subscribes via LiveObject#subscribe | +| RTINS16d1 | Event.object is the Instance | +| RTINS16e | Returns Subscription | +| RTINS16f | Identity-based subscription | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "counter:score@1000" +``` + +--- + +## RTINS16b - subscribe() on primitive throws 92007 + +**Test ID**: `objects/unit/RTINS16b/subscribe-primitive-throws-0` + +**Spec requirement:** If wrapped value is not LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +name_inst.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS16f - Instance subscription follows identity not path + +**Test ID**: `objects/unit/RTINS16f/subscription-follows-identity-0` + +**Spec requirement:** Instance follows the specific LiveObject, regardless of tree position. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT counter_inst.id() == "counter:score@1000" +``` + +--- + +## RTINS17 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTINS17/unsubscribe-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTINS15a - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTINS15a/decrement-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTINS16 - Subscription event contains message metadata + +**Test ID**: `objects/unit/RTINS16/subscription-event-metadata-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16d1 | Event.object is the Instance | +| RTINS16d2 | Event.message is the ObjectMessage that triggered the update | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +events = [] +root_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "root" +ASSERT events[0].message IS NOT null +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.mapSet.key == "name" +``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md new file mode 100644 index 000000000..300f1779b --- /dev/null +++ b/uts/objects/unit/live_counter.md @@ -0,0 +1,824 @@ +# LiveCounter Tests + +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveCounter` CRDT data structure. LiveCounter holds a 64-bit float and supports increment operations, create operations (initial value merge), data replacement during sync, tombstoning, and serial-based newness checks. + +Tests operate directly on LiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_create`, `build_object_delete`, `build_object_state`. + +--- + +## RTLC4 - Zero-value LiveCounter + +**Test ID**: `objects/unit/RTLC4/zero-value-0` + +**Spec requirement:** The zero-value LiveCounter has data set to 0, empty siteTimeserials, createOperationIsMerged false, isTombstone false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.objectId == "counter:abc@1000" +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +ASSERT counter.createOperationIsMerged == false +ASSERT counter.siteTimeserials == {} +``` + +--- + +## RTLC9 - COUNTER_INC adds number to data + +**Test ID**: `objects/unit/RTLC9/counter-inc-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC9f | Add `CounterInc.number` to data if it exists | +| RTLC9g | Return LiveCounterUpdate with amount set to the number | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 5 +ASSERT update.noop == false +ASSERT update.update.amount == 5 +``` + +--- + +## RTLC9 - COUNTER_INC with negative number + +**Test ID**: `objects/unit/RTLC9/counter-inc-negative-0` + +**Spec requirement:** COUNTER_INC with a negative number decrements the counter. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", -3, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 7 +ASSERT update.update.amount == -3 +``` + +--- + +## RTLC9 - COUNTER_INC with missing number is noop + +**Test ID**: `objects/unit/RTLC9/counter-inc-missing-number-0` + +**Spec requirement:** If CounterInc.number does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: {} + } +) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 10 +ASSERT update.noop == true +``` + +--- + +## RTLC9 - Multiple COUNTER_INC operations accumulate + +**Test ID**: `objects/unit/RTLC9/counter-inc-accumulate-0` + +**Spec requirement:** Multiple increments accumulate additively. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation(build_counter_inc("counter:abc@1000", 10, "01", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", 20, "02", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", -5, "01", "site2"), source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 25 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE merges initial count + +**Test ID**: `objects/unit/RTLC8/counter-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLC8c | Merge initial value via RTLC16 | +| RTLC16a | Add counterCreate.count to data | +| RTLC16b | Set createOperationIsMerged to true | +| RTLC16c | Return LiveCounterUpdate with amount = count | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 42 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 42 +``` + +--- + +## RTLC8 - COUNTER_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLC8/counter-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, log and return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 99 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT update.noop == true +``` + +--- + +## RTLC16 - COUNTER_CREATE with missing count is noop + +**Test ID**: `objects/unit/RTLC16/counter-create-no-count-0` + +**Spec requirement:** If counterCreate.count does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", {}, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.createOperationIsMerged == true +ASSERT update.noop == true +``` + +--- + +## RTLO4a - canApplyOperation allows when siteSerial is empty + +**Test ID**: `objects/unit/RTLO4a/apply-empty-site-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a5 | If siteSerial is null or empty, return true | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result IS NOT false +ASSERT counter.data == 5 +``` + +--- + +## RTLO4a - canApplyOperation rejects stale serial + +**Test ID**: `objects/unit/RTLO4a/reject-stale-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a6 | Return true only if serial is greater than siteSerial lexicographically | +| RTLC7b | If canApplyOperation returns false, discard and return false | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "03", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation rejects equal serial + +**Test ID**: `objects/unit/RTLO4a/reject-equal-serial-0` + +**Spec requirement:** Serial must be strictly greater; equal serial is rejected. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "05", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation warns on empty serial or siteCode + +**Test ID**: `objects/unit/RTLO4a/warn-invalid-serial-0` + +**Spec requirement:** Both serial and siteCode must be non-empty strings. Otherwise, log warning and do not apply. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg_no_serial = ObjectMessage( + serial: "", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result1 = counter.applyOperation(msg_no_serial, source: CHANNEL) + +msg_no_site = ObjectMessage( + serial: "01", + siteCode: "", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result2 = counter.applyOperation(msg_no_site, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT result1 == false +ASSERT result2 == false +``` + +--- + +## RTLC7c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLC7c - LOCAL source does not update siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/local-source-no-serial-update-0` + +**Spec requirement:** If source is LOCAL, siteTimeserials must not be updated. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials == {} +ASSERT counter.data == 5 +``` + +--- + +## RTLC7g - applyOperation returns true on success + +**Test ID**: `objects/unit/RTLC7g/apply-returns-true-0` + +**Spec requirement:** Returns a boolean indicating whether the operation was successfully applied. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == true +``` + +--- + +## RTLO4e, RTLO5 - OBJECT_DELETE tombstones counter + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-0` + +| Spec | Requirement | +|------|-------------| +| RTLO5b | Tombstone the LiveObject | +| RTLO4e2 | Set isTombstone to true | +| RTLO4e4 | Set data to zero-value | +| RTLC7d4a | Emit LiveCounterUpdate with negated previous value | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000000000) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT counter.tombstonedAt == 1700000000000 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC7e - Operations on tombstoned counter are rejected + +**Test ID**: `objects/unit/RTLC7e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, the operation cannot be applied. Return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLO6 - tombstonedAt from serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-from-serial-timestamp-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6a | tombstonedAt equals serialTimestamp if it exists | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000050000) +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.tombstonedAt == 1700000050000 +``` + +--- + +## RTLO6 - tombstonedAt from local clock when no serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-local-clock-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6b | tombstonedAt equals current local time if serialTimestamp not provided | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +before_time = current_time() +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +after_time = current_time() +ASSERT counter.tombstonedAt >= before_time +ASSERT counter.tombstonedAt <= after_time +``` + +--- + +## RTLC7d3 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLC7d3/unsupported-action-0` + +**Spec requirement:** Log warning, discard without action, return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { action: "MAP_SET", objectId: "counter:abc@1000", mapSet: { key: "x", value: { string: "y" } } } +) +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLC6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLC6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6a | Replace siteTimeserials from ObjectState | +| RTLC6b | Set createOperationIsMerged to false | +| RTLC6c | Set data to counter.count | +| RTLC6h | Return diff as LiveCounterUpdate | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site2": "05"}, { + counter: { count: 50 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 50 +ASSERT counter.siteTimeserials == { "site2": "05" } +ASSERT counter.createOperationIsMerged == false +ASSERT update.update.amount == 40 +``` + +--- + +## RTLC6 - replaceData with createOp merges initial value + +**Test ID**: `objects/unit/RTLC6/replace-data-with-create-op-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6c | Set data to counter.count | +| RTLC6d | If createOp present, merge via RTLC16 | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 50 } } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 150 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 150 +``` + +--- + +## RTLC6e - replaceData on tombstoned counter is noop + +**Test ID**: `objects/unit/RTLC6e/replace-data-tombstoned-noop-0` + +**Spec requirement:** If isTombstone is true, finish processing. Return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +counter.data = 0 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 999 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.noop == true +``` + +--- + +## RTLC6f - replaceData with tombstone flag tombstones counter + +**Test ID**: `objects/unit/RTLC6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6f | If ObjectState.tombstone is true, tombstone the counter | +| RTLC6f1 | Return LiveCounterUpdate with amount = negated previous data | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 30 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 0 }, + tombstone: true +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT update.update.amount == -30 +``` + +--- + +## RTLC6 - replaceData with missing counter.count defaults to 0 + +**Test ID**: `objects/unit/RTLC6/replace-data-missing-count-0` + +**Spec requirement:** Set data to counter.count, or to 0 if it does not exist. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: {} +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC14 - Diff calculation + +**Test ID**: `objects/unit/RTLC14/diff-calculation-0` + +**Spec requirement:** Return LiveCounterUpdate with amount = newData - previousData. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 20 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 75 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT update.update.amount == 55 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates + +**Test ID**: `objects/unit/RTLC8/create-then-inc-0` + +**Spec requirement:** Create operation merges initial count, then increment adds to it. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation( + build_counter_create("counter:abc@1000", { count: 100 }, "01", "site1"), + source: CHANNEL +) +counter.applyOperation( + build_counter_inc("counter:abc@1000", 25, "02", "site1"), + source: CHANNEL +) +``` + +### Assertions +```pseudo +ASSERT counter.data == 125 +ASSERT counter.createOperationIsMerged == true +``` + +--- + +## RTLO3 - LiveObject properties initialized correctly + +**Test ID**: `objects/unit/RTLO3/live-object-init-properties-0` + +| Spec | Requirement | +|------|-------------| +| RTLO3a1 | objectId must be provided in constructor | +| RTLO3b1 | siteTimeserials set to empty map | +| RTLO3c1 | createOperationIsMerged set to false | +| RTLO3d1 | isTombstone set to false | +| RTLO3e1 | tombstonedAt set to null | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:test@2000") +``` + +### Assertions +```pseudo +ASSERT counter.objectId == "counter:test@2000" +ASSERT counter.siteTimeserials == {} +ASSERT counter.createOperationIsMerged == false +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +``` diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md new file mode 100644 index 000000000..2b5e733e9 --- /dev/null +++ b/uts/objects/unit/live_counter_api.md @@ -0,0 +1,343 @@ +# LiveCounter API Tests + +Spec points: `RTLC5`, `RTLC11`–`RTLC13` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLC5 - value() returns current counter data + +**Test ID**: `objects/unit/RTLC5/value-returns-data-0` + +| Spec | Requirement | +|------|-------------| +| RTLC5c | Returns current data value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter = root.get("score") +ASSERT counter.value() == 100 +``` + +--- + +## RTLC5a - value() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLC5a/value-requires-subscribe-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +This is implicitly tested by `setup_synced_channel` which always includes OBJECT_SUBSCRIBE. A negative test would use a channel without OBJECT_SUBSCRIBE and verify the error. + +--- + +## RTLC12 - increment sends v6 COUNTER_INC message + +**Test ID**: `objects/unit/RTLC12/increment-sends-counter-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC12e2 | action set to COUNTER_INC | +| RTLC12e3 | objectId set to counter's objectId | +| RTLC12e5 | counterInc.number set to amount | +| RTLC12g | Publishes via publishAndApply | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(25) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTLC12 - increment applies locally after ACK + +**Test ID**: `objects/unit/RTLC12/increment-applies-locally-0` + +**Spec requirement:** Via publishAndApply, value reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(50) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 150 +``` + +--- + +## RTLC12b - increment requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLC12d - increment with echoMessages false throws + +**Test ID**: `objects/unit/RTLC12d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLC12e1 - increment with non-number throws + +**Test ID**: `objects/unit/RTLC12e1/increment-non-number-0` + +**Spec requirement:** If amount is null, not Number, not finite, or omitted, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment("not_a_number") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLC13 - decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTLC13/decrement-negates-0` + +| Spec | Requirement | +|------|-------------| +| RTLC13b | Alias for increment with negative amount | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.decrement(15) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.counterInc.number == -15 +ASSERT root.get("score").value() == 85 +``` + +--- + +## RTLC11 - LiveCounterUpdate emitted on increment + +**Test ID**: `objects/unit/RTLC11/counter-update-on-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC11b1 | update.amount is the increment value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote-site") +])) + +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates[0].message.operation.counterInc.number == 7 +``` + +--- + +## RTLC12e1 - Table-driven invalid increment amounts + +**Test ID**: `objects/unit/RTLC12e1/increment-invalid-amounts-table-0` + +**Spec requirement:** If amount is null, not Number, not finite, or NaN, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_amounts = [ + { value: null, label: "null" }, + { value: NaN, label: "NaN" }, + { value: Infinity, label: "Infinity" }, + { value: -Infinity, label: "-Infinity" }, + { value: "10", label: "string" }, + { value: true, label: "boolean" }, + { value: [1, 2], label: "array" }, + { value: { n: 1 }, label: "object" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_amounts: + AWAIT root.increment(scenario.value) FAILS WITH error + ASSERT error.code == 40003 +``` diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/live_map.md new file mode 100644 index 000000000..a930c17a3 --- /dev/null +++ b/uts/objects/unit/live_map.md @@ -0,0 +1,980 @@ +# LiveMap Tests + +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, and diff calculation. + +Tests operate directly on LiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions. + +--- + +## RTLM4 - Zero-value LiveMap + +**Test ID**: `objects/unit/RTLM4/zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM4 | Zero-value LiveMap has empty data map and null clearTimeserial | +| RTLM25 | clearTimeserial initially null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.data == {} +ASSERT map.clearTimeserial == null +ASSERT map.isTombstone == false +ASSERT map.createOperationIsMerged == false +ASSERT map.siteTimeserials == {} +``` + +--- + +## RTLM7 - MAP_SET creates new entry + +**Test ID**: `objects/unit/RTLM7/map-set-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7b4 | Create new ObjectsMapEntry with data and timeserial | +| RTLM7f | Return LiveMapUpdate with key set to "updated" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].timeserial == "01" +ASSERT map.data["name"].tombstone == false +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7 - MAP_SET updates existing entry + +**Test ID**: `objects/unit/RTLM7/map-set-update-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2e | Set data to MapSet.value | +| RTLM7a2b | Set timeserial to the provided serial | +| RTLM7a2c | Set tombstone to false | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT map.data["name"].timeserial == "02" +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM9 - LWW rejects stale serial on existing entry + +**Test ID**: `objects/unit/RTLM9/lww-reject-stale-0` + +| Spec | Requirement | +|------|-------------| +| RTLM9a | Operation serial must be strictly greater than entry serial | +| RTLM9e | Compare lexicographically | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9 - LWW rejects equal serial + +**Test ID**: `objects/unit/RTLM9/lww-reject-equal-0` + +**Spec requirement:** Equal serials are rejected — must be strictly greater. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9b - Both serials empty rejects operation + +**Test ID**: `objects/unit/RTLM9b/both-empty-reject-0` + +**Spec requirement:** If both the entry serial and operation serial are null/empty, considered equal, so operation is not applied. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9d - Missing entry serial allows operation + +**Test ID**: `objects/unit/RTLM9d/missing-entry-serial-allows-0` + +**Spec requirement:** If only the operation serial exists and is non-empty, it is greater than the missing entry serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7h - MAP_SET rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM7h/map-set-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM7g - MAP_SET with objectId creates zero-value object + +**Test ID**: `objects/unit/RTLM7g/map-set-objectid-creates-zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g | If MapSet.value.objectId is non-empty, create zero-value LiveObject | +| RTLM7g1 | Create via RTO6 | + +This test requires an ObjectsPool to be passed alongside the LiveMap. The LiveMap creates a zero-value object in the pool when it encounters an objectId reference. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:new@2000" }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 0 +``` + +--- + +## RTLM8 - MAP_REMOVE tombstones existing entry + +**Test ID**: `objects/unit/RTLM8/map-remove-existing-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a2a | Set data to null | +| RTLM8a2b | Set timeserial to serial | +| RTLM8a2c | Set tombstone to true | +| RTLM8a2d | Set tombstonedAt via RTLO6 | +| RTLM8e | Return LiveMapUpdate with key set to "removed" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == null +ASSERT map.data["name"].tombstone == true +ASSERT map.data["name"].timeserial == "02" +ASSERT map.data["name"].tombstonedAt == 1700000000000 +ASSERT update.update == { "name": "removed" } +``` + +--- + +## RTLM8 - MAP_REMOVE creates tombstoned entry if not exists + +**Test ID**: `objects/unit/RTLM8/map-remove-nonexistent-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8b1 | Create new entry with data null and timeserial | +| RTLM8b2 | Set tombstone to true | +| RTLM8b3 | Set tombstonedAt via RTLO6 | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "ghost", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ghost"].tombstone == true +ASSERT map.data["ghost"].tombstonedAt == 1700000000000 +ASSERT update.update == { "ghost": "removed" } +``` + +--- + +## RTLM8g - MAP_REMOVE rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM8g/map-remove-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "name": { data: { string: "Alice" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "03", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT update.noop == true +``` + +--- + +## RTLM24 - MAP_CLEAR sets clearTimeserial and removes older entries + +**Test ID**: `objects/unit/RTLM24/map-clear-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24d | Set clearTimeserial to serial | +| RTLM24e1a | Remove entries with timeserial null or < serial | +| RTLM24f | Return LiveMapUpdate with removed keys | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "02", tombstone: false }, + "new": { data: { string: "new" }, timeserial: "06", tombstone: false }, + "same": { data: { string: "same" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "04", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "04" +ASSERT "old" NOT IN map.data +ASSERT "same" NOT IN map.data +ASSERT "new" IN map.data +ASSERT update.update == { "old": "removed", "same": "removed" } +``` + +--- + +## RTLM24c - MAP_CLEAR rejected when clearTimeserial is already greater + +**Test ID**: `objects/unit/RTLM24c/map-clear-stale-0` + +**Spec requirement:** If existing clearTimeserial is greater than provided serial, discard. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "10" +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "10" +ASSERT update.noop == true +``` + +--- + +## RTLM16, RTLM23 - MAP_CREATE merges entries + +**Test ID**: `objects/unit/RTLM16/map-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLM16d | Merge via RTLM23 | +| RTLM23a1 | Non-tombstoned entries merged via MAP_SET logic | +| RTLM23a2 | Tombstoned entries merged via MAP_REMOVE logic | +| RTLM23b | Set createOperationIsMerged to true | + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "01" }, + "removed_key": { tombstone: true, timeserial: "01", serialTimestamp: 1700000000000 } + } +}, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["removed_key"].tombstone == true +ASSERT map.createOperationIsMerged == true +ASSERT update.update == { "name": "updated", "removed_key": "removed" } +``` + +--- + +## RTLM16b - MAP_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLM16b/map-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, return noop. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map.createOperationIsMerged = true +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { "name": { data: { string: "Bob" }, timeserial: "01" } } +}, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM15c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLM15c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLM15e - Operations on tombstoned map are rejected + +**Test ID**: `objects/unit/RTLM15e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, finish without action, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.isTombstone = true +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT map.data == {} +``` + +--- + +## RTLO5 - OBJECT_DELETE tombstones map + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-map-0` + +| Spec | Requirement | +|------|-------------| +| RTLM15d5a | Emit LiveMapUpdate with removed keys | +| RTLM15d5b | Return true | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false }, + "age": { data: { number: 30 }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed", "age": "removed" } +``` + +--- + +## RTLM14, RTLM14c - Tombstoned entry check includes objectId reference + +**Test ID**: `objects/unit/RTLM14/tombstone-check-objectid-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM14a | Entry is tombstoned if entry.tombstone is true | +| RTLM14c | Entry is tombstoned if referenced LiveObject.isTombstone is true | + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false }, + "dead_entry": { data: null, timeserial: "01", tombstone: true }, + "dead_ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +ASSERT isTombstoned(map.data["alive"]) == false +ASSERT isTombstoned(map.data["dead_entry"]) == true +ASSERT isTombstoned(map.data["dead_ref"]) == true +``` + +--- + +## RTLM6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLM6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6a | Replace siteTimeserials | +| RTLM6b | Set createOperationIsMerged to false | +| RTLM6i | Set clearTimeserial from ObjectState.map.clearTimeserial | +| RTLM6c | Set data to ObjectState.map.entries | +| RTLM6h | Return diff LiveMapUpdate | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "01", tombstone: false } +} +map.createOperationIsMerged = true +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site2": "05"}, { + map: { + semantics: "LWW", + clearTimeserial: "03", + entries: { + "new": { data: { string: "new" }, timeserial: "04", tombstone: false } + } + } +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials == { "site2": "05" } +ASSERT map.createOperationIsMerged == false +ASSERT map.clearTimeserial == "03" +ASSERT "old" NOT IN map.data +ASSERT map.data["new"].data == { string: "new" } +ASSERT update.update == { "old": "removed", "new": "updated" } +``` + +--- + +## RTLM6c1 - replaceData sets tombstonedAt on tombstoned entries + +**Test ID**: `objects/unit/RTLM6c1/replace-data-tombstoned-entries-0` + +**Spec requirement:** For each tombstoned entry, set tombstonedAt via RTLO6. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "dead": { tombstone: true, timeserial: "01", serialTimestamp: 1700000050000 } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["dead"].tombstonedAt == 1700000050000 +``` + +--- + +## RTLM6d - replaceData with createOp merges initial entries + +**Test ID**: `objects/unit/RTLM6d/replace-data-with-create-op-0` + +**Spec requirement:** If createOp present, merge via RTLM23. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("map:test@1000", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "from_sync": { data: { string: "synced" }, timeserial: "01" } + } + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "from_create": { data: { string: "created" }, timeserial: "00" } + } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["from_sync"].data == { string: "synced" } +ASSERT map.data["from_create"].data == { string: "created" } +ASSERT map.createOperationIsMerged == true +``` + +--- + +## RTLM19 - GC removes tombstoned entries past grace period + +**Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` + +**Spec requirement:** Entries where tombstonedAt + gracePeriod <= currentTime are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +grace_period = 86400000 +now = 1700100000000 + +map.data = { + "recent_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - 1000 }, + "old_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - grace_period - 1 }, + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +map.gcTombstonedEntries(grace_period, now) +``` + +### Assertions +```pseudo +ASSERT "recent_dead" IN map.data +ASSERT "old_dead" NOT IN map.data +ASSERT "alive" IN map.data +``` + +--- + +## RTLM22 - Diff between two data states + +**Test ID**: `objects/unit/RTLM22/diff-calculation-0` + +| Spec | Requirement | +|------|-------------| +| RTLM22b1 | Key in previous but not new -> removed | +| RTLM22b2 | Key in new but not previous -> updated | +| RTLM22b3 | Key in both with different data -> updated | +| RTLM22b | Only non-tombstoned entries are considered | + +### Setup +```pseudo +previousData = { + "removed": { data: { string: "gone" }, timeserial: "01", tombstone: false }, + "changed": { data: { string: "old" }, timeserial: "01", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "was_dead": { data: null, timeserial: "01", tombstone: true } +} + +newData = { + "added": { data: { string: "new" }, timeserial: "02", tombstone: false }, + "changed": { data: { string: "new_val" }, timeserial: "02", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "now_dead": { data: null, timeserial: "02", tombstone: true } +} +``` + +### Test Steps +```pseudo +update = LiveMap.diff(previousData, newData) +``` + +### Assertions +```pseudo +ASSERT update.update["removed"] == "removed" +ASSERT update.update["added"] == "updated" +ASSERT update.update["changed"] == "updated" +ASSERT "unchanged" NOT IN update.update +ASSERT "was_dead" NOT IN update.update +ASSERT "now_dead" NOT IN update.update +``` + +--- + +## RTLM15d4 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLM15d4/unsupported-action-0` + +**Spec requirement:** Log warning, discard, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "root", counterInc: { number: 5 } } +) +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +``` + +--- + +## RTLM6i - replaceData without clearTimeserial resets to null + +**Test ID**: `objects/unit/RTLM6i/replace-data-resets-clear-timeserial-0` + +**Spec requirement:** If ObjectState.map.clearTimeserial is absent, clearTimeserial is reset to null. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "x": { data: { number: 1 }, timeserial: "03", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "y": { data: { number: 2 }, timeserial: "01" } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == null +ASSERT "y" IN map.data +``` + +--- + +## RTLM14c, RTLM5 - MAP_SET referencing tombstoned objectId yields null value + +**Test ID**: `objects/unit/RTLM14c/tombstoned-ref-yields-null-0` + +**Spec requirement:** If entry references an objectId whose LiveObject is tombstoned, the entry is treated as tombstoned (RTLM14c). Value resolution returns null. + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +// The entry itself is not tombstoned, but the referenced object is +ASSERT map.data["ref"].tombstone == false +// size() should NOT count this entry because RTLM14c makes it tombstoned +ASSERT map.size() == 0 +// get() should return null for the value +ASSERT map.get("ref") == null +``` + +--- + +## RTLM7 - MAP_SET revives tombstoned entry + +**Test ID**: `objects/unit/RTLM7/map-set-revives-tombstoned-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2c | Set tombstone to false | +| RTLM7a2d | Set tombstonedAt to null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: null, timeserial: "01", tombstone: true, tombstonedAt: 1700000000000 } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT map.data["name"].tombstonedAt == null +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM24 - MAP_CLEAR preserves entries with newer serial + +**Test ID**: `objects/unit/RTLM24/map-clear-preserves-newer-0` + +**Spec requirement:** Only entries with timeserial null or <= serial are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "before": { data: { string: "a" }, timeserial: "03", tombstone: false }, + "after": { data: { string: "b" }, timeserial: "07", tombstone: false }, + "no_ts": { data: { string: "c" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "before" NOT IN map.data +ASSERT "no_ts" NOT IN map.data +ASSERT map.data["after"].data == { string: "b" } +ASSERT "before" IN update.update +ASSERT "no_ts" IN update.update +ASSERT "after" NOT IN update.update +``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md new file mode 100644 index 000000000..7a7282246 --- /dev/null +++ b/uts/objects/unit/live_map_api.md @@ -0,0 +1,483 @@ +# LiveMap API Tests + +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLM5 - get() returns resolved value from LiveMap + +**Test ID**: `objects/unit/RTLM5/get-string-value-0` + +**Spec requirement:** Returns value at key, resolved per RTLM5d2. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTLM5 - get() returns null for non-existent key + +**Test ID**: `objects/unit/RTLM5/get-nonexistent-key-0` + +**Spec requirement:** If no entry exists at key, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +``` + +--- + +## RTLM5 - get() resolves objectId to LiveObject + +**Test ID**: `objects/unit/RTLM5/get-objectid-reference-0` + +**Spec requirement:** If data.objectId exists, resolve from pool. Return LiveCounter/LiveMap. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +ASSERT root.get("profile").get("email").value() == "alice@example.com" +``` + +--- + +## RTLM10 - size() returns non-tombstoned entry count + +**Test ID**: `objects/unit/RTLM10/size-non-tombstoned-0` + +**Spec requirement:** Returns number of non-tombstoned entries. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +``` + +--- + +## RTLM11 - entries() yields key-value pairs + +**Test ID**: `objects/unit/RTLM11/entries-yields-pairs-0` + +**Spec requirement:** Returns non-tombstoned key-value pairs. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = [] +FOR [key, pathObj] IN root.entries(): + entries.append(key) +``` + +### Assertions +```pseudo +ASSERT "name" IN entries +ASSERT "age" IN entries +ASSERT "active" IN entries +ASSERT "score" IN entries +ASSERT "profile" IN entries +ASSERT "data" IN entries +ASSERT "avatar" IN entries +ASSERT entries.length == 7 +``` + +--- + +## RTLM12 - keys() yields only keys + +**Test ID**: `objects/unit/RTLM12/keys-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = list(root.keys()) +``` + +### Assertions +```pseudo +ASSERT keys.length == 7 +ASSERT "name" IN keys +``` + +--- + +## RTLM20 - set() sends MAP_SET message with v6 format + +**Test ID**: `objects/unit/RTLM20/set-sends-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e2 | action set to MAP_SET | +| RTLM20e3 | objectId set to LiveMap's objectId | +| RTLM20e6 | mapSet.key set | +| RTLM20e7c | mapSet.value.string for string value | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTLM20 - set() with different value types + +**Test ID**: `objects/unit/RTLM20/set-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7b | JsonArray/JsonObject -> mapSet.value.json | +| RTLM20e7d | Number -> mapSet.value.number | +| RTLM20e7e | Boolean -> mapSet.value.boolean | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("num_key", 42) +AWAIT root.set("bool_key", false) +AWAIT root.set("json_key", {"nested": true}) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.number == 42 +ASSERT captured_messages[1].state[0].operation.mapSet.value.boolean == false +ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": true} +``` + +--- + +## RTLM20e7g - set() with LiveCounterValueType consumes and sends create + set + +**Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7g1 | Consume value type to generate COUNTER_CREATE | +| RTLM20e7g2 | Set mapSet.value.objectId to the created objectId | +| RTLM20h1 | Array: CREATE messages then MAP_SET | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.set("new_counter", LiveCounter.create(50)) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId +``` + +--- + +## RTLM21 - remove() sends MAP_REMOVE message + +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` + +| Spec | Requirement | +|------|-------------| +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTLM20d - set() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM21d - remove() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM21d/echo-messages-false-0` + +**Spec requirement:** Same as RTLM20d for remove. + +### Setup +```pseudo +// Same echoMessages: false setup as above +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM20 - set() applies locally after ACK + +**Test ID**: `objects/unit/RTLM20/set-applies-locally-0` + +**Spec requirement:** Via publishAndApply, local state reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTLM24 - clear() sends MAP_CLEAR message + +**Test ID**: `objects/unit/RTLM24/clear-sends-map-clear-0` + +**Spec requirement:** Constructs MAP_CLEAR ObjectMessage. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup capturing OBJECT messages) +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.clear() +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_CLEAR" +ASSERT obj_msg.operation.objectId == "root" +``` + +--- + +## RTLM20 - Table-driven invalid set value types + +**Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` + +**Spec requirement:** set() rejects values of unsupported types with error 40013. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_values = [ + { value: some_function, label: "function" }, + { value: undefined, label: "undefined" }, + { value: some_symbol, label: "symbol" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_values: + AWAIT root.set("key", scenario.value) FAILS WITH error + ASSERT error.code == 40013 +``` + +--- + +## RTLM20 - set() with bytes value type + +**Test ID**: `objects/unit/RTLM20/set-bytes-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7f | Binary -> mapSet.value.bytes (base64 encoded) | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("binary_data", bytes([1, 2, 3])) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.bytes == "AQID" +``` diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md new file mode 100644 index 000000000..5f8398e87 --- /dev/null +++ b/uts/objects/unit/live_object_subscribe.md @@ -0,0 +1,244 @@ +# LiveObject Subscribe Tests + +Spec points: `RTLO4b`, `RTLO4c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLO4b - subscribe registers listener for data updates + +**Test ID**: `objects/unit/RTLO4b/subscribe-receives-updates-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b3 | User provides listener for data updates | +| RTLO4b4c2 | Listener called with LiveObjectUpdate | +| RTLO4b7 | Returns Subscription object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b4c1 - noop update does not trigger listener + +**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` + +**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c - unsubscribe deregisters listener + +**Test ID**: `objects/unit/RTLO4c/unsubscribe-deregisters-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4c3 | Once deregistered, subsequent updates do not call listener | +| RTLO4c4 | No side effects on channel or RealtimeObject | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "02", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b1 - subscribe requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLO4b1/subscribe-requires-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_PUBLISH"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLO4b6 - subscribe has no side effects + +**Test ID**: `objects/unit/RTLO4b6/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTLO4b - subscribe on LiveMap receives LiveMapUpdate + +**Test ID**: `objects/unit/RTLO4b/subscribe-map-update-0` + +**Spec requirement:** LiveMapUpdate.update contains key -> "updated"/"removed". + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c1 - unsubscribe requires no channel mode + +**Test ID**: `objects/unit/RTLO4c1/unsubscribe-no-mode-required-0` + +**Spec requirement:** Does not require any specific channel modes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +instance = root.get("score").instance() +sub = instance.subscribe((event) => {}) +``` + +### Test Steps +```pseudo +sub.unsubscribe() +``` + +### Assertions +```pseudo +// No error thrown +``` diff --git a/uts/objects/unit/object_id.md b/uts/objects/unit/object_id.md new file mode 100644 index 000000000..8f51f7bc9 --- /dev/null +++ b/uts/objects/unit/object_id.md @@ -0,0 +1,159 @@ +# ObjectId Generation Tests + +Spec points: `RTO14` + +## Test Type +Unit test — pure function, no mocks required. + +## Purpose + +Tests the ObjectId generation procedure. ObjectId format is `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}`. This is a deterministic hash-based scheme that ensures uniqueness across clients. + +--- + +## RTO14 - ObjectId format for counter type + +**Test ID**: `objects/unit/RTO14/objectid-format-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTO14a1 | type must be "map" or "counter" | +| RTO14b1 | SHA-256 of UTF-8 encoded "[initialValue]:[nonce]" | +| RTO14b2 | Base64URL encode (RFC 4648 s.5) | +| RTO14c | Format: [type]:[hash]@[timestamp] | + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":42}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT objectId STARTS WITH "counter:" +ASSERT objectId CONTAINS "@1700000000000" +parts = objectId.split(":") +type_part = parts[0] +rest = parts[1] +hash_and_ts = rest.split("@") +hash_part = hash_and_ts[0] +ts_part = hash_and_ts[1] +ASSERT type_part == "counter" +ASSERT ts_part == "1700000000000" +ASSERT hash_part IS valid base64url string +ASSERT hash_part does NOT contain "+" or "/" or "=" +``` + +--- + +## RTO14 - ObjectId format for map type + +**Test ID**: `objects/unit/RTO14/objectid-format-map-0` + +**Spec requirement:** Same format with "map" type prefix. + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "map", + initialValue: '{"map":{"semantics":"LWW","entries":{}}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT objectId STARTS WITH "map:" +ASSERT objectId CONTAINS "@1700000000000" +``` + +--- + +## RTO14 - Deterministic output for same inputs + +**Test ID**: `objects/unit/RTO14/deterministic-0` + +**Spec requirement:** Same type, initialValue, nonce, and timestamp produce the same objectId. + +### Test Steps +```pseudo +id1 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "same-nonce-1234567", + timestamp: 1700000000000 +) +id2 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "same-nonce-1234567", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT id1 == id2 +``` + +--- + +## RTO14 - Different nonce produces different objectId + +**Test ID**: `objects/unit/RTO14/different-nonce-0` + +**Spec requirement:** Nonce ensures uniqueness across clients. + +### Test Steps +```pseudo +id1 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "nonce-aaaaaaaaaaaaa", + timestamp: 1700000000000 +) +id2 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "nonce-bbbbbbbbbbbbb", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT id1 != id2 +``` + +--- + +## RTO14b - SHA-256 hash is base64url encoded (not standard base64) + +**Test ID**: `objects/unit/RTO14b/base64url-encoding-0` + +| Spec | Requirement | +|------|-------------| +| RTO14b2 | Must use URL-safe Base64 per RFC 4648 s.5, not standard Base64 | + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +hash_part = objectId.split(":")[1].split("@")[0] +``` + +### Assertions +```pseudo +ASSERT hash_part does NOT contain "+" +ASSERT hash_part does NOT contain "/" +ASSERT hash_part does NOT end with "=" +``` diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md new file mode 100644 index 000000000..214fe7db0 --- /dev/null +++ b/uts/objects/unit/objects_pool.md @@ -0,0 +1,910 @@ +# ObjectsPool Tests + +Spec points: `RTO3`–`RTO9` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `ObjectsPool` internal data structure and sync state machine. ObjectsPool is a `Dict` that manages all objects on a channel. It processes ATTACHED messages (to determine sync mode), OBJECT_SYNC messages (to build state from server), and OBJECT messages (to apply operations). It maintains a SyncObjectsPool for accumulating sync data, buffers operations during SYNCING, and manages the INITIALIZED -> SYNCING -> SYNCED state transitions. + +Tests operate directly on ObjectsPool by calling `processAttached()`, `processObjectSync()`, and `processObjectMessage()`. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTO3 - ObjectsPool initialization with root LiveMap + +**Test ID**: `objects/unit/RTO3/pool-init-root-0` + +| Spec | Requirement | +|------|-------------| +| RTO3a | ObjectsPool is Dict | +| RTO3b | Must always contain a LiveMap with id "root" | +| RTO3b1 | On initialization, create zero-value LiveMap with objectId "root" | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Assertions +```pseudo +ASSERT "root" IN pool +ASSERT pool["root"] IS LiveMap +ASSERT pool["root"].data == {} +ASSERT pool["root"].objectId == "root" +``` + +--- + +## RTO4a - ATTACHED with HAS_OBJECTS flag starts SYNCING + +**Test ID**: `objects/unit/RTO4/attached-has-objects-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO4c | Sync state transitions to SYNCING | +| RTO4d | bufferedObjectOperations cleared | +| RTO4a | HAS_OBJECTS=1 means server will send OBJECT_SYNC | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + channelSerial: "sync1:cursor", + flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO4b - ATTACHED without HAS_OBJECTS clears pool and goes to SYNCED + +**Test ID**: `objects/unit/RTO4b/attached-no-objects-synced-0` + +| Spec | Requirement | +|------|-------------| +| RTO4b1 | Remove all objects except root | +| RTO4b2 | Clear root LiveMap data to zero-value | +| RTO4b2a | Emit LiveMapUpdate for root with removed entries | +| RTO4b4 | Perform sync completion actions | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["root"].data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:abc@1000" NOT IN pool +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT updates.length >= 1 +ASSERT updates[0].update == { "name": "removed" } +``` + +--- + +## RTO5 - OBJECT_SYNC complete sequence + +**Test ID**: `objects/unit/RTO5/sync-complete-sequence-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a1 | channelSerial is "sequenceId:cursor" | +| RTO5a4 | Sync complete when cursor is empty | +| RTO5f1 | Store new entries in SyncObjectsPool | +| RTO5c8 | Transition to SYNCED | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "root" IN pool +ASSERT "counter:abc@1000" IN pool +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["counter:abc@1000"].data == 42 +``` + +--- + +## RTO5a2 - New sync sequence discards previous + +**Test ID**: `objects/unit/RTO5a2/new-sequence-discards-old-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2a | SyncObjectsPool must be cleared | +| RTO5a2 | New sequence id starts fresh sync | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "seq1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "seq1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5f2a - Partial object state merge for maps + +**Test ID**: `objects/unit/RTO5f2a/partial-map-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTO5f2 | Existing entry: partial state, merge into existing | +| RTO5f2a2 | Merge map entries from incoming into existing | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + } + }) +])) + +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "age": { data: { number: 30 }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["root"].data["age"].data == { number: 30 } +``` + +--- + +## RTO5c2 - Sync completion removes objects not in sync + +**Test ID**: `objects/unit/RTO5c2/remove-absent-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c2 | Remove objects not received during sync | +| RTO5c2a | root must not be removed | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:old@1000"] = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"].data = 99 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT "counter:old@1000" NOT IN pool +ASSERT "root" IN pool +``` + +--- + +## RTO5c9 - Sync completion clears appliedOnAckSerials + +**Test ID**: `objects/unit/RTO5c9/clear-applied-on-ack-serials-0` + +**Spec requirement:** appliedOnAckSerials set must be cleared after sync. + +### Setup +```pseudo +pool = ObjectsPool() +pool.appliedOnAckSerials = {"serial-1", "serial-2"} +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.appliedOnAckSerials == {} +``` + +--- + +## RTO7, RTO8a - OBJECT messages buffered during SYNCING + +**Test ID**: `objects/unit/RTO8a/buffer-during-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO8a | If sync state is not SYNCED, buffer ObjectMessages | +| RTO7a | bufferedObjectOperations is an array | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT "counter:abc@1000" NOT IN pool +``` + +--- + +## RTO5c6, RTO8b - Buffered operations applied on sync completion + +**Test ID**: `objects/unit/RTO5c6/apply-buffered-on-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered operations with source CHANNEL | +| RTO8b | When SYNCED, apply directly | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 10, "02", "site1") +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 110 +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO9a1 - Null operation is discarded with warning + +**Test ID**: `objects/unit/RTO9a1/null-operation-warning-0` + +**Spec requirement:** If ObjectMessage.operation is null or omitted, log warning and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage(serial: "01", siteCode: "site1", operation: null) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO9a3 - appliedOnAckSerials deduplication + +**Test ID**: `objects/unit/RTO9a3/dedup-applied-on-ack-0` + +**Spec requirement:** If appliedOnAckSerials contains the serial, log debug, remove from set, and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"].data = 10 +pool.appliedOnAckSerials = {"echo-serial-1"} +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "echo-serial-1", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +ASSERT "echo-serial-1" NOT IN pool.appliedOnAckSerials +``` + +--- + +## RTO9a2a4 - LOCAL source adds serial to appliedOnAckSerials + +**Test ID**: `objects/unit/RTO9a2a4/local-source-adds-serial-0` + +**Spec requirement:** If source is LOCAL and operation was applied successfully, add serial to appliedOnAckSerials. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +pool.applyObjectMessages([ + build_counter_inc("counter:abc@1000", 5, "local-serial-1", "test-site") +], source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT "local-serial-1" IN pool.appliedOnAckSerials +ASSERT pool["counter:abc@1000"].data == 5 +``` + +--- + +## RTO9a2b - Unsupported action is discarded with warning + +**Test ID**: `objects/unit/RTO9a2b/unsupported-action-warning-0` + +**Spec requirement:** Log warning, discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "UNKNOWN_ACTION", objectId: "counter:abc@1000" } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO6 - Zero-value object creation from objectId prefix + +**Test ID**: `objects/unit/RTO6/zero-value-from-prefix-0` + +| Spec | Requirement | +|------|-------------| +| RTO6b1 | Parse type from objectId prefix before ":" | +| RTO6b2 | "map" prefix creates zero-value LiveMap | +| RTO6b3 | "counter" prefix creates zero-value LiveCounter | +| RTO6a | Skip if object already exists | + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:new@2000", 5, "01", "site1") +])) +pool.processObjectMessage(build_object_message("test", [ + build_map_set("map:new@2000", "key", { string: "val" }, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 5 + +ASSERT "map:new@2000" IN pool +ASSERT pool["map:new@2000"] IS LiveMap +ASSERT pool["map:new@2000"].data["key"].data == { string: "val" } +``` + +--- + +## RTO5d - OBJECT_SYNC with null object field is skipped + +**Test ID**: `objects/unit/RTO5d/null-object-skipped-0` + +**Spec requirement:** If ObjectMessage.object is null or omitted, skip processing. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + ObjectMessage(object: null), + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +``` + +--- + +## RTO5f3 - OBJECT_SYNC with unsupported object type is skipped + +**Test ID**: `objects/unit/RTO5f3/unsupported-type-skipped-0` + +**Spec requirement:** If neither map nor counter is present, log warning and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + ObjectMessage(object: { objectId: "unknown:xyz@1000", siteTimeserials: {} }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "unknown:xyz@1000" NOT IN pool +``` + +--- + +## RTO5e - OBJECT_SYNC transitions to SYNCING + +**Test ID**: `objects/unit/RTO5e/object-sync-transitions-syncing-0` + +**Spec requirement:** When OBJECT_SYNC received, sync state must transition to SYNCING if not already. + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO5c7 - Sync completion emits updates for existing objects + +**Test ID**: `objects/unit/RTO5c7/sync-emits-updates-0` + +**Spec requirement:** For each previously existing object updated by sync, emit the stored LiveObjectUpdate. + +### Setup +```pseudo +pool = ObjectsPool() +pool["root"].data = { + "name": { data: { string: "Old" }, timeserial: "01", tombstone: false } +} + +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "New" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length >= 1 +ASSERT "name" IN updates[0].update +ASSERT updates[0].update["name"] == "updated" +``` + +--- + +## RTO5f2b - Partial counter state logs error + +**Test ID**: `objects/unit/RTO5f2b/partial-counter-error-0` + +**Spec requirement:** If counter is present on partial merge, log error and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 5 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +``` + +--- + +## RTO4d - ATTACHED clears buffered operations + +**Test ID**: `objects/unit/RTO4d/attached-clears-buffer-0` + +**Spec requirement:** On ATTACHED, bufferedObjectOperations is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO4, RTO5 - ATTACHED during SYNCING resets sync + +**Test ID**: `objects/unit/RTO4-RTO5/attached-during-syncing-resets-0` + +**Spec requirement:** A new ATTACHED message during SYNCING resets the sync state machine. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +ASSERT pool.syncState == SYNCING +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5, RTO7 - New OBJECT_SYNC sequence does NOT clear buffer + +**Test ID**: `objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0` + +**Spec requirement:** When a new OBJECT_SYNC sequence starts (RTO5a2), only the SyncObjectsPool is discarded. Buffered OBJECT messages are retained for application after sync completion. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT pool["counter:abc@1000"].data == 105 +``` + +--- + +## RTO7, RTO8 - OBJECT messages buffered even without preceding ATTACHED + +**Test ID**: `objects/unit/RTO7-RTO8/buffer-without-attached-0` + +**Spec requirement:** RTO8a: if sync state is not SYNCED, buffer ObjectMessages. This applies regardless of whether ATTACHED was received — INITIALIZED state also buffers. + +### Setup +```pseudo +pool = ObjectsPool() +ASSERT pool.syncState == INITIALIZED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +--- + +## RTO5c, RTLM23 - Sync with clearTimeserial hides initial createOp entries + +**Test ID**: `objects/unit/RTO5c-RTLM23/sync-clear-timeserial-hides-create-entries-0` + +**Spec requirement:** When a map's ObjectState includes a clearTimeserial, createOp entries with serials <= clearTimeserial are rejected during merge. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: {}, + clearTimeserial: "05" + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "old_key": { data: { string: "old" }, timeserial: "03" }, + "new_key": { data: { string: "new" }, timeserial: "07" } + } + } + } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "old_key" NOT IN pool["root"].data +ASSERT pool["root"].data["new_key"].data == { string: "new" } +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md new file mode 100644 index 000000000..5a83c8e9c --- /dev/null +++ b/uts/objects/unit/path_object.md @@ -0,0 +1,603 @@ +# PathObject Read Operations Tests + +Spec points: `RTPO1`–`RTPO14` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO4 - path() returns dot-delimited string + +**Test ID**: `objects/unit/RTPO4/path-string-representation-0` + +| Spec | Requirement | +|------|-------------| +| RTPO4a | Dot-delimited string of path segments | +| RTPO4c | Empty path returns empty string | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.path() == "" +ASSERT root.get("profile").path() == "profile" +ASSERT root.get("profile").get("email").path() == "profile.email" +``` + +--- + +## RTPO4b - path() escapes dots in segments + +**Test ID**: `objects/unit/RTPO4b/path-escapes-dots-0` + +**Spec requirement:** Dot characters within segments are escaped with backslash. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.get("a.b").get("c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO5 - get() returns new PathObject with appended key + +**Test ID**: `objects/unit/RTPO5/get-appends-key-0` + +| Spec | Requirement | +|------|-------------| +| RTPO5c | New PathObject with key appended | +| RTPO5d | Purely navigational, no resolution | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child = root.get("profile") +grandchild = child.get("email") +``` + +### Assertions +```pseudo +ASSERT child.path() == "profile" +ASSERT grandchild.path() == "profile.email" +ASSERT child IS NOT root +``` + +--- + +## RTPO5b - get() throws on non-string key + +**Test ID**: `objects/unit/RTPO5b/get-non-string-throws-0` + +**Spec requirement:** If key is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.get(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO6 - at() parses dot-delimited path + +**Test ID**: `objects/unit/RTPO6/at-parses-path-0` + +| Spec | Requirement | +|------|-------------| +| RTPO6b | Parses dots as separators, backslash-escaped dots as literal | +| RTPO6d | Equivalent to chained get() calls | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("profile.email") +``` + +### Assertions +```pseudo +ASSERT po.path() == "profile.email" +ASSERT po.value() == "alice@example.com" +``` + +--- + +## RTPO6 - at() respects escaped dots + +**Test ID**: `objects/unit/RTPO6/at-escaped-dots-0` + +**Spec requirement:** `\.` is a literal dot within a segment. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("a\\.b.c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO7 - value() returns counter numeric value + +**Test ID**: `objects/unit/RTPO7/value-counter-0` + +**Spec requirement:** If resolved value is LiveCounter, returns numeric value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTPO7 - value() returns primitive value + +**Test ID**: `objects/unit/RTPO7/value-primitive-0` + +**Spec requirement:** If resolved value is a primitive, returns the value directly. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTPO7d - value() returns null for LiveMap + +**Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` + +**Spec requirement:** If resolved value is a LiveMap, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").value() == null +``` + +--- + +## RTPO7e - value() returns null on resolution failure + +**Test ID**: `objects/unit/RTPO7e/value-unresolvable-null-0` + +**Spec requirement:** If path resolution fails, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").get("deep").value() == null +``` + +--- + +## RTPO8 - instance() returns Instance for LiveObject + +**Test ID**: `objects/unit/RTPO8/instance-live-object-0` + +| Spec | Requirement | +|------|-------------| +| RTPO8b | LiveMap or LiveCounter -> Instance wrapping that object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst IS Instance +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst IS Instance +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTPO8c - instance() returns null for primitive + +**Test ID**: `objects/unit/RTPO8c/instance-primitive-null-0` + +**Spec requirement:** If resolved value is a primitive, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").instance() == null +``` + +--- + +## RTPO9 - entries() yields [key, PathObject] pairs + +**Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9b | Iterator of [key, PathObject] for LiveMap entries | +| RTPO9c | Only non-tombstoned entries | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, pathObj] IN root.entries(): + entries[key] = pathObj.path() +``` + +### Assertions +```pseudo +ASSERT entries["name"] == "name" +ASSERT entries["profile"] == "profile" +ASSERT entries.length == 7 +``` + +--- + +## RTPO9d - entries() returns empty iterator for non-LiveMap + +**Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` + +**Spec requirement:** If resolved value is not LiveMap or resolution fails, return empty iterator. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = list(root.get("score").entries()) +``` + +### Assertions +```pseudo +ASSERT entries.length == 0 +``` + +--- + +## RTPO12 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTPO12/size-count-0` + +**Spec requirement:** For LiveMap, returns non-tombstoned entry count. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +ASSERT root.get("profile").size() == 3 +``` + +--- + +## RTPO12c - size() returns null for non-LiveMap + +**Test ID**: `objects/unit/RTPO12c/size-non-map-null-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").size() == null +ASSERT root.get("name").size() == null +``` + +--- + +## RTPO13 - compact() recursively compacts LiveMap tree + +**Test ID**: `objects/unit/RTPO13/compact-recursive-0` + +| Spec | Requirement | +|------|-------------| +| RTPO13b1 | Each entry included, tombstoned excluded | +| RTPO13b2 | Nested LiveMap recursively compacted | +| RTPO13b3 | Nested LiveCounter resolved to number | +| RTPO13b4 | Primitives as-is | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["age"] == 30 +ASSERT result["active"] == true +ASSERT result["score"] == 100 +ASSERT result["data"] == {"tags": ["a", "b"]} +ASSERT result["avatar"] IS bytes [1, 2, 3] +ASSERT result["profile"]["email"] == "alice@example.com" +ASSERT result["profile"]["nested_counter"] == 5 +ASSERT result["profile"]["prefs"]["theme"] == "dark" +``` + +--- + +## RTPO13b5 - compact() handles cycles via shared reference + +**Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` + +**Spec requirement:** Cyclic references reuse the already-compacted in-memory object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compact() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] IS result +``` + +--- + +## RTPO13c - compact() returns number for LiveCounter + +**Test ID**: `objects/unit/RTPO13c/compact-counter-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").compact() == 100 +``` + +--- + +## RTPO14 - compactJson() encodes binary as base64 and cycles as objectId + +**Test ID**: `objects/unit/RTPO14/compact-json-0` + +| Spec | Requirement | +|------|-------------| +| RTPO14a1 | Binary as base64 strings | +| RTPO14a2 | Cycles as {objectId: ...} | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compactJson() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] == { "objectId": "map:profile@1000" } +``` + +--- + +## RTPO3 - Path resolution walks through LiveMaps + +**Test ID**: `objects/unit/RTPO3/path-resolution-walk-0` + +| Spec | Requirement | +|------|-------------| +| RTPO3a | Walk segments through LiveMaps | +| RTPO3b | Empty path resolves to root | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.value() == null +ASSERT root.get("profile").get("prefs").get("theme").value() == "dark" +``` + +--- + +## RTPO3a1 - Resolution fails if intermediate is not LiveMap + +**Test ID**: `objects/unit/RTPO3a1/intermediate-not-map-0` + +**Spec requirement:** Current object must be a LiveMap. If not, resolution fails. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").get("something").value() == null +``` + +--- + +## RTPO3c1 - Read operation returns null on resolution failure + +**Test ID**: `objects/unit/RTPO3c1/read-null-on-failure-0` + +**Spec requirement:** For read operations, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +ASSERT root.get("nonexistent").instance() == null +ASSERT root.get("nonexistent").size() == null +ASSERT root.get("nonexistent").compact() == null +``` + +--- + +## RTPO6b - at() throws for non-string input + +**Test ID**: `objects/unit/RTPO6b/at-non-string-throws-0` + +**Spec requirement:** If path is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.at(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO7 - value() returns bytes for binary entry + +**Test ID**: `objects/unit/RTPO7/value-bytes-0` + +**Spec requirement:** If resolved value is bytes, returns the raw binary data. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("avatar").value() IS bytes [1, 2, 3] +``` + +--- + +## RTPO14 - compactJson() encodes bytes as base64 string + +**Test ID**: `objects/unit/RTPO14/compact-json-bytes-0` + +**Spec requirement:** Binary values encoded as base64 strings in JSON representation. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compactJson() +``` + +### Assertions +```pseudo +ASSERT result["avatar"] == "AQID" +``` diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md new file mode 100644 index 000000000..ef33a1a15 --- /dev/null +++ b/uts/objects/unit/path_object_mutations.md @@ -0,0 +1,321 @@ +# PathObject Write Operations Tests + +Spec points: `RTPO15`–`RTPO18`, `RTPO3c2` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO15 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTPO15/set-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO15b | Resolves path, on failure throws RTPO3c2 | +| RTPO15c | LiveMap -> delegates to LiveMap#set | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTPO15 - set() on nested path + +**Test ID**: `objects/unit/RTPO15/set-nested-path-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("profile").set("email", "bob@example.com") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").get("email").value() == "bob@example.com" +``` + +--- + +## RTPO15d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO16 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTPO16/remove-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO16b | Resolves path, on failure throws RTPO3c2 | +| RTPO16c | LiveMap -> delegates to LiveMap#remove | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTPO16d - remove() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").remove("key") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO17 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTPO17/increment-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO17b | Resolves path, on failure throws RTPO3c2 | +| RTPO17c | LiveCounter -> delegates to LiveCounter#increment | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTPO17 - increment() defaults to 1 + +**Test ID**: `objects/unit/RTPO17/increment-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTPO17d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO18 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTPO18/decrement-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO18b | Resolves path, on failure throws RTPO3c2 | +| RTPO18c | LiveCounter -> delegates to LiveCounter#decrement | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTPO18 - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTPO18/decrement-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTPO18d - decrement() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.decrement(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO3c2 - set() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/set-unresolvable-throws-0` + +**Spec requirement:** For write operations, if path resolution fails, throw 92005. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` + +--- + +## RTPO3c2 - increment() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md new file mode 100644 index 000000000..503ac43f2 --- /dev/null +++ b/uts/objects/unit/path_object_subscribe.md @@ -0,0 +1,618 @@ +# PathObject Subscribe Tests + +Spec points: `RTPO19`–`RTPO21`, `RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO19 - subscribe() returns Subscription and receives events + +**Test ID**: `objects/unit/RTPO19/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTPO19c | Returns Subscription object | +| RTPO19d1 | Event.object is a PathObject pointing to change path | +| RTPO19d2 | Event.message is the ObjectMessage | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].message IS NOT null +``` + +--- + +## RTPO19b1b - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19b1b/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 1 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO19b1c - subscribe() with depth 2 receives self and children + +**Test ID**: `objects/unit/RTPO19b1c/subscribe-depth-2-children-0` + +**Spec requirement:** depth=n means changes up to n-1 levels of children trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTPO19b1a - subscribe() with no depth receives all descendants + +**Test ID**: `objects/unit/RTPO19b1a/subscribe-unlimited-depth-0` + +**Spec requirement:** If depth is undefined, subscription receives events at any depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +poll_until(events.length >= 3, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 3 +``` + +--- + +## RTPO19b1d - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-non-positive-depth-throws-0` + +**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19b1d - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-negative-depth-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19e - subscribe() follows path not identity + +**Test ID**: `objects/unit/RTPO19e/subscribe-follows-path-0` + +**Spec requirement:** If the object at the path changes identity, the subscription continues to deliver events for the new object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// Replace the counter at "score" with a new counter +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +// Increment the NEW counter at "score" +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:new@2000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Should receive event for the new counter, since subscription follows path +found_new = false +FOR event IN events: + IF event.object.path() == "score": + found_new = true +ASSERT found_new == true +``` + +--- + +## RTPO19f - child events bubble up to parent subscription + +**Test ID**: `objects/unit/RTPO19f/child-events-bubble-0` + +**Spec requirement:** Events at child paths bubble up subject to depth filtering. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("profile").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 2 +``` + +--- + +## RTO24b3 - depth filtering formula + +**Test ID**: `objects/unit/RTO24b3/depth-filtering-formula-0` + +**Spec requirement:** Event dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +// Subscribe at "profile" with depth 2: +// self (profile) → segmentDiff=0, 0+1=1 ≤ 2 ✓ +// child (profile.email) → segmentDiff=1, 1+1=2 ≤ 2 ✓ +// grandchild (profile.prefs.theme) → segmentDiff=2, 2+1=3 > 2 ✗ +root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +// Self event (profile map update) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +// Child event (nested counter) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +// Grandchild event (prefs.theme) — should NOT be received +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTO24b5 - listener exception does not affect other listeners + +**Test ID**: `objects/unit/RTO24b5/listener-exception-caught-0` + +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => { THROW Error("boom") }) +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO20 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTPO20/unsubscribe-deregisters-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTPO19g - subscribe() has no side effects + +**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +``` + +### Test Steps +```pseudo +root.get("score").subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTPO19 - MAP_CLEAR triggers subscription events on child paths + +**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` + +**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_clear("root", "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +``` + +--- + +## RTPO19 - subscribe() on primitive path receives change events + +**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` + +**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("name").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +ASSERT events[0].object.path() == "name" +``` + +--- + +## RTPO19d - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19d/event-path-object-correct-0` + +**Spec requirement:** RTPO19d1: event.object is a PathObject pointing to the change location. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].object.value() == 107 +``` + +--- + +## RTPO21 - subscribeIterator() yields events + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-yields-0` + +| Spec | Requirement | +|------|-------------| +| RTPO21b | Returns async iterable of PathObjectSubscriptionEvent | +| RTPO21d | Each iteration yields next event | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) + +event = AWAIT iter.next() +``` + +### Assertions +```pseudo +ASSERT event.object IS PathObject +ASSERT event.object.path() == "score" +``` + +--- + +## RTPO21 - subscribeIterator() with depth option + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-depth-0` + +**Spec requirement:** subscribeIterator accepts same options as subscribe, including depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.subscribeIterator({ depth: 1 }) +``` + +### Test Steps +```pseudo +// Self event (depth 1 allows) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +event = AWAIT iter.next() + +// Child event (depth 1 rejects — counter at depth 2) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT event.object.path() == "" +``` + +--- + +## RTPO21 - subscribeIterator() break cleanup + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-break-cleanup-0` + +**Spec requirement:** Breaking out of the iterator loop cleans up the underlying subscription. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +received = [] +``` + +### Test Steps +```pseudo +iter = root.get("score").subscribeIterator() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "99", "remote") +])) + +event = AWAIT iter.next() +received.append(event) + +// Break the iterator (cleanup) +iter.return() + +// Further events should not be received +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +``` + +--- + +## RTPO21 - subscribeIterator() multiple concurrent iterators + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-concurrent-0` + +**Spec requirement:** Multiple iterators can coexist independently. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter1 = root.get("score").subscribeIterator() +iter2 = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "99", "remote") +])) + +event1 = AWAIT iter1.next() +event2 = AWAIT iter2.next() +``` + +### Assertions +```pseudo +ASSERT event1.object.path() == "score" +ASSERT event2.object.path() == "score" +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md new file mode 100644 index 000000000..fd833be65 --- /dev/null +++ b/uts/objects/unit/realtime_object.md @@ -0,0 +1,927 @@ +# RealtimeObject Tests + +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_channel_no_ack`, and builder functions. + +--- + +## RTO23 - get() returns PathObject wrapping root + +**Test ID**: `objects/unit/RTO23/get-returns-path-object-0` + +| Spec | Requirement | +|------|-------------| +| RTO23d | Returns PathObject wrapping root LiveMap with empty path | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO23a - get() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO23a/get-requires-subscribe-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO23b - get() throws on DETACHED or FAILED channel + +**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` + +**Spec requirement:** If channel is DETACHED or FAILED, throw 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ) +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +``` + +--- + +## RTO23c - get() waits for SYNCED state + +**Test ID**: `objects/unit/RTO23c/get-waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, waits for SYNCED transition. + +### Setup +```pseudo +attach_sent = false +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_sent = true + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(attach_sent, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message( + "test", "sync1:", STANDARD_POOL_OBJECTS +)) + +root = AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +``` + +--- + +## RTO15 - publish sends OBJECT ProtocolMessage + +**Test ID**: `objects/unit/RTO15/publish-sends-object-pm-0` + +| Spec | Requirement | +|------|-------------| +| RTO15e1 | action set to OBJECT | +| RTO15e2 | channel set to channel name | +| RTO15e3 | state set to encoded ObjectMessages | +| RTO15h | Returns PublishResult from ACK | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +result = AWAIT channel.object.publish([ + build_counter_inc("counter:score@1000", 5, null, null) +]) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].action == OBJECT +ASSERT captured_messages[0].channel == "test" +ASSERT captured_messages[0].state.length == 1 +ASSERT result.serials == ["serial-0"] +``` + +--- + +## RTO20 - publishAndApply applies locally on ACK + +**Test ID**: `objects/unit/RTO20/publish-and-apply-local-0` + +| Spec | Requirement | +|------|-------------| +| RTO20b | Calls publish and awaits PublishResult | +| RTO20d2a | Synthetic message serial from PublishResult | +| RTO20d2b | Synthetic message siteCode from ConnectionDetails | +| RTO20f | Apply synthetic messages with source LOCAL | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20c - publishAndApply logs error when siteCode missing + +**Test ID**: `objects/unit/RTO20c/missing-site-code-0` + +| Spec | Requirement | +|------|-------------| +| RTO20c1 | Requires siteCode from ConnectionDetails | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20d1 - null serial in PublishResult is skipped + +**Test ID**: `objects/unit/RTO20d1/null-serial-skipped-0` + +**Spec requirement:** If serial from PublishResult is null, skip that ObjectMessage. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, [null])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20e - publishAndApply waits for SYNCED during SYNCING + +**Test ID**: `objects/unit/RTO20e/waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, wait for SYNCED transition. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait + +**Test ID**: `objects/unit/RTO20e1/fails-on-channel-failed-0` + +**Spec requirement:** If channel enters DETACHED/SUSPENDED/FAILED while waiting, fail with 92008. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) + +AWAIT inc_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO17, RTO18 - Sync state events + +**Test ID**: `objects/unit/RTO17/sync-state-events-0` + +| Spec | Requirement | +|------|-------------| +| RTO17b | Emit event matching new sync state | +| RTO18b1 | SYNCING event | +| RTO18b2 | SYNCED event | +| RTO18e | Listeners called with no arguments | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +events = [] +channel.object.on(SYNCING, () => events.append("SYNCING")) +channel.object.on(SYNCED, () => events.append("SYNCED")) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + +AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"] +``` + +--- + +## RTO18d - Duplicate listener registered twice fires twice + +**Test ID**: `objects/unit/RTO18d/duplicate-listener-0` + +**Spec requirement:** If same listener registered twice, it is invoked twice per event. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +channel.object.on(SYNCED, listener) +channel.object.on(SYNCED, listener) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +poll_until(call_count >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT call_count == 2 +``` + +--- + +## RTO19 - off() deregisters listener + +**Test ID**: `objects/unit/RTO19/off-deregisters-0` + +**Spec requirement:** Deregisters event listener previously registered via on(). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +sub = channel.object.on(SYNCED, listener) +sub.off() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) +``` + +### Assertions +```pseudo +ASSERT call_count == 0 +``` + +--- + +## RTO2 - Channel mode enforcement + +**Test ID**: `objects/unit/RTO2/mode-enforcement-0` + +| Spec | Requirement | +|------|-------------| +| RTO2a | ATTACHED state checks granted modes | +| RTO2b | Non-ATTACHED checks requested modes | +| RTO2a2 | Missing mode throws 40024 | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO10 - GC removes tombstoned objects past grace period + +**Test ID**: `objects/unit/RTO10/gc-tombstoned-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO10a | Check at regular intervals | +| RTO10c1b | Remove if difference >= grace period | +| RTO10b1 | Grace period from ConnectionDetails | + +### Setup +```pseudo +enable_fake_timers() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +ADVANCE_TIME(86400000 + 300000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO20 - Echo deduplication via appliedOnAckSerials + +**Test ID**: `objects/unit/RTO20/echo-dedup-0` + +**Spec requirement:** When echo arrives with same serial as applied-on-ACK, it is deduplicated. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +score_after_apply = root.get("score").value() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) +score_after_echo = root.get("score").value() +``` + +### Assertions +```pseudo +ASSERT score_after_apply == 110 +ASSERT score_after_echo == 110 +``` + +--- + +## RTO20f - Apply-on-ACK does not update siteTimeserials + +**Test ID**: `objects/unit/RTO20f/ack-no-site-timeserials-update-0` + +| Spec | Requirement | +|------|-------------| +| RTO20f | Apply with source LOCAL | +| RTLC7c2 | LOCAL source does not update siteTimeserials | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +site_serials_before = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +site_serials_after = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Assertions +```pseudo +ASSERT site_serials_after == site_serials_before +``` + +--- + +## RTO20 - ACK after echo does not double-apply + +**Test ID**: `objects/unit/RTO20/ack-after-echo-no-double-apply-0` + +**Spec requirement:** If the echo arrives before the ACK is processed, the ACK-based apply finds the serial already applied and deduplicates via RTO9a3. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel_no_ack("test") +``` + +### Test Steps +```pseudo +inc_future = root.increment(10) + +// Send the echo BEFORE the ACK +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) + +// Now send the ACK +mock_ws.send_to_client(build_ack_message(0, ["ack-0:0"])) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO5c9, RTO20 - appliedOnAckSerials cleared on re-sync + +**Test ID**: `objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0` + +**Spec requirement:** appliedOnAckSerials is cleared when sync completes. After re-sync, an echo with a previously-applied serial is applied normally (not deduplicated). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +ASSERT root.get("score").value() == 110 + +// Trigger re-sync +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +// After re-sync, the score is back to 100 (from pool state) +ASSERT root.get("score").value() == 100 +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20 - Subscription fires on apply-on-ACK + +**Test ID**: `objects/unit/RTO20/subscription-fires-on-ack-apply-0` + +**Spec requirement:** When publishAndApply applies locally via ACK, subscription listeners are notified. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO23 - get() implicitly attaches channel + +**Test ID**: `objects/unit/RTO23/get-implicit-attach-0` + +**Spec requirement:** get() triggers attach if channel is not yet attached. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +ASSERT channel.state == INITIALIZED +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT channel.state == ATTACHED +``` + +--- + +## RTO23d - get() resolves immediately when already SYNCED + +**Test ID**: `objects/unit/RTO23d/get-resolves-immediately-synced-0` + +**Spec requirement:** If sync state is already SYNCED, get() resolves immediately. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root2 = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root2 IS PathObject +ASSERT root2.path() == "" +``` + +--- + +## RTO10b1 - GC grace period from ConnectionDetails + +**Test ID**: `objects/unit/RTO10b1/gc-grace-period-source-0` + +**Spec requirement:** GC grace period comes from ConnectionDetails.objectsGCGracePeriod. + +### Setup +```pseudo +enable_fake_timers() +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 5000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +// Short grace period (5000ms) — advance past it +ADVANCE_TIME(5000 + 1000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO17, RTO18 - Sync event sequences for all state transitions + +**Test ID**: `objects/unit/RTO17-RTO18/sync-event-sequences-0` + +**Spec requirement:** Verify all sync state transition sequences. + +### Setup +```pseudo +scenarios = [ + { + name: "initial attach", + trigger: () => { + channel.attach() + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-attach after detach", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: "test")) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-sync on new ATTACHED", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync3:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync3:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "ATTACHED without HAS_OBJECTS", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync4:", flags: 0 + )) + }, + expected_events: ["SYNCED"] + } +] + +FOR scenario IN scenarios: + { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + events = [] + channel.object.on(SYNCING, () => events.append("SYNCING")) + channel.object.on(SYNCED, () => events.append("SYNCED")) + + scenario.trigger() + poll_until(events.length >= scenario.expected_events.length, timeout: 5s) + + ASSERT events == scenario.expected_events +``` diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md new file mode 100644 index 000000000..dc99aec26 --- /dev/null +++ b/uts/objects/unit/value_types.md @@ -0,0 +1,451 @@ +# Value Types Tests + +Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` + +## Test Type +Unit test — pure construction and consumption, no mocks required. + +## Purpose + +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When consumed by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). + +--- + +## RTLCV3 - LiveCounter.create with initial count + +**Test ID**: `objects/unit/RTLCV3/create-with-count-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV3a1 | Accepts optional initialCount | +| RTLCV3b | Returns LiveCounterValueType with internal count | +| RTLCV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +ASSERT vt.count == 42 +``` + +--- + +## RTLCV3 - LiveCounter.create defaults to 0 + +**Test ID**: `objects/unit/RTLCV3/create-default-zero-0` + +**Spec requirement:** If initialCount omitted, defaults to 0. + +### Test Steps +```pseudo +vt = LiveCounter.create() +``` + +### Assertions +```pseudo +ASSERT vt.count == 0 +``` + +--- + +## RTLCV3c - No validation at creation time + +**Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` + +**Spec requirement:** No input validation is performed at creation time; deferred to consumption. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +``` + +--- + +## RTLCV4 - Consumption generates COUNTER_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLCV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV4b1 | CounterCreate.count set to internal count | +| RTLCV4c | Initial value JSON string from CounterCreate | +| RTLCV4d | Unique nonce with 16+ characters | +| RTLCV4f | objectId generated via RTO14 with type "counter" | +| RTLCV4g1 | action set to COUNTER_CREATE | +| RTLCV4g2 | objectId set | +| RTLCV4g3 | counterCreateWithObjectId.nonce set | +| RTLCV4g4 | counterCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "COUNTER_CREATE" +ASSERT msg.operation.objectId STARTS WITH "counter:" +ASSERT msg.operation.objectId CONTAINS "@" +ASSERT msg.operation.counterCreateWithObjectId IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.counterCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLCV4g5 - Consumption retains local CounterCreate + +**Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` + +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate IS NOT null +ASSERT msg.operation.counterCreate.count == 42 +``` + +--- + +## RTLCV4a - Consumption validates count type + +**Test ID**: `objects/unit/RTLCV4a/consume-validates-count-0` + +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLCV4 - Consumption with count 0 + +**Test ID**: `objects/unit/RTLCV4/consume-zero-count-0` + +**Spec requirement:** count=0 is valid and should be included in CounterCreate. + +### Test Steps +```pseudo +vt = LiveCounter.create(0) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate.count == 0 +``` + +--- + +## RTLMV3 - LiveMap.create with entries + +**Test ID**: `objects/unit/RTLMV3/create-with-entries-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV3a1 | Accepts optional entries dict | +| RTLMV3b | Returns LiveMapValueType with internal entries | +| RTLMV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "name": "Alice", + "age": 30 +}) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +ASSERT vt.entries["name"] == "Alice" +ASSERT vt.entries["age"] == 30 +``` + +--- + +## RTLMV3 - LiveMap.create with no entries + +**Test ID**: `objects/unit/RTLMV3/create-no-entries-0` + +**Spec requirement:** If entries omitted, internal entries is undefined. + +### Test Steps +```pseudo +vt = LiveMap.create() +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +``` + +--- + +## RTLMV4 - Consumption generates MAP_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLMV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4e1 | MapCreate.semantics set to LWW | +| RTLMV4f | Initial value JSON string | +| RTLMV4g | Unique nonce 16+ chars | +| RTLMV4i | objectId via RTO14 with type "map" | +| RTLMV4j1 | action set to MAP_CREATE | +| RTLMV4j3 | mapCreateWithObjectId.nonce set | +| RTLMV4j4 | mapCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "MAP_CREATE" +ASSERT msg.operation.objectId STARTS WITH "map:" +ASSERT msg.operation.mapCreateWithObjectId IS NOT null +ASSERT msg.operation.mapCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.mapCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLMV4j5 - Consumption retains local MapCreate + +**Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` + +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate IS NOT null +ASSERT msg.operation.mapCreate.semantics == "LWW" +ASSERT msg.operation.mapCreate.entries["name"].data.string == "Alice" +``` + +--- + +## RTLMV4d - Entry value type mapping + +**Test ID**: `objects/unit/RTLMV4d/entry-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d3 | JsonArray/JsonObject -> data.json | +| RTLMV4d4 | String -> data.string | +| RTLMV4d5 | Number -> data.number | +| RTLMV4d6 | Boolean -> data.boolean | +| RTLMV4d7 | Binary -> data.bytes | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "str": "hello", + "num": 42, + "bool": true, + "json_arr": [1, 2, 3], + "json_obj": { "key": "value" } +}) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +entries = msg.operation.mapCreate.entries +ASSERT entries["str"].data.string == "hello" +ASSERT entries["num"].data.number == 42 +ASSERT entries["bool"].data.boolean == true +ASSERT entries["json_arr"].data.json == [1, 2, 3] +ASSERT entries["json_obj"].data.json == { "key": "value" } +``` + +--- + +## RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages + +**Test ID**: `objects/unit/RTLMV4d1/nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d1 | LiveCounterValueType consumed, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively consumed, all ObjectMessages collected | +| RTLMV4k | Return depth-first order: inner creates before outer | + +### Test Steps +```pseudo +inner_counter = LiveCounter.create(10) +inner_map = LiveMap.create({ + "nested_count": inner_counter +}) +outer = LiveMap.create({ + "child": inner_map +}) +messages = consume(outer) +``` + +### Assertions +```pseudo +ASSERT messages.length == 3 +ASSERT messages[0].operation.action == "COUNTER_CREATE" +ASSERT messages[0].operation.objectId STARTS WITH "counter:" +ASSERT messages[1].operation.action == "MAP_CREATE" +ASSERT messages[1].operation.objectId STARTS WITH "map:" +ASSERT messages[2].operation.action == "MAP_CREATE" +ASSERT messages[2].operation.objectId STARTS WITH "map:" + +inner_counter_id = messages[0].operation.objectId +inner_map_id = messages[1].operation.objectId +outer_map_id = messages[2].operation.objectId + +ASSERT messages[1].operation.mapCreate.entries["nested_count"].data.objectId == inner_counter_id +ASSERT messages[2].operation.mapCreate.entries["child"].data.objectId == inner_map_id +``` + +--- + +## RTLMV4a - Consumption validates entries type + +**Test ID**: `objects/unit/RTLMV4a/consume-validates-entries-0` + +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create(null) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4b - Consumption validates key types + +**Test ID**: `objects/unit/RTLMV4b/consume-validates-keys-0` + +**Spec requirement:** If any key is not String, throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create({ 123: "value" }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4c - Consumption validates value types + +**Test ID**: `objects/unit/RTLMV4c/consume-validates-values-0` + +**Spec requirement:** If any value is not an expected type, throw 40013. + +### Test Steps +```pseudo +vt = LiveMap.create({ "fn": some_function }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40013 +``` + +--- + +## RTLMV4e2 - Empty entries produces MapCreate with empty entries + +**Test ID**: `objects/unit/RTLMV4e2/empty-entries-0` + +**Spec requirement:** If internal entries is undefined, MapCreate.entries is empty map. + +### Test Steps +```pseudo +vt = LiveMap.create() +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate.entries == {} +``` + +--- + +## RTLMV4d - Table-driven MAP_SET value type mapping + +**Test ID**: `objects/unit/RTLMV4d/map-set-all-types-table-0` + +**Spec requirement:** Every supported value type maps to the correct data field. + +### Test Steps +```pseudo +type_scenarios = [ + { input: "hello", expected_field: "string", expected_value: "hello" }, + { input: 42, expected_field: "number", expected_value: 42 }, + { input: 3.14, expected_field: "number", expected_value: 3.14 }, + { input: 0, expected_field: "number", expected_value: 0 }, + { input: -1, expected_field: "number", expected_value: -1 }, + { input: true, expected_field: "boolean", expected_value: true }, + { input: false, expected_field: "boolean", expected_value: false }, + { input: [1, "a", null], expected_field: "json", expected_value: [1, "a", null] }, + { input: { "k": "v" }, expected_field: "json", expected_value: { "k": "v" } }, + { input: bytes([1, 2, 3]), expected_field: "bytes", expected_value: "AQID" } +] + +FOR scenario IN type_scenarios: + vt = LiveMap.create({ "test_key": scenario.input }) + messages = consume(vt) + entry = messages[0].operation.mapCreate.entries["test_key"] + ASSERT entry.data[scenario.expected_field] == scenario.expected_value +``` From 3de4a4fcc6219740601d584cc14ef6cd465d3f20 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Thu, 14 May 2026 08:16:21 +0100 Subject: [PATCH 02/16] Delegate proxy port assignment to uts-proxy in all test specs Remove client-side allocated_port/port_base patterns from all proxy test specs and helper docs. Port is now auto-assigned by the proxy when omitted from create_proxy_session(). Matches uts-proxy v0.2.0. Co-Authored-By: Claude Opus 4.6 --- uts/docs/integration-testing.md | 1 - uts/docs/writing-test-specs.md | 1 - .../integration/proxy/objects_faults.md | 10 ++--- uts/realtime/integration/helpers/proxy.md | 5 +-- uts/realtime/integration/proxy/auth_reauth.md | 13 +----- .../integration/proxy/channel_faults.md | 14 +++--- .../proxy/connection_open_failures.md | 10 ++--- .../integration/proxy/connection_resume.md | 44 +++++-------------- uts/realtime/integration/proxy/heartbeat.md | 2 +- uts/realtime/integration/proxy/rest_faults.md | 6 +-- uts/rest/integration/proxy/rest_fallback.md | 14 +++--- 11 files changed, 42 insertions(+), 78 deletions(-) diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md index fa7fd0a6c..cca26a715 100644 --- a/uts/docs/integration-testing.md +++ b/uts/docs/integration-testing.md @@ -157,7 +157,6 @@ Proxy tests additionally set up a proxy session per test or group of tests. See BEFORE EACH TEST: session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, rules: [ ...initial rules... ] ) diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md index 1102181a8..4abce6575 100644 --- a/uts/docs/writing-test-specs.md +++ b/uts/docs/writing-test-specs.md @@ -344,7 +344,6 @@ Tests that [behaviour] when the proxy injects [fault]. ```pseudo session = create_proxy_session( target: TargetConfig(realtimeHost: "sandbox.realtime.ably-nonprod.net", restHost: "sandbox.realtime.ably-nonprod.net"), - port: allocated_port, rules: [{ "match": { ... }, "action": { ... }, diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md index 24069b737..8988a0191 100644 --- a/uts/objects/integration/proxy/objects_faults.md +++ b/uts/objects/integration/proxy/objects_faults.md @@ -80,7 +80,7 @@ channel_name = "objects-sync-interrupt-" + random_id() // Disconnect after first OBJECT_SYNC frame session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": 20 }, "action": { "type": "disconnect" }, @@ -163,7 +163,7 @@ AWAIT root_a.set("key1", "initial") // Client B: through proxy, will be disconnected session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -242,7 +242,7 @@ channel_name = "objects-detach-resync-" + random_id() session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -318,7 +318,7 @@ channel_name = "objects-publish-failed-" + random_id() session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -403,7 +403,7 @@ AWAIT root_a.set("existing", "before") // Client B: through proxy with delayed OBJECT_SYNC session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": 20 }, "action": { "type": "delay", "delayMs": 3000 }, diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md index 18274311e..303d8ea74 100644 --- a/uts/realtime/integration/helpers/proxy.md +++ b/uts/realtime/integration/helpers/proxy.md @@ -20,7 +20,6 @@ Proxy integration tests use this to verify fault-handling behaviour against the # 1. Create a proxy session with rules session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, rules: [ ...rules... ] ) @@ -58,7 +57,7 @@ session.close() interface ProxySession: session_id: String proxy_host: String # Always "localhost" - proxy_port: Int # Assigned from port pool + proxy_port: Int # Auto-assigned by proxy, or explicit if specified add_rules(rules: List, position?: "append"|"prepend") trigger_action(action: ActionRequest) @@ -67,7 +66,7 @@ interface ProxySession: function create_proxy_session( endpoint: String, # e.g. "nonprod:sandbox" → resolves to sandbox.realtime.ably-nonprod.net - port: Int, + port?: Int, # Optional; proxy auto-assigns a free port if omitted rules?: List, timeoutMs?: Int # Session auto-cleanup timeout (default 30000) ): ProxySession diff --git a/uts/realtime/integration/proxy/auth_reauth.md b/uts/realtime/integration/proxy/auth_reauth.md index 6d704c966..edb4d9894 100644 --- a/uts/realtime/integration/proxy/auth_reauth.md +++ b/uts/realtime/integration/proxy/auth_reauth.md @@ -31,16 +31,6 @@ AFTER ALL TESTS: WITH Authorization: Basic {api_key} ``` -## Port Allocation - -Each test allocates a unique proxy port to avoid conflicts: - -```pseudo -BEFORE ALL TESTS: - port_base = allocate_port_range(count: 1) - # Tests use port_base + 0 -``` - --- ## Test 26: RTN22/RTC8a -- Server-initiated re-authentication @@ -63,7 +53,6 @@ Tests that when the proxy injects a server-initiated AUTH ProtocolMessage (actio ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [] ) ``` @@ -83,7 +72,7 @@ auth_callback = FUNCTION(params, callback): client = Realtime(options: ClientOptions( authCallback: auth_callback, endpoint: "localhost", - port: port_base + 0, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false diff --git a/uts/realtime/integration/proxy/channel_faults.md b/uts/realtime/integration/proxy/channel_faults.md index 1035d6772..35b2da0fd 100644 --- a/uts/realtime/integration/proxy/channel_faults.md +++ b/uts/realtime/integration/proxy/channel_faults.md @@ -71,7 +71,7 @@ channel_name = "test-RTL4f-${random_id()}" # Create proxy session that suppresses ATTACH messages for our channel session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_server", "action": "ATTACH", "channel": channel_name }, "action": { "type": "suppress" }, @@ -177,7 +177,7 @@ channel_name = "test-RTL14-error-on-attach-${random_id()}" # Create proxy session that replaces ATTACHED with channel ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "ATTACHED", "channel": channel_name }, "action": { @@ -274,7 +274,7 @@ channel_name = "test-RTL5f-${random_id()}" # Phase 1: Create proxy session with NO fault rules (clean passthrough) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -374,7 +374,7 @@ channel_name = "test-RTL13a-${random_id()}" # Create proxy session with clean passthrough (no fault rules initially) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -471,7 +471,7 @@ channel_name = "test-RTL14-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -563,7 +563,7 @@ channel_name = "test-RTL12-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -666,7 +666,7 @@ channel_b_name = "test-RTL3d-b-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) diff --git a/uts/realtime/integration/proxy/connection_open_failures.md b/uts/realtime/integration/proxy/connection_open_failures.md index 44b65ae79..0c79673db 100644 --- a/uts/realtime/integration/proxy/connection_open_failures.md +++ b/uts/realtime/integration/proxy/connection_open_failures.md @@ -66,7 +66,7 @@ Tests that when the server responds with a fatal ERROR (non-token error code) du # Create proxy session that replaces the first CONNECTED with a fatal ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -151,7 +151,7 @@ auth_callback_count = 0 # Create proxy session that injects token error on first CONNECTED only session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -246,7 +246,7 @@ Tests that when the first WebSocket connection is refused at the transport level # Create proxy session that refuses the first WebSocket connection session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_connect", "count": 1 }, "action": { "type": "refuse_connection" }, @@ -325,7 +325,7 @@ Tests that when the server responds with a connection-level ERROR (no channel fi # Create proxy session that replaces the first CONNECTED with a server ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -408,7 +408,7 @@ Tests that when the server accepts the WebSocket but never sends a CONNECTED mes # Create proxy session that suppresses all CONNECTED messages session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { "type": "suppress" }, diff --git a/uts/realtime/integration/proxy/connection_resume.md b/uts/realtime/integration/proxy/connection_resume.md index 3c908366a..1621137a1 100644 --- a/uts/realtime/integration/proxy/connection_resume.md +++ b/uts/realtime/integration/proxy/connection_resume.md @@ -28,16 +28,6 @@ AFTER ALL TESTS: WITH Authorization: Basic {api_key} ``` -## Port Allocation - -Each test allocates a unique proxy port to avoid conflicts: - -```pseudo -BEFORE ALL TESTS: - port_base = allocate_port_range(count: 11) - # Tests use port_base + 0 through port_base + 10 -``` - --- ## Test 6: RTN15a - Unexpected disconnect triggers resume @@ -59,7 +49,6 @@ Tests that an unexpected transport disconnect causes the SDK to reconnect and at ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -79,7 +68,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 0, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -163,7 +152,6 @@ frame) after a 1-second delay. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -211,7 +199,6 @@ Tests that after an unexpected disconnect and successful resume, the connection ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 1, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -231,7 +218,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 1, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -309,7 +296,6 @@ Tests that when a resume fails (simulated by the proxy replacing the server's se ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 2, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -358,7 +344,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 2, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -442,7 +428,6 @@ Tests that when the proxy injects a DISCONNECTED message with a token error (cod ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 3, rules: [ { "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, @@ -479,7 +464,7 @@ token_string = token_details.token client = Realtime(options: ClientOptions( token: token_string, endpoint: "localhost", - port: port_base + 3, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -553,7 +538,6 @@ Tests that when the proxy injects a DISCONNECTED message with a non-token error ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 4, rules: [ { "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, @@ -583,7 +567,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 4, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -669,7 +653,6 @@ Tests that a connection-level ERROR ProtocolMessage (no channel field) causes th ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 5, rules: [] ) ``` @@ -682,7 +665,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 5, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -799,7 +782,6 @@ Tests that when the client has been disconnected for longer than connectionState ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 6, rules: [ { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, @@ -849,7 +831,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 6, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, @@ -932,7 +914,6 @@ Tests that a message awaiting ACK on the old transport is resent after reconnect ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 7, rules: [ { "match": { "type": "ws_frame_to_client", "action": "ACK" }, @@ -954,7 +935,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 7, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -1067,7 +1048,6 @@ Use a direct proxy session (passthrough, no rules) to connect to the sandbox, at ```pseudo session_1 = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 8, rules: [] ) @@ -1076,7 +1056,7 @@ client_1 = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 8, + port: session_1.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -1090,7 +1070,6 @@ A second proxy session is used so we can inspect the `recover` query parameter i ```pseudo session_2 = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 9, rules: [] ) ``` @@ -1140,7 +1119,7 @@ client_2 = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 9, + port: session_2.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, @@ -1206,7 +1185,6 @@ Tests that when a recovery attempt fails (the server responds with a new connect ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 10, rules: [ { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, @@ -1257,7 +1235,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 10, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, diff --git a/uts/realtime/integration/proxy/heartbeat.md b/uts/realtime/integration/proxy/heartbeat.md index 8f6e1e2b3..e213436b7 100644 --- a/uts/realtime/integration/proxy/heartbeat.md +++ b/uts/realtime/integration/proxy/heartbeat.md @@ -66,7 +66,7 @@ The proxy closes the WebSocket connection after a 2s delay from ws_connect, simu # Create proxy session that closes the WebSocket after 2s to simulate transport failure session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "delay_after_ws_connect", "delayMs": 2000 }, "action": { "type": "close" }, diff --git a/uts/realtime/integration/proxy/rest_faults.md b/uts/realtime/integration/proxy/rest_faults.md index 7fdeb4592..e93ce3cf9 100644 --- a/uts/realtime/integration/proxy/rest_faults.md +++ b/uts/realtime/integration/proxy/rest_faults.md @@ -89,7 +89,7 @@ auth_callback_count = 0 # Create proxy session that returns 401 on the first channel request session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, "action": { @@ -175,7 +175,7 @@ Tests that when a REST request receives an HTTP 503 (Service Unavailable) and th # Create proxy session that returns 503 on the first channel request session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, "action": { @@ -251,7 +251,7 @@ Tests that the proxy transparently forwards both WebSocket and HTTP traffic with # Create proxy session with no rules (pure passthrough) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) diff --git a/uts/rest/integration/proxy/rest_fallback.md b/uts/rest/integration/proxy/rest_fallback.md index bd3b13232..51ffc5261 100644 --- a/uts/rest/integration/proxy/rest_fallback.md +++ b/uts/rest/integration/proxy/rest_fallback.md @@ -100,7 +100,7 @@ a fallback host (also routed through the proxy) and succeeds. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -160,7 +160,7 @@ fallback host. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -273,7 +273,7 @@ on the retry. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -328,7 +328,7 @@ are configured, so the error propagates directly to the caller. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -382,7 +382,7 @@ non-parseable body while still returning valid JSON. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -435,7 +435,7 @@ should trigger fallback; 4xx errors indicate a client-side problem. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -502,7 +502,7 @@ on the library-generated message `id`. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "method": "POST", "pathContains": "/channels/" }, "action": { From 2fba05edcb09f81ef7090dd19d8131c018184afb Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Wed, 27 May 2026 22:52:50 +0100 Subject: [PATCH 03/16] Update UTS test specs to match LiveObjects path-based API spec (a397e34) Align all ~330 LiveObjects UTS test specs with the squashed spec revision a397e34 (LiveObjects path-based API). Key changes: - Add parent_references.md (20 tests): RTLO3f, RTLO4g/4h, RTLO4f, RTO5c10 - Add public_object_message.md (13 tests): PAOM1-3, PAOOP1-3 - Thread ObjectMessage through all CRDT operations and LiveObjectUpdate - Add RTO25 (access preconditions) and RTO26 (write preconditions) - Update subscription model: subscribe returns Subscription object - Add RTO24 (PathObjectSubscriptionRegister) dispatch tests - Add parentReferences maintenance tests to live_map.md (+8 tests) - Add post-sync parentReferences rebuild tests to objects_pool.md (+3) - Rename "consume"/"consumption" to "evaluate"/"evaluation" in value_types - Remove batch.md (Batch API deferred from current spec revision) - Remove subscribeIterator and LiveObject#unsubscribe tests - Update PLAN.md to reflect new file structure and test counts Co-Authored-By: Claude Opus 4.6 --- uts/objects/PLAN.md | 126 ++-- uts/objects/helpers/standard_test_pool.md | 45 ++ uts/objects/unit/batch.md | 782 ---------------------- uts/objects/unit/instance.md | 230 ++++--- uts/objects/unit/live_counter.md | 31 +- uts/objects/unit/live_counter_api.md | 92 +-- uts/objects/unit/live_map.md | 414 +++++++++++- uts/objects/unit/live_map_api.md | 143 ++-- uts/objects/unit/live_object_subscribe.md | 250 +++++-- uts/objects/unit/objects_pool.md | 225 ++++++- uts/objects/unit/parent_references.md | 734 ++++++++++++++++++++ uts/objects/unit/path_object.md | 206 +++++- uts/objects/unit/path_object_mutations.md | 67 +- uts/objects/unit/path_object_subscribe.md | 514 +++++++++----- uts/objects/unit/public_object_message.md | 555 +++++++++++++++ uts/objects/unit/realtime_object.md | 444 +++++++++++- uts/objects/unit/value_types.md | 80 +-- 17 files changed, 3552 insertions(+), 1386 deletions(-) delete mode 100644 uts/objects/unit/batch.md create mode 100644 uts/objects/unit/parent_references.md create mode 100644 uts/objects/unit/public_object_message.md diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md index 3cc547856..a95734829 100644 --- a/uts/objects/PLAN.md +++ b/uts/objects/PLAN.md @@ -2,7 +2,7 @@ ## Context -The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — specifically the path-based API version on branch `origin/AIT-30/liveobjects-path-based-api-spec` (with batch API additions on `origin/AIT-30/liveobjects-batch-api`). +The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — the path-based API version squashed as commit `a397e34` ("LiveObjects path-based API spec"). An earlier attempt at UTS test specs exists in `uts/test/realtime/unit/objects/` (14 files). It was written against a different spec namespace (PO* vs RTPO*/RTINS*/RTLCV*/RTLMV*), used v5 wire format field names, had apply-on-ACK contradictions, and duplicated setup across files. We're doing a clean rewrite using the correct spec, informed by that earlier work. @@ -12,7 +12,7 @@ All new test files go in `specification/uts/objects/`. **Internal (not user-facing):** LiveObject, LiveCounter (CRDT counter), LiveMap (LWW map), ObjectsPool (sync state machine), RealtimeObject (channel orchestrator with publishAndApply) -**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), BatchContext (atomic multi-op publish) +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), PublicAPI::ObjectMessage/ObjectOperation (user-facing event metadata) **Wire protocol v6:** `counterInc.number`, `mapSet.{key,value}`, `mapRemove.key`, `mapCreate.{semantics,entries}`, `counterCreateWithObjectId.{nonce,initialValue}`, `mapCreateWithObjectId.{nonce,initialValue}` @@ -30,31 +30,32 @@ All new test files go in `specification/uts/objects/`. ### Pure Unit Tests (no mocks) | File | Spec Points | ~Tests | |------|-------------|--------| -| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~28 | -| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~42 | -| `unit/objects_pool.md` | RTO3-9 | ~35 | +| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3-6, RTLO4b4d-e | ~23 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3-6, RTLO4g-h, RTLO4e9 | ~38 | +| `unit/objects_pool.md` | RTO3-9, RTO5c10 | ~28 | | `unit/object_id.md` | RTO14 | ~5 | -| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (consumption generates ObjectMessages with v6 wire format) | ~19 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (evaluation generates ObjectMessages with v6 wire format) | ~19 | +| `unit/parent_references.md` | RTLO3f, RTLO4f-h, RTO5c10 (parentReferences, getFullPaths, add/remove/rebuild) | ~20 | +| `unit/public_object_message.md` | PAOM1-3, PAOOP1-3 (PublicAPI::ObjectMessage/ObjectOperation construction) | ~13 | ### Mock WebSocket Unit Tests | File | Spec Points | ~Tests | |------|-------------|--------| -| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-24 (sync events, publish, publishAndApply, mode checks, GC) | ~33 | +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-26 (sync events, publish, publishAndApply, GC, RTO24/25/26 preconditions) | ~36 | | `unit/live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | -| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24 (reads + mutations through channel, echoMessages check) | ~18 | -| `unit/live_object_subscribe.md` | RTLO4b, RTLO4c (subscribe/unsubscribe on internal LiveObject) | ~8 | -| `unit/path_object.md` | RTPO1-14 (navigation, value, instance, entries, compact, compactJson) | ~33 | -| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2 (set, remove, increment, decrement, error on unresolvable path) | ~12 | -| `unit/path_object_subscribe.md` | RTPO19-21, RTO24 (path subscriptions, depth filtering, path-following semantics, subscribeIterator) | ~20 | -| `unit/instance.md` | RTINS1-18 (id, value, get, entries, size, compact, set, remove, increment, subscribe) | ~26 | -| `unit/batch.md` | RTPO22, RTINS19, RTBC1-16 (batch entry, BatchContext methods, RootBatchContext flush/close) | ~20 | +| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24, RTLCV4, RTLMV4 (reads + mutations, value type evaluation) | ~20 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4b4c3, RTLO4b4d-e, RTLO4b7 (subscribe, dispatch chain, tombstone cleanup, Subscription) | ~11 | +| `unit/path_object.md` | RTPO1-14, RTO25 (navigation, value, instance, entries, compact, compactJson, access preconditions) | ~27 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2, RTO26 (set, remove, increment, decrement, write preconditions) | ~14 | +| `unit/path_object_subscribe.md` | RTPO19, RTO24 (path subscriptions, depth filtering, dispatch, PAOM delivery) | ~22 | +| `unit/instance.md` | RTINS1-16 (id, value, get, entries, size, compact, set, remove, increment, subscribe, RTO25/26) | ~21 | ### Integration Tests (sandbox) | File | Spec Points | ~Tests | |------|-------------|--------| | `integration/objects_lifecycle_test.md` | RTO23, RTPO15, RTPO17 (create objects, mutate via PathObject, read back, REST provisioning) | ~6 | | `integration/objects_sync_test.md` | RTO4, RTO5, RTO17 (attach, sync sequence, re-attach) | ~4 | -| `integration/objects_batch_test.md` | RTPO22, RTBC12-15 (batch publish, atomic delivery) | ~3 | +| ~~`integration/objects_batch_test.md`~~ | ~~Batch API not in current spec revision~~ | — | | `integration/objects_gc_test.md` | RTO10, RTLM19 (behavioral GC verification with ADVANCE_TIME) | ~2 | ### Proxy Integration Tests @@ -62,7 +63,7 @@ All new test files go in `specification/uts/objects/`. |------|-------------|--------| | `integration/proxy/objects_faults.md` | RTO5a2, RTO7, RTO8, RTO17, RTO20e (sync interruption, mutation buffering during re-sync, server-initiated detach, publish failure on FAILED channel, publish during delayed sync) | ~5 | -**Totals: ~21 files, ~330 tests** +**Totals: ~20 files, ~310 tests** --- @@ -198,17 +199,17 @@ Pure function tests: ### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType -Tests the static `create()` factories and consumption procedure. +Tests the static `create()` factories and evaluation procedure. **LiveCounterValueType (RTLCV1-4):** 1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 2. `LiveCounter.create()` -> count defaults to 0 -3. Consumption: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` -4. Non-number count throws 40003 during consumption +3. Evaluation: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during evaluation **LiveMapValueType (RTLMV1-4):** 1. `LiveMap.create({entries})` -> immutable LiveMapValueType -2. Consumption: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` +2. Evaluation: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` 3. Nested value types: LiveMapValueType containing LiveCounterValueType -> depth-first ObjectMessage array (inner creates before outer) 4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) @@ -253,14 +254,16 @@ Uses `setup_synced_channel()` from helper. ### `unit/path_object_subscribe.md` -- Path-Based Subscriptions -- **RTPO19:** subscribe returns Subscription, listener receives PathObjectSubscriptionEvent -- **RTPO19b1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) -- **RTPO19b1d:** non-positive depth throws 40003 -- **RTPO19e:** follows path not identity -- object replacement at path -> subscription tracks new object -- **RTPO19f:** child events bubble up to parent subscription -- **RTO24b3:** depth formula: `eventPath.length - subscriptionPath.length + 1 <= depth` -- **RTO24b5:** listener exception caught, doesn't affect other listeners -- **RTPO20:** unsubscribe deregisters +- **RTPO19:** subscribe returns Subscription (RTPO19d), listener receives PathObjectSubscriptionEvent (RTPO19e) +- **RTPO19b:** checks RTO25 access API preconditions +- **RTPO19c1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19c1a:** non-positive depth throws 40003 +- **RTPO19e2:** event.message carries PublicAPI::ObjectMessage when operation present +- **RTPO19f:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTO24b2a:** candidate path construction includes map update keys +- **RTO24c1:** coverage rule: prefix match + depth constraint +- **RTO24b2c:** listener exception caught, doesn't affect other listeners +- **RTO24b1:** multi-path dispatch via getFullPaths ### `unit/instance.md` -- Identity-Bound Reference @@ -287,26 +290,29 @@ Uses `setup_synced_channel()` from helper. - **RTLM5:** get(key) returns resolved value - **RTLM10/RTLM11:** entries/keys/values iterate non-tombstoned entries - **RTLM12/RTLM13:** set/remove construct correct v6 wire ObjectMessages -- **RTLM20:** set with LiveCounterValueType/LiveMapValueType consumes value type +- **RTLM20:** set with LiveCounterValueType/LiveMapValueType evaluates value type - **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply - **RTLM24:** clear constructs MAP_CLEAR ObjectMessage ### `unit/live_object_subscribe.md` -- Internal Subscription -- **RTLO4b:** subscribe(listener) registers on internal LiveObject -- **RTLO4c:** unsubscribe removes listener -- Events fire on applyOperation with update details +- **RTLO4b:** subscribe(listener) registers on internal LiveObject, returns Subscription (RTLO4b7) +- **RTLO4b4c3:** dispatch chain: direct listeners → path dispatch → tombstone cleanup +- **RTLO4b4d/e:** LiveObjectUpdate carries objectMessage and tombstone fields +- Subscription#unsubscribe deregisters (idempotent) +- Tombstone update deregisters all direct listeners (RTLO4b4c3c) -### `unit/batch.md` -- Batch API +### `unit/parent_references.md` -- parentReferences Tracking -- **RTPO22/RTINS19:** batch entry points -- resolve to LiveObject, create RootBatchContext, execute fn, flush -- **RTPO22c/RTINS19c:** unresolvable path / non-LiveObject throws 92007 -- **RTBC3-11:** read methods delegate to Instance (id, value, get, entries, keys, values, size, compact, compactJson) -- **RTBC4d:** get() wraps result via RootBatchContext#wrapInstance (memoized by objectId -- RTBC16c) -- **RTBC12-15:** write methods (set, remove, increment, decrement) queue message constructors synchronously -- **RTBC16d:** flush executes constructors, publishes all as single array via RTO15 (NOT publishAndApply) -- **RTBC16e:** closed batch throws 40000 on any method call -- **RTBC16f:** RootBatchContext closed after flush regardless of success/failure +- **RTLO3f:** parentReferences initialized to empty Dict> +- **RTLO4g/RTLO4h:** addParentReference/removeParentReference methods +- **RTLO4f:** getFullPaths — DFS traversal of inverse parentReferences graph, simple paths only +- **RTO5c10:** post-sync parentReferences rebuild from LiveMap entries + +### `unit/public_object_message.md` -- User-Facing Event Types + +- **PAOM1-3:** PublicAPI::ObjectMessage construction from internal ObjectMessage +- **PAOOP1-3:** PublicAPI::ObjectOperation construction, mapCreate/counterCreate resolution from *WithObjectId variants --- @@ -341,23 +347,23 @@ onMessageFromClient: (msg) => { ## Dependency Ordering (write order) 1. `helpers/standard_test_pool.md` -2. `unit/live_counter.md` -- no dependencies -3. `unit/live_map.md` -- no dependencies -4. `unit/object_id.md` -- no dependencies -5. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts -6. `unit/value_types.md` -- uses objectId generation -7. `unit/realtime_object.md` -- uses helper, tests orchestration -8. `unit/live_counter_api.md` -- uses helper -9. `unit/live_map_api.md` -- uses helper -10. `unit/live_object_subscribe.md` -- uses helper -11. `unit/path_object.md` -- uses helper -12. `unit/instance.md` -- uses helper -13. `unit/path_object_mutations.md` -- uses helper -14. `unit/path_object_subscribe.md` -- uses helper -15. `unit/batch.md` -- uses helper, depends on PathObject/Instance concepts -16. `integration/objects_lifecycle_test.md` -17. `integration/objects_sync_test.md` -18. `integration/objects_batch_test.md` +2. `unit/parent_references.md` -- foundational for graph tracking +3. `unit/public_object_message.md` -- standalone type construction +4. `unit/live_counter.md` -- no dependencies +5. `unit/live_map.md` -- no dependencies +6. `unit/object_id.md` -- no dependencies +7. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +8. `unit/value_types.md` -- uses objectId generation +9. `unit/realtime_object.md` -- uses helper, tests orchestration +10. `unit/live_counter_api.md` -- uses helper +11. `unit/live_map_api.md` -- uses helper +12. `unit/live_object_subscribe.md` -- uses helper +13. `unit/path_object.md` -- uses helper +14. `unit/instance.md` -- uses helper +15. `unit/path_object_mutations.md` -- uses helper +16. `unit/path_object_subscribe.md` -- uses helper +17. `integration/objects_lifecycle_test.md` +18. `integration/objects_sync_test.md` 19. `integration/objects_gc_test.md` 20. `integration/proxy/objects_faults.md` @@ -370,8 +376,8 @@ onMessageFromClient: (msg) => { | Wire format v6 everywhere | Spec branch uses v6 field names; old v5 names are "replaced by" stubs | | `appliedOnAckSerials` on RealtimeObject (RTO7b), not on pool | Matches spec's placement; cleared at sync completion (RTO5c9) | | No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | -| `echoMessages` check retained on mutations | Spec retains RTLC12d, RTLM20d, RTLM21d | -| Batch uses RTO15 (publish), NOT RTO20 (publishAndApply) | RTBC16d says "publishes ... using `RealtimeObject#publish`" -- batch does NOT apply locally on ACK | +| `echoMessages` check moved to RTO26 | RTO26c checks echoMessages=false; callers (PathObject/Instance) enforce via RTO26 | +| Batch API deferred | Not included in current spec revision (a397e34); may be added in a future spec update | | LiveObject/LiveMap/LiveCounter marked internal but still unit-tested | Direct testing of CRDT logic is essential; public API tests can't cover all edge cases | | Test IDs use `objects/unit/` prefix | Matches directory structure, not nested under `realtime/` | | Behavioral GC testing via ADVANCE_TIME | Verify GC through observable consequences (value becomes null, object recreatable) rather than internal pool state inspection | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md index e01062903..093b1e996 100644 --- a/uts/objects/helpers/standard_test_pool.md +++ b/uts/objects/helpers/standard_test_pool.md @@ -32,6 +32,20 @@ map:prefs@1000 (LiveMap, semantics: LWW) All map entries have timeserial `"t:0"` and `tombstone: false` unless otherwise noted. All objects have `siteTimeserials: { "aaa": "t:0" }` and `createOperationIsMerged: true` unless otherwise noted. +### Expected parentReferences after sync + +After `setup_synced_channel` completes (including the RTO5c10 rebuild), each object's `parentReferences` should be: + +| Object | parentReferences | +|--------|-----------------| +| `root` | `{}` (empty -- root is not referenced by any parent) | +| `counter:score@1000` | `{ "root": {"score"} }` | +| `map:profile@1000` | `{ "root": {"profile"} }` | +| `counter:nested@1000` | `{ "map:profile@1000": {"nested_counter"} }` | +| `map:prefs@1000` | `{ "map:profile@1000": {"prefs"} }` | + +Only entries whose value is a `LiveObject` (i.e. `data.objectId` is present) contribute to parentReferences. Primitive-valued entries ("name", "age", "active", "data", "avatar", "email", "theme") do not. + --- ## STANDARD_POOL_OBJECTS @@ -216,12 +230,43 @@ build_object_state(objectId, siteTimeserials, opts): RETURN ObjectMessage(object: state) ``` +### ObjectMessage Builder (State wrapper) + +Wraps an existing `ObjectState` in an `ObjectMessage` with the `object` field populated. Used when `replaceData` (RTLC6, RTLM6) needs an `ObjectMessage` rather than a bare `ObjectState`. + +```pseudo +build_object_message_with_state(objectState): + RETURN ObjectMessage(object: objectState) +``` + +### PublicAPI::ObjectMessage Builder + +Constructs a `PublicAPI::ObjectMessage` from an internal `ObjectMessage` and a channel name, per PAOM3. Used by subscription tests that verify the user-facing message delivered to listeners. + +```pseudo +build_public_object_message(objectMessage, channelName): + pub = PublicAPI::ObjectMessage() + pub.channel = channelName + pub.id = objectMessage.id + pub.clientId = objectMessage.clientId + pub.connectionId = objectMessage.connectionId + pub.timestamp = objectMessage.timestamp + pub.serial = objectMessage.serial + pub.serialTimestamp = objectMessage.serialTimestamp + pub.siteCode = objectMessage.siteCode + pub.extras = objectMessage.extras + pub.operation = PublicAPI::ObjectOperation from objectMessage.operation per PAOOP3 + RETURN pub +``` + --- ## Standard Synced-Channel Setup Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. +After the OBJECT_SYNC sequence completes, the SDK rebuilds parentReferences per RTO5c10: reset all LiveObject parentReferences to empty (RTLO3f2), then iterate all LiveMap entries calling addParentReference (RTLO4g) for each entry whose value is a LiveObject. See "Expected parentReferences after sync" above for the resulting state. + ```pseudo setup_synced_channel(channel_name): mock_ws = MockWebSocket( diff --git a/uts/objects/unit/batch.md b/uts/objects/unit/batch.md deleted file mode 100644 index b53098c35..000000000 --- a/uts/objects/unit/batch.md +++ /dev/null @@ -1,782 +0,0 @@ -# Batch API Tests - -Spec points: `RTPO22`, `RTINS19`, `RTBC1`–`RTBC16` - -## Test Type -Unit test with mocked WebSocket client - -## Mock WebSocket Infrastructure - -See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. - -## Shared Helpers - -See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. - ---- - -## RTPO22 - PathObject#batch resolves path and executes fn - -**Test ID**: `objects/unit/RTPO22/batch-resolves-and-executes-0` - -| Spec | Requirement | -|------|-------------| -| RTPO22c | Resolves path to LiveObject | -| RTPO22d | Creates RootBatchContext wrapping Instance | -| RTPO22e | Executes fn with BatchContext | -| RTPO22f | Flushes after fn returns | - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") - ctx.set("age", 31) -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -ASSERT captured_messages[0].state.length == 2 -ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[0].operation.mapSet.key == "name" -ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[1].operation.mapSet.key == "age" -``` - ---- - -## RTPO22c - PathObject#batch on unresolvable path throws 92007 - -**Test ID**: `objects/unit/RTPO22c/batch-unresolvable-throws-0` - -**Spec requirement:** If path does not resolve to LiveObject, throw 92007. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -AWAIT root.get("nonexistent").get("deep").batch((ctx) => {}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTINS19 - Instance#batch resolves and executes fn - -**Test ID**: `objects/unit/RTINS19/batch-instance-executes-0` - -| Spec | Requirement | -|------|-------------| -| RTINS19d | Creates RootBatchContext wrapping Instance | -| RTINS19e | Executes fn with BatchContext | -| RTINS19f | Flushes after fn returns | - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -instance = root.instance() -AWAIT instance.batch((ctx) => { - ctx.set("name", "Charlie") - ctx.remove("age") -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -ASSERT captured_messages[0].state.length == 2 -ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[1].operation.action == "MAP_REMOVE" -``` - ---- - -## RTINS19c - Instance#batch on non-LiveObject throws 92007 - -**Test ID**: `objects/unit/RTINS19c/batch-non-live-object-throws-0` - -**Spec requirement:** If wrapped value is not a LiveObject, throw 92007. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -name_inst = root.instance().get("name") -AWAIT name_inst.batch((ctx) => {}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTBC3 - BatchContext#id returns objectId - -**Test ID**: `objects/unit/RTBC3/id-returns-objectid-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -received_id = null -AWAIT root.batch((ctx) => { - received_id = ctx.id() -}) -``` - -### Assertions -```pseudo -ASSERT received_id == "root" -``` - ---- - -## RTBC5 - BatchContext#value delegates to Instance#value - -**Test ID**: `objects/unit/RTBC5/value-delegates-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -received_value = null -AWAIT root.get("score").batch((ctx) => { - received_value = ctx.value() -}) -``` - -### Assertions -```pseudo -ASSERT received_value == 100 -``` - ---- - -## RTBC4 - BatchContext#get wraps result via wrapInstance - -**Test ID**: `objects/unit/RTBC4/get-wraps-instance-0` - -| Spec | Requirement | -|------|-------------| -| RTBC4c | Delegates to Instance#get | -| RTBC4d | Wraps result via RootBatchContext#wrapInstance | - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -child_id = null -AWAIT root.batch((ctx) => { - child = ctx.get("score") - child_id = child.id() -}) -``` - -### Assertions -```pseudo -ASSERT child_id == "counter:score@1000" -``` - ---- - -## RTBC4 - BatchContext#get returns null for nonexistent key - -**Test ID**: `objects/unit/RTBC4/get-null-nonexistent-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -result = "not_null" -AWAIT root.batch((ctx) => { - result = ctx.get("nonexistent") -}) -``` - -### Assertions -```pseudo -ASSERT result == null -``` - ---- - -## RTBC6 - BatchContext#entries yields [key, BatchContext] pairs - -**Test ID**: `objects/unit/RTBC6/entries-yields-pairs-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -keys = [] -AWAIT root.batch((ctx) => { - FOR [key, child] IN ctx.entries(): - keys.append(key) -}) -``` - -### Assertions -```pseudo -ASSERT keys.length == 6 -ASSERT "name" IN keys -ASSERT "score" IN keys -``` - ---- - -## RTBC9 - BatchContext#size delegates to Instance#size - -**Test ID**: `objects/unit/RTBC9/size-delegates-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -received_size = null -AWAIT root.batch((ctx) => { - received_size = ctx.size() -}) -``` - -### Assertions -```pseudo -ASSERT received_size == 6 -``` - ---- - -## RTBC10 - BatchContext#compact delegates to Instance#compact - -**Test ID**: `objects/unit/RTBC10/compact-delegates-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -result = null -AWAIT root.batch((ctx) => { - result = ctx.compact() -}) -``` - -### Assertions -```pseudo -ASSERT result["name"] == "Alice" -ASSERT result["score"] == 100 -``` - ---- - -## RTBC12 - BatchContext#set queues MAP_SET message - -**Test ID**: `objects/unit/RTBC12/set-queues-map-set-0` - -| Spec | Requirement | -|------|-------------| -| RTBC12d | Queues message constructor for MAP_SET | - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_SET" -ASSERT obj_msg.operation.objectId == "root" -ASSERT obj_msg.operation.mapSet.key == "name" -ASSERT obj_msg.operation.mapSet.value.string == "Bob" -``` - ---- - -## RTBC12c - BatchContext#set on non-LiveMap throws 92007 - -**Test ID**: `objects/unit/RTBC12c/set-non-map-throws-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -AWAIT root.get("score").batch((ctx) => { - ctx.set("key", "value") -}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTBC13 - BatchContext#remove queues MAP_REMOVE message - -**Test ID**: `objects/unit/RTBC13/remove-queues-map-remove-0` - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.remove("name") -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_REMOVE" -ASSERT obj_msg.operation.objectId == "root" -ASSERT obj_msg.operation.mapRemove.key == "name" -``` - ---- - -## RTBC14 - BatchContext#increment queues COUNTER_INC message - -**Test ID**: `objects/unit/RTBC14/increment-queues-counter-inc-0` - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.get("score").batch((ctx) => { - ctx.increment(25) -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "COUNTER_INC" -ASSERT obj_msg.operation.objectId == "counter:score@1000" -ASSERT obj_msg.operation.counterInc.number == 25 -``` - ---- - -## RTBC14c - BatchContext#increment on non-LiveCounter throws 92007 - -**Test ID**: `objects/unit/RTBC14c/increment-non-counter-throws-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.increment(5) -}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTBC15 - BatchContext#decrement delegates to increment with negated amount - -**Test ID**: `objects/unit/RTBC15/decrement-negates-0` - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.get("score").batch((ctx) => { - ctx.decrement(10) -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "COUNTER_INC" -ASSERT obj_msg.operation.counterInc.number == -10 -``` - ---- - -## RTBC16c - wrapInstance memoizes by objectId - -**Test ID**: `objects/unit/RTBC16c/wrap-instance-memoized-0` - -**Spec requirement:** If a wrapper for that objectId already exists, the existing wrapper is returned. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -same_ref = false -AWAIT root.batch((ctx) => { - child1 = ctx.get("score") - child2 = ctx.get("score") - same_ref = (child1 IS child2) -}) -``` - -### Assertions -```pseudo -ASSERT same_ref == true -``` - ---- - -## RTBC16d - flush publishes via RTO15 (publish, not publishAndApply) - -**Test ID**: `objects/unit/RTBC16d/flush-uses-publish-0` - -**Spec requirement:** Flushes queued messages as a single array via RealtimeObject#publish. - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") - ctx.set("age", 31) - child = ctx.get("score") - child.increment(50) -}) -``` - -### Assertions -```pseudo -// All operations published as a single OBJECT message -ASSERT captured_messages.length == 1 -ASSERT captured_messages[0].state.length == 3 -ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[2].operation.action == "COUNTER_INC" -``` - ---- - -## RTBC16d - flush with no queued messages does not publish - -**Test ID**: `objects/unit/RTBC16d/flush-empty-no-publish-0` - -**Spec requirement:** If there are no queued messages, no publish is performed. - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - // Read-only: no writes queued - ctx.value() - ctx.size() -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 0 -``` - ---- - -## RTBC16e - closed batch throws 40000 on any method call - -**Test ID**: `objects/unit/RTBC16e/closed-batch-throws-0` - -**Spec requirement:** After the batch is closed, any method call must throw 40000. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -saved_ctx = null -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - saved_ctx = ctx -}) - -saved_ctx.set("name", "Bob") FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40000 -``` - ---- - -## RTBC16e - closed batch read methods also throw 40000 - -**Test ID**: `objects/unit/RTBC16e/closed-batch-read-throws-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -saved_ctx = null -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - saved_ctx = ctx -}) - -saved_ctx.id() FAILS WITH error_id -saved_ctx.value() FAILS WITH error_value -saved_ctx.size() FAILS WITH error_size -``` - -### Assertions -```pseudo -ASSERT error_id.code == 40000 -ASSERT error_value.code == 40000 -ASSERT error_size.code == 40000 -``` - ---- - -## RTPO22g - RootBatchContext closed after flush regardless of success - -**Test ID**: `objects/unit/RTPO22g/closed-after-flush-0` - -**Spec requirement:** The RootBatchContext is closed after flush completes, regardless of success or failure. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -saved_ctx = null -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - saved_ctx = ctx - ctx.set("name", "Bob") -}) - -saved_ctx.set("age", 99) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40000 -``` - ---- - -## RTPO22b - PathObject#batch requires OBJECT_PUBLISH mode - -**Test ID**: `objects/unit/RTPO22b/batch-requires-publish-mode-0` - -**Spec requirement:** Requires OBJECT_PUBLISH channel mode per RTO2. - -### Setup -```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") -}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40024 -``` diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md index 221d635e7..bf8a67b47 100644 --- a/uts/objects/unit/instance.md +++ b/uts/objects/unit/instance.md @@ -1,6 +1,6 @@ # Instance Tests -Spec points: `RTINS1`–`RTINS19` +Spec points: `RTINS1`–`RTINS16` ## Test Type Unit test with mocked WebSocket client @@ -46,8 +46,10 @@ ASSERT map_inst.id() == "map:profile@1000" | Spec | Requirement | |------|-------------| -| RTINS4a | LiveCounter -> numeric value | -| RTINS4c | LiveMap -> null | +| RTINS4a | Checks access API preconditions per RTO25 | +| RTINS4b | LiveCounter -> delegates to LiveCounter#value | +| RTINS4c | Primitive -> returns value directly | +| RTINS4d | LiveMap -> null | ### Setup ```pseudo @@ -71,8 +73,9 @@ ASSERT map_inst.value() == null | Spec | Requirement | |------|-------------| -| RTINS5b | LiveMap -> look up key, wrap result in Instance | -| RTINS5c | Non-LiveMap -> null | +| RTINS5b | Checks access API preconditions per RTO25 | +| RTINS5c | LiveMap -> look up key, wrap result in Instance | +| RTINS5d | Non-LiveMap -> null | ### Setup ```pseudo @@ -95,14 +98,15 @@ ASSERT null_inst == null --- -## RTINS6 - entries() yields [key, Instance] pairs +## RTINS6 - entries() returns array of [key, Instance] pairs **Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` | Spec | Requirement | |------|-------------| -| RTINS6a | LiveMap -> [key, Instance] pairs | -| RTINS6b | Non-LiveMap -> empty iterator | +| RTINS6a | Checks access API preconditions per RTO25 | +| RTINS6b | LiveMap -> array of [key, Instance] pairs | +| RTINS6c | Non-LiveMap -> empty array | ### Setup ```pseudo @@ -132,8 +136,9 @@ ASSERT entries["name"].value() == "Alice" | Spec | Requirement | |------|-------------| -| RTINS9a | LiveMap -> non-tombstoned entry count | -| RTINS9b | Non-LiveMap -> null | +| RTINS9a | Checks access API preconditions per RTO25 | +| RTINS9b | LiveMap -> non-tombstoned entry count | +| RTINS9c | Non-LiveMap -> null | ### Setup ```pseudo @@ -155,7 +160,10 @@ ASSERT counter_inst.size() == null **Test ID**: `objects/unit/RTINS10/compact-0` -**Spec requirement:** Behaves identically to PathObject#compact on the wrapped value. +| Spec | Requirement | +|------|-------------| +| RTINS10a | Checks access API preconditions per RTO25 | +| RTINS10b | Behaves identically to PathObject#compact on the wrapped value | ### Setup ```pseudo @@ -183,8 +191,9 @@ ASSERT result["profile"]["email"] == "alice@example.com" | Spec | Requirement | |------|-------------| -| RTINS12b | LiveMap -> delegate to LiveMap#set | -| RTINS12c | Non-LiveMap -> throw 92007 | +| RTINS12b | Checks write API preconditions per RTO26 | +| RTINS12c | LiveMap -> delegate to LiveMap#set | +| RTINS12d | Non-LiveMap -> throw 92007 | ### Setup ```pseudo @@ -204,9 +213,11 @@ ASSERT root.get("name").value() == "Bob" --- -## RTINS12c - set() on non-LiveMap throws 92007 +## RTINS12d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12d/set-non-map-throws-0` -**Test ID**: `objects/unit/RTINS12c/set-non-map-throws-0` +**Spec requirement:** If the wrapped value is not a LiveMap, throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -230,6 +241,12 @@ ASSERT error.code == 92007 **Test ID**: `objects/unit/RTINS13/remove-delegates-0` +| Spec | Requirement | +|------|-------------| +| RTINS13b | Checks write API preconditions per RTO26 | +| RTINS13c | LiveMap -> delegate to LiveMap#remove | +| RTINS13d | Non-LiveMap -> throw 92007 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -254,8 +271,9 @@ ASSERT root.get("name").value() == null | Spec | Requirement | |------|-------------| -| RTINS14b | LiveCounter -> delegate to increment | -| RTINS14c | Non-LiveCounter -> throw 92007 | +| RTINS14b | Checks write API preconditions per RTO26 | +| RTINS14c | LiveCounter -> delegate to increment | +| RTINS14d | Non-LiveCounter -> throw 92007 | ### Setup ```pseudo @@ -275,9 +293,11 @@ ASSERT root.get("score").value() == 125 --- -## RTINS14c - increment() on non-LiveCounter throws 92007 +## RTINS14d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14d/increment-non-counter-throws-0` -**Test ID**: `objects/unit/RTINS14c/increment-non-counter-throws-0` +**Spec requirement:** If the wrapped value is not a LiveCounter, throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -301,6 +321,12 @@ ASSERT error.code == 92007 **Test ID**: `objects/unit/RTINS15/decrement-delegates-0` +| Spec | Requirement | +|------|-------------| +| RTINS15b | Checks write API preconditions per RTO26 | +| RTINS15c | LiveCounter -> delegate to decrement | +| RTINS15d | Non-LiveCounter -> throw 92007 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -319,16 +345,65 @@ ASSERT root.get("score").value() == 90 --- +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1 (RTINS14a1). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTINS15a - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTINS15a/decrement-default-0` + +**Spec requirement:** amount defaults to 1 (RTINS15a1). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + ## RTINS16 - subscribe() receives InstanceSubscriptionEvent **Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` | Spec | Requirement | |------|-------------| -| RTINS16c | Subscribes via LiveObject#subscribe | -| RTINS16d1 | Event.object is the Instance | -| RTINS16e | Returns Subscription | -| RTINS16f | Identity-based subscription | +| RTINS16b | Checks access API preconditions per RTO25 | +| RTINS16d | Subscribes via LiveObject#subscribe (RTLO4b) | +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16f | Returns Subscription | +| RTINS16g | Identity-based subscription | ### Setup ```pseudo @@ -356,11 +431,11 @@ ASSERT events[0].object.id() == "counter:score@1000" --- -## RTINS16b - subscribe() on primitive throws 92007 +## RTINS16c - subscribe() on primitive throws 92007 -**Test ID**: `objects/unit/RTINS16b/subscribe-primitive-throws-0` +**Test ID**: `objects/unit/RTINS16c/subscribe-primitive-throws-0` -**Spec requirement:** If wrapped value is not LiveObject, throw 92007. +**Spec requirement:** If wrapped value is not a LiveObject (i.e. it is a primitive), throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -380,43 +455,51 @@ ASSERT error.code == 92007 --- -## RTINS16f - Instance subscription follows identity not path +## RTINS16e2 - InstanceSubscriptionEvent contains PublicAPI::ObjectMessage + +**Test ID**: `objects/unit/RTINS16e2/subscription-event-message-0` -**Test ID**: `objects/unit/RTINS16f/subscription-follows-identity-0` +| Spec | Requirement | +|------|-------------| +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16e2 | Event.message is a PublicAPI::ObjectMessage derived from the triggering ObjectMessage | -**Spec requirement:** Instance follows the specific LiveObject, regardless of tree position. +Tests that the InstanceSubscriptionEvent includes both the `object` (Instance) and `message` (PublicAPI::ObjectMessage) fields when a data update arrives. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -counter_inst = root.get("score").instance() +root_inst = root.instance() events = [] -counter_inst.subscribe((event) => events.append(event)) +root_inst.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") -])) - -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 10, "100", "remote") + build_map_set("root", "name", { string: "Bob" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length >= 1 -ASSERT counter_inst.id() == "counter:score@1000" +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "root" +ASSERT events[0].message IS NOT null +ASSERT events[0].message.channel == "test" +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.objectId == "root" +ASSERT events[0].message.operation.mapSet.key == "name" ``` --- -## RTINS17 - unsubscribe() deregisters listener +## RTINS16f - subscribe() returns Subscription for deregistration + +**Test ID**: `objects/unit/RTINS16f/subscribe-returns-subscription-0` -**Test ID**: `objects/unit/RTINS17/unsubscribe-0` +**Spec requirement:** Returns a Subscription object (RTINS16f). Deregistration is via Subscription#unsubscribe. ### Setup ```pseudo @@ -441,84 +524,59 @@ ASSERT events.length == 0 --- -## RTINS14a - increment() defaults to 1 +## RTINS16g - Instance subscription follows identity not path -**Test ID**: `objects/unit/RTINS14a/increment-default-0` +**Test ID**: `objects/unit/RTINS16g/subscription-follows-identity-0` -**Spec requirement:** amount defaults to 1. +**Spec requirement:** The subscription is identity-based: it follows the specific LiveObject instance, regardless of where it sits in the graph. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") counter_inst = root.get("score").instance() +events = [] +counter_inst.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -AWAIT counter_inst.increment() -``` - -### Assertions -```pseudo -ASSERT root.get("score").value() == 101 -``` - ---- - -## RTINS15a - decrement() defaults to 1 - -**Test ID**: `objects/unit/RTINS15a/decrement-default-0` - -**Spec requirement:** amount defaults to 1. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -counter_inst = root.get("score").instance() -``` +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) -### Test Steps -```pseudo -AWAIT counter_inst.decrement() +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT root.get("score").value() == 99 +ASSERT events.length >= 1 +ASSERT counter_inst.id() == "counter:score@1000" ``` --- -## RTINS16 - Subscription event contains message metadata +## RTINS16h - subscribe() has no side effects -**Test ID**: `objects/unit/RTINS16/subscription-event-metadata-0` +**Test ID**: `objects/unit/RTINS16h/subscribe-no-side-effects-0` -| Spec | Requirement | -|------|-------------| -| RTINS16d1 | Event.object is the Instance | -| RTINS16d2 | Event.message is the ObjectMessage that triggered the update | +**Spec requirement:** The subscribe operation must not have any side effects on RealtimeObject, the underlying channel, or their status. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -root_inst = root.instance() -events = [] -root_inst.subscribe((event) => events.append(event)) +counter_inst = root.get("score").instance() +channel_state_before = channel.state ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "name", { string: "Bob" }, "99", "remote") -])) -poll_until(events.length >= 1, timeout: 5s) +sub = counter_inst.subscribe((event) => {}) ``` ### Assertions ```pseudo -ASSERT events[0].object IS Instance -ASSERT events[0].object.id() == "root" -ASSERT events[0].message IS NOT null -ASSERT events[0].message.operation.action == "MAP_SET" -ASSERT events[0].message.operation.mapSet.key == "name" +ASSERT channel.state == channel_state_before ``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md index 300f1779b..d5f2c3401 100644 --- a/uts/objects/unit/live_counter.md +++ b/uts/objects/unit/live_counter.md @@ -1,6 +1,6 @@ # LiveCounter Tests -Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4e`, `RTLO5`, `RTLO6` ## Test Type Unit test — pure data structure, no mocks required. @@ -47,7 +47,7 @@ ASSERT counter.siteTimeserials == {} | Spec | Requirement | |------|-------------| | RTLC9f | Add `CounterInc.number` to data if it exists | -| RTLC9g | Return LiveCounterUpdate with amount set to the number | +| RTLC9g | Return LiveCounterUpdate with amount set to the number and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -65,6 +65,7 @@ update = counter.applyOperation(msg, source: CHANNEL) ASSERT counter.data == 5 ASSERT update.noop == false ASSERT update.update.amount == 5 +ASSERT update.objectMessage == msg ``` --- @@ -92,6 +93,7 @@ update = counter.applyOperation(msg, source: CHANNEL) ```pseudo ASSERT counter.data == 7 ASSERT update.update.amount == -3 +ASSERT update.objectMessage == msg ``` --- @@ -164,7 +166,7 @@ ASSERT counter.data == 25 | RTLC8c | Merge initial value via RTLC16 | | RTLC16a | Add counterCreate.count to data | | RTLC16b | Set createOperationIsMerged to true | -| RTLC16c | Return LiveCounterUpdate with amount = count | +| RTLC16c | Return LiveCounterUpdate with amount = count and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -182,6 +184,7 @@ update = counter.applyOperation(msg, source: CHANNEL) ASSERT counter.data == 42 ASSERT counter.createOperationIsMerged == true ASSERT update.update.amount == 42 +ASSERT update.objectMessage == msg ``` --- @@ -441,9 +444,13 @@ ASSERT result == true | Spec | Requirement | |------|-------------| | RTLO5b | Tombstone the LiveObject | +| RTLO5c | Return the LiveObjectUpdate returned by tombstone | | RTLO4e2 | Set isTombstone to true | | RTLO4e4 | Set data to zero-value | -| RTLC7d4a | Emit LiveCounterUpdate with negated previous value | +| RTLO4e5 | Compute diff for the tombstone update | +| RTLO4e6 | Set tombstone flag on the update | +| RTLO4e7 | Set objectMessage on the update | +| RTLC7d4c | Emit LiveCounterUpdate returned by RTLO5 | ### Setup ```pseudo @@ -464,6 +471,8 @@ ASSERT counter.isTombstone == true ASSERT counter.data == 0 ASSERT counter.tombstonedAt == 1700000000000 ASSERT update.update.amount == -42 +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg ``` --- @@ -588,7 +597,7 @@ ASSERT counter.data == 0 | RTLC6a | Replace siteTimeserials from ObjectState | | RTLC6b | Set createOperationIsMerged to false | | RTLC6c | Set data to counter.count | -| RTLC6h | Return diff as LiveCounterUpdate | +| RTLC6h | Return diff as LiveCounterUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -612,6 +621,7 @@ ASSERT counter.data == 50 ASSERT counter.siteTimeserials == { "site2": "05" } ASSERT counter.createOperationIsMerged == false ASSERT update.update.amount == 40 +ASSERT update.objectMessage == state_msg ``` --- @@ -644,6 +654,7 @@ update = counter.replaceData(state_msg) ASSERT counter.data == 150 ASSERT counter.createOperationIsMerged == true ASSERT update.update.amount == 150 +ASSERT update.objectMessage == state_msg ``` --- @@ -684,8 +695,10 @@ ASSERT update.noop == true | Spec | Requirement | |------|-------------| -| RTLC6f | If ObjectState.tombstone is true, tombstone the counter | -| RTLC6f1 | Return LiveCounterUpdate with amount = negated previous data | +| RTLC6f | If ObjectState.tombstone is true, tombstone the counter via LiveObject.tombstone | +| RTLC6f2 | Return the LiveCounterUpdate returned by LiveObject.tombstone | +| RTLO4e6 | Tombstone flag set on the update | +| RTLO4e7 | objectMessage set on the update | ### Setup ```pseudo @@ -707,6 +720,8 @@ update = counter.replaceData(state_msg) ASSERT counter.isTombstone == true ASSERT counter.data == 0 ASSERT update.update.amount == -30 +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg ``` --- @@ -735,6 +750,7 @@ update = counter.replaceData(state_msg) ```pseudo ASSERT counter.data == 0 ASSERT update.update.amount == -42 +ASSERT update.objectMessage == state_msg ``` --- @@ -762,6 +778,7 @@ update = counter.replaceData(state_msg) ### Assertions ```pseudo ASSERT update.update.amount == 55 +ASSERT update.objectMessage == state_msg ``` --- diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md index 2b5e733e9..f6bca2a1a 100644 --- a/uts/objects/unit/live_counter_api.md +++ b/uts/objects/unit/live_counter_api.md @@ -23,6 +23,8 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct |------|-------------| | RTLC5c | Returns current data value | +Note: RTLC5a and RTLC5b have been replaced by RTO25. The access API preconditions (OBJECT_SUBSCRIBE mode check and channel state check) are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -36,16 +38,6 @@ ASSERT counter.value() == 100 --- -## RTLC5a - value() requires OBJECT_SUBSCRIBE mode - -**Test ID**: `objects/unit/RTLC5a/value-requires-subscribe-0` - -**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. - -This is implicitly tested by `setup_synced_channel` which always includes OBJECT_SUBSCRIBE. A negative test would use a channel without OBJECT_SUBSCRIBE and verify the error. - ---- - ## RTLC12 - increment sends v6 COUNTER_INC message **Test ID**: `objects/unit/RTLC12/increment-sends-counter-inc-0` @@ -124,87 +116,11 @@ ASSERT root.get("score").value() == 150 --- -## RTLC12b - increment requires OBJECT_PUBLISH mode +## RTLC12b/c/d - increment write preconditions (replaced by RTO26) **Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` -**Spec requirement:** Requires OBJECT_PUBLISH channel mode. - -### Setup -```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.increment(10) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40024 -``` - ---- - -## RTLC12d - increment with echoMessages false throws - -**Test ID**: `objects/unit/RTLC12d/echo-messages-false-0` - -**Spec requirement:** If echoMessages is false, throw 40000. - -### Setup -```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key", echoMessages: false }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.increment(10) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40000 -``` +Note: RTLC12b, RTLC12c, and RTLC12d have been replaced by RTO26. The write API preconditions (OBJECT_PUBLISH mode check, channel state check, and echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. --- diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/live_map.md index a930c17a3..0186570bb 100644 --- a/uts/objects/unit/live_map.md +++ b/uts/objects/unit/live_map.md @@ -1,13 +1,13 @@ # LiveMap Tests -Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO4g`, `RTLO4h`, `RTLO5`, `RTLO6` ## Test Type Unit test — pure data structure, no mocks required. ## Purpose -Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, and diff calculation. +Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, diff calculation, and parentReferences maintenance. Tests operate directly on LiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. @@ -49,7 +49,7 @@ ASSERT map.siteTimeserials == {} | Spec | Requirement | |------|-------------| | RTLM7b4 | Create new ObjectsMapEntry with data and timeserial | -| RTLM7f | Return LiveMapUpdate with key set to "updated" | +| RTLM7f | Return LiveMapUpdate with key set to "updated" and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -68,6 +68,7 @@ ASSERT map.data["name"].data == { string: "Alice" } ASSERT map.data["name"].timeserial == "01" ASSERT map.data["name"].tombstone == false ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -81,6 +82,7 @@ ASSERT update.update == { "name": "updated" } | RTLM7a2e | Set data to MapSet.value | | RTLM7a2b | Set timeserial to the provided serial | | RTLM7a2c | Set tombstone to false | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -101,6 +103,7 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.data["name"].data == { string: "Bob" } ASSERT map.data["name"].timeserial == "02" ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -216,6 +219,7 @@ update = map.applyOperation(msg, source: CHANNEL) ```pseudo ASSERT map.data["name"].data == { string: "Bob" } ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -288,7 +292,7 @@ ASSERT pool["counter:new@2000"].data == 0 | RTLM8a2b | Set timeserial to serial | | RTLM8a2c | Set tombstone to true | | RTLM8a2d | Set tombstonedAt via RTLO6 | -| RTLM8e | Return LiveMapUpdate with key set to "removed" | +| RTLM8e | Return LiveMapUpdate with key set to "removed" and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -311,6 +315,7 @@ ASSERT map.data["name"].tombstone == true ASSERT map.data["name"].timeserial == "02" ASSERT map.data["name"].tombstonedAt == 1700000000000 ASSERT update.update == { "name": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -324,6 +329,7 @@ ASSERT update.update == { "name": "removed" } | RTLM8b1 | Create new entry with data null and timeserial | | RTLM8b2 | Set tombstone to true | | RTLM8b3 | Set tombstonedAt via RTLO6 | +| RTLM8e | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -341,6 +347,7 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.data["ghost"].tombstone == true ASSERT map.data["ghost"].tombstonedAt == 1700000000000 ASSERT update.update == { "ghost": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -383,7 +390,7 @@ ASSERT update.noop == true |------|-------------| | RTLM24d | Set clearTimeserial to serial | | RTLM24e1a | Remove entries with timeserial null or < serial | -| RTLM24f | Return LiveMapUpdate with removed keys | +| RTLM24f | Return LiveMapUpdate with removed keys and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -408,6 +415,7 @@ ASSERT "old" NOT IN map.data ASSERT "same" NOT IN map.data ASSERT "new" IN map.data ASSERT update.update == { "old": "removed", "same": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -448,6 +456,7 @@ ASSERT update.noop == true | RTLM23a1 | Non-tombstoned entries merged via MAP_SET logic | | RTLM23a2 | Tombstoned entries merged via MAP_REMOVE logic | | RTLM23b | Set createOperationIsMerged to true | +| RTLM23c | Return LiveMapUpdate with merged update map and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -472,6 +481,7 @@ ASSERT map.data["name"].data == { string: "Alice" } ASSERT map.data["removed_key"].tombstone == true ASSERT map.createOperationIsMerged == true ASSERT update.update == { "name": "updated", "removed_key": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -562,8 +572,11 @@ ASSERT map.data == {} | Spec | Requirement | |------|-------------| -| RTLM15d5a | Emit LiveMapUpdate with removed keys | +| RTLM15d5c | Emit LiveMapUpdate returned by RTLO5 | | RTLM15d5b | Return true | +| RTLO4e5 | Compute diff for the tombstone update | +| RTLO4e6 | Set tombstone flag on the update | +| RTLO4e7 | Set objectMessage on the update | ### Setup ```pseudo @@ -586,6 +599,8 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.isTombstone == true ASSERT map.data == {} ASSERT update.update == { "name": "removed", "age": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg ``` --- @@ -633,7 +648,7 @@ ASSERT isTombstoned(map.data["dead_ref"]) == true | RTLM6b | Set createOperationIsMerged to false | | RTLM6i | Set clearTimeserial from ObjectState.map.clearTimeserial | | RTLM6c | Set data to ObjectState.map.entries | -| RTLM6h | Return diff LiveMapUpdate | +| RTLM6h | Return diff LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -666,6 +681,7 @@ ASSERT map.clearTimeserial == "03" ASSERT "old" NOT IN map.data ASSERT map.data["new"].data == { string: "new" } ASSERT update.update == { "old": "removed", "new": "updated" } +ASSERT update.objectMessage == state_msg ``` --- @@ -705,7 +721,7 @@ ASSERT map.data["dead"].tombstonedAt == 1700000050000 **Test ID**: `objects/unit/RTLM6d/replace-data-with-create-op-0` -**Spec requirement:** If createOp present, merge via RTLM23. +**Spec requirement:** If createOp present, merge via RTLM23, passing in the ObjectMessage. ### Setup ```pseudo @@ -742,6 +758,45 @@ ASSERT map.createOperationIsMerged == true --- +## RTLM6f - replaceData with tombstone flag tombstones map + +**Test ID**: `objects/unit/RTLM6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6f | If ObjectState.tombstone is true, tombstone the map via LiveObject.tombstone | +| RTLM6f2 | Return the LiveMapUpdate returned by LiveObject.tombstone | +| RTLO4e6 | Tombstone flag set on the update | +| RTLO4e7 | objectMessage set on the update | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { semantics: "LWW", entries: {} }, + tombstone: true +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg +``` + +--- + ## RTLM19 - GC removes tombstoned entries past grace period **Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` @@ -922,6 +977,7 @@ ASSERT map.get("ref") == null |------|-------------| | RTLM7a2c | Set tombstone to false | | RTLM7a2d | Set tombstonedAt to null | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -943,6 +999,7 @@ ASSERT map.data["name"].data == { string: "Alice" } ASSERT map.data["name"].tombstone == false ASSERT map.data["name"].tombstonedAt == null ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -977,4 +1034,345 @@ ASSERT map.data["after"].data == { string: "b" } ASSERT "before" IN update.update ASSERT "no_ts" IN update.update ASSERT "after" NOT IN update.update +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7a3, RTLM7g2 - parentReferences: MAP_SET overwrites entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM7a3/map-set-overwrite-objectid-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a3a | Before overwriting, check if existing entry has objectId | +| RTLM7a3b | If old entry references a LiveObject, call removeParentReference on old child | +| RTLM7g2 | After setting new objectId value, call addParentReference on new child | + +Tests that when MAP_SET overwrites an entry whose value is a LiveObject with a new LiveObject value, removeParentReference is called on the old child and addParentReference is called on the new child. + +### Setup +```pseudo +pool = ObjectsPool() +old_counter = LiveCounter(objectId: "counter:old@1000") +new_counter = LiveCounter(objectId: "counter:new@2000") +pool["counter:old@1000"] = old_counter +pool["counter:new@2000"] = new_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } +} +// Simulate existing parentReference +old_counter.parentReferences = { "root": {"ref"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "ref", { objectId: "counter:new@2000" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ref"].data == { objectId: "counter:new@2000" } +// removeParentReference was called on the old child +ASSERT "root" NOT IN old_counter.parentReferences OR "ref" NOT IN old_counter.parentReferences["root"] +// addParentReference was called on the new child +ASSERT "root" IN new_counter.parentReferences +ASSERT "ref" IN new_counter.parentReferences["root"] +ASSERT update.update == { "ref": "updated" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7g2 - parentReferences: MAP_SET new entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM7g2/map-set-new-entry-add-parent-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g2 | After setting new objectId value, call addParentReference on the new child | + +Tests that when MAP_SET creates a new entry whose value is a LiveObject, addParentReference is called on the child. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:child@1000" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["score"].data == { objectId: "counter:child@1000" } +ASSERT "root" IN child_counter.parentReferences +ASSERT "score" IN child_counter.parentReferences["root"] +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7 - parentReferences: MAP_SET with non-LiveObject value does not affect parentReferences + +**Test ID**: `objects/unit/RTLM7/map-set-primitive-no-parent-refs-0` + +**Spec requirement:** parentReferences operations only apply when the entry value contains an objectId. Primitive values do not trigger addParentReference or removeParentReference. + +### Setup +```pseudo +pool = ObjectsPool() +old_counter = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"] = old_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } +} +old_counter.parentReferences = { "root": {"ref"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "ref", { string: "plain_value" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ref"].data == { string: "plain_value" } +// removeParentReference was called on old child (entry previously had objectId) +ASSERT "root" NOT IN old_counter.parentReferences OR "ref" NOT IN old_counter.parentReferences["root"] +// No addParentReference call because new value is a primitive +ASSERT update.update == { "ref": "updated" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM8a3 - parentReferences: MAP_REMOVE entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM8a3/map-remove-objectid-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a3a | Before tombstoning, check if existing entry has objectId | +| RTLM8a3b | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when MAP_REMOVE tombstones an entry whose value is a LiveObject, removeParentReference is called on the child. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "score": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false } +} +child_counter.parentReferences = { "root": {"score"} } +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "score", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["score"].tombstone == true +// removeParentReference was called on the child +ASSERT "root" NOT IN child_counter.parentReferences OR "score" NOT IN child_counter.parentReferences["root"] +ASSERT update.update == { "score": "removed" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM8 - parentReferences: MAP_REMOVE entry with non-LiveObject value + +**Test ID**: `objects/unit/RTLM8/map-remove-primitive-no-parent-refs-0` + +**Spec requirement:** MAP_REMOVE on a primitive-valued entry does not call removeParentReference because there is no objectId. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].tombstone == true +ASSERT update.update == { "name": "removed" } +ASSERT update.objectMessage == msg +// No parentReference calls needed -- test passes without errors +``` + +--- + +## RTLM24e1c - parentReferences: MAP_CLEAR removes parent references for cleared entries + +**Test ID**: `objects/unit/RTLM24e1c/map-clear-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24e1c1 | Before removing entry, check if it has objectId | +| RTLM24e1c2 | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when MAP_CLEAR removes entries that reference LiveObjects, removeParentReference is called for each. + +### Setup +```pseudo +pool = ObjectsPool() +counter_a = LiveCounter(objectId: "counter:a@1000") +counter_b = LiveCounter(objectId: "counter:b@1000") +pool["counter:a@1000"] = counter_a +pool["counter:b@1000"] = counter_b + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref_a": { data: { objectId: "counter:a@1000" }, timeserial: "02", tombstone: false }, + "ref_b": { data: { objectId: "counter:b@1000" }, timeserial: "02", tombstone: false }, + "primitive": { data: { string: "hello" }, timeserial: "02", tombstone: false }, + "newer": { data: { string: "kept" }, timeserial: "09", tombstone: false } +} +counter_a.parentReferences = { "root": {"ref_a"} } +counter_b.parentReferences = { "root": {"ref_b"} } +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +// ref_a and ref_b removed (timeserial "02" < "05"), newer kept (timeserial "09" > "05") +ASSERT "ref_a" NOT IN map.data +ASSERT "ref_b" NOT IN map.data +ASSERT "primitive" NOT IN map.data +ASSERT "newer" IN map.data +// removeParentReference was called on both child counters +ASSERT "root" NOT IN counter_a.parentReferences OR "ref_a" NOT IN counter_a.parentReferences["root"] +ASSERT "root" NOT IN counter_b.parentReferences OR "ref_b" NOT IN counter_b.parentReferences["root"] +ASSERT update.update == { "ref_a": "removed", "ref_b": "removed", "primitive": "removed" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLO4e9 - parentReferences: tombstone LiveMap removes parent references for all entries + +**Test ID**: `objects/unit/RTLO4e9/tombstone-map-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4e9a | Before clearing data, for each entry check if it has objectId | +| RTLO4e9b | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when a LiveMap is tombstoned (via OBJECT_DELETE), removeParentReference is called for each entry that references a LiveObject before the data is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +child_map = LiveMap(objectId: "map:child@1000", semantics: "LWW") +pool["counter:child@1000"] = child_counter +pool["map:child@1000"] = child_map + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "counter_ref": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false }, + "map_ref": { data: { objectId: "map:child@1000" }, timeserial: "01", tombstone: false }, + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +child_counter.parentReferences = { "root": {"counter_ref"} } +child_map.parentReferences = { "root": {"map_ref"} } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +// removeParentReference was called on both children +ASSERT "root" NOT IN child_counter.parentReferences OR "counter_ref" NOT IN child_counter.parentReferences["root"] +ASSERT "root" NOT IN child_map.parentReferences OR "map_ref" NOT IN child_map.parentReferences["root"] +ASSERT update.update == { "counter_ref": "removed", "map_ref": "removed", "name": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7a3, RTLM7g2 - parentReferences: MAP_SET overwriting LiveObject with LiveObject calls both remove and add + +**Test ID**: `objects/unit/RTLM7a3/map-set-replace-objectid-both-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a3b | removeParentReference called on old child before overwrite | +| RTLM7g2 | addParentReference called on new child after set | + +Tests that both removeParentReference and addParentReference are called in the correct order when replacing one LiveObject reference with another. + +### Setup +```pseudo +pool = ObjectsPool() +old_map = LiveMap(objectId: "map:old@1000", semantics: "LWW") +new_map = LiveMap(objectId: "map:new@2000", semantics: "LWW") +pool["map:old@1000"] = old_map +pool["map:new@2000"] = new_map + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "child": { data: { objectId: "map:old@1000" }, timeserial: "01", tombstone: false } +} +old_map.parentReferences = { "root": {"child"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "child", { objectId: "map:new@2000" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["child"].data == { objectId: "map:new@2000" } +// Old child no longer references root +ASSERT "root" NOT IN old_map.parentReferences OR "child" NOT IN old_map.parentReferences["root"] +// New child references root +ASSERT "root" IN new_map.parentReferences +ASSERT "child" IN new_map.parentReferences["root"] +ASSERT update.update == { "child": "updated" } +ASSERT update.objectMessage == msg ``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md index 7a7282246..44dcb795b 100644 --- a/uts/objects/unit/live_map_api.md +++ b/uts/objects/unit/live_map_api.md @@ -1,6 +1,6 @@ # LiveMap API Tests -Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24` +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24`, `RTLMV4`, `RTLCV4` ## Test Type Unit test with mocked WebSocket client @@ -19,7 +19,11 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct **Test ID**: `objects/unit/RTLM5/get-string-value-0` -**Spec requirement:** Returns value at key, resolved per RTLM5d2. +| Spec | Requirement | +|------|-------------| +| RTLM5d2 | Returns value at key, resolved per RTLM5d2 | + +Note: RTLM5b and RTLM5c have been replaced by RTO25. The access API preconditions (OBJECT_SUBSCRIBE mode check and channel state check) are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. ### Setup ```pseudo @@ -76,7 +80,11 @@ ASSERT root.get("profile").get("email").value() == "alice@example.com" **Test ID**: `objects/unit/RTLM10/size-non-tombstoned-0` -**Spec requirement:** Returns number of non-tombstoned entries. +| Spec | Requirement | +|------|-------------| +| RTLM10d | Returns number of non-tombstoned entries | + +Note: RTLM10b and RTLM10c have been replaced by RTO25. The access API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. ### Setup ```pseudo @@ -94,7 +102,11 @@ ASSERT root.size() == 7 **Test ID**: `objects/unit/RTLM11/entries-yields-pairs-0` -**Spec requirement:** Returns non-tombstoned key-value pairs. +| Spec | Requirement | +|------|-------------| +| RTLM11d | Returns non-tombstoned key-value pairs | + +Note: RTLM11b and RTLM11c have been replaced by RTO25. The access API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. ### Setup ```pseudo @@ -150,10 +162,15 @@ ASSERT "name" IN keys | Spec | Requirement | |------|-------------| +| RTLM20a3 | value parameter accepts Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounterValueType, or LiveMapValueType | +| RTLM20e1 | Validates key and value per RTLMV4b and RTLMV4c | | RTLM20e2 | action set to MAP_SET | | RTLM20e3 | objectId set to LiveMap's objectId | | RTLM20e6 | mapSet.key set | | RTLM20e7c | mapSet.value.string for string value | +| RTLM20h2 | For non-value-type values, MAP_SET ObjectMessage is passed as single element | + +Note: RTLM20b, RTLM20c, and RTLM20d have been replaced by RTO26. The write API preconditions (OBJECT_PUBLISH mode check, channel state check, and echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. ### Setup ```pseudo @@ -232,15 +249,15 @@ ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": t --- -## RTLM20e7g - set() with LiveCounterValueType consumes and sends create + set +## RTLM20e7g - set() with LiveCounterValueType generates COUNTER_CREATE + MAP_SET **Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` | Spec | Requirement | |------|-------------| -| RTLM20e7g1 | Consume value type to generate COUNTER_CREATE | -| RTLM20e7g2 | Set mapSet.value.objectId to the created objectId | -| RTLM20h1 | Array: CREATE messages then MAP_SET | +| RTLM20e7g1 | Evaluate LiveCounterValueType per RTLCV4 to generate COUNTER_CREATE ObjectMessage | +| RTLM20e7g2 | Set mapSet.value.objectId to the objectId from the generated ObjectMessage | +| RTLM20h1 | Array contains *_CREATE ObjectMessages followed by MAP_SET ObjectMessage | ### Setup ```pseudo @@ -266,100 +283,125 @@ ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId --- -## RTLM21 - remove() sends MAP_REMOVE message +## RTLM20e7g - set() with LiveMapValueType generates nested CREATE messages + MAP_SET -**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` +**Test ID**: `objects/unit/RTLM20e7g/set-map-value-type-0` | Spec | Requirement | |------|-------------| -| RTLM21e2 | action set to MAP_REMOVE | -| RTLM21e5 | mapRemove.key set | +| RTLM20e7g1 | Evaluate LiveMapValueType per RTLMV4 to generate ordered list of ObjectMessages | +| RTLM20e7g2 | Set mapSet.value.objectId to the objectId from the final ObjectMessage in the list | +| RTLM20h1 | Array contains *_CREATE ObjectMessages followed by MAP_SET ObjectMessage | ### Setup ```pseudo captured_messages = [] -// (same mock setup as above) +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) ``` ### Test Steps ```pseudo -AWAIT root.remove("name") +AWAIT root.set("nested_map", LiveMap.create({ "key1": "value1" })) ``` ### Assertions ```pseudo -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_REMOVE" -ASSERT obj_msg.operation.objectId == "root" -ASSERT obj_msg.operation.mapRemove.key == "name" +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "MAP_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "map:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.key == "nested_map" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId ``` --- -## RTLM20d - set() with echoMessages false throws +## RTLM20h1 - set() with nested LiveMapValueType containing LiveCounterValueType -**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` +**Test ID**: `objects/unit/RTLM20h1/set-nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20h1 | Array contains all *_CREATE ObjectMessages followed by MAP_SET | +| RTLMV4d1 | Nested LiveCounterValueType is evaluated per RTLCV4 | +| RTLMV4d2 | Nested LiveMapValueType is recursively evaluated per RTLMV4 | -**Spec requirement:** If echoMessages is false, throw 40000. +Tests that when a LiveMapValueType contains a nested LiveCounterValueType, all CREATE messages appear before the MAP_SET in depth-first order. ### Setup ```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key", echoMessages: false }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() +captured_messages = [] +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) ``` ### Test Steps ```pseudo -AWAIT root.set("name", "Bob") FAILS WITH error +AWAIT root.set("stats", LiveMap.create({ + "count": LiveCounter.create(0), + "label": "test" +})) ``` ### Assertions ```pseudo -ASSERT error.code == 40000 +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +# Expect: COUNTER_CREATE, MAP_CREATE, MAP_SET (depth-first, then the MAP_SET at root) +ASSERT state.length == 3 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_CREATE" +ASSERT state[1].operation.objectId STARTS WITH "map:" +ASSERT state[2].operation.action == "MAP_SET" +ASSERT state[2].operation.mapSet.key == "stats" +ASSERT state[2].operation.mapSet.value.objectId == state[1].operation.objectId ``` --- -## RTLM21d - remove() with echoMessages false throws +## RTLM21 - remove() sends MAP_REMOVE message -**Test ID**: `objects/unit/RTLM21d/echo-messages-false-0` +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` -**Spec requirement:** Same as RTLM20d for remove. +| Spec | Requirement | +|------|-------------| +| RTLM21e1 | Validates key per RTLMV4b | +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +Note: RTLM21b, RTLM21c, and RTLM21d have been replaced by RTO26. The write API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. ### Setup ```pseudo -// Same echoMessages: false setup as above +captured_messages = [] +// (same mock setup as above) ``` ### Test Steps ```pseudo -AWAIT root.remove("name") FAILS WITH error +AWAIT root.remove("name") ``` ### Assertions ```pseudo -ASSERT error.code == 40000 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" ``` --- +## RTLM20d/RTLM21d - set()/remove() write preconditions (replaced by RTO26) + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +Note: RTLM20d and RTLM21d have been replaced by RTO26. The write API preconditions (including the echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. + +--- + ## RTLM20 - set() applies locally after ACK **Test ID**: `objects/unit/RTLM20/set-applies-locally-0` @@ -414,7 +456,10 @@ ASSERT obj_msg.operation.objectId == "root" **Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` -**Spec requirement:** set() rejects values of unsupported types with error 40013. +| Spec | Requirement | +|------|-------------| +| RTLM20e1 | Validates value per RTLMV4c | +| RTLMV4c | Unsupported value types throw error 40013 | ### Setup ```pseudo diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md index 5f8398e87..7911e852e 100644 --- a/uts/objects/unit/live_object_subscribe.md +++ b/uts/objects/unit/live_object_subscribe.md @@ -1,6 +1,6 @@ # LiveObject Subscribe Tests -Spec points: `RTLO4b`, `RTLO4c` +Spec points: `RTLO4b`, `RTLO4b3`, `RTLO4b4c1`, `RTLO4b4c3a`, `RTLO4b4c3c`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4b6`, `RTLO4b7` ## Test Type Unit test with mocked WebSocket client @@ -22,7 +22,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct | Spec | Requirement | |------|-------------| | RTLO4b3 | User provides listener for data updates | -| RTLO4b4c2 | Listener called with LiveObjectUpdate | +| RTLO4b4c3a | Registered listeners called with LiveObjectUpdate | | RTLO4b7 | Returns Subscription object | ### Setup @@ -49,50 +49,45 @@ ASSERT updates.length == 1 --- -## RTLO4b4c1 - noop update does not trigger listener +## RTLO4b7 - subscribe returns Subscription with unsubscribe method -**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` +**Test ID**: `objects/unit/RTLO4b7/subscribe-returns-subscription-0` -**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. +| Spec | Requirement | +|------|-------------| +| RTLO4b7 | Returns a Subscription object | + +Tests that `subscribe` returns a `Subscription` object that has an `unsubscribe` method. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -updates = [] instance = root.get("score").instance() -instance.subscribe((event) => updates.append(event)) ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 5, "01", "remote") -])) -poll_until(updates.length >= 1, timeout: 5s) - -mock_ws.send_to_client(build_object_message("test", [ - ObjectMessage( - serial: "01", siteCode: "remote", - operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } - ) -])) +sub = instance.subscribe((event) => {}) ``` ### Assertions ```pseudo -ASSERT updates.length == 1 +ASSERT sub IS Subscription +ASSERT sub.unsubscribe IS Function ``` --- -## RTLO4c - unsubscribe deregisters listener +## RTLO4b7 - Subscription#unsubscribe stops delivery -**Test ID**: `objects/unit/RTLO4c/unsubscribe-deregisters-0` +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0` | Spec | Requirement | |------|-------------| -| RTLO4c3 | Once deregistered, subsequent updates do not call listener | -| RTLO4c4 | No side effects on channel or RealtimeObject | +| RTLO4b7 | Returns a Subscription object | +| RTLO4b4c3a | Registered listeners called with LiveObjectUpdate | + +Tests that calling `unsubscribe()` on the returned `Subscription` deregisters the listener so that subsequent updates do not trigger it. ### Setup ```pseudo @@ -123,45 +118,64 @@ ASSERT updates.length == 1 --- -## RTLO4b1 - subscribe requires OBJECT_SUBSCRIBE mode +## RTLO4b7 - Subscription#unsubscribe is idempotent -**Test ID**: `objects/unit/RTLO4b1/subscribe-requires-mode-0` +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0` -**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. +**Spec requirement:** Calling `Subscription#unsubscribe()` multiple times must not throw or produce errors. ### Setup ```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS, modes: ["OBJECT_PUBLISH"] - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") instance = root.get("score").instance() +sub = instance.subscribe((event) => {}) ``` ### Test Steps ```pseudo -instance.subscribe((event) => {}) FAILS WITH error +sub.unsubscribe() +sub.unsubscribe() ``` ### Assertions ```pseudo -ASSERT error.code == 40024 +// No error thrown — both calls complete without error +``` + +--- + +## RTLO4b4c1 - noop update does not trigger listener + +**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` + +**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 ``` --- @@ -220,25 +234,153 @@ ASSERT updates.length == 1 --- -## RTLO4c1 - unsubscribe requires no channel mode +## RTLO4b4c3c - tombstone update deregisters all LiveObject#subscribe listeners -**Test ID**: `objects/unit/RTLO4c1/unsubscribe-no-mode-required-0` +**Test ID**: `objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4c3c | If LiveObjectUpdate.tombstone is true, deregister all LiveObject#subscribe listeners | +| RTLO4b4c3a | Listeners are called with the tombstone update itself before deregistration | -**Spec requirement:** Does not require any specific channel modes. +Tests that when a tombstone update is emitted, all registered listeners are called with the tombstone update, but subsequent updates do not fire any listener because they have been deregistered. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates_a = [] +updates_b = [] instance = root.get("score").instance() -sub = instance.subscribe((event) => {}) +instance.subscribe((event) => updates_a.append(event)) +instance.subscribe((event) => updates_b.append(event)) ``` ### Test Steps ```pseudo -sub.unsubscribe() +# Send an OBJECT_DELETE which causes a tombstone LiveObjectUpdate +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "50", "remote") +])) +poll_until(updates_a.length >= 1, timeout: 5s) + +# Both listeners should have received the tombstone update +ASSERT updates_a.length == 1 +ASSERT updates_a[0].tombstone == true +ASSERT updates_b.length == 1 +ASSERT updates_b[0].tombstone == true + +# Send another update — listeners should have been deregistered by tombstone +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 3, "51", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates_a.length == 1 +ASSERT updates_b.length == 1 +``` + +--- + +## RTLO4b4d - LiveObjectUpdate.objectMessage is populated from source ObjectMessage + +**Test ID**: `objects/unit/RTLO4b4d/update-has-object-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4d | LiveObjectUpdate.objectMessage is the source ObjectMessage that caused the update | + +Tests that when an update is triggered by an incoming ObjectMessage, the `LiveObjectUpdate.objectMessage` field is populated with that source ObjectMessage. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -// No error thrown +ASSERT updates.length == 1 +ASSERT updates[0].objectMessage IS NOT null +ASSERT updates[0].objectMessage.serial == "99" +ASSERT updates[0].objectMessage.siteCode == "remote" +ASSERT updates[0].objectMessage.operation.action == "COUNTER_INC" +ASSERT updates[0].objectMessage.operation.objectId == "counter:score@1000" +``` + +--- + +## RTLO4b4e - LiveObjectUpdate.tombstone is true for tombstone updates + +**Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-true-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4e | LiveObjectUpdate.tombstone indicates the update was emitted as a result of tombstoning | + +Tests that when a `LiveObject` is tombstoned (e.g. via OBJECT_DELETE), the emitted `LiveObjectUpdate` has `tombstone == true`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "50", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].tombstone == true +``` + +--- + +## RTLO4b4e - LiveObjectUpdate.tombstone is false for normal updates + +**Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-false-0` + +**Spec requirement:** LiveObjectUpdate.tombstone defaults to false if not explicitly set. + +Tests that for a normal (non-tombstone) update, `LiveObjectUpdate.tombstone` is `false`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].tombstone == false ``` diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md index 214fe7db0..cbbf400ed 100644 --- a/uts/objects/unit/objects_pool.md +++ b/uts/objects/unit/objects_pool.md @@ -82,7 +82,7 @@ ASSERT pool.syncState == SYNCING |------|-------------| | RTO4b1 | Remove all objects except root | | RTO4b2 | Clear root LiveMap data to zero-value | -| RTO4b2a | Emit LiveMapUpdate for root with removed entries | +| RTO4b2a | Emit LiveMapUpdate for root with removed entries, without populating objectMessage | | RTO4b4 | Perform sync completion actions | ### Setup @@ -114,6 +114,7 @@ ASSERT "root" IN pool ASSERT pool["root"].data == {} ASSERT updates.length >= 1 ASSERT updates[0].update == { "name": "removed" } +ASSERT updates[0].objectMessage IS null ``` --- @@ -908,3 +909,225 @@ ASSERT pool.syncState == SYNCED ASSERT "old_key" NOT IN pool["root"].data ASSERT pool["root"].data["new_key"].data == { string: "new" } ``` + +--- + +## RTO5c10 - Sync completion rebuilds parentReferences + +**Test ID**: `objects/unit/RTO5c10/sync-rebuilds-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10 | Rebuild every parentReferences map after sync completion | +| RTO5c10a | For each LiveObject in ObjectsPool, reset parentReferences to empty map (RTLO3f2) | +| RTO5c10b | For each LiveMap, iterate entries (RTLM11); for each entry whose value is a LiveObject, call addParentReference(parent, key) per RTLO4g | + +Tests that after a normal sync, each LiveObject in the pool has correct parentReferences matching its position in the synced tree. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "name": { data: { string: "Alice" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "nested_counter": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }) +])) +``` + +### Assertions +```pseudo +# root is not referenced by any parent +ASSERT pool["root"].parentReferences == {} + +# counter:score@1000 is referenced by root at key "score" +ASSERT pool["counter:score@1000"].parentReferences == { "root": {"score"} } + +# map:profile@1000 is referenced by root at key "profile" +ASSERT pool["map:profile@1000"].parentReferences == { "root": {"profile"} } + +# counter:nested@1000 is referenced by map:profile@1000 at key "nested_counter" +ASSERT pool["counter:nested@1000"].parentReferences == { "map:profile@1000": {"nested_counter"} } + +# Primitive-valued entries ("name") do not appear in any parentReferences +``` + +--- + +## RTO5c10 - Re-sync rebuilds parentReferences with new tree structure + +**Test ID**: `objects/unit/RTO5c10/resync-rebuilds-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | Reset parentReferences to empty map before rebuilding | +| RTO5c10b | Rebuild from current LiveMap entries after sync completion | + +Tests that after a second sync sequence with a different tree structure, parentReferences are reset then rebuilt to reflect the new tree, not the old one. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +# First sync: counter:abc@1000 is a child of root +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "counter_key": { data: { objectId: "counter:abc@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 10 }, + createOp: { counterCreate: { count: 10 } } + }) +])) + +# Verify first sync parentReferences +ASSERT pool["counter:abc@1000"].parentReferences == { "root": {"counter_key"} } +``` + +### Test Steps +```pseudo +# Second sync: counter:abc@1000 is now a child of map:wrapper@1000, not root +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "wrapper": { data: { objectId: "map:wrapper@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("map:wrapper@1000", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "moved_counter": { data: { objectId: "counter:abc@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:1"}, { + counter: { count: 20 }, + createOp: { counterCreate: { count: 20 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# root is not referenced by any parent +ASSERT pool["root"].parentReferences == {} + +# map:wrapper@1000 is now a child of root at key "wrapper" +ASSERT pool["map:wrapper@1000"].parentReferences == { "root": {"wrapper"} } + +# counter:abc@1000 is now a child of map:wrapper@1000, NOT of root +ASSERT pool["counter:abc@1000"].parentReferences == { "map:wrapper@1000": {"moved_counter"} } +``` + +--- + +## RTO5c10 - Empty sync leaves root with empty parentReferences + +**Test ID**: `objects/unit/RTO5c10/empty-sync-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | Reset parentReferences to empty map | +| RTO4b | ATTACHED without HAS_OBJECTS performs immediate sync completion | + +Tests that after an empty sync (no HAS_OBJECTS flag), root has empty parentReferences because there are no children to reference it. + +### Setup +```pseudo +pool = ObjectsPool() + +# First, do a normal sync to populate parentReferences +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "child": { data: { objectId: "counter:child@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:child@1000", {"aaa": "t:0"}, { + counter: { count: 1 }, + createOp: { counterCreate: { count: 1 } } + }) +])) + +# Verify parentReferences are populated after first sync +ASSERT pool["counter:child@1000"].parentReferences == { "root": {"child"} } +``` + +### Test Steps +```pseudo +# Empty sync: ATTACHED without HAS_OBJECTS +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# counter:child@1000 was removed from pool (RTO4b1) +ASSERT "counter:child@1000" NOT IN pool + +# root exists with empty data and empty parentReferences +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT pool["root"].parentReferences == {} +``` diff --git a/uts/objects/unit/parent_references.md b/uts/objects/unit/parent_references.md new file mode 100644 index 000000000..33d4d74e6 --- /dev/null +++ b/uts/objects/unit/parent_references.md @@ -0,0 +1,734 @@ +# Parent References Tests + +Spec points: `RTLO3f`, `RTLO4g`, `RTLO4h`, `RTLO4f`, `RTO5c10` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `parentReferences` tracking on `LiveObject`, the `addParentReference` and `removeParentReference` methods, the `getFullPaths` graph traversal, and the post-sync rebuild of parentReferences by the ObjectsPool. + +`parentReferences` is a `Dict>` keyed by parent LiveMap objectId, with each value being the set of keys at which that LiveMap references this LiveObject. These references allow `getFullPaths` to determine every key-path from root to a given object in the LiveObjects graph. + +Tests operate directly on LiveObject/LiveCounter/LiveMap instances and on ObjectsPool for the post-sync rebuild tests. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTLO3f2 - parentReferences initialized to empty map on LiveCounter + +**Test ID**: `objects/unit/RTLO3f2/init-empty-counter-0` + +**Spec requirement:** parentReferences is set to an empty map when the LiveObject is initialized. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.parentReferences == {} +``` + +--- + +## RTLO3f2 - parentReferences initialized to empty map on LiveMap + +**Test ID**: `objects/unit/RTLO3f2/init-empty-map-0` + +**Spec requirement:** parentReferences is set to an empty map when the LiveObject is initialized. + +### Setup +```pseudo +map = LiveMap(objectId: "map:abc@1000", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.parentReferences == {} +``` + +--- + +## RTLO4g2 - addParentReference creates new entry for first reference + +**Test ID**: `objects/unit/RTLO4g2/first-reference-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4g2 | If parentReferences does not contain an entry for parent.objectId, insert a new entry with a set containing only key | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT "map:parent@1000" IN child.parentReferences +ASSERT child.parentReferences["map:parent@1000"] == {"score"} +``` + +--- + +## RTLO4g1 - addParentReference adds key to existing entry for same parent + +**Test ID**: `objects/unit/RTLO4g1/second-key-same-parent-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4g1 | If parentReferences already contains an entry for parent.objectId, add key to that entry's set | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.addParentReference(parent, "points") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"score", "points"} +``` + +--- + +## RTLO4g - addParentReference with different parent creates separate entry + +**Test ID**: `objects/unit/RTLO4g/different-parent-separate-entry-0` + +**Spec requirement:** Each parent LiveMap gets its own entry in parentReferences. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent_a, "x") +child.addParentReference(parent_b, "y") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:a@1000"] == {"x"} +ASSERT child.parentReferences["map:b@1000"] == {"y"} +``` + +--- + +## RTLO4g - addParentReference with multiple parents and multiple keys + +**Test ID**: `objects/unit/RTLO4g/multiple-parents-multiple-keys-0` + +**Spec requirement:** parentReferences correctly tracks multiple keys across multiple parents. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent_a, "x") +child.addParentReference(parent_a, "y") +child.addParentReference(parent_b, "p") +child.addParentReference(parent_b, "q") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:a@1000"] == {"x", "y"} +ASSERT child.parentReferences["map:b@1000"] == {"p", "q"} +``` + +--- + +## RTLO4h1 - removeParentReference no-op for non-existent parent + +**Test ID**: `objects/unit/RTLO4h1/nonexistent-parent-noop-0` + +**Spec requirement:** If parentReferences does not contain an entry for parent.objectId, do nothing. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences == {} +``` + +--- + +## RTLO4h2 - removeParentReference removes key but leaves other keys + +**Test ID**: `objects/unit/RTLO4h2/remove-key-leaves-others-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4h2 | Remove key from that entry's set | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score", "points"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"points"} +``` + +--- + +## RTLO4h3 - removeParentReference removes entry when set becomes empty + +**Test ID**: `objects/unit/RTLO4h3/remove-last-key-removes-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4h2 | Remove key from that entry's set | +| RTLO4h3 | If the entry's set is empty after removal, remove the entry from parentReferences | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT "map:parent@1000" NOT IN child.parentReferences +ASSERT child.parentReferences == {} +``` + +--- + +## RTLO4h - removeParentReference for non-existent key in existing parent + +**Test ID**: `objects/unit/RTLO4h/remove-nonexistent-key-0` + +**Spec requirement:** Removing a key that does not exist in the parent's set does not alter the existing keys. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "nonexistent") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"score"} +``` + +--- + +## RTLO4f2 - getFullPaths for root returns empty key-path + +**Test ID**: `objects/unit/RTLO4f2/root-returns-empty-path-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | The empty simple path (which exists only when this LiveObject is itself root) contributes the empty key-path [] | + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] +``` + +### Assertions +```pseudo +paths = root.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS [] +``` + +--- + +## RTLO4f - getFullPaths for direct child of root + +**Test ID**: `objects/unit/RTLO4f/direct-child-single-path-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f1 | Graph G has directed edges from parent to child labelled with key, derived from parentReferences | +| RTLO4f2 | Each simple path from root to this LiveObject contributes one key-path | + +Tests that a LiveObject referenced directly from root at key "score" returns [["score"]]. + +### Setup +```pseudo +pool = ObjectsPool() +counter = LiveCounter(objectId: "counter:score@1000") +pool["counter:score@1000"] = counter + +root = pool["root"] +counter.addParentReference(root, "score") +``` + +### Assertions +```pseudo +paths = counter.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS ["score"] +``` + +--- + +## RTLO4f - getFullPaths for deeply nested object + +**Test ID**: `objects/unit/RTLO4f/deep-nesting-0` + +**Spec requirement:** getFullPaths traverses multiple levels of parentReferences to find all key-paths from root. + +Tests the path root --"profile"--> map:profile --"prefs"--> map:prefs --"theme_counter"--> counter:theme. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +profile = LiveMap(objectId: "map:profile@1000", semantics: "LWW") +pool["map:profile@1000"] = profile +profile.addParentReference(root, "profile") + +prefs = LiveMap(objectId: "map:prefs@1000", semantics: "LWW") +pool["map:prefs@1000"] = prefs +prefs.addParentReference(profile, "prefs") + +theme_counter = LiveCounter(objectId: "counter:theme@1000") +pool["counter:theme@1000"] = theme_counter +theme_counter.addParentReference(prefs, "theme_counter") +``` + +### Assertions +```pseudo +paths = theme_counter.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS ["profile", "prefs", "theme_counter"] +``` + +--- + +## RTLO4f - getFullPaths with multiple parents (diamond graph) + +**Test ID**: `objects/unit/RTLO4f/diamond-graph-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | Each simple path from root to this LiveObject contributes one key-path | +| RTLO4f3 | Each key-path appears exactly once; order is unspecified | + +Tests a diamond: root --"a"--> map:A --"x"--> counter:leaf, and root --"b"--> map:B --"y"--> counter:leaf. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +pool["map:a@1000"] = map_a +map_a.addParentReference(root, "a") + +map_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +pool["map:b@1000"] = map_b +map_b.addParentReference(root, "b") + +leaf = LiveCounter(objectId: "counter:leaf@1000") +pool["counter:leaf@1000"] = leaf +leaf.addParentReference(map_a, "x") +leaf.addParentReference(map_b, "y") +``` + +### Assertions +```pseudo +paths = leaf.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["a", "x"] +ASSERT paths CONTAINS ["b", "y"] +``` + +--- + +## RTLO4f - getFullPaths with single parent referencing at multiple keys + +**Test ID**: `objects/unit/RTLO4f/single-parent-multiple-keys-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | Each simple path from root contributes one key-path | +| RTLO4f3 | Each key-path appears exactly once | + +Tests that when a parent map references the same child at two different keys, two distinct key-paths are returned. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +child = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child +child.addParentReference(root, "primary") +child.addParentReference(root, "alias") +``` + +### Assertions +```pseudo +paths = child.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["primary"] +ASSERT paths CONTAINS ["alias"] +``` + +--- + +## RTLO4f - getFullPaths for orphan returns empty list + +**Test ID**: `objects/unit/RTLO4f/orphan-returns-empty-0` + +**Spec requirement:** An object with no parentReferences path leading to root has no key-paths. + +### Setup +```pseudo +pool = ObjectsPool() + +orphan = LiveCounter(objectId: "counter:orphan@1000") +pool["counter:orphan@1000"] = orphan +``` + +### Assertions +```pseudo +paths = orphan.getFullPaths() +ASSERT paths.length == 0 +``` + +--- + +## RTLO4f - getFullPaths suppresses cycles + +**Test ID**: `objects/unit/RTLO4f/cycle-suppression-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | A simple path visits each node at most once | +| RTLO4f4 | (non-normative) Typical approach skips branches that would revisit a node | + +Tests that a cycle in parentReferences does not cause infinite traversal. Graph: root --"a"--> map:A --"b"--> map:B --"a"--> map:A (cycle). The only valid simple path to map:B is ["a", "b"]. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +pool["map:a@1000"] = map_a +map_a.addParentReference(root, "a") + +map_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +pool["map:b@1000"] = map_b +map_b.addParentReference(map_a, "b") + +# Create a cycle: map:A also has map:B as a parent +map_a.addParentReference(map_b, "a") +``` + +### Assertions +```pseudo +paths_b = map_b.getFullPaths() +ASSERT paths_b.length == 1 +ASSERT paths_b CONTAINS ["a", "b"] + +paths_a = map_a.getFullPaths() +ASSERT paths_a.length == 1 +ASSERT paths_a CONTAINS ["a"] +``` + +--- + +## RTLO4f - getFullPaths with complex diamond and deep nesting + +**Test ID**: `objects/unit/RTLO4f/complex-diamond-deep-0` + +**Spec requirement:** getFullPaths returns all distinct simple paths from root, including through multiple intermediate nodes. + +Tests a graph where root has two branches that converge on a deeply nested object: +- root --"left"--> map:L --"mid"--> map:M --"target"--> counter:T +- root --"right"--> map:R --"target"--> counter:T + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_l = LiveMap(objectId: "map:l@1000", semantics: "LWW") +pool["map:l@1000"] = map_l +map_l.addParentReference(root, "left") + +map_r = LiveMap(objectId: "map:r@1000", semantics: "LWW") +pool["map:r@1000"] = map_r +map_r.addParentReference(root, "right") + +map_m = LiveMap(objectId: "map:m@1000", semantics: "LWW") +pool["map:m@1000"] = map_m +map_m.addParentReference(map_l, "mid") + +target = LiveCounter(objectId: "counter:t@1000") +pool["counter:t@1000"] = target +target.addParentReference(map_m, "target") +target.addParentReference(map_r, "target") +``` + +### Assertions +```pseudo +paths = target.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["left", "mid", "target"] +ASSERT paths CONTAINS ["right", "target"] +``` + +--- + +## RTO5c10 - Post-sync rebuild populates parentReferences from LiveMap entries + +**Test ID**: `objects/unit/RTO5c10/rebuild-from-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | For each LiveObject in the ObjectsPool, reset parentReferences to empty map | +| RTO5c10b | For each LiveMap, iterate entries; for each entry whose value is a LiveObject, call addParentReference on that LiveObject | + +Tests that after a sync completes, parentReferences are rebuilt from the LiveMap entries received during sync. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "nested": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# counter:score@1000 is referenced by root at key "score" +score = pool["counter:score@1000"] +ASSERT score.parentReferences["root"] == {"score"} + +# map:profile@1000 is referenced by root at key "profile" +profile = pool["map:profile@1000"] +ASSERT profile.parentReferences["root"] == {"profile"} + +# counter:nested@1000 is referenced by map:profile@1000 at key "nested" +nested = pool["counter:nested@1000"] +ASSERT nested.parentReferences["map:profile@1000"] == {"nested"} + +# root has no parent references +ASSERT pool["root"].parentReferences == {} + +# getFullPaths works correctly after rebuild +ASSERT score.getFullPaths() CONTAINS ["score"] +ASSERT nested.getFullPaths() CONTAINS ["profile", "nested"] +``` + +--- + +## RTO5c10a - Post-sync rebuild clears stale parentReferences + +**Test ID**: `objects/unit/RTO5c10a/rebuild-clears-stale-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | For each LiveObject, reset parentReferences to the initial value (empty map) | +| RTO5c10b | Then rebuild from current LiveMap entries | + +Tests that parentReferences from a previous sync are cleared and rebuilt from the new sync data, even when objects are reused across syncs. + +### Setup +```pseudo +pool = ObjectsPool() + +# First sync: root --"score"--> counter:abc@1000 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:abc@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 10 }, + createOp: { counterCreate: { count: 10 } } + }) +])) +ASSERT pool["counter:abc@1000"].parentReferences["root"] == {"score"} +``` + +### Test Steps +```pseudo +# Second sync: root --"points"--> counter:abc@1000 (key changed from "score" to "points") +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "points": { data: { objectId: "counter:abc@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:1"}, { + counter: { count: 20 }, + createOp: { counterCreate: { count: 20 } } + }) +])) +``` + +### Assertions +```pseudo +counter = pool["counter:abc@1000"] + +# Old "score" reference should be gone, replaced by "points" +ASSERT counter.parentReferences["root"] == {"points"} +ASSERT counter.getFullPaths() CONTAINS ["points"] + +paths = counter.getFullPaths() +ASSERT paths.length == 1 +``` + +--- + +## RTO5c10 - Post-sync unreferenced objects have empty parentReferences + +**Test ID**: `objects/unit/RTO5c10/unreferenced-empty-refs-0` + +**Spec requirement:** Objects that exist in the pool but are not referenced by any LiveMap entry have empty parentReferences after rebuild. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:orphan@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# The counter exists in the pool but no LiveMap entry points to it +orphan = pool["counter:orphan@1000"] +ASSERT orphan.parentReferences == {} + +# getFullPaths returns empty list for unreferenced object +ASSERT orphan.getFullPaths().length == 0 +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md index 5a83c8e9c..96d989754 100644 --- a/uts/objects/unit/path_object.md +++ b/uts/objects/unit/path_object.md @@ -167,7 +167,10 @@ ASSERT po.path() == "a\\.b.c" **Test ID**: `objects/unit/RTPO7/value-counter-0` -**Spec requirement:** If resolved value is LiveCounter, returns numeric value. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7c | LiveCounter -> delegates to LiveCounter#value | ### Setup ```pseudo @@ -185,7 +188,10 @@ ASSERT root.get("score").value() == 100 **Test ID**: `objects/unit/RTPO7/value-primitive-0` -**Spec requirement:** If resolved value is a primitive, returns the value directly. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive -> returns value directly | ### Setup ```pseudo @@ -205,7 +211,10 @@ ASSERT root.get("active").value() == true **Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` -**Spec requirement:** If resolved value is a LiveMap, returns null. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7e | LiveMap -> returns null | ### Setup ```pseudo @@ -223,7 +232,10 @@ ASSERT root.get("profile").value() == null **Test ID**: `objects/unit/RTPO7e/value-unresolvable-null-0` -**Spec requirement:** If path resolution fails, returns null. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7f | Resolution failure -> returns null per RTPO3c1 | ### Setup ```pseudo @@ -243,7 +255,8 @@ ASSERT root.get("nonexistent").get("deep").value() == null | Spec | Requirement | |------|-------------| -| RTPO8b | LiveMap or LiveCounter -> Instance wrapping that object | +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8c | LiveObject -> Instance wrapping that object | ### Setup ```pseudo @@ -267,7 +280,10 @@ ASSERT map_inst.id() == "map:profile@1000" **Test ID**: `objects/unit/RTPO8c/instance-primitive-null-0` -**Spec requirement:** If resolved value is a primitive, returns null. +| Spec | Requirement | +|------|-------------| +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8d | Primitive -> returns null | ### Setup ```pseudo @@ -281,14 +297,15 @@ ASSERT root.get("name").instance() == null --- -## RTPO9 - entries() yields [key, PathObject] pairs +## RTPO9 - entries() returns array of [key, PathObject] pairs **Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` | Spec | Requirement | |------|-------------| -| RTPO9b | Iterator of [key, PathObject] for LiveMap entries | -| RTPO9c | Only non-tombstoned entries | +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9c | Uses LiveMap#keys (RTLM12) to get keys, returns array of [key, PathObject] pairs | +| RTPO9d | Only non-tombstoned entries (tombstoned excluded by LiveMap#keys) | ### Setup ```pseudo @@ -311,11 +328,14 @@ ASSERT entries.length == 7 --- -## RTPO9d - entries() returns empty iterator for non-LiveMap +## RTPO9d - entries() returns empty array for non-LiveMap **Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` -**Spec requirement:** If resolved value is not LiveMap or resolution fails, return empty iterator. +| Spec | Requirement | +|------|-------------| +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9d | Not LiveMap or resolution failure -> returns empty array | ### Setup ```pseudo @@ -324,7 +344,7 @@ ASSERT entries.length == 7 ### Test Steps ```pseudo -entries = list(root.get("score").entries()) +entries = root.get("score").entries() ``` ### Assertions @@ -334,11 +354,132 @@ ASSERT entries.length == 0 --- +## RTPO10 - keys() returns array of key strings + +**Test ID**: `objects/unit/RTPO10/keys-returns-array-0` + +| Spec | Requirement | +|------|-------------| +| RTPO10a | Checks access API preconditions per RTO25 | +| RTPO10c | LiveMap -> delegates to LiveMap#keys (RTLM12) | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = root.keys() +``` + +### Assertions +```pseudo +ASSERT keys IS Array +ASSERT keys.length == 7 +ASSERT "name" IN keys +ASSERT "profile" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTPO10d - keys() returns empty array for non-LiveMap + +**Test ID**: `objects/unit/RTPO10d/keys-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO10a | Checks access API preconditions per RTO25 | +| RTPO10d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = root.get("score").keys() +``` + +### Assertions +```pseudo +ASSERT keys IS Array +ASSERT keys.length == 0 +``` + +--- + +## RTPO11 - values() returns array of PathObjects + +**Test ID**: `objects/unit/RTPO11/values-returns-array-0` + +| Spec | Requirement | +|------|-------------| +| RTPO11a | Checks access API preconditions per RTO25 | +| RTPO11c | LiveMap -> uses LiveMap#keys (RTLM12) and returns array of PathObjects | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +vals = root.values() +``` + +### Assertions +```pseudo +ASSERT vals IS Array +ASSERT vals.length == 7 +// Each element is a PathObject whose path is the key +paths = {} +FOR v IN vals: + paths[v.path()] = true +ASSERT paths["name"] == true +ASSERT paths["profile"] == true +ASSERT paths["score"] == true +``` + +--- + +## RTPO11d - values() returns empty array for non-LiveMap + +**Test ID**: `objects/unit/RTPO11d/values-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO11a | Checks access API preconditions per RTO25 | +| RTPO11d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +vals = root.get("score").values() +``` + +### Assertions +```pseudo +ASSERT vals IS Array +ASSERT vals.length == 0 +``` + +--- + ## RTPO12 - size() returns non-tombstoned count **Test ID**: `objects/unit/RTPO12/size-count-0` -**Spec requirement:** For LiveMap, returns non-tombstoned entry count. +| Spec | Requirement | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12c | LiveMap -> delegates to LiveMap#size (RTLM10) | ### Setup ```pseudo @@ -357,6 +498,11 @@ ASSERT root.get("profile").size() == 3 **Test ID**: `objects/unit/RTPO12c/size-non-map-null-0` +| Spec | Requirement | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12d | Not LiveMap or resolution failure -> returns null | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -376,10 +522,11 @@ ASSERT root.get("name").size() == null | Spec | Requirement | |------|-------------| -| RTPO13b1 | Each entry included, tombstoned excluded | -| RTPO13b2 | Nested LiveMap recursively compacted | -| RTPO13b3 | Nested LiveCounter resolved to number | -| RTPO13b4 | Primitives as-is | +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c1 | Each entry included, tombstoned excluded | +| RTPO13c2 | Nested LiveMap recursively compacted | +| RTPO13c3 | Nested LiveCounter resolved to number | +| RTPO13c4 | Primitives as-is | ### Setup ```pseudo @@ -410,7 +557,10 @@ ASSERT result["profile"]["prefs"]["theme"] == "dark" **Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` -**Spec requirement:** Cyclic references reuse the already-compacted in-memory object. +| Spec | Requirement | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c5 | Cyclic references reuse already-compacted in-memory object | ### Setup ```pseudo @@ -437,6 +587,11 @@ ASSERT result["prefs"]["back_ref"] IS result **Test ID**: `objects/unit/RTPO13c/compact-counter-0` +| Spec | Requirement | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13d | LiveCounter -> returns numeric value | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -455,8 +610,9 @@ ASSERT root.get("score").compact() == 100 | Spec | Requirement | |------|-------------| -| RTPO14a1 | Binary as base64 strings | -| RTPO14a2 | Cycles as {objectId: ...} | +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary as base64 strings | +| RTPO14b2 | Cycles as {objectId: ...} | ### Setup ```pseudo @@ -567,7 +723,10 @@ ASSERT error.code == 40003 **Test ID**: `objects/unit/RTPO7/value-bytes-0` -**Spec requirement:** If resolved value is bytes, returns the raw binary data. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive (Binary) -> returns raw binary data | ### Setup ```pseudo @@ -585,7 +744,10 @@ ASSERT root.get("avatar").value() IS bytes [1, 2, 3] **Test ID**: `objects/unit/RTPO14/compact-json-bytes-0` -**Spec requirement:** Binary values encoded as base64 strings in JSON representation. +| Spec | Requirement | +|------|-------------| +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary values encoded as base64 strings | ### Setup ```pseudo diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md index ef33a1a15..43e8f2d59 100644 --- a/uts/objects/unit/path_object_mutations.md +++ b/uts/objects/unit/path_object_mutations.md @@ -21,8 +21,9 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct | Spec | Requirement | |------|-------------| -| RTPO15b | Resolves path, on failure throws RTPO3c2 | -| RTPO15c | LiveMap -> delegates to LiveMap#set | +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO15c | Resolves path, on failure throws RTPO3c2 | +| RTPO15d | LiveMap -> delegates to LiveMap#set (RTLM20) | ### Setup ```pseudo @@ -45,6 +46,11 @@ ASSERT root.get("name").value() == "Bob" **Test ID**: `objects/unit/RTPO15/set-nested-path-0` +| Spec | Requirement | +|------|-------------| +| RTPO15a2 | value accepts same types as LiveMap#set (RTLM20): primitives and LiveCounterValueType/LiveMapValueType | +| RTPO15b | Checks write API preconditions per RTO26 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -66,7 +72,10 @@ ASSERT root.get("profile").get("email").value() == "bob@example.com" **Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` -**Spec requirement:** If resolved value is not a LiveMap, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO15e | Not LiveMap -> throws 92007 | ### Setup ```pseudo @@ -91,8 +100,9 @@ ASSERT error.code == 92007 | Spec | Requirement | |------|-------------| -| RTPO16b | Resolves path, on failure throws RTPO3c2 | -| RTPO16c | LiveMap -> delegates to LiveMap#remove | +| RTPO16b | Checks write API preconditions per RTO26 | +| RTPO16c | Resolves path, on failure throws RTPO3c2 | +| RTPO16d | LiveMap -> delegates to LiveMap#remove (RTLM21) | ### Setup ```pseudo @@ -115,7 +125,10 @@ ASSERT root.get("name").value() == null **Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` -**Spec requirement:** If resolved value is not a LiveMap, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO16b | Checks write API preconditions per RTO26 | +| RTPO16e | Not LiveMap -> throws 92007 | ### Setup ```pseudo @@ -140,8 +153,9 @@ ASSERT error.code == 92007 | Spec | Requirement | |------|-------------| -| RTPO17b | Resolves path, on failure throws RTPO3c2 | -| RTPO17c | LiveCounter -> delegates to LiveCounter#increment | +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO17c | Resolves path, on failure throws RTPO3c2 | +| RTPO17d | LiveCounter -> delegates to LiveCounter#increment (RTLC12) | ### Setup ```pseudo @@ -164,7 +178,10 @@ ASSERT root.get("score").value() == 125 **Test ID**: `objects/unit/RTPO17/increment-default-amount-0` -**Spec requirement:** amount defaults to 1. +| Spec | Requirement | +|------|-------------| +| RTPO17a1 | amount defaults to 1 | +| RTPO17b | Checks write API preconditions per RTO26 | ### Setup ```pseudo @@ -187,7 +204,10 @@ ASSERT root.get("score").value() == 101 **Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` -**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO17e | Not LiveCounter -> throws 92007 | ### Setup ```pseudo @@ -212,8 +232,9 @@ ASSERT error.code == 92007 | Spec | Requirement | |------|-------------| -| RTPO18b | Resolves path, on failure throws RTPO3c2 | -| RTPO18c | LiveCounter -> delegates to LiveCounter#decrement | +| RTPO18b | Checks write API preconditions per RTO26 | +| RTPO18c | Resolves path, on failure throws RTPO3c2 | +| RTPO18d | LiveCounter -> delegates to LiveCounter#decrement (RTLC13) | ### Setup ```pseudo @@ -236,7 +257,10 @@ ASSERT root.get("score").value() == 90 **Test ID**: `objects/unit/RTPO18/decrement-default-amount-0` -**Spec requirement:** amount defaults to 1. +| Spec | Requirement | +|------|-------------| +| RTPO18a1 | amount defaults to 1 | +| RTPO18b | Checks write API preconditions per RTO26 | ### Setup ```pseudo @@ -259,7 +283,10 @@ ASSERT root.get("score").value() == 99 **Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` -**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO18b | Checks write API preconditions per RTO26 | +| RTPO18e | Not LiveCounter -> throws 92007 | ### Setup ```pseudo @@ -282,7 +309,10 @@ ASSERT error.code == 92007 **Test ID**: `objects/unit/RTPO3c2/set-unresolvable-throws-0` -**Spec requirement:** For write operations, if path resolution fails, throw 92005. +| Spec | Requirement | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO3c2 | Write operations on unresolvable path throw ErrorInfo with statusCode 400, code 92005 | ### Setup ```pseudo @@ -297,6 +327,7 @@ AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error ### Assertions ```pseudo ASSERT error.code == 92005 +ASSERT error.statusCode == 400 ``` --- @@ -305,6 +336,11 @@ ASSERT error.code == 92005 **Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` +| Spec | Requirement | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO3c2 | Write operations on unresolvable path throw ErrorInfo with statusCode 400, code 92005 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -318,4 +354,5 @@ AWAIT root.get("nonexistent").increment(5) FAILS WITH error ### Assertions ```pseudo ASSERT error.code == 92005 +ASSERT error.statusCode == 400 ``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md index 503ac43f2..5f0de3fbc 100644 --- a/uts/objects/unit/path_object_subscribe.md +++ b/uts/objects/unit/path_object_subscribe.md @@ -1,6 +1,6 @@ # PathObject Subscribe Tests -Spec points: `RTPO19`–`RTPO21`, `RTO24` +Spec points: `RTPO19`, `RTO24`, `RTO25` ## Test Type Unit test with mocked WebSocket client @@ -21,9 +21,9 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct | Spec | Requirement | |------|-------------| -| RTPO19c | Returns Subscription object | -| RTPO19d1 | Event.object is a PathObject pointing to change path | -| RTPO19d2 | Event.message is the ObjectMessage | +| RTPO19d | Returns Subscription object | +| RTPO19e1 | Event.object is a PathObject pointing to change path | +| RTPO19e2 | Event.message is the PublicAPI::ObjectMessage | ### Setup ```pseudo @@ -47,15 +47,117 @@ ASSERT events.length == 1 ASSERT events[0].object IS PathObject ASSERT events[0].object.path() == "score" ASSERT events[0].message IS NOT null +ASSERT events[0].message.serial == "99" +ASSERT events[0].message.siteCode == "remote" +ASSERT events[0].message.operation IS NOT null +ASSERT events[0].message.operation.action == "COUNTER_INC" +ASSERT events[0].message.channel == "test" ``` --- -## RTPO19b1b - subscribe() with depth 1 only receives self events +## RTPO19b - subscribe() checks RTO25 access API preconditions on DETACHED channel -**Test ID**: `objects/unit/RTPO19b1b/subscribe-depth-1-self-only-0` +**Test ID**: `objects/unit/RTPO19b/subscribe-precondition-detached-0` -**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener. +| Spec | Requirement | +|------|-------------| +| RTPO19b | Checks the access API preconditions per RTO25 | +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that subscribe() on a DETACHED channel throws 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key", autoConnect: true }) +channel = client.channels.get("test", { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] +}) +channel.attach() +AWAIT_STATE channel.state == DETACHED +root_path = channel.object.getRoot() +``` + +### Test Steps +```pseudo +root_path.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTPO19c1a - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0` + +**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19c1a - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19c1a/subscribe-negative-depth-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19c1 - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener (RTO24c2b). ### Setup ```pseudo @@ -83,11 +185,11 @@ ASSERT events.length == 1 --- -## RTPO19b1c - subscribe() with depth 2 receives self and children +## RTPO19c1 - subscribe() with depth 2 receives self and children -**Test ID**: `objects/unit/RTPO19b1c/subscribe-depth-2-children-0` +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-2-children-0` -**Spec requirement:** depth=n means changes up to n-1 levels of children trigger the listener. +**Spec requirement:** depth=2 means changes at the subscribed path and one level of children trigger the listener (RTO24c2c). ### Setup ```pseudo @@ -120,11 +222,11 @@ ASSERT events.length == 2 --- -## RTPO19b1a - subscribe() with no depth receives all descendants +## RTPO19c1 - subscribe() with no depth receives all descendants -**Test ID**: `objects/unit/RTPO19b1a/subscribe-unlimited-depth-0` +**Test ID**: `objects/unit/RTPO19c1/subscribe-unlimited-depth-0` -**Spec requirement:** If depth is undefined, subscription receives events at any depth. +**Spec requirement:** If depth is undefined, subscription receives events at any depth (RTO24c2a). ### Setup ```pseudo @@ -158,55 +260,76 @@ ASSERT events.length >= 3 --- -## RTPO19b1d - subscribe() with non-positive depth throws 40003 +## RTPO19d - subscribe() returns Subscription with unsubscribe() -**Test ID**: `objects/unit/RTPO19b1d/subscribe-non-positive-depth-throws-0` +**Test ID**: `objects/unit/RTPO19d/subscribe-returns-subscription-0` -**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. +**Spec requirement:** RTPO19d: subscribe returns a Subscription (SUB1) object. Calling unsubscribe() deregisters the listener. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +ASSERT sub IS Subscription +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) ``` ### Assertions ```pseudo -ASSERT error.code == 40003 +ASSERT events.length == 0 ``` --- -## RTPO19b1d - subscribe() with negative depth throws 40003 +## RTPO19e1 - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19e1/event-path-object-correct-0` -**Test ID**: `objects/unit/RTPO19b1d/subscribe-negative-depth-throws-0` +**Spec requirement:** RTPO19e1: event.object is a PathObject pointing to the change location. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT error.code == 40003 +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].object.value() == 107 ``` --- -## RTPO19e - subscribe() follows path not identity +## RTPO19e2 - subscribe() event delivers PublicAPI::ObjectMessage for operations + +**Test ID**: `objects/unit/RTPO19e2/event-message-delivery-0` -**Test ID**: `objects/unit/RTPO19e/subscribe-follows-path-0` +| Spec | Requirement | +|------|-------------| +| RTPO19e2 | event.message is a PublicAPI::ObjectMessage derived from the LiveObjectUpdate.objectMessage per PAOM3 | +| RTO24b2b2 | message populated when objectMessage has an operation field | -**Spec requirement:** If the object at the path changes identity, the subscription continues to deliver events for the new object. +Tests that the event delivered to a subscription listener includes a `message` field containing a `PublicAPI::ObjectMessage` with the correct fields copied from the source ObjectMessage. ### Setup ```pseudo @@ -217,402 +340,459 @@ root.get("score").subscribe((event) => events.append(event)) ### Test Steps ```pseudo -// Replace the counter at "score" with a new counter mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") -])) - -// Increment the NEW counter at "score" -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:new@2000", 10, "100", "remote") + build_counter_inc("counter:score@1000", 42, "serial-1", "site-a") ])) poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -// Should receive event for the new counter, since subscription follows path -found_new = false -FOR event IN events: - IF event.object.path() == "score": - found_new = true -ASSERT found_new == true +ASSERT events[0].message IS NOT null +ASSERT events[0].message.channel == "test" +ASSERT events[0].message.serial == "serial-1" +ASSERT events[0].message.siteCode == "site-a" +ASSERT events[0].message.operation IS NOT null +ASSERT events[0].message.operation.action == "COUNTER_INC" +ASSERT events[0].message.operation.objectId == "counter:score@1000" +ASSERT events[0].message.operation.counterInc.number == 42 ``` --- -## RTPO19f - child events bubble up to parent subscription +## RTPO19e2 - subscribe() event omits message when objectMessage has no operation -**Test ID**: `objects/unit/RTPO19f/child-events-bubble-0` +**Test ID**: `objects/unit/RTPO19e2/event-message-omitted-no-operation-0` -**Spec requirement:** Events at child paths bubble up subject to depth filtering. +**Spec requirement:** RTPO19e2: if the objectMessage's operation field is not populated, message is omitted. + +Tests that events triggered by non-operation updates (e.g. sync-only changes) do not include a message field. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -root.get("profile").subscribe((event) => events.append(event)) +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") -])) +// Send a MAP_SET on the root that replaces "score" with a new objectId, +// which triggers a subscription event on root. +// Then send an OBJECT_SYNC that changes counter:score@1000's state +// without an operation field — this triggers an update via replaceData +// which has no objectMessage.operation +mock_ws.send_to_client(ProtocolMessage( + action: OBJECT_SYNC, + channel: "test", + channelSerial: "sync2:", + state: [ + build_object_state("counter:score@1000", {"aaa": "t:1"}, { + counter: { count: 200 }, + createOp: { counterCreate: { count: 200 } } + }) + ] +)) poll_until(events.length >= 1, timeout: 5s) - -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:nested@1000", 3, "100", "remote") -])) -poll_until(events.length >= 2, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length >= 2 +// Events from sync-triggered updates should have no message +FOR event IN events: + ASSERT event.message IS null OR event.message IS undefined ``` --- -## RTO24b3 - depth filtering formula +## RTPO19f - subscribe() follows path not identity -**Test ID**: `objects/unit/RTO24b3/depth-filtering-formula-0` +**Test ID**: `objects/unit/RTPO19f/subscribe-follows-path-0` -**Spec requirement:** Event dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`. +**Spec requirement:** RTPO19f: subscription is registered by path, so if the object at the path changes identity, the subscription continues to deliver events for the new object. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -// Subscribe at "profile" with depth 2: -// self (profile) → segmentDiff=0, 0+1=1 ≤ 2 ✓ -// child (profile.email) → segmentDiff=1, 1+1=2 ≤ 2 ✓ -// grandchild (profile.prefs.theme) → segmentDiff=2, 2+1=3 > 2 ✗ -root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +root.get("score").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -// Self event (profile map update) +// Replace the counter at "score" with a new counter mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) -poll_until(events.length >= 1, timeout: 5s) -// Child event (nested counter) -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:nested@1000", 3, "100", "remote") -])) -poll_until(events.length >= 2, timeout: 5s) - -// Grandchild event (prefs.theme) — should NOT be received +// Increment the NEW counter at "score" mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") + build_counter_inc("counter:new@2000", 10, "100", "remote") ])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length == 2 +// Should receive event for the new counter, since subscription follows path +found_new = false +FOR event IN events: + IF event.object.path() == "score": + found_new = true +ASSERT found_new == true ``` --- -## RTO24b5 - listener exception does not affect other listeners +## RTPO19g - subscribe() has no side effects -**Test ID**: `objects/unit/RTO24b5/listener-exception-caught-0` +**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` -**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions. +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -events = [] -root.subscribe((event) => { THROW Error("boom") }) -root.subscribe((event) => events.append(event)) +state_before = channel.state ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "name", { string: "Bob" }, "99", "remote") -])) -poll_until(events.length >= 1, timeout: 5s) +root.get("score").subscribe((event) => {}) ``` ### Assertions ```pseudo -ASSERT events.length == 1 +ASSERT channel.state == state_before ``` --- -## RTPO20 - unsubscribe() deregisters listener +## RTPO19 - subscribe() on primitive path receives change events -**Test ID**: `objects/unit/RTPO20/unsubscribe-deregisters-0` +**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` + +**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -sub = root.get("score").subscribe((event) => events.append(event)) -sub.unsubscribe() +root.get("name").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 7, "99", "remote") + build_map_set("root", "name", { string: "Bob" }, "99", "remote") ])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length == 0 +ASSERT events.length == 1 +ASSERT events[0].object.path() == "name" ``` --- -## RTPO19g - subscribe() has no side effects +## RTPO19 - MAP_CLEAR triggers subscription events on child paths -**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` +**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` -**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. +**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -state_before = channel.state +events = [] +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -root.get("score").subscribe((event) => {}) +mock_ws.send_to_client(build_object_message("test", [ + build_map_clear("root", "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT channel.state == state_before +ASSERT events.length >= 1 ``` --- -## RTPO19 - MAP_CLEAR triggers subscription events on child paths +## RTPO19 - child events bubble up to parent subscription -**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` +**Test ID**: `objects/unit/RTPO19/child-events-bubble-0` -**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. +**Spec requirement:** Events at child paths bubble up subject to depth filtering. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -root.subscribe((event) => events.append(event)) +root.get("profile").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo mock_ws.send_to_client(build_object_message("test", [ - build_map_clear("root", "99", "remote") + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length >= 1 +ASSERT events.length >= 2 ``` --- -## RTPO19 - subscribe() on primitive path receives change events +## RTO24c1 - depth filtering formula -**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` +**Test ID**: `objects/unit/RTO24c1/depth-filtering-formula-0` -**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. +| Spec | Requirement | +|------|-------------| +| RTO24c1 | subPath is a prefix of eventPath AND (depth null OR eventPath.length - subPath.length + 1 <= depth) | ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -root.get("name").subscribe((event) => events.append(event)) +// Subscribe at "profile" with depth 2: +// self (profile) -> eventPath=["profile"], subPath=["profile"], 1 - 1 + 1 = 1 <= 2 yes +// child (profile.email) -> eventPath=["profile","email"], subPath=["profile"], 2 - 1 + 1 = 2 <= 2 yes +// grandchild (profile.prefs.theme) -> eventPath=["profile","prefs","theme"], subPath=["profile"], 3 - 1 + 1 = 3 > 2 no +root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) ``` ### Test Steps ```pseudo +// Self event (profile map update) mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "name", { string: "Bob" }, "99", "remote") + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) + +// Child event (nested counter) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +// Grandchild event (prefs.theme) — should NOT be received +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) ``` ### Assertions ```pseudo -ASSERT events.length == 1 -ASSERT events[0].object.path() == "name" +ASSERT events.length == 2 ``` --- -## RTPO19d - subscribe() event provides correct PathObject +## RTO24c1 - prefix mismatch does not trigger subscription -**Test ID**: `objects/unit/RTPO19d/event-path-object-correct-0` +**Test ID**: `objects/unit/RTO24c1/prefix-mismatch-0` -**Spec requirement:** RTPO19d1: event.object is a PathObject pointing to the change location. +| Spec | Requirement | +|------|-------------| +| RTO24c1 | subPath must be a prefix of eventPath | +| RTO24c2d | ["admins"] and ["userPosts"] not covered by subscription at ["users"] | + +Tests that a subscription at one path does not receive events for a sibling path that is not a prefix match. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -events = [] -root.subscribe((event) => events.append(event)) +profile_events = [] +root.get("profile").subscribe((event) => profile_events.append(event)) ``` ### Test Steps ```pseudo +// Change at "score" — "profile" is not a prefix of "score" mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:score@1000", 7, "99", "remote") ])) -poll_until(events.length >= 1, timeout: 5s) + +// Change at "name" — "profile" is not a prefix of "name" +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "100", "remote") +])) ``` ### Assertions ```pseudo -ASSERT events[0].object IS PathObject -ASSERT events[0].object.path() == "score" -ASSERT events[0].object.value() == 107 +ASSERT profile_events.length == 0 ``` --- -## RTPO21 - subscribeIterator() yields events +## RTO24b2a - candidate path construction includes map update keys -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-yields-0` +**Test ID**: `objects/unit/RTO24b2a/candidate-paths-map-keys-0` | Spec | Requirement | |------|-------------| -| RTPO21b | Returns async iterable of PathObjectSubscriptionEvent | -| RTPO21d | Each iteration yields next event | +| RTO24b2a1 | First candidate is pathToThis itself | +| RTO24b2a2 | For LiveMapUpdate, append pathToThis extended by each update key | + +Tests that when a MAP_SET updates a key on a map, subscriptions on the child path (pathToThis + key) are notified, not just subscriptions on the map itself. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -iter = root.get("score").subscribeIterator() +score_events = [] +root_events = [] +// Subscribe at the child path "score" (pathToThis=[""] + key "score" = ["score"]) +root.get("score").subscribe((event) => score_events.append(event)) +// Subscribe at root path (pathToThis=[""]) +root.subscribe((event) => root_events.append(event)) ``` ### Test Steps ```pseudo +// 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 mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 7, "99", "remote") + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) - -event = AWAIT iter.next() +poll_until(score_events.length >= 1, timeout: 5s) +poll_until(root_events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT event.object IS PathObject -ASSERT event.object.path() == "score" +ASSERT score_events.length == 1 +ASSERT score_events[0].object.path() == "score" +ASSERT root_events.length == 1 ``` --- -## RTPO21 - subscribeIterator() with depth option +## RTO24b2c - listener exception does not affect other listeners -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-depth-0` +**Test ID**: `objects/unit/RTO24b2c/listener-exception-caught-0` -**Spec requirement:** subscribeIterator accepts same options as subscribe, including depth. +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions or other pathToThis iterations. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -iter = root.subscribeIterator({ depth: 1 }) +events = [] +root.subscribe((event) => { THROW Error("boom") }) +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -// Self event (depth 1 allows) mock_ws.send_to_client(build_object_message("test", [ build_map_set("root", "name", { string: "Bob" }, "99", "remote") ])) -event = AWAIT iter.next() - -// Child event (depth 1 rejects — counter at depth 2) -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 7, "100", "remote") -])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT event.object.path() == "" +ASSERT events.length == 1 ``` --- -## RTPO21 - subscribeIterator() break cleanup +## RTO24b1 - dispatch via getFullPaths for multi-path objects -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-break-cleanup-0` +**Test ID**: `objects/unit/RTO24b1/multi-path-dispatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b1 | Let pathsToThis be the set of paths returned by getFullPaths on the LiveObject | +| RTO24b2 | For each pathToThis, construct candidates and dispatch | -**Spec requirement:** Breaking out of the iterator loop cleans up the underlying subscription. +Tests that when a LiveObject is reachable via multiple paths, subscriptions on all those paths receive events. We create this by adding a second reference to the same counter. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -received = [] -``` - -### Test Steps -```pseudo -iter = root.get("score").subscribeIterator() +events_score = [] +events_alias = [] +// "score" already points to counter:score@1000. +// Add a second reference "alias" -> counter:score@1000 so it has two paths. mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 1, "99", "remote") + build_map_set("root", "alias", { objectId: "counter:score@1000" }, "98", "remote") ])) -event = AWAIT iter.next() -received.append(event) - -// Break the iterator (cleanup) -iter.return() +root.get("score").subscribe((event) => events_score.append(event)) +root.get("alias").subscribe((event) => events_alias.append(event)) +``` -// Further events should not be received +### Test Steps +```pseudo +// Increment counter:score@1000 — getFullPaths returns ["score"] and ["alias"] mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 1, "100", "remote") + build_counter_inc("counter:score@1000", 5, "99", "remote") ])) +poll_until(events_score.length >= 1, timeout: 5s) +poll_until(events_alias.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT received.length == 1 +ASSERT events_score.length == 1 +ASSERT events_score[0].object.path() == "score" +ASSERT events_alias.length == 1 +ASSERT events_alias[0].object.path() == "alias" ``` --- -## RTPO21 - subscribeIterator() multiple concurrent iterators +## RTO24b2b - subscription fires exactly once per dispatch -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-concurrent-0` +**Test ID**: `objects/unit/RTO24b2b/fires-once-per-dispatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b2b | Find the first eventPath in candidatePaths that the subscription covers; call the listener exactly once | -**Spec requirement:** Multiple iterators can coexist independently. +Tests that when a MAP_SET generates multiple candidate paths that a subscription covers, the listener is called exactly once with the first (most preferred) candidate. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -iter1 = root.get("score").subscribeIterator() -iter2 = root.get("score").subscribeIterator() +events = [] +// Subscribe at root (unlimited depth) — covers both [] and ["score"] +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo +// 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 = []) mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 5, "99", "remote") + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) - -event1 = AWAIT iter1.next() -event2 = AWAIT iter2.next() +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT event1.object.path() == "score" -ASSERT event2.object.path() == "score" +// Exactly one event per dispatch, even though multiple candidates match +ASSERT events.length == 1 ``` diff --git a/uts/objects/unit/public_object_message.md b/uts/objects/unit/public_object_message.md new file mode 100644 index 000000000..24373f1b1 --- /dev/null +++ b/uts/objects/unit/public_object_message.md @@ -0,0 +1,555 @@ +# PublicAPI::ObjectMessage and PublicAPI::ObjectOperation Tests + +Spec points: `PAOM1`, `PAOM2`, `PAOM3`, `PAOOP1`, `PAOOP2`, `PAOOP3` + +## Test Type +Unit test — pure data structure construction, no mocks required. + +## Purpose + +Tests the construction of `PublicAPI::ObjectMessage` from an internal `ObjectMessage`, and the construction of `PublicAPI::ObjectOperation` from an internal `ObjectOperation`. These are user-facing types exposed to subscription listeners so that user code can inspect the metadata of the message that triggered an object change. + +Tests verify that all fields are correctly copied, that `channel` comes from the channel object (not from the ObjectMessage), that the `operation` is derived via PAOOP3, and that the `mapCreate`/`counterCreate` resolution logic handles direct, derived-from-WithObjectId, and absent cases correctly. + +--- + +## PAOM3 - Construction copies all fields from source ObjectMessage + +**Test ID**: `objects/unit/PAOM3/construction-all-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOM3b | Set channel attribute to channel.name | +| PAOM3c | Copy id, clientId, connectionId, timestamp, serial, serialTimestamp, siteCode, extras from source | +| PAOM3d | Set operation to PublicAPI::ObjectOperation derived per PAOOP3 | + +Tests that constructing a PublicAPI::ObjectMessage from a source ObjectMessage with all fields populated correctly copies every attribute and derives the operation. + +### Setup +```pseudo +source = ObjectMessage( + id: "msg-id-1", + clientId: "client-1", + connectionId: "conn-1", + timestamp: 1700000000000, + serial: "01", + serialTimestamp: 1700000001000, + siteCode: "site1", + extras: { "key": "value" }, + operation: { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "name", value: { string: "Alice" } } + } +) + +channel = { name: "test-channel" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.id == "msg-id-1" +ASSERT public_msg.clientId == "client-1" +ASSERT public_msg.connectionId == "conn-1" +ASSERT public_msg.timestamp == 1700000000000 +ASSERT public_msg.channel == "test-channel" +ASSERT public_msg.serial == "01" +ASSERT public_msg.serialTimestamp == 1700000001000 +ASSERT public_msg.siteCode == "site1" +ASSERT public_msg.extras == { "key": "value" } +ASSERT public_msg.operation IS NOT null +ASSERT public_msg.operation.action == "MAP_SET" +ASSERT public_msg.operation.objectId == "map:abc@1000" +ASSERT public_msg.operation.mapSet.key == "name" +``` + +--- + +## PAOM3 - Construction with optional fields missing + +**Test ID**: `objects/unit/PAOM3/construction-optional-fields-missing-0` + +| Spec | Requirement | +|------|-------------| +| PAOM2a | id is optional | +| PAOM2b | clientId is optional | +| PAOM2c | connectionId is optional | +| PAOM2d | timestamp is optional | +| PAOM2g | serial is optional | +| PAOM2h | serialTimestamp is optional | +| PAOM2i | siteCode is optional | +| PAOM2j | extras is optional | +| PAOM3c | Copy fields from source; absent fields remain null/undefined | + +Tests that constructing a PublicAPI::ObjectMessage from a source ObjectMessage with only required fields works correctly, and optional fields are null/undefined. + +### Setup +```pseudo +source = ObjectMessage( + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: { number: 5 } + } +) + +channel = { name: "my-channel" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.id == null +ASSERT public_msg.clientId == null +ASSERT public_msg.connectionId == null +ASSERT public_msg.timestamp == null +ASSERT public_msg.channel == "my-channel" +ASSERT public_msg.serial == null +ASSERT public_msg.serialTimestamp == null +ASSERT public_msg.siteCode == null +ASSERT public_msg.extras == null +ASSERT public_msg.operation IS NOT null +ASSERT public_msg.operation.action == "COUNTER_INC" +``` + +--- + +## PAOM3b - Channel is set from channel.name, not from ObjectMessage + +**Test ID**: `objects/unit/PAOM3/channel-from-channel-name-0` + +**Spec requirement:** The `channel` attribute is set to `channel.name`, not derived from any field on the ObjectMessage itself. + +Tests that the channel field on the PublicAPI::ObjectMessage comes from the channel object's name property. + +### Setup +```pseudo +source = ObjectMessage( + operation: { + action: "OBJECT_DELETE", + objectId: "counter:abc@1000" + } +) + +channel = { name: "different-channel-name" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.channel == "different-channel-name" +``` + +--- + +## PAOOP3a - MAP_SET operation copies mapSet, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-set-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy action, objectId, mapSet, mapRemove, counterInc, objectDelete, mapClear directly | +| PAOOP2d | mapSet is the mapSet of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_SET source copies action, objectId, and mapSet, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "color", value: { string: "blue" } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_SET" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapSet.key == "color" +ASSERT public_op.mapSet.value.string == "blue" +ASSERT public_op.mapCreate == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - MAP_REMOVE operation copies mapRemove, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-remove-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy mapRemove directly from source | +| PAOOP2e | mapRemove is the mapRemove of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_REMOVE source copies action, objectId, and mapRemove, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_REMOVE", + objectId: "map:abc@1000", + mapRemove: { key: "old-key" } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_REMOVE" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapRemove.key == "old-key" +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - COUNTER_INC operation copies counterInc, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/counter-inc-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy counterInc directly from source | +| PAOOP2g | counterInc is the counterInc of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a COUNTER_INC source copies action, objectId, and counterInc, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: { number: 42 } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_INC" +ASSERT public_op.objectId == "counter:abc@1000" +ASSERT public_op.counterInc.number == 42 +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - OBJECT_DELETE operation copies objectDelete, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/object-delete-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy objectDelete directly from source | +| PAOOP2h | objectDelete is the objectDelete of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from an OBJECT_DELETE source copies action, objectId, and objectDelete, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "OBJECT_DELETE", + objectId: "counter:abc@1000", + objectDelete: {} +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "OBJECT_DELETE" +ASSERT public_op.objectId == "counter:abc@1000" +ASSERT public_op.objectDelete IS NOT null +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - MAP_CLEAR operation copies mapClear, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-clear-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy mapClear directly from source | +| PAOOP2i | mapClear is the mapClear of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_CLEAR source copies action, objectId, and mapClear, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_CLEAR", + objectId: "map:abc@1000", + mapClear: {} +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CLEAR" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapClear IS NOT null +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +``` + +--- + +## PAOOP3b1 - MAP_CREATE with mapCreate directly present + +**Test ID**: `objects/unit/PAOOP3/map-create-direct-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b1 | If mapCreate is present on the source, set mapCreate to that value | + +Tests that when the source ObjectOperation has a `mapCreate` field, the PublicAPI::ObjectOperation uses it directly. + +### Setup +```pseudo +source_operation = { + action: "MAP_CREATE", + objectId: "map:new@2000", + mapCreate: { semantics: "LWW", entries: { "key1": { data: { string: "val1" } } } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CREATE" +ASSERT public_op.objectId == "map:new@2000" +ASSERT public_op.mapCreate IS NOT null +ASSERT public_op.mapCreate.semantics == "LWW" +ASSERT public_op.mapCreate.entries["key1"].data.string == "val1" +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId + +**Test ID**: `objects/unit/PAOOP3/map-create-from-with-object-id-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b2 | If mapCreateWithObjectId is present on the source, set mapCreate to the MapCreate from which it was derived | + +Tests that when the source ObjectOperation has `mapCreateWithObjectId` but not `mapCreate`, the PublicAPI::ObjectOperation resolves `mapCreate` to the derived MapCreate. + +### Setup +```pseudo +derived_map_create = { semantics: "LWW", entries: { "x": { data: { number: 10 } } } } + +source_operation = { + action: "MAP_CREATE", + objectId: "map:derived@3000", + mapCreateWithObjectId: { + objectId: "map:derived@3000", + semantics: "LWW", + entries: { "x": { data: { number: 10 } } }, + _derivedFrom: derived_map_create + } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CREATE" +ASSERT public_op.objectId == "map:derived@3000" +ASSERT public_op.mapCreate IS NOT null +ASSERT public_op.mapCreate.semantics == "LWW" +ASSERT public_op.mapCreate.entries["x"].data.number == 10 +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId + +**Test ID**: `objects/unit/PAOOP3/counter-create-from-with-object-id-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3c2 | If counterCreateWithObjectId is present on the source, set counterCreate to the CounterCreate from which it was derived | + +Tests that when the source ObjectOperation has `counterCreateWithObjectId` but not `counterCreate`, the PublicAPI::ObjectOperation resolves `counterCreate` to the derived CounterCreate. + +### Setup +```pseudo +derived_counter_create = { count: 100 } + +source_operation = { + action: "COUNTER_CREATE", + objectId: "counter:derived@3000", + counterCreateWithObjectId: { + objectId: "counter:derived@3000", + count: 100, + _derivedFrom: derived_counter_create + } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_CREATE" +ASSERT public_op.objectId == "counter:derived@3000" +ASSERT public_op.counterCreate IS NOT null +ASSERT public_op.counterCreate.count == 100 +ASSERT public_op.mapCreate == null +``` + +--- + +## PAOOP3b3, PAOOP3c3 - Create payloads omitted when neither variant is present + +**Test ID**: `objects/unit/PAOOP3/create-payloads-omitted-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b3 | If neither mapCreate nor mapCreateWithObjectId is present, omit mapCreate | +| PAOOP3c3 | If neither counterCreate nor counterCreateWithObjectId is present, omit counterCreate | + +Tests that when the source ObjectOperation has no create payloads (neither direct nor WithObjectId variants), both `mapCreate` and `counterCreate` are omitted on the resulting PublicAPI::ObjectOperation. + +### Setup +```pseudo +source_operation = { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "k", value: { string: "v" } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.mapCreate == null +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3 - Only the relevant operation field is present per action type + +**Test ID**: `objects/unit/PAOOP3/only-relevant-field-per-action-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy only the fields that exist on the source; unrelated fields are omitted | +| PAOOP2c | mapCreate is optional | +| PAOOP2d | mapSet is optional | +| PAOOP2e | mapRemove is optional | +| PAOOP2f | counterCreate is optional | +| PAOOP2g | counterInc is optional | +| PAOOP2h | objectDelete is optional | +| PAOOP2i | mapClear is optional | + +Tests that for a COUNTER_CREATE operation with `counterCreate` directly present, only `counterCreate` is set and all other operation-specific fields are null. + +### Setup +```pseudo +source_operation = { + action: "COUNTER_CREATE", + objectId: "counter:new@2000", + counterCreate: { count: 50 } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_CREATE" +ASSERT public_op.objectId == "counter:new@2000" +ASSERT public_op.counterCreate IS NOT null +ASSERT public_op.counterCreate.count == 50 +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index fd833be65..4a0fc116e 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1,6 +1,6 @@ # RealtimeObject Tests -Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO24` +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26` ## Test Type Unit test with mocked WebSocket client @@ -21,7 +21,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_ch | Spec | Requirement | |------|-------------| -| RTO23d | Returns PathObject wrapping root LiveMap with empty path | +| RTO23d | Returns PathObject with path set to empty list and root set to root LiveMap | ### Setup ```pseudo @@ -31,7 +31,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_ch ### Assertions ```pseudo ASSERT root IS PathObject -ASSERT root.path() == "" +ASSERT root.path == [] ``` --- @@ -75,11 +75,15 @@ ASSERT error.code == 40024 --- -## RTO23b - get() throws on DETACHED or FAILED channel +## RTO23b - get() throws on DETACHED channel **Test ID**: `objects/unit/RTO23b/get-throws-detached-0` -**Spec requirement:** If channel is DETACHED or FAILED, throw 90001. +| Spec | Requirement | +|------|-------------| +| RTO23b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that get() on a DETACHED channel throws 90001 per the RTO25 access API preconditions. ### Setup ```pseudo @@ -88,7 +92,19 @@ mock_ws = MockWebSocket( ProtocolMessage(action: CONNECTED, connectionDetails: { connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" }) - ) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } ) install_mock(mock_ws) client = Realtime(options: { key: "fake:key" }) @@ -97,12 +113,18 @@ channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) ### Test Steps ```pseudo +// Attach and sync first, then detach +AWAIT channel.object.get() +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED + AWAIT channel.object.get() FAILS WITH error ``` ### Assertions ```pseudo ASSERT error.code == 90001 +ASSERT error.statusCode == 400 ``` --- @@ -153,6 +175,7 @@ root = AWAIT get_future ### Assertions ```pseudo ASSERT root IS PathObject +ASSERT root.path == [] ``` --- @@ -563,6 +586,412 @@ ASSERT error.code == 40024 --- +## RTO25a - Access API precondition requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO25a/access-requires-subscribe-mode-0` + +| Spec | Requirement | +|------|-------------| +| RTO25a | Require OBJECT_SUBSCRIBE channel mode per RTO2 | + +Tests that a read operation (e.g. PathObject value()) without OBJECT_SUBSCRIBE mode throws error 40024. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_PUBLISH"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO25b - Access API precondition throws on DETACHED channel + +**Test ID**: `objects/unit/RTO25b/access-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that calling get() on a DETACHED channel throws 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Attach, sync, then detach to get channel into DETACHED state +AWAIT channel.object.get() +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO25b - Access API precondition throws on FAILED channel + +**Test ID**: `objects/unit/RTO25b/access-throws-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that calling get() on a FAILED channel throws 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: msg.channel, + error: { code: 90000, statusCode: 400, message: "Channel error" } + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Trigger attach which will fail, putting channel into FAILED state +channel.attach() +AWAIT_STATE channel.state == FAILED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26a - Write API precondition requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTO26a/write-requires-publish-mode-0` + +| Spec | Requirement | +|------|-------------| +| RTO26a | Require OBJECT_PUBLISH channel mode per RTO2 | + +Tests that a write operation without OBJECT_PUBLISH mode throws error 40024. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26b - Write API precondition throws on DETACHED channel + +**Test ID**: `objects/unit/RTO26b/write-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO26b | If channel is DETACHED, FAILED, or SUSPENDED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that a write operation on a DETACHED channel throws 90001. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +// Detach the channel after sync +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) +AWAIT_STATE channel.state == DETACHED + +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26b - Write API precondition throws on FAILED channel + +**Test ID**: `objects/unit/RTO26b/write-throws-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO26b | If channel is DETACHED, FAILED, or SUSPENDED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that a write operation on a FAILED channel throws 90001. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +// Force channel to FAILED state +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel error" } +)) +AWAIT_STATE channel.state == FAILED + +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26c - Write API precondition throws when echoMessages is false + +**Test ID**: `objects/unit/RTO26c/write-throws-echo-disabled-0` + +| Spec | Requirement | +|------|-------------| +| RTO26c | If echoMessages is false, throw ErrorInfo with statusCode 400 and code 40000 | + +Tests that a write operation with echoMessages disabled throws 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister + +**Test ID**: `objects/unit/RTO24a/single-register-instance-0` + +**Spec requirement:** The RealtimeObject instance maintains a single PathObjectSubscriptionRegister that manages all path-based subscriptions for the channel. + +Tests that subscriptions registered via different PathObjects on the same channel share a single register, so updates are dispatched to all matching subscriptions regardless of which PathObject was used to subscribe. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +events_root = [] +events_score = [] + +// Subscribe via root PathObject at path [] +root.subscribe((event) => events_root.append(event)) + +// Subscribe via a deeper PathObject at path ["score"] +score_path = root.get("score") +score_path.subscribe((event) => events_score.append(event)) +``` + +### Test Steps +```pseudo +// Trigger an update on the score counter +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "s:1", "aaa") +])) + +poll_until(events_score.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Both subscriptions are managed by the same register and both fire +ASSERT events_root.length >= 1 +ASSERT events_score.length >= 1 +``` + +--- + +## RTO24c1 - Subscription coverage: prefix match with depth constraint + +**Test ID**: `objects/unit/RTO24c1/coverage-prefix-depth-0` + +| Spec | Requirement | +|------|-------------| +| RTO24c1 | Subscription covers eventPath if subPath is prefix and depth constraint satisfied | + +Tests that a subscription with a depth constraint only receives events within the specified depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +shallow_events = [] +deep_events = [] + +// Subscribe at root with depth 1 — covers root and immediate children only +root.subscribe({ depth: 1 }, (event) => shallow_events.append(event)) + +// Subscribe at root with no depth limit — covers everything +root.subscribe((event) => deep_events.append(event)) +``` + +### Test Steps +```pseudo +// Update a direct child of root (path ["score"]) — depth 1 from root +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "s:1", "aaa") +])) +poll_until(deep_events.length >= 1, timeout: 5s) + +// Update a nested object (path ["profile", "nested_counter"]) — depth 2 from root +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 1, "s:2", "aaa") +])) +poll_until(deep_events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +// Shallow subscription (depth 1) only sees the direct child update +ASSERT shallow_events.length == 1 + +// Deep subscription (no depth limit) sees both updates +ASSERT deep_events.length >= 2 +``` + +--- + ## RTO10 - GC removes tombstoned objects past grace period **Test ID**: `objects/unit/RTO10/gc-tombstoned-objects-0` @@ -786,6 +1215,7 @@ root = AWAIT channel.object.get() ### Assertions ```pseudo ASSERT root IS PathObject +ASSERT root.path == [] ASSERT channel.state == ATTACHED ``` @@ -810,7 +1240,7 @@ root2 = AWAIT channel.object.get() ### Assertions ```pseudo ASSERT root2 IS PathObject -ASSERT root2.path() == "" +ASSERT root2.path == [] ``` --- diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md index dc99aec26..300eb6aae 100644 --- a/uts/objects/unit/value_types.md +++ b/uts/objects/unit/value_types.md @@ -3,11 +3,11 @@ Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` ## Test Type -Unit test — pure construction and consumption, no mocks required. +Unit test — pure construction and evaluation, no mocks required. ## Purpose -Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When consumed by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When evaluated by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). --- @@ -56,7 +56,7 @@ ASSERT vt.count == 0 **Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` -**Spec requirement:** No input validation is performed at creation time; deferred to consumption. +**Spec requirement:** No input validation is performed at creation time. Validation is deferred to the evaluation procedure (RTLCV4). ### Test Steps ```pseudo @@ -70,9 +70,9 @@ ASSERT vt IS LiveCounterValueType --- -## RTLCV4 - Consumption generates COUNTER_CREATE ObjectMessage +## RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage -**Test ID**: `objects/unit/RTLCV4/consume-generates-message-0` +**Test ID**: `objects/unit/RTLCV4/evaluate-generates-message-0` | Spec | Requirement | |------|-------------| @@ -88,7 +88,7 @@ ASSERT vt IS LiveCounterValueType ### Test Steps ```pseudo vt = LiveCounter.create(42) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -106,16 +106,16 @@ ASSERT msg.operation.counterCreateWithObjectId.initialValue IS NOT null --- -## RTLCV4g5 - Consumption retains local CounterCreate +## RTLCV4g5 - Evaluation retains local CounterCreate **Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` -**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use. +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use (RTLCV4g5). Needed for message size calculation and local application. ### Test Steps ```pseudo vt = LiveCounter.create(42) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -127,16 +127,16 @@ ASSERT msg.operation.counterCreate.count == 42 --- -## RTLCV4a - Consumption validates count type +## RTLCV4a - Evaluation validates count type -**Test ID**: `objects/unit/RTLCV4a/consume-validates-count-0` +**Test ID**: `objects/unit/RTLCV4a/evaluate-validates-count-0` -**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003. +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003 (RTLCV4a). Validation happens during evaluation, not at creation time. ### Test Steps ```pseudo vt = LiveCounter.create("not_a_number") -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -146,16 +146,16 @@ ASSERT error.code == 40003 --- -## RTLCV4 - Consumption with count 0 +## RTLCV4 - Evaluation with count 0 -**Test ID**: `objects/unit/RTLCV4/consume-zero-count-0` +**Test ID**: `objects/unit/RTLCV4/evaluate-zero-count-0` **Spec requirement:** count=0 is valid and should be included in CounterCreate. ### Test Steps ```pseudo vt = LiveCounter.create(0) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -211,9 +211,9 @@ ASSERT vt IS LiveMapValueType --- -## RTLMV4 - Consumption generates MAP_CREATE ObjectMessage +## RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage -**Test ID**: `objects/unit/RTLMV4/consume-generates-message-0` +**Test ID**: `objects/unit/RTLMV4/evaluate-generates-message-0` | Spec | Requirement | |------|-------------| @@ -228,7 +228,7 @@ ASSERT vt IS LiveMapValueType ### Test Steps ```pseudo vt = LiveMap.create({ "name": "Alice" }) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -244,16 +244,16 @@ ASSERT msg.operation.mapCreateWithObjectId.initialValue IS NOT null --- -## RTLMV4j5 - Consumption retains local MapCreate +## RTLMV4j5 - Evaluation retains local MapCreate **Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` -**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use. +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use (RTLMV4j5). Needed for message size calculation and local application. ### Test Steps ```pseudo vt = LiveMap.create({ "name": "Alice" }) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -287,7 +287,7 @@ vt = LiveMap.create({ "json_arr": [1, 2, 3], "json_obj": { "key": "value" } }) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -309,8 +309,8 @@ ASSERT entries["json_obj"].data.json == { "key": "value" } | Spec | Requirement | |------|-------------| -| RTLMV4d1 | LiveCounterValueType consumed, ObjectMessage collected, objectId set | -| RTLMV4d2 | LiveMapValueType recursively consumed, all ObjectMessages collected | +| RTLMV4d1 | LiveCounterValueType evaluated, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively evaluated, all ObjectMessages collected | | RTLMV4k | Return depth-first order: inner creates before outer | ### Test Steps @@ -322,7 +322,7 @@ inner_map = LiveMap.create({ outer = LiveMap.create({ "child": inner_map }) -messages = consume(outer) +messages = evaluate(outer) ``` ### Assertions @@ -345,16 +345,16 @@ ASSERT messages[2].operation.mapCreate.entries["child"].data.objectId == inner_m --- -## RTLMV4a - Consumption validates entries type +## RTLMV4a - Evaluation validates entries type -**Test ID**: `objects/unit/RTLMV4a/consume-validates-entries-0` +**Test ID**: `objects/unit/RTLMV4a/evaluate-validates-entries-0` -**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003. +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003 (RTLMV4a). Validation happens during evaluation, not at creation time. ### Test Steps ```pseudo vt = LiveMap.create(null) -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -364,16 +364,16 @@ ASSERT error.code == 40003 --- -## RTLMV4b - Consumption validates key types +## RTLMV4b - Evaluation validates key types -**Test ID**: `objects/unit/RTLMV4b/consume-validates-keys-0` +**Test ID**: `objects/unit/RTLMV4b/evaluate-validates-keys-0` -**Spec requirement:** If any key is not String, throw 40003. +**Spec requirement:** If any key is not String, throw 40003 (RTLMV4b). ### Test Steps ```pseudo vt = LiveMap.create({ 123: "value" }) -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -383,16 +383,16 @@ ASSERT error.code == 40003 --- -## RTLMV4c - Consumption validates value types +## RTLMV4c - Evaluation validates value types -**Test ID**: `objects/unit/RTLMV4c/consume-validates-values-0` +**Test ID**: `objects/unit/RTLMV4c/evaluate-validates-values-0` -**Spec requirement:** If any value is not an expected type, throw 40013. +**Spec requirement:** If any value is not an expected type, throw 40013 (RTLMV4c). ### Test Steps ```pseudo vt = LiveMap.create({ "fn": some_function }) -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -411,7 +411,7 @@ ASSERT error.code == 40013 ### Test Steps ```pseudo vt = LiveMap.create() -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -445,7 +445,7 @@ type_scenarios = [ FOR scenario IN type_scenarios: vt = LiveMap.create({ "test_key": scenario.input }) - messages = consume(vt) + messages = evaluate(vt) entry = messages[0].operation.mapCreate.entries["test_key"] ASSERT entry.data[scenario.expected_field] == scenario.expected_value ``` From 86e96366efe9e0ca5d02989175220bbe6017f21b Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:35:14 +0100 Subject: [PATCH 04/16] UTS: add missing assertions --- uts/objects/integration/proxy/objects_faults.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md index 8988a0191..b4e08fab8 100644 --- a/uts/objects/integration/proxy/objects_faults.md +++ b/uts/objects/integration/proxy/objects_faults.md @@ -365,6 +365,8 @@ AWAIT root.set("key", "value") FAILS WITH error ```pseudo ASSERT error.code == 92008 +ASSERT error.cause IS NOT null +ASSERT error.cause.code == 90000 ``` --- From e57d340e275f438cb0add380f167ee63f035e0a4 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:35:45 +0100 Subject: [PATCH 05/16] UTS: correct sandbox endpoint domain name --- uts/objects/integration/objects_batch_test.md | 6 +++--- uts/objects/integration/objects_gc_test.md | 6 +++--- uts/objects/integration/objects_lifecycle_test.md | 6 +++--- uts/objects/integration/objects_sync_test.md | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/uts/objects/integration/objects_batch_test.md b/uts/objects/integration/objects_batch_test.md index a5805482a..b3fd4d849 100644 --- a/uts/objects/integration/objects_batch_test.md +++ b/uts/objects/integration/objects_batch_test.md @@ -14,13 +14,13 @@ correctly to other clients. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -28,7 +28,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` diff --git a/uts/objects/integration/objects_gc_test.md b/uts/objects/integration/objects_gc_test.md index 2d9bc86a2..57ca232d9 100644 --- a/uts/objects/integration/objects_gc_test.md +++ b/uts/objects/integration/objects_gc_test.md @@ -13,13 +13,13 @@ through observable API consequences rather than internal pool state inspection. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -27,7 +27,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md index 9c440f512..d4e3ae23a 100644 --- a/uts/objects/integration/objects_lifecycle_test.md +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -13,13 +13,13 @@ server sync, mutation delivery, and object creation. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -27,7 +27,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md index 7f0721ec2..666d0ce0c 100644 --- a/uts/objects/integration/objects_sync_test.md +++ b/uts/objects/integration/objects_sync_test.md @@ -13,13 +13,13 @@ the client detaches and re-attaches to verify the pool is re-synced. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -27,7 +27,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` From add9398a6dde389bafcd88680d18b8f450fbbc0f Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:36:24 +0100 Subject: [PATCH 06/16] UTS: remove spurious Map.clear() test --- uts/objects/unit/live_map_api.md | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md index 44dcb795b..3babfcb53 100644 --- a/uts/objects/unit/live_map_api.md +++ b/uts/objects/unit/live_map_api.md @@ -425,33 +425,6 @@ ASSERT root.get("name").value() == "Bob" --- -## RTLM24 - clear() sends MAP_CLEAR message - -**Test ID**: `objects/unit/RTLM24/clear-sends-map-clear-0` - -**Spec requirement:** Constructs MAP_CLEAR ObjectMessage. - -### Setup -```pseudo -captured_messages = [] -// (same mock setup capturing OBJECT messages) -``` - -### Test Steps -```pseudo -instance = root.instance() -AWAIT instance.clear() -``` - -### Assertions -```pseudo -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_CLEAR" -ASSERT obj_msg.operation.objectId == "root" -``` - ---- - ## RTLM20 - Table-driven invalid set value types **Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` From 3ad5a416b562a0af03bcd5d2cc612bff62461acf Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:36:55 +0100 Subject: [PATCH 07/16] UTS: remove unnecessary test --- uts/objects/unit/value_types.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md index 300eb6aae..3a4033a8c 100644 --- a/uts/objects/unit/value_types.md +++ b/uts/objects/unit/value_types.md @@ -52,24 +52,6 @@ ASSERT vt.count == 0 --- -## RTLCV3c - No validation at creation time - -**Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` - -**Spec requirement:** No input validation is performed at creation time. Validation is deferred to the evaluation procedure (RTLCV4). - -### Test Steps -```pseudo -vt = LiveCounter.create("not_a_number") -``` - -### Assertions -```pseudo -ASSERT vt IS LiveCounterValueType -``` - ---- - ## RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage **Test ID**: `objects/unit/RTLCV4/evaluate-generates-message-0` From e77bdb8050095356faf498b9d4d01834520d0fef Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:50:13 +0100 Subject: [PATCH 08/16] =?UTF-8?q?UTS:=20delete=20integration=20GC=20test?= =?UTF-8?q?=20=E2=80=94=20duplicates=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration GC tests (RTO10, RTLM19) use enable_fake_timers() + ADVANCE_TIME(), which only affect the SDK's local clock, not the sandbox server. The same scenarios are already covered by unit tests at realtime_object.md (RTO10) and live_map.md (RTLM19) where fake timers work correctly against the mock WebSocket. Co-Authored-By: Claude Opus 4.6 --- uts/objects/integration/objects_gc_test.md | 138 --------------------- 1 file changed, 138 deletions(-) delete mode 100644 uts/objects/integration/objects_gc_test.md diff --git a/uts/objects/integration/objects_gc_test.md b/uts/objects/integration/objects_gc_test.md deleted file mode 100644 index 57ca232d9..000000000 --- a/uts/objects/integration/objects_gc_test.md +++ /dev/null @@ -1,138 +0,0 @@ -# Objects GC Integration Tests - -Spec points: `RTO10`, `RTLM19` - -## Test Type -Integration test against Ably sandbox - -## Purpose - -Behavioral verification of garbage collection for tombstoned objects and tombstoned -map entries. Uses `ADVANCE_TIME` (fake timers) to control timing and verifies GC -through observable API consequences rather than internal pool state inspection. - -## Sandbox Setup - -Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. - -### App Provisioning - -```pseudo -BEFORE ALL TESTS: - response = POST https://sandbox.realtime.ably-nonprod.net/apps - WITH body from ably-common/test-resources/test-app-setup.json - - app_config = parse_json(response.body) - api_key = app_config.keys[0].key_str - app_id = app_config.app_id - -AFTER ALL TESTS: - DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} - WITH Authorization: Basic {api_key} -``` - -### Notes -- These tests use fake timers to control GC timing -- Each test uses a unique channel name - ---- - -## RTO10 - Tombstoned object is GC'd and recreatable - -**Test ID**: `objects/integration/RTO10/tombstoned-object-gc-recreate-0` - -**Spec requirement:** After an object is tombstoned and the GC grace period elapses, -the object is removed from the pool. A new object can then be created at the same -map key. - -### Setup -```pseudo -enable_fake_timers() -channel_name = "objects-gc-object-" + random_id() - -client = Realtime(options: { key: api_key }) -client.connect() -AWAIT_STATE client.connection.state == CONNECTED - -channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -// Create a counter -AWAIT root.set("counter", LiveCounter.create(42)) -ASSERT root.get("counter").value() == 42 -counter_id = root.get("counter").instance().id() - -// Remove it (tombstones the entry and the object) -AWAIT root.remove("counter") -ASSERT root.get("counter").value() == null - -// Advance past GC grace period -ADVANCE_TIME(86400000 + 300000) - -// Create a new counter at the same key -AWAIT root.set("counter", LiveCounter.create(99)) -``` - -### Assertions -```pseudo -ASSERT root.get("counter").value() == 99 -new_counter_id = root.get("counter").instance().id() -ASSERT new_counter_id != counter_id -``` - -### Teardown -```pseudo -client.close() -``` - ---- - -## RTLM19 - Tombstoned map entry is GC'd, re-settable with old serial - -**Test ID**: `objects/integration/RTLM19/tombstoned-entry-gc-reset-0` - -**Spec requirement:** After a map entry is tombstoned and GC'd, the entry is fully -removed. A subsequent MAP_SET with any serial succeeds because there is no existing -entry to compare against. - -### Setup -```pseudo -enable_fake_timers() -channel_name = "objects-gc-entry-" + random_id() - -client = Realtime(options: { key: api_key }) -client.connect() -AWAIT_STATE client.connection.state == CONNECTED - -channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -// Set then remove a key -AWAIT root.set("ephemeral", "temporary") -ASSERT root.get("ephemeral").value() == "temporary" - -AWAIT root.remove("ephemeral") -ASSERT root.get("ephemeral").value() == null - -// Advance past GC grace period for entries -ADVANCE_TIME(86400000 + 300000) - -// Set the same key again -AWAIT root.set("ephemeral", "revived") -``` - -### Assertions -```pseudo -ASSERT root.get("ephemeral").value() == "revived" -``` - -### Teardown -```pseudo -client.close() -``` From eaa69837f4a12054b0876ea807a328de6f3287f1 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:55:58 +0100 Subject: [PATCH 09/16] UTS: add Protocol Variants to LiveObjects integration tests Data-path integration tests must run with both JSON and msgpack per G1. Add ## Protocol Variants section and useBinaryProtocol to ClientOptions in objects_lifecycle_test.md and objects_sync_test.md. Update the annotated specs list in integration-testing.md. Co-Authored-By: Claude Opus 4.6 --- uts/docs/integration-testing.md | 4 +++ .../integration/objects_lifecycle_test.md | 27 ++++++++++++------- uts/objects/integration/objects_sync_test.md | 17 ++++++++---- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md index cca26a715..74464f5cc 100644 --- a/uts/docs/integration-testing.md +++ b/uts/docs/integration-testing.md @@ -295,6 +295,10 @@ The following integration test specs are annotated with `## Protocol Variants`: - `realtime/integration/mutable_messages_test.md` - `realtime/integration/delta_decoding_test.md` +**LiveObjects:** +- `objects/integration/objects_lifecycle_test.md` +- `objects/integration/objects_sync_test.md` + ## Writing Proxy Tests The proxy mediates between the SDK and the real Ably server. It is not a mock server. Tests should be written to rely on actual server responses as much as possible, with the proxy intervening only where necessary to create the specific fault or error condition under test. diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md index d4e3ae23a..a72ae3b68 100644 --- a/uts/objects/integration/objects_lifecycle_test.md +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -5,6 +5,13 @@ Spec points: `RTO23`, `RTPO15`, `RTPO17` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose End-to-end lifecycle: connect, sync, create objects via PathObject, mutate, and @@ -47,8 +54,8 @@ propagates via the server and a second client sees the updated value. ```pseudo channel_name = "objects-lifecycle-" + random_id() -client_a = Realtime(options: { key: api_key }) -client_b = Realtime(options: { key: api_key }) +client_a = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client_a.connect() AWAIT_STATE client_a.connection.state == CONNECTED @@ -98,8 +105,8 @@ on the server. Second client syncs and reads the counter value. ```pseudo channel_name = "objects-counter-create-" + random_id() -client_a = Realtime(options: { key: api_key }) -client_b = Realtime(options: { key: api_key }) +client_a = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client_a.connect() AWAIT_STATE client_a.connection.state == CONNECTED @@ -145,8 +152,8 @@ The server applies the increment and propagates the updated value. ```pseudo channel_name = "objects-increment-" + random_id() -client_a = Realtime(options: { key: api_key }) -client_b = Realtime(options: { key: api_key }) +client_a = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client_a.connect() AWAIT_STATE client_a.connection.state == CONNECTED @@ -197,8 +204,8 @@ Second client can navigate into the nested map. ```pseudo channel_name = "objects-map-create-" + random_id() -client_a = Realtime(options: { key: api_key }) -client_b = Realtime(options: { key: api_key }) +client_a = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client_a.connect() AWAIT_STATE client_a.connection.state == CONNECTED @@ -247,7 +254,7 @@ after the sync sequence completes. ```pseudo channel_name = "objects-get-root-" + random_id() -client = Realtime(options: { key: api_key }) +client = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client.connect() AWAIT_STATE client.connection.state == CONNECTED @@ -298,7 +305,7 @@ provision_objects_via_rest(api_key, channel_name, [ ### Test Steps ```pseudo -client = Realtime(options: { key: api_key }) +client = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client.connect() AWAIT_STATE client.connection.state == CONNECTED diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md index 666d0ce0c..16af7ecee 100644 --- a/uts/objects/integration/objects_sync_test.md +++ b/uts/objects/integration/objects_sync_test.md @@ -5,6 +5,13 @@ Spec points: `RTO4`, `RTO5`, `RTO17` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose Verify the sync sequence against the real server: attach with HAS_OBJECTS, @@ -47,7 +54,7 @@ processes OBJECT_SYNC messages, then transitions to SYNCED. get() waits for SYNC ```pseudo channel_name = "objects-sync-" + random_id() -client = Realtime(options: { key: api_key }) +client = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client.connect() AWAIT_STATE client.connection.state == CONNECTED @@ -82,8 +89,8 @@ client.close() ```pseudo channel_name = "objects-two-sync-" + random_id() -client_a = Realtime(options: { key: api_key }) -client_b = Realtime(options: { key: api_key }) +client_a = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client_a.connect() AWAIT_STATE client_a.connection.state == CONNECTED @@ -130,7 +137,7 @@ is re-populated from the server. ```pseudo channel_name = "objects-reattach-" + random_id() -client = Realtime(options: { key: api_key }) +client = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client.connect() AWAIT_STATE client.connection.state == CONNECTED @@ -176,7 +183,7 @@ sends HAS_OBJECTS, sync completes, root is an empty LiveMap. ```pseudo channel_name = "objects-subscribe-only-" + random_id() -client = Realtime(options: { key: api_key }) +client = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) client.connect() AWAIT_STATE client.connection.state == CONNECTED From 99421b7b6a003f434806ccc902211dee2696d699 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 21:59:52 +0100 Subject: [PATCH 10/16] =?UTF-8?q?UTS:=20fix=20root.increment()=20=E2=86=92?= =?UTF-8?q?=20root.get("score").increment()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit root is the PathObject at / (a LiveMap). Calling increment/decrement on it would throw 92007 (RTPO17e). The tests intend to operate on the counter at root["score"]. Fixed in realtime_object.md (10 sites) and live_counter_api.md (5 sites). path_object_mutations.md is unchanged — those are intentional negative-path tests for RTPO17e. Co-Authored-By: Claude Opus 4.6 --- uts/objects/unit/live_counter_api.md | 10 +++++----- uts/objects/unit/realtime_object.md | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md index f6bca2a1a..f55bd589c 100644 --- a/uts/objects/unit/live_counter_api.md +++ b/uts/objects/unit/live_counter_api.md @@ -79,7 +79,7 @@ root = AWAIT channel.object.get() ### Test Steps ```pseudo -AWAIT root.increment(25) +AWAIT root.get("score").increment(25) ``` ### Assertions @@ -106,7 +106,7 @@ ASSERT obj_msg.operation.counterInc.number == 25 ### Test Steps ```pseudo -AWAIT root.increment(50) +AWAIT root.get("score").increment(50) ``` ### Assertions @@ -137,7 +137,7 @@ Note: RTLC12b, RTLC12c, and RTLC12d have been replaced by RTO26. The write API p ### Test Steps ```pseudo -AWAIT root.increment("not_a_number") FAILS WITH error +AWAIT root.get("score").increment("not_a_number") FAILS WITH error ``` ### Assertions @@ -185,7 +185,7 @@ root = AWAIT channel.object.get() ### Test Steps ```pseudo -AWAIT root.decrement(15) +AWAIT root.get("score").decrement(15) ``` ### Assertions @@ -254,6 +254,6 @@ invalid_amounts = [ ### Test Steps ```pseudo FOR scenario IN invalid_amounts: - AWAIT root.increment(scenario.value) FAILS WITH error + AWAIT root.get("score").increment(scenario.value) FAILS WITH error ASSERT error.code == 40003 ``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 4a0fc116e..842abec78 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -255,7 +255,7 @@ ASSERT result.serials == ["serial-0"] ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) ``` ### Assertions @@ -301,7 +301,7 @@ root = AWAIT channel.object.get() ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) ``` ### Assertions @@ -345,7 +345,7 @@ root = AWAIT channel.object.get() ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) ``` ### Assertions @@ -373,7 +373,7 @@ mock_ws.send_to_client(ProtocolMessage( flags: HAS_OBJECTS )) -inc_future = root.increment(10) +inc_future = root.get("score").increment(10) mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) @@ -405,7 +405,7 @@ mock_ws.send_to_client(ProtocolMessage( flags: HAS_OBJECTS )) -inc_future = root.increment(10) +inc_future = root.get("score").increment(10) mock_ws.send_to_client(ProtocolMessage( action: DETACHED, channel: "test", @@ -1037,7 +1037,7 @@ ASSERT root.get("score").value() == null ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) score_after_apply = root.get("score").value() mock_ws.send_to_client(build_object_message("test", [ @@ -1071,7 +1071,7 @@ site_serials_before = root.get("score").instance()._liveObject.siteTimeserials ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) site_serials_after = root.get("score").instance()._liveObject.siteTimeserials ``` @@ -1095,7 +1095,7 @@ ASSERT site_serials_after == site_serials_before ### Test Steps ```pseudo -inc_future = root.increment(10) +inc_future = root.get("score").increment(10) // Send the echo BEFORE the ACK mock_ws.send_to_client(build_object_message("test", [ @@ -1128,7 +1128,7 @@ ASSERT root.get("score").value() == 110 ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) ASSERT root.get("score").value() == 110 // Trigger re-sync @@ -1163,7 +1163,7 @@ root.get("score").subscribe((event) => events.append(event)) ### Test Steps ```pseudo -AWAIT root.increment(10) +AWAIT root.get("score").increment(10) ``` ### Assertions From 8f524477e0b3add0cc74dc90f699199ba7ccb728 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 22:18:47 +0100 Subject: [PATCH 11/16] UTS: fix InstanceSubscriptionEvent assertions in live_object_subscribe Tests use instance.subscribe() (RTINS16) which emits InstanceSubscriptionEvent { object, message }, not LiveObjectUpdate { tombstone, objectMessage }. Fix all assertions to use .message instead of .objectMessage, and check .message.operation.action instead of .tombstone. Also fix noop test serial from "01" to "02" so the newness check (RTLO4a6) doesn't suppress it before the noop path fires. Co-Authored-By: Claude Opus 4.6 --- uts/objects/unit/live_object_subscribe.md | 51 +++++++++++------------ 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md index 7911e852e..db1989b57 100644 --- a/uts/objects/unit/live_object_subscribe.md +++ b/uts/objects/unit/live_object_subscribe.md @@ -165,11 +165,9 @@ mock_ws.send_to_client(build_object_message("test", [ ])) poll_until(updates.length >= 1, timeout: 5s) +# Serial "02" passes the newness check (RTLO4a6); the zero increment is the noop mock_ws.send_to_client(build_object_message("test", [ - ObjectMessage( - serial: "01", siteCode: "remote", - operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } - ) + build_counter_inc("counter:score@1000", 0, "02", "remote") ])) ``` @@ -234,16 +232,16 @@ ASSERT updates.length == 1 --- -## RTLO4b4c3c - tombstone update deregisters all LiveObject#subscribe listeners +## RTLO4b4c3c - tombstone update deregisters all Instance#subscribe listeners **Test ID**: `objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0` | Spec | Requirement | |------|-------------| -| RTLO4b4c3c | If LiveObjectUpdate.tombstone is true, deregister all LiveObject#subscribe listeners | +| RTLO4b4c3c | If LiveObjectUpdate.tombstone is true, deregister all listeners | | RTLO4b4c3a | Listeners are called with the tombstone update itself before deregistration | -Tests that when a tombstone update is emitted, all registered listeners are called with the tombstone update, but subsequent updates do not fire any listener because they have been deregistered. +Tests that when a tombstone update is emitted, all registered listeners are called with the update, but subsequent updates do not fire any listener because they have been deregistered. Tested through Instance#subscribe (RTINS16); the tombstone is identified by `message.operation.action == "OBJECT_DELETE"`. ### Setup ```pseudo @@ -257,7 +255,7 @@ instance.subscribe((event) => updates_b.append(event)) ### Test Steps ```pseudo -# Send an OBJECT_DELETE which causes a tombstone LiveObjectUpdate +# Send an OBJECT_DELETE which causes a tombstone mock_ws.send_to_client(build_object_message("test", [ build_object_delete("counter:score@1000", "50", "remote") ])) @@ -265,9 +263,9 @@ poll_until(updates_a.length >= 1, timeout: 5s) # Both listeners should have received the tombstone update ASSERT updates_a.length == 1 -ASSERT updates_a[0].tombstone == true +ASSERT updates_a[0].message.operation.action == "OBJECT_DELETE" ASSERT updates_b.length == 1 -ASSERT updates_b[0].tombstone == true +ASSERT updates_b[0].message.operation.action == "OBJECT_DELETE" # Send another update — listeners should have been deregistered by tombstone mock_ws.send_to_client(build_object_message("test", [ @@ -283,15 +281,16 @@ ASSERT updates_b.length == 1 --- -## RTLO4b4d - LiveObjectUpdate.objectMessage is populated from source ObjectMessage +## RTLO4b4d - InstanceSubscriptionEvent.message is populated from source ObjectMessage **Test ID**: `objects/unit/RTLO4b4d/update-has-object-message-0` | Spec | Requirement | |------|-------------| -| RTLO4b4d | LiveObjectUpdate.objectMessage is the source ObjectMessage that caused the update | +| RTLO4b4d | The update carries the source ObjectMessage | +| RTINS16e | InstanceSubscriptionEvent.message is a PublicAPI::ObjectMessage | -Tests that when an update is triggered by an incoming ObjectMessage, the `LiveObjectUpdate.objectMessage` field is populated with that source ObjectMessage. +Tests that when an update is triggered by an incoming ObjectMessage, the `InstanceSubscriptionEvent.message` field is populated with the public ObjectMessage. Tested through Instance#subscribe (RTINS16). ### Setup ```pseudo @@ -312,24 +311,24 @@ poll_until(updates.length >= 1, timeout: 5s) ### Assertions ```pseudo ASSERT updates.length == 1 -ASSERT updates[0].objectMessage IS NOT null -ASSERT updates[0].objectMessage.serial == "99" -ASSERT updates[0].objectMessage.siteCode == "remote" -ASSERT updates[0].objectMessage.operation.action == "COUNTER_INC" -ASSERT updates[0].objectMessage.operation.objectId == "counter:score@1000" +ASSERT updates[0].message IS NOT null +ASSERT updates[0].message.serial == "99" +ASSERT updates[0].message.siteCode == "remote" +ASSERT updates[0].message.operation.action == "COUNTER_INC" +ASSERT updates[0].message.operation.objectId == "counter:score@1000" ``` --- -## RTLO4b4e - LiveObjectUpdate.tombstone is true for tombstone updates +## RTLO4b4e - tombstone update identified by OBJECT_DELETE action **Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-true-0` | Spec | Requirement | |------|-------------| -| RTLO4b4e | LiveObjectUpdate.tombstone indicates the update was emitted as a result of tombstoning | +| RTLO4b4e | Tombstone update emitted when LiveObject is tombstoned | -Tests that when a `LiveObject` is tombstoned (e.g. via OBJECT_DELETE), the emitted `LiveObjectUpdate` has `tombstone == true`. +Tests that when a `LiveObject` is tombstoned (e.g. via OBJECT_DELETE), the emitted event carries an OBJECT_DELETE operation. Tested through Instance#subscribe (RTINS16). ### Setup ```pseudo @@ -350,18 +349,18 @@ poll_until(updates.length >= 1, timeout: 5s) ### Assertions ```pseudo ASSERT updates.length == 1 -ASSERT updates[0].tombstone == true +ASSERT updates[0].message.operation.action == "OBJECT_DELETE" ``` --- -## RTLO4b4e - LiveObjectUpdate.tombstone is false for normal updates +## RTLO4b4e - normal update carries non-tombstone action **Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-false-0` -**Spec requirement:** LiveObjectUpdate.tombstone defaults to false if not explicitly set. +**Spec requirement:** Normal (non-tombstone) updates carry a regular operation action. -Tests that for a normal (non-tombstone) update, `LiveObjectUpdate.tombstone` is `false`. +Tests that for a normal update, the event carries a COUNTER_INC action (not OBJECT_DELETE). Tested through Instance#subscribe (RTINS16). ### Setup ```pseudo @@ -382,5 +381,5 @@ poll_until(updates.length >= 1, timeout: 5s) ### Assertions ```pseudo ASSERT updates.length == 1 -ASSERT updates[0].tombstone == false +ASSERT updates[0].message.operation.action == "COUNTER_INC" ``` From 0b15af8516b64b295f205929ed0324407405bb69 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 22:22:52 +0100 Subject: [PATCH 12/16] UTS: move appliedOnAckSerials/bufferedObjectOperations to RealtimeObject Per RTO7a/RTO7b, these attributes belong on RealtimeObject, not ObjectsPool. Add `realtime_object = RealtimeObject(pool: pool)` to affected test setups and redirect all references (8 tests, ~11 lines). Co-Authored-By: Claude Opus 4.6 --- uts/objects/unit/objects_pool.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md index cbbf400ed..092fe6307 100644 --- a/uts/objects/unit/objects_pool.md +++ b/uts/objects/unit/objects_pool.md @@ -299,7 +299,8 @@ ASSERT "root" IN pool ### Setup ```pseudo pool = ObjectsPool() -pool.appliedOnAckSerials = {"serial-1", "serial-2"} +realtime_object = RealtimeObject(pool: pool) +realtime_object.appliedOnAckSerials = {"serial-1", "serial-2"} pool.processAttached(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS )) @@ -317,7 +318,7 @@ pool.processObjectSync(build_object_sync_message("test", "sync1:", [ ### Assertions ```pseudo -ASSERT pool.appliedOnAckSerials == {} +ASSERT realtime_object.appliedOnAckSerials == {} ``` --- @@ -334,6 +335,7 @@ ASSERT pool.appliedOnAckSerials == {} ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) pool.processAttached(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS )) @@ -349,7 +351,7 @@ pool.processObjectMessage(build_object_message("test", [ ### Assertions ```pseudo ASSERT pool.syncState == SYNCING -ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT realtime_object.bufferedObjectOperations.length == 1 ASSERT "counter:abc@1000" NOT IN pool ``` @@ -367,6 +369,7 @@ ASSERT "counter:abc@1000" NOT IN pool ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) pool.processAttached(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS )) @@ -393,7 +396,7 @@ pool.processObjectSync(build_object_sync_message("test", "sync1:", [ ### Assertions ```pseudo ASSERT pool["counter:abc@1000"].data == 110 -ASSERT pool.bufferedObjectOperations.length == 0 +ASSERT realtime_object.bufferedObjectOperations.length == 0 ``` --- @@ -433,10 +436,11 @@ ASSERT pool.keys().length == 1 ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) pool.syncState = SYNCED pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") pool["counter:abc@1000"].data = 10 -pool.appliedOnAckSerials = {"echo-serial-1"} +realtime_object.appliedOnAckSerials = {"echo-serial-1"} ``` ### Test Steps @@ -453,7 +457,7 @@ pool.processObjectMessage(build_object_message("test", [ ### Assertions ```pseudo ASSERT pool["counter:abc@1000"].data == 10 -ASSERT "echo-serial-1" NOT IN pool.appliedOnAckSerials +ASSERT "echo-serial-1" NOT IN realtime_object.appliedOnAckSerials ``` --- @@ -467,6 +471,7 @@ ASSERT "echo-serial-1" NOT IN pool.appliedOnAckSerials ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) pool.syncState = SYNCED pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") ``` @@ -480,7 +485,7 @@ pool.applyObjectMessages([ ### Assertions ```pseudo -ASSERT "local-serial-1" IN pool.appliedOnAckSerials +ASSERT "local-serial-1" IN realtime_object.appliedOnAckSerials ASSERT pool["counter:abc@1000"].data == 5 ``` @@ -734,6 +739,7 @@ ASSERT pool["counter:abc@1000"].data == 10 ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) pool.processAttached(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS )) @@ -741,7 +747,7 @@ pool.processAttached(ProtocolMessage( pool.processObjectMessage(build_object_message("test", [ build_counter_inc("counter:abc@1000", 5, "01", "site1") ])) -ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT realtime_object.bufferedObjectOperations.length == 1 ``` ### Test Steps @@ -753,7 +759,7 @@ pool.processAttached(ProtocolMessage( ### Assertions ```pseudo -ASSERT pool.bufferedObjectOperations.length == 0 +ASSERT realtime_object.bufferedObjectOperations.length == 0 ``` --- @@ -809,6 +815,7 @@ ASSERT "counter:new@1000" IN pool ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) pool.processAttached(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS )) @@ -816,7 +823,7 @@ pool.processAttached(ProtocolMessage( pool.processObjectMessage(build_object_message("test", [ build_counter_inc("counter:abc@1000", 5, "01", "site1") ])) -ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT realtime_object.bufferedObjectOperations.length == 1 ``` ### Test Steps @@ -850,6 +857,7 @@ ASSERT pool["counter:abc@1000"].data == 105 ### Setup ```pseudo pool = ObjectsPool() +realtime_object = RealtimeObject(pool: pool) ASSERT pool.syncState == INITIALIZED ``` @@ -862,7 +870,7 @@ pool.processObjectMessage(build_object_message("test", [ ### Assertions ```pseudo -ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT realtime_object.bufferedObjectOperations.length == 1 ``` --- From f7ace4c46f976366ae00103c542995bd961e1266 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 22:25:10 +0100 Subject: [PATCH 13/16] UTS: fix RTPO19b subscribe-on-detached test Replace non-existent getRoot() with get(), and restructure so root is obtained while attached then channel is detached before subscribe(). The previous setup responded to ATTACH with DETACHED, so get() would throw 90001 before reaching subscribe(). Co-Authored-By: Claude Opus 4.6 --- uts/objects/unit/path_object_subscribe.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md index 5f0de3fbc..ef89a6096 100644 --- a/uts/objects/unit/path_object_subscribe.md +++ b/uts/objects/unit/path_object_subscribe.md @@ -80,6 +80,11 @@ mock_ws = MockWebSocket( ), onMessageFromClient: (msg) => { IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == DETACH: mock_ws.send_to_client(ProtocolMessage( action: DETACHED, channel: msg.channel )) @@ -91,14 +96,15 @@ client = Realtime(options: { key: "fake:key", autoConnect: true }) channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -channel.attach() +root = AWAIT channel.object.get() + +AWAIT channel.detach() AWAIT_STATE channel.state == DETACHED -root_path = channel.object.getRoot() ``` ### Test Steps ```pseudo -root_path.subscribe((event) => {}) FAILS WITH error +root.subscribe((event) => {}) FAILS WITH error ``` ### Assertions From d7d75e7874ed8cd0c99764d8e5c7cb2179b4f029 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 22:32:20 +0100 Subject: [PATCH 14/16] UTS: rewrite RTO20f to use observable behaviour Replace private _liveObject.siteTimeserials access with an observable test: after a local increment (applied via ACK with source LOCAL), send an inbound COUNTER_INC with the same siteCode and serial. If LOCAL incorrectly wrote to siteTimeserials, the newness check would reject the inbound message. The counter reaching 120 proves LOCAL did not pollute siteTimeserials. Co-Authored-By: Claude Opus 4.6 --- uts/objects/unit/realtime_object.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 842abec78..11f438f6c 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1063,21 +1063,36 @@ ASSERT score_after_echo == 110 | RTO20f | Apply with source LOCAL | | RTLC7c2 | LOCAL source does not update siteTimeserials | +Verified through observable behaviour: after a local increment (applied via ACK +with source LOCAL), an inbound COUNTER_INC from the same siteCode and serial as +the ACK should still apply. If LOCAL had incorrectly written to siteTimeserials, +the newness check would reject the inbound message as stale. + +The mock's ACK serial for the first publish is `"t:1:0"` with siteCode `"test"` +(from ConnectionDetails). The inbound message reuses that siteCode and serial. + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -site_serials_before = root.get("score").instance()._liveObject.siteTimeserials ``` ### Test Steps ```pseudo AWAIT root.get("score").increment(10) -site_serials_after = root.get("score").instance()._liveObject.siteTimeserials +ASSERT root.get("score").value() == 110 + +# Send inbound COUNTER_INC from siteCode "test" with serial "t:1:0" +# (same siteCode and serial as the ACK). If LOCAL incorrectly set +# siteTimeserials["test"] = "t:1:0", this would fail the newness check. +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "t:1:0", "test") +])) +poll_until(root.get("score").value() == 120, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT site_serials_after == site_serials_before +ASSERT root.get("score").value() == 120 ``` --- From e3614cd9065e3d667e6739b36b125cce5d3297c9 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 22:39:17 +0100 Subject: [PATCH 15/16] UTS: rewrite RTO5c9 re-sync test to use observable behaviour Instead of just checking score == 100 after re-sync (which proves nothing since OBJECT_SYNC resets the pool regardless), replay a previously-applied serial after re-sync. If appliedOnAckSerials was properly cleared, the replayed message applies normally and score reaches 110. If not cleared, dedup (RTO9a3) rejects it. Co-Authored-By: Claude Opus 4.6 --- uts/objects/unit/realtime_object.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 11f438f6c..6ab753cd5 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1146,19 +1146,25 @@ ASSERT root.get("score").value() == 110 AWAIT root.get("score").increment(10) ASSERT root.get("score").value() == 110 -// Trigger re-sync +// Trigger re-sync — appliedOnAckSerials should be cleared per RTO5c9 mock_ws.send_to_client(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS )) mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) - -// After re-sync, the score is back to 100 (from pool state) ASSERT root.get("score").value() == 100 + +// Replay the same serial ("t:1: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. +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "t:1:0", "test") +])) +poll_until(root.get("score").value() == 110, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT root.get("score").value() == 100 +ASSERT root.get("score").value() == 110 ``` --- From 6bcf59355db24f7711e88402b459bb05f577384c Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Fri, 5 Jun 2026 22:41:15 +0100 Subject: [PATCH 16/16] UTS: add OBJECT, OBJECT_SYNC, ANNOTATION to proxy action numbers table Add missing protocol message actions from TR2: OBJECT (19), OBJECT_SYNC (20), and ANNOTATION (21). Co-Authored-By: Claude Opus 4.6 --- uts/realtime/integration/helpers/proxy.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md index 303d8ea74..786929604 100644 --- a/uts/realtime/integration/helpers/proxy.md +++ b/uts/realtime/integration/helpers/proxy.md @@ -202,6 +202,9 @@ ASSERT attach_frames.length == 1 | MESSAGE | 15 | Both | | SYNC | 16 | Server → Client | | AUTH | 17 | Client → Server | +| OBJECT | 19 | Both | +| OBJECT_SYNC | 20 | Server → Client | +| ANNOTATION | 21 | Both | ## SDK ClientOptions for Proxy Tests