Add Android unit and UI tests with test dependencies
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Loam — MTB Build & Tune
|
# 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) |
|
| Images | sharp (layer compositing) |
|
||||||
| AI | Ollama (local LLM) |
|
| AI | Ollama (local LLM) |
|
||||||
| Validation | Zod |
|
| Validation | Zod |
|
||||||
|
| Android | Kotlin, Jetpack Compose, Ktor, Koin |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,6 +117,12 @@ src/
|
|||||||
types.ts # shared TypeScript types
|
types.ts # shared TypeScript types
|
||||||
public/
|
public/
|
||||||
images/ # product assets + user-generated builds
|
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
|
1. Implementing cookie jar storage for the `loam-session` cookie
|
||||||
2. Calling `/api/auth/login` or `/api/auth/register` to obtain a session
|
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
|
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 db:studio` | Open Prisma Studio |
|
||||||
| `npm run lint` | Run ESLint |
|
| `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
|
## Environment variables
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Android client for the BikeSetup backend. Built with Kotlin, Jetpack Compose, an
|
|||||||
- **DI**: Koin
|
- **DI**: Koin
|
||||||
- **Networking**: Ktor client (OkHttp engine) with automatic cookie jar for session auth
|
- **Networking**: Ktor client (OkHttp engine) with automatic cookie jar for session auth
|
||||||
- **Serialization**: Kotlinx Serialization
|
- **Serialization**: Kotlinx Serialization
|
||||||
|
- **Testing**: JUnit, MockK, coroutine test utilities, Robolectric, Compose UI test APIs
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ com.bikeloam.app/
|
|||||||
./gradlew installDebug
|
./gradlew installDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
6. Run JVM unit tests:
|
||||||
|
```bash
|
||||||
|
./gradlew testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
## API Configuration
|
## 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`).
|
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.
|
- **Ride Reviews**: Submit post-ride reviews with 1–10 sliders and issue checkboxes.
|
||||||
- **AI Recommendations**: Fetches setup recommendations from `/api/ai/recommend`.
|
- **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
|
## Architecture Notes
|
||||||
|
|
||||||
- **Session cookies** are handled automatically by Ktor's `HttpCookies` plugin.
|
- **Session cookies** are handled automatically by Ktor's `HttpCookies` plugin.
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ dependencies {
|
|||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
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.ext:junit:1.2.1")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user