|
| 1 | +--- |
| 2 | +description: "Translate a UTS pseudocode test spec into Kotlin tests in the uts module. Usage: /uts-to-kotlin <path-to-spec-file>" |
| 3 | +allowed-tools: Bash, Read, Edit, Write |
| 4 | +--- |
| 5 | + |
| 6 | +You are translating a UTS pseudocode test spec file into a runnable Kotlin test in the `uts` module. Follow these steps in order. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## Step 1 — Read the spec |
| 11 | + |
| 12 | +Read the file at `$ARGUMENTS`. Identify: |
| 13 | +- All test cases (each has an ID like `RTN4a`, `RSC1`, etc. and a description) |
| 14 | +- The protocol/transport used (WebSocket for Realtime, HTTP for REST) |
| 15 | +- Any timer usage (`enable_fake_timers`, `ADVANCE_TIME`) |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## Step 2 — Determine output path and package |
| 20 | + |
| 21 | +Map the spec path to a test path: |
| 22 | + |
| 23 | +| Spec location | Test location | |
| 24 | +|---|---| |
| 25 | +| `.../uts/test/rest/unit/<name>.md` | `uts/src/test/kotlin/io/ably/lib/rest/unit/<Name>Test.kt` | |
| 26 | +| `.../uts/test/realtime/unit/<sub>/<name>.md` | `uts/src/test/kotlin/io/ably/lib/realtime/unit/<sub>/<Name>Test.kt` | |
| 27 | + |
| 28 | +Class name: take the file name, strip `_test` suffix, convert `snake_case` → `PascalCase`, append `Test`. |
| 29 | + |
| 30 | +Example: `connection_state_machine_test.md` → `ConnectionStateMachineTest` |
| 31 | + |
| 32 | +Package: derived from the output path under `kotlin/`. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## Step 3 — Read mock infrastructure files |
| 37 | + |
| 38 | +Read ALL of these before generating any code (you need exact method signatures): |
| 39 | + |
| 40 | +``` |
| 41 | +uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt |
| 42 | +uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt |
| 43 | +uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt |
| 44 | +uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt |
| 45 | +uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt |
| 46 | +uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt |
| 47 | +``` |
| 48 | + |
| 49 | +--- |
| 50 | + |
| 51 | +## Step 4 — Generate the Kotlin test file |
| 52 | + |
| 53 | +Apply the translation rules below, then write the file. |
| 54 | + |
| 55 | +### Client construction |
| 56 | + |
| 57 | +| Pseudocode | Kotlin | |
| 58 | +|---|---| |
| 59 | +| `Rest(options: ClientOptions(key: "..."))` | `AblyRest(DebugOptions("..."))` | |
| 60 | +| `Realtime(options: ClientOptions(key: "...", autoConnect: false))` | `DebugOptions("...").apply { autoConnect = false }.let { AblyRealtime(it) }` | |
| 61 | +| `ClientOptions(token: "...", autoConnect: false)` | `DebugOptions().apply { token = "..."; autoConnect = false }` | |
| 62 | + |
| 63 | +### Mock setup — CRITICAL |
| 64 | + |
| 65 | +The pseudocode uses callback-style (`onConnectionAttempt: (conn) => {...}`) but Kotlin mocks use **coroutine await-style**. Each callback body becomes a `launch { ... }` block started **before** the SDK client is created or connected. |
| 66 | + |
| 67 | +| Pseudocode | Kotlin | |
| 68 | +|---|---| |
| 69 | +| `mock_http = MockHttpClient(...)` + `install_mock(mock_http)` | `val mock = MockHttpClient(); mock.installOn(options)` | |
| 70 | +| `mock_ws = MockWebSocket(...)` + `install_mock(mock_ws)` | `val mock = MockWebSocket(); mock.installOn(options)` | |
| 71 | +| `onConnectionAttempt: (conn) => { conn.respond_with_success() }` | `launch { val conn = mock.awaitConnectionAttempt(); conn.respondWithSuccess() }` | |
| 72 | +| `onRequest: (req) => { req.respond_with(200, body) }` | `launch { val req = mock.awaitRequest(); req.respondWith(200, body) }` | |
| 73 | +| Repeated connection attempts | `launch { repeat(N) { val conn = mock.awaitConnectionAttempt(); conn.respondWithRefused() } }` | |
| 74 | +| `enable_fake_timers()` | `val clock = FakeClock(); options.clock = clock` (before client construction) | |
| 75 | + |
| 76 | +### Connection/request actions |
| 77 | + |
| 78 | +| Pseudocode | Kotlin | |
| 79 | +|---|---| |
| 80 | +| `conn.respond_with_success()` | `conn.respondWithSuccess()` | |
| 81 | +| `conn.respond_with_refused()` | `conn.respondWithRefused()` | |
| 82 | +| `conn.respond_with_timeout()` | `conn.respondWithTimeout()` | |
| 83 | +| `conn.respond_with_dns_error()` | `conn.respondWithDnsError()` | |
| 84 | +| `conn.send_to_client(msg)` | `mock.sendToClient(msg)` (after `respondWithSuccess()`) | |
| 85 | +| `conn.send_to_client_and_close(msg)` | `mock.sendToClientAndClose(msg)` | |
| 86 | +| `mock_ws.simulate_disconnect()` | `mock.simulateDisconnect()` | |
| 87 | +| `req.respond_with(200, {...})` | `req.respondWith(200, mapOf(...))` | |
| 88 | +| `req.respond_with_timeout()` | `req.respondWithTimeout()` | |
| 89 | + |
| 90 | +### Protocol messages and types |
| 91 | + |
| 92 | +| Pseudocode | Kotlin | |
| 93 | +|---|---| |
| 94 | +| `ProtocolMessage(action: CONNECTED, ...)` | `ProtocolMessage().apply { action = ProtocolMessage.Action.connected; ... }` | |
| 95 | +| `CONNECTED` / `DISCONNECTED` / `ERROR` / `HEARTBEAT` / `ATTACH` / `DETACHED` | `.connected` / `.disconnected` / `.error` / `.heartbeat` / `.attach` / `.detached` | |
| 96 | +| `ErrorInfo(code: X, statusCode: Y, message: "...")` | `ErrorInfo("...", X, Y)` | |
| 97 | +| `ConnectionDetails(connectionKey: ..., maxIdleInterval: ..., connectionStateTtl: ...)` | `ConnectionDetails().apply { connectionKey = "..."; maxIdleInterval = ...; connectionStateTtl = ... }` | |
| 98 | +| `ConnectionState.connected` etc. | `ConnectionState.connected`, `.disconnected`, `.suspended`, `.failed`, `.connecting`, `.closing`, `.closed` | |
| 99 | + |
| 100 | +### Awaiting state |
| 101 | + |
| 102 | +`AWAIT_STATE client.connection.state == ConnectionState.X WITH timeout: N seconds` → call the `awaitState()` helper (included in the file template below): |
| 103 | + |
| 104 | +```kotlin |
| 105 | +awaitState(client, ConnectionState.x, timeoutMs = N * 1000L) |
| 106 | +``` |
| 107 | + |
| 108 | +### Timer control |
| 109 | + |
| 110 | +| Pseudocode | Kotlin | |
| 111 | +|---|---| |
| 112 | +| `enable_fake_timers()` | `val clock = FakeClock()` then `options.clock = clock` | |
| 113 | +| `ADVANCE_TIME(ms)` | `clock.advance(ms)` | |
| 114 | + |
| 115 | +After `clock.advance()`, always yield to let the SDK's timer callbacks dispatch: |
| 116 | + |
| 117 | +```kotlin |
| 118 | +clock.advance(30_000) |
| 119 | +yield() |
| 120 | +``` |
| 121 | + |
| 122 | +### Assertions |
| 123 | + |
| 124 | +| Pseudocode | Kotlin | |
| 125 | +|---|---| |
| 126 | +| `ASSERT x == y` | `assertEquals(y, x)` | |
| 127 | +| `ASSERT x IS NOT null` | `assertNotNull(x)` | |
| 128 | +| `ASSERT x IS null` | `assertNull(x)` | |
| 129 | +| `ASSERT x IS Auth` | `assertIs<Auth>(x)` | |
| 130 | +| `ASSERT "key" IN map` | `assertContains(map, "key")` | |
| 131 | +| `ASSERT x matches pattern "..."` | `assertTrue(x.matches(Regex("...")))` | |
| 132 | +| `ASSERT list CONTAINS_IN_ORDER [a, b, c]` | `val it = list.iterator(); assertEquals(a, it.next()); assertEquals(b, it.next()); ...` | |
| 133 | +| `AWAIT expr FAILS WITH error` | `val error = assertFailsWith<AblyException> { expr }; assertEquals(..., error.errorInfo.code)` | |
| 134 | +| `ASSERT list.length == N` | `assertEquals(N, list.size)` | |
| 135 | + |
| 136 | +### Test naming |
| 137 | + |
| 138 | +- Method name: backtick string `` `<spec-id> - <description>` `` |
| 139 | +- Add `// UTS: <test-id>` comment on the line immediately above `@Test` |
| 140 | +- Use `runTest { }` from `kotlinx.coroutines.test` for all async tests |
| 141 | + |
| 142 | +### File template |
| 143 | + |
| 144 | +```kotlin |
| 145 | +package io.ably.lib.<category>.unit[.<subcategory>] |
| 146 | + |
| 147 | +import io.ably.lib.debug.DebugOptions |
| 148 | +import io.ably.lib.realtime.AblyRealtime // or AblyRest for REST tests |
| 149 | +import io.ably.lib.realtime.ConnectionState |
| 150 | +import io.ably.lib.realtime.ConnectionStateListener |
| 151 | +import io.ably.lib.test.mock.FakeClock |
| 152 | +import io.ably.lib.test.mock.MockWebSocket // or MockHttpClient |
| 153 | +import io.ably.lib.types.ProtocolMessage |
| 154 | +import io.ably.lib.types.ErrorInfo |
| 155 | +import kotlinx.coroutines.launch |
| 156 | +import kotlinx.coroutines.suspendCancellableCoroutine |
| 157 | +import kotlinx.coroutines.test.runTest |
| 158 | +import kotlinx.coroutines.withTimeout |
| 159 | +import kotlinx.coroutines.yield |
| 160 | +import kotlin.coroutines.resume |
| 161 | +import kotlin.test.* |
| 162 | + |
| 163 | +class <Name>Test { |
| 164 | + |
| 165 | + @AfterTest |
| 166 | + fun tearDown() { |
| 167 | + // close any clients opened in each test (declare them at test scope, not class scope) |
| 168 | + } |
| 169 | + |
| 170 | + // UTS: <test-id> |
| 171 | + @Test |
| 172 | + fun `<spec-id> - <description>`() = runTest { |
| 173 | + val mock = MockWebSocket() |
| 174 | + val options = DebugOptions("appId.keyId:keySecret").apply { |
| 175 | + autoConnect = false |
| 176 | + mock.installOn(this) |
| 177 | + } |
| 178 | + |
| 179 | + launch { |
| 180 | + val conn = mock.awaitConnectionAttempt() |
| 181 | + conn.respondWithSuccess() |
| 182 | + mock.sendToClient(ProtocolMessage().apply { |
| 183 | + action = ProtocolMessage.Action.connected |
| 184 | + connectionId = "test-connection-id" |
| 185 | + connectionKey = "test-key" |
| 186 | + }) |
| 187 | + } |
| 188 | + |
| 189 | + val client = AblyRealtime(options) |
| 190 | + client.connect() |
| 191 | + awaitState(client, ConnectionState.connected) |
| 192 | + |
| 193 | + assertEquals(ConnectionState.connected, client.connection.state) |
| 194 | + client.close() |
| 195 | + } |
| 196 | + |
| 197 | + private suspend fun awaitState( |
| 198 | + client: AblyRealtime, |
| 199 | + target: ConnectionState, |
| 200 | + timeoutMs: Long = 5000 |
| 201 | + ) { |
| 202 | + if (client.connection.state == target) return |
| 203 | + withTimeout(timeoutMs) { |
| 204 | + suspendCancellableCoroutine { cont -> |
| 205 | + val listener = ConnectionStateListener { change -> |
| 206 | + if (change.current == target && cont.isActive) cont.resume(Unit) |
| 207 | + } |
| 208 | + client.connection.on(listener) |
| 209 | + cont.invokeOnCancellation { client.connection.off(listener) } |
| 210 | + } |
| 211 | + } |
| 212 | + } |
| 213 | +} |
| 214 | +``` |
| 215 | + |
| 216 | +--- |
| 217 | + |
| 218 | +## Step 5 — Compile |
| 219 | + |
| 220 | +```bash |
| 221 | +./gradlew :uts:compileTestKotlin |
| 222 | +``` |
| 223 | + |
| 224 | +Fix any compilation errors and recompile until clean. Common issues: |
| 225 | +- Missing imports (add them) |
| 226 | +- Method names differ from what you read in the mock files (use the exact names you read) |
| 227 | +- `Scheduled` is a top-level class in `FakeClock`, not nested inside `FakeNamedTimer` |
| 228 | + |
| 229 | +--- |
| 230 | + |
| 231 | +## Step 6 — Run tests |
| 232 | + |
| 233 | +```bash |
| 234 | +./gradlew :uts:test --tests "<package>.<ClassName>Test" |
| 235 | +``` |
| 236 | + |
| 237 | +Handle test failures: |
| 238 | + |
| 239 | +1. **UTS spec error** (pseudocode itself is wrong): fix the test to match what the spec intends, add a `// NOTE: spec pseudocode had X, corrected to Y` comment. |
| 240 | +2. **Translation error** (you misread the pseudocode): fix silently. |
| 241 | +3. **SDK deviation** (confirmed against `uts/spec/features.md` — SDK does not comply): |
| 242 | + - Wrap the failing assertion in an env gate: |
| 243 | + ```kotlin |
| 244 | + if (System.getenv("RUN_DEVIATIONS") != null) { |
| 245 | + assertEquals(specCorrectValue, actualValue) |
| 246 | + } |
| 247 | + ``` |
| 248 | + - Add a comment explaining the deviation. |
| 249 | + - Append an entry to `uts/src/test/kotlin/io/ably/lib/deviations.md`: |
| 250 | + - Spec point, what spec requires, what SDK does, which test is affected. |
0 commit comments