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.