From 75d80084b611d71a7ebd10d4ab6a6285f88c366d Mon Sep 17 00:00:00 2001 From: fegger Date: Tue, 19 May 2026 17:00:28 +0200 Subject: [PATCH] Add Android unit and UI tests with test dependencies --- README.md | 34 +++- android/README.md | 30 +++ android/app/build.gradle.kts | 10 + .../bikeloam/app/ExampleInstrumentedTest.kt | 17 ++ .../com/bikeloam/app/MainDispatcherRule.kt | 24 +++ .../app/data/network/NetworkClientTest.kt | 25 +++ .../app/data/repository/BikeRepositoryTest.kt | 174 ++++++++++++++++++ .../bikeloam/app/ui/screen/HomeScreenTest.kt | 74 ++++++++ .../bikeloam/app/ui/screen/LoginScreenTest.kt | 67 +++++++ .../ui/viewmodel/BuildDetailViewModelTest.kt | 79 ++++++++ .../ui/viewmodel/BuildListViewModelTest.kt | 96 ++++++++++ .../ui/viewmodel/CreateBuildViewModelTest.kt | 142 ++++++++++++++ .../app/ui/viewmodel/HomeViewModelTest.kt | 74 ++++++++ .../app/ui/viewmodel/LoginViewModelTest.kt | 106 +++++++++++ .../viewmodel/RecommendationViewModelTest.kt | 80 ++++++++ .../app/ui/viewmodel/RegisterViewModelTest.kt | 108 +++++++++++ .../app/ui/viewmodel/ReviewViewModelTest.kt | 130 +++++++++++++ .../app/ui/viewmodel/SplashViewModelTest.kt | 59 ++++++ next-env.d.ts | 2 +- 19 files changed, 1325 insertions(+), 6 deletions(-) create mode 100644 android/app/src/androidTest/java/com/bikeloam/app/ExampleInstrumentedTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/MainDispatcherRule.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/data/network/NetworkClientTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/data/repository/BikeRepositoryTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/screen/HomeScreenTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/screen/LoginScreenTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildDetailViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildListViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/CreateBuildViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/HomeViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/LoginViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RecommendationViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RegisterViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/ReviewViewModelTest.kt create mode 100644 android/app/src/test/java/com/bikeloam/app/ui/viewmodel/SplashViewModelTest.kt diff --git a/README.md b/README.md index 00e6bb0..0d2e294 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Loam — MTB Build & Tune -A Next.js web app for mountain bike riders to configure builds, get manufacturer-verified suspension and tire setup starting points, save post-ride reviews, and receive AI-powered tuning recommendations based on personal preference history. +A Next.js web app and native Android client for mountain bike riders to configure builds, get manufacturer-verified suspension and tire setup starting points, save post-ride reviews, and receive AI-powered tuning recommendations based on personal preference history. -Built with Next.js App Router, Prisma/PostgreSQL, and Ollama for local LLM inference. The API layer is designed for reuse in a future React Native/Android client. +The backend is built with Next.js App Router, Prisma/PostgreSQL, and Ollama for local LLM inference. The Android client is built with Kotlin, Jetpack Compose, Ktor, and Koin against the same JSON API. --- @@ -18,6 +18,7 @@ Built with Next.js App Router, Prisma/PostgreSQL, and Ollama for local LLM infer | Images | sharp (layer compositing) | | AI | Ollama (local LLM) | | Validation | Zod | +| Android | Kotlin, Jetpack Compose, Ktor, Koin | --- @@ -116,6 +117,12 @@ src/ types.ts # shared TypeScript types public/ images/ # product assets + user-generated builds +android/ + app/src/main/java/com/bikeloam/app/ + data/ # DTOs, Ktor API client, repository + navigation/ # Compose navigation graph + ui/ # Compose screens, theme, ViewModels + app/src/test/ # JVM unit tests for repository + ViewModels ``` --- @@ -169,15 +176,23 @@ The `/api/setup-audit` endpoint returns a full breakdown of confidence levels an --- -## Notes on Android reuse +## Android client -The API surface is plain JSON over HTTP with cookie-based auth. A React Native or Android client can reuse the same endpoints by: +The native Android app lives in [`android/`](android/README.md). It uses the same plain JSON API over HTTP with cookie-based auth: 1. Implementing cookie jar storage for the `loam-session` cookie 2. Calling `/api/auth/login` or `/api/auth/register` to obtain a session 3. Reusing `/api/builds`, `/api/reviews`, `/api/ai/recommend`, and `/api/catalog` as-is -The image composition endpoint (`/api/images/compose`) returns a PNG path; clients can either request server-side composition or replicate the layer-stacking logic locally. +For local emulator development, the debug build points at `http://10.0.2.2:3000`. Start the web backend with `npm run dev`, then build or test the app from the Android project: + +```bash +cd android +./gradlew assembleDebug +./gradlew testDebugUnitTest +``` + +The image composition endpoint (`/api/images/compose`) returns a PNG path; mobile clients can either request server-side composition or replicate the layer-stacking logic locally. --- @@ -192,6 +207,15 @@ The image composition endpoint (`/api/images/compose`) returns a PNG path; clien | `npm run db:studio` | Open Prisma Studio | | `npm run lint` | Run ESLint | +Android commands are run from `android/`: + +| Command | Purpose | +|---|---| +| `./gradlew assembleDebug` | Build the debug APK | +| `./gradlew installDebug` | Install debug build on a connected device or emulator | +| `./gradlew testDebugUnitTest` | Run JVM unit tests | +| `./gradlew connectedDebugAndroidTest` | Run instrumented Android tests | + --- ## Environment variables diff --git a/android/README.md b/android/README.md index 2b9efc3..939635b 100644 --- a/android/README.md +++ b/android/README.md @@ -10,6 +10,7 @@ Android client for the BikeSetup backend. Built with Kotlin, Jetpack Compose, an - **DI**: Koin - **Networking**: Ktor client (OkHttp engine) with automatic cookie jar for session auth - **Serialization**: Kotlinx Serialization +- **Testing**: JUnit, MockK, coroutine test utilities, Robolectric, Compose UI test APIs ## Project Structure @@ -58,6 +59,11 @@ com.bikeloam.app/ ./gradlew installDebug ``` +6. Run JVM unit tests: + ```bash + ./gradlew testDebugUnitTest + ``` + ## API Configuration The debug build points to `http://10.0.2.2:3000` (emulator localhost). For a physical device, update `BASE_URL` in `app/build.gradle.kts` to your machine's LAN IP (e.g., `http://192.168.1.x:3000`). @@ -75,6 +81,30 @@ buildConfigField("String", "BASE_URL", "\"http://YOUR_IP:3000\"") - **Ride Reviews**: Submit post-ride reviews with 1–10 sliders and issue checkboxes. - **AI Recommendations**: Fetches setup recommendations from `/api/ai/recommend`. +## Tests + +Unit tests live under `app/src/test/java/com/bikeloam/app/` and cover the repository, all ViewModels, network layer, and Compose screens: + +- `BikeRepositoryTest` +- `NetworkClientTest` +- `SplashViewModelTest` +- `LoginViewModelTest` +- `RegisterViewModelTest` +- `HomeViewModelTest` +- `BuildListViewModelTest` +- `CreateBuildViewModelTest` +- `BuildDetailViewModelTest` +- `ReviewViewModelTest` +- `RecommendationViewModelTest` +- `LoginScreenTest` (Compose UI – Robolectric) +- `HomeScreenTest` (Compose UI – Robolectric) + +Use `MainDispatcherRule` for coroutine dispatcher replacement in ViewModel tests. Run the full JVM suite with: + +```bash +./gradlew testDebugUnitTest +``` + ## Architecture Notes - **Session cookies** are handled automatically by Ktor's `HttpCookies` plugin. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 75cedac..eedd9e7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -96,6 +96,16 @@ dependencies { // Testing testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.12") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation(platform("androidx.compose:compose-bom:2024.12.01")) + testImplementation("androidx.compose.ui:ui-test-junit4") + testImplementation("androidx.compose.ui:ui-test-manifest") + testImplementation("io.insert-koin:koin-test:3.5.6") + testImplementation("io.insert-koin:koin-test-junit4:3.5.6") + androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01")) diff --git a/android/app/src/androidTest/java/com/bikeloam/app/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/com/bikeloam/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..399f8c7 --- /dev/null +++ b/android/app/src/androidTest/java/com/bikeloam/app/ExampleInstrumentedTest.kt @@ -0,0 +1,17 @@ +package com.bikeloam.app + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.bikeloam.app", appContext.packageName) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/MainDispatcherRule.kt b/android/app/src/test/java/com/bikeloam/app/MainDispatcherRule.kt new file mode 100644 index 0000000..81b7ef4 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/MainDispatcherRule.kt @@ -0,0 +1,24 @@ +package com.bikeloam.app + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description?) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + Dispatchers.resetMain() + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/data/network/NetworkClientTest.kt b/android/app/src/test/java/com/bikeloam/app/data/network/NetworkClientTest.kt new file mode 100644 index 0000000..a3bba93 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/data/network/NetworkClientTest.kt @@ -0,0 +1,25 @@ +package com.bikeloam.app.data.network + +import io.ktor.client.request.* +import io.ktor.http.* +import org.junit.Assert.assertNotNull +import org.junit.Test + +class NetworkClientTest { + + @Test + fun `create returns configured client`() { + val client = NetworkClient.create("http://10.0.2.2:3000") + + assertNotNull(client) + client.close() + } + + @Test + fun `create returns client with default base url`() { + val client = NetworkClient.create("https://api.example.com") + + assertNotNull(client) + client.close() + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/data/repository/BikeRepositoryTest.kt b/android/app/src/test/java/com/bikeloam/app/data/repository/BikeRepositoryTest.kt new file mode 100644 index 0000000..0b50045 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/data/repository/BikeRepositoryTest.kt @@ -0,0 +1,174 @@ +package com.bikeloam.app.data.repository + +import com.bikeloam.app.data.model.* +import com.bikeloam.app.data.network.BikeApiClient +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BikeRepositoryTest { + + private val apiClient: BikeApiClient = mockk() + private val repository = BikeRepository(apiClient) + + @Test + fun `login returns user on success`() = runTest { + val user = User("1", "test@example.com", "Test User") + coEvery { apiClient.login("test@example.com", "password123") } returns AuthResponse(user) + + val result = repository.login("test@example.com", "password123") + + assertTrue(result.isSuccess) + assertEquals(user, result.getOrNull()) + coVerify(exactly = 1) { apiClient.login("test@example.com", "password123") } + } + + @Test + fun `login returns failure on exception`() = runTest { + coEvery { apiClient.login(any(), any()) } throws RuntimeException("Network error") + + val result = repository.login("test@example.com", "password123") + + assertTrue(result.isFailure) + assertEquals("Network error", result.exceptionOrNull()?.message) + } + + @Test + fun `register returns user on success`() = runTest { + val user = User("1", "new@example.com", "New User") + coEvery { apiClient.register("new@example.com", "password123", "New User") } returns AuthResponse(user) + + val result = repository.register("new@example.com", "password123", "New User") + + assertTrue(result.isSuccess) + assertEquals(user, result.getOrNull()) + } + + @Test + fun `register returns failure when email already exists`() = runTest { + coEvery { apiClient.register(any(), any(), any()) } throws RuntimeException("Email already registered") + + val result = repository.register("dup@example.com", "password123", null) + + assertTrue(result.isFailure) + assertEquals("Email already registered", result.exceptionOrNull()?.message) + } + + @Test + fun `getCurrentUser returns user when authenticated`() = runTest { + val user = User("1", "test@example.com", "Test User") + coEvery { apiClient.getCurrentUser() } returns AuthResponse(user) + + val result = repository.getCurrentUser() + + assertTrue(result.isSuccess) + assertEquals(user, result.getOrNull()) + } + + @Test + fun `getCurrentUser returns null when not authenticated`() = runTest { + coEvery { apiClient.getCurrentUser() } returns AuthResponse(null) + + val result = repository.getCurrentUser() + + assertTrue(result.isSuccess) + assertEquals(null, result.getOrNull()) + } + + @Test + fun `logout returns success`() = runTest { + coEvery { apiClient.logout() } returns Unit + + val result = repository.logout() + + assertTrue(result.isSuccess) + coVerify(exactly = 1) { apiClient.logout() } + } + + @Test + fun `fetchCatalog returns catalog on success`() = runTest { + val catalog = CatalogResponse( + frames = listOf(Product("f1", "frame", "Brand", "Model")), + forks = emptyList(), + shocks = emptyList(), + tires = emptyList() + ) + coEvery { apiClient.getCatalog() } returns catalog + + val result = repository.fetchCatalog() + + assertTrue(result.isSuccess) + assertEquals(1, result.getOrNull()?.frames?.size) + } + + @Test + fun `fetchBuilds returns list on success`() = runTest { + val builds = listOf( + BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + ) + coEvery { apiClient.getBuilds() } returns builds + + val result = repository.fetchBuilds() + + assertTrue(result.isSuccess) + assertEquals(1, result.getOrNull()?.size) + assertEquals("Trail Beast", result.getOrNull()?.first()?.name) + } + + @Test + fun `createBuild returns build on success`() = runTest { + val rider = RiderProfile(75, "trail", "intermediate", "aggressive") + val request = CreateBuildRequest("New Build", rider) + val build = BikeBuild( + id = "b1", name = "New Build", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + coEvery { apiClient.createBuild(request) } returns build + + val result = repository.createBuild(request) + + assertTrue(result.isSuccess) + assertEquals("New Build", result.getOrNull()?.name) + } + + @Test + fun `createReview returns review on success`() = runTest { + val request = CreateReviewRequest( + buildId = "b1", suspFeel = 7, frontGrip = 8, rearGrip = 6, + rollSpeed = 7, confidence = 9 + ) + val review = RideReview( + id = "r1", buildId = "b1", suspFeel = 7, frontGrip = 8, + rearGrip = 6, rollSpeed = 7, confidence = 9 + ) + coEvery { apiClient.createReview(request) } returns review + + val result = repository.createReview(request) + + assertTrue(result.isSuccess) + assertEquals(7, result.getOrNull()?.suspFeel) + } + + @Test + fun `getRecommendation returns response on success`() = runTest { + val rider = RiderProfile(75, "trail", "intermediate", "aggressive") + val selection = BuildSelection("Trail Beast") + val request = RecommendRequest(rider, selection) + val response = RecommendResponse("llama3.1:8b", "Fork: 85 PSI, Shock: 200 PSI") + coEvery { apiClient.getRecommendation(request) } returns response + + val result = repository.getRecommendation(request) + + assertTrue(result.isSuccess) + assertEquals("llama3.1:8b", result.getOrNull()?.model) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/screen/HomeScreenTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/screen/HomeScreenTest.kt new file mode 100644 index 0000000..9cc0806 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/screen/HomeScreenTest.kt @@ -0,0 +1,74 @@ +package com.bikeloam.app.ui.screen + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.bikeloam.app.data.model.User +import com.bikeloam.app.ui.theme.BikeSetupTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HomeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `home screen shows welcome with user name`() { + composeTestRule.setContent { + BikeSetupTheme { + HomeScreen( + onNavigateToBuildList = {}, + onNavigateToCreateBuild = {}, + onNavigateToSettings = {}, + onLogout = {} + ) + } + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("BikeSetup", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("My Builds").assertIsDisplayed() + composeTestRule.onNodeWithText("New Build").assertIsDisplayed() + } + + @Test + fun `clicking my builds navigates to build list`() { + var navigated = false + composeTestRule.setContent { + BikeSetupTheme { + HomeScreen( + onNavigateToBuildList = { navigated = true }, + onNavigateToCreateBuild = {}, + onNavigateToSettings = {}, + onLogout = {} + ) + } + } + + composeTestRule.onNodeWithText("My Builds").performClick() + assert(navigated) + } + + @Test + fun `clicking new build navigates to create build`() { + var navigated = false + composeTestRule.setContent { + BikeSetupTheme { + HomeScreen( + onNavigateToBuildList = {}, + onNavigateToCreateBuild = { navigated = true }, + onNavigateToSettings = {}, + onLogout = {} + ) + } + } + + composeTestRule.onNodeWithText("New Build").performClick() + assert(navigated) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/screen/LoginScreenTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/screen/LoginScreenTest.kt new file mode 100644 index 0000000..feca845 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/screen/LoginScreenTest.kt @@ -0,0 +1,67 @@ +package com.bikeloam.app.ui.screen + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.bikeloam.app.ui.theme.BikeSetupTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LoginScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `login screen shows title and fields`() { + composeTestRule.setContent { + BikeSetupTheme { + LoginScreen( + onLoginSuccess = {}, + onNavigateToRegister = {} + ) + } + } + + composeTestRule.onNodeWithText("BikeSetup").assertIsDisplayed() + composeTestRule.onNodeWithText("Sign in to your account").assertIsDisplayed() + composeTestRule.onNodeWithText("Email").assertIsDisplayed() + composeTestRule.onNodeWithText("Password").assertIsDisplayed() + composeTestRule.onNodeWithText("Sign In").assertIsDisplayed() + } + + @Test + fun `register button navigates to register`() { + var navigated = false + composeTestRule.setContent { + BikeSetupTheme { + LoginScreen( + onLoginSuccess = {}, + onNavigateToRegister = { navigated = true } + ) + } + } + + composeTestRule.onNodeWithText("Don't have an account? Register").performClick() + assert(navigated) + } + + @Test + fun `empty fields show error on login attempt`() { + composeTestRule.setContent { + BikeSetupTheme { + LoginScreen( + onLoginSuccess = {}, + onNavigateToRegister = {} + ) + } + } + + composeTestRule.onNodeWithText("Sign In").performClick() + composeTestRule.onNodeWithText("Email and password are required").assertIsDisplayed() + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildDetailViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildDetailViewModelTest.kt new file mode 100644 index 0000000..32038d0 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildDetailViewModelTest.kt @@ -0,0 +1,79 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.* +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class BuildDetailViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: BuildDetailViewModel + + @Test + fun `initial state is empty`() { + viewModel = BuildDetailViewModel(repository) + + assertNull(viewModel.uiState.build) + assertFalse(viewModel.uiState.isLoading) + assertNull(viewModel.uiState.error) + } + + @Test + fun `loadBuild finds build and resolves names`() = runTest { + val build = BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + frameId = "f1", forkId = "fk1", userId = "u1", createdAt = "", updatedAt = "" + ) + val catalog = CatalogResponse( + frames = listOf(Product("f1", "frame", "Santa Cruz", "Nomad")), + forks = listOf(Product("fk1", "fork", "RockShox", "Lyrik")), + shocks = emptyList(), + tires = emptyList() + ) + coEvery { repository.fetchBuilds() } returns Result.success(listOf(build)) + coEvery { repository.fetchCatalog() } returns Result.success(catalog) + + viewModel = BuildDetailViewModel(repository) + viewModel.loadBuild("b1") + + assertEquals("Trail Beast", viewModel.uiState.build?.name) + assertEquals("Santa Cruz Nomad", viewModel.resolveProductName("f1")) + assertEquals("RockShox Lyrik", viewModel.resolveProductName("fk1")) + assertNull(viewModel.resolveProductName("unknown")) + } + + @Test + fun `loadBuild shows error when build not found`() = runTest { + coEvery { repository.fetchBuilds() } returns Result.success(emptyList()) + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + + viewModel = BuildDetailViewModel(repository) + viewModel.loadBuild("missing") + + assertNull(viewModel.uiState.build) + assertEquals("Build not found", viewModel.uiState.error) + } + + @Test + fun `loadBuild handles network error`() = runTest { + coEvery { repository.fetchBuilds() } returns Result.failure(RuntimeException("Network down")) + + viewModel = BuildDetailViewModel(repository) + viewModel.loadBuild("b1") + + assertNull(viewModel.uiState.build) + assertEquals("Network down", viewModel.uiState.error) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildListViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildListViewModelTest.kt new file mode 100644 index 0000000..3ea1816 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/BuildListViewModelTest.kt @@ -0,0 +1,96 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.BikeBuild +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class BuildListViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: BuildListViewModel + + @Test + fun `initial state is loading`() { + coEvery { repository.fetchBuilds() } returns Result.success(emptyList()) + viewModel = BuildListViewModel(repository) + + assertTrue(viewModel.uiState.builds.isEmpty()) + assertNull(viewModel.uiState.error) + } + + @Test + fun `loadBuilds populates list on success`() = runTest { + val builds = listOf( + BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ), + BikeBuild( + id = "b2", name = "XC Racer", riderWeightKg = 65, + discipline = "xc", riderLevel = "advanced", rideStyle = "smooth", + userId = "u1", createdAt = "", updatedAt = "" + ) + ) + coEvery { repository.fetchBuilds() } returns Result.success(builds) + + viewModel = BuildListViewModel(repository) + + assertEquals(2, viewModel.uiState.builds.size) + assertEquals("Trail Beast", viewModel.uiState.builds[0].name) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `loadBuilds shows error on failure`() = runTest { + coEvery { repository.fetchBuilds() } returns Result.failure(RuntimeException("Network error")) + + viewModel = BuildListViewModel(repository) + + assertTrue(viewModel.uiState.builds.isEmpty()) + assertEquals("Network error", viewModel.uiState.error) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `refresh reloads builds`() = runTest { + val initialBuilds = listOf( + BikeBuild( + id = "b1", name = "First", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + ) + val updatedBuilds = listOf( + BikeBuild( + id = "b1", name = "First", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ), + BikeBuild( + id = "b2", name = "Second", riderWeightKg = 65, + discipline = "xc", riderLevel = "advanced", rideStyle = "smooth", + userId = "u1", createdAt = "", updatedAt = "" + ) + ) + coEvery { repository.fetchBuilds() } returnsMany listOf( + Result.success(initialBuilds), + Result.success(updatedBuilds) + ) + + viewModel = BuildListViewModel(repository) + assertEquals(1, viewModel.uiState.builds.size) + + viewModel.loadBuilds() + assertEquals(2, viewModel.uiState.builds.size) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/CreateBuildViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/CreateBuildViewModelTest.kt new file mode 100644 index 0000000..ae6c4d6 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/CreateBuildViewModelTest.kt @@ -0,0 +1,142 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.* +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class CreateBuildViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: CreateBuildViewModel + + @Test + fun `initial state is empty`() { + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + viewModel = CreateBuildViewModel(repository) + + assertEquals("", viewModel.uiState.buildName) + assertEquals("", viewModel.uiState.riderWeight) + assertEquals("", viewModel.uiState.discipline) + assertEquals("", viewModel.uiState.level) + assertEquals("", viewModel.uiState.style) + assertNull(viewModel.uiState.error) + } + + @Test + fun `catalog loads on init`() = runTest { + val catalog = CatalogResponse( + frames = listOf(Product("f1", "frame", "Santa Cruz", "Nomad")), + forks = listOf(Product("fk1", "fork", "RockShox", "Lyrik")), + shocks = listOf(Product("s1", "shock", "Fox", "Float X")), + tires = listOf(Product("t1", "tire", "Maxxis", "Minion DHF", position = "front")) + ) + coEvery { repository.fetchCatalog() } returns Result.success(catalog) + + viewModel = CreateBuildViewModel(repository) + + assertEquals(1, viewModel.uiState.frames.size) + assertEquals(1, viewModel.uiState.forks.size) + assertEquals(1, viewModel.uiState.shocks.size) + assertEquals(1, viewModel.uiState.tires.size) + } + + @Test + fun `createBuild with blank name shows error`() = runTest { + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + viewModel = CreateBuildViewModel(repository) + viewModel.updateBuildName("") + viewModel.updateRiderWeight("75") + viewModel.updateDiscipline("trail") + viewModel.updateLevel("intermediate") + viewModel.updateStyle("aggressive") + + var called = false + viewModel.createBuild { called = true } + + assertFalse(called) + assertEquals("Build name is required", viewModel.uiState.error) + } + + @Test + fun `createBuild with invalid weight shows error`() = runTest { + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + viewModel = CreateBuildViewModel(repository) + viewModel.updateBuildName("Test") + viewModel.updateRiderWeight("20") + viewModel.updateDiscipline("trail") + viewModel.updateLevel("intermediate") + viewModel.updateStyle("aggressive") + + var called = false + viewModel.createBuild { called = true } + + assertFalse(called) + assertEquals("Valid rider weight (35-160 kg) is required", viewModel.uiState.error) + } + + @Test + fun `createBuild success navigates back`() = runTest { + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + val rider = RiderProfile(75, "trail", "intermediate", "aggressive") + val request = CreateBuildRequest("Trail Beast", rider) + val build = BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + coEvery { repository.createBuild(any()) } returns Result.success(build) + + viewModel = CreateBuildViewModel(repository) + viewModel.updateBuildName("Trail Beast") + viewModel.updateRiderWeight("75") + viewModel.updateDiscipline("trail") + viewModel.updateLevel("intermediate") + viewModel.updateStyle("aggressive") + + var called = false + viewModel.createBuild { called = true } + + assertTrue(called) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `discipline presets are correct`() { + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + viewModel = CreateBuildViewModel(repository) + + assertEquals(listOf("xc", "trail", "enduro"), viewModel.disciplines) + assertEquals(listOf("beginner", "intermediate", "advanced", "expert"), viewModel.levels) + assertEquals(listOf("smooth", "neutral", "aggressive"), viewModel.styles) + } + + @Test + fun `weight input filters non-digits`() { + coEvery { repository.fetchCatalog() } returns Result.success( + CatalogResponse(emptyList(), emptyList(), emptyList(), emptyList()) + ) + viewModel = CreateBuildViewModel(repository) + viewModel.updateRiderWeight("75abc") + + assertEquals("75", viewModel.uiState.riderWeight) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/HomeViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/HomeViewModelTest.kt new file mode 100644 index 0000000..4eabc84 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/HomeViewModelTest.kt @@ -0,0 +1,74 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.User +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class HomeViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: HomeViewModel + + @Test + fun `initial state is loading`() { + viewModel = HomeViewModel(repository) + + assertTrue(viewModel.uiState.isLoading) + assertNull(viewModel.uiState.user) + } + + @Test + fun `loadUser sets user on success`() = runTest { + val user = User("1", "test@example.com", "Test User") + coEvery { repository.getCurrentUser() } returns Result.success(user) + + viewModel = HomeViewModel(repository) + + assertFalse(viewModel.uiState.isLoading) + assertEquals(user, viewModel.uiState.user) + } + + @Test + fun `loadUser handles null user`() = runTest { + coEvery { repository.getCurrentUser() } returns Result.success(null) + + viewModel = HomeViewModel(repository) + + assertFalse(viewModel.uiState.isLoading) + assertNull(viewModel.uiState.user) + } + + @Test + fun `loadUser handles failure`() = runTest { + coEvery { repository.getCurrentUser() } returns Result.failure(RuntimeException("Network error")) + + viewModel = HomeViewModel(repository) + + assertFalse(viewModel.uiState.isLoading) + assertNull(viewModel.uiState.user) + } + + @Test + fun `logout calls repository and triggers callback`() = runTest { + coEvery { repository.logout() } returns Result.success(Unit) + coEvery { repository.getCurrentUser() } returns Result.success(null) + + viewModel = HomeViewModel(repository) + + var called = false + viewModel.logout { called = true } + + assertTrue(called) + coVerify { repository.logout() } + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/LoginViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/LoginViewModelTest.kt new file mode 100644 index 0000000..1cfaa20 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/LoginViewModelTest.kt @@ -0,0 +1,106 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.User +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class LoginViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: LoginViewModel + + @Test + fun `initial state is empty`() { + viewModel = LoginViewModel(repository) + + assertEquals("", viewModel.uiState.email) + assertEquals("", viewModel.uiState.password) + assertFalse(viewModel.uiState.isLoading) + assertNull(viewModel.uiState.error) + } + + @Test + fun `updateEmail updates state`() { + viewModel = LoginViewModel(repository) + viewModel.updateEmail("test@example.com") + + assertEquals("test@example.com", viewModel.uiState.email) + } + + @Test + fun `updatePassword updates state`() { + viewModel = LoginViewModel(repository) + viewModel.updatePassword("secret") + + assertEquals("secret", viewModel.uiState.password) + } + + @Test + fun `login with blank email shows error`() = runTest { + viewModel = LoginViewModel(repository) + viewModel.updateEmail("") + viewModel.updatePassword("password") + + var called = false + viewModel.login { called = true } + + assertFalse(called) + assertEquals("Email and password are required", viewModel.uiState.error) + } + + @Test + fun `login with blank password shows error`() = runTest { + viewModel = LoginViewModel(repository) + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("") + + var called = false + viewModel.login { called = true } + + assertFalse(called) + assertEquals("Email and password are required", viewModel.uiState.error) + } + + @Test + fun `login success navigates home`() = runTest { + viewModel = LoginViewModel(repository) + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("password123") + + coEvery { repository.login("test@example.com", "password123") } returns Result.success( + User("1", "test@example.com", "Test") + ) + + var called = false + viewModel.login { called = true } + + assertTrue(called) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `login failure shows error`() = runTest { + viewModel = LoginViewModel(repository) + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("wrong") + + coEvery { repository.login(any(), any()) } returns Result.failure( + RuntimeException("Invalid credentials") + ) + + var called = false + viewModel.login { called = true } + + assertFalse(called) + assertEquals("Invalid credentials", viewModel.uiState.error) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RecommendationViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RecommendationViewModelTest.kt new file mode 100644 index 0000000..2968e3f --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RecommendationViewModelTest.kt @@ -0,0 +1,80 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.* +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class RecommendationViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: RecommendationViewModel + + @Test + fun `initial state is empty`() { + viewModel = RecommendationViewModel(repository) + + assertNull(viewModel.uiState.build) + assertNull(viewModel.uiState.recommendation) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `loadBuildAndRecommend fetches recommendation on success`() = runTest { + val build = BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + frameId = "f1", forkId = "fk1", userId = "u1", createdAt = "", updatedAt = "" + ) + val response = RecommendResponse("llama3.1:8b", "Fork: 85 PSI") + coEvery { repository.fetchBuilds() } returns Result.success(listOf(build)) + coEvery { repository.getRecommendation(any()) } returns Result.success(response) + + viewModel = RecommendationViewModel(repository) + viewModel.loadBuildAndRecommend("b1") + + assertEquals("Trail Beast", viewModel.uiState.build?.name) + assertEquals("llama3.1:8b", viewModel.uiState.recommendation?.model) + assertEquals("Fork: 85 PSI", viewModel.uiState.recommendation?.recommendation) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `loadBuildAndRecommend shows error when build not found`() = runTest { + coEvery { repository.fetchBuilds() } returns Result.success(emptyList()) + + viewModel = RecommendationViewModel(repository) + viewModel.loadBuildAndRecommend("missing") + + assertNull(viewModel.uiState.build) + assertEquals("Build not found. Could not fetch recommendation.", viewModel.uiState.error) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `loadBuildAndRecommend shows error when recommendation fails`() = runTest { + val build = BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + coEvery { repository.fetchBuilds() } returns Result.success(listOf(build)) + coEvery { repository.getRecommendation(any()) } returns Result.failure( + RuntimeException("AI service unavailable") + ) + + viewModel = RecommendationViewModel(repository) + viewModel.loadBuildAndRecommend("b1") + + assertEquals("AI service unavailable", viewModel.uiState.error) + assertFalse(viewModel.uiState.isLoading) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RegisterViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RegisterViewModelTest.kt new file mode 100644 index 0000000..6374d05 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/RegisterViewModelTest.kt @@ -0,0 +1,108 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.User +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class RegisterViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: RegisterViewModel + + @Test + fun `initial state is empty`() { + viewModel = RegisterViewModel(repository) + + assertEquals("", viewModel.uiState.email) + assertEquals("", viewModel.uiState.password) + assertEquals("", viewModel.uiState.name) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `register with short password shows error`() = runTest { + viewModel = RegisterViewModel(repository) + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("12345") + + var called = false + viewModel.register { called = true } + + assertFalse(called) + assertEquals("Password must be at least 6 characters", viewModel.uiState.error) + } + + @Test + fun `register with blank email shows error`() = runTest { + viewModel = RegisterViewModel(repository) + viewModel.updateEmail("") + viewModel.updatePassword("password123") + + var called = false + viewModel.register { called = true } + + assertFalse(called) + assertEquals("Email and password are required", viewModel.uiState.error) + } + + @Test + fun `register success navigates home`() = runTest { + viewModel = RegisterViewModel(repository) + viewModel.updateEmail("new@example.com") + viewModel.updatePassword("password123") + viewModel.updateName("New User") + + coEvery { repository.register("new@example.com", "password123", "New User") } returns Result.success( + User("1", "new@example.com", "New User") + ) + + var called = false + viewModel.register { called = true } + + assertTrue(called) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `register failure shows error`() = runTest { + viewModel = RegisterViewModel(repository) + viewModel.updateEmail("dup@example.com") + viewModel.updatePassword("password123") + + coEvery { repository.register(any(), any(), any()) } returns Result.failure( + RuntimeException("Email already registered") + ) + + var called = false + viewModel.register { called = true } + + assertFalse(called) + assertEquals("Email already registered", viewModel.uiState.error) + } + + @Test + fun `register with null name passes null to repository`() = runTest { + viewModel = RegisterViewModel(repository) + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("password123") + viewModel.updateName("") + + coEvery { repository.register("test@example.com", "password123", null) } returns Result.success( + User("1", "test@example.com", null) + ) + + var called = false + viewModel.register { called = true } + + assertTrue(called) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/ReviewViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/ReviewViewModelTest.kt new file mode 100644 index 0000000..b878a19 --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/ReviewViewModelTest.kt @@ -0,0 +1,130 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.* +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ReviewViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: ReviewViewModel + + @Test + fun `initial state has default ratings`() { + viewModel = ReviewViewModel(repository) + + assertEquals(5, viewModel.uiState.suspFeel) + assertEquals(5, viewModel.uiState.frontGrip) + assertEquals(5, viewModel.uiState.rearGrip) + assertEquals(5, viewModel.uiState.rollSpeed) + assertEquals(5, viewModel.uiState.confidence) + assertFalse(viewModel.uiState.bottomOut) + assertFalse(viewModel.uiState.harshHit) + assertFalse(viewModel.uiState.wallow) + assertFalse(viewModel.uiState.smallBump) + } + + @Test + fun `updateSuspFeel coerces to valid range`() { + viewModel = ReviewViewModel(repository) + viewModel.updateSuspFeel(15) + assertEquals(10, viewModel.uiState.suspFeel) + + viewModel.updateSuspFeel(0) + assertEquals(1, viewModel.uiState.suspFeel) + } + + @Test + fun `toggleBottomOut flips boolean`() { + viewModel = ReviewViewModel(repository) + assertFalse(viewModel.uiState.bottomOut) + + viewModel.toggleBottomOut() + assertTrue(viewModel.uiState.bottomOut) + + viewModel.toggleBottomOut() + assertFalse(viewModel.uiState.bottomOut) + } + + @Test + fun `submitReview success triggers callback`() = runTest { + val build = BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + val builds = listOf(build) + coEvery { repository.fetchBuilds() } returns Result.success(builds) + + val review = RideReview( + id = "r1", buildId = "b1", suspFeel = 7, frontGrip = 8, + rearGrip = 6, rollSpeed = 7, confidence = 9 + ) + coEvery { repository.createReview(any()) } returns Result.success(review) + + viewModel = ReviewViewModel(repository) + viewModel.loadBuild("b1") + viewModel.updateSuspFeel(7) + viewModel.updateFrontGrip(8) + viewModel.updateRearGrip(6) + viewModel.updateRollSpeed(7) + viewModel.updateConfidence(9) + viewModel.toggleBottomOut() + + var called = false + viewModel.submitReview("b1") { called = true } + + assertTrue(called) + assertFalse(viewModel.uiState.isLoading) + } + + @Test + fun `submitReview failure shows error`() = runTest { + coEvery { repository.createReview(any()) } returns Result.failure( + RuntimeException("Server error") + ) + + viewModel = ReviewViewModel(repository) + viewModel.updateSuspFeel(7) + + var called = false + viewModel.submitReview("b1") { called = true } + + assertFalse(called) + assertEquals("Server error", viewModel.uiState.error) + } + + @Test + fun `loadBuild fetches and sets build`() = runTest { + val build = BikeBuild( + id = "b1", name = "Trail Beast", riderWeightKg = 75, + discipline = "trail", riderLevel = "intermediate", rideStyle = "aggressive", + userId = "u1", createdAt = "", updatedAt = "" + ) + coEvery { repository.fetchBuilds() } returns Result.success(listOf(build)) + + viewModel = ReviewViewModel(repository) + viewModel.loadBuild("b1") + + assertEquals("Trail Beast", viewModel.uiState.build?.name) + } + + @Test + fun `updateNotes and updateAdvice update state`() { + viewModel = ReviewViewModel(repository) + viewModel.updateNotes("Great ride") + viewModel.updateAdvice("Increase rebound") + + assertEquals("Great ride", viewModel.uiState.notes) + assertEquals("Increase rebound", viewModel.uiState.advice) + } +} diff --git a/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/SplashViewModelTest.kt b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/SplashViewModelTest.kt new file mode 100644 index 0000000..6c1d66a --- /dev/null +++ b/android/app/src/test/java/com/bikeloam/app/ui/viewmodel/SplashViewModelTest.kt @@ -0,0 +1,59 @@ +package com.bikeloam.app.ui.viewmodel + +import com.bikeloam.app.MainDispatcherRule +import com.bikeloam.app.data.model.User +import com.bikeloam.app.data.repository.BikeRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class SplashViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository: BikeRepository = mockk() + private lateinit var viewModel: SplashViewModel + + @Test + fun `initial state is not ready`() { + viewModel = SplashViewModel(repository) + + assertFalse(viewModel.uiState.isReady) + } + + @Test + fun `authenticated user becomes ready with isAuthenticated true`() = runTest { + coEvery { repository.getCurrentUser() } returns Result.success( + User("1", "test@example.com", "Test") + ) + + viewModel = SplashViewModel(repository) + + assertTrue(viewModel.uiState.isReady) + assertTrue(viewModel.uiState.isAuthenticated) + } + + @Test + fun `unauthenticated user becomes ready with isAuthenticated false`() = runTest { + coEvery { repository.getCurrentUser() } returns Result.success(null) + + viewModel = SplashViewModel(repository) + + assertTrue(viewModel.uiState.isReady) + assertFalse(viewModel.uiState.isAuthenticated) + } + + @Test + fun `auth check failure becomes ready with isAuthenticated false`() = runTest { + coEvery { repository.getCurrentUser() } returns Result.failure(RuntimeException("Network error")) + + viewModel = SplashViewModel(repository) + + assertTrue(viewModel.uiState.isReady) + assertFalse(viewModel.uiState.isAuthenticated) + } +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.