diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fb7bfb8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,208 @@ +name: Build + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + # Skip build if head commit contains 'skip ci' + if: "!contains(github.event.head_commit.message, 'skip ci')" + + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Assemble and check + run: ./gradlew check assembleDebug -Pandroidx.baselineprofile.skipgeneration + + - name: Run unit tests + run: ./gradlew test -Pandroidx.baselineprofile.skipgeneration + + - name: Run androidHostTest tests + run: ./gradlew androidHostTest -Pandroidx.baselineprofile.skipgeneration + + - name: Upload reports + Roborazzi outputs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: reports + path: | + **/build/reports/** + **/build/outputs/roborazzi/** + + detekt: + # Skip if head commit contains 'skip ci' + if: "!contains(github.event.head_commit.message, 'skip ci')" + + runs-on: macos-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Run Detekt + run: ./gradlew detektFull + + - name: Upload Detekt reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: detekt-reports + path: '**/build/reports/detekt/**' + + konsist: + # Skip if head commit contains 'skip ci' + if: "!contains(github.event.head_commit.message, 'skip ci')" + + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Run Konsist tests + run: ./gradlew :konsist:test + + - name: Upload Konsist reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: konsist-reports + path: 'konsist/build/reports/tests/**' + + device-tests: + # Skip if head commit contains 'skip ci' + if: "!contains(github.event.head_commit.message, 'skip ci')" + + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Enable KVM for emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + sudo chmod 777 /dev/kvm + + - name: Run Android Device Tests + uses: reactivecircus/android-emulator-runner@v3 + with: + api-level: 33 + arch: x86_64 + profile: Nexus 6P + script: ./gradlew connectedAndroidDeviceTest -Pandroidx.baselineprofile.skipgeneration + + - name: Upload device test reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: device-test-reports + path: | + **/build/reports/** + **/build/outputs/connected-android-test/** + + coverage: + # Skip if head commit contains 'skip ci' + if: "!contains(github.event.head_commit.message, 'skip ci')" + + runs-on: macos-latest + needs: [ build ] + timeout-minutes: 30 + # TODO: Remove continue-on-error once the kover convention plugin is applied to modules + continue-on-error: true + + steps: + - uses: actions/checkout@v6 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Generate Kover coverage report + run: ./gradlew koverXmlReport + + - name: Generate test reports + run: ./gradlew testReport + + - name: Upload coverage report + if: success() + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: '**/build/reports/kover/**' + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-reports + path: | + **/build/reports/tests/** + **/test-results/** + diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml new file mode 100644 index 0000000..629a715 --- /dev/null +++ b/.github/workflows/close-issues.yml @@ -0,0 +1,26 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: + +jobs: + close-issues: + runs-on: ubuntu-latest + + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v10 + with: + days-before-issue-stale: 21 + days-before-issue-close: 7 + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 21 days with no activity." + close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." + days-before-pr-stale: -1 + days-before-pr-close: -1 + exempt-issue-assignees: 'MaxMichel2', 'matthiaslao' + exempt-pr-assignees: 'MaxMichel2', 'matthiaslao' \ No newline at end of file diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml new file mode 100644 index 0000000..ab56b64 --- /dev/null +++ b/.github/workflows/pr-hygiene.yml @@ -0,0 +1,31 @@ +name: PR Hygiene + +on: + pull_request: + types: [ opened, edited, synchronize, reopened ] + +jobs: + semantic-title: + runs-on: ubuntu-latest + + permissions: + pull-requests: read + + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Enforce conventional commit types matching the project's Renovate prefix conventions + types: | + feat + fix + docs + chore + refactor + test + ci + perf + build + revert + requireScope: false diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 0000000..858af19 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,115 @@ +name: Publish docs + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pages: write + id-token: write + +jobs: + build_docs: + runs-on: macos-latest + env: + DYLD_FALLBACK_LIBRARY_PATH: /opt/homebrew/lib + + steps: + - uses: actions/checkout@v6 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + brew install cairo freetype libffi libjpeg libpng zlib + python3 -m pip install --upgrade pip + python3 -m pip install -r pip-requirements.txt + + - uses: gradle/actions/setup-gradle@v5 + + - name: Build docs + run: ./scripts/build_docs.sh build + + - name: Upload docs + uses: actions/upload-artifact@v7 + with: + name: docs + retention-days: 1 + path: | + docs/ + + deploy_docs: + runs-on: macos-latest + needs: build_docs + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + env: + DYLD_FALLBACK_LIBRARY_PATH: /opt/homebrew/lib + + steps: + - uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + brew install cairo freetype libffi libjpeg libpng zlib + python3 -m pip install --upgrade pip + python3 -m pip install -r pip-requirements.txt + + - name: Get Docs Version + run: cat gradle.properties | grep --color=never VERSION_NAME >> $GITHUB_OUTPUT + id: version + + - name: Download docs builds + uses: actions/download-artifact@v8 + with: + name: docs + path: docs + + - name: Configure git for mike + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git fetch origin gh-pages --depth=1 + + - name: Deploy dev docs with mike 🗿 + if: ${{ success() && contains(steps.version.outputs.VERSION_NAME, 'SNAPSHOT') }} + env: + DEVVIEW_VERSION: "dev" + run: mike deploy -u --push "$DEVVIEW_VERSION" + + - name: Deploy release docs with mike 🚀 + if: ${{ success() && !contains(steps.version.outputs.VERSION_NAME , 'SNAPSHOT') }} + env: + DEVVIEW_VERSION: ${{ steps.version.outputs.VERSION_NAME }} + run: mike deploy -u --push "$DEVVIEW_VERSION" latest + + - name: Delete old doc versions + run: | + git fetch origin gh-pages --depth=1 + scripts/delete_old_version_docs.sh \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c683d42 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,146 @@ +name: Publish + +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Assemble and check + run: ./gradlew check assembleDebug -Pandroidx.baselineprofile.skipgeneration + + - name: Upload reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: reports + path: | + **/build/reports/** + **/build/outputs/roborazzi/** + + detekt: + runs-on: macos-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Run Detekt + run: ./gradlew detektFull + + - name: Upload Detekt reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: detekt-reports + path: '**/build/reports/detekt/**' + + konsist: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v5 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Run Konsist tests + run: ./gradlew :konsist:test + + - name: Upload Konsist reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: konsist-reports + path: 'konsist/build/reports/tests/**' + + publish: + runs-on: macos-latest + needs: [ build, detekt, konsist ] + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Strip tag prefix to get version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Set release version in gradle.properties + run: sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=${{ steps.version.outputs.VERSION }}/" gradle.properties + + - name: Publish to Maven Central + run: ./gradlew publish --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} + + github-release: + runs-on: ubuntu-latest + needs: [ publish ] + timeout-minutes: 10 + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + tag_name: ${{ github.ref_name }} + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cf15386 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,17 @@ +on: + release: + types: [published] + +permissions: + issues: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: apexskier/github-release-commenter@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + comment-template: | + FYI: This is available in release {release_link}. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 866ed9e..9ad578a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,7 @@ plugins { alias(libs.plugins.kotlin.plugin.serialization) apply false alias(libs.plugins.kover) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.mokkery) apply false alias(libs.plugins.poko) apply false alias(libs.plugins.room) apply false } diff --git a/devview-analytics/build.gradle.kts b/devview-analytics/build.gradle.kts index b220127..7b6709a 100644 --- a/devview-analytics/build.gradle.kts +++ b/devview-analytics/build.gradle.kts @@ -1,7 +1,9 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.compose.multiplatform) - alias(libs.plugins.convention.datastore) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.deviceTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) alias(libs.plugins.poko) @@ -10,7 +12,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.analytics" } diff --git a/devview-analytics/src/androidDeviceTest/kotlin/com/worldline/devview/analytics/AnalyticsScreenTest.kt b/devview-analytics/src/androidDeviceTest/kotlin/com/worldline/devview/analytics/AnalyticsScreenTest.kt new file mode 100644 index 0000000..2f2e3d1 --- /dev/null +++ b/devview-analytics/src/androidDeviceTest/kotlin/com/worldline/devview/analytics/AnalyticsScreenTest.kt @@ -0,0 +1,137 @@ +package com.worldline.devview.analytics + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.analytics.model.AnalyticsLog +import com.worldline.devview.analytics.model.AnalyticsLogCategory +import com.worldline.devview.analytics.model.AnalyticsLogType +import kotlin.time.Clock +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class AnalyticsScreenTest { + + @Test + fun analyticsScreen_shows_empty_state_when_no_logs() = runComposeUiTest { + setContent { + CompositionLocalProvider(LocalAnalytics provides emptyList()) { + AnalyticsScreen(highlightedAnalyticsLogTypes = persistentListOf()) + } + } + + onNodeWithTag(testTag = "empty_state_message").assertIsDisplayed() + } + + @Test + fun analyticsScreen_filters_by_query_and_clear_restores_results() = runComposeUiTest { + val logs = listOf( + log(tag = "login_click", type = AnalyticsLogCategory.Action.Click), + log(tag = "screen_view", type = AnalyticsLogCategory.Screen.View) + ) + + setContent { + CompositionLocalProvider(LocalAnalytics provides logs) { + AnalyticsScreen(highlightedAnalyticsLogTypes = persistentListOf()) + } + } + + onNodeWithTag(testTag = "analytics_log_item_login_click").assertIsDisplayed() + onNodeWithTag(testTag = "analytics_log_item_screen_view").assertIsDisplayed() + + onNodeWithTag(testTag = "analytics_filter_field").performTextInput("login") + + onNodeWithTag(testTag = "analytics_log_item_login_click").assertIsDisplayed() + onAllNodesWithTag(testTag = "analytics_log_item_screen_view").assertCountEquals(0) + + onNodeWithContentDescription(label = "Clear filter").performClick() + + onNodeWithTag(testTag = "analytics_log_item_screen_view").assertIsDisplayed() + } + + @Test + fun analyticsScreen_can_filter_by_category_chip() = runComposeUiTest { + val logs = listOf( + log(tag = "click_tag", type = AnalyticsLogCategory.Action.Click), + log(tag = "view_tag", type = AnalyticsLogCategory.Screen.View) + ) + + setContent { + CompositionLocalProvider(LocalAnalytics provides logs) { + AnalyticsScreen(highlightedAnalyticsLogTypes = persistentListOf()) + } + } + + onNodeWithContentDescription(label = "Expand filter").performClick() + onNodeWithTag(testTag = "category_chip_${AnalyticsLogCategory.Action.Click.category.displayName}").performClick() + + onNodeWithTag(testTag = "analytics_log_item_click_tag").assertIsDisplayed() + onAllNodesWithTag(testTag = "analytics_log_item_view_tag").assertCountEquals(0) + } + + @Test + fun analyticsScreen_time_range_filters_out_old_logs() = runComposeUiTest { + val now = Clock.System.now().toEpochMilliseconds() + val logs = listOf( + log( + tag = "recent_log", + type = AnalyticsLogCategory.Action.Click, + timestamp = now - 60_000L + ), + log( + tag = "old_log", + type = AnalyticsLogCategory.Action.Click, + timestamp = now - (20 * 60 * 1000L) + ) + ) + + setContent { + CompositionLocalProvider(LocalAnalytics provides logs) { + AnalyticsScreen(highlightedAnalyticsLogTypes = persistentListOf()) + } + } + + onNodeWithContentDescription(label = "Expand filter").performClick() + onNodeWithTag(testTag = "time_range_5m").performClick() + + onNodeWithTag(testTag = "analytics_log_item_recent_log").assertIsDisplayed() + onAllNodesWithTag(testTag = "analytics_log_item_old_log").assertCountEquals(0) + } + + @Test + fun analyticsScreen_shows_no_match_message_for_non_matching_query() = runComposeUiTest { + val logs = listOf( + log(tag = "login_click", type = AnalyticsLogCategory.Action.Click) + ) + + setContent { + CompositionLocalProvider(LocalAnalytics provides logs) { + AnalyticsScreen(highlightedAnalyticsLogTypes = persistentListOf()) + } + } + + onNodeWithTag(testTag = "analytics_filter_field").performTextInput("does-not-match") + + onNodeWithTag(testTag = "empty_state_message").assertIsDisplayed() + onAllNodesWithTag(testTag = "analytics_log_item_login_click").assertCountEquals(0) + } + + private fun log( + tag: String, + type: AnalyticsLogType, + timestamp: Long = 1_700_000_000_000 + ): AnalyticsLog = + AnalyticsLog( + tag = tag, + screenClass = "TestScreen", + timestamp = timestamp, + type = type + ) +} + diff --git a/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/AnalyticsScreen.kt b/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/AnalyticsScreen.kt index ac888e0..11c4a5f 100644 --- a/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/AnalyticsScreen.kt +++ b/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/AnalyticsScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp @@ -231,9 +232,13 @@ public fun AnalyticsScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) + .testTag(tag = "time_range_selector") ) { TimeRange.entries.forEachIndexed { index, timeRange -> SegmentedButton( + modifier = Modifier.testTag( + tag = "time_range_${timeRange.label}" + ), selected = selectedTimeRange == timeRange, onClick = { selectedTimeRange = timeRange }, shape = SegmentedButtonDefaults.itemShape( @@ -248,13 +253,17 @@ public fun AnalyticsScreen( LazyRow( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = 4.dp) + .testTag(tag = "category_filter_row"), horizontalArrangement = Arrangement.spacedBy(space = 8.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 16.dp) ) { items(items = availableCategories) { category -> FilterChip( + modifier = Modifier.testTag( + tag = "category_chip_${category.displayName}" + ), selected = category in selectedCategories, onClick = { selectedCategories = @@ -297,7 +306,8 @@ public fun AnalyticsScreen( modifier = Modifier .weight(weight = 1f) .padding(vertical = 8.dp) - .padding(bottom = bottomPadding), + .padding(bottom = bottomPadding) + .testTag(tag = "analytics_filter_field"), value = filterQuery, onValueChange = { filterQuery = it }, placeholder = { Text(text = "Filter by tag or screen...") }, @@ -309,7 +319,10 @@ public fun AnalyticsScreen( }, trailingIcon = { AnimatedVisibility(visible = filterQuery.isNotEmpty()) { - IconButton(onClick = { filterQuery = "" }) { + IconButton( + modifier = Modifier.testTag(tag = "clear_filter_button"), + onClick = { filterQuery = "" } + ) { Icon( imageVector = Icons.Rounded.Close, contentDescription = "Clear filter" @@ -323,7 +336,8 @@ public fun AnalyticsScreen( VerticalDivider(modifier = Modifier.fillMaxHeight()) IconButton( modifier = Modifier - .padding(bottom = bottomPadding), + .padding(bottom = bottomPadding) + .testTag(tag = "expand_filter_button"), onClick = { filtersExpanded = !filtersExpanded } ) { Icon( @@ -368,7 +382,8 @@ public fun AnalyticsScreen( modifier = Modifier .fillMaxWidth() .padding(all = 16.dp) - .wrapContentWidth(align = Alignment.CenterHorizontally), + .wrapContentWidth(align = Alignment.CenterHorizontally) + .testTag(tag = "empty_state_message"), text = if (filterQuery.isBlank()) { "Analytics logs will appear here" } else { @@ -386,6 +401,7 @@ public fun AnalyticsScreen( Column( modifier = Modifier .animateItem() + .testTag(tag = "analytics_log_item_${log.tag}") ) { AnalyticsLogItem( analyticsLog = log diff --git a/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/components/AnalyticsLogItem.kt b/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/components/AnalyticsLogItem.kt index 4145eb6..c95dc95 100644 --- a/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/components/AnalyticsLogItem.kt +++ b/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/components/AnalyticsLogItem.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -57,6 +58,7 @@ internal fun AnalyticsLogItem(analyticsLog: AnalyticsLog, modifier: Modifier = M verticalArrangement = Arrangement.spacedBy(space = 2.dp) ) { Text( + modifier = Modifier.testTag(tag = "analytics_log_tag_${analyticsLog.tag}"), text = analyticsLog.tag, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -65,6 +67,9 @@ internal fun AnalyticsLogItem(analyticsLog: AnalyticsLog, modifier: Modifier = M ) ) Text( + modifier = Modifier.testTag( + tag = "analytics_log_screen_class_${analyticsLog.screenClass}" + ), text = analyticsLog.screenClass, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -78,6 +83,9 @@ internal fun AnalyticsLogItem(analyticsLog: AnalyticsLog, modifier: Modifier = M verticalArrangement = Arrangement.spacedBy(space = 4.dp) ) { Text( + modifier = Modifier.testTag( + tag = "analytics_log_timestamp_${analyticsLog.formattedTimestamp}" + ), text = analyticsLog.formattedTimestamp, style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium @@ -85,6 +93,9 @@ internal fun AnalyticsLogItem(analyticsLog: AnalyticsLog, modifier: Modifier = M color = MaterialTheme.colorScheme.onSurface ) CategoryChip( + modifier = Modifier.testTag( + tag = "analytics_log_category_chip_${analyticsLog.type.category}" + ), category = analyticsLog.type.category ) } diff --git a/devview-analytics/src/commonTest/kotlin/com/worldline/devview/analytics/AnalyticsLoggerTest.kt b/devview-analytics/src/commonTest/kotlin/com/worldline/devview/analytics/AnalyticsLoggerTest.kt new file mode 100644 index 0000000..f5395fd --- /dev/null +++ b/devview-analytics/src/commonTest/kotlin/com/worldline/devview/analytics/AnalyticsLoggerTest.kt @@ -0,0 +1,48 @@ +package com.worldline.devview.analytics + +import com.worldline.devview.analytics.model.AnalyticsLog +import com.worldline.devview.analytics.model.AnalyticsLogCategory +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlin.test.AfterTest +import kotlin.test.Test + +class AnalyticsLoggerTest { + + @AfterTest + fun tearDown() { + AnalyticsLogger.clear() + } + + @Test + fun `log adds analytics entries and updates hasLogs`() { + val first = testLog(tag = "first") + val second = testLog(tag = "second") + + AnalyticsLogger.log(first) + AnalyticsLogger.log(second) + + AnalyticsLogger.hasLogs shouldBe true + AnalyticsLogger.logs shouldHaveSize 2 + AnalyticsLogger.logs.shouldContainExactly(first, second) + } + + @Test + fun `clear removes all logged entries`() { + AnalyticsLogger.log(testLog(tag = "only")) + + AnalyticsLogger.clear() + + AnalyticsLogger.hasLogs shouldBe false + AnalyticsLogger.logs shouldHaveSize 0 + } + + private fun testLog(tag: String): AnalyticsLog = AnalyticsLog( + tag = tag, + screenClass = "TestScreen", + timestamp = 1_700_000_000_000, + type = AnalyticsLogCategory.Action.Click + ) +} + diff --git a/devview-analytics/src/commonTest/kotlin/com/worldline/devview/analytics/AnalyticsModuleTest.kt b/devview-analytics/src/commonTest/kotlin/com/worldline/devview/analytics/AnalyticsModuleTest.kt new file mode 100644 index 0000000..26e7f7b --- /dev/null +++ b/devview-analytics/src/commonTest/kotlin/com/worldline/devview/analytics/AnalyticsModuleTest.kt @@ -0,0 +1,86 @@ +package com.worldline.devview.analytics + +import com.worldline.devview.analytics.model.AnalyticsLog +import com.worldline.devview.analytics.model.AnalyticsLogCategory +import com.worldline.devview.analytics.model.AnalyticsLogType +import com.worldline.devview.core.Section +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.AfterTest +import kotlin.test.Test + +class AnalyticsModuleTest { + + @AfterTest + fun tearDown() { + AnalyticsLogger.clear() + } + + @Test + fun `analytics module exposes expected metadata and destination action`() { + val module = Analytics() + + module.section shouldBe Section.LOGGING + module.destinations.keys.shouldContain(AnalyticsDestination.Main) + + val mainMetadata = module.destinations[AnalyticsDestination.Main].shouldNotBeNull() + + mainMetadata.title shouldBe "Analytics" + mainMetadata.actions shouldHaveSize 1 + + val clearAction = mainMetadata.actions.single() + + clearAction.popup.shouldNotBeNull().apply { + title shouldBe "Clear Logs" + confirmButton shouldBe "Clear" + dismissButton shouldBe "Cancel" + } + } + + @Test + fun `clear action removes existing logs`() { + val module = Analytics() + + val clearAction = module.destinations[AnalyticsDestination.Main] + .shouldNotBeNull() + .actions + .single() + + AnalyticsLogger.log( + AnalyticsLog( + tag = "tap", + screenClass = "HomeScreen", + timestamp = 1_700_000_000_000, + type = AnalyticsLogCategory.Action.Click + ) + ) + + clearAction.action() + + AnalyticsLogger.hasLogs shouldBe false + } + + @Test + fun `analytics constructor keeps highlighted log types`() { + val module = Analytics( + highlightedLogType1 = AnalyticsLogCategory.Action.Scroll, + highlightedLogType2 = AnalyticsLogCategory.Performance.Metrics, + highlightedLogType3 = AnalyticsLogCategory.Social.Share + ) + + module.highlightedLogType1 shouldBe AnalyticsLogCategory.Action.Scroll + module.highlightedLogType2 shouldBe AnalyticsLogCategory.Performance.Metrics + module.highlightedLogType3 shouldBe AnalyticsLogCategory.Social.Share + } + + @Test + fun `analytics log types grouping is consistent`() { + val allTypes = AnalyticsLogType.allTypes() + + val grouped = AnalyticsLogType.typesByCategory() + + grouped.values.flatten().toSet() shouldBe allTypes.toSet() + } +} diff --git a/devview-featureflip/build.gradle.kts b/devview-featureflip/build.gradle.kts index 6d8f9c9..b1b0d99 100644 --- a/devview-featureflip/build.gradle.kts +++ b/devview-featureflip/build.gradle.kts @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.compose.multiplatform) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.deviceTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) alias(libs.plugins.poko) @@ -9,7 +12,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.featureflip" } diff --git a/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt b/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt new file mode 100644 index 0000000..5bbb84c --- /dev/null +++ b/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/FeatureFlipScreenTest.kt @@ -0,0 +1,160 @@ +package com.worldline.devview.featureflip + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.FeatureHandler +import com.worldline.devview.featureflip.model.FeatureState +import com.worldline.devview.featureflip.model.LocalFeatureHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test + +class FeatureFlipScreenTest { + + @Test + fun featureFlipScreen_renders_features_and_filters_by_query() = runComposeUiTest { + val handler = FeatureHandler( + dataStore = FakePreferencesDataStore(), + initialFeatures = listOf( + Feature.LocalFeature("dark_mode", "Dark mode", false), + Feature.RemoteFeature("new_checkout", "Checkout v2", false, FeatureState.REMOTE) + ) + ) + + setContent { + CompositionLocalProvider(LocalFeatureHandler provides handler) { + FeatureFlipScreen() + } + } + + // Verify both features are displayed + onNodeWithTag(testTag = "feature_item_dark_mode").assertIsDisplayed() + onNodeWithTag(testTag = "feature_item_new_checkout").assertIsDisplayed() + + // Filter by entering "dark" in the search field + onNodeWithTag(testTag = "feature_filter_field").performTextInput("dark") + + // After filtering, only dark_mode should be visible + onNodeWithTag(testTag = "feature_item_dark_mode").assertIsDisplayed() + onAllNodesWithTag(testTag = "feature_item_new_checkout").assertCountEquals(0) + + // Clear the filter by clicking the clear button + onNodeWithTag(testTag = "clear_feature_filter_button").performClick() + + // After clearing, new_checkout should be visible again + onNodeWithTag(testTag = "feature_item_new_checkout").assertIsDisplayed() + } + + @Test + fun featureFlipScreen_remote_chip_filters_out_local_features() = runComposeUiTest { + val handler = FeatureHandler( + dataStore = FakePreferencesDataStore(), + initialFeatures = listOf( + Feature.LocalFeature("dark_mode", null, false), + Feature.RemoteFeature("new_checkout", null, true, FeatureState.REMOTE) + ) + ) + + setContent { + CompositionLocalProvider(LocalFeatureHandler provides handler) { + FeatureFlipScreen() + } + } + + // Click the Remote filter chip + onNodeWithTag(testTag = "feature_filter_chip_REMOTE").performClick() + + // After filtering to Remote only, only new_checkout should be visible + onNodeWithTag(testTag = "feature_item_new_checkout").assertIsDisplayed() + onAllNodesWithTag(testTag = "feature_item_dark_mode").assertCountEquals(0) + } + + @Test + fun featureFlipScreen_state_changes_are_reflected_in_filters() = runComposeUiTest { + val handler = FeatureHandler( + dataStore = FakePreferencesDataStore(), + initialFeatures = listOf( + Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = false, + state = FeatureState.REMOTE + ) + ) + ) + + setContent { + CompositionLocalProvider(LocalFeatureHandler provides handler) { + FeatureFlipScreen() + } + } + + // Click the OFF filter chip to show only disabled features + onNodeWithTag(testTag = "feature_filter_chip_OFF").performClick() + onNodeWithTag(testTag = "feature_item_new_checkout").assertIsDisplayed() + + onNodeWithContentDescription(label = "LOCAL_ON").performClick() + + // After changing state, feature should be filtered out since it no longer matches OFF filter + onAllNodesWithTag(testTag = "feature_item_new_checkout").assertCountEquals(0) + } + + @Test + fun featureFlipScreen_clear_filter_icon_visibility_and_behavior() = runComposeUiTest { + val handler = FeatureHandler( + dataStore = FakePreferencesDataStore(), + initialFeatures = listOf( + Feature.LocalFeature("dark_mode", null, false), + Feature.RemoteFeature("new_checkout", null, true, FeatureState.REMOTE) + ) + ) + + setContent { + CompositionLocalProvider(LocalFeatureHandler provides handler) { + FeatureFlipScreen() + } + } + + // Initially, clear button should not be visible (no text input) + onAllNodesWithTag(testTag = "clear_feature_filter_button").assertCountEquals(0) + + // Type in filter field + onNodeWithTag(testTag = "feature_filter_field").performTextInput("dark") + + // Clear button should now be visible + onAllNodesWithTag(testTag = "clear_feature_filter_button").assertCountEquals(1) + + // Click the clear button + onNodeWithTag(testTag = "clear_feature_filter_button").performClick() + + // After clearing, button should be gone again + onAllNodesWithTag(testTag = "clear_feature_filter_button").assertCountEquals(0) + + // Both features should be visible again + onNodeWithTag(testTag = "feature_item_dark_mode").assertIsDisplayed() + onNodeWithTag(testTag = "feature_item_new_checkout").assertIsDisplayed() + } +} + +private class FakePreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } +} diff --git a/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/components/FeatureTriStateSwitchTest.kt b/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/components/FeatureTriStateSwitchTest.kt new file mode 100644 index 0000000..53d3d89 --- /dev/null +++ b/devview-featureflip/src/androidDeviceTest/kotlin/com/worldline/devview/featureflip/components/FeatureTriStateSwitchTest.kt @@ -0,0 +1,49 @@ +package com.worldline.devview.featureflip.components + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.featureflip.model.Feature +import com.worldline.devview.featureflip.model.FeatureState +import kotlin.test.assertEquals +import org.junit.Test + +class FeatureTriStateSwitchTest { + + @Test + fun triStateSwitch_shows_all_states_and_notifies_state_changes() = runComposeUiTest { + var latestState: FeatureState? = null + + setContent { + val state = remember { mutableStateOf(FeatureState.REMOTE) } + val feature = Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = false, + state = state.value + ) + + FeatureTriStateSwitch( + feature = feature, + onStateChange = { + state.value = it + latestState = it + } + ) + } + + onNodeWithContentDescription(label = "REMOTE").assertIsDisplayed() + onNodeWithContentDescription(label = "LOCAL_OFF").assertIsDisplayed() + onNodeWithContentDescription(label = "LOCAL_ON").assertIsDisplayed() + + onNodeWithContentDescription(label = "LOCAL_ON").performClick() + + runOnIdle { + assertEquals(FeatureState.LOCAL_ON, latestState) + } + } +} + diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt index 7633f4d..1fa84a0 100644 --- a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlipScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -196,6 +197,9 @@ public fun FeatureFlipScreen(modifier: Modifier = Modifier, bottomPadding: Dp = .forEach { (state, selected) -> item { FilterChip( + modifier = Modifier.testTag( + tag = "feature_filter_chip_${state.name}" + ), selected = selected, label = { Text( @@ -226,7 +230,8 @@ public fun FeatureFlipScreen(modifier: Modifier = Modifier, bottomPadding: Dp = OutlinedTextField( modifier = Modifier .padding(all = 8.dp) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(tag = "feature_filter_field"), value = filterQuery, onValueChange = { filterQuery = it @@ -240,7 +245,12 @@ public fun FeatureFlipScreen(modifier: Modifier = Modifier, bottomPadding: Dp = }, trailingIcon = { AnimatedVisibility(visible = filterQuery.isNotEmpty()) { - IconButton(onClick = { filterQuery = "" }) { + IconButton( + modifier = Modifier.testTag( + tag = "clear_feature_filter_button" + ), + onClick = { filterQuery = "" } + ) { Icon( imageVector = Icons.Rounded.Close, contentDescription = "Clear filter" @@ -279,6 +289,7 @@ public fun FeatureFlipScreen(modifier: Modifier = Modifier, bottomPadding: Dp = Column( modifier = Modifier .animateItem() + .testTag(tag = "feature_item_${feature.name}") ) { FeatureItem( modifier = Modifier diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt index 49cc227..3105bba 100644 --- a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/components/FeatureItem.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -83,6 +84,7 @@ internal fun FeatureItem( verticalArrangement = Arrangement.spacedBy(space = 4.dp) ) { Text( + modifier = Modifier.testTag(tag = "feature_name_${feature.name}"), text = feature.name, style = MaterialTheme.typography.bodyLarge.copy( fontWeight = FontWeight.Bold @@ -90,6 +92,7 @@ internal fun FeatureItem( ) feature.description?.let { Text( + modifier = Modifier.testTag(tag = "feature_description_${feature.name}"), text = it, style = MaterialTheme.typography.bodySmall ) @@ -98,6 +101,7 @@ internal fun FeatureItem( when (feature) { is LocalFeature -> Switch( + modifier = Modifier.testTag(tag = "feature_switch_${feature.name}"), checked = feature.isEnabled, onCheckedChange = { onStateChange(if (it) FeatureState.LOCAL_ON else FeatureState.LOCAL_OFF) @@ -105,6 +109,7 @@ internal fun FeatureItem( ) is RemoteFeature -> FeatureTriStateSwitch( + modifier = Modifier.testTag(tag = "feature_tri_state_switch_${feature.name}"), feature = feature, onStateChange = onStateChange ) diff --git a/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/FeatureFlipModuleTest.kt b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/FeatureFlipModuleTest.kt new file mode 100644 index 0000000..3828aad --- /dev/null +++ b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/FeatureFlipModuleTest.kt @@ -0,0 +1,29 @@ +package com.worldline.devview.featureflip + +import com.worldline.devview.core.Section +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class FeatureFlipModuleTest { + + @Test + fun `feature flip module exposes expected metadata`() { + FeatureFlip.section shouldBe Section.FEATURES + FeatureFlip.dataStoreName shouldBe FEATURE_FLIP_DATASTORE_NAME + + FeatureFlip.destinations.keys.shouldContain(FeatureFlipDestination.Main) + + val metadata = FeatureFlip.destinations[FeatureFlipDestination.Main].shouldNotBeNull() + metadata.title shouldBe "Feature Flip" + metadata.actions shouldHaveSize 0 + } + + @Test + fun `feature flip module has a datastore delegate instance`() { + FeatureFlip.dataStoreDelegate.shouldNotBeNull() + } +} + diff --git a/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureHandlerTest.kt b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureHandlerTest.kt new file mode 100644 index 0000000..0db1a8d --- /dev/null +++ b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureHandlerTest.kt @@ -0,0 +1,84 @@ +package com.worldline.devview.featureflip.model + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class FeatureHandlerTest { + + @Test + fun `addFeatures persists initial values used by enabled flow`() = runTest { + val store = FakePreferencesDataStore() + val local = Feature.LocalFeature(name = "dark_mode", description = null, isEnabled = true) + val remote = Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = false, + state = FeatureState.LOCAL_ON + ) + val handler = FeatureHandler(dataStore = store, initialFeatures = emptyList()) + + handler.addFeatures(listOf(local, remote)) + + handler.isFeatureEnabledFlow("dark_mode").first() shouldBe true + handler.isFeatureEnabledFlow("new_checkout").first() shouldBe true + } + + @Test + fun `setFeatureState updates local and remote features`() = runTest { + val store = FakePreferencesDataStore() + val local = Feature.LocalFeature(name = "dark_mode", description = null, isEnabled = false) + val remote = Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = true, + state = FeatureState.REMOTE + ) + val handler = FeatureHandler(dataStore = store, initialFeatures = listOf(local, remote)) + + handler.setFeatureState("dark_mode", FeatureState.LOCAL_ON) + handler.setFeatureState("new_checkout", FeatureState.LOCAL_OFF) + + handler.isFeatureEnabledFlow("dark_mode").first() shouldBe true + handler.isFeatureEnabledFlow("new_checkout").first() shouldBe false + } + + @Test + fun `setFeatureState throws when setting REMOTE for local feature`() = runTest { + val local = Feature.LocalFeature(name = "dark_mode", description = null, isEnabled = false) + val handler = FeatureHandler(dataStore = FakePreferencesDataStore(), initialFeatures = listOf(local)) + + shouldThrow { + handler.setFeatureState("dark_mode", FeatureState.REMOTE) + } + } + + @Test + fun `isFeatureEnabledFlow throws for unknown feature`() = runTest { + val handler = FeatureHandler(dataStore = FakePreferencesDataStore(), initialFeatures = emptyList()) + + shouldThrow { + handler.isFeatureEnabledFlow("missing").first() + } + } +} + +private class FakePreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } +} + diff --git a/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureStateTest.kt b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureStateTest.kt new file mode 100644 index 0000000..22c5aeb --- /dev/null +++ b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureStateTest.kt @@ -0,0 +1,23 @@ +package com.worldline.devview.featureflip.model + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class FeatureStateTest { + + @Test + fun `fromOrdinal maps valid ordinals`() { + FeatureState.fromOrdinal(0) shouldBe FeatureState.REMOTE + FeatureState.fromOrdinal(1) shouldBe FeatureState.LOCAL_OFF + FeatureState.fromOrdinal(2) shouldBe FeatureState.LOCAL_ON + } + + @Test + fun `fromOrdinal throws for unknown ordinal`() { + shouldThrow { + FeatureState.fromOrdinal(3) + } + } +} + diff --git a/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureTest.kt b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureTest.kt new file mode 100644 index 0000000..2278ec0 --- /dev/null +++ b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureTest.kt @@ -0,0 +1,57 @@ +package com.worldline.devview.featureflip.model + +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class FeatureTest { + + @Test + fun `remote feature isEnabled reflects remote state`() { + Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = true, + state = FeatureState.REMOTE + ).isEnabled shouldBe true + + Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = false, + state = FeatureState.REMOTE + ).isEnabled shouldBe false + } + + @Test + fun `remote feature local overrides take precedence`() { + Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = false, + state = FeatureState.LOCAL_ON + ).isEnabled shouldBe true + + Feature.RemoteFeature( + name = "new_checkout", + description = null, + defaultRemoteValue = true, + state = FeatureState.LOCAL_OFF + ).isEnabled shouldBe false + } + + @Test + fun `local feature isEnabled returns stored value`() { + Feature.LocalFeature( + name = "dark_mode", + description = null, + isEnabled = true + ).isEnabled shouldBe true + + Feature.LocalFeature( + name = "dark_mode", + description = null, + isEnabled = false + ).isEnabled shouldBe false + } +} + diff --git a/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureTypeTest.kt b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureTypeTest.kt new file mode 100644 index 0000000..84192fd --- /dev/null +++ b/devview-featureflip/src/commonTest/kotlin/com/worldline/devview/featureflip/model/FeatureTypeTest.kt @@ -0,0 +1,22 @@ +package com.worldline.devview.featureflip.model + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class FeatureTypeTest { + + @Test + fun `fromOrdinal maps valid ordinals`() { + FeatureType.fromOrdinal(0) shouldBe FeatureType.REMOTE + FeatureType.fromOrdinal(1) shouldBe FeatureType.LOCAL + } + + @Test + fun `fromOrdinal throws for unknown ordinal`() { + shouldThrow { + FeatureType.fromOrdinal(2) + } + } +} + diff --git a/devview-networkmock-core/build.gradle.kts b/devview-networkmock-core/build.gradle.kts index 9834ed3..58fa8be 100644 --- a/devview-networkmock-core/build.gradle.kts +++ b/devview-networkmock-core/build.gradle.kts @@ -1,7 +1,8 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.compose.multiplatform) - alias(libs.plugins.convention.datastore) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) } @@ -9,7 +10,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.networkmock.core" } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockDataStoreDelegate.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/NetworkMockDataStoreDelegate.kt similarity index 97% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockDataStoreDelegate.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/NetworkMockDataStoreDelegate.kt index 4f9f10a..184bca5 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockDataStoreDelegate.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/NetworkMockDataStoreDelegate.kt @@ -1,4 +1,4 @@ -package com.worldline.devview.networkmock +package com.worldline.devview.networkmock.core import com.worldline.devview.utils.DataStoreDelegate diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockInitializer.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/NetworkMockInitializer.kt similarity index 88% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockInitializer.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/NetworkMockInitializer.kt index abb0ecb..1aff4b2 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockInitializer.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/NetworkMockInitializer.kt @@ -1,11 +1,11 @@ -package com.worldline.devview.networkmock +package com.worldline.devview.networkmock.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import com.worldline.devview.networkmock.repository.MockConfigRepository -import com.worldline.devview.networkmock.repository.MockStateRepository +import com.worldline.devview.networkmock.core.repository.MockConfigRepository +import com.worldline.devview.networkmock.core.repository.MockStateRepository /** * Singleton initializer for the network mock module's repositories. @@ -61,7 +61,11 @@ public object NetworkMockInitializer { resourceLoader: suspend (String) -> ByteArray ) { if (stateRepository != null) return - stateRepository = remember { MockStateRepository(dataStore = dataStore) } + stateRepository = remember { + MockStateRepository( + dataStore = dataStore + ) + } configRepository = remember { MockConfigRepository( configPath = configPath, @@ -71,7 +75,7 @@ public object NetworkMockInitializer { } /** - * Returns the [MockStateRepository] instance. + * Returns the [com.worldline.devview.networkmock.core.repository.MockStateRepository] instance. * * Used by both the UI layer (`devview-networkmock`) and the Ktor plugin * (`devview-networkmock-ktor`) to read and update mock state. @@ -86,7 +90,7 @@ public object NetworkMockInitializer { ) /** - * Returns the [MockConfigRepository] instance. + * Returns the [com.worldline.devview.networkmock.core.repository.MockConfigRepository] instance. * * Used by both the UI layer (`devview-networkmock`) and the Ktor plugin * (`devview-networkmock-ktor`) to load mock configuration and response files. diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt similarity index 97% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt index e53651b..f71a95d 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt @@ -1,4 +1,4 @@ -package com.worldline.devview.networkmock.model +package com.worldline.devview.networkmock.core.model import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable @@ -53,7 +53,7 @@ import kotlinx.serialization.Serializable * @property hosts List of host configurations, each containing API endpoints * @see HostConfig * @see EndpointConfig - * @see com.worldline.devview.networkmock.repository.MockConfigRepository + * @see com.worldline.devview.networkmock.core.repository.MockConfigRepository */ @Serializable public data class MockConfiguration(val hosts: List) diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt similarity index 96% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt index b951989..1b5aac3 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt @@ -1,7 +1,7 @@ -package com.worldline.devview.networkmock.model +package com.worldline.devview.networkmock.core.model import androidx.compose.runtime.Immutable -import com.worldline.devview.networkmock.utils.parseStatusCode +import com.worldline.devview.networkmock.core.utils.parseStatusCode /** * Represents a loaded mock response that can be returned by the network mock plugin. @@ -71,7 +71,7 @@ import com.worldline.devview.networkmock.utils.parseStatusCode * @property content The raw JSON response body as a String * @see MockConfiguration * @see EndpointConfig - * @see com.worldline.devview.networkmock.repository.MockConfigRepository + * @see com.worldline.devview.networkmock.core.repository.MockConfigRepository */ @Immutable public data class MockResponse( @@ -94,7 +94,7 @@ public data class MockResponse( * - `get-user-200.json` → status = 200 (hyphenated endpoint ID supported) * * Status code extraction is delegated to - * [parseStatusCode][com.worldline.devview.networkmock.utils.parseStatusCode], which is the + * [parseStatusCode][com.worldline.devview.networkmock.core.utils.parseStatusCode], which is the * single source of truth for this parsing logic. * * ## Custom Status Text @@ -255,7 +255,7 @@ public data class MockResponse( * @property config The complete endpoint configuration * @see MockConfiguration * @see EndpointConfig - * @see com.worldline.devview.networkmock.repository.MockConfigRepository.findMatchingMock + * @see com.worldline.devview.networkmock.core.repository.MockConfigRepository.findMatchingMock */ @Immutable public data class MockMatch(val hostId: String, val endpointId: String, val config: EndpointConfig) diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt similarity index 94% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt index ec1fac7..42e0357 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/NetworkMockState.kt @@ -1,7 +1,7 @@ -package com.worldline.devview.networkmock.model +package com.worldline.devview.networkmock.core.model import androidx.compose.runtime.Immutable -import com.worldline.devview.networkmock.utils.parseStatusCode +import com.worldline.devview.networkmock.core.utils.parseStatusCode import kotlin.time.Clock import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -22,7 +22,7 @@ import kotlinx.serialization.json.JsonClassDiscriminator * * ## Persistence * State is automatically saved to DataStore when changes are made through the - * [com.worldline.devview.networkmock.repository.MockStateRepository]: + * [com.worldline.devview.networkmock.core.repository.MockStateRepository]: * ```kotlin * val repository = MockStateRepository(dataStore) * @@ -56,7 +56,7 @@ import kotlinx.serialization.json.JsonClassDiscriminator * @property endpointStates Map of endpoint states, keyed by "{hostId}-{endpointId}" * @property lastModified Timestamp (milliseconds since epoch) of last state modification * @see EndpointMockState - * @see com.worldline.devview.networkmock.repository.MockStateRepository + * @see com.worldline.devview.networkmock.core.repository.MockStateRepository */ @Serializable public data class NetworkMockState( @@ -188,7 +188,7 @@ public sealed interface EndpointMockState { * * @property responseFile The file name of the selected mock response * (e.g. `"getUser-200.json"`). Used as the key to load the response via - * [com.worldline.devview.networkmock.repository.MockConfigRepository.loadMockResponse]. + * [com.worldline.devview.networkmock.core.repository.MockConfigRepository.loadMockResponse]. * The status code and display name are derived from this name at runtime — * they are not stored here to avoid redundancy with [MockResponse]. */ @@ -208,7 +208,7 @@ public sealed interface EndpointMockState { * format. * * Computed on each access by delegating to - * [com.worldline.devview.networkmock.utils.parseStatusCode] — the single + * [parseStatusCode] — the single * source of truth for status-code extraction from response file names. * * ```kotlin diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/StatusCodeFamily.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/StatusCodeFamily.kt similarity index 93% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/StatusCodeFamily.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/StatusCodeFamily.kt index cdddf05..210b0ae 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/StatusCodeFamily.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/StatusCodeFamily.kt @@ -1,4 +1,4 @@ -package com.worldline.devview.networkmock.model +package com.worldline.devview.networkmock.core.model public enum class StatusCodeFamily { INFORMATIONAL, diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt similarity index 92% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt index a1f88ad..335e8b8 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt @@ -1,8 +1,8 @@ -package com.worldline.devview.networkmock.repository +package com.worldline.devview.networkmock.core.repository -import com.worldline.devview.networkmock.model.MockConfiguration -import com.worldline.devview.networkmock.model.MockMatch -import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.core.model.MockConfiguration +import com.worldline.devview.networkmock.core.model.MockMatch +import com.worldline.devview.networkmock.core.model.MockResponse import kotlinx.serialization.json.Json /** @@ -102,9 +102,9 @@ import kotlinx.serialization.json.Json * @property statusCodesToDiscover The list of HTTP status codes to probe when * discovering response files. Defaults to [DEFAULT_STATUS_CODES]. Override this * to include non-standard status codes used by your API. - * @see MockConfiguration - * @see MockResponse - * @see MockMatch + * @see com.worldline.devview.networkmock.core.model.MockConfiguration + * @see com.worldline.devview.networkmock.core.model.MockResponse + * @see com.worldline.devview.networkmock.core.model.MockMatch * @see RequestMatcher */ public class MockConfigRepository( @@ -115,7 +115,7 @@ public class MockConfigRepository( private val json = Json { ignoreUnknownKeys = true } // Cache the loaded configuration to avoid re-parsing - private var cachedConfig: MockConfiguration? = null + private var cachedConfig: com.worldline.devview.networkmock.core.model.MockConfiguration? = null public companion object { /** @@ -186,7 +186,10 @@ public class MockConfigRepository( val configBytes = resourceLoader(configPath) val configJson = configBytes.decodeToString() - val config = json.decodeFromString(string = configJson) + val config = json + .decodeFromString( + string = configJson + ) cachedConfig = config println(message = "[NetworkMock][Config] Successfully loaded configuration:") @@ -215,7 +218,7 @@ public class MockConfigRepository( * * This method performs the following matching steps: * 1. Extract hostname from the host parameter - * 2. Find a [com.worldline.devview.networkmock.model.HostConfig] with matching hostname (case-insensitive) + * 2. Find a [HostConfig] with matching hostname (case-insensitive) * 3. For each endpoint in that host, check if the path and method match * 4. Use [RequestMatcher] to handle path parameters (e.g., `/users/{userId}`) * @@ -354,7 +357,12 @@ public class MockConfigRepository( println( message = "[NetworkMock][Discovery] Discovered ${responses.size} response file(s) for '$endpointId'" ) - return responses.sortedBy { it.statusCode } + + // A custom statusCodesToDiscover list may accidentally include duplicates. + // Deduplicate by file name so callers get a stable set of discovered files. + return responses + .distinctBy { it.fileName } + .sortedBy { it.statusCode } } /** @@ -397,7 +405,11 @@ public class MockConfigRepository( ): MockResponse? = runCatching { val responseBytes = resourceLoader(filePath) val content = responseBytes.decodeToString() - MockResponse.fromFile(fileName = fileName, content = content) + MockResponse.Companion + .fromFile( + fileName = fileName, + content = content + ) }.getOrNull() /** diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepository.kt similarity index 98% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepository.kt index 09e971e..a9638e7 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepository.kt @@ -1,4 +1,4 @@ -package com.worldline.devview.networkmock.repository +package com.worldline.devview.networkmock.core.repository import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -7,8 +7,8 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -import com.worldline.devview.networkmock.model.EndpointMockState -import com.worldline.devview.networkmock.model.NetworkMockState +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.NetworkMockState import kotlin.time.Clock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -353,7 +353,7 @@ public class MockStateRepository(private val dataStore: DataStore) * (before any writes). * * @param endpoints List of `(hostId, endpointId)` pairs from the loaded - * [com.worldline.devview.networkmock.model.MockConfiguration] + * [com.worldline.devview.networkmock.core.model.MockConfiguration] */ public fun registerEndpoints(endpoints: List>) { endpoints.forEach { (hostId, endpointId) -> diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/RequestMatcher.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt similarity index 99% rename from devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/RequestMatcher.kt rename to devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt index 13356f1..ff1dce4 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/RequestMatcher.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt @@ -1,4 +1,4 @@ -package com.worldline.devview.networkmock.repository +package com.worldline.devview.networkmock.core.repository /** * Utility object for matching HTTP request paths against configured endpoint paths. diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/utils/MockFileNameUtils.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/utils/MockFileNameUtils.kt new file mode 100644 index 0000000..22f12b2 --- /dev/null +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/utils/MockFileNameUtils.kt @@ -0,0 +1,49 @@ +package com.worldline.devview.networkmock.core.utils + +/** + * Parses the HTTP status code from a response file name. + * + * This is the single source of truth for status-code extraction from mock + * response file names. It is used by both + * [fromFile][com.worldline.devview.networkmock.core.model.MockResponse.Companion.fromFile] when building + * a [MockResponse][com.worldline.devview.networkmock.core.model.MockResponse] from disk, and by + * [Mock.statusCode][com.worldline.devview.networkmock.core.model.EndpointMockState.Mock.statusCode] + * when a quick status-code lookup is needed without loading the full response. + * + * ## File name format + * Expected format: `{endpointId}-{statusCode}[-{suffix}].json` + * + * The status code is located by searching from the right for a `-` followed by + * exactly three digits, making this robust to endpoint IDs that themselves + * contain hyphens. + * + * ## Extension handling + * The `.json` extension is stripped before matching via [String.removeSuffix]. If the + * input has no `.json` extension, `removeSuffix` is a no-op and the regex is applied + * to the string as-is. A file name without a `.json` extension is therefore still + * parsed correctly as long as it otherwise follows the naming convention. + * + * ## Examples + * ```kotlin + * "getUser-200.json".parseStatusCode() // 200 + * "getUser-404-simple.json".parseStatusCode() // 404 + * "get-user-500.json".parseStatusCode() // 500 + * "getUser-200".parseStatusCode() // 200 (no extension — still valid) + * "getUser.json".parseStatusCode() // null (no three-digit code) + * "getUser-json.json".parseStatusCode() // null (non-numeric code segment) + * "getUser-20.json".parseStatusCode() // null (only two digits) + * "".parseStatusCode() // null (empty string) + * ``` + * + * @receiver The response file name to parse (e.g. `"getUser-200.json"`) + * @return The 3-digit HTTP status code, or `null` if the file name does not + * contain a `-{3 digits}` segment (with or without a `.json` extension) + */ +internal fun String.parseStatusCode(): Int? { + val nameWithoutExtension = removeSuffix(suffix = ".json") + return Regex(pattern = """-(\d{3})(-.+)?$""") + .find(input = nameWithoutExtension) + ?.groupValues + ?.get(index = 1) + ?.toIntOrNull() +} diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/MockFileNameUtils.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/MockFileNameUtils.kt deleted file mode 100644 index bc8be98..0000000 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/MockFileNameUtils.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.worldline.devview.networkmock.utils - -/** - * Parses the HTTP status code from a response file name. - * - * This is the single source of truth for status-code extraction from mock - * response file names. It is used by both - * [fromFile][com.worldline.devview.networkmock.model.MockResponse.fromFile] when building - * a [MockResponse][com.worldline.devview.networkmock.model.MockResponse] from disk, and by - * [Mock.statusCode][com.worldline.devview.networkmock.model.EndpointMockState.Mock.statusCode] - * when a quick status-code lookup is needed without loading the full response. - * - * ## File name format - * Expected format: `{endpointId}-{statusCode}[-{suffix}].json` - * - * The status code is located by searching from the right for a `-` followed by - * exactly three digits, making this robust to endpoint IDs that themselves - * contain hyphens. - * - * ## Examples - * ```kotlin - * "getUser-200.json".parseStatusCode() // 200 - * "getUser-404-simple.json".parseStatusCode() // 404 - * "get-user-500.json".parseStatusCode() // 500 - * "malformed.json".parseStatusCode() // null - * ``` - * - * @receiver The response file name to parse (e.g. `"getUser-200.json"`) - * @return The 3-digit HTTP status code, or `null` if the file name does not - * match the expected `{endpointId}-{statusCode}[-{suffix}].json` format - */ -internal fun String.parseStatusCode(): Int? { - val nameWithoutExtension = removeSuffix(suffix = ".json") - return Regex(pattern = """-(\d{3})(-.+)?$""") - .find(input = nameWithoutExtension) - ?.groupValues - ?.get(index = 1) - ?.toIntOrNull() -} diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/fixtures/FakePreferencesDataStore.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/fixtures/FakePreferencesDataStore.kt new file mode 100644 index 0000000..3de8786 --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/fixtures/FakePreferencesDataStore.kt @@ -0,0 +1,48 @@ +package com.worldline.devview.networkmock.core.fixtures + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import okio.IOException + +/** + * In-memory [DataStore] implementation for use in unit tests. + * + * Stores preferences in a [MutableStateFlow] so that [data] emits synchronously + * on every [updateData] call, making coroutine-based assertions straightforward + * with [kotlinx.coroutines.flow.first] or Turbine. + */ +internal class FakePreferencesDataStore : DataStore { + + private val state = MutableStateFlow(value = emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences + ): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } +} + +/** + * A [DataStore] implementation that immediately throws an [IOException] from [data], + * used to verify that [com.worldline.devview.networkmock.core.repository.MockStateRepository.observeState] + * recovers gracefully and emits a safe default state instead of propagating the exception. + */ +internal class ThrowingPreferencesDataStore : DataStore { + + override val data: Flow = flow { + throw IOException("Simulated DataStore read failure") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences + ): Preferences = throw IOException("Simulated DataStore write failure") +} + diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/fixtures/MockTestData.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/fixtures/MockTestData.kt new file mode 100644 index 0000000..30feb1b --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/fixtures/MockTestData.kt @@ -0,0 +1,156 @@ +package com.worldline.devview.networkmock.core.fixtures + +import com.worldline.devview.networkmock.core.model.EndpointConfig +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.HostConfig +import com.worldline.devview.networkmock.core.model.MockConfiguration +import com.worldline.devview.networkmock.core.model.NetworkMockState + +/** + * Shared test data fixtures for the networkmock-core module. + * + * All builders produce immutable values and are safe to reuse across tests. + * Prefer these helpers over inline object construction so that test assertions + * stay focused on the behaviour being tested rather than on data setup. + */ +internal object MockTestData { + + // ------------------------------------------------------------------------- + // EndpointConfig builders + // ------------------------------------------------------------------------- + + /** A minimal GET endpoint with no path parameters. */ + fun endpointConfig( + id: String = "getUser", + name: String = "Get User", + path: String = "/api/users", + method: String = "GET", + ): EndpointConfig = EndpointConfig(id = id, name = name, path = path, method = method) + + /** An endpoint whose path contains a single path parameter. */ + fun endpointConfigWithParam( + id: String = "getUserById", + name: String = "Get User By ID", + path: String = "/api/users/{userId}", + method: String = "GET", + ): EndpointConfig = EndpointConfig(id = id, name = name, path = path, method = method) + + /** A POST endpoint (no path parameters). */ + fun postEndpointConfig( + id: String = "createUser", + name: String = "Create User", + path: String = "/api/users", + method: String = "POST", + ): EndpointConfig = EndpointConfig(id = id, name = name, path = path, method = method) + + // ------------------------------------------------------------------------- + // HostConfig builders + // ------------------------------------------------------------------------- + + /** A staging host with a single GET endpoint (no path parameters). */ + fun stagingHostConfig( + id: String = "staging", + url: String = "https://staging.api.example.com", + endpoints: List = listOf(endpointConfig()), + ): HostConfig = HostConfig(id = id, url = url, endpoints = endpoints) + + /** A production host with a single endpoint. */ + fun productionHostConfig( + id: String = "production", + url: String = "https://api.example.com", + endpoints: List = listOf(endpointConfig()), + ): HostConfig = HostConfig(id = id, url = url, endpoints = endpoints) + + /** A host with multiple endpoints (GET + POST). */ + fun multiEndpointHostConfig( + id: String = "staging", + url: String = "https://staging.api.example.com", + ): HostConfig = HostConfig( + id = id, + url = url, + endpoints = listOf( + endpointConfig(id = "getUser", path = "/api/users/{userId}", method = "GET"), + postEndpointConfig(id = "createUser", path = "/api/users", method = "POST"), + endpointConfig(id = "deleteUser", path = "/api/users/{userId}", method = "DELETE"), + ) + ) + + // ------------------------------------------------------------------------- + // MockConfiguration builders + // ------------------------------------------------------------------------- + + /** A configuration with a single staging host. */ + fun singleHostConfig( + host: HostConfig = stagingHostConfig(), + ): MockConfiguration = MockConfiguration(hosts = listOf(host)) + + /** A configuration with both a staging and production host. */ + fun multiHostConfig(): MockConfiguration = MockConfiguration( + hosts = listOf(stagingHostConfig(), productionHostConfig()) + ) + + /** An empty configuration with no hosts. */ + fun emptyConfig(): MockConfiguration = MockConfiguration(hosts = emptyList()) + + // ------------------------------------------------------------------------- + // EndpointMockState variations + // ------------------------------------------------------------------------- + + /** Network (pass-through) state — the default for every endpoint. */ + val networkState: EndpointMockState = EndpointMockState.Network + + /** A mock state backed by a 200 OK response file. */ + fun mockState200(endpointId: String = "getUser"): EndpointMockState.Mock = + EndpointMockState.Mock(responseFile = "$endpointId-200.json") + + /** A mock state backed by a 404 Not Found response file. */ + fun mockState404(endpointId: String = "getUser"): EndpointMockState.Mock = + EndpointMockState.Mock(responseFile = "$endpointId-404.json") + + /** A mock state backed by a 500 Internal Server Error response file. */ + fun mockState500(endpointId: String = "getUser"): EndpointMockState.Mock = + EndpointMockState.Mock(responseFile = "$endpointId-500.json") + + /** A mock state with an explicit suffix in the file name (e.g. `getUser-404-simple.json`). */ + fun mockStateWithSuffix( + endpointId: String = "getUser", + statusCode: Int = 404, + suffix: String = "simple", + ): EndpointMockState.Mock = + EndpointMockState.Mock(responseFile = "$endpointId-$statusCode-$suffix.json") + + // ------------------------------------------------------------------------- + // NetworkMockState builders + // ------------------------------------------------------------------------- + + /** Default state: global mocking disabled, no endpoint states. */ + val defaultNetworkMockState: NetworkMockState = NetworkMockState() + + /** State with global mocking enabled but no individual endpoint overrides. */ + val globalMockingEnabled: NetworkMockState = NetworkMockState(globalMockingEnabled = true) + + /** + * State with global mocking enabled and a single endpoint set to a 200 mock. + * + * Key used: `"staging-getUser"`. + */ + val singleEndpointMocked: NetworkMockState = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf("staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json")) + ) + + /** + * State with global mocking enabled and two endpoints configured + * with different mock responses. + * + * Keys used: `"staging-getUser"`, `"staging-createUser"`. + */ + val multipleEndpointsMocked: NetworkMockState = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json"), + "staging-createUser" to EndpointMockState.Mock(responseFile = "createUser-201.json"), + ) + ) +} + diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/model/MockResponseTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/model/MockResponseTest.kt new file mode 100644 index 0000000..798ecf0 --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/model/MockResponseTest.kt @@ -0,0 +1,90 @@ +package com.worldline.devview.networkmock.core.model + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.test.assertNotNull + +class MockResponseTest { + + @Test + fun `fromFile returns null for invalid file names`() { + MockResponse.fromFile(fileName = "invalid.json", content = "{}") shouldBe null + MockResponse.fromFile(fileName = "endpoint-no-status.json", content = "{}") shouldBe null + MockResponse.fromFile(fileName = "", content = "{}") shouldBe null + } + + @Test + fun `fromFile builds response with default status text and no suffix`() { + val response = MockResponse.fromFile( + fileName = "getUser-200.json", + content = "{\"id\":\"1\"}" + ) + + assertNotNull(response) + response.statusCode shouldBe 200 + response.fileName shouldBe "getUser-200.json" + response.displayName shouldBe "Success (200)" + response.content shouldBe "{\"id\":\"1\"}" + } + + @Test + fun `fromFile capitalizes single suffix token in display name`() { + val response = MockResponse.fromFile( + fileName = "getUser-404-simple.json", + content = "{}" + ) + + assertNotNull(response) + response.statusCode shouldBe 404 + response.displayName shouldBe "Not Found - Simple (404)" + } + + @Test + fun `fromFile capitalizes multi token suffix and preserves spaces`() { + val response = MockResponse.fromFile( + fileName = "getUser-404-not-found.json", + content = "{}" + ) + + assertNotNull(response) + response.displayName shouldBe "Not Found - Not Found (404)" + } + + @Test + fun `fromFile supports hyphenated endpoint id`() { + val response = MockResponse.fromFile( + fileName = "get-user-profile-201.json", + content = "{}" + ) + + assertNotNull(response) + response.statusCode shouldBe 201 + response.displayName shouldBe "Created (201)" + } + + @Test + fun `fromFile falls back to HTTP code text when status is unknown`() { + val response = MockResponse.fromFile( + fileName = "batch-599.json", + content = "{}" + ) + + assertNotNull(response) + response.statusCode shouldBe 599 + response.displayName shouldBe "HTTP 599 (599)" + } + + @Test + fun `fromFile uses custom status text provider`() { + val response = MockResponse.fromFile( + fileName = "checkout-422-validation-error.json", + content = "{}", + statusTextProvider = { code -> "Code $code" } + ) + + assertNotNull(response) + response.statusCode shouldBe 422 + response.displayName shouldBe "Code 422 - Validation Error (422)" + } +} + diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/model/StatusCodeFamilyTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/model/StatusCodeFamilyTest.kt new file mode 100644 index 0000000..f412fce --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/model/StatusCodeFamilyTest.kt @@ -0,0 +1,37 @@ +package com.worldline.devview.networkmock.core.model + +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.checkAll +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class StatusCodeFamilyTest { + + @Test + fun `fromStatusCode maps any integer to the expected family`() = runTest { + checkAll(iterations = 1_000, genA = Arb.int()) { statusCode -> + val expected = when (statusCode) { + in 100..199 -> StatusCodeFamily.INFORMATIONAL + in 200..299 -> StatusCodeFamily.SUCCESSFUL + in 300..399 -> StatusCodeFamily.REDIRECTION + in 400..499 -> StatusCodeFamily.CLIENT_ERROR + in 500..599 -> StatusCodeFamily.SERVER_ERROR + else -> StatusCodeFamily.UNKNOWN + } + + StatusCodeFamily.fromStatusCode(statusCode = statusCode) shouldBe expected + } + } + + @Test + fun `displayName returns expected user-facing labels`() { + StatusCodeFamily.INFORMATIONAL.displayName shouldBe "Informational" + StatusCodeFamily.SUCCESSFUL.displayName shouldBe "Successful" + StatusCodeFamily.REDIRECTION.displayName shouldBe "Redirection" + StatusCodeFamily.CLIENT_ERROR.displayName shouldBe "Client Error" + StatusCodeFamily.SERVER_ERROR.displayName shouldBe "Server Error" + StatusCodeFamily.UNKNOWN.displayName shouldBe "Unknown" + } +} diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt new file mode 100644 index 0000000..64e4ee5 --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -0,0 +1,356 @@ +package com.worldline.devview.networkmock.core.repository + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class MockConfigRepositoryTest { + + @Test + fun `loadConfiguration returns parsed configuration`() = runTest { + val repository = createRepository( + resources = baseResources() + ) + + val result = repository.loadConfiguration() + + result.isSuccess shouldBe true + val config = result.getOrThrow() + config.hosts shouldHaveSize 2 + config.hosts[0].id shouldBe "staging" + config.hosts[1].id shouldBe "production" + } + + @Test + fun `loadConfiguration uses cache and avoids second file read`() = runTest { + val loader = RecordingResourceLoader(resources = baseResources()) + val repository = MockConfigRepository( + configPath = CONFIG_PATH, + resourceLoader = loader::load + ) + + repository.loadConfiguration().getOrThrow() + repository.loadConfiguration().getOrThrow() + + loader.callCount(path = CONFIG_PATH) shouldBe 1 + } + + @Test + fun `loadConfiguration returns failure when config file is missing`() = runTest { + val repository = createRepository(resources = emptyMap()) + + val result = repository.loadConfiguration() + + result.isFailure shouldBe true + } + + @Test + fun `loadConfiguration returns failure when config json is malformed`() = runTest { + val repository = createRepository( + resources = mapOf( + CONFIG_PATH to """{ "hosts": [ {""" + ) + ) + + val result = repository.loadConfiguration() + + result.isFailure shouldBe true + } + + @Test + fun `findMatchingMock returns endpoint for exact host path and method`() = runTest { + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "staging.api.example.com", + path = "/api/users/42", + method = "GET" + ) + + match?.hostId shouldBe "staging" + match?.endpointId shouldBe "getUser" + match?.config?.method shouldBe "GET" + } + + @Test + fun `findMatchingMock host matching is case insensitive`() = runTest { + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "STAGING.API.EXAMPLE.COM", + path = "/api/users/42", + method = "GET" + ) + + match?.endpointId shouldBe "getUser" + } + + @Test + fun `findMatchingMock handles configured url with scheme port and path`() = runTest { + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "staging.api.example.com", + path = "/api/users/42", + method = "GET" + ) + + match?.hostId shouldBe "staging" + } + + @Test + fun `findMatchingMock method matching is case sensitive`() = runTest { + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "staging.api.example.com", + path = "/api/users/42", + method = "get" + ) + + match.shouldBeNull() + } + + @Test + fun `findMatchingMock returns null when path does not match`() = runTest { + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "staging.api.example.com", + path = "/api/unknown", + method = "GET" + ) + + match.shouldBeNull() + } + + @Test + fun `findMatchingMock returns null when host does not match`() = runTest { + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "unknown.example.com", + path = "/api/users/42", + method = "GET" + ) + + match.shouldBeNull() + } + + @Test + fun `findMatchingMock returns null when configuration cannot be loaded`() = runTest { + val repository = createRepository(resources = emptyMap()) + + val match = repository.findMatchingMock( + host = "staging.api.example.com", + path = "/api/users/42", + method = "GET" + ) + + match.shouldBeNull() + } + + @Test + fun `discoverResponseFiles returns responses sorted by status code`() = runTest { + val repository = createRepository( + resources = baseResources(), + statusCodesToDiscover = listOf(500, 200, 404) + ) + + val responses = repository.discoverResponseFiles(endpointId = "getUser") + + responses.map { response -> response.statusCode } shouldBe listOf(200, 404) + } + + @Test + fun `discoverResponseFiles discovers suffix variants`() = runTest { + val resources = baseResources() + mapOf( + "files/networkmocks/responses/getUser/getUser-404-simple.json" to """{"error":"simple"}""" + ) + val repository = createRepository( + resources = resources, + statusCodesToDiscover = listOf(404) + ) + + val responses = repository.discoverResponseFiles(endpointId = "getUser") + + responses shouldHaveSize 2 + responses.map { response -> response.fileName } shouldContain "getUser-404-simple.json" + } + + @Test + fun `discoverResponseFiles preserves deterministic order for same status suffix variants`() = runTest { + val resources = baseResources() + mapOf( + "files/networkmocks/responses/getUser/getUser-404-simple.json" to """{"error":"simple"}""", + "files/networkmocks/responses/getUser/getUser-404-detailed.json" to """{"error":"detailed"}""" + ) + val repository = createRepository( + resources = resources, + statusCodesToDiscover = listOf(404) + ) + + val responses = repository.discoverResponseFiles(endpointId = "getUser") + + responses.map { response -> response.fileName } shouldContainExactly listOf( + "getUser-404.json", + "getUser-404-simple.json", + "getUser-404-detailed.json" + ) + } + + @Test + fun `discoverResponseFiles deduplicates entries when statusCodesToDiscover contains duplicates`() = runTest { + val repository = createRepository( + resources = baseResources(), + statusCodesToDiscover = listOf(404, 404) + ) + + val responses = repository.discoverResponseFiles(endpointId = "getUser") + + responses shouldHaveSize 1 + responses.single().fileName shouldBe "getUser-404.json" + } + + @Test + fun `discoverResponseFiles honors custom statusCodesToDiscover`() = runTest { + val repository = createRepository( + resources = baseResources(), + statusCodesToDiscover = listOf(200) + ) + + val responses = repository.discoverResponseFiles(endpointId = "getUser") + + responses shouldHaveSize 1 + responses.single().statusCode shouldBe 200 + } + + @Test + fun `loadMockResponse returns parsed response when file exists`() = runTest { + val repository = createRepository(resources = baseResources()) + + val response = repository.loadMockResponse( + endpointId = "getUser", + fileName = "getUser-200.json" + ) + + response?.statusCode shouldBe 200 + response?.displayName shouldBe "Success (200)" + response?.content shouldBe """{"id":1}""" + } + + @Test + fun `loadMockResponse returns null when file is missing`() = runTest { + val repository = createRepository(resources = baseResources()) + + val response = repository.loadMockResponse( + endpointId = "getUser", + fileName = "getUser-999.json" + ) + + response.shouldBeNull() + } + + @Test + fun `loadMockResponse returns null when file name is malformed`() = runTest { + val resources = baseResources() + mapOf( + "files/networkmocks/responses/getUser/getUser-invalid.json" to """{"id":1}""" + ) + val repository = createRepository(resources = resources) + + val response = repository.loadMockResponse( + endpointId = "getUser", + fileName = "getUser-invalid.json" + ) + + response.shouldBeNull() + } + + @Test + fun `discoverResponseFiles returns empty list when endpoint has no files`() = runTest { + val repository = createRepository(resources = baseResources()) + + val responses = repository.discoverResponseFiles(endpointId = "doesNotExist") + + responses shouldBe emptyList() + } + + private fun createRepository( + resources: Map, + statusCodesToDiscover: List = MockConfigRepository.DEFAULT_STATUS_CODES + ): MockConfigRepository { + val loader = RecordingResourceLoader(resources = resources) + return MockConfigRepository( + configPath = CONFIG_PATH, + resourceLoader = loader::load, + statusCodesToDiscover = statusCodesToDiscover + ) + } + + private class RecordingResourceLoader( + private val resources: Map + ) { + private val calls = mutableMapOf() + + fun load(path: String): ByteArray { + calls[path] = (calls[path] ?: 0) + 1 + return resources[path]?.encodeToByteArray() + ?: error("Resource not found: $path") + } + + fun callCount(path: String): Int = calls[path] ?: 0 + } + + private fun baseResources(): Map = mapOf( + CONFIG_PATH to baseConfigJson(), + "files/networkmocks/responses/getUser/getUser-200.json" to """{"id":1}""", + "files/networkmocks/responses/getUser/getUser-404.json" to """{"error":"not found"}""", + "files/networkmocks/responses/createUser/createUser-201.json" to """{"id":2}""" + ) + + @Suppress("MaxLineLength") + private fun baseConfigJson(): String = """ + { + "hosts": [ + { + "id": "staging", + "url": "https://staging.api.example.com:8443/v1", + "endpoints": [ + { + "id": "getUser", + "name": "Get User", + "path": "/api/users/{userId}", + "method": "GET" + }, + { + "id": "createUser", + "name": "Create User", + "path": "/api/users", + "method": "POST" + } + ] + }, + { + "id": "production", + "url": "https://api.example.com", + "endpoints": [ + { + "id": "getProduct", + "name": "Get Product", + "path": "/api/products/{productId}", + "method": "GET" + } + ] + } + ] + } + """.trimIndent() + + private companion object { + const val CONFIG_PATH: String = "files/networkmocks/mocks.json" + } +} + 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 new file mode 100644 index 0000000..6081ec8 --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockStateRepositoryTest.kt @@ -0,0 +1,366 @@ +package com.worldline.devview.networkmock.core.repository + +import app.cash.turbine.test +import com.worldline.devview.networkmock.core.fixtures.FakePreferencesDataStore +import com.worldline.devview.networkmock.core.fixtures.ThrowingPreferencesDataStore +import com.worldline.devview.networkmock.core.model.EndpointMockState +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class MockStateRepositoryTest { + + private fun createRepository(): MockStateRepository = + MockStateRepository(dataStore = FakePreferencesDataStore()) + + // region Initial state + + @Test + fun `initial state has global mocking disabled`() = runTest { + val repository = createRepository() + + val state = repository.getState() + + state.globalMockingEnabled shouldBe false + } + + @Test + fun `initial state has no endpoint states`() = runTest { + val repository = createRepository() + + val state = repository.getState() + + state.endpointStates shouldBe emptyMap() + } + + // endregion + + // region Global mocking toggle + + @Test + fun `setGlobalMockingEnabled true persists to DataStore`() = runTest { + val repository = createRepository() + + repository.setGlobalMockingEnabled(enabled = true) + + repository.getState().globalMockingEnabled shouldBe true + } + + @Test + fun `setGlobalMockingEnabled false persists to DataStore`() = runTest { + val repository = createRepository() + + repository.setGlobalMockingEnabled(enabled = true) + repository.setGlobalMockingEnabled(enabled = false) + + repository.getState().globalMockingEnabled shouldBe false + } + + @Test + fun `observeState emits updated value when global mocking is toggled`() = runTest { + val repository = createRepository() + + repository.observeState().test { + awaitItem().globalMockingEnabled shouldBe false + + repository.setGlobalMockingEnabled(enabled = true) + awaitItem().globalMockingEnabled shouldBe true + + repository.setGlobalMockingEnabled(enabled = false) + awaitItem().globalMockingEnabled shouldBe false + + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + // region Endpoint state persistence + + @Test + fun `setEndpointMockState persists mock state for an endpoint`() = runTest { + val repository = createRepository() + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + + val endpointState = repository.getState().getEndpointState( + hostId = "staging", + endpointId = "getUser" + ) + endpointState.shouldBeInstanceOf() + endpointState.responseFile shouldBe "getUser-200.json" + } + + @Test + fun `setEndpointMockState persists network state for an endpoint`() = runTest { + val repository = createRepository() + + // First set to mock, then back to network + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Network + ) + + val endpointState = repository.getState().getEndpointState( + hostId = "staging", + endpointId = "getUser" + ) + endpointState shouldBe EndpointMockState.Network + } + + @Test + fun `setEndpointMockState is reflected in observeState`() = runTest { + val repository = createRepository() + + repository.observeState().test { + awaitItem().endpointStates shouldNotContainKey "staging-getUser" + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + awaitItem().endpointStates shouldContainKey "staging-getUser" + + cancelAndIgnoreRemainingEvents() + } + } + + // endregion + + // region Multiple endpoints tracked independently + + @Test + fun `multiple endpoint states are tracked independently`() = runTest { + val repository = createRepository() + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + repository.setEndpointMockState( + hostId = "staging", + endpointId = "createUser", + state = EndpointMockState.Mock(responseFile = "createUser-201.json") + ) + + val state = repository.getState() + val getUserState = state.getEndpointState(hostId = "staging", endpointId = "getUser") + val createUserState = state.getEndpointState(hostId = "staging", endpointId = "createUser") + + (getUserState as EndpointMockState.Mock).responseFile shouldBe "getUser-200.json" + (createUserState as EndpointMockState.Mock).responseFile shouldBe "createUser-201.json" + } + + @Test + fun `updating one endpoint does not affect another`() = runTest { + val repository = createRepository() + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + repository.setEndpointMockState( + hostId = "staging", + endpointId = "createUser", + state = EndpointMockState.Mock(responseFile = "createUser-201.json") + ) + + // Update only getUser + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-404.json") + ) + + val createUserState = repository.getState().getEndpointState( + hostId = "staging", + endpointId = "createUser" + ) + (createUserState as EndpointMockState.Mock).responseFile shouldBe "createUser-201.json" + } + + @Test + fun `endpoints on different hosts are tracked independently`() = runTest { + val repository = createRepository() + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + repository.setEndpointMockState( + hostId = "production", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-500.json") + ) + + val stagingState = repository.getState().getEndpointState( + hostId = "staging", + endpointId = "getUser" + ) + val productionState = repository.getState().getEndpointState( + hostId = "production", + endpointId = "getUser" + ) + + (stagingState as EndpointMockState.Mock).responseFile shouldBe "getUser-200.json" + (productionState as EndpointMockState.Mock).responseFile shouldBe "getUser-500.json" + } + + // endregion + + // region Reset operations + + @Test + fun `resetKnownEndpointsToNetwork resets all previously written endpoints`() = runTest { + val repository = createRepository() + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + repository.setEndpointMockState( + hostId = "staging", + endpointId = "createUser", + state = EndpointMockState.Mock(responseFile = "createUser-201.json") + ) + + repository.resetKnownEndpointsToNetwork() + + val state = repository.getState() + state.getEndpointState(hostId = "staging", endpointId = "getUser") shouldBe EndpointMockState.Network + state.getEndpointState(hostId = "staging", endpointId = "createUser") shouldBe EndpointMockState.Network + } + + @Test + fun `resetKnownEndpointsToNetwork does not change global mocking state`() = runTest { + val repository = createRepository() + + repository.setGlobalMockingEnabled(enabled = true) + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + + repository.resetKnownEndpointsToNetwork() + + repository.getState().globalMockingEnabled shouldBe true + } + + @Test + fun `setAllEndpointStates overwrites all endpoint states`() = runTest { + val repository = createRepository() + + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + + repository.setAllEndpointStates( + states = mapOf( + "staging-getUser" to EndpointMockState.Network, + "staging-createUser" to EndpointMockState.Network + ) + ) + + val state = repository.getState() + state.getEndpointState(hostId = "staging", endpointId = "getUser") shouldBe EndpointMockState.Network + state.getEndpointState(hostId = "staging", endpointId = "createUser") shouldBe EndpointMockState.Network + } + + @Test + fun `setAllEndpointStates does not change global mocking state`() = runTest { + val repository = createRepository() + + repository.setGlobalMockingEnabled(enabled = true) + + repository.setAllEndpointStates( + states = mapOf("staging-getUser" to EndpointMockState.Network) + ) + + repository.getState().globalMockingEnabled shouldBe true + } + + // endregion + + // region Non-existent endpoint lookup + + @Test + fun `getEndpointState returns null for endpoint that has never been set`() = runTest { + val repository = createRepository() + + val state = repository.getState().getEndpointState( + hostId = "staging", + endpointId = "nonExistent" + ) + + state shouldBe null + } + + // endregion + + // region registerEndpoints + + @Test + fun `registerEndpoints pre-populates keys so resetKnownEndpointsToNetwork covers them`() = runTest { + val repository = createRepository() + + // Register endpoints without any prior writes + repository.registerEndpoints( + endpoints = listOf("staging" to "getUser", "staging" to "createUser") + ) + + // Write a mock state so there is something to reset for one of them + repository.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + state = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + + repository.resetKnownEndpointsToNetwork() + + repository.getState().getEndpointState( + hostId = "staging", + endpointId = "getUser" + ) shouldBe EndpointMockState.Network + } + + // endregion + + // region IO error recovery + + @Test + fun `observeState emits default state when DataStore throws IOException`() = runTest { + val throwingDataStore = ThrowingPreferencesDataStore() + val repository = MockStateRepository(dataStore = throwingDataStore) + + repository.observeState().test { + val state = awaitItem() + state.globalMockingEnabled shouldBe false + state.endpointStates shouldBe emptyMap() + cancelAndIgnoreRemainingEvents() + } + } + + // endregion +} + diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt new file mode 100644 index 0000000..318377d --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt @@ -0,0 +1,363 @@ +package com.worldline.devview.networkmock.core.repository + +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class RequestMatcherTest { + + // region Exact path matching (no parameters) + + @Test + fun `matches identical single-segment paths`() { + RequestMatcher.matchesPath( + configPath = "/api", + requestPath = "/api" + ) shouldBe true + } + + @Test + fun `matches identical multi-segment paths`() { + RequestMatcher.matchesPath( + configPath = "/api/users", + requestPath = "/api/users" + ) shouldBe true + } + + @Test + fun `rejects paths with different final segment`() { + RequestMatcher.matchesPath( + configPath = "/api/users", + requestPath = "/api/posts" + ) shouldBe false + } + + @Test + fun `rejects paths with different leading segment`() { + RequestMatcher.matchesPath( + configPath = "/v1/users", + requestPath = "/v2/users" + ) shouldBe false + } + + // endregion + + // region Segment count mismatch + + @Test + fun `rejects request path with more segments than config`() { + RequestMatcher.matchesPath( + configPath = "/api/users", + requestPath = "/api/users/123" + ) shouldBe false + } + + @Test + fun `rejects request path with fewer segments than config`() { + RequestMatcher.matchesPath( + configPath = "/api/users/profile", + requestPath = "/api/users" + ) shouldBe false + } + + @Test + fun `rejects parameterized config path with extra request segment`() { + RequestMatcher.matchesPath( + configPath = "/api/users/{id}", + requestPath = "/api/users/42/details" + ) shouldBe false + } + + // endregion + + // region Single path parameter + + @Test + fun `matches single path parameter with numeric value`() { + RequestMatcher.matchesPath( + configPath = "/api/users/{id}", + requestPath = "/api/users/42" + ) shouldBe true + } + + @Test + fun `matches single path parameter with alphabetic value`() { + RequestMatcher.matchesPath( + configPath = "/api/users/{id}", + requestPath = "/api/users/abc" + ) shouldBe true + } + + @Test + fun `matches single path parameter with hyphenated value`() { + RequestMatcher.matchesPath( + configPath = "/api/users/{id}", + requestPath = "/api/users/user-123" + ) shouldBe true + } + + @Test + fun `rejects single parameter when non-parameter segment differs`() { + RequestMatcher.matchesPath( + configPath = "/api/users/{id}", + requestPath = "/api/posts/42" + ) shouldBe false + } + + // endregion + + // region Multiple path parameters + + @Test + fun `matches multiple path parameters with numeric values`() { + RequestMatcher.matchesPath( + configPath = "/api/posts/{postId}/comments/{commentId}", + requestPath = "/api/posts/123/comments/456" + ) shouldBe true + } + + @Test + fun `matches multiple path parameters with mixed values`() { + RequestMatcher.matchesPath( + configPath = "/api/posts/{postId}/comments/{commentId}", + requestPath = "/api/posts/abc/comments/xyz" + ) shouldBe true + } + + @Test + fun `rejects multiple parameters when a literal segment differs`() { + RequestMatcher.matchesPath( + configPath = "/api/posts/{postId}/comments/{commentId}", + requestPath = "/api/posts/123/likes/456" + ) shouldBe false + } + + // endregion + + // region Leading and trailing slash normalisation + + @Test + fun `matches when request path has no leading slash`() { + RequestMatcher.matchesPath( + configPath = "/api/users", + requestPath = "api/users" + ) shouldBe true + } + + @Test + fun `matches when config path has no leading slash`() { + RequestMatcher.matchesPath( + configPath = "api/users", + requestPath = "/api/users" + ) shouldBe true + } + + @Test + fun `matches when config path has trailing slash`() { + RequestMatcher.matchesPath( + configPath = "/api/users/", + requestPath = "/api/users" + ) shouldBe true + } + + @Test + fun `matches when request path has trailing slash`() { + RequestMatcher.matchesPath( + configPath = "/api/users", + requestPath = "/api/users/" + ) shouldBe true + } + + @Test + fun `matches when both paths have no leading or trailing slashes`() { + RequestMatcher.matchesPath( + configPath = "api/users", + requestPath = "api/users/" + ) shouldBe true + } + + // endregion + + // region Case sensitivity + + @Test + fun `is case-sensitive for literal segments`() { + RequestMatcher.matchesPath( + configPath = "/api/Users", + requestPath = "/api/users" + ) shouldBe false + } + + @Test + fun `is case-sensitive for method segment`() { + RequestMatcher.matchesPath( + configPath = "/api/users/Profile", + requestPath = "/api/users/profile" + ) shouldBe false + } + + // endregion + + // region Edge cases — empty and root paths + + @Test + fun `matches two empty strings`() { + RequestMatcher.matchesPath( + configPath = "", + requestPath = "" + ) shouldBe true + } + + @Test + fun `matches two root slash paths`() { + RequestMatcher.matchesPath( + configPath = "/", + requestPath = "/" + ) shouldBe true + } + + @Test + fun `matches root slash config against empty request`() { + // Both reduce to zero segments after filtering + RequestMatcher.matchesPath( + configPath = "/", + requestPath = "" + ) shouldBe true + } + + @Test + fun `rejects non-empty config against empty request`() { + RequestMatcher.matchesPath( + configPath = "/api", + requestPath = "" + ) shouldBe false + } + + @Test + fun `rejects non-empty config against root slash request`() { + RequestMatcher.matchesPath( + configPath = "/api", + requestPath = "/" + ) shouldBe false + } + + // endregion + + // region Edge cases — double slashes (empty segments are ignored) + + @Test + fun `matches when config path has consecutive slashes`() { + // double slash in the middle produces an empty segment that is filtered out + RequestMatcher.matchesPath( + configPath = "/api//users", + requestPath = "/api/users" + ) shouldBe true + } + + @Test + fun `matches when request path has consecutive slashes`() { + RequestMatcher.matchesPath( + configPath = "/api/users", + requestPath = "/api//users" + ) shouldBe true + } + + @Test + fun `matches when both paths have consecutive slashes in the same position`() { + RequestMatcher.matchesPath( + configPath = "/api//users", + requestPath = "/api//users" + ) shouldBe true + } + + // endregion + + // region Edge cases — special characters in literal segments + + @Test + fun `matches path segments containing dots`() { + RequestMatcher.matchesPath( + configPath = "/api/v1.0/users", + requestPath = "/api/v1.0/users" + ) shouldBe true + } + + @Test + fun `matches path segments containing underscores`() { + RequestMatcher.matchesPath( + configPath = "/api/user_profile", + requestPath = "/api/user_profile" + ) shouldBe true + } + + @Test + fun `matches path segments containing percent-encoded characters`() { + RequestMatcher.matchesPath( + configPath = "/api/search/hello%20world", + requestPath = "/api/search/hello%20world" + ) shouldBe true + } + + @Test + fun `rejects path segments with different percent-encoded values`() { + RequestMatcher.matchesPath( + configPath = "/api/search/hello%20world", + requestPath = "/api/search/hello+world" + ) shouldBe false + } + + // endregion + + // region Edge cases — parameter boundary conditions + + @Test + fun `treats empty braces as a literal segment not a parameter`() { + // "{}" has length 2 so isParameterSegment returns false — must match literally + RequestMatcher.matchesPath( + configPath = "/api/{}", + requestPath = "/api/anything" + ) shouldBe false + } + + @Test + fun `treats empty braces literal as exact match`() { + RequestMatcher.matchesPath( + configPath = "/api/{}", + requestPath = "/api/{}" + ) shouldBe true + } + + @Test + fun `matches parameter segment that contains only one character name`() { + RequestMatcher.matchesPath( + configPath = "/api/{x}", + requestPath = "/api/42" + ) shouldBe true + } + + @Test + fun `matches parameter segment with hyphenated parameter name`() { + RequestMatcher.matchesPath( + configPath = "/api/{post-id}", + requestPath = "/api/123" + ) shouldBe true + } + + @Test + fun `matches path parameter whose value contains dots`() { + RequestMatcher.matchesPath( + configPath = "/api/files/{filename}", + requestPath = "/api/files/report.pdf" + ) shouldBe true + } + + @Test + fun `matches path parameter whose value is a UUID`() { + RequestMatcher.matchesPath( + configPath = "/api/users/{id}", + requestPath = "/api/users/550e8400-e29b-41d4-a716-446655440000" + ) shouldBe true + } + + // endregion +} + diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/utils/MockFileNameUtilsTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/utils/MockFileNameUtilsTest.kt new file mode 100644 index 0000000..95494e5 --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/utils/MockFileNameUtilsTest.kt @@ -0,0 +1,83 @@ +package com.worldline.devview.networkmock.core.utils + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class MockFileNameUtilsTest { + + // region Valid file names + + @Test + fun `parses status code from simple endpoint id`() { + "getUser-200.json".parseStatusCode() shouldBe 200 + } + + @Test + fun `parses status code from endpoint id with suffix`() { + "getUser-404-simple.json".parseStatusCode() shouldBe 404 + } + + @Test + fun `parses status code from endpoint id with multiple suffixes`() { + "getUser-404-not-found.json".parseStatusCode() shouldBe 404 + } + + @Test + fun `parses status code from hyphenated endpoint id`() { + "get-user-500.json".parseStatusCode() shouldBe 500 + } + + @Test + fun `parses status code from deeply hyphenated endpoint id with suffix`() { + "get-user-profile-201-created.json".parseStatusCode() shouldBe 201 + } + + @Test + fun `parses 3xx status code`() { + "getUser-301.json".parseStatusCode() shouldBe 301 + } + + @Test + fun `parses 5xx status code`() { + "createUser-503.json".parseStatusCode() shouldBe 503 + } + + @Test + fun `parses status code from file name without json extension`() { + // removeSuffix(".json") is a no-op when the extension is absent, + // so the regex still finds the three-digit code at the end of the string. + "getUser-200".parseStatusCode() shouldBe 200 + } + + // endregion + + // region Invalid or malformed file names + + @Test + fun `returns null for file name with no status code`() { + "getUser.json".parseStatusCode().shouldBeNull() + } + + @Test + fun `returns null for file name with non-numeric suffix after hyphen`() { + "getUser-json.json".parseStatusCode().shouldBeNull() + } + + @Test + fun `returns null for file name with only two digits`() { + "getUser-20.json".parseStatusCode().shouldBeNull() + } + + @Test + fun `returns null for file name with four digits`() { + "getUser-2000.json".parseStatusCode().shouldBeNull() + } + + @Test + fun `returns null for empty string`() { + "".parseStatusCode().shouldBeNull() + } + + // endregion +} diff --git a/devview-networkmock-ktor/build.gradle.kts b/devview-networkmock-ktor/build.gradle.kts index e999acc..5cecb2d 100644 --- a/devview-networkmock-ktor/build.gradle.kts +++ b/devview-networkmock-ktor/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.ktor) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) } @@ -8,7 +10,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.networkmock.ktor" } diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt new file mode 100644 index 0000000..f1dd6f9 --- /dev/null +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt @@ -0,0 +1,60 @@ +package com.worldline.devview.networkmock.ktor.fixtures + +/** + * Shared test resource strings for the Ktor plugin tests. + */ +internal object KtorPluginTestData { + + val defaultConfigJson: String = """ + { + "hosts": [ + { + "id": "staging", + "url": "https://staging.api.example.com", + "endpoints": [ + { + "id": "getUser", + "name": "Get User", + "path": "/api/users/{userId}", + "method": "GET" + }, + { + "id": "createUser", + "name": "Create User", + "path": "/api/users", + "method": "POST" + } + ] + }, + { + "id": "production", + "url": "https://api.example.com", + "endpoints": [ + { + "id": "getProduct", + "name": "Get Product", + "path": "/api/products/{productId}", + "method": "GET" + } + ] + } + ] + } + """.trimIndent() + + /** Response file resources keyed by their path under `files/networkmocks/`. */ + val responseResources: Map = mapOf( + "files/networkmocks/mocks.json" to defaultConfigJson, + "files/networkmocks/responses/getUser/getUser-200.json" to """{"id":1,"name":"Alice"}""", + "files/networkmocks/responses/getUser/getUser-404.json" to """{"error":"not found"}""", + "files/networkmocks/responses/createUser/createUser-201.json" to """{"id":2}""", + "files/networkmocks/responses/getProduct/getProduct-200.json" to """{"id":10,"name":"Widget"}""" + ) + + /** Resource loader backed by the in-memory map above. */ + fun resourceLoader( + resources: Map = responseResources + ): suspend (String) -> ByteArray = + { path -> resources[path]?.encodeToByteArray() ?: error("Resource not found: $path") } +} + 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 new file mode 100644 index 0000000..61cf22a --- /dev/null +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt @@ -0,0 +1,410 @@ +package com.worldline.devview.networkmock.ktor.plugin + +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.NetworkMockState +import com.worldline.devview.networkmock.core.repository.MockConfigRepository +import com.worldline.devview.networkmock.core.repository.MockStateRepository +import com.worldline.devview.networkmock.ktor.fixtures.KtorPluginTestData +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest + +class NetworkMockPluginTest { + + @Test + fun requestPassesThrough_whenGlobalMockingDisabled() = runTest { + val stateRepo = stateRepositoryMock(state = NetworkMockState(globalMockingEnabled = false)) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepo + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe """{"source":"network"}""" + } + + // region Matching and mock response returned + + @Test + fun returnsMockResponse_whenEndpointIsMocked() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe """{"id":1,"name":"Alice"}""" + } + + @Test + fun returnsMockResponse_withCorrectStatusCode_404() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-404.json") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/99" + ) + + response.status shouldBe HttpStatusCode.NotFound + response.body() shouldBe """{"error":"not found"}""" + } + + @Test + fun returnsMockResponse_forPostEndpoint() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-createUser" to EndpointMockState.Mock(responseFile = "createUser-201.json") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.post( + urlString = "https://staging.api.example.com/api/users" + ) { + setBody("""{"name":"Bob"}""") + } + + response.status shouldBe HttpStatusCode.Created + response.body() shouldBe """{"id":2}""" + } + + @Test + fun returnsMockResponse_forDifferentHost_production() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "production-getProduct" to EndpointMockState.Mock(responseFile = "getProduct-200.json") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://api.example.com/api/products/10" + ) + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe """{"id":10,"name":"Widget"}""" + } + + // endregion + + // region Non-matching requests pass through + + @Test + fun requestPassesThrough_whenHostDoesNotMatch() = runTest { + val state = NetworkMockState(globalMockingEnabled = true) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://unknown.host.example.com/api/users/1" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestPassesThrough_whenPathDoesNotMatch() = runTest { + val state = NetworkMockState(globalMockingEnabled = true) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/completely/different/path" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestPassesThrough_whenMethodDoesNotMatch() = runTest { + // getUser is GET-only; sending POST should fall through to network + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.post( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestPassesThrough_whenEndpointStateIsNetwork() = runTest { + // Global mocking on, but endpoint left as Network — should pass through + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf("staging-getUser" to EndpointMockState.Network) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestPassesThrough_whenEndpointHasNoStoredState() = runTest { + // Global mocking on, endpoint exists in config but has no entry in endpointStates + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = emptyMap() + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + // endregion + + // region Path parameter matching + + @Test + fun pathParameterMatching_matchesDifferentConcreteValues() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + // Both /api/users/1 and /api/users/abc-uuid should match /api/users/{userId} + client.get(urlString = "https://staging.api.example.com/api/users/1") + .status shouldBe HttpStatusCode.OK + client.get(urlString = "https://staging.api.example.com/api/users/abc-uuid-123") + .status shouldBe HttpStatusCode.OK + } + + // endregion + + // region Error / fallback behaviour + + @Test + fun requestFallsBackToNetwork_whenResponseFileIsMissing() = runTest { + val resourcesWithoutResponseFile = KtorPluginTestData.responseResources + .filterKeys { key -> key != "files/networkmocks/responses/getUser/getUser-200.json" } + + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(resources = resourcesWithoutResponseFile), + stateRepository = stateRepositoryMock(state = state) + ) + + // Plugin catches the load failure and falls back to network + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestFallsBackToNetwork_whenConfigurationIsMissing() = runTest { + val resourcesWithoutConfig = KtorPluginTestData.responseResources + .filterKeys { key -> key != "files/networkmocks/mocks.json" } + + val state = NetworkMockState(globalMockingEnabled = true) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(resources = resourcesWithoutConfig), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestFallsBackToNetwork_whenConfigurationIsMalformed_butEndpointStateIsMocked() = runTest { + val resourcesWithMalformedConfig = KtorPluginTestData.responseResources + mapOf( + "files/networkmocks/mocks.json" to """{ "hosts": [ {""" + ) + + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(resources = resourcesWithMalformedConfig), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun requestFallsBackToNetwork_whenResponseFileNameIsMalformed() = runTest { + val resourcesWithMalformedFileName = KtorPluginTestData.responseResources + mapOf( + "files/networkmocks/responses/getUser/getUser-invalid.json" to """{"id":1}""" + ) + + val state = NetworkMockState( + globalMockingEnabled = true, + endpointStates = mapOf( + "staging-getUser" to EndpointMockState.Mock(responseFile = "getUser-invalid.json") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(resources = resourcesWithMalformedFileName), + stateRepository = stateRepositoryMock(state = state) + ) + + // File exists, but MockResponse.fromFile cannot parse status from the malformed name. + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + // endregion + + // region Helpers + + /** + * A [MockEngine] that always responds with [body] and [status], standing in + * for "the real network". + */ + private fun networkEngine( + status: HttpStatusCode = HttpStatusCode.OK, + body: String = """{"source":"network"}""" + ): MockEngine = MockEngine { _ -> + respond( + content = body, + status = status, + headers = headersOf("Content-Type", "application/json") + ) + } + + private fun buildClient( + engine: MockEngine, + configRepository: MockConfigRepository, + stateRepository: MockStateRepository + ): HttpClient = HttpClient(engine = engine) { + install(plugin = NetworkMockPlugin) { + mockRepository = configRepository + this.stateRepository = stateRepository + } + } + + private fun configRepository( + resources: Map = KtorPluginTestData.responseResources + ): MockConfigRepository = MockConfigRepository( + configPath = "files/networkmocks/mocks.json", + resourceLoader = KtorPluginTestData.resourceLoader(resources = resources) + ) + + /** + * Creates a MockK mock of [MockStateRepository] whose [MockStateRepository.getState] + * returns [state] and whose [MockStateRepository.observeState] emits it. + * All write operations are stubbed to do nothing. + */ + private fun stateRepositoryMock( + state: NetworkMockState = NetworkMockState() + ): MockStateRepository = mockk(relaxed = true) { + coEvery { getState() } returns state + every { observeState() } returns flowOf(state) + } + + // endregion +} + diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockConfig.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt similarity index 90% rename from devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockConfig.kt rename to devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt index af7f0e6..d479bd9 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockConfig.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockConfig.kt @@ -1,8 +1,8 @@ -package com.worldline.devview.networkmock.plugin +package com.worldline.devview.networkmock.ktor.plugin -import com.worldline.devview.networkmock.NetworkMockInitializer -import com.worldline.devview.networkmock.repository.MockConfigRepository -import com.worldline.devview.networkmock.repository.MockStateRepository +import com.worldline.devview.networkmock.core.NetworkMockInitializer +import com.worldline.devview.networkmock.core.repository.MockConfigRepository +import com.worldline.devview.networkmock.core.repository.MockStateRepository /** * Configuration class for the [NetworkMockPlugin]. diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt similarity index 99% rename from devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt rename to devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt index d98601c..c43c9b6 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt @@ -1,8 +1,8 @@ @file:Suppress("StringLiteralDuplication") -package com.worldline.devview.networkmock.plugin +package com.worldline.devview.networkmock.ktor.plugin -import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.EndpointMockState import io.ktor.client.HttpClient import io.ktor.client.call.HttpClientCall import io.ktor.client.plugins.HttpClientPlugin @@ -24,6 +24,7 @@ import io.ktor.util.date.GMTDate import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.InternalAPI import kotlin.coroutines.CoroutineContext +import kotlin.text.get private const val LOG_PREFIX = "[NetworkMock][Plugin]" diff --git a/devview-networkmock/build.gradle.kts b/devview-networkmock/build.gradle.kts index 24ff54a..5a73c58 100644 --- a/devview-networkmock/build.gradle.kts +++ b/devview-networkmock/build.gradle.kts @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.compose.multiplatform) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.deviceTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) } @@ -8,7 +11,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.networkmock" } diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt new file mode 100644 index 0000000..40fc4c8 --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt @@ -0,0 +1,152 @@ +package com.worldline.devview.networkmock + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.fixtures.MockScreenTestData +import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class NetworkMockScreenTest { + + @Test + fun showsLoadingStateUi() = runComposeUiTest { + setScreen(uiState = NetworkMockUiState.Loading) + + onNodeWithText(text = "Loading mock configuration...") + .assertIsDisplayed() + } + + @Test + fun showsErrorStateUi_withMessage() = runComposeUiTest { + setScreen(uiState = NetworkMockUiState.Error(message = "boom")) + + onNodeWithText(text = "Error Loading Configuration") + .assertIsDisplayed() + + onNodeWithText(text = "boom") + .assertIsDisplayed() + } + + + @Test + fun showsEmptyStateUi() = runComposeUiTest { + setScreen(uiState = NetworkMockUiState.Empty) + + onNodeWithText(text = "No Mocks Configured", substring = true) + .assertIsDisplayed() + } + + + @Test + fun rendersHostTabs_forContentState() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + + onNodeWithTag(testTag = "host_tab_staging").assertIsDisplayed() + onNodeWithTag(testTag = "host_tab_production").assertIsDisplayed() + } + + + @Test + fun initialSelectedTab_isFirstHost() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + + onNodeWithTag(testTag = "host_tab_staging").assertIsSelected() + + } + + @Test + fun tabSwitching_changesVisibleEndpoints() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + + onNodeWithTag(testTag = "endpoint_card_staging_getUser").assertIsDisplayed() + + onNodeWithTag(testTag = "host_tab_production").performClick() + waitForIdle() + + onNodeWithTag(testTag = "endpoint_card_production_getProduct").assertIsDisplayed() + } + + + @Test + fun globalToggle_isVisibleInContentState() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState(globalMockingEnabled = false)) + + onNodeWithTag(testTag = "global_mock_toggle_switch").assertIsDisplayed() + } + + + @Test + fun globalToggle_checkedStateIsOn() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState(globalMockingEnabled = true)) + onNodeWithTag(testTag = "global_mock_toggle_switch").assertIsOn() + + } + + @Test + fun globalToggle_checkedStateIsOff() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState(globalMockingEnabled = false)) + onNodeWithTag(testTag = "global_mock_toggle_switch").assertIsOff() + } + + + @Test + fun globalToggle_stateChangeInvokesCallback() = runComposeUiTest { + var callbackValue: Boolean? = null + + setScreen( + uiState = MockScreenTestData.contentState(globalMockingEnabled = false), + onGlobalToggle = { enabled -> callbackValue = enabled } + ) + + onNodeWithTag(testTag = "global_mock_toggle_switch").performClick() + + + callbackValue shouldBe true + } + + @Test + fun endpointSelection_invokesSelectEndpointCallback() = runComposeUiTest { + var selected: Pair? = null + + setScreen( + uiState = MockScreenTestData.contentState(), + selectEndpoint = { hostId, endpointId -> selected = hostId to endpointId } + ) + + onNodeWithTag(testTag = "endpoint_card_staging_getUser").performClick() + + selected shouldBe ("staging" to "getUser") + } + + private fun ComposeUiTest.setScreen( + uiState: NetworkMockUiState, + onGlobalToggle: (Boolean) -> Unit = {}, + setEndpointMockState: (String, String, String?) -> Unit = { _, _, _ -> }, + selectEndpoint: (String, String) -> Unit = { _, _ -> }, + clearSelectedEndpoint: () -> Unit = {}, + ) { + setContent { + MaterialTheme { + NetworkMockScreenContent( + uiState = uiState, + onGlobalToggle = onGlobalToggle, + setEndpointMockState = setEndpointMockState, + selectEndpoint = selectEndpoint, + clearSelectedEndpoint = clearSelectedEndpoint, + selectedDescriptor = null, + selectedEndpointState = EndpointMockState.Network + ) + } + } + } +} diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/EndpointCardTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/EndpointCardTest.kt new file mode 100644 index 0000000..a790940 --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/EndpointCardTest.kt @@ -0,0 +1,155 @@ +package com.worldline.devview.networkmock.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.networkmock.core.model.EndpointConfig +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.MockResponse +import com.worldline.devview.networkmock.viewmodel.EndpointUiModel +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class EndpointCardTest { + + @Test + fun displaysEndpointName() = runComposeUiTest { + setEndpointCard(endpoint = networkEndpoint()) + + onNodeWithTag(testTag = "endpoint_name_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun displaysHttpMethod() = runComposeUiTest { + setEndpointCard(endpoint = networkEndpoint()) + + onNodeWithTag(testTag = "endpoint_method_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun displaysEndpointPath() = runComposeUiTest { + setEndpointCard(endpoint = networkEndpoint()) + + onNodeWithTag(testTag = "endpoint_path_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun displaysStateChip() = runComposeUiTest { + setEndpointCard(endpoint = networkEndpoint()) + + onNodeWithTag(testTag = "endpoint_state_chip_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun clickInvokesOpenBottomSheetCallback() = runComposeUiTest { + var clicked = false + + setEndpointCard( + endpoint = networkEndpoint(), + openEndpointBottomSheet = { clicked = true } + ) + + onNodeWithTag(testTag = "endpoint_state_chip_getUser", useUnmergedTree = true).performClick() + + clicked shouldBe true + } + + @Test + fun showFileName_isFalse_stateTextIsNotDisplayed() = runComposeUiTest { + setEndpointCard( + endpoint = mockEndpoint(), + showFileName = false + ) + + onNodeWithTag(testTag = "endpoint_state_getUser", useUnmergedTree = true).assertIsNotDisplayed() + } + + @Test + fun showFileName_isTrue_stateTextIsDisplayed() = runComposeUiTest { + setEndpointCard( + endpoint = mockEndpoint(), + showFileName = true + ) + + onNodeWithTag(testTag = "endpoint_state_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun stateChipIsDisplayed_forNetworkState() = runComposeUiTest { + setEndpointCard(endpoint = networkEndpoint()) + + onNodeWithTag(testTag = "endpoint_state_chip_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun stateChipIsDisplayed_forMockState() = runComposeUiTest { + setEndpointCard(endpoint = mockEndpoint()) + + onNodeWithTag(testTag = "endpoint_state_chip_getUser", useUnmergedTree = true).assertIsDisplayed() + } + + private fun networkEndpoint() = EndpointUiModel( + descriptor = EndpointDescriptor( + hostId = "staging", + endpointId = "getUser", + config = EndpointConfig( + id = "getUser", + name = "Get User", + path = "/api/users/{userId}", + method = "GET" + ), + availableResponses = listOf( + MockResponse( + statusCode = 200, + fileName = "getUser-200.json", + displayName = "Success (200)", + content = "{}" + ) + ) + ), + currentState = EndpointMockState.Network + ) + + private fun mockEndpoint() = EndpointUiModel( + descriptor = EndpointDescriptor( + hostId = "staging", + endpointId = "getUser", + config = EndpointConfig( + id = "getUser", + name = "Get User", + path = "/api/users/{userId}", + method = "GET" + ), + availableResponses = listOf( + MockResponse( + statusCode = 200, + fileName = "getUser-200.json", + displayName = "Success (200)", + content = "{}" + ) + ) + ), + currentState = EndpointMockState.Mock(responseFile = "getUser-200.json") + ) + + private fun ComposeUiTest.setEndpointCard( + endpoint: EndpointUiModel, + openEndpointBottomSheet: () -> Unit = {}, + showFileName: Boolean = false + ) { + setContent { + MaterialTheme { + EndpointCard( + endpoint = endpoint, + openEndpointBottomSheet = openEndpointBottomSheet, + showFileName = showFileName + ) + } + } + } +} diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/EndpointStateChipTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/EndpointStateChipTest.kt new file mode 100644 index 0000000..808e42d --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/EndpointStateChipTest.kt @@ -0,0 +1,75 @@ +package com.worldline.devview.networkmock.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.networkmock.core.model.EndpointMockState +import kotlin.test.Test + +class EndpointStateChipTest { + + @Test + fun displaysNetworkLabel_forNetworkState() = runComposeUiTest { + setChip(state = EndpointMockState.Network) + + onNodeWithTag(testTag = "endpoint_state_chip_label_Network", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun displaysStatusCode_forMockState_200() = runComposeUiTest { + setChip(state = EndpointMockState.Mock(responseFile = "getUser-200.json")) + + onNodeWithTag(testTag = "endpoint_state_chip_label_200", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun displaysStatusCode_forMockState_404() = runComposeUiTest { + setChip(state = EndpointMockState.Mock(responseFile = "getUser-404.json")) + + onNodeWithTag(testTag = "endpoint_state_chip_label_404", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun displaysStatusCode_forMockState_500() = runComposeUiTest { + setChip(state = EndpointMockState.Mock(responseFile = "getUser-500.json")) + + onNodeWithTag(testTag = "endpoint_state_chip_label_500", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun displaysStatusCode_forMockState_withSuffix() = runComposeUiTest { + setChip(state = EndpointMockState.Mock(responseFile = "getUser-404-simple.json")) + + onNodeWithTag(testTag = "endpoint_state_chip_label_404", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun chipIsDisplayed_forNetworkState() = runComposeUiTest { + setChip(state = EndpointMockState.Network) + + onNodeWithTag(testTag = "endpoint_state_chip", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun chipIsDisplayed_forMockState() = runComposeUiTest { + setChip(state = EndpointMockState.Mock(responseFile = "getUser-200.json")) + + onNodeWithTag(testTag = "endpoint_state_chip", useUnmergedTree = true).assertIsDisplayed() + } + + private fun ComposeUiTest.setChip( + state: EndpointMockState + ) { + setContent { + MaterialTheme { + EndpointStateChip( + endpointMockState = state + ) + } + } + } +} diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggleTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggleTest.kt new file mode 100644 index 0000000..27cab70 --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggleTest.kt @@ -0,0 +1,94 @@ +package com.worldline.devview.networkmock.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class GlobalMockToggleTest { + + @Test + fun switchIsDisplayed() = runComposeUiTest { + setToggle(enabled = false) + + onNodeWithTag(testTag = "global_mock_toggle_switch").assertIsDisplayed() + } + + @Test + fun switchIsOn_whenEnabled() = runComposeUiTest { + setToggle(enabled = true) + + onNodeWithTag(testTag = "global_mock_toggle_switch").assertIsOn() + } + + @Test + fun switchIsOff_whenDisabled() = runComposeUiTest { + setToggle(enabled = false) + + onNodeWithTag(testTag = "global_mock_toggle_switch").assertIsOff() + } + + @Test + fun displaysEnabledMessage_whenEnabled() = runComposeUiTest { + setToggle(enabled = true) + + onNodeWithText(text = "Mock responses enabled", substring = true).assertIsDisplayed() + } + + @Test + fun displaysDisabledMessage_whenDisabled() = runComposeUiTest { + setToggle(enabled = false) + + onNodeWithText(text = "Mocking disabled", substring = true).assertIsDisplayed() + } + + @Test + fun toggleCallback_isInvoked_withTrue_whenSwitchedOn() = runComposeUiTest { + var callbackValue: Boolean? = null + + setToggle( + enabled = false, + onToggle = { value -> callbackValue = value } + ) + + onNodeWithTag(testTag = "global_mock_toggle_switch").performClick() + + callbackValue shouldBe true + } + + @Test + fun toggleCallback_isInvoked_withFalse_whenSwitchedOff() = runComposeUiTest { + var callbackValue: Boolean? = null + + setToggle( + enabled = true, + onToggle = { value -> callbackValue = value } + ) + + onNodeWithTag(testTag = "global_mock_toggle_switch").performClick() + + callbackValue shouldBe false + } + + private fun ComposeUiTest.setToggle( + enabled: Boolean, + onToggle: (Boolean) -> Unit = {} + ) { + setContent { + MaterialTheme { + GlobalMockToggle( + enabled = enabled, + onToggle = onToggle + ) + } + } + } +} + diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/FakeMockConfigRepository.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/FakeMockConfigRepository.kt new file mode 100644 index 0000000..965f601 --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/FakeMockConfigRepository.kt @@ -0,0 +1,5 @@ +package com.worldline.devview.networkmock.fixtures + +// Intentionally left without declarations. +// ViewModel tests were moved to androidHostTest and now use MockK instead of device-test fakes. + diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/FakeMockStateRepository.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/FakeMockStateRepository.kt new file mode 100644 index 0000000..3d9f2f5 --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/FakeMockStateRepository.kt @@ -0,0 +1,7 @@ +package com.worldline.devview.networkmock.fixtures + +// Intentionally left without declarations. +// ViewModel tests were moved to androidHostTest and now use MockK instead of device-test fakes. + + + diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt new file mode 100644 index 0000000..7173aa1 --- /dev/null +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt @@ -0,0 +1,184 @@ +package com.worldline.devview.networkmock.fixtures + +import com.worldline.devview.networkmock.core.model.EndpointConfig +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.HostConfig +import com.worldline.devview.networkmock.core.model.MockConfiguration +import com.worldline.devview.networkmock.core.model.MockResponse +import com.worldline.devview.networkmock.viewmodel.EndpointUiModel +import com.worldline.devview.networkmock.viewmodel.HostUiModel +import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState +import kotlinx.collections.immutable.toPersistentList + +internal object MockScreenTestData { + + const val configPath: String = "files/networkmocks/mocks.json" + + private val stagingGetUserResponses = listOf( + MockResponse(statusCode = 200, fileName = "getUser-200.json", displayName = "Success (200)", content = "{}"), + MockResponse(statusCode = 404, fileName = "getUser-404-simple.json", displayName = "Not Found - Simple (404)", content = "{}") + ) + + private val stagingCreateUserResponses = listOf( + MockResponse(statusCode = 201, fileName = "createUser-201.json", displayName = "Created (201)", content = "{}") + ) + + private val productionGetProductResponses = listOf( + MockResponse(statusCode = 200, fileName = "getProduct-200.json", displayName = "Success (200)", content = "{}") + ) + + fun configurationWithTwoHosts(): MockConfiguration = MockConfiguration( + hosts = listOf( + HostConfig( + id = "staging", + url = "https://staging.api.example.com", + endpoints = listOf( + EndpointConfig( + id = "getUser", + name = "Get User", + path = "/api/users/{userId}", + method = "GET" + ), + EndpointConfig( + id = "createUser", + name = "Create User", + path = "/api/users", + method = "POST" + ) + ) + ), + HostConfig( + id = "production", + url = "https://api.example.com", + endpoints = listOf( + EndpointConfig( + id = "getProduct", + name = "Get Product", + path = "/api/products/{productId}", + method = "GET" + ) + ) + ) + ) + ) + + fun configurationWithNoHosts(): MockConfiguration = MockConfiguration(hosts = emptyList()) + + fun defaultResources(): Map = mapOf( + configPath to defaultConfigJson(), + "files/networkmocks/responses/getUser/getUser-200.json" to "{}", + "files/networkmocks/responses/getUser/getUser-404-simple.json" to "{}", + "files/networkmocks/responses/createUser/createUser-201.json" to "{}", + "files/networkmocks/responses/getProduct/getProduct-200.json" to "{}" + ) + + fun emptyHostsResources(): Map = mapOf( + configPath to """ + { + "hosts": [] + } + """.trimIndent() + ) + + fun defaultConfigJson(): String = """ + { + "hosts": [ + { + "id": "staging", + "url": "https://staging.api.example.com", + "endpoints": [ + { + "id": "getUser", + "name": "Get User", + "path": "/api/users/{userId}", + "method": "GET" + }, + { + "id": "createUser", + "name": "Create User", + "path": "/api/users", + "method": "POST" + } + ] + }, + { + "id": "production", + "url": "https://api.example.com", + "endpoints": [ + { + "id": "getProduct", + "name": "Get Product", + "path": "/api/products/{productId}", + "method": "GET" + } + ] + } + ] + } + """.trimIndent() + + fun contentState(globalMockingEnabled: Boolean = false): NetworkMockUiState.Content = + NetworkMockUiState.Content( + globalMockingEnabled = globalMockingEnabled, + hosts = listOf( + HostUiModel( + id = "staging", + name = "staging", + url = "https://staging.api.example.com", + endpoints = listOf( + EndpointUiModel( + descriptor = EndpointDescriptor( + hostId = "staging", + endpointId = "getUser", + config = EndpointConfig( + id = "getUser", + name = "Get User", + path = "/api/users/{userId}", + method = "GET" + ), + availableResponses = stagingGetUserResponses + ), + currentState = EndpointMockState.Network + ), + EndpointUiModel( + descriptor = EndpointDescriptor( + hostId = "staging", + endpointId = "createUser", + config = EndpointConfig( + id = "createUser", + name = "Create User", + path = "/api/users", + method = "POST" + ), + availableResponses = stagingCreateUserResponses + ), + currentState = EndpointMockState.Mock(responseFile = "createUser-201.json") + ) + ).toPersistentList() + ), + HostUiModel( + id = "production", + name = "production", + url = "https://api.example.com", + endpoints = listOf( + EndpointUiModel( + descriptor = EndpointDescriptor( + hostId = "production", + endpointId = "getProduct", + config = EndpointConfig( + id = "getProduct", + name = "Get Product", + path = "/api/products/{productId}", + method = "GET" + ), + availableResponses = productionGetProductResponses + ), + currentState = EndpointMockState.Network + ) + ).toPersistentList() + ) + ).toPersistentList() + ) +} + diff --git a/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt new file mode 100644 index 0000000..dafd074 --- /dev/null +++ b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt @@ -0,0 +1,313 @@ +package com.worldline.devview.networkmock.viewmodel + +import com.worldline.devview.networkmock.core.model.EndpointConfig +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.HostConfig +import com.worldline.devview.networkmock.core.model.MockConfiguration +import com.worldline.devview.networkmock.core.model.MockResponse +import com.worldline.devview.networkmock.core.model.NetworkMockState +import com.worldline.devview.networkmock.core.repository.MockConfigRepository +import com.worldline.devview.networkmock.core.repository.MockStateRepository +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest + +class NetworkMockViewModelTest : ViewModelTest() { + + @BeforeTest + override fun setup() { + super.setup() + } + + @Test + fun initialUiState_isLoading_whileConfigIsStillLoading() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock( + loadResult = Result.success(testConfiguration()), + loadDelayMs = 500 + ) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectState(viewModel.uiState) + + viewModel.uiState.value shouldBe NetworkMockUiState.Loading + } + + @Test + fun emitsContentState_afterSuccessfulConfigurationLoad() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectState(viewModel.uiState) + + val content = viewModel.uiState.value.shouldBeInstanceOf() + content.hosts.shouldHaveSize(2) + content.hosts.first { it.id == "staging" }.endpoints.shouldHaveSize(2) + content.hosts.first { it.id == "production" }.endpoints.shouldHaveSize(1) + } + + @Test + fun emitsContent_withEmptyHosts_whenConfigHasNoHosts() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock( + loadResult = Result.success(MockConfiguration(hosts = emptyList())) + ) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectState(viewModel.uiState) + + val content = viewModel.uiState.value.shouldBeInstanceOf() + content.hosts shouldHaveSize 0 + } + + @Test + fun emitsErrorState_whenConfigurationLoadFails() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock( + loadResult = Result.failure(IllegalStateException("config missing")) + ) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectState(viewModel.uiState) + + val error = viewModel.uiState.value.shouldBeInstanceOf() + error.message shouldBe "config missing" + } + + @Test + fun content_reflectsGlobalMockingEnabledState() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState(globalMockingEnabled = true)) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectState(viewModel.uiState) + + viewModel.uiState.value + .shouldBeInstanceOf() + .globalMockingEnabled shouldBe true + } + + @Test + fun selectEndpoint_updatesSelectedEndpointDescriptor() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectStates(viewModel.uiState, viewModel.selectedEndpointDescriptor) + + viewModel.selectEndpoint(hostId = "staging", endpointId = "getUser") + + val selected = viewModel.selectedEndpointDescriptor.value + selected?.hostId shouldBe "staging" + selected?.endpointId shouldBe "getUser" + } + + @Test + fun clearSelectedEndpoint_resetsSelectedEndpointDescriptor_toNull() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + viewModel.selectEndpoint(hostId = "staging", endpointId = "getUser") + viewModel.clearSelectedEndpoint() + + viewModel.selectedEndpointDescriptor.value shouldBe null + } + + @Test + fun selectedEndpointState_reflectsSelectedEndpointMockState() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + collectStates(viewModel.uiState, viewModel.selectedEndpointState) + + viewModel.selectEndpoint(hostId = "staging", endpointId = "getUser") + viewModel.setEndpointMockState( + hostId = "staging", + endpointId = "getUser", + responseFileName = "getUser-200.json" + ) + + val selected = viewModel.selectedEndpointState.value.shouldBeInstanceOf() + selected.responseFile shouldBe "getUser-200.json" + } + + @Test + fun setGlobalMockingEnabled_persistsInRepository() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + viewModel.setGlobalMockingEnabled(enabled = true) + + stateFlow.value.globalMockingEnabled shouldBe true + coVerify(exactly = 1) { stateRepository.setGlobalMockingEnabled(enabled = true) } + } + + @Test + fun setEndpointMockState_persistsMockAndNetworkTransitions() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + viewModel.setEndpointMockState("staging", "getUser", "getUser-200.json") + stateFlow.value.getEndpointState("staging", "getUser") + .shouldBeInstanceOf() + .responseFile shouldBe "getUser-200.json" + + viewModel.setEndpointMockState("staging", "getUser", null) + stateFlow.value.getEndpointState("staging", "getUser") shouldBe EndpointMockState.Network + } + + @Test + fun resetAllToNetwork_resetsEveryConfiguredEndpoint_toNetwork() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + + viewModel.setEndpointMockState("staging", "getUser", "getUser-200.json") + viewModel.setEndpointMockState("production", "getProduct", "getProduct-200.json") + + val statesSlot = slot>() + coEvery { stateRepository.setAllEndpointStates(states = capture(statesSlot)) } coAnswers { + stateFlow.value = stateFlow.value.copy(endpointStates = statesSlot.captured) + } + + viewModel.resetAllToNetwork() + + val allNetwork = statesSlot.captured + allNetwork["staging-getUser"] shouldBe EndpointMockState.Network + allNetwork["staging-createUser"] shouldBe EndpointMockState.Network + allNetwork["production-getProduct"] shouldBe EndpointMockState.Network + } + + private fun createConfigRepositoryMock( + loadResult: Result, + loadDelayMs: Long = 0L + ): MockConfigRepository { + val repository = mockk() + + coEvery { repository.loadConfiguration() } coAnswers { + if (loadDelayMs > 0) { + delay(loadDelayMs) + } + loadResult + } + + coEvery { repository.discoverResponseFiles("getUser") } returns listOf( + MockResponse(200, "getUser-200.json", "Success (200)", "{}") + ) + coEvery { repository.discoverResponseFiles("createUser") } returns listOf( + MockResponse(201, "createUser-201.json", "Created (201)", "{}") + ) + coEvery { repository.discoverResponseFiles("getProduct") } returns listOf( + MockResponse(200, "getProduct-200.json", "Success (200)", "{}") + ) + coEvery { repository.discoverResponseFiles(any()) } returns emptyList() + + return repository + } + + private fun createStateRepositoryMock( + stateFlow: MutableStateFlow + ): MockStateRepository { + val repository = mockk() + + coEvery { repository.setGlobalMockingEnabled(any()) } coAnswers { + val enabled = firstArg() + stateFlow.value = stateFlow.value.copy(globalMockingEnabled = enabled) + } + coEvery { repository.setEndpointMockState(any(), any(), any()) } coAnswers { + val hostId = firstArg() + val endpointId = secondArg() + val state = thirdArg() + stateFlow.value = stateFlow.value.withEndpointState(hostId, endpointId, state) + } + coEvery { repository.setAllEndpointStates(any()) } coAnswers { + stateFlow.value = stateFlow.value.copy(endpointStates = firstArg()) + } + coEvery { repository.resetKnownEndpointsToNetwork() } coAnswers { + stateFlow.value = stateFlow.value.resetAllToNetwork() + } + + every { repository.observeState() } returns stateFlow + every { repository.registerEndpoints(any()) } just Runs + + coEvery { repository.getState() } coAnswers { stateFlow.value } + + return repository + } + + private fun testConfiguration(): MockConfiguration = MockConfiguration( + hosts = listOf( + HostConfig( + id = "staging", + url = "https://staging.api.example.com", + endpoints = listOf( + EndpointConfig( + id = "getUser", + name = "Get User", + path = "/api/users/{userId}", + method = "GET" + ), + EndpointConfig( + id = "createUser", + name = "Create User", + path = "/api/users", + method = "POST" + ) + ) + ), + HostConfig( + id = "production", + url = "https://api.example.com", + endpoints = listOf( + EndpointConfig( + id = "getProduct", + name = "Get Product", + path = "/api/products/{productId}", + method = "GET" + ) + ) + ) + ) + ) +} + + diff --git a/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/ViewModelStateCollector.kt b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/ViewModelStateCollector.kt new file mode 100644 index 0000000..80ff80f --- /dev/null +++ b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/ViewModelStateCollector.kt @@ -0,0 +1,20 @@ +package com.worldline.devview.networkmock.viewmodel + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +fun TestScope.collectState(stateFlow: StateFlow) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + stateFlow.collect { } + } +} + +fun TestScope.collectStates(vararg stateFlows: StateFlow<*>) { + for (stateFlow in stateFlows) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + stateFlow.collect { } + } + } +} diff --git a/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/ViewModelTest.kt b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/ViewModelTest.kt new file mode 100644 index 0000000..3bac1c1 --- /dev/null +++ b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/ViewModelTest.kt @@ -0,0 +1,22 @@ +package com.worldline.devview.networkmock.viewmodel + +import io.mockk.clearAllMocks +import kotlin.test.AfterTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +open class ViewModelTest { + open fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + clearAllMocks() + Dispatchers.resetMain() + } +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt index 80fa07e..4d647c2 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt @@ -12,8 +12,9 @@ import com.worldline.devview.core.DestinationMetadata import com.worldline.devview.core.Module import com.worldline.devview.core.Section import com.worldline.devview.core.withTitle -import com.worldline.devview.networkmock.repository.MockConfigRepository -import com.worldline.devview.networkmock.repository.MockStateRepository +import com.worldline.devview.networkmock.core.NETWORK_MOCK_DATASTORE_NAME +import com.worldline.devview.networkmock.core.NetworkMockDataStoreDelegate +import com.worldline.devview.networkmock.core.NetworkMockInitializer import com.worldline.devview.utils.DataStoreDelegate import com.worldline.devview.utils.RequiresDataStore import kotlinx.collections.immutable.PersistentMap diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt index 83ab511..5356e2c 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt @@ -28,9 +28,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.worldline.devview.networkmock.components.MockItem import com.worldline.devview.networkmock.components.NetworkItem -import com.worldline.devview.networkmock.model.EndpointDescriptor -import com.worldline.devview.networkmock.model.EndpointMockState -import com.worldline.devview.networkmock.model.StatusCodeFamily +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.StatusCodeFamily import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider import com.worldline.devview.networkmock.viewmodel.EndpointUiModel import kotlinx.coroutines.launch 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 aa1eeb3..177fc0c 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 @@ -25,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp @@ -36,11 +37,11 @@ import com.worldline.devview.networkmock.components.EndpointCard import com.worldline.devview.networkmock.components.ErrorState import com.worldline.devview.networkmock.components.GlobalMockToggle import com.worldline.devview.networkmock.components.LoadingState -import com.worldline.devview.networkmock.model.EndpointDescriptor -import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.repository.MockConfigRepository +import com.worldline.devview.networkmock.core.repository.MockStateRepository import com.worldline.devview.networkmock.preview.NetworkMockUiStatePreviewParameterProvider -import com.worldline.devview.networkmock.repository.MockConfigRepository -import com.worldline.devview.networkmock.repository.MockStateRepository import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState import com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel import kotlinx.coroutines.flow.SharedFlow @@ -97,7 +98,7 @@ public fun NetworkMockScreen( } @Composable -private fun NetworkMockScreenContent( +internal fun NetworkMockScreenContent( uiState: NetworkMockUiState, onGlobalToggle: (Boolean) -> Unit, setEndpointMockState: (String, String, String?) -> Unit, @@ -176,6 +177,7 @@ private fun ContentState( ) { uiState.hosts.forEachIndexed { index, host -> Tab( + modifier = Modifier.testTag(tag = "host_tab_${host.id}"), selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }, text = { Text(text = host.name) } @@ -199,6 +201,9 @@ private fun ContentState( key = { _, endpoint -> "${host.id}-${endpoint.descriptor.endpointId}" } ) { index, endpoint -> EndpointCard( + modifier = Modifier.testTag( + tag = "endpoint_card_${endpoint.descriptor.hostId}_${endpoint.descriptor.endpointId}" + ), endpoint = endpoint, openEndpointBottomSheet = { selectEndpoint( 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 83876e9..51c671c 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 @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -33,7 +34,7 @@ import com.worldline.devview.networkmock.viewmodel.EndpointUiModel * @param modifier Optional modifier */ @Composable -public fun EndpointCard( +internal fun EndpointCard( endpoint: EndpointUiModel, openEndpointBottomSheet: () -> Unit, modifier: Modifier = Modifier, @@ -58,6 +59,9 @@ public fun EndpointCard( verticalArrangement = Arrangement.spacedBy(space = spacing.dp) ) { Text( + modifier = Modifier.testTag( + tag = "endpoint_name_${endpoint.descriptor.endpointId}" + ), text = endpoint.descriptor.config.name, style = MaterialTheme.typography.titleMedium ) @@ -66,12 +70,18 @@ public fun EndpointCard( verticalAlignment = Alignment.CenterVertically ) { Text( + modifier = Modifier.testTag( + tag = "endpoint_method_${endpoint.descriptor.endpointId}" + ), text = endpoint.descriptor.config.method, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace ) Text( + modifier = Modifier.testTag( + tag = "endpoint_path_${endpoint.descriptor.endpointId}" + ), text = endpoint.descriptor.config.path, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -82,6 +92,9 @@ public fun EndpointCard( visible = showFileName ) { Text( + modifier = Modifier.testTag( + tag = "endpoint_state_${endpoint.descriptor.endpointId}" + ), text = endpoint.currentState.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -89,137 +102,11 @@ public fun EndpointCard( } } EndpointStateChip( - endpointMockState = endpoint.currentState + endpointMockState = endpoint.currentState, + chipTestTag = "endpoint_state_chip_${endpoint.descriptor.endpointId}", + labelTestTag = "endpoint_state_chip_label_${endpoint.descriptor.endpointId}" ) } - -// Card( -// modifier = modifier -// .fillMaxWidth() -// ) { -// Column( -// modifier = Modifier -// .fillMaxWidth() -// .padding(all = 16.dp) -// ) { -// // Endpoint name and method/path -// Text( -// text = endpoint.config.name, -// style = MaterialTheme.typography.titleMedium -// ) -// -// Spacer(modifier = Modifier.height(height = 4.dp)) -// -// Row( -// horizontalArrangement = Arrangement.spacedBy(space = 8.dp), -// verticalAlignment = Alignment.CenterVertically -// ) { -// Text( -// text = endpoint.config.method, -// style = MaterialTheme.typography.labelSmall, -// color = MaterialTheme.colorScheme.primary, -// fontFamily = FontFamily.Monospace -// ) -// Text( -// text = endpoint.config.path, -// style = MaterialTheme.typography.bodySmall, -// color = MaterialTheme.colorScheme.onSurfaceVariant, -// fontFamily = FontFamily.Monospace -// ) -// } -// -// Spacer(modifier = Modifier.height(height = 12.dp)) -// -// // Mock toggle -// Row( -// modifier = Modifier.fillMaxWidth(), -// horizontalArrangement = Arrangement.SpaceBetween, -// verticalAlignment = Alignment.CenterVertically -// ) { -// Text( -// text = if (endpoint.currentState.mockEnabled) "Use Mock" else "Use Network", -// style = MaterialTheme.typography.bodyMedium -// ) -// Switch( -// checked = endpoint.currentState.mockEnabled, -// onCheckedChange = onToggleMock -// ) -// } -// -// // Response selector (only when mock is enabled) -// if (endpoint.currentState.mockEnabled && endpoint.availableResponses.isNotEmpty()) { -// Spacer(modifier = Modifier.height(height = 12.dp)) -// -// Column { -// Text( -// text = "Mock Response:", -// style = MaterialTheme.typography.labelMedium, -// color = MaterialTheme.colorScheme.onSurfaceVariant -// ) -// -// Spacer(modifier = Modifier.height(height = 4.dp)) -// -// OutlinedButton( -// onClick = { showDropdown = true }, -// modifier = Modifier.fillMaxWidth() -// ) { -// Row( -// modifier = Modifier.fillMaxWidth(), -// horizontalArrangement = Arrangement.SpaceBetween, -// verticalAlignment = Alignment.CenterVertically -// ) { -// Text( -// text = endpoint.currentState.selectedResponseFile -// ?: "Select response...", -// style = MaterialTheme.typography.bodyMedium -// ) -// Icon( -// imageVector = Icons.Default.ArrowDropDown, -// contentDescription = "Select response" -// ) -// } -// } -// -// DropdownMenu( -// expanded = showDropdown, -// onDismissRequest = { showDropdown = false } -// ) { -// endpoint.availableResponses.forEach { response -> -// DropdownMenuItem( -// text = { -// Column { -// Text( -// text = response.displayName, -// style = MaterialTheme.typography.bodyMedium -// ) -// Text( -// text = response.fileName, -// style = MaterialTheme.typography.bodySmall, -// color = MaterialTheme.colorScheme.onSurfaceVariant -// ) -// } -// }, -// onClick = { -// onSelectResponse(response.fileName) -// showDropdown = false -// } -// ) -// } -// } -// } -// } -// -// // Show message if no responses available -// if (endpoint.currentState.mockEnabled && endpoint.availableResponses.isEmpty()) { -// Spacer(modifier = Modifier.height(height = 8.dp)) -// Text( -// text = "No mock responses available for this endpoint", -// style = MaterialTheme.typography.bodySmall, -// color = MaterialTheme.colorScheme.error -// ) -// } -// } -// } } @Preview(locale = "en") 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 ffc174c..d95ab39 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 @@ -13,10 +13,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.EndpointMockState import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider import com.worldline.devview.networkmock.utils.containerColor import com.worldline.devview.networkmock.utils.contentColor @@ -24,9 +25,20 @@ import com.worldline.devview.networkmock.utils.icon import com.worldline.devview.networkmock.viewmodel.EndpointUiModel @Composable -public fun EndpointStateChip(endpointMockState: EndpointMockState, modifier: Modifier = Modifier) { +public fun EndpointStateChip( + endpointMockState: EndpointMockState, + modifier: Modifier = Modifier, + chipTestTag: String = "endpoint_state_chip", + labelTestTag: String = "endpoint_state_chip_label" +) { + val displayedLabel = when (endpointMockState) { + is EndpointMockState.Mock -> endpointMockState.statusCode.toString() + EndpointMockState.Network -> endpointMockState.displayName + } + Row( modifier = modifier + .testTag(tag = chipTestTag) .clip( shape = MaterialTheme.shapes.small ).background( @@ -46,10 +58,8 @@ public fun EndpointStateChip(endpointMockState: EndpointMockState, modifier: Mod tint = endpointMockState.contentColor ) Text( - text = when (endpointMockState) { - is EndpointMockState.Mock -> endpointMockState.statusCode.toString() - EndpointMockState.Network -> endpointMockState.displayName - }, + modifier = Modifier.testTag(tag = "${labelTestTag}_$displayedLabel"), + text = displayedLabel, style = MaterialTheme.typography.bodySmallEmphasized, color = endpointMockState.contentColor ) diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggle.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggle.kt index 5500a5c..8cb64af 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggle.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/GlobalMockToggle.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -71,6 +72,7 @@ internal fun GlobalMockToggle( } Switch( + modifier = Modifier.testTag(tag = "global_mock_toggle_switch"), checked = enabled, onCheckedChange = onToggle ) diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt index 62db0ca..a7fb89c 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt @@ -20,8 +20,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.worldline.devview.networkmock.model.EndpointMockState -import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.preview.MockResponsePreviewParameterProvider import com.worldline.devview.networkmock.utils.containerColor import com.worldline.devview.networkmock.utils.containerColorForStatusCode diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/MockResponsePreviewParameterProvider.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/MockResponsePreviewParameterProvider.kt index 0ead7fb..b54c62b 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/MockResponsePreviewParameterProvider.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/MockResponsePreviewParameterProvider.kt @@ -1,7 +1,7 @@ package com.worldline.devview.networkmock.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.utils.fake internal class MockResponsePreviewParameterProvider : PreviewParameterProvider { 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 5406dcc..7b803fa 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 @@ -10,10 +10,10 @@ import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Wifi import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import com.worldline.devview.networkmock.model.EndpointConfig -import com.worldline.devview.networkmock.model.EndpointDescriptor -import com.worldline.devview.networkmock.model.EndpointMockState -import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.core.model.EndpointConfig +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.MockResponse import com.worldline.devview.networkmock.viewmodel.EndpointUiModel import com.worldline.devview.networkmock.viewmodel.HostUiModel import kotlinx.collections.immutable.toPersistentList 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 1d7fa77..60a1e72 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 @@ -3,11 +3,11 @@ package com.worldline.devview.networkmock.viewmodel import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.worldline.devview.networkmock.model.EndpointDescriptor -import com.worldline.devview.networkmock.model.EndpointMockState -import com.worldline.devview.networkmock.model.MockConfiguration -import com.worldline.devview.networkmock.repository.MockConfigRepository -import com.worldline.devview.networkmock.repository.MockStateRepository +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.MockConfiguration +import com.worldline.devview.networkmock.core.repository.MockConfigRepository +import com.worldline.devview.networkmock.core.repository.MockStateRepository import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow diff --git a/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/utils/ModelUtilsTest.kt b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/utils/ModelUtilsTest.kt new file mode 100644 index 0000000..d7b8125 --- /dev/null +++ b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/utils/ModelUtilsTest.kt @@ -0,0 +1,113 @@ +package com.worldline.devview.networkmock.utils + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material.icons.automirrored.rounded.Redo +import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.ui.graphics.Color +import com.worldline.devview.networkmock.core.model.EndpointDescriptor +import com.worldline.devview.networkmock.core.model.EndpointMockState +import com.worldline.devview.networkmock.core.model.MockResponse +import com.worldline.devview.networkmock.viewmodel.EndpointUiModel +import com.worldline.devview.networkmock.viewmodel.HostUiModel +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class ModelUtilsTest { + + @Test + fun `iconForStatusCode maps HTTP families and fallback`() { + iconForStatusCode(statusCode = 101) shouldBe Icons.Rounded.Info + iconForStatusCode(statusCode = 204) shouldBe Icons.Rounded.CheckCircleOutline + iconForStatusCode(statusCode = 302) shouldBe Icons.AutoMirrored.Rounded.Redo + iconForStatusCode(statusCode = 404) shouldBe Icons.Rounded.ErrorOutline + iconForStatusCode(statusCode = 503) shouldBe Icons.Rounded.CloudOff + + iconForStatusCode(statusCode = null) shouldBe Icons.AutoMirrored.Rounded.HelpOutline + iconForStatusCode(statusCode = 42) shouldBe Icons.AutoMirrored.Rounded.HelpOutline + } + + @Test + fun `contentColorForStatusCode maps HTTP families and fallback`() { + contentColorForStatusCode(statusCode = 150) shouldBe Color(color = 0xFF184559) + contentColorForStatusCode(statusCode = 250) shouldBe Color(color = 0xFF103C13) + contentColorForStatusCode(statusCode = 350) shouldBe Color(color = 0xFF603610) + contentColorForStatusCode(statusCode = 450) shouldBe Color(color = 0xFF6F1111) + contentColorForStatusCode(statusCode = 550) shouldBe Color(color = 0xFF611A59) + + contentColorForStatusCode(statusCode = null) shouldBe Color(color = 0xFF3D3D3D) + contentColorForStatusCode(statusCode = 700) shouldBe Color(color = 0xFF3D3D3D) + } + + @Test + fun `containerColorForStatusCode maps HTTP families and fallback`() { + containerColorForStatusCode(statusCode = 150) shouldBe Color(color = 0xFFB7DCEC) + containerColorForStatusCode(statusCode = 250) shouldBe Color(color = 0xFFB7ECBA) + containerColorForStatusCode(statusCode = 350) shouldBe Color(color = 0xFFF0CAA7) + containerColorForStatusCode(statusCode = 450) shouldBe Color(color = 0xFFECB7B7) + containerColorForStatusCode(statusCode = 550) shouldBe Color(color = 0xFFECB7E6) + + containerColorForStatusCode(statusCode = null) shouldBe Color(color = 0xFFD1D1D1) + containerColorForStatusCode(statusCode = 700) shouldBe Color(color = 0xFFD1D1D1) + } + + @Test + fun `endpoint state extension properties use network defaults`() { + val state = EndpointMockState.Network + + state.icon shouldBe Icons.Rounded.Wifi + state.contentColor shouldBe Color(color = 0xFF0D1F3A) + state.containerColor shouldBe Color(color = 0xFFABC4ED) + } + + @Test + fun `endpoint state extension properties use mock status code mapping`() { + val state = EndpointMockState.Mock(responseFile = "response-404.json") + + state.icon shouldBe Icons.Rounded.ErrorOutline + state.contentColor shouldBe Color(color = 0xFF6F1111) + state.containerColor shouldBe Color(color = 0xFFECB7B7) + } + + @Test + fun `fake HostUiModel creates requested amount with nested endpoints`() { + val hosts = HostUiModel.fake(amount = 3) + + hosts shouldHaveSize 3 + hosts[0].id shouldBe "host-0" + hosts[0].name shouldBe "Host 0" + hosts[0].url shouldBe "https://api.host0.com" + hosts[0].endpoints shouldHaveSize 7 + } + + @Test + fun `fake EndpointDescriptor creates requested amount and response count`() { + val descriptors = EndpointDescriptor.fake(amount = 2, availableResponsesAmount = 4, hostId = "qa") + + descriptors shouldHaveSize 2 + descriptors[0].hostId shouldBe "qa" + descriptors[0].endpointId shouldBe "endpoint-0" + descriptors[0].config.path shouldBe "/endpoint0" + descriptors[0].availableResponses shouldHaveSize 4 + } + + @Test + fun `fake EndpointUiModel and MockResponse create requested amount`() { + val endpoints = EndpointUiModel.fake(amount = 5, availableResponsesAmount = 2) + val responses = MockResponse.fake(amount = 4) + + endpoints shouldHaveSize 5 + endpoints[0].descriptor.availableResponses shouldHaveSize 2 + endpoints[0].currentState shouldBe EndpointMockState.Mock(responseFile = "response-100.json") + + responses shouldHaveSize 4 + responses[1].fileName shouldBe "response1.json" + responses[1].statusCode shouldBe 200 + responses[1].displayName shouldBe "Response 1" + } +} diff --git a/devview-utils/build.gradle.kts b/devview-utils/build.gradle.kts index aa4608d..76bb547 100644 --- a/devview-utils/build.gradle.kts +++ b/devview-utils/build.gradle.kts @@ -2,6 +2,9 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.compose.multiplatform) alias(libs.plugins.convention.datastore) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.deviceTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) } @@ -9,7 +12,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.utils" } diff --git a/devview-utils/src/androidDeviceTest/kotlin/com/worldline/devview/utils/DataStoreDelegateUiTest.kt b/devview-utils/src/androidDeviceTest/kotlin/com/worldline/devview/utils/DataStoreDelegateUiTest.kt new file mode 100644 index 0000000..8e727d4 --- /dev/null +++ b/devview-utils/src/androidDeviceTest/kotlin/com/worldline/devview/utils/DataStoreDelegateUiTest.kt @@ -0,0 +1,27 @@ +package com.worldline.devview.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.assertNotNull +import org.junit.Test + +class DataStoreDelegateUiTest { + + @Test + fun dataStoreDelegate_init_in_composition_allows_get_afterwards() = runComposeUiTest { + val delegate = DataStoreDelegate() + + setContent { + InitDelegate(delegate = delegate) + } + + runOnIdle { + assertNotNull(delegate.get()) + } + } +} + +@Composable +private fun InitDelegate(delegate: DataStoreDelegate) { + delegate.init(dataStoreName = "devview_utils_ui_test.preferences_pb") +} diff --git a/devview-utils/src/commonTest/kotlin/com/worldline/devview/utils/DataStoreDelegateTest.kt b/devview-utils/src/commonTest/kotlin/com/worldline/devview/utils/DataStoreDelegateTest.kt new file mode 100644 index 0000000..2641793 --- /dev/null +++ b/devview-utils/src/commonTest/kotlin/com/worldline/devview/utils/DataStoreDelegateTest.kt @@ -0,0 +1,20 @@ +package com.worldline.devview.utils + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.string.shouldContain +import kotlin.test.Test + +class DataStoreDelegateTest { + + @Test + fun `get throws when datastore is not initialized`() { + val delegate = DataStoreDelegate() + + val exception = shouldThrow { + delegate.get() + } + + exception.message.shouldContain("DataStore not initialised") + } +} + diff --git a/devview-utils/src/commonTest/kotlin/com/worldline/devview/utils/preview/BooleanPreviewParameterProviderTest.kt b/devview-utils/src/commonTest/kotlin/com/worldline/devview/utils/preview/BooleanPreviewParameterProviderTest.kt new file mode 100644 index 0000000..1abbd04 --- /dev/null +++ b/devview-utils/src/commonTest/kotlin/com/worldline/devview/utils/preview/BooleanPreviewParameterProviderTest.kt @@ -0,0 +1,25 @@ +package com.worldline.devview.utils.preview + +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class BooleanPreviewParameterProviderTest { + + @Test + fun `values exposes true then false`() { + val provider = BooleanPreviewParameterProvider() + + provider.values.toList().shouldContainExactly(true, false) + } + + @Test + fun `display names map to values and out of range returns null`() { + val provider = BooleanPreviewParameterProvider() + + provider.getDisplayName(0) shouldBe "True" + provider.getDisplayName(1) shouldBe "False" + provider.getDisplayName(2) shouldBe null + } +} + diff --git a/devview/build.gradle.kts b/devview/build.gradle.kts index bd1af8d..85273ec 100644 --- a/devview/build.gradle.kts +++ b/devview/build.gradle.kts @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.convention.multiplatform.library) alias(libs.plugins.convention.compose.multiplatform) + alias(libs.plugins.convention.unitTest) + alias(libs.plugins.convention.deviceTest) + alias(libs.plugins.convention.kover) alias(libs.plugins.dokka) alias(libs.plugins.maven.publish) alias(libs.plugins.poko) @@ -9,7 +12,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview" } @@ -30,3 +33,12 @@ poko { tasks.withType { failOnNoDiscoveredTests.set(false) } + +dependencies { + kover(projects.devviewAnalytics) + kover(projects.devviewFeatureflip) + kover(projects.devviewNetworkmock) + kover(projects.devviewNetworkmockCore) + kover(projects.devviewNetworkmockKtor) + kover(projects.devviewUtils) +} diff --git a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt new file mode 100644 index 0000000..64b89c7 --- /dev/null +++ b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt @@ -0,0 +1,82 @@ +package com.worldline.devview + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.printToLog +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.Dp +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.worldline.devview.core.DestinationMetadata +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import com.worldline.devview.core.withTitle +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import org.junit.Test + +class DevViewTest { + + @Test + fun devView_hidden_does_not_render_home_content() = runComposeUiTest { + setContent { + DevView( + devViewIsOpen = false, + closeDevView = {}, + modules = persistentListOf(DevViewModule) + ) + } + + onAllNodesWithTag(testTag = "module_item_${DevViewModule.moduleName}").assertCountEquals(0) + } + + @Test + fun devView_open_navigates_to_module_first_destination() = runComposeUiTest { + setContent { + DevView( + devViewIsOpen = true, + closeDevView = {}, + modules = persistentListOf(DevViewModule) + ) + } + + onNodeWithTag(testTag = "module_item_${DevViewModule.moduleName}").assertIsDisplayed() + + onNodeWithTag(testTag = "module_item_${DevViewModule.moduleName}").performClick() + + onNodeWithText(text = "Network Mock Screen").assertIsDisplayed() + } +} + +@Serializable +private data object DevViewDestination : NavKey + +private data object DevViewModule : Module { + override val moduleName: String = "Network Mock" + override val section: Section = Section.NETWORK + override val destinations: PersistentMap = + persistentMapOf(DevViewDestination.withTitle(title = "Network Mock Screen")) + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit, + bottomPadding: Dp + ) { + entry { + Box(modifier = Modifier.fillMaxSize()) + } + } +} diff --git a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/HomeScreenTest.kt b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/HomeScreenTest.kt new file mode 100644 index 0000000..28f4d46 --- /dev/null +++ b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/HomeScreenTest.kt @@ -0,0 +1,58 @@ +package com.worldline.devview + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import io.kotest.matchers.shouldBe +import org.junit.Test + +class HomeScreenTest { + + @Test + fun homeScreen_displays_section_headers_and_module_names() = runComposeUiTest { + val modules = listOf( + TestModule(name = "App Info", section = Section.SETTINGS), + TestModule(name = "Feature Flags", section = Section.FEATURES), + TestModule(name = "Console", section = Section.LOGGING) + ) + + setContent { + HomeScreen( + modules = modules, + openModule = {} + ) + } + + onNodeWithTag(testTag = "section_header_SETTINGS").assertIsDisplayed() + onNodeWithTag(testTag = "section_header_FEATURES").assertIsDisplayed() + onNodeWithTag(testTag = "section_header_LOGGING").assertIsDisplayed() + + onNodeWithText("App Info").assertIsDisplayed() + onNodeWithText("Feature Flags").assertIsDisplayed() + onNodeWithText("Console").assertIsDisplayed() + } + + @Test + fun homeScreen_calls_openModule_with_clicked_module() = runComposeUiTest { + val analytics = TestModule(name = "Analytics", section = Section.LOGGING) + val featureFlip = TestModule(name = "Feature Flip", section = Section.FEATURES) + var opened: Module? = null + + setContent { + HomeScreen( + modules = listOf(featureFlip, analytics), + openModule = { opened = it } + ) + } + + onNodeWithTag(testTag = "module_item_Analytics").performClick() + + runOnIdle { + opened shouldBe analytics + } + } +} diff --git a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/TestModule.kt b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/TestModule.kt new file mode 100644 index 0000000..3178341 --- /dev/null +++ b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/TestModule.kt @@ -0,0 +1,34 @@ +package com.worldline.devview + +import androidx.compose.ui.unit.Dp +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.worldline.devview.core.DestinationMetadata +import com.worldline.devview.core.Module +import com.worldline.devview.core.Section +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +data class TestModule( + private val name: String, + override val subtitle: String? = null, + override val section: Section = Section.CUSTOM, + private val destinationsToUse: List = emptyList(), + private val entries: EntryProviderScope.() -> Unit = {}, +) : Module { + override val moduleName: String = name + override val destinations: PersistentMap = + destinationsToUse.associateWith { + DestinationMetadata() + }.toPersistentMap() + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit, + bottomPadding: Dp + ) { + entries() + } +} \ No newline at end of file diff --git a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/core/ModuleRegistryUiTest.kt b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/core/ModuleRegistryUiTest.kt new file mode 100644 index 0000000..2d80f44 --- /dev/null +++ b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/core/ModuleRegistryUiTest.kt @@ -0,0 +1,88 @@ +package com.worldline.devview.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import io.kotest.matchers.collections.shouldContainExactly +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import org.junit.Test + +class ModuleRegistryUiTest { + + @Test + fun rememberModules_builds_modules_in_order_and_calls_initModule() = runComposeUiTest { + val initCalls = mutableListOf() + lateinit var builtModules: List + + setContent { + builtModules = rememberModules { + module(TrackingModule(name = "A", initCalls = initCalls)) + module(TrackingModule(name = "B", initCalls = initCalls)) + } + } + + runOnIdle { + builtModules.map { it.moduleName }.shouldContainExactly("A", "B") + initCalls.shouldContainExactly("A", "B") + } + } + + @Test + fun rememberModules_does_not_call_initModule_again_when_parent_recomposes() = runComposeUiTest { + val initCalls = mutableListOf() + lateinit var builtModules: List + lateinit var triggerRecompose: () -> Unit + + setContent { + val recomposeTick = remember { mutableIntStateOf(value = 0) } + triggerRecompose = { recomposeTick.intValue++ } + recomposeTick.intValue + + builtModules = rememberModules { + module(TrackingModule(name = "A", initCalls = initCalls)) + module(TrackingModule(name = "B", initCalls = initCalls)) + } + } + + runOnIdle { + initCalls.shouldContainExactly("A", "B") + triggerRecompose() + } + + waitForIdle() + + runOnIdle { + builtModules.map { it.moduleName }.shouldContainExactly("A", "B") + initCalls.shouldContainExactly("A", "B") + } + } +} + +private data object UiTestDestination : NavKey + +private class TrackingModule( + name: String, + private val initCalls: MutableList +) : Module { + override val moduleName: String = name + override val section: Section = Section.CUSTOM + override val destinations: PersistentMap = + persistentMapOf(UiTestDestination to DestinationMetadata()) + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit, + bottomPadding: androidx.compose.ui.unit.Dp + ) = Unit + + @Composable + override fun initModule() { + initCalls.add(moduleName) + } +} diff --git a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/internal/components/ModuleItemUiTest.kt b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/internal/components/ModuleItemUiTest.kt new file mode 100644 index 0000000..d15b4d2 --- /dev/null +++ b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/internal/components/ModuleItemUiTest.kt @@ -0,0 +1,54 @@ +package com.worldline.devview.internal.components + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import com.worldline.devview.TestModule +import com.worldline.devview.core.Module +import io.kotest.matchers.shouldBe +import org.junit.Test + +class ModuleItemUiTest { + + @Test + fun moduleItem_displays_title_and_subtitle_and_handles_click() = runComposeUiTest { + var opened: Module? = null + val module = TestModule(name = "Network Mock", subtitle = "Inspect requests") + + setContent { + ModuleItem( + module = module, + position = ModulePosition.SINGLE, + openModule = { opened = it } + ) + } + + onNodeWithTag(testTag = "module_name_Network Mock").assertIsDisplayed() + onNodeWithTag(testTag = "module_subtitle_Network Mock").assertIsDisplayed() + + onNodeWithTag(testTag = "module_name_Network Mock").performClick() + + runOnIdle { + opened shouldBe module + } + } + + @Test + fun moduleItem_hides_subtitle_when_not_provided() = runComposeUiTest { + val module = TestModule(name = "Analytics", subtitle = null) + + setContent { + ModuleItem( + module = module, + position = ModulePosition.SINGLE, + openModule = {} + ) + } + + onNodeWithTag(testTag = "module_name_Analytics").assertIsDisplayed() + onAllNodesWithTag(testTag = "module_subtitle_Analytics").assertCountEquals(0) + } +} diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt b/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt index 2c8a60e..d179304 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/HomeScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -118,7 +119,8 @@ internal fun HomeScreen( .background( color = MaterialTheme.colorScheme.background ).padding(start = 16.dp + 16.dp) - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .testTag(tag = "section_header_${section.name}"), text = section.name.replace(oldChar = '_', newChar = ' '), style = MaterialTheme.typography.bodySmallEmphasized.copy( color = MaterialTheme.colorScheme.outline, diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt index 6646c72..d4a3d74 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleRegistry.kt @@ -240,14 +240,15 @@ public fun buildModules(block: ModuleRegistry.() -> Unit): ImmutableList */ @Composable public fun rememberModules(block: ModuleRegistry.() -> Unit): ImmutableList { - val modules = buildModules(block = block) + val modules = remember { buildModules(block = block) } + val initializedModules = remember { mutableSetOf() } modules.forEach { module -> - if (module is RequiresDataStore) module.initDataStore() - module.initModule() + if (initializedModules.add(element = module)) { + if (module is RequiresDataStore) module.initDataStore() + module.initModule() + } } - return remember { - modules - } + return modules } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt b/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt index 8e79b95..15b9675 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/internal/components/ModuleItem.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -102,6 +103,7 @@ internal fun ModuleItem( Card( modifier = modifier + .testTag(tag = "module_item_${module.moduleName}") .padding(horizontal = 16.dp), onClick = { openModule(module) }, shape = shape @@ -143,6 +145,7 @@ internal fun ModuleItem( verticalArrangement = Arrangement.Center ) { Text( + modifier = Modifier.testTag(tag = "module_name_${module.moduleName}"), text = module.moduleName, style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Normal @@ -150,6 +153,9 @@ internal fun ModuleItem( ) module.subtitle?.let { Text( + modifier = Modifier.testTag( + tag = "module_subtitle_${module.moduleName}" + ), text = it, style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Light diff --git a/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt b/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt new file mode 100644 index 0000000..441054f --- /dev/null +++ b/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt @@ -0,0 +1,101 @@ +package com.worldline.devview.core + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.navigation3.runtime.NavKey +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import kotlin.test.Test + +class DestinationMetadataExtensionsTest { + + @Test + fun `asDestination registers nav key with default metadata`() { + val key = TestNavKey + + val (registeredKey, metadata) = key.asDestination() + + registeredKey shouldBeSameInstanceAs key + metadata.title.shouldBeNull() + metadata.actions.shouldBeEmpty() + } + + @Test + fun `withTitle without block sets title and keeps actions empty`() { + val key = TestNavKey + + val (registeredKey, metadata) = key.withTitle(title = "My Screen") + + registeredKey shouldBeSameInstanceAs key + metadata.title shouldBe "My Screen" + metadata.actions.shouldBeEmpty() + } + + @Test + fun `withTitle with block sets title and builds actions in order`() { + val key = TestNavKey + val firstIcon = Icons.Default.Check + val secondIcon = Icons.Default.Close + val popup = ModuleDestinationActionPopup( + title = "Clear Logs", + subtitle = "This will remove all logged events.", + confirmButton = "Clear", + dismissButton = "Cancel" + ) + var firstCalls = 0 + var secondCalls = 0 + + val (registeredKey, metadata) = key.withTitle(title = "Analytics") { + action(icon = firstIcon, popup = popup) { + firstCalls++ + } + action(icon = secondIcon) { + secondCalls++ + } + } + + registeredKey shouldBeSameInstanceAs key + metadata.title shouldBe "Analytics" + metadata.actions shouldHaveSize 2 + + metadata.actions[0].icon shouldBeSameInstanceAs firstIcon + metadata.actions[0].popup shouldBe popup + metadata.actions[1].icon shouldBeSameInstanceAs secondIcon + metadata.actions[1].popup.shouldBeNull() + + metadata.actions[0].action() + metadata.actions[1].action() + + firstCalls shouldBe 1 + secondCalls shouldBe 1 + } + + @Test + fun `withActions with block keeps title null and builds actions`() { + val key = TestNavKey + val icon = Icons.Default.Check + var calls = 0 + + val (registeredKey, metadata) = key.withActions { + action(icon = icon) { + calls++ + } + } + + registeredKey shouldBeSameInstanceAs key + metadata.title.shouldBeNull() + metadata.actions shouldHaveSize 1 + metadata.actions.single().icon shouldBeSameInstanceAs icon + metadata.actions.single().popup.shouldBeNull() + + metadata.actions.single().action() + + calls shouldBe 1 + } +} + +private data object TestNavKey : NavKey diff --git a/devview/src/commonTest/kotlin/com/worldline/devview/core/ModuleRegistryTest.kt b/devview/src/commonTest/kotlin/com/worldline/devview/core/ModuleRegistryTest.kt new file mode 100644 index 0000000..e23448f --- /dev/null +++ b/devview/src/commonTest/kotlin/com/worldline/devview/core/ModuleRegistryTest.kt @@ -0,0 +1,95 @@ +package com.worldline.devview.core + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import kotlin.test.Test + +class ModuleRegistryTest { + + @Test + fun `module adds a single module and preserves insertion order`() { + val registry = ModuleRegistry() + + val built = registry + .module(ModuleA) + .module(ModuleB) + .build() + + built.shouldContainExactly(ModuleA, ModuleB) + } + + @Test + fun `modules adds all provided modules in order`() { + val registry = ModuleRegistry() + + val built = registry + .modules(ModuleA, ModuleB, ModuleC) + .build() + + built.shouldContainExactly(ModuleA, ModuleB, ModuleC) + } + + @Test + fun `module and modules can be chained together`() { + val registry = ModuleRegistry() + + val built = registry + .module(ModuleA) + .modules(ModuleB, ModuleC) + .module(ModuleD) + .build() + + built.shouldContainExactly(ModuleA, ModuleB, ModuleC, ModuleD) + } + + @Test + fun `buildModules DSL produces the same ordered immutable list`() { + val built = buildModules { + module(ModuleA) + modules(ModuleB, ModuleC) + module(ModuleD) + } + + built.shouldContainExactly(ModuleA, ModuleB, ModuleC, ModuleD) + } + + @Test + fun `builder methods return the same registry instance for fluent chaining`() { + val registry = ModuleRegistry() + + val fromModule = registry.module(ModuleA) + val fromModules = registry.modules(ModuleB) + + fromModule shouldBe registry + fromModules shouldBe registry + } +} + +private data object DummyDestination : NavKey + +private abstract class TestModule( + name: String +) : Module { + override val moduleName: String = name + override val section: Section = Section.CUSTOM + override val destinations: PersistentMap = + persistentMapOf(DummyDestination to DestinationMetadata()) + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit, + bottomPadding: androidx.compose.ui.unit.Dp + ) = Unit +} + +private data object ModuleA : TestModule(name = "A") +private data object ModuleB : TestModule(name = "B") +private data object ModuleC : TestModule(name = "C") +private data object ModuleD : TestModule(name = "D") + diff --git a/gradle.properties b/gradle.properties index fc9cd87..e62cbfb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,31 @@ -#Kotlin -kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx3072M +# Turn on parallel compilation, caching and on-demand configuration +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx5g -Dfile.encoding=UTF-8 -#Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +# https://docs.gradle.org/7.6/userguide/configuration_cache.html org.gradle.configuration-cache=true -org.gradle.caching=true +org.gradle.unsafe.configuration-cache-problems=warn #Android android.nonTransitiveRClass=true android.useAndroidX=true +kotlin.mpp.androidSourceSetLayoutVersion=2 + +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M + +# Dokka v2 +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true + +# Required to publish to Nexus (see https://github.com/gradle/gradle/issues/11308) +systemProp.org.gradle.internal.publish.checksums.insecure=true +# Increase timeout when pushing to Sonatype (otherwise we get timeouts) +systemProp.org.gradle.internal.http.socketTimeout=30000 + ################################## # Publishing ################################## @@ -22,10 +37,10 @@ GROUP=com.worldline.devview VERSION_NAME=0.0.1-SNAPSHOT POM_DESCRIPTION=DevView -POM_URL=https://github.com/worldline/DevView/ -POM_SCM_URL=https://github.com/worldline/DevView/ -POM_SCM_CONNECTION=scm:git:git://github.com/worldline/DevView/.git -POM_SCM_DEV_CONNECTION=scm:git:git://github.com/worldline/DevView/.git +POM_URL=https://github.com/worldline/devview/ +POM_SCM_URL=https://github.com/worldline/devview/ +POM_SCM_CONNECTION=scm:git:git://github.com/worldline/devview/.git +POM_SCM_DEV_CONNECTION=scm:git:git://github.com/worldline/devview/.git POM_LICENCE_NAME=The Apache Software License, Version 2.0 POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt diff --git a/gradle/build-logic/convention/build.gradle.kts b/gradle/build-logic/convention/build.gradle.kts index cb5f049..c696b78 100644 --- a/gradle/build-logic/convention/build.gradle.kts +++ b/gradle/build-logic/convention/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kover.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) + compileOnly(libs.mokkery.gradlePlugin) compileOnly(libs.room.gradlePlugin) } @@ -53,6 +54,10 @@ gradlePlugin { id = libs.plugins.convention.datastore.get().pluginId implementationClass = "DatastoreConventionPlugin" } + register("deviceTest") { + id = libs.plugins.convention.deviceTest.get().pluginId + implementationClass = "DeviceTestConventionPlugin" + } register("konsist") { id = libs.plugins.convention.konsist.get().pluginId implementationClass = "KonsistConventionPlugin" diff --git a/gradle/build-logic/convention/src/main/kotlin/ComposeMultiplatformConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/ComposeMultiplatformConventionPlugin.kt index 7e2bf60..9963843 100644 --- a/gradle/build-logic/convention/src/main/kotlin/ComposeMultiplatformConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/ComposeMultiplatformConventionPlugin.kt @@ -41,18 +41,6 @@ class ComposeMultiplatformConventionPlugin : Plugin { extensions.getByType() androidExtension.apply { - withHostTestBuilder { - sourceSetTreeName = null - }.configure { - isIncludeAndroidResources = true - } - - withDeviceTestBuilder { - sourceSetTreeName = null - }.configure { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - targets.withType { compileSdk = Versions.COMPILE_SDK @@ -107,17 +95,6 @@ class ComposeMultiplatformConventionPlugin : Plugin { } } - getByName("androidDeviceTest") { - dependencies { - val bom = libs.findLibrary("androidx.compose.bom").get() - implementation(project.dependencies.platform(bom)) - implementation(libs.findLibrary("androidx.compose.ui.test.junit4.android").get()) - implementation(libs.findLibrary("androidx.compose.ui.test.manifest").get()) - implementation(kotlin("test")) - implementation(kotlin("test-annotations-common")) - } - } - androidMain { dependencies { implementation(libs.findLibrary("jetbrains.compose.ui.tooling").get()) diff --git a/gradle/build-logic/convention/src/main/kotlin/DeviceTestConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/DeviceTestConventionPlugin.kt new file mode 100644 index 0000000..2c7f89b --- /dev/null +++ b/gradle/build-logic/convention/src/main/kotlin/DeviceTestConventionPlugin.kt @@ -0,0 +1,52 @@ +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryExtension +import com.worldline.buildlogic.convention.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.invoke +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class DeviceTestConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + extensions.configure { + val androidExtension = + extensions.getByType() + + androidExtension.apply { + withDeviceTestBuilder { + sourceSetTreeName = null + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + all { + languageSettings.optIn("androidx.compose.ui.test.ExperimentalTestApi") + } + + getByName("androidDeviceTest") { + dependencies { + val composeBom = libs.findLibrary("androidx.compose.bom").get() + implementation(project.dependencies.platform(composeBom)) + implementation( + libs.findLibrary("androidx.compose.ui.test.junit4.android").get() + ) + implementation( + libs.findLibrary("androidx.compose.ui.test.manifest").get() + ) + implementation(kotlin("test")) + implementation(kotlin("test-annotations-common")) + + val kotestBom = libs.findLibrary("kotest.bom").get() + implementation(project.dependencies.platform(kotestBom)) + implementation(libs.findLibrary("kotest.assertions.core").get()) + } + } + } + } + } + } +} diff --git a/gradle/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt index 5098d4c..1fee72c 100644 --- a/gradle/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt @@ -17,16 +17,36 @@ class KoverConventionPlugin : Plugin { androidGeneratedClasses() packages( "*.generated.resources", + "*.internal.*", + "*.preview", + "*.preview.*", + "*.components", ) classes( "*_*", "*.*Constructor*", "*.*BuildKonfig*", "*.*ComposableSingletons*", + "*.*ScreenKt", + "*.TimeRange*", + "*.FeatureFilter*", + "*.*DefaultImpls*", + "*.*Destination*", + "*.*DataStoreDelegate*", + "com.worldline.devview.networkmock.core.NetworkMockInitializer" ) annotatedBy( - "*.Composable", - "*.Preview" + "androidx.compose.runtime.Composable", + "androidx.compose.ui.tooling.preview.Preview" + ) + inheritedFrom( + "*.HasTitle", + "*.PolymorphicModuleBuilder", + "*.ProvidableCompositionLocal", + "*.HighlightedAnalyticsLog", + "*.PreviewParameterProvider", + "*.DataStoreDelegate", + "*.Module" ) } } diff --git a/gradle/build-logic/convention/src/main/kotlin/KtorConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/KtorConventionPlugin.kt index c8000ab..4ac5fbd 100644 --- a/gradle/build-logic/convention/src/main/kotlin/KtorConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/KtorConventionPlugin.kt @@ -1,12 +1,12 @@ +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryExtension import com.worldline.buildlogic.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.dsl.kotlinExtension -import kotlin.apply -import org.gradle.kotlin.dsl.configure class KtorConventionPlugin : Plugin { override fun apply(target: Project) { @@ -21,9 +21,13 @@ class KtorConventionPlugin : Plugin { dependencies { implementation(dependencies.platform(bom)) implementation(libs.findLibrary("ktor.client.core").get()) - implementation(libs.findLibrary("ktor.client.content.negotiation").get()) + implementation( + libs.findLibrary("ktor.client.content.negotiation").get() + ) implementation(libs.findLibrary("ktor.client.logging").get()) - implementation(libs.findLibrary("ktor.serialization.kotlinx.json").get()) + implementation( + libs.findLibrary("ktor.serialization.kotlinx.json").get() + ) } } diff --git a/gradle/build-logic/convention/src/main/kotlin/MultiplatformLibraryConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/MultiplatformLibraryConventionPlugin.kt index c0b3043..af0c0a4 100644 --- a/gradle/build-logic/convention/src/main/kotlin/MultiplatformLibraryConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/MultiplatformLibraryConventionPlugin.kt @@ -42,12 +42,6 @@ class MultiplatformLibraryConventionPlugin : Plugin { implementation(libs.findLibrary("kermit").get()) } } - commonTest { - dependencies { - implementation(kotlin("test")) - implementation(kotlin("test-annotations-common")) - } - } } } diff --git a/gradle/build-logic/convention/src/main/kotlin/UnitTestConventionPlugin.kt b/gradle/build-logic/convention/src/main/kotlin/UnitTestConventionPlugin.kt index 51bb978..10ccabe 100644 --- a/gradle/build-logic/convention/src/main/kotlin/UnitTestConventionPlugin.kt +++ b/gradle/build-logic/convention/src/main/kotlin/UnitTestConventionPlugin.kt @@ -1,46 +1,107 @@ +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryExtension import com.worldline.buildlogic.convention.libs +import dev.mokkery.gradle.ApplicationRule +import dev.mokkery.gradle.MokkeryGradleExtension import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.internal.Actions.with import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.dsl.kotlinExtension /** * Convention plugin for unit test configuration in multiplatform modules. * - * This plugin configures: - * - Kotest as the testing framework - * - Common test dependencies (assertions, property testing, coroutines) + * This plugin configures test dependencies across all test source sets: + * - **commonTest**: Kotest framework, assertions, property testing, coroutine testing + * - **androidHostTest**: Kotest runner (JUnit 5), Android test core utilities, mocking * - * Note: This plugin should be applied alongside [MultiplatformLibraryConventionPlugin] - * which handles Kotlin Multiplatform and Android configuration. + * For device/emulator tests (`androidDeviceTest`), apply [ComposeDeviceTestConventionPlugin] + * in addition to this plugin. * - * TODO: Revisit this file - currently it mainly configures Kotest. Consider either: - * - Renaming to KotestConventionPlugin for clarity, or - * - Adding more globally necessary unit test dependencies + * ## Applied Frameworks + * - **Kotest** (6.1.7): Multiplatform testing framework + * - **kotlinx-coroutines-test**: Coroutine testing utilities + * - **Mockk** (1.13.15): Kotlin-native mocking library + * - **Turbine** (1.1.0): Flow testing utilities + * - **androidx-test-core**: Android test utilities (androidHostTest only) + * + * ## Usage + * Apply this plugin in your library's `build.gradle.kts`: + * ```kotlin + * plugins { + * alias(libs.plugins.convention.multiplatform.library) + * alias(libs.plugins.convention.unitTest) + * } + * ``` */ class UnitTestConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { + apply(plugin = "com.google.devtools.ksp") apply(plugin = "io.kotest") + apply(plugin = "dev.mokkery") extensions.configure { + val androidExtension = + extensions.getByType() + + androidExtension.apply { + withHostTestBuilder { + sourceSetTreeName = null + }.configure { + isIncludeAndroidResources = true + } + } + sourceSets { + all { + languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + } + + val kotestBom = libs.findLibrary("kotest.bom").get() + val ktorBom = libs.findLibrary("ktor.bom").get() + commonTest { dependencies { - val bom = libs.findLibrary("kotest.bom").get() - implementation(project.dependencies.platform(bom)) + implementation(project.dependencies.platform(kotestBom)) implementation(libs.findLibrary("kotest.framework.engine").get()) implementation(libs.findLibrary("kotest.assertions.core").get()) implementation(libs.findLibrary("kotest.property").get()) implementation(libs.findLibrary("kotlinx.coroutines.test").get()) + implementation(libs.findLibrary("turbine").get()) + + implementation(kotlin("test")) + implementation(kotlin("test-annotations-common")) + + implementation(dependencies.platform(ktorBom)) + implementation(libs.findLibrary("ktor.client.mock").get()) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(project.dependencies.platform(kotestBom)) + implementation(libs.findLibrary("kotest.runner.junit5").get()) + implementation(libs.findLibrary("mockk").get()) + + implementation(kotlin("test")) + implementation(kotlin("test-annotations-common")) + + implementation(dependencies.platform(ktorBom)) + implementation(libs.findLibrary("ktor.client.mock").get()) } } } } + + extensions.configure { + rule.set(ApplicationRule.All) + ignoreFinalMembers.set(true) + } } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 53647d4..df6ed93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,8 +26,10 @@ kotlinx-datetime = "0.7.1-0.6.x-compat" ksp = "2.3.6" ktor = "3.4.1" maven-publish = "0.36.0" +mockk = "1.14.9" mrmans0n-detekt-compose-rules = "0.5.6" poko = "0.22.0" +turbine = "1.2.1" # Kotlin only dependencies compose-stability-analyzer = "0.7.0" @@ -35,6 +37,7 @@ kotlin = "2.3.20" kover = "0.9.7" kotlinx-io = "0.9.0" kotlinx-serialization-json = "1.10.0" +mokkery = "3.3.0" # Compose Multiplatform only dependencies jetbrains-androidx-lifecycle = "2.10.0-beta01" @@ -106,6 +109,7 @@ koin-test = { module = "io.insert-koin:koin-test" } konsist = { module = "com.lemonappdev:konsist", version.ref = "konsist" } kotest-bom = { module = "io.kotest:kotest-bom", version.ref = "kotest" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core" } +kotest-framework-datatest = { module = "io.kotest:kotest-framework-datatest" } kotest-framework-engine = { module = "io.kotest:kotest-framework-engine" } kotest-property = { module = "io.kotest:kotest-property" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5" } @@ -120,9 +124,12 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti ktor-client-core = { module = "io.ktor:ktor-client-core" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin" } ktor-client-logging = { module = "io.ktor:ktor-client-logging" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "compose-screenshot"} +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } @@ -132,6 +139,7 @@ detekt-gradlePlugin = { group = "dev.detekt", name = "detekt-gradle-plugin", ver kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } kover-gradlePlugin = { group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +mokkery-gradlePlugin = { group = "dev.mokkery", name = "mokkery-gradle", version.ref = "mokkery" } room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "androidx-room" } [plugins] @@ -151,6 +159,7 @@ kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization" kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } +mokkery = { id = "dev.mokkery", version.ref = "mokkery" } poko = { id = "dev.drewhamilton.poko", version.ref = "poko" } room = { id = "androidx.room", version.ref = "androidx-room" } @@ -158,6 +167,7 @@ room = { id = "androidx.room", version.ref = "androidx-room" } convention-android-application = { id = "convention.android.application", version = "unspecified" } convention-compose-multiplatform = { id = "convention.compose.multiplatform", version = "unspecified" } convention-datastore = { id = "convention.datastore", version = "unspecified" } +convention-deviceTest = { id = "convention.deviceTest", version = "unspecified" } convention-koin = { id = "convention.koin", version = "unspecified" } convention-koin-compose = { id = "convention.koin.compose", version = "unspecified" } convention-konsist = { id = "convention.konsist", version = "unspecified" } diff --git a/internal/dokka/build.gradle.kts b/internal/dokka/build.gradle.kts index cd2e509..ab15512 100644 --- a/internal/dokka/build.gradle.kts +++ b/internal/dokka/build.gradle.kts @@ -6,7 +6,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.docs" } } diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts new file mode 100644 index 0000000..204d3f0 --- /dev/null +++ b/konsist/build.gradle.kts @@ -0,0 +1,36 @@ +import dev.detekt.gradle.Detekt +import org.gradle.kotlin.dsl.withType + +plugins { + alias(libs.plugins.convention.konsist) + alias(libs.plugins.detekt) +} + +tasks.withType { + outputs.upToDateWhen { false } +} + +detekt { + source.setFrom(files("$projectDir/src")) + config.setFrom(files( + "$rootDir/config/quality/detekt/default-config.yml", + "$rootDir/config/quality/detekt/ktlint-config.yml" + )) + baseline.set(file("$rootDir/config/quality/detekt/baseline.xml")) + autoCorrect.set(true) +} + +tasks.withType { + reports { + sarif.required.set(false) + html.required.set(true) + checkstyle.required.set(true) + markdown.required.set(false) + } +} + +dependencies { + detektPlugins(libs.detekt.cli) + detektPlugins(libs.detekt.ktlint) + detektPlugins(libs.detekt.libraries) +} \ No newline at end of file diff --git a/konsist/src/test/kotlin/com/worldline/devview/konsist/ComposeTest.kt b/konsist/src/test/kotlin/com/worldline/devview/konsist/ComposeTest.kt new file mode 100644 index 0000000..667f46b --- /dev/null +++ b/konsist/src/test/kotlin/com/worldline/devview/konsist/ComposeTest.kt @@ -0,0 +1,60 @@ +package com.worldline.devview.konsist + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.ext.list.withAnnotationOf +import com.lemonappdev.konsist.api.ext.list.withNameEndingWith +import com.lemonappdev.konsist.api.ext.list.withPackage +import com.lemonappdev.konsist.api.verify.assertFalse +import com.lemonappdev.konsist.api.verify.assertTrue +import io.kotest.core.spec.style.FunSpec + +/** + * Verifies Compose conventions across all DevView modules. + * + * Conventions: + * - Composables in `components` packages are `internal` + * - `@Preview` composable functions are `private` + * - `PreviewParameterProvider` subclasses are `internal` and live in a `preview` package + */ +class ComposeTest : FunSpec(body = { + test(name = "Composables in components packages are internal") { + Konsist + .scopeFromProject() + .functions() + .withAnnotationOf(androidx.compose.runtime.Composable::class) + .withPackage("..components..") + .assertFalse(additionalMessage = "Composables in a 'components' package must be 'internal'") { function -> + function.hasPublicOrDefaultModifier + } + } + + test(name = "Preview composables are private") { + Konsist + .scopeFromProject() + .functions() + .withAnnotationOf(androidx.compose.ui.tooling.preview.Preview::class) + .assertTrue(additionalMessage = "@Preview composables must be 'private'") { function -> + function.hasPrivateModifier + } + } + + test(name = "PreviewParameterProvider subclasses are internal") { + Konsist + .scopeFromProject() + .classes() + .withNameEndingWith("PreviewParameterProvider") + .assertTrue(additionalMessage = "PreviewParameterProvider subclasses must be 'internal'") { clazz -> + clazz.hasInternalModifier + } + } + + test(name = "PreviewParameterProvider subclasses reside in a preview package") { + Konsist + .scopeFromProject() + .classes() + .withNameEndingWith("PreviewParameterProvider") + .assertTrue(additionalMessage = "PreviewParameterProvider subclasses must be in a 'preview' package") { clazz -> + clazz.packagee?.name?.endsWith(".preview") ?: false + } + } +}) diff --git a/konsist/src/test/kotlin/com/worldline/devview/konsist/DevViewModules.kt b/konsist/src/test/kotlin/com/worldline/devview/konsist/DevViewModules.kt new file mode 100644 index 0000000..e39c369 --- /dev/null +++ b/konsist/src/test/kotlin/com/worldline/devview/konsist/DevViewModules.kt @@ -0,0 +1,45 @@ +package com.worldline.devview.konsist + +import com.lemonappdev.konsist.api.Konsist + +internal const val DEVVIEW_BASE_MODULE = "devview" +internal const val DEVVIEW_UTILS_MODULE = "devview-utils" +internal const val DEVVIEW_MODULE_PREFIX = "devview-" +internal const val CORE_IDENTIFIER = "core" +internal const val BASE_PACKAGE = "com.worldline.devview" +internal const val UTILS_PACKAGE = "com.worldline.devview.utils" + +/** Extracts `` from `devview-` or `devview--`. */ +internal fun featureNameOf(moduleName: String): String? { + if (!moduleName.startsWith(DEVVIEW_MODULE_PREFIX)) return null + val withoutPrefix = moduleName.removePrefix(DEVVIEW_MODULE_PREFIX) + if (withoutPrefix.isEmpty() || withoutPrefix == CORE_IDENTIFIER) return null + // Parts after the prefix, e.g. "networkmock-core" → ["networkmock", "core"] + return withoutPrefix.split("-").first() +} + +/** + * Returns all devview feature module names discovered from the project scope, + * excluding the base (`devview`) and utils (`devview-utils`) modules. + */ +internal fun devviewFeatureModuleNames(): List = + Konsist.scopeFromProject() + .files + .map { it.moduleName } + .distinct() + .filter { name -> + name.startsWith(DEVVIEW_MODULE_PREFIX) && + name != DEVVIEW_UTILS_MODULE + } + +/** + * Returns the expected package prefix for a given devview module name by converting + * the module name to a package name: `com.worldline.`. + * + * - `devview` → `com.worldline.devview` + * - `devview-utils` → `com.worldline.devview.utils` + * - `devview-` → `com.worldline.devview.` + * - `devview--` → `com.worldline.devview..` + */ +internal fun expectedPackagePrefixOf(moduleName: String): String = + "com.worldline.${moduleName.replace("-", ".")}" diff --git a/konsist/src/test/kotlin/com/worldline/devview/konsist/KotestUtils.kt b/konsist/src/test/kotlin/com/worldline/devview/konsist/KotestUtils.kt new file mode 100644 index 0000000..bcd4006 --- /dev/null +++ b/konsist/src/test/kotlin/com/worldline/devview/konsist/KotestUtils.kt @@ -0,0 +1,6 @@ +package com.worldline.devview.konsist + +import io.kotest.core.test.TestScope + +val TestScope.koTestName: String + get() = this.testCase.name.name diff --git a/konsist/src/test/kotlin/com/worldline/devview/konsist/ModuleDependencyTest.kt b/konsist/src/test/kotlin/com/worldline/devview/konsist/ModuleDependencyTest.kt new file mode 100644 index 0000000..d42608f --- /dev/null +++ b/konsist/src/test/kotlin/com/worldline/devview/konsist/ModuleDependencyTest.kt @@ -0,0 +1,112 @@ +package com.worldline.devview.konsist + +import com.lemonappdev.konsist.api.Konsist +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe + +/** + * Verifies inter-module dependency rules for the DevView module family. + * + * Module naming convention: + * - `devview` → core base module + * - `devview-utils` → independent utility module (no devview dependencies) + * - `devview-` → feature module, must depend on `devview` core + * - `devview--` → feature sub-module, must depend on `devview--core` + * + * Rules for feature families (modules sharing the same `` name): + * - There must be exactly ONE `-core` sub-module per feature family + * - `devview-` must depend on `devview--core` + * - `devview--` must depend on `devview--core` + */ +class ModuleDependencyTest : FunSpec(body = { + test(name = "DevView feature modules depend on DevView core module") { + val featureModules = devviewFeatureModuleNames() + .filter { !it.removePrefix(DEVVIEW_MODULE_PREFIX).contains("-") } + + featureModules.forEach { moduleName -> + val hasImportFromCore = Konsist.scopeFromModule(moduleName) + .files + .any { file -> + file.imports.any { import -> + import.name.startsWith(BASE_PACKAGE) && + !import.name.startsWith(UTILS_PACKAGE) + } + } + withClue("'$moduleName' has no file importing from the core '$BASE_PACKAGE' package") { + hasImportFromCore.shouldBeTrue() + } + } + } + + test(name = "Each DevView feature family has exactly one -core sub-module") { + val allModules = devviewFeatureModuleNames() + val subModules = allModules.filter { it.removePrefix(DEVVIEW_MODULE_PREFIX).contains("-") } + + subModules + .mapNotNull { featureNameOf(it) } + .distinct() + .forEach { featureName -> + val coreModule = "$DEVVIEW_MODULE_PREFIX$featureName-$CORE_IDENTIFIER" + allModules shouldContain coreModule + subModules.count { it == coreModule } shouldBe 1 + } + } + + test(name = "DevView feature modules depend on their -core sub-module") { + val allModules = devviewFeatureModuleNames() + val featureModules = allModules.filter { !it.removePrefix(DEVVIEW_MODULE_PREFIX).contains("-") } + + featureModules.forEach { moduleName -> + val featureName = featureNameOf(moduleName) ?: return@forEach + val coreModule = "$DEVVIEW_MODULE_PREFIX$featureName-$CORE_IDENTIFIER" + if (coreModule !in allModules) return@forEach + + val corePackage = "$BASE_PACKAGE.$featureName" + val hasImportFromCoreModule = Konsist.scopeFromModule(moduleName) + .files + .any { file -> file.imports.any { import -> import.name.startsWith(corePackage) } } + withClue("'$moduleName' has no file importing from core sub-module package '$corePackage'") { + hasImportFromCoreModule.shouldBeTrue() + } + } + } + + test(name = "DevView feature sub-modules depend on their -core sub-module") { + val allModules = devviewFeatureModuleNames() + val subModules = allModules.filter { it.removePrefix(DEVVIEW_MODULE_PREFIX).contains("-") } + + subModules + .filter { !it.endsWith("-$CORE_IDENTIFIER") } + .forEach { moduleName -> + val featureName = featureNameOf(moduleName) ?: return@forEach + val coreModule = "$DEVVIEW_MODULE_PREFIX$featureName-$CORE_IDENTIFIER" + if (coreModule !in allModules) return@forEach + + val corePackage = "$BASE_PACKAGE.$featureName" + val hasImportFromCoreModule = Konsist.scopeFromModule(moduleName) + .files + .any { file -> file.imports.any { import -> import.name.startsWith(corePackage) } } + withClue("'$moduleName' has no file importing from core sub-module package '$corePackage'") { + hasImportFromCoreModule.shouldBeTrue() + } + } + } + + test(name = "DevView utils module does not depend on any DevView module") { + val hasImportFromDevView = Konsist.scopeFromModule(DEVVIEW_UTILS_MODULE) + .files + .any { file -> + file.imports.any { import -> + import.name.startsWith(BASE_PACKAGE) && + !import.name.startsWith(UTILS_PACKAGE) + } + } + withClue("'$DEVVIEW_UTILS_MODULE' must not import from '$BASE_PACKAGE'") { + hasImportFromDevView.shouldBeFalse() + } + } +}) diff --git a/konsist/src/test/kotlin/com/worldline/devview/konsist/PackageNamingTest.kt b/konsist/src/test/kotlin/com/worldline/devview/konsist/PackageNamingTest.kt new file mode 100644 index 0000000..35d7a53 --- /dev/null +++ b/konsist/src/test/kotlin/com/worldline/devview/konsist/PackageNamingTest.kt @@ -0,0 +1,48 @@ +package com.worldline.devview.konsist + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.verify.assertTrue +import io.kotest.core.spec.style.FunSpec + +/** + * Verifies package naming conventions across all DevView modules. + */ +class PackageNamingTest : FunSpec(body = { + test(name = "DevView module files use the correct package prefix") { + Konsist.scopeFromProject() + .files + .map { it.moduleName } + .distinct() + .filter { it.startsWith(DEVVIEW_BASE_MODULE) } + .forEach { moduleName -> + val expectedPrefix = expectedPackagePrefixOf(moduleName) + Konsist.scopeFromModule(moduleName) + .files + .assertTrue(additionalMessage = "Files in '$moduleName' must use package prefix '$expectedPrefix'") { file -> + file.packagee?.name?.startsWith(expectedPrefix) ?: true + } + } + } + + test(name = "Package name does not contain upper case characters") { + Konsist + .scopeFromProject() + .files + .assertTrue { file -> + file.packagee?.name?.let { packageName -> + !packageName.any { it.isUpperCase() } + } ?: true + } + } + + test(name = "Package name does not contain underscores") { + Konsist + .scopeFromProject() + .files + .assertTrue { file -> + file.packagee?.name?.let { packageName -> + !packageName.any { it == '_' } + } ?: true + } + } +}) diff --git a/konsist/src/test/kotlin/com/worldline/devview/konsist/ViewModelTest.kt b/konsist/src/test/kotlin/com/worldline/devview/konsist/ViewModelTest.kt new file mode 100644 index 0000000..183fdb8 --- /dev/null +++ b/konsist/src/test/kotlin/com/worldline/devview/konsist/ViewModelTest.kt @@ -0,0 +1,35 @@ +package com.worldline.devview.konsist + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.ext.list.withNameEndingWith +import com.lemonappdev.konsist.api.verify.assertTrue +import io.kotest.core.spec.style.FunSpec + +/** + * Verifies ViewModel conventions across all DevView modules. + * + * Conventions: + * - `ViewModel` subclasses live in a `viewmodel` package + * - `ViewModel` subclasses are `public` + */ +class ViewModelTest : FunSpec(body = { + test(name = "ViewModel subclasses reside in a viewmodel package") { + Konsist + .scopeFromProject() + .classes() + .withNameEndingWith("ViewModel") + .assertTrue(additionalMessage = "ViewModel subclasses must be in a 'viewmodel' package") { clazz -> + clazz.packagee?.name?.endsWith(".viewmodel") ?: false + } + } + + test(name = "ViewModel subclasses are public") { + Konsist + .scopeFromProject() + .classes() + .withNameEndingWith("ViewModel") + .assertTrue(additionalMessage = "ViewModel subclasses must be 'public'") { clazz -> + clazz.hasPublicOrDefaultModifier + } + } +}) diff --git a/sample/network/build.gradle.kts b/sample/network/build.gradle.kts index 0ac6628..dcb7632 100644 --- a/sample/network/build.gradle.kts +++ b/sample/network/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "com.worldline.devview.sample.network" androidResources { diff --git a/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/BaseHttpClientConfig.kt b/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/BaseHttpClientConfig.kt index a34a307..b9e5277 100644 --- a/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/BaseHttpClientConfig.kt +++ b/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/BaseHttpClientConfig.kt @@ -1,6 +1,6 @@ package com.worldline.devview.sample.network -import com.worldline.devview.networkmock.plugin.NetworkMockPlugin +import com.worldline.devview.networkmock.ktor.plugin.NetworkMockPlugin import io.ktor.client.HttpClientConfig import io.ktor.client.engine.HttpClientEngineConfig diff --git a/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/HttpClientWithMocking.kt b/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/HttpClientWithMocking.kt index d4e0354..42f76fb 100644 --- a/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/HttpClientWithMocking.kt +++ b/sample/network/src/commonMain/kotlin/com/worldline/devview/sample/network/HttpClientWithMocking.kt @@ -7,7 +7,7 @@ import io.ktor.client.HttpClient * * Repositories are resolved automatically from * [com.worldline.devview.networkmock.NetworkMockInitializer] via - * [com.worldline.devview.networkmock.plugin.NetworkMockPlugin]'s default + * [com.worldline.devview.networkmock.ktor.plugin.NetworkMockPlugin]'s default * configuration — no manual wiring required. * * @return Configured HttpClient with Network Mock plugin installed diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 82a33f0..447d9a4 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "com.worldline.devview.sample.shared" } diff --git a/scripts/build_docs.sh b/scripts/build_docs.sh new file mode 100644 index 0000000..d75d08e --- /dev/null +++ b/scripts/build_docs.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +./gradlew \ + :internal:dokka:dokkaGenerate + +rm -rf docs/api/ +cp -r internal/dokka/build/dokka/html/ docs/api/ + +cp CHANGELOG.md docs/changelog.md + +mkdocs $@ \ No newline at end of file diff --git a/scripts/delete_old_version_docs.sh b/scripts/delete_old_version_docs.sh new file mode 100644 index 0000000..68cb1b3 --- /dev/null +++ b/scripts/delete_old_version_docs.sh @@ -0,0 +1,31 @@ +#!/bin/zsh + +versions=($(mike list | tr ' ' '\n')) + +declare -A major_latest + +# Find latest per X.Y +for v in "${versions[@]}"; do + major="${v%.*}" + if [[ -z "${major_latest[$major]}" ]]; then + major_latest[$major]="$v" + else + latest="${major_latest[$major]}" + # Use version sort (-V) to compare + greater=$(printf "%s\n%s\n" "$latest" "$v" | sort -V | tail -n1) + major_latest[$major]="$greater" + fi +done + +to_delete=() +for v in "${versions[@]}"; do + major="${v%.*}" + if [[ "$v" != "${major_latest[$major]}" ]]; then + to_delete+=("$v") + fi +done + +for v in "${to_delete[@]}"; do + echo "Deleting $v" + mike delete "$v" --push +done \ No newline at end of file diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..6e33192 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -exo pipefail + +# Gets a property out of a .properties file +# usage: getProperty $key $filename +function getProperty() { + grep "${1}" "$2" | cut -d'=' -f2 +} + +NEW_VERSION=$1 +NEW_SNAPSHOT_VERSION=$2 +CUR_SNAPSHOT_VERSION=$(getProperty 'VERSION_NAME' gradle.properties) + +if [ -z "$NEW_VERSION" ]; then + echo "Usage: ./release.sh []" + echo "Example: ./release.sh 1.0.0 1.1.0-SNAPSHOT" + exit 1 +fi + +if [ -z "$NEW_SNAPSHOT_VERSION" ]; then + # If no snapshot version was provided, keep the current value + NEW_SNAPSHOT_VERSION=$CUR_SNAPSHOT_VERSION +fi + +echo "Preparing release $NEW_VERSION" + +# Bump to release version and commit +sed -i.bak "s/${CUR_SNAPSHOT_VERSION}/${NEW_VERSION}/g" gradle.properties +git add gradle.properties +git commit -m "Prepare for release $NEW_VERSION" + +# Tag the release — this is what triggers the publish.yml CI workflow +git tag "v${NEW_VERSION}" + +# Bump back to next snapshot version and commit +echo "Setting next snapshot version $NEW_SNAPSHOT_VERSION" +sed -i.bak "s/${NEW_VERSION}/${NEW_SNAPSHOT_VERSION}/g" gradle.properties +git add gradle.properties +git commit -m "Prepare next development version" + +# Remove the backup file from sed edits +rm gradle.properties.bak + +# Push commits and the release tag — CI takes it from here +git push && git push --tags \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index dc5d243..ea6504c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,6 +44,7 @@ include( ":devview-networkmock-ktor", ":devview-utils", ":internal:dokka", + "konsist", ":sample:androidApp", ":sample:network", ":sample:shared"