Add Android unit and UI tests with test dependencies

This commit is contained in:
2026-05-19 17:00:28 +02:00
parent 4e18d97b85
commit 75d80084b6
19 changed files with 1325 additions and 6 deletions
+10
View File
@@ -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"))
@@ -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)
}
}
@@ -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)
}
}