feat(ui): add category and location management screens

Closes #25

ui/category/:
- CategoryListViewModel: StateFlow-based ViewModel with add/delete
  dialog state management, backed by CategoryRepository
- CategoryListScreen: Material 3 Scaffold with LazyColumn, FAB for
  adding, delete confirmation dialog with CASCADE warning

ui/location/:
- LocationListViewModel: same pattern for LocationRepository
- LocationListScreen: same UI pattern for location management

Tests:
- CategoryListViewModelTest: 11 tests covering init, add, delete,
  dialog state, blank name rejection
- LocationListViewModelTest: 11 tests (same coverage)

Dependencies:
- Added lifecycle-runtime-compose for collectAsStateWithLifecycle
- Added kotlinx-coroutines-test for ViewModel unit tests
This commit is contained in:
Jens Reinemann 2026-05-14 00:56:36 +02:00
parent 0c1e06afca
commit a27660fd4a
9 changed files with 1016 additions and 0 deletions

View file

@ -46,6 +46,7 @@ android {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
@ -70,6 +71,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.espresso.core)

View file

@ -0,0 +1,197 @@
package de.krisenvorrat.app.ui.category
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CategoryListScreen(
onNavigateBack: () -> Unit,
viewModel: CategoryListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Kategorien") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = viewModel::showAddDialog) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Kategorie hinzufügen"
)
}
}
) { innerPadding ->
if (uiState.categories.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
text = "Noch keine Kategorien vorhanden",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
items(uiState.categories, key = { it.id }) { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = category.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { viewModel.showDeleteDialog(category) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Kategorie löschen",
tint = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider()
}
}
}
}
if (uiState.isAddDialogVisible) {
AddCategoryDialog(
name = uiState.newCategoryName,
onNameChange = viewModel::updateNewCategoryName,
onConfirm = viewModel::addCategory,
onDismiss = viewModel::dismissAddDialog
)
}
if (uiState.isDeleteDialogVisible && uiState.categoryToDelete != null) {
DeleteCategoryDialog(
categoryName = uiState.categoryToDelete!!.name,
onConfirm = viewModel::deleteCategory,
onDismiss = viewModel::dismissDeleteDialog
)
}
}
@Composable
private fun AddCategoryDialog(
name: String,
onNameChange: (String) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Kategorie hinzufügen") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = onConfirm,
enabled = name.isNotBlank()
) {
Text("Hinzufügen")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}
@Composable
private fun DeleteCategoryDialog(
categoryName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Kategorie löschen?") },
text = {
Text(
"Möchten Sie die Kategorie \"$categoryName\" wirklich löschen?\n\n" +
"Achtung: Alle Artikel in dieser Kategorie werden ebenfalls gelöscht!"
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
"Löschen",
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}

View file

@ -0,0 +1,83 @@
package de.krisenvorrat.app.ui.category
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
internal data class CategoryListUiState(
val categories: List<CategoryEntity> = emptyList(),
val isAddDialogVisible: Boolean = false,
val isDeleteDialogVisible: Boolean = false,
val categoryToDelete: CategoryEntity? = null,
val newCategoryName: String = ""
)
@HiltViewModel
internal class CategoryListViewModel @Inject constructor(
private val repository: CategoryRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(CategoryListUiState())
val uiState: StateFlow<CategoryListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
repository.getAll().collect { categories ->
_uiState.update { it.copy(categories = categories) }
}
}
}
fun showAddDialog() {
_uiState.update { it.copy(isAddDialogVisible = true, newCategoryName = "") }
}
fun dismissAddDialog() {
_uiState.update { it.copy(isAddDialogVisible = false, newCategoryName = "") }
}
fun updateNewCategoryName(name: String) {
_uiState.update { it.copy(newCategoryName = name) }
}
fun addCategory() {
val name = _uiState.value.newCategoryName.trim()
if (name.isBlank()) return
viewModelScope.launch {
try {
repository.insert(CategoryEntity(name = name))
} catch (_: Exception) {
// Room-Fehler werden still behandelt
}
}
_uiState.update { it.copy(isAddDialogVisible = false, newCategoryName = "") }
}
fun showDeleteDialog(category: CategoryEntity) {
_uiState.update { it.copy(isDeleteDialogVisible = true, categoryToDelete = category) }
}
fun dismissDeleteDialog() {
_uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) }
}
fun deleteCategory() {
val category = _uiState.value.categoryToDelete ?: return
viewModelScope.launch {
try {
repository.delete(category)
} catch (_: Exception) {
// Room-Fehler werden still behandelt
}
}
_uiState.update { it.copy(isDeleteDialogVisible = false, categoryToDelete = null) }
}
}

View file

@ -0,0 +1,197 @@
package de.krisenvorrat.app.ui.location
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun LocationListScreen(
onNavigateBack: () -> Unit,
viewModel: LocationListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Lagerorte") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = viewModel::showAddDialog) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Lagerort hinzufügen"
)
}
}
) { innerPadding ->
if (uiState.locations.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
text = "Noch keine Lagerorte vorhanden",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
items(uiState.locations, key = { it.id }) { location ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = location.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { viewModel.showDeleteDialog(location) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Lagerort löschen",
tint = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider()
}
}
}
}
if (uiState.isAddDialogVisible) {
AddLocationDialog(
name = uiState.newLocationName,
onNameChange = viewModel::updateNewLocationName,
onConfirm = viewModel::addLocation,
onDismiss = viewModel::dismissAddDialog
)
}
if (uiState.isDeleteDialogVisible && uiState.locationToDelete != null) {
DeleteLocationDialog(
locationName = uiState.locationToDelete!!.name,
onConfirm = viewModel::deleteLocation,
onDismiss = viewModel::dismissDeleteDialog
)
}
}
@Composable
private fun AddLocationDialog(
name: String,
onNameChange: (String) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Lagerort hinzufügen") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = onConfirm,
enabled = name.isNotBlank()
) {
Text("Hinzufügen")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}
@Composable
private fun DeleteLocationDialog(
locationName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Lagerort löschen?") },
text = {
Text(
"Möchten Sie den Lagerort \"$locationName\" wirklich löschen?\n\n" +
"Achtung: Alle Artikel an diesem Lagerort werden ebenfalls gelöscht!"
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
"Löschen",
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}

View file

@ -0,0 +1,83 @@
package de.krisenvorrat.app.ui.location
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.LocationRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
internal data class LocationListUiState(
val locations: List<LocationEntity> = emptyList(),
val isAddDialogVisible: Boolean = false,
val isDeleteDialogVisible: Boolean = false,
val locationToDelete: LocationEntity? = null,
val newLocationName: String = ""
)
@HiltViewModel
internal class LocationListViewModel @Inject constructor(
private val repository: LocationRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LocationListUiState())
val uiState: StateFlow<LocationListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
repository.getAll().collect { locations ->
_uiState.update { it.copy(locations = locations) }
}
}
}
fun showAddDialog() {
_uiState.update { it.copy(isAddDialogVisible = true, newLocationName = "") }
}
fun dismissAddDialog() {
_uiState.update { it.copy(isAddDialogVisible = false, newLocationName = "") }
}
fun updateNewLocationName(name: String) {
_uiState.update { it.copy(newLocationName = name) }
}
fun addLocation() {
val name = _uiState.value.newLocationName.trim()
if (name.isBlank()) return
viewModelScope.launch {
try {
repository.insert(LocationEntity(name = name))
} catch (_: Exception) {
// Room-Fehler werden still behandelt
}
}
_uiState.update { it.copy(isAddDialogVisible = false, newLocationName = "") }
}
fun showDeleteDialog(location: LocationEntity) {
_uiState.update { it.copy(isDeleteDialogVisible = true, locationToDelete = location) }
}
fun dismissDeleteDialog() {
_uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) }
}
fun deleteLocation() {
val location = _uiState.value.locationToDelete ?: return
viewModelScope.launch {
try {
repository.delete(location)
} catch (_: Exception) {
// Room-Fehler werden still behandelt
}
}
_uiState.update { it.copy(isDeleteDialogVisible = false, locationToDelete = null) }
}
}

View file

@ -0,0 +1,226 @@
package de.krisenvorrat.app.ui.category
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CategoryListViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeRepository: FakeCategoryRepository
private lateinit var viewModel: CategoryListViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeRepository = FakeCategoryRepository()
viewModel = CategoryListViewModel(fakeRepository)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun test_init_withEmptyRepository_categoriesListIsEmpty() = runTest(testDispatcher) {
// Given fresh ViewModel
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.categories.isEmpty())
}
@Test
fun test_init_withExistingCategories_categoriesAreLoaded() = runTest(testDispatcher) {
// Given
fakeRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals(1, state.categories.size)
assertEquals("Lebensmittel", state.categories.first().name)
}
@Test
fun test_showAddDialog_setsIsAddDialogVisibleToTrue() = runTest(testDispatcher) {
// Given
advanceUntilIdle()
// When
viewModel.showAddDialog()
// Then
assertTrue(viewModel.uiState.value.isAddDialogVisible)
assertEquals("", viewModel.uiState.value.newCategoryName)
}
@Test
fun test_dismissAddDialog_setsIsAddDialogVisibleToFalse() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
advanceUntilIdle()
// When
viewModel.dismissAddDialog()
// Then
assertFalse(viewModel.uiState.value.isAddDialogVisible)
}
@Test
fun test_updateNewCategoryName_updatesStateCorrectly() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
advanceUntilIdle()
// When
viewModel.updateNewCategoryName("Hygiene")
// Then
assertEquals("Hygiene", viewModel.uiState.value.newCategoryName)
}
@Test
fun test_addCategory_withValidName_insertsAndClosesDialog() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
viewModel.updateNewCategoryName("Hygiene")
advanceUntilIdle()
// When
viewModel.addCategory()
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.isAddDialogVisible)
assertEquals("", viewModel.uiState.value.newCategoryName)
assertEquals(1, fakeRepository.insertedCategories.size)
assertEquals("Hygiene", fakeRepository.insertedCategories.first().name)
}
@Test
fun test_addCategory_withBlankName_doesNotInsert() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
viewModel.updateNewCategoryName(" ")
advanceUntilIdle()
// When
viewModel.addCategory()
advanceUntilIdle()
// Then
assertTrue(fakeRepository.insertedCategories.isEmpty())
}
@Test
fun test_showDeleteDialog_setsDialogStateCorrectly() = runTest(testDispatcher) {
// Given
val category = CategoryEntity(id = 1, name = "Lebensmittel")
advanceUntilIdle()
// When
viewModel.showDeleteDialog(category)
// Then
val state = viewModel.uiState.value
assertTrue(state.isDeleteDialogVisible)
assertEquals(category, state.categoryToDelete)
}
@Test
fun test_dismissDeleteDialog_clearsDialogState() = runTest(testDispatcher) {
// Given
viewModel.showDeleteDialog(CategoryEntity(id = 1, name = "Test"))
advanceUntilIdle()
// When
viewModel.dismissDeleteDialog()
// Then
val state = viewModel.uiState.value
assertFalse(state.isDeleteDialogVisible)
assertNull(state.categoryToDelete)
}
@Test
fun test_deleteCategory_withSelectedCategory_deletesAndClosesDialog() = runTest(testDispatcher) {
// Given
val category = CategoryEntity(id = 1, name = "Lebensmittel")
viewModel.showDeleteDialog(category)
advanceUntilIdle()
// When
viewModel.deleteCategory()
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.isDeleteDialogVisible)
assertNull(viewModel.uiState.value.categoryToDelete)
assertEquals(1, fakeRepository.deletedCategories.size)
assertEquals(category, fakeRepository.deletedCategories.first())
}
@Test
fun test_deleteCategory_withNoSelection_doesNothing() = runTest(testDispatcher) {
// Given no category selected
advanceUntilIdle()
// When
viewModel.deleteCategory()
advanceUntilIdle()
// Then
assertTrue(fakeRepository.deletedCategories.isEmpty())
}
}
private class FakeCategoryRepository : CategoryRepository {
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
val insertedCategories = mutableListOf<CategoryEntity>()
val deletedCategories = mutableListOf<CategoryEntity>()
fun emit(categories: List<CategoryEntity>) {
flow.value = categories
}
override fun getAll(): Flow<List<CategoryEntity>> = flow
override suspend fun insert(category: CategoryEntity) {
insertedCategories.add(category)
flow.value = flow.value + category
}
override suspend fun update(category: CategoryEntity) {
flow.value = flow.value.map { if (it.id == category.id) category else it }
}
override suspend fun delete(category: CategoryEntity) {
deletedCategories.add(category)
flow.value = flow.value.filter { it.id != category.id }
}
}

View file

@ -0,0 +1,225 @@
package de.krisenvorrat.app.ui.location
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.LocationRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class LocationListViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeRepository: FakeLocationRepository
private lateinit var viewModel: LocationListViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeRepository = FakeLocationRepository()
viewModel = LocationListViewModel(fakeRepository)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun test_init_withEmptyRepository_locationsListIsEmpty() = runTest(testDispatcher) {
// Given fresh ViewModel
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.locations.isEmpty())
}
@Test
fun test_init_withExistingLocations_locationsAreLoaded() = runTest(testDispatcher) {
// Given
fakeRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals(1, state.locations.size)
assertEquals("Keller", state.locations.first().name)
}
@Test
fun test_showAddDialog_setsIsAddDialogVisibleToTrue() = runTest(testDispatcher) {
// Given
advanceUntilIdle()
// When
viewModel.showAddDialog()
// Then
assertTrue(viewModel.uiState.value.isAddDialogVisible)
assertEquals("", viewModel.uiState.value.newLocationName)
}
@Test
fun test_dismissAddDialog_setsIsAddDialogVisibleToFalse() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
advanceUntilIdle()
// When
viewModel.dismissAddDialog()
// Then
assertFalse(viewModel.uiState.value.isAddDialogVisible)
}
@Test
fun test_updateNewLocationName_updatesStateCorrectly() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
advanceUntilIdle()
// When
viewModel.updateNewLocationName("Dachboden")
// Then
assertEquals("Dachboden", viewModel.uiState.value.newLocationName)
}
@Test
fun test_addLocation_withValidName_insertsAndClosesDialog() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
viewModel.updateNewLocationName("Dachboden")
advanceUntilIdle()
// When
viewModel.addLocation()
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.isAddDialogVisible)
assertEquals("", viewModel.uiState.value.newLocationName)
assertEquals(1, fakeRepository.insertedLocations.size)
assertEquals("Dachboden", fakeRepository.insertedLocations.first().name)
}
@Test
fun test_addLocation_withBlankName_doesNotInsert() = runTest(testDispatcher) {
// Given
viewModel.showAddDialog()
viewModel.updateNewLocationName(" ")
advanceUntilIdle()
// When
viewModel.addLocation()
advanceUntilIdle()
// Then
assertTrue(fakeRepository.insertedLocations.isEmpty())
}
@Test
fun test_showDeleteDialog_setsDialogStateCorrectly() = runTest(testDispatcher) {
// Given
val location = LocationEntity(id = 1, name = "Keller")
advanceUntilIdle()
// When
viewModel.showDeleteDialog(location)
// Then
val state = viewModel.uiState.value
assertTrue(state.isDeleteDialogVisible)
assertEquals(location, state.locationToDelete)
}
@Test
fun test_dismissDeleteDialog_clearsDialogState() = runTest(testDispatcher) {
// Given
viewModel.showDeleteDialog(LocationEntity(id = 1, name = "Test"))
advanceUntilIdle()
// When
viewModel.dismissDeleteDialog()
// Then
val state = viewModel.uiState.value
assertFalse(state.isDeleteDialogVisible)
assertNull(state.locationToDelete)
}
@Test
fun test_deleteLocation_withSelectedLocation_deletesAndClosesDialog() = runTest(testDispatcher) {
// Given
val location = LocationEntity(id = 1, name = "Keller")
viewModel.showDeleteDialog(location)
advanceUntilIdle()
// When
viewModel.deleteLocation()
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.isDeleteDialogVisible)
assertNull(viewModel.uiState.value.locationToDelete)
assertEquals(1, fakeRepository.deletedLocations.size)
assertEquals(location, fakeRepository.deletedLocations.first())
}
@Test
fun test_deleteLocation_withNoSelection_doesNothing() = runTest(testDispatcher) {
// Given no location selected
advanceUntilIdle()
// When
viewModel.deleteLocation()
advanceUntilIdle()
// Then
assertTrue(fakeRepository.deletedLocations.isEmpty())
}
}
private class FakeLocationRepository : LocationRepository {
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
val insertedLocations = mutableListOf<LocationEntity>()
val deletedLocations = mutableListOf<LocationEntity>()
fun emit(locations: List<LocationEntity>) {
flow.value = locations
}
override fun getAll(): Flow<List<LocationEntity>> = flow
override suspend fun insert(location: LocationEntity) {
insertedLocations.add(location)
flow.value = flow.value + location
}
override suspend fun update(location: LocationEntity) {
flow.value = flow.value.map { if (it.id == location.id) location else it }
}
override suspend fun delete(location: LocationEntity) {
deletedLocations.add(location)
flow.value = flow.value.filter { it.id != location.id }
}
}

View file

@ -14,6 +14,7 @@ hiltNavigationCompose = "1.2.0"
room = "2.6.1"
navigationCompose = "2.8.5"
kotlinxSerialization = "1.7.3"
kotlinxCoroutines = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -21,6 +22,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
@ -39,6 +41,7 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }