feat(item): add ItemFormViewModel and ItemFormScreen for create/edit

ItemFormViewModel:
- Create-Modus (new article) and Edit-Modus (load existing by ID via
  SavedStateHandle navigation argument)
- Form state with all ItemEntity fields as MutableStateFlow
- Validation: name required, quantity > 0, category and location required
- Save function (insert for create, update for edit)
- Loads categories and locations for dropdown selection

ItemFormScreen:
- OutlinedTextField for name, quantity, unit, price, kcal/100g,
  min stock, notes
- ExposedDropdownMenuBox for category and location selection
- Material 3 DatePickerDialog for expiry date (MHD)
- Inline validation error display per field
- Save button in TopAppBar, back navigation on successful save
- UUID generation for new articles

Tests:
- 18 unit tests covering create mode, edit mode, field updates,
  validation (all required fields), and save behavior (insert vs update)

Closes #27
This commit is contained in:
Jens Reinemann 2026-05-14 01:11:36 +02:00
parent a1cd7e5199
commit f0ad946140
3 changed files with 1093 additions and 0 deletions

View file

@ -0,0 +1,371 @@
package de.krisenvorrat.app.ui.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MenuAnchorType
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.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ItemFormScreen(
onNavigateBack: () -> Unit,
viewModel: ItemFormViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.isSaved) {
if (uiState.isSaved) {
onNavigateBack()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (uiState.isEditMode) "Artikel bearbeiten" else "Neuer Artikel") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
},
actions = {
IconButton(
onClick = viewModel::save,
enabled = !uiState.isSaving
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Speichern"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Name
OutlinedTextField(
value = uiState.name,
onValueChange = viewModel::updateName,
label = { Text("Name *") },
isError = uiState.validationErrors.containsKey("name"),
supportingText = uiState.validationErrors["name"]?.let { error ->
{ Text(error) }
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Kategorie Dropdown
CategoryDropdown(
categories = uiState.categories,
selectedCategoryId = uiState.categoryId,
onCategorySelected = viewModel::updateCategoryId,
isError = uiState.validationErrors.containsKey("categoryId"),
errorText = uiState.validationErrors["categoryId"]
)
Spacer(modifier = Modifier.height(8.dp))
// Menge
OutlinedTextField(
value = uiState.quantity,
onValueChange = viewModel::updateQuantity,
label = { Text("Menge *") },
isError = uiState.validationErrors.containsKey("quantity"),
supportingText = uiState.validationErrors["quantity"]?.let { error ->
{ Text(error) }
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Einheit
OutlinedTextField(
value = uiState.unit,
onValueChange = viewModel::updateUnit,
label = { Text("Einheit") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Lagerort Dropdown
LocationDropdown(
locations = uiState.locations,
selectedLocationId = uiState.locationId,
onLocationSelected = viewModel::updateLocationId,
isError = uiState.validationErrors.containsKey("locationId"),
errorText = uiState.validationErrors["locationId"]
)
Spacer(modifier = Modifier.height(8.dp))
// Ablaufdatum
ExpiryDateField(
expiryDate = uiState.expiryDate,
onDateSelected = viewModel::updateExpiryDate
)
Spacer(modifier = Modifier.height(8.dp))
// Preis
OutlinedTextField(
value = uiState.unitPrice,
onValueChange = viewModel::updateUnitPrice,
label = { Text("Preis (€)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// kcal/100g
OutlinedTextField(
value = uiState.kcalPer100g,
onValueChange = viewModel::updateKcalPer100g,
label = { Text("kcal / 100g") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Mindestbestand
OutlinedTextField(
value = uiState.minStock,
onValueChange = viewModel::updateMinStock,
label = { Text("Mindestbestand") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Notizen
OutlinedTextField(
value = uiState.notes,
onValueChange = viewModel::updateNotes,
label = { Text("Notizen") },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryDropdown(
categories: List<de.krisenvorrat.app.data.db.entity.CategoryEntity>,
selectedCategoryId: Int?,
onCategorySelected: (Int) -> Unit,
isError: Boolean,
errorText: String?
) {
var isExpanded by remember { mutableStateOf(false) }
val selectedName = categories.find { it.id == selectedCategoryId }?.name ?: ""
ExposedDropdownMenuBox(
expanded = isExpanded,
onExpandedChange = { isExpanded = it }
) {
OutlinedTextField(
value = selectedName,
onValueChange = {},
readOnly = true,
label = { Text("Kategorie *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
isError = isError,
supportingText = errorText?.let { error -> { Text(error) } },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = isExpanded,
onDismissRequest = { isExpanded = false }
) {
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name) },
onClick = {
onCategorySelected(category.id)
isExpanded = false
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationDropdown(
locations: List<de.krisenvorrat.app.data.db.entity.LocationEntity>,
selectedLocationId: Int?,
onLocationSelected: (Int) -> Unit,
isError: Boolean,
errorText: String?
) {
var isExpanded by remember { mutableStateOf(false) }
val selectedName = locations.find { it.id == selectedLocationId }?.name ?: ""
ExposedDropdownMenuBox(
expanded = isExpanded,
onExpandedChange = { isExpanded = it }
) {
OutlinedTextField(
value = selectedName,
onValueChange = {},
readOnly = true,
label = { Text("Lagerort *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
isError = isError,
supportingText = errorText?.let { error -> { Text(error) } },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = isExpanded,
onDismissRequest = { isExpanded = false }
) {
locations.forEach { location ->
DropdownMenuItem(
text = { Text(location.name) },
onClick = {
onLocationSelected(location.id)
isExpanded = false
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpiryDateField(
expiryDate: LocalDate?,
onDateSelected: (LocalDate?) -> Unit
) {
var isDatePickerVisible by remember { mutableStateOf(false) }
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val displayValue = expiryDate?.format(formatter) ?: ""
OutlinedTextField(
value = displayValue,
onValueChange = {},
readOnly = true,
label = { Text("Ablaufdatum (MHD)") },
trailingIcon = {
IconButton(onClick = { isDatePickerVisible = true }) {
Icon(
imageVector = Icons.Default.DateRange,
contentDescription = "Datum wählen"
)
}
},
modifier = Modifier.fillMaxWidth()
)
if (isDatePickerVisible) {
val initialMillis = expiryDate
?.atStartOfDay(ZoneId.of("UTC"))
?.toInstant()
?.toEpochMilli()
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { isDatePickerVisible = false },
confirmButton = {
TextButton(
onClick = {
val selectedMillis = datePickerState.selectedDateMillis
if (selectedMillis != null) {
val selectedDate = Instant.ofEpochMilli(selectedMillis)
.atZone(ZoneId.of("UTC"))
.toLocalDate()
onDateSelected(selectedDate)
}
isDatePickerVisible = false
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = {
onDateSelected(null)
isDatePickerVisible = false
}) {
Text("Entfernen")
}
}
) {
DatePicker(state = datePickerState)
}
}
}

View file

@ -0,0 +1,209 @@
package de.krisenvorrat.app.ui.item
import androidx.lifecycle.SavedStateHandle
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.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
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 java.time.LocalDate
import java.util.UUID
import javax.inject.Inject
internal data class ItemFormUiState(
val isEditMode: Boolean = false,
val itemId: String = "",
val name: String = "",
val categoryId: Int? = null,
val quantity: String = "",
val unit: String = "",
val unitPrice: String = "",
val kcalPer100g: String = "",
val expiryDate: LocalDate? = null,
val locationId: Int? = null,
val minStock: String = "",
val notes: String = "",
val categories: List<CategoryEntity> = emptyList(),
val locations: List<LocationEntity> = emptyList(),
val validationErrors: Map<String, String> = emptyMap(),
val isSaving: Boolean = false,
val isSaved: Boolean = false,
val isLoading: Boolean = false
)
@HiltViewModel
internal class ItemFormViewModel @Inject constructor(
private val itemRepository: ItemRepository,
private val categoryRepository: CategoryRepository,
private val locationRepository: LocationRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(ItemFormUiState())
val uiState: StateFlow<ItemFormUiState> = _uiState.asStateFlow()
private val editItemId: String? = savedStateHandle.get<String>("itemId")
init {
loadDropdownData()
if (editItemId != null) {
loadItem(editItemId)
}
}
private fun loadDropdownData() {
viewModelScope.launch {
categoryRepository.getAll().collect { categories ->
_uiState.update { it.copy(categories = categories) }
}
}
viewModelScope.launch {
locationRepository.getAll().collect { locations ->
_uiState.update { it.copy(locations = locations) }
}
}
}
private fun loadItem(itemId: String) {
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
try {
val item = itemRepository.getById(itemId)
if (item != null) {
_uiState.update {
it.copy(
isEditMode = true,
itemId = item.id,
name = item.name,
categoryId = item.categoryId,
quantity = item.quantity.toBigDecimal().stripTrailingZeros().toPlainString(),
unit = item.unit,
unitPrice = item.unitPrice.toBigDecimal().stripTrailingZeros().toPlainString(),
kcalPer100g = item.kcalPer100g?.toString() ?: "",
expiryDate = item.expiryDate,
locationId = item.locationId,
minStock = item.minStock.toBigDecimal().stripTrailingZeros().toPlainString(),
notes = item.notes,
isLoading = false
)
}
} else {
_uiState.update { it.copy(isLoading = false) }
}
} catch (_: Exception) {
_uiState.update { it.copy(isLoading = false) }
}
}
}
fun updateName(value: String) {
_uiState.update { it.copy(name = value, validationErrors = it.validationErrors - "name") }
}
fun updateCategoryId(value: Int) {
_uiState.update { it.copy(categoryId = value, validationErrors = it.validationErrors - "categoryId") }
}
fun updateQuantity(value: String) {
_uiState.update { it.copy(quantity = value, validationErrors = it.validationErrors - "quantity") }
}
fun updateUnit(value: String) {
_uiState.update { it.copy(unit = value) }
}
fun updateUnitPrice(value: String) {
_uiState.update { it.copy(unitPrice = value) }
}
fun updateKcalPer100g(value: String) {
_uiState.update { it.copy(kcalPer100g = value) }
}
fun updateExpiryDate(value: LocalDate?) {
_uiState.update { it.copy(expiryDate = value) }
}
fun updateLocationId(value: Int) {
_uiState.update { it.copy(locationId = value, validationErrors = it.validationErrors - "locationId") }
}
fun updateMinStock(value: String) {
_uiState.update { it.copy(minStock = value) }
}
fun updateNotes(value: String) {
_uiState.update { it.copy(notes = value) }
}
fun save() {
val state = _uiState.value
val errors = validate(state)
if (errors.isNotEmpty()) {
_uiState.update { it.copy(validationErrors = errors) }
return
}
_uiState.update { it.copy(isSaving = true) }
val item = ItemEntity(
id = if (state.isEditMode) state.itemId else UUID.randomUUID().toString(),
name = state.name.trim(),
categoryId = state.categoryId!!,
quantity = state.quantity.toDouble(),
unit = state.unit.trim(),
unitPrice = state.unitPrice.toDoubleOrNull() ?: 0.0,
kcalPer100g = state.kcalPer100g.toIntOrNull(),
expiryDate = state.expiryDate,
locationId = state.locationId!!,
minStock = state.minStock.toDoubleOrNull() ?: 0.0,
notes = state.notes.trim(),
lastUpdated = System.currentTimeMillis()
)
viewModelScope.launch {
try {
if (state.isEditMode) {
itemRepository.update(item)
} else {
itemRepository.insert(item)
}
_uiState.update { it.copy(isSaving = false, isSaved = true) }
} catch (_: Exception) {
_uiState.update { it.copy(isSaving = false) }
}
}
}
private fun validate(state: ItemFormUiState): Map<String, String> {
val errors = mutableMapOf<String, String>()
if (state.name.isBlank()) {
errors["name"] = "Name ist erforderlich"
}
val quantity = state.quantity.toDoubleOrNull()
if (quantity == null || quantity <= 0) {
errors["quantity"] = "Menge muss größer als 0 sein"
}
if (state.categoryId == null) {
errors["categoryId"] = "Kategorie ist erforderlich"
}
if (state.locationId == null) {
errors["locationId"] = "Lagerort ist erforderlich"
}
return errors
}
}

View file

@ -0,0 +1,513 @@
package de.krisenvorrat.app.ui.item
import androidx.lifecycle.SavedStateHandle
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
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.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class)
class ItemFormViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeItemRepository: FakeItemFormRepository
private lateinit var fakeCategoryRepository: FakeFormCategoryRepository
private lateinit var fakeLocationRepository: FakeFormLocationRepository
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeItemRepository = FakeItemFormRepository()
fakeCategoryRepository = FakeFormCategoryRepository()
fakeLocationRepository = FakeFormLocationRepository()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(itemId: String? = null): ItemFormViewModel {
val savedStateHandle = SavedStateHandle().apply {
if (itemId != null) set("itemId", itemId)
}
return ItemFormViewModel(
itemRepository = fakeItemRepository,
categoryRepository = fakeCategoryRepository,
locationRepository = fakeLocationRepository,
savedStateHandle = savedStateHandle
)
}
// --- Create Mode Tests ---
@Test
fun test_init_createMode_stateIsEmpty() = runTest(testDispatcher) {
// Given / When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isEditMode)
assertEquals("", state.name)
assertEquals(null, state.categoryId)
assertEquals("", state.quantity)
assertEquals(null, state.locationId)
assertFalse(state.isSaved)
}
@Test
fun test_init_createMode_loadsCategories() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(
listOf(
CategoryEntity(id = 1, name = "Lebensmittel"),
CategoryEntity(id = 2, name = "Hygiene")
)
)
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
assertEquals(2, viewModel.uiState.value.categories.size)
}
@Test
fun test_init_createMode_loadsLocations() = runTest(testDispatcher) {
// Given
fakeLocationRepository.emit(
listOf(
LocationEntity(id = 1, name = "Keller"),
LocationEntity(id = 2, name = "Küche")
)
)
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
assertEquals(2, viewModel.uiState.value.locations.size)
}
// --- Edit Mode Tests ---
@Test
fun test_init_editMode_loadsExistingItem() = runTest(testDispatcher) {
// Given
val item = ItemEntity(
id = "edit-1",
name = "Konserve",
categoryId = 1,
quantity = 5.0,
unit = "Stk",
unitPrice = 2.5,
kcalPer100g = 120,
expiryDate = LocalDate.of(2026, 12, 31),
locationId = 2,
minStock = 2.0,
notes = "Bohnen",
lastUpdated = 1000L
)
fakeItemRepository.addItem(item)
// When
val viewModel = createViewModel(itemId = "edit-1")
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.isEditMode)
assertEquals("edit-1", state.itemId)
assertEquals("Konserve", state.name)
assertEquals(1, state.categoryId)
assertEquals("5", state.quantity)
assertEquals("Stk", state.unit)
assertEquals("2.5", state.unitPrice)
assertEquals("120", state.kcalPer100g)
assertEquals(LocalDate.of(2026, 12, 31), state.expiryDate)
assertEquals(2, state.locationId)
assertEquals("2", state.minStock)
assertEquals("Bohnen", state.notes)
}
@Test
fun test_init_editMode_withUnknownId_staysInCreateMode() = runTest(testDispatcher) {
// Given no item with this ID exists
// When
val viewModel = createViewModel(itemId = "nonexistent")
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.isEditMode)
}
// --- Field Update Tests ---
@Test
fun test_updateName_updatesState() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.updateName("Reis")
// Then
assertEquals("Reis", viewModel.uiState.value.name)
}
@Test
fun test_updateCategoryId_updatesState() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.updateCategoryId(3)
// Then
assertEquals(3, viewModel.uiState.value.categoryId)
}
@Test
fun test_updateQuantity_updatesState() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.updateQuantity("10.5")
// Then
assertEquals("10.5", viewModel.uiState.value.quantity)
}
@Test
fun test_updateExpiryDate_updatesState() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
val date = LocalDate.of(2027, 6, 15)
// When
viewModel.updateExpiryDate(date)
// Then
assertEquals(date, viewModel.uiState.value.expiryDate)
}
// --- Validation Tests ---
@Test
fun test_save_withEmptyName_showsValidationError() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateQuantity("5")
viewModel.updateCategoryId(1)
viewModel.updateLocationId(1)
// When
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.validationErrors.containsKey("name"))
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_save_withZeroQuantity_showsValidationError() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateName("Reis")
viewModel.updateQuantity("0")
viewModel.updateCategoryId(1)
viewModel.updateLocationId(1)
// When
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.validationErrors.containsKey("quantity"))
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_save_withInvalidQuantity_showsValidationError() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateName("Reis")
viewModel.updateQuantity("abc")
viewModel.updateCategoryId(1)
viewModel.updateLocationId(1)
// When
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.validationErrors.containsKey("quantity"))
}
@Test
fun test_save_withoutCategory_showsValidationError() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateName("Reis")
viewModel.updateQuantity("5")
viewModel.updateLocationId(1)
// When
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.validationErrors.containsKey("categoryId"))
}
@Test
fun test_save_withoutLocation_showsValidationError() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateName("Reis")
viewModel.updateQuantity("5")
viewModel.updateCategoryId(1)
// When
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.validationErrors.containsKey("locationId"))
}
@Test
fun test_save_withMultipleMissingFields_showsAllErrors() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
// When all required fields are empty
viewModel.save()
advanceUntilIdle()
// Then
val errors = viewModel.uiState.value.validationErrors
assertTrue(errors.containsKey("name"))
assertTrue(errors.containsKey("quantity"))
assertTrue(errors.containsKey("categoryId"))
assertTrue(errors.containsKey("locationId"))
}
@Test
fun test_updateName_clearsNameValidationError() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.save() // triggers validation errors
// When
viewModel.updateName("Reis")
// Then
assertFalse(viewModel.uiState.value.validationErrors.containsKey("name"))
}
// --- Save Tests ---
@Test
fun test_save_createMode_withValidData_insertsItem() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateName("Reis")
viewModel.updateQuantity("5")
viewModel.updateCategoryId(1)
viewModel.updateLocationId(2)
viewModel.updateUnit("kg")
// When
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertEquals(1, fakeItemRepository.insertedItems.size)
val inserted = fakeItemRepository.insertedItems.first()
assertEquals("Reis", inserted.name)
assertEquals(5.0, inserted.quantity, 0.001)
assertEquals(1, inserted.categoryId)
assertEquals(2, inserted.locationId)
assertEquals("kg", inserted.unit)
assertNotNull(inserted.id) // UUID was generated
}
@Test
fun test_save_editMode_withValidData_updatesItem() = runTest(testDispatcher) {
// Given
val item = ItemEntity(
id = "upd-1",
name = "Alt",
categoryId = 1,
quantity = 2.0,
unit = "Stk",
unitPrice = 0.0,
kcalPer100g = null,
expiryDate = null,
locationId = 1,
minStock = 0.0,
notes = "",
lastUpdated = 0L
)
fakeItemRepository.addItem(item)
val viewModel = createViewModel(itemId = "upd-1")
advanceUntilIdle()
// When
viewModel.updateName("Neu")
viewModel.updateQuantity("10")
viewModel.save()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertEquals(1, fakeItemRepository.updatedItems.size)
val updated = fakeItemRepository.updatedItems.first()
assertEquals("upd-1", updated.id)
assertEquals("Neu", updated.name)
assertEquals(10.0, updated.quantity, 0.001)
}
@Test
fun test_save_createMode_doesNotUseEditModeId() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateName("Test")
viewModel.updateQuantity("1")
viewModel.updateCategoryId(1)
viewModel.updateLocationId(1)
// When
viewModel.save()
advanceUntilIdle()
// Then
val inserted = fakeItemRepository.insertedItems.first()
assertTrue(inserted.id.isNotBlank())
assertTrue(fakeItemRepository.updatedItems.isEmpty())
}
}
// region Test Fakes
private class FakeItemFormRepository : ItemRepository {
private val items = mutableListOf<ItemEntity>()
val insertedItems = mutableListOf<ItemEntity>()
val updatedItems = mutableListOf<ItemEntity>()
fun addItem(item: ItemEntity) {
items.add(item)
}
override fun getAll(): Flow<List<ItemEntity>> = MutableStateFlow(items.toList())
override suspend fun getById(id: String): ItemEntity? = items.find { it.id == id }
override suspend fun insert(item: ItemEntity) {
insertedItems.add(item)
}
override suspend fun update(item: ItemEntity) {
updatedItems.add(item)
}
override suspend fun delete(item: ItemEntity) {
items.remove(item)
}
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
MutableStateFlow(items.filter { it.categoryId == categoryId })
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> =
MutableStateFlow(items.filter { it.locationId == locationId })
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
MutableStateFlow(emptyList())
}
private class FakeFormCategoryRepository : CategoryRepository {
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
fun emit(categories: List<CategoryEntity>) {
flow.value = categories
}
override fun getAll(): Flow<List<CategoryEntity>> = flow
override suspend fun insert(category: CategoryEntity) {
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) {
flow.value = flow.value.filter { it.id != category.id }
}
}
private class FakeFormLocationRepository : LocationRepository {
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
fun emit(locations: List<LocationEntity>) {
flow.value = locations
}
override fun getAll(): Flow<List<LocationEntity>> = flow
override suspend fun insert(location: LocationEntity) {
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) {
flow.value = flow.value.filter { it.id != location.id }
}
}
// endregion