diff --git a/CHANGELOG.md b/CHANGELOG.md index 2434f40..d2eba07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- NetworkMock: an operation can now be configured as a **sequence** — an ordered list of + responses it advances through one step per matched request, sticking on the last step once + exhausted rather than looping back to the start. Useful for polling flows (order status, + upload progress, async job completion) where the interesting behavior is the transition + across repeated calls. Build one from the operation sheet's new "SEQUENCE" section: tap + "Build a Sequence", tap responses in the desired order, then "Save Sequence"; "Reset + Position" restarts at step 1 without leaving the sequence. `OperationMockState` gains a + `Sequence(responses: List, currentIndex: Int)` variant (**breaking**: another new + sealed subtype); the position is persisted as part of this same state, so resetting an + operation to `Network` discards it with no special-casing needed. + `NetworkMockViewModel` gains `setOperationSequenceState`/`resetOperationSequencePosition`. + (`devview-networkmock-core`, `devview-networkmock-ktor`, `devview-networkmock`, #96) - NetworkMock: an operation can now simulate a network failure instead of returning a response, two ways — **deterministically**, by selecting Timeout or Connection Refused in the operation sheet's picker page (a new "Simulate Failure" section, alongside the response variants); or diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index 69c85b5..a16487a 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -239,6 +239,21 @@ package com.worldline.devview.networkmock.core.model { field public static final com.worldline.devview.networkmock.core.model.OperationMockState.Network INSTANCE; } + @androidx.compose.runtime.Immutable @kotlinx.serialization.SerialName("sequence") @kotlinx.serialization.Serializable public static final class OperationMockState.Sequence implements com.worldline.devview.networkmock.core.model.OperationMockState { + ctor public OperationMockState.Sequence(java.util.List responses, optional int currentIndex); + method public java.util.List component1(); + method public int component2(); + method public com.worldline.devview.networkmock.core.model.OperationMockState.Sequence copy(optional java.util.List responses, optional int currentIndex); + method @InaccessibleFromKotlin public int getCurrentIndex(); + method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.OperationMockState.Mock? getCurrentResponse(); + method @InaccessibleFromKotlin public String getDisplayName(); + method @InaccessibleFromKotlin public java.util.List getResponses(); + property public int currentIndex; + property public com.worldline.devview.networkmock.core.model.OperationMockState.Mock? currentResponse; + property public String displayName; + property public java.util.List responses; + } + public enum StatusCodeFamily { method @InaccessibleFromKotlin public String getDisplayName(); property public String displayName; diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt index cb34984..bfb0a5f 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt @@ -89,6 +89,7 @@ public data class NetworkMockState( * | [Network] | All requests pass through to the actual network (default) | `"Network"` | * | [Mock] | Requests return the selected response variant | `"$statusCode - $exampleName"` | * | [Failure] | Requests fail with the selected [FailureKind] | e.g. `"Timeout"` | + * | [Sequence] | Requests advance through an ordered list, sticking on the last | e.g. `"200 - default (2/3)"` | * * @see NetworkMockState */ @@ -157,6 +158,36 @@ public sealed interface OperationMockState { public data class Failure(val kind: FailureKind) : OperationMockState { override val displayName: String get() = kind.displayName } + + /** + * The operation advances through [responses] in order on each successive matched request, + * sticking on the last response once exhausted rather than looping back to the start or + * falling through to the real network — the least surprising default, and the one that + * matches how a real async-completion flow behaves (the terminal state is stable). + * + * [currentIndex] is persisted as part of this same state — no separate counter — so a plain + * [NetworkMockState.resetAllToNetwork]/reset-to-`Network` already discards the position + * along with everything else about the sequence, with no special-casing needed. + * + * @property responses The ordered steps, at least one. Two-or-more is the useful case; a + * single-element sequence is just [Mock] with extra ceremony. + * @property currentIndex Which step serves next, clamped to `responses.indices` by whatever + * advances it — see `NetworkMockPlugin` in `devview-networkmock-ktor`. + */ + @Immutable + @Serializable + @SerialName("sequence") + public data class Sequence(val responses: List, val currentIndex: Int = 0) : + OperationMockState { + /** The step that serves next, or `null` if [responses] is empty. */ + public val currentResponse: Mock? get() = responses.getOrNull(index = currentIndex) + + override val displayName: String + get() { + val current = currentResponse ?: return "Sequence" + return "${current.displayName} (${currentIndex + 1}/${responses.size})" + } + } } /** diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt index 74b32f9..be9632a 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt @@ -129,6 +129,43 @@ class MockStateRepositoryTest { operationState.kind shouldBe FailureKind.TIMEOUT } + @Test + fun `setOperationMockState persists sequence state with its position for an operation`() = runTest { + val repository = createRepository() + val steps = listOf( + OperationMockState.Mock(statusCode = 202, exampleName = "pending"), + OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + + repository.setOperationMockState( + key = key(operationId = "getUser"), + state = OperationMockState.Sequence(responses = steps, currentIndex = 1) + ) + + val operationState = repository.getState().getOperationState(key = key(operationId = "getUser")) + operationState.shouldBeInstanceOf() + operationState.responses shouldBe steps + operationState.currentIndex shouldBe 1 + } + + @Test + fun `setOperationMockState persists network state and discards a previous sequence position`() = runTest { + val repository = createRepository() + val steps = listOf( + OperationMockState.Mock(statusCode = 202, exampleName = "pending"), + OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + repository.setOperationMockState( + key = key(operationId = "getUser"), + state = OperationMockState.Sequence(responses = steps, currentIndex = 1) + ) + + repository.setOperationMockState(key = key(operationId = "getUser"), state = OperationMockState.Network) + + val operationState = repository.getState().getOperationState(key = key(operationId = "getUser")) + operationState shouldBe OperationMockState.Network + } + @Test fun `setOperationMockState is reflected in observeState`() = runTest { val repository = createRepository() diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt index 539d7c2..0fcf2e9 100644 --- a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt @@ -2,6 +2,7 @@ package com.worldline.devview.networkmock.ktor.plugin import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.NetworkMockState +import com.worldline.devview.networkmock.core.model.OperationKey import com.worldline.devview.networkmock.core.model.OperationMockState import com.worldline.devview.networkmock.core.repository.MockConfigRepository import com.worldline.devview.networkmock.core.repository.MockStateRepository @@ -25,6 +26,7 @@ import io.mockk.mockk import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertFailsWith +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlinx.io.IOException @@ -221,6 +223,83 @@ class NetworkMockPluginTest { // endregion + // region Sequential mocks + + @Test + fun sequence_advancesThroughStepsOnSuccessiveRequests_andSticksOnLastOnceExhausted() = runTest { + val steps = listOf( + OperationMockState.Mock(statusCode = 200, exampleName = "default"), + OperationMockState.Mock(statusCode = 404, exampleName = "default") + ) + val stateRepository = mutableStateRepositoryMock( + initial = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Sequence(responses = steps) + ) + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepository + ) + + // Step 1: the first response, then advances to index 1. + client.get(urlString = "https://staging.api.example.com/api/users/42") + .status shouldBe HttpStatusCode.OK + // Step 2: the second (last) response, then sticks at index 1 - no third step exists. + client.get(urlString = "https://staging.api.example.com/api/users/42") + .status shouldBe HttpStatusCode.NotFound + // Step 3 onward: still the last response. + client.get(urlString = "https://staging.api.example.com/api/users/42") + .status shouldBe HttpStatusCode.NotFound + } + + @Test + fun probabilisticFailure_appliesToSequenceStatesToo() = runTest { + val resources = flakySpecResources(failureRate = 1.0) + val stateRepository = mutableStateRepositoryMock( + initial = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Sequence( + responses = listOf(OperationMockState.Mock(statusCode = 200, exampleName = "default")) + ) + ) + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(resources = resources), + stateRepository = stateRepository, + random = FixedRandom(value = 0.0) + ) + + assertFailsWith { + client.get(urlString = "https://staging.api.example.com/api/users/42") + } + } + + /** + * Unlike [stateRepositoryMock], writes actually mutate the backing state, so a test can + * make more than one request and observe the plugin's own advance-and-persist write. + */ + private fun mutableStateRepositoryMock(initial: NetworkMockState): MockStateRepository { + val stateFlow = MutableStateFlow(initial) + return mockk(relaxed = true) { + coEvery { getState() } answers { stateFlow.value } + every { observeState() } returns stateFlow + coEvery { setOperationMockState(key = any(), state = any()) } answers { + val key = firstArg() + val newState = secondArg() + stateFlow.value = stateFlow.value.withOperationState(key = key, state = newState) + } + } + } + + // endregion + // region Failure simulation @Test diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt index 1f57fdd..408839d 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt @@ -3,7 +3,9 @@ package com.worldline.devview.networkmock.ktor.plugin import co.touchlab.kermit.Logger import com.worldline.devview.networkmock.core.model.FailureKind import com.worldline.devview.networkmock.core.model.NetworkMockState +import com.worldline.devview.networkmock.core.model.OperationKey import com.worldline.devview.networkmock.core.model.OperationMockState +import com.worldline.devview.networkmock.core.repository.MockConfigRepository import io.ktor.client.HttpClient import io.ktor.client.call.HttpClientCall import io.ktor.client.plugins.HttpClientPlugin @@ -147,7 +149,7 @@ public val NetworkMockPlugin: HttpClientPlugin NETWORK (declared mock " + "${endpointState.statusCode}/${endpointState.exampleName} not found)" @@ -236,25 +242,75 @@ public val NetworkMockPlugin: HttpClientPlugin delay(timeMillis = ms) } + logger.d { + "$method $path -> MOCK ${endpointState.statusCode}/${endpointState.exampleName}" + } + call + } catch (e: Exception) { + logger.w( + throwable = e + ) { "$method $path -> NETWORK (error loading mock response)" } + execute(requestBuilder = requestBuilder) + } + } + is OperationMockState.Sequence -> { + val step = endpointState.currentResponse + if (step == null) { + logger.w { "$method $path -> NETWORK (sequence has no declared steps)" } + return@intercept execute(requestBuilder = requestBuilder) + } + val failureRate = mockMatch.config.failureRate + if (failureRate != null && random.nextDouble() < failureRate) { logger.d { - "$method $path -> MOCK ${mockResponse.statusCode}/${mockResponse.exampleName}" + "$method $path -> FAILURE (probabilistic, rate=$failureRate)" } - createMockHttpClientCall( + throw simulatedFailure( + kind = FailureKind.CONNECTION_REFUSED, + requestData = request + ) + } + + @Suppress("TooGenericExceptionCaught") + try { + val call = buildMockCall( + mockRepository = mockRepository, client = scope, - requestData = request, - statusCode = HttpStatusCode.fromValue( - value = mockResponse.statusCode - ), - content = mockResponse.content, - contentType = mockResponse.contentType, - headers = mockResponse.headers + request = request, + key = mockMatch.key, + statusCode = step.statusCode, + exampleName = step.exampleName, + delayMs = mockMatch.delayMs ) + + if (call == null) { + logger.w { + "$method $path -> NETWORK (declared sequence step " + + "${step.statusCode}/${step.exampleName} not found)" + } + return@intercept execute(requestBuilder = requestBuilder) + } + + // Advance and persist before returning - sticks on the last index + // once exhausted rather than looping back to the start. + val nextIndex = (endpointState.currentIndex + 1) + .coerceAtMost(maximumValue = endpointState.responses.lastIndex) + if (nextIndex != endpointState.currentIndex) { + stateRepository.setOperationMockState( + key = mockMatch.key, + state = endpointState.copy(currentIndex = nextIndex) + ) + } + + logger.d { + "$method $path -> MOCK ${step.statusCode}/${step.exampleName} " + + "(sequence ${endpointState.currentIndex + 1}/${endpointState.responses.size})" + } + call } catch (e: Exception) { logger.w( throwable = e - ) { "$method $path -> NETWORK (error loading mock response)" } + ) { "$method $path -> NETWORK (error loading sequence step)" } execute(requestBuilder = requestBuilder) } } @@ -283,6 +339,40 @@ private fun simulatedFailure(kind: FailureKind, requestData: HttpRequestData): T IOException("Connection refused (simulated by DevView NetworkMock)") } +/** + * Loads the declared `(statusCode, exampleName)` variant and builds a mock [HttpClientCall] for + * it, applying [delayMs] first — shared by the [OperationMockState.Mock] and + * [OperationMockState.Sequence] branches, which differ only in where the pair comes from. + * + * @return The call to return, or `null` if the variant isn't declared in the spec (caller falls + * back to the real network). + */ +@Suppress("DocumentationOverPrivateFunction") +private suspend fun buildMockCall( + mockRepository: MockConfigRepository, + client: HttpClient, + request: HttpRequestData, + key: OperationKey, + statusCode: Int, + exampleName: String, + delayMs: Long? +): HttpClientCall? { + val mockResponse = mockRepository.loadMockResponse( + key = key, + statusCode = statusCode, + exampleName = exampleName + ) ?: return null + delayMs?.let { ms -> delay(timeMillis = ms) } + return createMockHttpClientCall( + client = client, + requestData = request, + statusCode = HttpStatusCode.fromValue(value = mockResponse.statusCode), + content = mockResponse.content, + contentType = mockResponse.contentType, + headers = mockResponse.headers + ) +} + /** * Creates a mock [HttpClientCall] without making an actual network request. * diff --git a/devview-networkmock/api/api.txt b/devview-networkmock/api/api.txt index 86c41d1..ddd30c1 100644 --- a/devview-networkmock/api/api.txt +++ b/devview-networkmock/api/api.txt @@ -165,9 +165,11 @@ package com.worldline.devview.networkmock.viewmodel { method public void openOperation(com.worldline.devview.networkmock.core.model.OperationKey key); method public void reloadConfiguration(); method public void resetAllToNetwork(); + method public void resetOperationSequencePosition(com.worldline.devview.networkmock.core.model.OperationKey key); method public void setGlobalMockingEnabled(boolean enabled); method public void setOperationFailureState(com.worldline.devview.networkmock.core.model.OperationKey key, com.worldline.devview.networkmock.core.model.FailureKind kind); method public void setOperationMockState(com.worldline.devview.networkmock.core.model.OperationKey key, com.worldline.devview.networkmock.core.model.MockResponse? response); + method public void setOperationSequenceState(com.worldline.devview.networkmock.core.model.OperationKey key, java.util.List responses); property public kotlinx.coroutines.flow.StateFlow sheetState; property public kotlinx.coroutines.flow.StateFlow uiState; } diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt index bc1b06d..b5e8f8f 100644 --- a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt @@ -127,6 +127,8 @@ class NetworkMockOperationSheetTest { currentState: OperationMockState, onSelectResponse: (MockResponse?) -> Unit = {}, onSelectFailure: (FailureKind) -> Unit = {}, + onCommitSequence: (List) -> Unit = {}, + onResetSequencePosition: () -> Unit = {}, onOpenPreview: () -> Unit = {}, onClose: () -> Unit = {} ) { @@ -154,6 +156,8 @@ class NetworkMockOperationSheetTest { markedForPreview = marked, onSelectResponse = onSelectResponse, onSelectFailure = onSelectFailure, + onCommitSequence = onCommitSequence, + onResetSequencePosition = onResetSequencePosition, onTogglePreview = { response -> marked = marked.transition(response = response) }, onOpenPreview = onOpenPreview, onClose = onClose diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt index 1e84982..f941d68 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheet.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -58,6 +59,7 @@ import com.worldline.devview.networkmock.utils.badgeContainerColor import com.worldline.devview.networkmock.utils.badgeContentColor import com.worldline.devview.networkmock.utils.fake import com.worldline.devview.networkmock.viewmodel.OperationSheetState +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch @@ -77,6 +79,8 @@ import kotlinx.coroutines.launch * @param onDismissRequest Called when the sheet should close (row tap, swipe, tap outside, close button). * @param onSelectResponse Called with the tapped response (or `null` for "no mock") when a row is selected. * @param onSelectFailure Called with the tapped [FailureKind] when a failure-simulation row is selected. + * @param onCommitSequence Called with the built ordered list when "Save Sequence" is tapped. + * @param onResetSequencePosition Called when "Reset Position" is tapped for an active sequence. * @param modifier [Modifier] to be applied to the [ModalBottomSheet]. */ @Composable @@ -85,6 +89,8 @@ internal fun NetworkMockOperationSheet( onDismissRequest: () -> Unit, onSelectResponse: (MockResponse?) -> Unit, onSelectFailure: (FailureKind) -> Unit, + onCommitSequence: (List) -> Unit, + onResetSequencePosition: () -> Unit, modifier: Modifier = Modifier ) { val modalSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -155,6 +161,11 @@ internal fun NetworkMockOperationSheet( onSelectFailure(kind) onClose() }, + onCommitSequence = { responses -> + onCommitSequence(responses) + onClose() + }, + onResetSequencePosition = onResetSequencePosition, onTogglePreview = { response -> markedForPreview = markedForPreview.transition(response = response) }, @@ -183,6 +194,8 @@ internal fun OperationPickerPage( markedForPreview: PreviewSheetState, onSelectResponse: (MockResponse?) -> Unit, onSelectFailure: (FailureKind) -> Unit, + onCommitSequence: (List) -> Unit, + onResetSequencePosition: () -> Unit, onTogglePreview: (MockResponse) -> Unit, onOpenPreview: () -> Unit, onClose: () -> Unit, @@ -192,11 +205,17 @@ internal fun OperationPickerPage( val groupedResponses = content.responses.groupBy { StatusCodeFamily.fromStatusCode(statusCode = it.statusCode) } + // Building a sequence is a page-level mode rather than a per-row toggle: while active, + // tapping a response below appends it to the sequence being built instead of selecting it + // immediately - see the "SEQUENCE" section below for the Save/Cancel affordance. + var buildingSequence by remember { mutableStateOf(value = false) } + var sequenceInProgress by remember { mutableStateOf(value = persistentListOf()) } val selectedResponse = when (val currentState = endpoint.currentState) { is OperationMockState.Mock -> content.responses.find { it.statusCode == currentState.statusCode && it.exampleName == currentState.exampleName } + is OperationMockState.Sequence -> null is OperationMockState.Failure -> null OperationMockState.Network -> null } @@ -217,8 +236,8 @@ internal fun OperationPickerPage( modifier = Modifier .padding(horizontal = 16.dp) .testTag(tag = "operation_sheet_network_item"), - selected = selectedResponse == null, - onClick = { onSelectResponse(null) } + selected = !buildingSequence && selectedResponse == null, + onClick = { if (!buildingSequence) onSelectResponse(null) } ) } @@ -242,8 +261,16 @@ internal fun OperationPickerPage( .padding(horizontal = 16.dp) .testTag(tag = itemKey), mockResponse = mockResponse, - selected = selectedResponse == mockResponse, - onClick = { onSelectResponse(mockResponse) }, + selected = !buildingSequence && selectedResponse == mockResponse, + onClick = { + if (buildingSequence) { + sequenceInProgress = sequenceInProgress.adding( + element = mockResponse + ) + } else { + onSelectResponse(mockResponse) + } + }, isMarkedForPreview = markedForPreview.isInPreviewMode( response = mockResponse ), @@ -257,6 +284,64 @@ internal fun OperationPickerPage( } } + stickyHeader(key = "header_sequence") { + Surface { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(text = "SEQUENCE", style = MaterialTheme.typography.labelLarge) + val activeSequence = endpoint.currentState as? OperationMockState.Sequence + when { + buildingSequence -> Text( + modifier = Modifier.testTag(tag = "sequence_build_hint"), + text = "Tap responses above in order (${sequenceInProgress.size} added)", + style = MaterialTheme.typography.bodySmall + ) + + activeSequence != null -> Text( + modifier = Modifier.testTag(tag = "sequence_position"), + text = "Step ${activeSequence.currentIndex + 1} of " + + "${activeSequence.responses.size}: ${activeSequence.displayName}", + style = MaterialTheme.typography.bodySmall + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(space = 8.dp)) { + if (buildingSequence) { + TextButton( + modifier = Modifier.testTag(tag = "sequence_cancel_button"), + onClick = { + buildingSequence = false + sequenceInProgress = persistentListOf() + } + ) { Text(text = "Cancel") } + TextButton( + modifier = Modifier.testTag(tag = "sequence_save_button"), + enabled = sequenceInProgress.size >= 2, + onClick = { + onCommitSequence(sequenceInProgress) + buildingSequence = false + sequenceInProgress = persistentListOf() + } + ) { Text(text = "Save Sequence (${sequenceInProgress.size})") } + } else { + TextButton( + modifier = Modifier.testTag(tag = "sequence_build_button"), + onClick = { buildingSequence = true } + ) { Text(text = "Build a Sequence") } + if (activeSequence != null) { + TextButton( + modifier = Modifier.testTag(tag = "sequence_reset_button"), + onClick = onResetSequencePosition + ) { Text(text = "Reset Position") } + } + } + } + } + } + } + stickyHeader(key = "header_simulate_failure") { Surface { Column { @@ -387,6 +472,8 @@ private fun NetworkMockOperationSheetPickerPreview( markedForPreview = PreviewSheetState.Hidden, onSelectResponse = {}, onSelectFailure = {}, + onCommitSequence = {}, + onResetSequencePosition = {}, onTogglePreview = {}, onOpenPreview = {}, onClose = {} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt index 6f1c36e..edb5651 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt @@ -140,6 +140,16 @@ public fun NetworkMockScreen( if (openKey != null) { viewModel.setOperationFailureState(key = openKey, kind = kind) } + }, + onCommitSequence = { responses -> + if (openKey != null) { + viewModel.setOperationSequenceState(key = openKey, responses = responses) + } + }, + onResetSequencePosition = { + if (openKey != null) { + viewModel.resetOperationSequencePosition(key = openKey) + } } ) } @@ -530,6 +540,7 @@ private fun OperationUiModel.matches( // Failure counts as "Mocked" for this filter — like Mock, it's a deliberately // configured non-default state, distinct only from plain pass-through. is OperationMockState.Mock -> MockStateFilter.MOCKED in mockStates + is OperationMockState.Sequence -> MockStateFilter.MOCKED in mockStates is OperationMockState.Failure -> MockStateFilter.MOCKED in mockStates OperationMockState.Network -> MockStateFilter.NETWORK in mockStates } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt index e631ca0..7d8df8a 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt @@ -53,6 +53,7 @@ internal fun EndpointCard( ) { val railColor = when (val state = endpoint.currentState) { is OperationMockState.Mock -> state.containerColor + is OperationMockState.Sequence -> state.containerColor is OperationMockState.Failure -> state.containerColor OperationMockState.Network -> Color.Transparent } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt index ec72bb1..0b28906 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt @@ -32,6 +32,7 @@ internal fun EndpointStateChip( modifier: Modifier = Modifier, label: String = when (endpointMockState) { is OperationMockState.Mock -> endpointMockState.statusCode.toString() + is OperationMockState.Sequence -> endpointMockState.displayName is OperationMockState.Failure -> endpointMockState.displayName OperationMockState.Network -> endpointMockState.displayName }, diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt index a33a258..f03e0f9 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt @@ -78,6 +78,9 @@ internal fun OperationUiModel.Companion.fake( internal val OperationMockState.icon: ImageVector get() = when (this) { is OperationMockState.Mock -> iconForStatusCode(statusCode = statusCode) + is OperationMockState.Sequence -> iconForStatusCode( + statusCode = currentResponse?.statusCode + ) is OperationMockState.Failure -> Icons.Rounded.WifiOff OperationMockState.Network -> Icons.Rounded.Wifi } @@ -96,6 +99,9 @@ internal val OperationMockState.contentColor: Color @ReadOnlyComposable get() = when (this) { is OperationMockState.Mock -> contentColorForStatusCode(statusCode = statusCode) + is OperationMockState.Sequence -> contentColorForStatusCode( + statusCode = currentResponse?.statusCode + ) is OperationMockState.Failure -> rememberMockColorScheme().failure.content OperationMockState.Network -> rememberMockColorScheme().network.content } @@ -110,6 +116,9 @@ internal val OperationMockState.containerColor: Color @ReadOnlyComposable get() = when (this) { is OperationMockState.Mock -> containerColorForStatusCode(statusCode = statusCode) + is OperationMockState.Sequence -> containerColorForStatusCode( + statusCode = currentResponse?.statusCode + ) is OperationMockState.Failure -> rememberMockColorScheme().failure.container OperationMockState.Network -> rememberMockColorScheme().network.container } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt index c060b2f..3ce9ff8 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt @@ -319,6 +319,44 @@ public class NetworkMockViewModel( } } + /** + * Sets an operation to advance through [responses] in order on each successive request, + * starting at the first step. Replaces any previously-selected mock/failure state. + * + * @param key The [OperationKey] identifying the spec and operation + * @param responses The ordered steps, at least one (two-or-more is the useful case) + * @see OperationMockState.Sequence + */ + public fun setOperationSequenceState(key: OperationKey, responses: List) { + viewModelScope.launch { + val steps = responses.map { + OperationMockState.Mock(statusCode = it.statusCode, exampleName = it.exampleName) + } + stateRepository.setOperationMockState( + key = key, + state = OperationMockState.Sequence(responses = steps) + ) + } + } + + /** + * Resets an active sequence's position back to its first step, without leaving the + * [OperationMockState.Sequence] state. A no-op if the operation isn't currently a sequence. + * + * @param key The [OperationKey] identifying the spec and operation + */ + public fun resetOperationSequencePosition(key: OperationKey) { + viewModelScope.launch { + val current = stateRepository.getState().getOperationState(key = key) + if (current is OperationMockState.Sequence) { + stateRepository.setOperationMockState( + key = key, + state = current.copy(currentIndex = 0) + ) + } + } + } + /** * Resets all operation mocks to use actual network. * diff --git a/docs/modules/networkmock-core.md b/docs/modules/networkmock-core.md index 5829b6d..c2b5441 100644 --- a/docs/modules/networkmock-core.md +++ b/docs/modules/networkmock-core.md @@ -199,7 +199,11 @@ State is persisted via `MockStateRepository`: `OperationMockState` is serialized as `{"type":"network"}` (pass-through), `{"type":"mock","statusCode":200,"exampleName":"default"}`, or -`{"type":"failure","kind":"timeout"}` / `{"type":"failure","kind":"connection_refused"}`. +`{"type":"failure","kind":"timeout"}` / `{"type":"failure","kind":"connection_refused"}`, or +`{"type":"sequence","responses":[...],"currentIndex":0}` — an ordered list of `Mock` steps plus +the position to serve next. The position is part of this same persisted value, not a separate +key, so resetting the operation to `Network` (or resetting all mocks) discards it along with +everything else about the sequence. See [Simulating a staged/polling flow](networkmock-workflows.md#simulating-a-stagedpolling-flow-sequential-mocks). **Upgrading from a pre-0.2.0 release**: the operation-state key shape changed (`{groupId}-{environmentId}-{endpointId}` → `{specId}-{operationId}`), and so did the `Mock` payload (a response file name → `(statusCode, exampleName)`). On first launch after upgrading, every `network_mock_endpoint_*` entry from the old shape is wiped once — this is disabled-by-default developer-tooling state, not user data, so previously-selected mocks are reset rather than translated. The global mocking toggle is unaffected. See the [migration guide](../guides/migrating-to-openapi.md) for converting an existing `mocks.json`. diff --git a/docs/modules/networkmock-workflows.md b/docs/modules/networkmock-workflows.md index 69dc26b..caa473e 100644 --- a/docs/modules/networkmock-workflows.md +++ b/docs/modules/networkmock-workflows.md @@ -138,6 +138,20 @@ that's this field — it's read-only in the UI; edit the spec to change it. See [Simulating failures](networkmock-core.md#simulating-failures) for the exact exception each failure kind throws. +## Simulating a staged/polling flow (sequential mocks) + +For endpoints where the interesting behavior is the transition across repeated calls (order +status, upload progress, async job completion): + +1. Open DevView → Network Mock → tap your operation. +2. In the "SEQUENCE" section, tap "Build a Sequence". +3. Tap responses above, in the order you want them served (e.g. `202 Pending`, then `200 Success`). +4. Tap "Save Sequence" (needs at least 2 steps). The operation now advances one step per request. + +Once the sequence reaches its last step, every further request keeps serving that last step — +it does not loop back to the start. "Reset Position" (in the same section) restarts at step 1 +without leaving the sequence; selecting "No mock" or a single response exits it entirely. + ## Resetting all mocks - **UI**: Open DevView → Network Mock → tap the restore icon in the top toolbar.