From a77f75a2ee794d60b112cce0ce47e9ecd349647e Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Wed, 13 May 2026 11:12:04 +0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 ```