From 34b9ee5605aa96dea3831ab63bf6051df3827beb Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 1 Jul 2026 00:51:33 +0530 Subject: [PATCH] chore(uts): reconcile LiveObjects (objects) test specs with main spec Align the uts/objects test specs with the canonical objects-features.md on main, and fold in the test-correctness fixes from the objects UTS audit. Reconciliation with main: - Rename the internal CRDT type LiveMap/LiveCounter -> InternalLiveMap/ InternalLiveCounter (271 refs), and rename the four CRDT spec files to internal_live_*.md, repointing all cross-references. Blueprint .create() factories, public PathObject/Instance, and LiveMapUpdate/ LiveMapValue are deliberately left unchanged. - get() precondition RTO23b -> RTO23e: get() now performs ensure-active-channel (RTL33) -- re-attaches on DETACHED, rejects 90001 only on FAILED -- instead of unconditionally throwing 90001. - Rename blueprint value types LiveMapValueType/LiveCounterValueType -> LiveMap/LiveCounter (static .create() factories), matching main. - Consolidate RTO25 (access) / RTO26 (write) preconditions as dedicated sections in realtime_object.md, fixing 8 references to two precondition files that never existed; drop the mislabeled RTO25b-via-get() tests. - public_object_message: replace the ably-js-private _derivedFrom with a spec-neutral derivedFrom (RTLCV4g5/RTLMV4j5 keep this retained Create local-only and unnamed). Test-correctness fixes (objects UTS audit): - Quiescence-barrier pattern for negative subscription assertions. - Tombstone / echo-dedup handling; map-clear (RTLM24) and LWW-reject (RTLM9b) semantics; path depth-coverage (RTO24b2a1) fixes. - ACK-serial helper + SITE_CODE in standard_test_pool; explicit REST per-op cardinality; assorted spec-ID and expected-event corrections. Validated against main objects-features.md and cross-checked with ably-js; spec naming is preferred over ably-js where the two differ. --- uts/objects/PLAN.md | 84 ++++--- uts/objects/helpers/standard_test_pool.md | 63 ++++- .../integration/objects_lifecycle_test.md | 12 +- uts/objects/integration/objects_sync_test.md | 2 +- uts/objects/unit/instance.md | 67 +++-- ...ve_counter.md => internal_live_counter.md} | 66 ++--- ...er_api.md => internal_live_counter_api.md} | 6 +- .../{live_map.md => internal_live_map.md} | 134 +++++----- ...ve_map_api.md => internal_live_map_api.md} | 38 +-- uts/objects/unit/live_object_subscribe.md | 53 +++- uts/objects/unit/objects_pool.md | 30 +-- uts/objects/unit/parent_references.md | 92 +++---- uts/objects/unit/path_object.md | 50 ++-- uts/objects/unit/path_object_mutations.md | 34 +-- uts/objects/unit/path_object_subscribe.md | 84 ++++++- uts/objects/unit/public_object_message.md | 4 +- uts/objects/unit/realtime_object.md | 235 ++++++++++++------ uts/objects/unit/value_types.md | 16 +- 18 files changed, 665 insertions(+), 405 deletions(-) rename uts/objects/unit/{live_counter.md => internal_live_counter.md} (88%) rename uts/objects/unit/{live_counter_api.md => internal_live_counter_api.md} (97%) rename uts/objects/unit/{live_map.md => internal_live_map.md} (87%) rename uts/objects/unit/{live_map_api.md => internal_live_map_api.md} (90%) diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md index a95734829..2a49da975 100644 --- a/uts/objects/PLAN.md +++ b/uts/objects/PLAN.md @@ -10,9 +10,9 @@ 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) +**Internal (not user-facing):** LiveObject, InternalLiveCounter (CRDT counter), InternalLiveMap (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), PublicAPI::ObjectMessage/ObjectOperation (user-facing event metadata) +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounter/LiveMap (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,8 +30,8 @@ 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-6, RTLO4b4d-e | ~23 | -| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3-6, RTLO4g-h, RTLO4e9 | ~38 | +| `unit/internal_live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3-6, RTLO4b4d-e | ~23 | +| `unit/internal_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 (evaluation generates ObjectMessages with v6 wire format) | ~19 | @@ -42,8 +42,8 @@ All new test files go in `specification/uts/objects/`. | File | Spec Points | ~Tests | |------|-------------|--------| | `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, RTLCV4, RTLMV4 (reads + mutations, value type evaluation) | ~20 | +| `unit/internal_live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | +| `unit/internal_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 | @@ -73,7 +73,7 @@ All new test files go in `specification/uts/objects/`. **Standard test tree:** ``` -root (LiveMap, objectId: "root") +root (InternalLiveMap, objectId: "root") +-- "name" -> string "Alice" +-- "age" -> number 30 +-- "active" -> boolean true @@ -82,16 +82,16 @@ root (LiveMap, objectId: "root") +-- "data" -> json {"tags": ["a", "b"]} +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) -counter:score@1000 (LiveCounter, data: 100) +counter:score@1000 (InternalLiveCounter, data: 100) -map:profile@1000 (LiveMap) +map:profile@1000 (InternalLiveMap) +-- "email" -> string "alice@example.com" +-- "nested_counter" -> objectId "counter:nested@1000" +-- "prefs" -> objectId "map:prefs@1000" -counter:nested@1000 (LiveCounter, data: 5) +counter:nested@1000 (InternalLiveCounter, data: 5) -map:prefs@1000 (LiveMap) +map:prefs@1000 (InternalLiveMap) +-- "theme" -> string "dark" ``` @@ -131,7 +131,8 @@ setup_synced_channel(channel_name): )) ELSE IF msg.action == OBJECT: // Auto-ACK with generated serials - serials = msg.state.map((_, i) => "ack-serial-" + i) + // canonical ack-serial form defined as ack_serial in helpers/standard_test_pool.md + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) } ) @@ -146,9 +147,9 @@ setup_synced_channel(channel_name): ## Pure Unit Test Design -### `unit/live_counter.md` -- CRDT Counter Data Structure +### `unit/internal_live_counter.md` -- CRDT Counter Data Structure -Directly construct `LiveCounter`, call `applyOperation()` and `replaceData()`, assert internal state. +Directly construct `InternalLiveCounter`, call `applyOperation()` and `replaceData()`, assert internal state. **Key test groups:** 1. **Zero value (RTLC4):** data=0, siteTimeserials={}, createOperationIsMerged=false, isTombstone=false @@ -161,7 +162,7 @@ Directly construct `LiveCounter`, call `applyOperation()` and `replaceData()`, a 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 +### `unit/internal_live_map.md` -- LWW Map Data Structure Same pattern. Key additional concerns: @@ -179,7 +180,7 @@ Same pattern. Key additional concerns: Directly construct ObjectsPool, call `processAttached()`, `processObjectSync()`, `processObjectMessage()`. -1. **Initialization (RTO3):** root LiveMap always present +1. **Initialization (RTO3):** root InternalLiveMap 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) @@ -197,20 +198,20 @@ Pure function tests: 4. Deterministic: same inputs -> same objectId 5. Different nonce -> different objectId -### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType +### `unit/value_types.md` -- LiveCounter / LiveMap Tests the static `create()` factories and evaluation procedure. -**LiveCounterValueType (RTLCV1-4):** -1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +**LiveCounter (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounter with count=42 2. `LiveCounter.create()` -> count defaults to 0 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 +**LiveMap (RTLMV1-4):** +1. `LiveMap.create({entries})` -> immutable LiveMap 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) +3. Nested value types: LiveMap containing LiveCounter -> depth-first ObjectMessage array (inner creates before outer) 4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) --- @@ -222,7 +223,7 @@ Tests the static `create()` factories and evaluation procedure. Uses `setup_synced_channel()` from helper. **Key tests:** -- **RTO23:** get() requires OBJECT_SUBSCRIBE, throws on DETACHED/FAILED, waits for SYNCED, returns PathObject +- **RTO23:** get() requires OBJECT_SUBSCRIBE (RTO23a), performs ensure-active-channel (RTO23e/RTL33 — re-attaches if DETACHED, rejects 90001 only if FAILED), waits for SYNCED (RTO23c), returns PathObject (RTO23d) - **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 @@ -236,13 +237,13 @@ Uses `setup_synced_channel()` from helper. - **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 +- **RTPO7:** value() -- counter returns number, primitive returns value, InternalLiveMap 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 +- **RTPO9-11:** entries/keys/values -- yields [key, PathObject] pairs for InternalLiveMap 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 +- **RTPO3:** path resolution (RTPO3a): walk segments through InternalLiveMaps; fail if intermediate not InternalLiveMap ### `unit/path_object_mutations.md` -- Write Operations @@ -250,7 +251,7 @@ Uses `setup_synced_channel()` from helper. - **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 +- **RTPO3c2:** mutation on unresolvable path throws 92005 ### `unit/path_object_subscribe.md` -- Path-Based Subscriptions @@ -269,28 +270,28 @@ Uses `setup_synced_channel()` from helper. - **RTINS1:** id property returns objectId - **RTINS2:** value() -- counter returns number, map returns null -- **RTINS3-5:** get(key), entries(), keys(), values() -- delegate to underlying LiveMap +- **RTINS3-5:** get(key), entries(), keys(), values() -- delegate to underlying InternalLiveMap - **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 +- **RTINS16g:** instance follows identity not path -- object replacement at path doesn't affect Instance +- **(no spec ID -- tombstoned-Instance behaviour is unspecified in objects-features.md):** operations on tombstoned Instance throw error -### `unit/live_counter_api.md` -- Counter Through Channel +### `unit/internal_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 +### `unit/internal_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 evaluates value type +- **RTLM20:** set with LiveCounter/LiveMap evaluates value type - **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply - **RTLM24:** clear constructs MAP_CLEAR ObjectMessage @@ -307,7 +308,7 @@ Uses `setup_synced_channel()` from helper. - **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 +- **RTO5c10:** post-sync parentReferences rebuild from InternalLiveMap entries ### `unit/public_object_message.md` -- User-Facing Event Types @@ -330,8 +331,9 @@ The RTO20 publishAndApply flow: onMessageFromClient: (msg) => { IF msg.action == OBJECT: serials = [] + // canonical ack-serial form defined as ack_serial in helpers/standard_test_pool.md FOR i IN 0..msg.state.length-1: - serials.append("ack-" + msg.msgSerial + "-" + i) + serials.append("ack-" + msg.msgSerial + ":" + i) mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) } ``` @@ -349,14 +351,14 @@ onMessageFromClient: (msg) => { 1. `helpers/standard_test_pool.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 +4. `unit/internal_live_counter.md` -- no dependencies +5. `unit/internal_live_map.md` -- no dependencies 6. `unit/object_id.md` -- no dependencies -7. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +7. `unit/objects_pool.md` -- uses InternalLiveCounter/InternalLiveMap 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 +10. `unit/internal_live_counter_api.md` -- uses helper +11. `unit/internal_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 @@ -378,7 +380,7 @@ onMessageFromClient: (msg) => { | No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | | `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 | +| LiveObject/InternalLiveMap/InternalLiveCounter 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 | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md index 1c2eeff85..901aff3b3 100644 --- a/uts/objects/helpers/standard_test_pool.md +++ b/uts/objects/helpers/standard_test_pool.md @@ -7,7 +7,7 @@ Shared fixtures, protocol message builders, and synced-channel setup pattern for 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) +root (InternalLiveMap, objectId: "root", semantics: LWW) +-- "name" -> string "Alice" +-- "age" -> number 30 +-- "active" -> boolean true @@ -16,16 +16,16 @@ root (LiveMap, objectId: "root", semantics: LWW) +-- "data" -> json {"tags": ["a", "b"]} +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) -counter:score@1000 (LiveCounter, data: 100) +counter:score@1000 (InternalLiveCounter, data: 100) -map:profile@1000 (LiveMap, semantics: LWW) +map:profile@1000 (InternalLiveMap, semantics: LWW) +-- "email" -> string "alice@example.com" +-- "nested_counter" -> objectId "counter:nested@1000" +-- "prefs" -> objectId "map:prefs@1000" -counter:nested@1000 (LiveCounter, data: 5) +counter:nested@1000 (InternalLiveCounter, data: 5) -map:prefs@1000 (LiveMap, semantics: LWW) +map:prefs@1000 (InternalLiveMap, semantics: LWW) +-- "theme" -> string "dark" ``` @@ -104,6 +104,22 @@ STANDARD_POOL_OBJECTS = [ ## Builder Functions +### Canonical Constants + +The standard synced-channel harness uses a fixed siteCode and a fixed ACK-serial +scheme. These are exposed so tests can reference them rather than hardcoding +literals: + +```pseudo +SITE_CODE = "test-site" # the harness ConnectionDetails siteCode + +ack_serial(msgSerial, i) => "ack-" + msgSerial + ":" + i + # the first publish's first op = ack_serial(0, 0) == "ack-0:0" +``` + +Replay tests that must reuse the apply-on-ACK serial/siteCode MUST reference +these (e.g. `ack_serial(0, 0)` / `SITE_CODE`), never hardcode a literal. + ### Protocol Message Builders ```pseudo @@ -265,7 +281,7 @@ build_public_object_message(objectMessage, channelName): 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. +After the OBJECT_SYNC sequence completes, the SDK rebuilds parentReferences per RTO5c10: reset all LiveObject parentReferences to empty (RTLO3f2), then iterate all InternalLiveMap 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): @@ -274,7 +290,7 @@ setup_synced_channel(channel_name): ProtocolMessage(action: CONNECTED, connectionDetails: { connectionId: "conn-1", connectionKey: "conn-key-1", - siteCode: "test-site", + siteCode: SITE_CODE, objectsGCGracePeriod: 86400000 }) ), @@ -292,7 +308,7 @@ setup_synced_channel(channel_name): ELSE IF msg.action == OBJECT: serials = [] FOR i IN 0..msg.state.length - 1: - serials.append("ack-" + msg.msgSerial + ":" + i) + serials.append(ack_serial(msg.msgSerial, i)) mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) } ) @@ -321,7 +337,7 @@ setup_synced_channel_no_ack(channel_name): ProtocolMessage(action: CONNECTED, connectionDetails: { connectionId: "conn-1", connectionKey: "conn-key-1", - siteCode: "test-site", + siteCode: SITE_CODE, objectsGCGracePeriod: 86400000 }) ), @@ -354,12 +370,41 @@ setup_synced_channel_no_ack(channel_name): --- +## Negative-assertion quiescence + +Subscription tests that assert a listener did NOT fire (or that a count is +unchanged) after an async send cannot simply check the count immediately: the +absence of a callback is not observable by waiting an arbitrary amount of time. +Instead, drive a positive signal through the same dispatch and AWAIT it, so that +once the control signal is delivered any expected callback would also have run; +THEN assert the count under test is unchanged. + +The control signal is either a second listener that WILL fire on the same +dispatch, or a follow-up observable message sent after the message under test. + +```pseudo +assert_unchanged_after_quiescence(count_under_test, control): + before = count_under_test() + # control is a listener (or follow-up message) that WILL fire on the same + # dispatch as the message under test + AWAIT control.delivered() + ASSERT count_under_test() == before +``` + +For multi-listener cases, AWAIT all involved listeners before asserting any count. + +--- + ## REST Fixture Provisioning For integration tests that need pre-existing object state before the test client connects, use the REST API to establish fixtures. The objects REST API uses the **V2 format** (per the LiveObjects OpenAPI specification). A request publishes a single operation, or a batch of operations as a JSON array — there is **no** `{ "messages": [...] }` envelope. Each operation names its type via a payload key (`mapSet`, `mapRemove`, `mapCreate`, `counterInc`, `counterCreate`) and targets an object by `objectId` **or** `path`. Note the endpoint path is singular (`/object`). +Target cardinality per op-class: mutate ops (`mapSet`, `mapRemove`, `counterInc`) MUST target exactly one of `objectId`/`path` (never both, never neither); create ops (`mapCreate`, `counterCreate`) MAY target zero-or-one (never both — a create with no target makes a standalone object). + +If an SDK uses a REST client object to perform provisioning, it must be closed after use (clients are typically AutoCloseable / hold HTTP resources). + ```pseudo provision_objects_via_rest(api_key, channel_name, operations): # operations: a single operation object, or an array of operation objects (batch) diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md index 4ed745d1c..defddfd13 100644 --- a/uts/objects/integration/objects_lifecycle_test.md +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -47,7 +47,7 @@ AFTER ALL TESTS: **Test ID**: `objects/integration/RTO23-RTPO15/set-primitive-propagates-0` -**Spec requirement:** PathObject#set delegates to LiveMap#set. The mutation +**Spec requirement:** PathObject#set delegates to InternalLiveMap#set. The mutation propagates via the server and a second client sees the updated value. ### Setup @@ -94,11 +94,11 @@ client_b.close() --- -## RTPO15 - Set with LiveCounterValueType, second client reads counter +## RTPO15 - Set with LiveCounter, second client reads counter **Test ID**: `objects/integration/RTPO15/set-counter-value-type-0` -**Spec requirement:** PathObject#set with LiveCounterValueType creates a new counter +**Spec requirement:** PathObject#set with LiveCounter creates a new counter on the server. Second client syncs and reads the counter value. ### Setup @@ -145,7 +145,7 @@ client_b.close() **Test ID**: `objects/integration/RTPO17/increment-propagates-0` -**Spec requirement:** PathObject#increment delegates to LiveCounter#increment. +**Spec requirement:** PathObject#increment delegates to InternalLiveCounter#increment. The server applies the increment and propagates the updated value. ### Setup @@ -193,11 +193,11 @@ client_b.close() --- -## RTPO15 - Set with LiveMapValueType, second client reads nested map +## RTPO15 - Set with LiveMap, 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. +**Spec requirement:** PathObject#set with LiveMap creates a nested map. Second client can navigate into the nested map. ### Setup diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md index eb7513676..ef748bf15 100644 --- a/uts/objects/integration/objects_sync_test.md +++ b/uts/objects/integration/objects_sync_test.md @@ -177,7 +177,7 @@ client.close() **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. +sends HAS_OBJECTS, sync completes, root is an empty InternalLiveMap. ### Setup ```pseudo diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md index bf8a67b47..d274a7241 100644 --- a/uts/objects/unit/instance.md +++ b/uts/objects/unit/instance.md @@ -47,9 +47,9 @@ ASSERT map_inst.id() == "map:profile@1000" | Spec | Requirement | |------|-------------| | RTINS4a | Checks access API preconditions per RTO25 | -| RTINS4b | LiveCounter -> delegates to LiveCounter#value | +| RTINS4b | InternalLiveCounter -> delegates to InternalLiveCounter#value | | RTINS4c | Primitive -> returns value directly | -| RTINS4d | LiveMap -> null | +| RTINS4d | InternalLiveMap -> null | ### Setup ```pseudo @@ -74,8 +74,8 @@ ASSERT map_inst.value() == null | Spec | Requirement | |------|-------------| | RTINS5b | Checks access API preconditions per RTO25 | -| RTINS5c | LiveMap -> look up key, wrap result in Instance | -| RTINS5d | Non-LiveMap -> null | +| RTINS5c | InternalLiveMap -> look up key, wrap result in Instance | +| RTINS5d | Non-InternalLiveMap -> null | ### Setup ```pseudo @@ -105,8 +105,8 @@ ASSERT null_inst == null | Spec | Requirement | |------|-------------| | RTINS6a | Checks access API preconditions per RTO25 | -| RTINS6b | LiveMap -> array of [key, Instance] pairs | -| RTINS6c | Non-LiveMap -> empty array | +| RTINS6b | InternalLiveMap -> array of [key, Instance] pairs | +| RTINS6c | Non-InternalLiveMap -> empty array | ### Setup ```pseudo @@ -137,8 +137,8 @@ ASSERT entries["name"].value() == "Alice" | Spec | Requirement | |------|-------------| | RTINS9a | Checks access API preconditions per RTO25 | -| RTINS9b | LiveMap -> non-tombstoned entry count | -| RTINS9c | Non-LiveMap -> null | +| RTINS9b | InternalLiveMap -> non-tombstoned entry count | +| RTINS9c | Non-InternalLiveMap -> null | ### Setup ```pseudo @@ -185,15 +185,15 @@ ASSERT result["profile"]["email"] == "alice@example.com" --- -## RTINS12 - set() delegates to LiveMap#set +## RTINS12 - set() delegates to InternalLiveMap#set **Test ID**: `objects/unit/RTINS12/set-delegates-0` | Spec | Requirement | |------|-------------| | RTINS12b | Checks write API preconditions per RTO26 | -| RTINS12c | LiveMap -> delegate to LiveMap#set | -| RTINS12d | Non-LiveMap -> throw 92007 | +| RTINS12c | InternalLiveMap -> delegate to InternalLiveMap#set | +| RTINS12d | Non-InternalLiveMap -> throw 92007 | ### Setup ```pseudo @@ -213,11 +213,11 @@ ASSERT root.get("name").value() == "Bob" --- -## RTINS12d - set() on non-LiveMap throws 92007 +## RTINS12d - set() on non-InternalLiveMap throws 92007 **Test ID**: `objects/unit/RTINS12d/set-non-map-throws-0` -**Spec requirement:** If the wrapped value is not a LiveMap, throw ErrorInfo with code 92007. +**Spec requirement:** If the wrapped value is not a InternalLiveMap, throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -237,15 +237,15 @@ ASSERT error.code == 92007 --- -## RTINS13 - remove() delegates to LiveMap#remove +## RTINS13 - remove() delegates to InternalLiveMap#remove **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 | +| RTINS13c | InternalLiveMap -> delegate to InternalLiveMap#remove | +| RTINS13d | Non-InternalLiveMap -> throw 92007 | ### Setup ```pseudo @@ -265,15 +265,15 @@ ASSERT root.get("name").value() == null --- -## RTINS14 - increment() delegates to LiveCounter#increment +## RTINS14 - increment() delegates to InternalLiveCounter#increment **Test ID**: `objects/unit/RTINS14/increment-delegates-0` | Spec | Requirement | |------|-------------| | RTINS14b | Checks write API preconditions per RTO26 | -| RTINS14c | LiveCounter -> delegate to increment | -| RTINS14d | Non-LiveCounter -> throw 92007 | +| RTINS14c | InternalLiveCounter -> delegate to increment | +| RTINS14d | Non-InternalLiveCounter -> throw 92007 | ### Setup ```pseudo @@ -293,11 +293,11 @@ ASSERT root.get("score").value() == 125 --- -## RTINS14d - increment() on non-LiveCounter throws 92007 +## RTINS14d - increment() on non-InternalLiveCounter throws 92007 **Test ID**: `objects/unit/RTINS14d/increment-non-counter-throws-0` -**Spec requirement:** If the wrapped value is not a LiveCounter, throw ErrorInfo with code 92007. +**Spec requirement:** If the wrapped value is not a InternalLiveCounter, throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -317,15 +317,15 @@ ASSERT error.code == 92007 --- -## RTINS15 - decrement() delegates to LiveCounter#decrement +## RTINS15 - decrement() delegates to InternalLiveCounter#decrement **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 | +| RTINS15c | InternalLiveCounter -> delegate to decrement | +| RTINS15d | Non-InternalLiveCounter -> throw 92007 | ### Setup ```pseudo @@ -508,6 +508,12 @@ counter_inst = root.get("score").instance() events = [] sub = counter_inst.subscribe((event) => events.append(event)) sub.unsubscribe() + +# Quiescence control: a second, still-subscribed listener on the same +# counter instance that WILL fire on the same dispatch as the send below. +# See helpers/standard_test_pool.md "Negative-assertion quiescence". +control_events = [] +counter_inst.subscribe((event) => control_events.append(event)) ``` ### Test Steps @@ -519,6 +525,11 @@ mock_ws.send_to_client(build_object_message("test", [ ### Assertions ```pseudo +# Negative-assertion quiescence (helpers/standard_test_pool.md): await the +# control listener so that once it has been delivered, the unsubscribed +# listener would also have fired had it remained subscribed; THEN assert +# the unsubscribed listener's count is unchanged. +poll_until(control_events.length >= 1, timeout: 5s) ASSERT events.length == 0 ``` @@ -553,7 +564,13 @@ poll_until(events.length >= 1, timeout: 5s) ### Assertions ```pseudo ASSERT events.length >= 1 -ASSERT counter_inst.id() == "counter:score@1000" +# RTINS16e1: the delivered event carries the Instance wrapping the +# LiveObject that fired. Assert against the DELIVERED EVENT's object id +# (not the pre-existing counter_inst handle, whose id is already +# "counter:score@1000" at subscribe time and so would pass even if the +# listener fired for the wrong object after the score key was repointed). +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "counter:score@1000" ``` --- diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/internal_live_counter.md similarity index 88% rename from uts/objects/unit/live_counter.md rename to uts/objects/unit/internal_live_counter.md index d5f2c3401..73a36b46d 100644 --- a/uts/objects/unit/live_counter.md +++ b/uts/objects/unit/internal_live_counter.md @@ -1,4 +1,4 @@ -# LiveCounter Tests +# InternalLiveCounter Tests Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4e`, `RTLO5`, `RTLO6` @@ -7,9 +7,9 @@ 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 the `InternalLiveCounter` CRDT data structure. InternalLiveCounter 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. +Tests operate directly on InternalLiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed. ## Shared Helpers @@ -17,15 +17,15 @@ See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_crea --- -## RTLC4 - Zero-value LiveCounter +## RTLC4 - Zero-value InternalLiveCounter **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. +**Spec requirement:** The zero-value InternalLiveCounter has data set to 0, empty siteTimeserials, createOperationIsMerged false, isTombstone false. ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Assertions @@ -51,7 +51,7 @@ ASSERT counter.siteTimeserials == {} ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -78,7 +78,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 10 counter.siteTimeserials = { "site1": "00" } ``` @@ -106,7 +106,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 10 ``` @@ -140,7 +140,7 @@ ASSERT update.noop == true ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -170,7 +170,7 @@ ASSERT counter.data == 25 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -197,7 +197,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 42 counter.createOperationIsMerged = true counter.siteTimeserials = { "site1": "00" } @@ -225,7 +225,7 @@ ASSERT update.noop == true ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -253,7 +253,7 @@ ASSERT update.noop == true ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -281,7 +281,7 @@ ASSERT counter.data == 5 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.siteTimeserials = { "site1": "05" } counter.data = 10 ``` @@ -308,7 +308,7 @@ ASSERT counter.data == 10 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.siteTimeserials = { "site1": "05" } counter.data = 10 ``` @@ -335,7 +335,7 @@ ASSERT counter.data == 10 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -372,7 +372,7 @@ ASSERT result2 == false ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -396,7 +396,7 @@ ASSERT counter.siteTimeserials["site1"] == "01" ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -421,7 +421,7 @@ ASSERT counter.data == 5 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -454,7 +454,7 @@ ASSERT result == true ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 42 counter.siteTimeserials = { "site1": "00" } ``` @@ -485,7 +485,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.isTombstone = true counter.tombstonedAt = 1700000000000 ``` @@ -514,7 +514,7 @@ ASSERT counter.data == 0 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -540,7 +540,7 @@ ASSERT counter.tombstonedAt == 1700000050000 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") before_time = current_time() ``` @@ -567,7 +567,7 @@ ASSERT counter.tombstonedAt <= after_time ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -601,7 +601,7 @@ ASSERT counter.data == 0 ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 10 counter.createOperationIsMerged = true counter.siteTimeserials = { "site1": "00" } @@ -637,7 +637,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -667,7 +667,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.isTombstone = true counter.tombstonedAt = 1700000000000 counter.data = 0 @@ -702,7 +702,7 @@ ASSERT update.noop == true ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 30 ``` @@ -734,7 +734,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 42 ``` @@ -763,7 +763,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") counter.data = 20 ``` @@ -791,7 +791,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -828,7 +828,7 @@ ASSERT counter.createOperationIsMerged == true ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:test@2000") +counter = InternalLiveCounter(objectId: "counter:test@2000") ``` ### Assertions diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/internal_live_counter_api.md similarity index 97% rename from uts/objects/unit/live_counter_api.md rename to uts/objects/unit/internal_live_counter_api.md index f55bd589c..1bef33e9d 100644 --- a/uts/objects/unit/live_counter_api.md +++ b/uts/objects/unit/internal_live_counter_api.md @@ -1,4 +1,4 @@ -# LiveCounter API Tests +# InternalLiveCounter API Tests Spec points: `RTLC5`, `RTLC11`–`RTLC13` @@ -23,7 +23,7 @@ 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`. +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/realtime_object.md` (RTO25a/RTO25b sections). ### Setup ```pseudo @@ -120,7 +120,7 @@ ASSERT root.get("score").value() == 150 **Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` -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`. +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/realtime_object.md` (RTO26a/RTO26b/RTO26c sections). --- diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/internal_live_map.md similarity index 87% rename from uts/objects/unit/live_map.md rename to uts/objects/unit/internal_live_map.md index 0186570bb..4eb5bc26d 100644 --- a/uts/objects/unit/live_map.md +++ b/uts/objects/unit/internal_live_map.md @@ -1,4 +1,4 @@ -# LiveMap Tests +# InternalLiveMap Tests Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO4g`, `RTLO4h`, `RTLO5`, `RTLO6` @@ -7,9 +7,9 @@ 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, diff calculation, and parentReferences maintenance. +Tests the `InternalLiveMap` LWW-map CRDT data structure. InternalLiveMap 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. +Tests operate directly on InternalLiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. ## Shared Helpers @@ -17,18 +17,18 @@ See `helpers/standard_test_pool.md` for builder functions. --- -## RTLM4 - Zero-value LiveMap +## RTLM4 - Zero-value InternalLiveMap **Test ID**: `objects/unit/RTLM4/zero-value-0` | Spec | Requirement | |------|-------------| -| RTLM4 | Zero-value LiveMap has empty data map and null clearTimeserial | +| RTLM4 | Zero-value InternalLiveMap has empty data map and null clearTimeserial | | RTLM25 | clearTimeserial initially null | ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") ``` ### Assertions @@ -53,7 +53,7 @@ ASSERT map.siteTimeserials == {} ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") ``` ### Test Steps @@ -86,7 +86,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } @@ -119,7 +119,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } } @@ -147,7 +147,7 @@ ASSERT update.noop == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } } @@ -175,7 +175,7 @@ ASSERT update.noop == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "", tombstone: false } } @@ -190,7 +190,11 @@ update = map.applyOperation(msg, source: CHANNEL) ### Assertions ```pseudo ASSERT map.data["name"].data == { string: "Alice" } -ASSERT update.noop == true +# The op's ObjectMessage.serial is empty, so the OBJECT-level gate (RTLO4a3, via canApplyOperation) +# rejects it before the entry-level RTLM9b comparison, and applyOperation returns false (RTLM15b). +# (NOTE: RTLM9b's "both serials empty" case is thus unreachable via applyOperation — a spec layering +# tension worth raising upstream; the observable result here is a plain false, not a noop update.) +ASSERT update == false ``` --- @@ -203,7 +207,7 @@ ASSERT update.noop == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: null, tombstone: false } } @@ -232,7 +236,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.clearTimeserial = "05" ``` @@ -259,12 +263,12 @@ ASSERT update.noop == true | 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. +This test requires an ObjectsPool to be passed alongside the InternalLiveMap. The InternalLiveMap 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) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) ``` ### Test Steps @@ -276,7 +280,7 @@ 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"] IS InternalLiveCounter ASSERT pool["counter:new@2000"].data == 0 ``` @@ -296,7 +300,7 @@ ASSERT pool["counter:new@2000"].data == 0 ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } @@ -333,7 +337,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") ``` ### Test Steps @@ -360,7 +364,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.clearTimeserial = "05" map.data = { "name": { data: { string: "Alice" }, timeserial: "04", tombstone: false } @@ -394,7 +398,7 @@ ASSERT update.noop == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "old": { data: { string: "old" }, timeserial: "02", tombstone: false }, "new": { data: { string: "new" }, timeserial: "06", tombstone: false }, @@ -412,9 +416,11 @@ update = map.applyOperation(msg, source: CHANNEL) ```pseudo ASSERT map.clearTimeserial == "04" ASSERT "old" NOT IN map.data -ASSERT "same" NOT IN map.data +# RTLM24e1: an entry is removed only if the clear serial is lexicographically GREATER than the entry's +# timeserial. "same" has timeserial "04" == the clear serial "04" (not greater), so it is KEPT. +ASSERT "same" IN map.data ASSERT "new" IN map.data -ASSERT update.update == { "old": "removed", "same": "removed" } +ASSERT update.update == { "old": "removed" } ASSERT update.objectMessage == msg ``` @@ -428,7 +434,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.clearTimeserial = "10" ``` @@ -460,7 +466,7 @@ ASSERT update.noop == true ### Setup ```pseudo -map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW") ``` ### Test Steps @@ -494,7 +500,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW") map.createOperationIsMerged = true map.siteTimeserials = { "site1": "00" } ``` @@ -524,7 +530,7 @@ ASSERT update.noop == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") ``` ### Test Steps @@ -548,7 +554,7 @@ ASSERT map.siteTimeserials["site1"] == "01" ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.isTombstone = true ``` @@ -580,7 +586,7 @@ ASSERT map.data == {} ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false }, "age": { data: { number: 30 }, timeserial: "01", tombstone: false } @@ -617,11 +623,11 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo pool = ObjectsPool() -tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter = InternalLiveCounter(objectId: "counter:dead@1000") tombstoned_counter.isTombstone = true pool["counter:dead@1000"] = tombstoned_counter -map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false }, "dead_entry": { data: null, timeserial: "01", tombstone: true }, @@ -652,7 +658,7 @@ ASSERT isTombstoned(map.data["dead_ref"]) == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "old": { data: { string: "old" }, timeserial: "01", tombstone: false } } @@ -694,7 +700,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") ``` ### Test Steps @@ -725,7 +731,7 @@ ASSERT map.data["dead"].tombstonedAt == 1700000050000 ### Setup ```pseudo -map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW") ``` ### Test Steps @@ -771,7 +777,7 @@ ASSERT map.createOperationIsMerged == true ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } @@ -805,7 +811,7 @@ ASSERT update.objectMessage == state_msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") grace_period = 86400000 now = 1700100000000 @@ -860,7 +866,7 @@ newData = { ### Test Steps ```pseudo -update = LiveMap.diff(previousData, newData) +update = InternalLiveMap.diff(previousData, newData) ``` ### Assertions @@ -883,7 +889,7 @@ ASSERT "now_dead" NOT IN update.update ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") ``` ### Test Steps @@ -910,7 +916,7 @@ ASSERT result == false ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.clearTimeserial = "05" map.data = { "x": { data: { number: 1 }, timeserial: "03", tombstone: false } @@ -947,11 +953,11 @@ ASSERT "y" IN map.data ### Setup ```pseudo pool = ObjectsPool() -tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter = InternalLiveCounter(objectId: "counter:dead@1000") tombstoned_counter.isTombstone = true pool["counter:dead@1000"] = tombstoned_counter -map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } } @@ -981,7 +987,7 @@ ASSERT map.get("ref") == null ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "name": { data: null, timeserial: "01", tombstone: true, tombstonedAt: 1700000000000 } } @@ -1012,7 +1018,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo -map = LiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "root", semantics: "LWW") map.data = { "before": { data: { string: "a" }, timeserial: "03", tombstone: false }, "after": { data: { string: "b" }, timeserial: "07", tombstone: false }, @@ -1054,12 +1060,12 @@ Tests that when MAP_SET overwrites an entry whose value is a LiveObject with a n ### Setup ```pseudo pool = ObjectsPool() -old_counter = LiveCounter(objectId: "counter:old@1000") -new_counter = LiveCounter(objectId: "counter:new@2000") +old_counter = InternalLiveCounter(objectId: "counter:old@1000") +new_counter = InternalLiveCounter(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 = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } } @@ -1100,10 +1106,10 @@ Tests that when MAP_SET creates a new entry whose value is a LiveObject, addPare ### Setup ```pseudo pool = ObjectsPool() -child_counter = LiveCounter(objectId: "counter:child@1000") +child_counter = InternalLiveCounter(objectId: "counter:child@1000") pool["counter:child@1000"] = child_counter -map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) ``` ### Test Steps @@ -1131,10 +1137,10 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo pool = ObjectsPool() -old_counter = LiveCounter(objectId: "counter:old@1000") +old_counter = InternalLiveCounter(objectId: "counter:old@1000") pool["counter:old@1000"] = old_counter -map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } } @@ -1173,10 +1179,10 @@ Tests that when MAP_REMOVE tombstones an entry whose value is a LiveObject, remo ### Setup ```pseudo pool = ObjectsPool() -child_counter = LiveCounter(objectId: "counter:child@1000") +child_counter = InternalLiveCounter(objectId: "counter:child@1000") pool["counter:child@1000"] = child_counter -map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "score": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false } } @@ -1209,7 +1215,7 @@ ASSERT update.objectMessage == msg ### Setup ```pseudo pool = ObjectsPool() -map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } @@ -1245,12 +1251,12 @@ Tests that when MAP_CLEAR removes entries that reference LiveObjects, removePare ### Setup ```pseudo pool = ObjectsPool() -counter_a = LiveCounter(objectId: "counter:a@1000") -counter_b = LiveCounter(objectId: "counter:b@1000") +counter_a = InternalLiveCounter(objectId: "counter:a@1000") +counter_b = InternalLiveCounter(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 = InternalLiveMap(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 }, @@ -1283,7 +1289,7 @@ ASSERT update.objectMessage == msg --- -## RTLO4e9 - parentReferences: tombstone LiveMap removes parent references for all entries +## RTLO4e9 - parentReferences: tombstone InternalLiveMap removes parent references for all entries **Test ID**: `objects/unit/RTLO4e9/tombstone-map-parent-refs-0` @@ -1292,17 +1298,17 @@ ASSERT update.objectMessage == msg | 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. +Tests that when a InternalLiveMap 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") +child_counter = InternalLiveCounter(objectId: "counter:child@1000") +child_map = InternalLiveMap(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 = InternalLiveMap(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 }, @@ -1347,12 +1353,12 @@ Tests that both removeParentReference and addParentReference are called in the c ### Setup ```pseudo pool = ObjectsPool() -old_map = LiveMap(objectId: "map:old@1000", semantics: "LWW") -new_map = LiveMap(objectId: "map:new@2000", semantics: "LWW") +old_map = InternalLiveMap(objectId: "map:old@1000", semantics: "LWW") +new_map = InternalLiveMap(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 = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) map.data = { "child": { data: { objectId: "map:old@1000" }, timeserial: "01", tombstone: false } } diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/internal_live_map_api.md similarity index 90% rename from uts/objects/unit/live_map_api.md rename to uts/objects/unit/internal_live_map_api.md index 3babfcb53..6e604461a 100644 --- a/uts/objects/unit/live_map_api.md +++ b/uts/objects/unit/internal_live_map_api.md @@ -1,4 +1,4 @@ -# LiveMap API Tests +# InternalLiveMap API Tests Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24`, `RTLMV4`, `RTLCV4` @@ -15,7 +15,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct --- -## RTLM5 - get() returns resolved value from LiveMap +## RTLM5 - get() returns resolved value from InternalLiveMap **Test ID**: `objects/unit/RTLM5/get-string-value-0` @@ -23,7 +23,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct |------|-------------| | 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`. +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/realtime_object.md` (RTO25a/RTO25b sections). ### Setup ```pseudo @@ -61,7 +61,7 @@ ASSERT root.get("nonexistent").value() == null **Test ID**: `objects/unit/RTLM5/get-objectid-reference-0` -**Spec requirement:** If data.objectId exists, resolve from pool. Return LiveCounter/LiveMap. +**Spec requirement:** If data.objectId exists, resolve from pool. Return InternalLiveCounter/InternalLiveMap. ### Setup ```pseudo @@ -84,7 +84,7 @@ ASSERT root.get("profile").get("email").value() == "alice@example.com" |------|-------------| | 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`. +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/realtime_object.md` (RTO25a/RTO25b sections). ### Setup ```pseudo @@ -106,7 +106,7 @@ ASSERT root.size() == 7 |------|-------------| | 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`. +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/realtime_object.md` (RTO25a/RTO25b sections). ### Setup ```pseudo @@ -162,15 +162,15 @@ ASSERT "name" IN keys | Spec | Requirement | |------|-------------| -| RTLM20a3 | value parameter accepts Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounterValueType, or LiveMapValueType | +| RTLM20a3 | value parameter accepts Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap | | RTLM20e1 | Validates key and value per RTLMV4b and RTLMV4c | | RTLM20e2 | action set to MAP_SET | -| RTLM20e3 | objectId set to LiveMap's objectId | +| RTLM20e3 | objectId set to InternalLiveMap'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`. +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/realtime_object.md` (RTO26a/RTO26b/RTO26c sections). ### Setup ```pseudo @@ -249,13 +249,13 @@ ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": t --- -## RTLM20e7g - set() with LiveCounterValueType generates COUNTER_CREATE + MAP_SET +## RTLM20e7g - set() with LiveCounter generates COUNTER_CREATE + MAP_SET **Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` | Spec | Requirement | |------|-------------| -| RTLM20e7g1 | Evaluate LiveCounterValueType per RTLCV4 to generate COUNTER_CREATE ObjectMessage | +| RTLM20e7g1 | Evaluate LiveCounter 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 | @@ -283,13 +283,13 @@ ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId --- -## RTLM20e7g - set() with LiveMapValueType generates nested CREATE messages + MAP_SET +## RTLM20e7g - set() with LiveMap generates nested CREATE messages + MAP_SET **Test ID**: `objects/unit/RTLM20e7g/set-map-value-type-0` | Spec | Requirement | |------|-------------| -| RTLM20e7g1 | Evaluate LiveMapValueType per RTLMV4 to generate ordered list of ObjectMessages | +| RTLM20e7g1 | Evaluate LiveMap 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 | @@ -318,17 +318,17 @@ ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId --- -## RTLM20h1 - set() with nested LiveMapValueType containing LiveCounterValueType +## RTLM20h1 - set() with nested LiveMap containing LiveCounter **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 | +| RTLMV4d1 | Nested LiveCounter is evaluated per RTLCV4 | +| RTLMV4d2 | Nested LiveMap is recursively evaluated per RTLMV4 | -Tests that when a LiveMapValueType contains a nested LiveCounterValueType, all CREATE messages appear before the MAP_SET in depth-first order. +Tests that when a LiveMap contains a nested LiveCounter, all CREATE messages appear before the MAP_SET in depth-first order. ### Setup ```pseudo @@ -371,7 +371,7 @@ ASSERT state[2].operation.mapSet.value.objectId == state[1].operation.objectId | 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`. +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/realtime_object.md` (RTO26a/RTO26b/RTO26c sections). ### Setup ```pseudo @@ -398,7 +398,7 @@ ASSERT obj_msg.operation.mapRemove.key == "name" **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`. +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/realtime_object.md` (RTO26a/RTO26b/RTO26c sections). --- diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md index db1989b57..c08bd21f3 100644 --- a/uts/objects/unit/live_object_subscribe.md +++ b/uts/objects/unit/live_object_subscribe.md @@ -93,6 +93,7 @@ Tests that calling `unsubscribe()` on the returned `Subscription` deregisters th ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") updates = [] +control = [] instance = root.get("score").instance() sub = instance.subscribe((event) => updates.append(event)) ``` @@ -106,13 +107,19 @@ poll_until(updates.length >= 1, timeout: 5s) sub.unsubscribe() +# Per the Negative-assertion quiescence pattern (helpers/standard_test_pool.md): subscribe a +# control listener that WILL fire on the same dispatch as the message under test, then AWAIT it +# before asserting `updates` is unchanged. +sub_control = instance.subscribe((event) => control.append(event)) mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:score@1000", 10, "02", "remote") ])) +poll_until(control.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo +# Control delivered, so the unsubscribed listener would also have run had it still been registered. ASSERT updates.length == 1 ``` @@ -154,6 +161,7 @@ sub.unsubscribe() ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") updates = [] +control = [] instance = root.get("score").instance() instance.subscribe((event) => updates.append(event)) ``` @@ -165,15 +173,34 @@ mock_ws.send_to_client(build_object_message("test", [ ])) poll_until(updates.length >= 1, timeout: 5s) -# Serial "02" passes the newness check (RTLO4a6); the zero increment is the noop +# Serial "02" passes the newness check (RTLO4a6); an increment with no `number` is the noop (RTLC9h) +# Use a raw ObjectMessage with no `number` field so it exercises the real RTLC9h/RTLO4b4c1 noop branch +# (a `number: 0` would EXIST per RTLC9g and produce a non-noop update with amount 0). mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 0, "02", "remote") + ObjectMessage( + serial: "02", + siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) ])) +# Negative-assertion quiescence (helpers/standard_test_pool.md): drive a follow-up "03" increment +# and await it via a SEPARATE control listener (its own `control` array). Because "03" is dispatched +# after the noop "02" on the same channel, once the control fires the noop has certainly been +# processed. The control is kept separate so it does not inflate `updates`. +control_sub = instance.subscribe((event) => control.append(event)) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 3, "03", "remote") +])) +poll_until(control.length >= 1, timeout: 5s) +control_sub.unsubscribe() ``` ### Assertions ```pseudo -ASSERT updates.length == 1 +# The noop "02" produced no LiveObjectUpdate, so the original listener fired only for "01" and "03" +# → updates.length == 2. (The separate control listener only provides the quiescence barrier; had +# the noop wrongly fired, updates.length would be 3.) +ASSERT updates.length == 2 ``` --- @@ -203,7 +230,7 @@ ASSERT channel.state == state_before --- -## RTLO4b - subscribe on LiveMap receives LiveMapUpdate +## RTLO4b - subscribe on InternalLiveMap receives LiveMapUpdate **Test ID**: `objects/unit/RTLO4b/subscribe-map-update-0` @@ -248,6 +275,7 @@ Tests that when a tombstone update is emitted, all registered listeners are call { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") updates_a = [] updates_b = [] +control = [] instance = root.get("score").instance() instance.subscribe((event) => updates_a.append(event)) instance.subscribe((event) => updates_b.append(event)) @@ -259,7 +287,10 @@ instance.subscribe((event) => updates_b.append(event)) mock_ws.send_to_client(build_object_message("test", [ build_object_delete("counter:score@1000", "50", "remote") ])) +# Per the Negative-assertion quiescence pattern (helpers/standard_test_pool.md): for the +# multi-listener case, AWAIT ALL involved listeners on this dispatch before asserting either count. poll_until(updates_a.length >= 1, timeout: 5s) +poll_until(updates_b.length >= 1, timeout: 5s) # Both listeners should have received the tombstone update ASSERT updates_a.length == 1 @@ -267,14 +298,26 @@ ASSERT updates_a[0].message.operation.action == "OBJECT_DELETE" ASSERT updates_b.length == 1 ASSERT updates_b[0].message.operation.action == "OBJECT_DELETE" -# Send another update — listeners should have been deregistered by tombstone +# Send another update to the tombstoned object — the deregistered listeners must not fire. +# QUIESCENCE: a tombstoned object ignores further operations (RTLC7e), so neither the deregistered +# listeners nor a fresh listener on counter:score@1000 would ever fire — it cannot serve as a +# control. Use a SEPARATE LIVE object: subscribe a control listener to map:profile@1000 and drive an +# observable update on it AFTER the message under test. Messages are processed in order, so once the +# control fires, "51" has also been processed (helpers/standard_test_pool.md "Negative-assertion quiescence"). +control_inst = root.get("profile").instance() +control_inst.subscribe((event) => control.append(event)) mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:score@1000", 3, "51", "remote") ])) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "quiescence_probe", { string: "x" }, "52", "remote") +])) +poll_until(control.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo +# Control delivered, so any still-registered original listener would also have run. ASSERT updates_a.length == 1 ASSERT updates_b.length == 1 ``` diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md index 092fe6307..307085957 100644 --- a/uts/objects/unit/objects_pool.md +++ b/uts/objects/unit/objects_pool.md @@ -17,15 +17,15 @@ See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJE --- -## RTO3 - ObjectsPool initialization with root LiveMap +## RTO3 - ObjectsPool initialization with root InternalLiveMap **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" | +| RTO3b | Must always contain a InternalLiveMap with id "root" | +| RTO3b1 | On initialization, create zero-value InternalLiveMap with objectId "root" | ### Setup ```pseudo @@ -35,7 +35,7 @@ pool = ObjectsPool() ### Assertions ```pseudo ASSERT "root" IN pool -ASSERT pool["root"] IS LiveMap +ASSERT pool["root"] IS InternalLiveMap ASSERT pool["root"].data == {} ASSERT pool["root"].objectId == "root" ``` @@ -81,14 +81,14 @@ ASSERT pool.syncState == SYNCING | Spec | Requirement | |------|-------------| | RTO4b1 | Remove all objects except root | -| RTO4b2 | Clear root LiveMap data to zero-value | +| RTO4b2 | Clear root InternalLiveMap data to zero-value | | RTO4b2a | Emit LiveMapUpdate for root with removed entries, without populating objectMessage | | RTO4b4 | Perform sync completion actions | ### Setup ```pseudo pool = ObjectsPool() -pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"] = InternalLiveCounter(objectId: "counter:abc@1000") pool["root"].data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } @@ -265,7 +265,7 @@ ASSERT pool["root"].data["age"].data == { number: 30 } ### Setup ```pseudo pool = ObjectsPool() -pool["counter:old@1000"] = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"] = InternalLiveCounter(objectId: "counter:old@1000") pool["counter:old@1000"].data = 99 pool.processAttached(ProtocolMessage( action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS @@ -438,7 +438,7 @@ ASSERT pool.keys().length == 1 pool = ObjectsPool() realtime_object = RealtimeObject(pool: pool) pool.syncState = SYNCED -pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"] = InternalLiveCounter(objectId: "counter:abc@1000") pool["counter:abc@1000"].data = 10 realtime_object.appliedOnAckSerials = {"echo-serial-1"} ``` @@ -473,7 +473,7 @@ ASSERT "echo-serial-1" NOT IN realtime_object.appliedOnAckSerials pool = ObjectsPool() realtime_object = RealtimeObject(pool: pool) pool.syncState = SYNCED -pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"] = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Test Steps @@ -527,8 +527,8 @@ ASSERT pool.keys().length == 1 | Spec | Requirement | |------|-------------| | RTO6b1 | Parse type from objectId prefix before ":" | -| RTO6b2 | "map" prefix creates zero-value LiveMap | -| RTO6b3 | "counter" prefix creates zero-value LiveCounter | +| RTO6b2 | "map" prefix creates zero-value InternalLiveMap | +| RTO6b3 | "counter" prefix creates zero-value InternalLiveCounter | | RTO6a | Skip if object already exists | ### Setup @@ -550,11 +550,11 @@ pool.processObjectMessage(build_object_message("test", [ ### Assertions ```pseudo ASSERT "counter:new@2000" IN pool -ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"] IS InternalLiveCounter 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"] IS InternalLiveMap ASSERT pool["map:new@2000"].data["key"].data == { string: "val" } ``` @@ -928,7 +928,7 @@ ASSERT pool["root"].data["new_key"].data == { string: "new" } |------|-------------| | 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 | +| RTO5c10b | For each InternalLiveMap, 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. @@ -1000,7 +1000,7 @@ ASSERT pool["counter:nested@1000"].parentReferences == { "map:profile@1000": {"n | Spec | Requirement | |------|-------------| | RTO5c10a | Reset parentReferences to empty map before rebuilding | -| RTO5c10b | Rebuild from current LiveMap entries after sync completion | +| RTO5c10b | Rebuild from current InternalLiveMap 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. diff --git a/uts/objects/unit/parent_references.md b/uts/objects/unit/parent_references.md index 33d4d74e6..4ef97516d 100644 --- a/uts/objects/unit/parent_references.md +++ b/uts/objects/unit/parent_references.md @@ -9,9 +9,9 @@ Unit test — pure data structure, no mocks required. 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. +`parentReferences` is a `Dict>` keyed by parent InternalLiveMap objectId, with each value being the set of keys at which that InternalLiveMap 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. +Tests operate directly on LiveObject/InternalLiveCounter/InternalLiveMap instances and on ObjectsPool for the post-sync rebuild tests. ## Shared Helpers @@ -19,7 +19,7 @@ See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJE --- -## RTLO3f2 - parentReferences initialized to empty map on LiveCounter +## RTLO3f2 - parentReferences initialized to empty map on InternalLiveCounter **Test ID**: `objects/unit/RTLO3f2/init-empty-counter-0` @@ -27,7 +27,7 @@ See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJE ### Setup ```pseudo -counter = LiveCounter(objectId: "counter:abc@1000") +counter = InternalLiveCounter(objectId: "counter:abc@1000") ``` ### Assertions @@ -37,7 +37,7 @@ ASSERT counter.parentReferences == {} --- -## RTLO3f2 - parentReferences initialized to empty map on LiveMap +## RTLO3f2 - parentReferences initialized to empty map on InternalLiveMap **Test ID**: `objects/unit/RTLO3f2/init-empty-map-0` @@ -45,7 +45,7 @@ ASSERT counter.parentReferences == {} ### Setup ```pseudo -map = LiveMap(objectId: "map:abc@1000", semantics: "LWW") +map = InternalLiveMap(objectId: "map:abc@1000", semantics: "LWW") ``` ### Assertions @@ -65,8 +65,8 @@ ASSERT map.parentReferences == {} ### Setup ```pseudo -child = LiveCounter(objectId: "counter:child@1000") -parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent = InternalLiveMap(objectId: "map:parent@1000", semantics: "LWW") ``` ### Test Steps @@ -92,8 +92,8 @@ ASSERT child.parentReferences["map:parent@1000"] == {"score"} ### Setup ```pseudo -child = LiveCounter(objectId: "counter:child@1000") -parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent = InternalLiveMap(objectId: "map:parent@1000", semantics: "LWW") child.parentReferences = { "map:parent@1000": {"score"} } ``` @@ -113,13 +113,13 @@ ASSERT child.parentReferences["map:parent@1000"] == {"score", "points"} **Test ID**: `objects/unit/RTLO4g/different-parent-separate-entry-0` -**Spec requirement:** Each parent LiveMap gets its own entry in parentReferences. +**Spec requirement:** Each parent InternalLiveMap 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") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent_a = InternalLiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = InternalLiveMap(objectId: "map:b@1000", semantics: "LWW") ``` ### Test Steps @@ -144,9 +144,9 @@ ASSERT child.parentReferences["map:b@1000"] == {"y"} ### 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") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent_a = InternalLiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = InternalLiveMap(objectId: "map:b@1000", semantics: "LWW") ``` ### Test Steps @@ -173,8 +173,8 @@ ASSERT child.parentReferences["map:b@1000"] == {"p", "q"} ### Setup ```pseudo -child = LiveCounter(objectId: "counter:child@1000") -parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent = InternalLiveMap(objectId: "map:parent@1000", semantics: "LWW") ``` ### Test Steps @@ -199,8 +199,8 @@ ASSERT child.parentReferences == {} ### Setup ```pseudo -child = LiveCounter(objectId: "counter:child@1000") -parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent = InternalLiveMap(objectId: "map:parent@1000", semantics: "LWW") child.parentReferences = { "map:parent@1000": {"score", "points"} } ``` @@ -227,8 +227,8 @@ ASSERT child.parentReferences["map:parent@1000"] == {"points"} ### Setup ```pseudo -child = LiveCounter(objectId: "counter:child@1000") -parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent = InternalLiveMap(objectId: "map:parent@1000", semantics: "LWW") child.parentReferences = { "map:parent@1000": {"score"} } ``` @@ -253,8 +253,8 @@ ASSERT child.parentReferences == {} ### Setup ```pseudo -child = LiveCounter(objectId: "counter:child@1000") -parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child = InternalLiveCounter(objectId: "counter:child@1000") +parent = InternalLiveMap(objectId: "map:parent@1000", semantics: "LWW") child.parentReferences = { "map:parent@1000": {"score"} } ``` @@ -307,7 +307,7 @@ Tests that a LiveObject referenced directly from root at key "score" returns [[" ### Setup ```pseudo pool = ObjectsPool() -counter = LiveCounter(objectId: "counter:score@1000") +counter = InternalLiveCounter(objectId: "counter:score@1000") pool["counter:score@1000"] = counter root = pool["root"] @@ -336,15 +336,15 @@ Tests the path root --"profile"--> map:profile --"prefs"--> map:prefs --"theme_c pool = ObjectsPool() root = pool["root"] -profile = LiveMap(objectId: "map:profile@1000", semantics: "LWW") +profile = InternalLiveMap(objectId: "map:profile@1000", semantics: "LWW") pool["map:profile@1000"] = profile profile.addParentReference(root, "profile") -prefs = LiveMap(objectId: "map:prefs@1000", semantics: "LWW") +prefs = InternalLiveMap(objectId: "map:prefs@1000", semantics: "LWW") pool["map:prefs@1000"] = prefs prefs.addParentReference(profile, "prefs") -theme_counter = LiveCounter(objectId: "counter:theme@1000") +theme_counter = InternalLiveCounter(objectId: "counter:theme@1000") pool["counter:theme@1000"] = theme_counter theme_counter.addParentReference(prefs, "theme_counter") ``` @@ -374,15 +374,15 @@ Tests a diamond: root --"a"--> map:A --"x"--> counter:leaf, and root --"b"--> ma pool = ObjectsPool() root = pool["root"] -map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +map_a = InternalLiveMap(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") +map_b = InternalLiveMap(objectId: "map:b@1000", semantics: "LWW") pool["map:b@1000"] = map_b map_b.addParentReference(root, "b") -leaf = LiveCounter(objectId: "counter:leaf@1000") +leaf = InternalLiveCounter(objectId: "counter:leaf@1000") pool["counter:leaf@1000"] = leaf leaf.addParentReference(map_a, "x") leaf.addParentReference(map_b, "y") @@ -414,7 +414,7 @@ Tests that when a parent map references the same child at two different keys, tw pool = ObjectsPool() root = pool["root"] -child = LiveCounter(objectId: "counter:child@1000") +child = InternalLiveCounter(objectId: "counter:child@1000") pool["counter:child@1000"] = child child.addParentReference(root, "primary") child.addParentReference(root, "alias") @@ -440,7 +440,7 @@ ASSERT paths CONTAINS ["alias"] ```pseudo pool = ObjectsPool() -orphan = LiveCounter(objectId: "counter:orphan@1000") +orphan = InternalLiveCounter(objectId: "counter:orphan@1000") pool["counter:orphan@1000"] = orphan ``` @@ -468,11 +468,11 @@ Tests that a cycle in parentReferences does not cause infinite traversal. Graph: pool = ObjectsPool() root = pool["root"] -map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +map_a = InternalLiveMap(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") +map_b = InternalLiveMap(objectId: "map:b@1000", semantics: "LWW") pool["map:b@1000"] = map_b map_b.addParentReference(map_a, "b") @@ -508,19 +508,19 @@ Tests a graph where root has two branches that converge on a deeply nested objec pool = ObjectsPool() root = pool["root"] -map_l = LiveMap(objectId: "map:l@1000", semantics: "LWW") +map_l = InternalLiveMap(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") +map_r = InternalLiveMap(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") +map_m = InternalLiveMap(objectId: "map:m@1000", semantics: "LWW") pool["map:m@1000"] = map_m map_m.addParentReference(map_l, "mid") -target = LiveCounter(objectId: "counter:t@1000") +target = InternalLiveCounter(objectId: "counter:t@1000") pool["counter:t@1000"] = target target.addParentReference(map_m, "target") target.addParentReference(map_r, "target") @@ -536,16 +536,16 @@ ASSERT paths CONTAINS ["right", "target"] --- -## RTO5c10 - Post-sync rebuild populates parentReferences from LiveMap entries +## RTO5c10 - Post-sync rebuild populates parentReferences from InternalLiveMap 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 | +| RTO5c10b | For each InternalLiveMap, 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. +Tests that after a sync completes, parentReferences are rebuilt from the InternalLiveMap entries received during sync. ### Setup ```pseudo @@ -621,7 +621,7 @@ ASSERT nested.getFullPaths() CONTAINS ["profile", "nested"] | Spec | Requirement | |------|-------------| | RTO5c10a | For each LiveObject, reset parentReferences to the initial value (empty map) | -| RTO5c10b | Then rebuild from current LiveMap entries | +| RTO5c10b | Then rebuild from current InternalLiveMap entries | Tests that parentReferences from a previous sync are cleared and rebuilt from the new sync data, even when objects are reused across syncs. @@ -692,7 +692,7 @@ ASSERT paths.length == 1 **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. +**Spec requirement:** Objects that exist in the pool but are not referenced by any InternalLiveMap entry have empty parentReferences after rebuild. ### Setup ```pseudo @@ -725,7 +725,7 @@ pool.processObjectSync(build_object_sync_message("test", "sync1:", [ ```pseudo ASSERT pool.syncState == SYNCED -# The counter exists in the pool but no LiveMap entry points to it +# The counter exists in the pool but no InternalLiveMap entry points to it orphan = pool["counter:orphan@1000"] ASSERT orphan.parentReferences == {} diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md index 96d989754..4a94aad6e 100644 --- a/uts/objects/unit/path_object.md +++ b/uts/objects/unit/path_object.md @@ -170,7 +170,7 @@ ASSERT po.path() == "a\\.b.c" | Spec | Requirement | |------|-------------| | RTPO7a | Checks access API preconditions per RTO25 | -| RTPO7c | LiveCounter -> delegates to LiveCounter#value | +| RTPO7c | InternalLiveCounter -> delegates to InternalLiveCounter#value | ### Setup ```pseudo @@ -207,14 +207,14 @@ ASSERT root.get("active").value() == true --- -## RTPO7d - value() returns null for LiveMap +## RTPO7d - value() returns null for InternalLiveMap **Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` | Spec | Requirement | |------|-------------| | RTPO7a | Checks access API preconditions per RTO25 | -| RTPO7e | LiveMap -> returns null | +| RTPO7e | InternalLiveMap -> returns null | ### Setup ```pseudo @@ -304,8 +304,8 @@ ASSERT root.get("name").instance() == null | Spec | Requirement | |------|-------------| | 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) | +| RTPO9c | Uses InternalLiveMap#keys (RTLM12) to get keys, returns array of [key, PathObject] pairs | +| RTPO9d | Only non-tombstoned entries (tombstoned excluded by InternalLiveMap#keys) | ### Setup ```pseudo @@ -328,14 +328,14 @@ ASSERT entries.length == 7 --- -## RTPO9d - entries() returns empty array for non-LiveMap +## RTPO9d - entries() returns empty array for non-InternalLiveMap **Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` | Spec | Requirement | |------|-------------| | RTPO9a | Checks access API preconditions per RTO25 | -| RTPO9d | Not LiveMap or resolution failure -> returns empty array | +| RTPO9d | Not InternalLiveMap or resolution failure -> returns empty array | ### Setup ```pseudo @@ -361,7 +361,7 @@ ASSERT entries.length == 0 | Spec | Requirement | |------|-------------| | RTPO10a | Checks access API preconditions per RTO25 | -| RTPO10c | LiveMap -> delegates to LiveMap#keys (RTLM12) | +| RTPO10c | InternalLiveMap -> delegates to InternalLiveMap#keys (RTLM12) | ### Setup ```pseudo @@ -384,14 +384,14 @@ ASSERT "score" IN keys --- -## RTPO10d - keys() returns empty array for non-LiveMap +## RTPO10d - keys() returns empty array for non-InternalLiveMap **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 | +| RTPO10d | Not InternalLiveMap or resolution failure -> returns empty array | ### Setup ```pseudo @@ -418,7 +418,7 @@ ASSERT keys.length == 0 | Spec | Requirement | |------|-------------| | RTPO11a | Checks access API preconditions per RTO25 | -| RTPO11c | LiveMap -> uses LiveMap#keys (RTLM12) and returns array of PathObjects | +| RTPO11c | InternalLiveMap -> uses InternalLiveMap#keys (RTLM12) and returns array of PathObjects | ### Setup ```pseudo @@ -445,14 +445,14 @@ ASSERT paths["score"] == true --- -## RTPO11d - values() returns empty array for non-LiveMap +## RTPO11d - values() returns empty array for non-InternalLiveMap **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 | +| RTPO11d | Not InternalLiveMap or resolution failure -> returns empty array | ### Setup ```pseudo @@ -479,7 +479,7 @@ ASSERT vals.length == 0 | Spec | Requirement | |------|-------------| | RTPO12a | Checks access API preconditions per RTO25 | -| RTPO12c | LiveMap -> delegates to LiveMap#size (RTLM10) | +| RTPO12c | InternalLiveMap -> delegates to InternalLiveMap#size (RTLM10) | ### Setup ```pseudo @@ -494,14 +494,14 @@ ASSERT root.get("profile").size() == 3 --- -## RTPO12c - size() returns null for non-LiveMap +## RTPO12c - size() returns null for non-InternalLiveMap **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 | +| RTPO12d | Not InternalLiveMap or resolution failure -> returns null | ### Setup ```pseudo @@ -516,7 +516,7 @@ ASSERT root.get("name").size() == null --- -## RTPO13 - compact() recursively compacts LiveMap tree +## RTPO13 - compact() recursively compacts InternalLiveMap tree **Test ID**: `objects/unit/RTPO13/compact-recursive-0` @@ -524,8 +524,8 @@ ASSERT root.get("name").size() == null |------|-------------| | RTPO13a | Checks access API preconditions per RTO25 | | RTPO13c1 | Each entry included, tombstoned excluded | -| RTPO13c2 | Nested LiveMap recursively compacted | -| RTPO13c3 | Nested LiveCounter resolved to number | +| RTPO13c2 | Nested InternalLiveMap recursively compacted | +| RTPO13c3 | Nested InternalLiveCounter resolved to number | | RTPO13c4 | Primitives as-is | ### Setup @@ -553,9 +553,9 @@ ASSERT result["profile"]["prefs"]["theme"] == "dark" --- -## RTPO13b5 - compact() handles cycles via shared reference +## RTPO13c5 - compact() handles cycles via shared reference -**Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` +**Test ID**: `objects/unit/RTPO13c5/compact-cycle-detection-0` | Spec | Requirement | |------|-------------| @@ -583,14 +583,14 @@ ASSERT result["prefs"]["back_ref"] IS result --- -## RTPO13c - compact() returns number for LiveCounter +## RTPO13c - compact() returns number for InternalLiveCounter **Test ID**: `objects/unit/RTPO13c/compact-counter-0` | Spec | Requirement | |------|-------------| | RTPO13a | Checks access API preconditions per RTO25 | -| RTPO13d | LiveCounter -> returns numeric value | +| RTPO13d | InternalLiveCounter -> returns numeric value | ### Setup ```pseudo @@ -657,11 +657,11 @@ ASSERT root.get("profile").get("prefs").get("theme").value() == "dark" --- -## RTPO3a1 - Resolution fails if intermediate is not LiveMap +## RTPO3a1 - Resolution fails if intermediate is not InternalLiveMap **Test ID**: `objects/unit/RTPO3a1/intermediate-not-map-0` -**Spec requirement:** Current object must be a LiveMap. If not, resolution fails. +**Spec requirement:** Current object must be a InternalLiveMap. If not, resolution fails. ### Setup ```pseudo diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md index 43e8f2d59..7cfa97c11 100644 --- a/uts/objects/unit/path_object_mutations.md +++ b/uts/objects/unit/path_object_mutations.md @@ -15,7 +15,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct --- -## RTPO15 - set() delegates to LiveMap#set +## RTPO15 - set() delegates to InternalLiveMap#set **Test ID**: `objects/unit/RTPO15/set-delegates-to-map-0` @@ -23,7 +23,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct |------|-------------| | RTPO15b | Checks write API preconditions per RTO26 | | RTPO15c | Resolves path, on failure throws RTPO3c2 | -| RTPO15d | LiveMap -> delegates to LiveMap#set (RTLM20) | +| RTPO15d | InternalLiveMap -> delegates to InternalLiveMap#set (RTLM20) | ### Setup ```pseudo @@ -48,7 +48,7 @@ ASSERT root.get("name").value() == "Bob" | Spec | Requirement | |------|-------------| -| RTPO15a2 | value accepts same types as LiveMap#set (RTLM20): primitives and LiveCounterValueType/LiveMapValueType | +| RTPO15a2 | value accepts same types as InternalLiveMap#set (RTLM20): primitives and LiveCounter/LiveMap | | RTPO15b | Checks write API preconditions per RTO26 | ### Setup @@ -68,14 +68,14 @@ ASSERT root.get("profile").get("email").value() == "bob@example.com" --- -## RTPO15d - set() on non-LiveMap throws 92007 +## RTPO15d - set() on non-InternalLiveMap throws 92007 **Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` | Spec | Requirement | |------|-------------| | RTPO15b | Checks write API preconditions per RTO26 | -| RTPO15e | Not LiveMap -> throws 92007 | +| RTPO15e | Not InternalLiveMap -> throws 92007 | ### Setup ```pseudo @@ -94,7 +94,7 @@ ASSERT error.code == 92007 --- -## RTPO16 - remove() delegates to LiveMap#remove +## RTPO16 - remove() delegates to InternalLiveMap#remove **Test ID**: `objects/unit/RTPO16/remove-delegates-to-map-0` @@ -102,7 +102,7 @@ ASSERT error.code == 92007 |------|-------------| | RTPO16b | Checks write API preconditions per RTO26 | | RTPO16c | Resolves path, on failure throws RTPO3c2 | -| RTPO16d | LiveMap -> delegates to LiveMap#remove (RTLM21) | +| RTPO16d | InternalLiveMap -> delegates to InternalLiveMap#remove (RTLM21) | ### Setup ```pseudo @@ -121,14 +121,14 @@ ASSERT root.get("name").value() == null --- -## RTPO16d - remove() on non-LiveMap throws 92007 +## RTPO16d - remove() on non-InternalLiveMap throws 92007 **Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` | Spec | Requirement | |------|-------------| | RTPO16b | Checks write API preconditions per RTO26 | -| RTPO16e | Not LiveMap -> throws 92007 | +| RTPO16e | Not InternalLiveMap -> throws 92007 | ### Setup ```pseudo @@ -147,7 +147,7 @@ ASSERT error.code == 92007 --- -## RTPO17 - increment() delegates to LiveCounter#increment +## RTPO17 - increment() delegates to InternalLiveCounter#increment **Test ID**: `objects/unit/RTPO17/increment-delegates-to-counter-0` @@ -155,7 +155,7 @@ ASSERT error.code == 92007 |------|-------------| | RTPO17b | Checks write API preconditions per RTO26 | | RTPO17c | Resolves path, on failure throws RTPO3c2 | -| RTPO17d | LiveCounter -> delegates to LiveCounter#increment (RTLC12) | +| RTPO17d | InternalLiveCounter -> delegates to InternalLiveCounter#increment (RTLC12) | ### Setup ```pseudo @@ -200,14 +200,14 @@ ASSERT root.get("score").value() == 101 --- -## RTPO17d - increment() on non-LiveCounter throws 92007 +## RTPO17d - increment() on non-InternalLiveCounter throws 92007 **Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` | Spec | Requirement | |------|-------------| | RTPO17b | Checks write API preconditions per RTO26 | -| RTPO17e | Not LiveCounter -> throws 92007 | +| RTPO17e | Not InternalLiveCounter -> throws 92007 | ### Setup ```pseudo @@ -226,7 +226,7 @@ ASSERT error.code == 92007 --- -## RTPO18 - decrement() delegates to LiveCounter#decrement +## RTPO18 - decrement() delegates to InternalLiveCounter#decrement **Test ID**: `objects/unit/RTPO18/decrement-delegates-to-counter-0` @@ -234,7 +234,7 @@ ASSERT error.code == 92007 |------|-------------| | RTPO18b | Checks write API preconditions per RTO26 | | RTPO18c | Resolves path, on failure throws RTPO3c2 | -| RTPO18d | LiveCounter -> delegates to LiveCounter#decrement (RTLC13) | +| RTPO18d | InternalLiveCounter -> delegates to InternalLiveCounter#decrement (RTLC13) | ### Setup ```pseudo @@ -279,14 +279,14 @@ ASSERT root.get("score").value() == 99 --- -## RTPO18d - decrement() on non-LiveCounter throws 92007 +## RTPO18d - decrement() on non-InternalLiveCounter throws 92007 **Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` | Spec | Requirement | |------|-------------| | RTPO18b | Checks write API preconditions per RTO26 | -| RTPO18e | Not LiveCounter -> throws 92007 | +| RTPO18e | Not InternalLiveCounter -> throws 92007 | ### Setup ```pseudo diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md index ef89a6096..8e4b30e05 100644 --- a/uts/objects/unit/path_object_subscribe.md +++ b/uts/objects/unit/path_object_subscribe.md @@ -170,6 +170,11 @@ ASSERT error.code == 40003 { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] root.subscribe((event) => events.append(event), { depth: 1 }) +// Quiescence control: an unlimited-depth root listener that DOES cover the out-of-scope child path, +// so it fires on the send below and gives us a delivery to await (Negative-assertion quiescence, +// helpers/standard_test_pool.md). +control = [] +root.subscribe((event) => control.append(event)) ``` ### Test Steps @@ -179,9 +184,13 @@ mock_ws.send_to_client(build_object_message("test", [ ])) poll_until(events.length >= 1, timeout: 5s) +control_before = control.length mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:score@1000", 7, "100", "remote") ])) +// Negative-assertion quiescence: the unlimited-depth control covers ["score"], so await its delivery +// for this dispatch, THEN assert the depth-1 listener did NOT fire on the out-of-scope child update. +poll_until(control.length > control_before, timeout: 5s) ``` ### Assertions @@ -202,23 +211,36 @@ ASSERT events.length == 1 { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] root.subscribe((event) => events.append(event), { depth: 2 }) +// Quiescence control: an unlimited-depth root listener that covers the out-of-scope grandchild path, +// so it fires on the send below (Negative-assertion quiescence, helpers/standard_test_pool.md). +control = [] +root.subscribe((event) => control.append(event)) ``` ### Test Steps ```pseudo +// Self event (root map update) — candidate [] is covered at depth 2. 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) +// Child event (root["score"] counter) — candidate ["score"], relativeDepth 1-0+1 = 2 <= 2, covered. 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) +// Grandchild event (root["profile"]["nested_counter"] counter) — candidate ["profile","nested_counter"], +// relativeDepth 2-0+1 = 3 > 2, NOT covered. A COUNTER_INC yields ONLY this single candidate (no key +// candidate), unlike a MAP_SET on a child map which would also emit the covered parent-map path (RTO24b2a1). +control_before = control.length mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "101", "remote") + build_counter_inc("counter:nested@1000", 1, "101", "remote") ])) +// Negative-assertion quiescence: the unlimited-depth control covers ["profile","nested_counter"], so await +// its delivery for this dispatch, THEN assert the depth-2 listener did NOT fire on the grandchild update. +poll_until(control.length > control_before, timeout: 5s) ``` ### Assertions @@ -277,6 +299,10 @@ ASSERT events.length >= 3 { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] sub = root.get("score").subscribe((event) => events.append(event)) +// Quiescence control: a separate, still-subscribed listener on the same (live) object that WILL fire +// on the send below, giving a delivery to await (Negative-assertion quiescence, helpers/standard_test_pool.md). +control = [] +root.get("score").subscribe((event) => control.append(event)) ``` ### Test Steps @@ -287,6 +313,9 @@ sub.unsubscribe() mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:score@1000", 7, "99", "remote") ])) +// Negative-assertion quiescence: the separate control listener (still subscribed) fires on this +// dispatch; await it, THEN assert the unsubscribed listener did not fire. +poll_until(control.length >= 1, timeout: 5s) ``` ### Assertions @@ -575,32 +604,48 @@ ASSERT events.length >= 2 ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +// Seed a grandchild OBJECT under profile.prefs (path ["profile","prefs","deep"]) so the grandchild +// stimulus below can be a COUNTER_INC yielding ONLY that single depth-3 candidate. Sent BEFORE +// subscribing, so it does not fire the listener under test. (RTO6 zero-value-creates counter:deep@3000.) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "deep", { objectId: "counter:deep@3000" }, "50", "remote") +])) events = [] // 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 +// self (profile) -> eventPath=["profile"], 1 - 1 + 1 = 1 <= 2 yes +// child (profile.nested) -> eventPath=["profile","nested_counter"], 2 - 1 + 1 = 2 <= 2 yes +// grandchild (prefs.deep) -> eventPath=["profile","prefs","deep"], 3 - 1 + 1 = 3 > 2 no root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +// Quiescence control: an unlimited-depth root listener that covers the out-of-scope grandchild path, +// so it fires on the grandchild send below (Negative-assertion quiescence, helpers/standard_test_pool.md). +control = [] +root.subscribe((event) => control.append(event)) ``` ### Test Steps ```pseudo -// Self event (profile map update) +// Self event (profile map update) — first covered candidate is ["profile"]. 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) +// Child event (nested counter at ["profile","nested_counter"], relativeDepth 2) — covered. 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 +// Grandchild event (counter:deep at ["profile","prefs","deep"], relativeDepth 3) — should NOT be received. +// A COUNTER_INC yields ONLY this single depth-3 candidate (no shallower covered candidate, unlike a +// MAP_SET on map:prefs which would also emit the covered ["profile","prefs"] path per RTO24b2a1). +control_before = control.length mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") + build_counter_inc("counter:deep@3000", 1, "101", "remote") ])) +// Negative-assertion quiescence: the unlimited-depth control covers ["profile","prefs","deep"], so +// await its delivery for this dispatch, THEN assert the depth-2 listener did NOT fire on the grandchild. +poll_until(control.length > control_before, timeout: 5s) ``` ### Assertions @@ -626,6 +671,11 @@ Tests that a subscription at one path does not receive events for a sibling path { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") profile_events = [] root.get("profile").subscribe((event) => profile_events.append(event)) +// Control listener at root: fires on both out-of-scope sends below, providing a +// delivery to await on the same dispatch (Negative-assertion quiescence, +// helpers/standard_test_pool.md) before asserting profile_events is unchanged. +control_events = [] +root.subscribe((event) => control_events.append(event)) ``` ### Test Steps @@ -639,6 +689,9 @@ mock_ws.send_to_client(build_object_message("test", [ mock_ws.send_to_client(build_object_message("test", [ build_map_set("root", "name", { string: "Bob" }, "100", "remote") ])) +// QUIESCENCE: await the control listener (fires for both sends) so that any +// profile_events callback would also have run before we assert it is unchanged. +poll_until(control_events.length >= 2, timeout: 5s) ``` ### Assertions @@ -795,10 +848,21 @@ mock_ws.send_to_client(build_object_message("test", [ build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) + +// QUIESCENCE: a second, single-candidate dispatch acts as the control delivery +// (Negative-assertion quiescence, helpers/standard_test_pool.md). Awaiting it +// guarantees any spurious second callback from the first (multi-candidate) +// dispatch would already have run, so events.length == 2 confirms the first +// dispatch fired exactly once. +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:new@2000", 1, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) ``` ### Assertions ```pseudo -// Exactly one event per dispatch, even though multiple candidates match -ASSERT events.length == 1 +// Exactly one event per dispatch, even though multiple candidates match: +// one from the multi-candidate MAP_SET + one from the control increment. +ASSERT events.length == 2 ``` diff --git a/uts/objects/unit/public_object_message.md b/uts/objects/unit/public_object_message.md index 24373f1b1..aa5c01261 100644 --- a/uts/objects/unit/public_object_message.md +++ b/uts/objects/unit/public_object_message.md @@ -413,7 +413,7 @@ source_operation = { objectId: "map:derived@3000", semantics: "LWW", entries: { "x": { data: { number: 10 } } }, - _derivedFrom: derived_map_create + derivedFrom: derived_map_create // retained MapCreate per RTLMV4j5 (local-only; not a wire field name) } } ``` @@ -455,7 +455,7 @@ source_operation = { counterCreateWithObjectId: { objectId: "counter:derived@3000", count: 100, - _derivedFrom: derived_counter_create + derivedFrom: derived_counter_create // retained CounterCreate per RTLCV4g5 (local-only; not a wire field name) } } ``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 6ab753cd5..e73c3a5c9 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -21,7 +21,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_ch | Spec | Requirement | |------|-------------| -| RTO23d | Returns PathObject with path set to empty list and root set to root LiveMap | +| RTO23d | Returns PathObject with path set to empty list and root set to root InternalLiveMap | ### Setup ```pseudo @@ -75,15 +75,19 @@ ASSERT error.code == 40024 --- -## RTO23b - get() throws on DETACHED channel +## RTO23e - get() re-attaches a DETACHED channel (ensure-active-channel) -**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` +**Test ID**: `objects/unit/RTO23e/get-reattaches-detached-0` | Spec | Requirement | |------|-------------| -| RTO23b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | +| RTO23e | Performs the ensure-active-channel procedure (RTL33) on the underlying RealtimeChannel; if it fails, get() rejects with that ErrorInfo | +| RTL33b | A DETACHED channel is implicitly (re-)attached and get() waits for it to complete | -Tests that get() on a DETACHED channel throws 90001 per the RTO25 access API preconditions. +Tests that get() on a DETACHED channel no longer throws 90001 — per RTO23e it runs ensure-active-channel +(RTL33), which for a DETACHED channel performs an implicit attach (RTL33b); once the channel re-attaches and +re-syncs, get() resolves with the root PathObject. (Contrast RTO25b, where the *access* APIs — value/keys/ +subscribe — still throw 90001 on DETACHED/FAILED; see the RTO25b sections below.) ### Setup ```pseudo @@ -118,13 +122,15 @@ AWAIT channel.object.get() AWAIT channel.detach() AWAIT_STATE channel.state == DETACHED -AWAIT channel.object.get() FAILS WITH error +// get() on a DETACHED channel triggers ensure-active-channel (RTL33b) -> implicit re-attach -> resolves +root = AWAIT channel.object.get() ``` ### Assertions ```pseudo -ASSERT error.code == 90001 -ASSERT error.statusCode == 400 +ASSERT root IS PathObject +ASSERT root.path == [] +ASSERT channel.state == ATTACHED ``` --- @@ -375,6 +381,11 @@ mock_ws.send_to_client(ProtocolMessage( inc_future = root.get("score").increment(10) +# Per RTO20e the write must WAIT for the sync to reach SYNCED: while still +# SYNCING the increment must not have applied yet. +ASSERT inc_future IS NOT complete +ASSERT root.get("score").value() == 100 + mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) AWAIT inc_future @@ -387,9 +398,9 @@ ASSERT root.get("score").value() == 110 --- -## RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait +## RTO20e1 - publishAndApply fails when channel enters DETACHED during sync wait -**Test ID**: `objects/unit/RTO20e1/fails-on-channel-failed-0` +**Test ID**: `objects/unit/RTO20e1/fails-on-channel-detached-0` **Spec requirement:** If channel enters DETACHED/SUSPENDED/FAILED while waiting, fail with 92008. @@ -586,97 +597,131 @@ ASSERT error.code == 40024 --- -## RTO25a - Access API precondition requires OBJECT_SUBSCRIBE mode +## RTO23e - get() on a FAILED channel rejects with 90001 (ensure-active-channel) -**Test ID**: `objects/unit/RTO25a/access-requires-subscribe-mode-0` +**Test ID**: `objects/unit/RTO23e/get-rejects-failed-0` | Spec | Requirement | |------|-------------| -| RTO25a | Require OBJECT_SUBSCRIBE channel mode per RTO2 | +| RTO23e | Performs ensure-active-channel (RTL33) on the underlying RealtimeChannel; if it fails, get() rejects with that ErrorInfo | +| RTL33c | A FAILED channel causes ensure-active-channel to throw ErrorInfo with statusCode 400 and code 90001 | -Tests that a read operation (e.g. PathObject value()) without OBJECT_SUBSCRIBE mode throws error 40024. +Tests that get() on a FAILED channel rejects with 90001 — per RTO23e, ensure-active-channel (RTL33) is run, and +for a FAILED channel RTL33c throws 90001. (The 90001 assertion is unchanged from the old RTO25b framing; only the +governing clause moved from RTO25b to RTO23e/RTL33c, since get() is gated by RTO23e, not the access-API RTO25.) ### 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 + 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, - modes: ["OBJECT_PUBLISH"] + action: ERROR, channel: msg.channel, + error: { code: 90000, statusCode: 400, message: "Channel error" } )) - 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"] }) +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 == 40024 +ASSERT error.code == 90001 ASSERT error.statusCode == 400 ``` --- -## RTO25b - Access API precondition throws on DETACHED channel +## RTO25a - Access API precondition requires OBJECT_SUBSCRIBE mode -**Test ID**: `objects/unit/RTO25b/access-throws-detached-0` +**Test ID**: `objects/unit/RTO25a/access-requires-subscribe-mode-0` | Spec | Requirement | |------|-------------| -| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | +| RTO25a | Require OBJECT_SUBSCRIBE channel mode per RTO2 | -Tests that calling get() on a DETACHED channel throws 90001. +Tests that the access path requires OBJECT_SUBSCRIBE — without it, obtaining/using the objects API 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" + 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 + flags: HAS_OBJECTS, + modes: ["OBJECT_PUBLISH"] )) 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"] }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) ``` ### Test Steps ```pseudo -// Attach, sync, then detach to get channel into DETACHED state -AWAIT channel.object.get() -AWAIT channel.detach() +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 an access method (`keys()`) on a DETACHED channel throws 90001. The `root` PathObject is obtained +while the channel is ATTACHED (with OBJECT_SUBSCRIBE granted); the channel is then detached, and the subsequent +read trips the RTO25b state precondition. (Contrast `get()`, which re-attaches per RTO23e.) + +### 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 channel.object.get() FAILS WITH error +root.keys() FAILS WITH error ``` ### Assertions @@ -695,36 +740,24 @@ ASSERT error.statusCode == 400 |------|-------------| | 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. +Tests that an access method (`keys()`) on a FAILED channel throws 90001. `root` is obtained while ATTACHED, the +channel is then forced to FAILED, and the subsequent read trips the RTO25b state precondition. ### 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"] }) +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") ``` ### Test Steps ```pseudo -// Trigger attach which will fail, putting channel into FAILED state -channel.attach() +// 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 channel.object.get() FAILS WITH error +root.keys() FAILS WITH error ``` ### Assertions @@ -925,9 +958,11 @@ score_path.subscribe((event) => events_score.append(event)) ### Test Steps ```pseudo -// Trigger an update on the score counter +// Trigger an update on the score counter. siteCode "remote" is absent from the pool's +// siteTimeserials ({"aaa":"t:0"}), so the op passes the newness check (RTLO4a) — "aaa" with an +// "s:N" serial would be stale ("s" < "t") and the op would be silently dropped, hanging the poll. mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 5, "s:1", "aaa") + build_counter_inc("counter:score@1000", 5, "s:1", "remote") ])) poll_until(events_score.length >= 1, timeout: 5s) @@ -959,7 +994,8 @@ Tests that a subscription with a depth constraint only receives events within th shallow_events = [] deep_events = [] -// Subscribe at root with depth 1 — covers root and immediate children only +// Subscribe at root with depth 1 — per RTO24c2b this covers ONLY root's own path ([]), +// NOT its children (a child like ["score"] is relativeDepth 1-0+1 = 2 > 1). root.subscribe({ depth: 1 }, (event) => shallow_events.append(event)) // Subscribe at root with no depth limit — covers everything @@ -968,22 +1004,29 @@ root.subscribe((event) => deep_events.append(event)) ### Test Steps ```pseudo -// Update a direct child of root (path ["score"]) — depth 1 from root +// Update root itself (a MAP_SET on root — candidate path [] is covered by depth 1). +// siteCode "remote" is absent from the pool's siteTimeserials ({"aaa":"t:0"}), so the op passes the +// newness check (RTLO4a / _canApplyOperation) — using "aaa" with an "s:N" serial would be stale ("s" < "t"). mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 5, "s:1", "aaa") + build_map_set("root", "name", { string: "Bob" }, "s:1", "remote") ])) poll_until(deep_events.length >= 1, timeout: 5s) -// Update a nested object (path ["profile", "nested_counter"]) — depth 2 from root +// Update a child of root (path ["score"], relativeDepth 2) — NOT covered by depth 1, covered by deep. mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:nested@1000", 1, "s:2", "aaa") + build_counter_inc("counter:score@1000", 5, "s:2", "remote") ])) poll_until(deep_events.length >= 2, timeout: 5s) + +// Negative-assertion quiescence: the shallow listener fired exactly once on the FIRST dispatch +// (the root self-update at []) and must NOT fire on the second (child ["score"]) dispatch. The deep +// listener is the control that fires on both; poll the shallow listener too so its count isn't racing. +poll_until(shallow_events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -// Shallow subscription (depth 1) only sees the direct child update +// Shallow subscription (depth 1) only sees the root self-update, not the child update ASSERT shallow_events.length == 1 // Deep subscription (no depth limit) sees both updates @@ -1061,15 +1104,16 @@ ASSERT score_after_echo == 110 | Spec | Requirement | |------|-------------| | RTO20f | Apply with source LOCAL | -| RTLC7c2 | LOCAL source does not update siteTimeserials | +| RTLC7c | siteTimeserials written only for CHANNEL source (verified via the source=LOCAL complement) | Verified through observable behaviour: after a local increment (applied via ACK with source LOCAL), an inbound COUNTER_INC from the same siteCode and serial as the ACK should still apply. If LOCAL had incorrectly written to siteTimeserials, the newness check would reject the inbound message as stale. -The mock's ACK serial for the first publish is `"t:1:0"` with siteCode `"test"` -(from ConnectionDetails). The inbound message reuses that siteCode and serial. +The mock's ACK serial for the first publish is `ack_serial(0, 0)` (= "ack-0:0") +with siteCode `SITE_CODE` (= "test-site", from ConnectionDetails). The inbound +message reuses that siteCode and serial. ### Setup ```pseudo @@ -1081,11 +1125,11 @@ The mock's ACK serial for the first publish is `"t:1:0"` with siteCode `"test"` AWAIT root.get("score").increment(10) ASSERT root.get("score").value() == 110 -# Send inbound COUNTER_INC from siteCode "test" with serial "t:1:0" +# Send inbound COUNTER_INC from siteCode SITE_CODE with serial ack_serial(0, 0) # (same siteCode and serial as the ACK). If LOCAL incorrectly set -# siteTimeserials["test"] = "t:1:0", this would fail the newness check. +# siteTimeserials[SITE_CODE] = ack_serial(0, 0), this would fail the newness check. mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 10, "t:1:0", "test") + build_counter_inc("counter:score@1000", 10, ack_serial(0, 0), SITE_CODE) ])) poll_until(root.get("score").value() == 120, timeout: 5s) ``` @@ -1153,11 +1197,11 @@ mock_ws.send_to_client(ProtocolMessage( mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) ASSERT root.get("score").value() == 100 -// Replay the same serial ("t:1:0") that was used for apply-on-ACK. +// Replay the same serial (ack_serial(0, 0)) that was used for apply-on-ACK. // If appliedOnAckSerials was cleared, this applies normally. // If NOT cleared, dedup (RTO9a3) would reject it and score stays 100. mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 10, "t:1:0", "test") + build_counter_inc("counter:score@1000", 10, ack_serial(0, 0), SITE_CODE) ])) poll_until(root.get("score").value() == 110, timeout: 5s) ``` @@ -1328,6 +1372,37 @@ ASSERT root.get("score").value() == null scenarios = [ { name: "initial attach", + // Fixture requirement: a genuine FIRST attach can only be observed on a + // fresh, NON-synced channel with the SYNCING/SYNCED listeners registered + // BEFORE attach(). setup_synced_channel() returns an already-ATTACHED+SYNCED + // channel, so attach() would be a no-op and the first transitions would have + // already fired before any listener could be attached. This scenario therefore + // provides its own setup that builds an unattached channel and wires the + // listeners up front; the shared loop honours scenario.setup when present. + setup: () => { + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "conn-key-1", + siteCode: SITE_CODE, 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("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + // NOTE: channel is NOT yet attached/synced here — listeners must be wired + // by the loop before scenario.trigger() calls attach(). + RETURN { client, channel, mock_ws } + }, trigger: () => { channel.attach() }, @@ -1361,12 +1436,20 @@ scenarios = [ action: ATTACHED, channel: "test", channelSerial: "sync4:", flags: 0 )) }, - expected_events: ["SYNCED"] + // RTO4c transitions the (currently SYNCED) sync state to SYNCING for ANY ATTACHED → emits SYNCING; + // RTO4b (no HAS_OBJECTS) then completes the sync immediately via RTO4b4 → emits SYNCED. + expected_events: ["SYNCING", "SYNCED"] } ] FOR scenario IN scenarios: - { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + // Scenarios that need a fresh (non-synced) fixture provide their own setup so + // listeners can be registered BEFORE the first attach; the rest reuse the + // standard already-synced channel and register listeners after setup. + IF scenario.setup IS PRESENT: + { client, channel, mock_ws } = scenario.setup() + ELSE: + { 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")) diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md index 3a4033a8c..0418c3503 100644 --- a/uts/objects/unit/value_types.md +++ b/uts/objects/unit/value_types.md @@ -7,7 +7,7 @@ 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 evaluated by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). +Tests `LiveCounter` and `LiveMap` — 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`). --- @@ -18,7 +18,7 @@ Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints cre | Spec | Requirement | |------|-------------| | RTLCV3a1 | Accepts optional initialCount | -| RTLCV3b | Returns LiveCounterValueType with internal count | +| RTLCV3b | Returns LiveCounter with internal count | | RTLCV3d | Returned value is immutable | ### Test Steps @@ -28,7 +28,7 @@ vt = LiveCounter.create(42) ### Assertions ```pseudo -ASSERT vt IS LiveCounterValueType +ASSERT vt IS LiveCounter ASSERT vt.count == 42 ``` @@ -155,7 +155,7 @@ ASSERT msg.operation.counterCreate.count == 0 | Spec | Requirement | |------|-------------| | RTLMV3a1 | Accepts optional entries dict | -| RTLMV3b | Returns LiveMapValueType with internal entries | +| RTLMV3b | Returns LiveMap with internal entries | | RTLMV3d | Returned value is immutable | ### Test Steps @@ -168,7 +168,7 @@ vt = LiveMap.create({ ### Assertions ```pseudo -ASSERT vt IS LiveMapValueType +ASSERT vt IS LiveMap ASSERT vt.entries["name"] == "Alice" ASSERT vt.entries["age"] == 30 ``` @@ -188,7 +188,7 @@ vt = LiveMap.create() ### Assertions ```pseudo -ASSERT vt IS LiveMapValueType +ASSERT vt IS LiveMap ``` --- @@ -291,8 +291,8 @@ ASSERT entries["json_obj"].data.json == { "key": "value" } | Spec | Requirement | |------|-------------| -| RTLMV4d1 | LiveCounterValueType evaluated, ObjectMessage collected, objectId set | -| RTLMV4d2 | LiveMapValueType recursively evaluated, all ObjectMessages collected | +| RTLMV4d1 | LiveCounter evaluated, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMap recursively evaluated, all ObjectMessages collected | | RTLMV4k | Return depth-first order: inner creates before outer | ### Test Steps