feat(item-list): Suche und Filter auf Item-Liste (#76)
ItemListScreen: Suchleiste (OutlinedTextField) mit Search/Clear-Icons, FilterChipRow mit Dropdown-Menüs für Kategorie, Lagerort und Ablaufstatus (Abgelaufen/Bald ablaufend/OK). Leere Ergebnisse zeigen 'Keine Items gefunden'. Suche durchsucht Name und Notizen (case-insensitive), alle Filter sind kombinierbar. ItemListViewModel: FilterState (MutableStateFlow) mit searchQuery, categoryId, locationId, expiryFilter. combine() mit 4 Flows, lokale Filterung auf gemappten ItemUiModel-Instanzen. ItemUiModel: locationId und notes mit Defaults hinzugefügt. 13 neue Unit-Tests für Suche, Filter, Kombinationen und Leerzustände. Alle 257 Tests grün. Closes #76
This commit is contained in:
parent
7c17f8ea2f
commit
6711a0e056
4 changed files with 625 additions and 25 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package de.krisenvorrat.app.ui.item
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -10,22 +11,29 @@ 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.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
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
|
||||
|
|
@ -106,23 +114,46 @@ internal fun ItemListScreen(
|
|||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (uiState.isEmpty) {
|
||||
EmptyState(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
ItemSearchBar(
|
||||
query = uiState.searchQuery,
|
||||
onQueryChanged = viewModel::onSearchQueryChanged,
|
||||
onClearClick = viewModel::clearSearch
|
||||
)
|
||||
} else {
|
||||
ItemList(
|
||||
|
||||
FilterChipRow(
|
||||
uiState = uiState,
|
||||
onCategorySelected = viewModel::onCategoryFilterChanged,
|
||||
onLocationSelected = viewModel::onLocationFilterChanged,
|
||||
onExpirySelected = viewModel::onExpiryFilterChanged
|
||||
)
|
||||
|
||||
when {
|
||||
uiState.isEmpty -> EmptyState(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
uiState.hasNoFilterResults -> NoFilterResultsState(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
else -> ItemList(
|
||||
groupedItems = uiState.groupedItems,
|
||||
onItemClick = onItemClick,
|
||||
onDeleteClick = viewModel::showDeleteDialog,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isDeleteDialogVisible && uiState.itemToDelete != null) {
|
||||
DeleteItemDialog(
|
||||
|
|
@ -256,6 +287,145 @@ private fun ExpiryDateText(item: ItemUiModel) {
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemSearchBar(
|
||||
query: String,
|
||||
onQueryChanged: (String) -> Unit,
|
||||
onClearClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChanged,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Suchen...") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = "Suchen")
|
||||
},
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = onClearClick) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Suche löschen")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChipRow(
|
||||
uiState: ItemListUiState,
|
||||
onCategorySelected: (Int?) -> Unit,
|
||||
onLocationSelected: (Int?) -> Unit,
|
||||
onExpirySelected: (ExpiryFilter?) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterDropdownChip(
|
||||
label = "Kategorie",
|
||||
selectedLabel = uiState.availableCategories
|
||||
.find { it.first == uiState.selectedCategoryId }?.second,
|
||||
options = uiState.availableCategories,
|
||||
onOptionSelected = { onCategorySelected(it) },
|
||||
onClear = { onCategorySelected(null) }
|
||||
)
|
||||
|
||||
FilterDropdownChip(
|
||||
label = "Lagerort",
|
||||
selectedLabel = uiState.availableLocations
|
||||
.find { it.first == uiState.selectedLocationId }?.second,
|
||||
options = uiState.availableLocations,
|
||||
onOptionSelected = { onLocationSelected(it) },
|
||||
onClear = { onLocationSelected(null) }
|
||||
)
|
||||
|
||||
FilterDropdownChip(
|
||||
label = "Ablauf",
|
||||
selectedLabel = uiState.selectedExpiryFilter?.label,
|
||||
options = ExpiryFilter.entries.map { it.ordinal to it.label },
|
||||
onOptionSelected = { ordinal ->
|
||||
onExpirySelected(ExpiryFilter.entries[ordinal])
|
||||
},
|
||||
onClear = { onExpirySelected(null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterDropdownChip(
|
||||
label: String,
|
||||
selectedLabel: String?,
|
||||
options: List<Pair<Int, String>>,
|
||||
onOptionSelected: (Int) -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val isSelected = selectedLabel != null
|
||||
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = { expanded = true },
|
||||
label = {
|
||||
Text(if (isSelected) "$label: $selectedLabel" else label)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
if (isSelected) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Alle") },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onClear()
|
||||
}
|
||||
)
|
||||
}
|
||||
options.forEach { (id, name) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOptionSelected(id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoFilterResultsState(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Keine Items gefunden",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteItemDialog(
|
||||
itemName: String,
|
||||
|
|
|
|||
|
|
@ -15,12 +15,39 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
internal enum class ExpiryFilter(val label: String) {
|
||||
EXPIRED("Abgelaufen"),
|
||||
EXPIRING_SOON("Bald ablaufend"),
|
||||
OK("OK")
|
||||
}
|
||||
|
||||
private data class FilterState(
|
||||
val searchQuery: String = "",
|
||||
val categoryId: Int? = null,
|
||||
val locationId: Int? = null,
|
||||
val expiryFilter: ExpiryFilter? = null
|
||||
)
|
||||
|
||||
internal data class ItemListUiState(
|
||||
val groupedItems: Map<String, List<ItemUiModel>> = emptyMap(),
|
||||
val totalItemCount: Int = 0,
|
||||
val searchQuery: String = "",
|
||||
val selectedCategoryId: Int? = null,
|
||||
val selectedLocationId: Int? = null,
|
||||
val selectedExpiryFilter: ExpiryFilter? = null,
|
||||
val availableCategories: List<Pair<Int, String>> = emptyList(),
|
||||
val availableLocations: List<Pair<Int, String>> = emptyList(),
|
||||
val isDeleteDialogVisible: Boolean = false,
|
||||
val itemToDelete: ItemUiModel? = null
|
||||
) {
|
||||
val isEmpty: Boolean get() = groupedItems.isEmpty()
|
||||
val isEmpty: Boolean get() = totalItemCount == 0
|
||||
val isFiltering: Boolean
|
||||
get() = searchQuery.isNotBlank() ||
|
||||
selectedCategoryId != null ||
|
||||
selectedLocationId != null ||
|
||||
selectedExpiryFilter != null
|
||||
val hasNoFilterResults: Boolean
|
||||
get() = isFiltering && groupedItems.isEmpty() && !isEmpty
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -30,6 +57,7 @@ internal class ItemListViewModel @Inject constructor(
|
|||
private val locationRepository: LocationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _filterState = MutableStateFlow(FilterState())
|
||||
private val _uiState = MutableStateFlow(ItemListUiState())
|
||||
val uiState: StateFlow<ItemListUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
|
@ -38,12 +66,13 @@ internal class ItemListViewModel @Inject constructor(
|
|||
combine(
|
||||
itemRepository.getAll(),
|
||||
categoryRepository.getAll(),
|
||||
locationRepository.getAll()
|
||||
) { items, categories, locations ->
|
||||
locationRepository.getAll(),
|
||||
_filterState
|
||||
) { items, categories, locations, filters ->
|
||||
val categoryMap = categories.associate { it.id to it.name }
|
||||
val locationMap = locations.associate { it.id to it.name }
|
||||
|
||||
items.map { item ->
|
||||
val allUiItems = items.map { item ->
|
||||
ItemUiModel(
|
||||
id = item.id,
|
||||
name = item.name,
|
||||
|
|
@ -52,15 +81,82 @@ internal class ItemListViewModel @Inject constructor(
|
|||
unit = item.unit,
|
||||
locationName = locationMap[item.locationId] ?: "Unbekannt",
|
||||
expiryDate = item.expiryDate,
|
||||
categoryId = item.categoryId
|
||||
categoryId = item.categoryId,
|
||||
locationId = item.locationId,
|
||||
notes = item.notes
|
||||
)
|
||||
}.groupBy { it.categoryName }
|
||||
}
|
||||
|
||||
val filteredItems = allUiItems
|
||||
.filter { item ->
|
||||
if (filters.searchQuery.isBlank()) true
|
||||
else {
|
||||
val query = filters.searchQuery.lowercase()
|
||||
item.name.lowercase().contains(query) ||
|
||||
item.notes.lowercase().contains(query)
|
||||
}
|
||||
}
|
||||
.filter { item ->
|
||||
filters.categoryId == null || item.categoryId == filters.categoryId
|
||||
}
|
||||
.filter { item ->
|
||||
filters.locationId == null || item.locationId == filters.locationId
|
||||
}
|
||||
.filter { item ->
|
||||
when (filters.expiryFilter) {
|
||||
null -> true
|
||||
ExpiryFilter.EXPIRED -> item.isExpired
|
||||
ExpiryFilter.EXPIRING_SOON -> item.isExpiringSoon
|
||||
ExpiryFilter.OK -> !item.isExpired && !item.isExpiringSoon
|
||||
}
|
||||
}
|
||||
|
||||
val grouped = filteredItems
|
||||
.groupBy { it.categoryName }
|
||||
.toSortedMap()
|
||||
}.collect { grouped ->
|
||||
_uiState.update { it.copy(groupedItems = grouped) }
|
||||
|
||||
ItemListUiState(
|
||||
groupedItems = grouped,
|
||||
totalItemCount = allUiItems.size,
|
||||
searchQuery = filters.searchQuery,
|
||||
selectedCategoryId = filters.categoryId,
|
||||
selectedLocationId = filters.locationId,
|
||||
selectedExpiryFilter = filters.expiryFilter,
|
||||
availableCategories = categories.map { it.id to it.name }
|
||||
.sortedBy { it.second },
|
||||
availableLocations = locations.map { it.id to it.name }
|
||||
.sortedBy { it.second }
|
||||
)
|
||||
}.collect { newState ->
|
||||
_uiState.update { old ->
|
||||
newState.copy(
|
||||
isDeleteDialogVisible = old.isDeleteDialogVisible,
|
||||
itemToDelete = old.itemToDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
_filterState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun onCategoryFilterChanged(categoryId: Int?) {
|
||||
_filterState.update { it.copy(categoryId = categoryId) }
|
||||
}
|
||||
|
||||
fun onLocationFilterChanged(locationId: Int?) {
|
||||
_filterState.update { it.copy(locationId = locationId) }
|
||||
}
|
||||
|
||||
fun onExpiryFilterChanged(expiryFilter: ExpiryFilter?) {
|
||||
_filterState.update { it.copy(expiryFilter = expiryFilter) }
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_filterState.update { it.copy(searchQuery = "") }
|
||||
}
|
||||
|
||||
fun showDeleteDialog(item: ItemUiModel) {
|
||||
_uiState.update { it.copy(isDeleteDialogVisible = true, itemToDelete = item) }
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ internal data class ItemUiModel(
|
|||
val unit: String,
|
||||
val locationName: String,
|
||||
val expiryDate: LocalDate?,
|
||||
val categoryId: Int
|
||||
val categoryId: Int,
|
||||
val locationId: Int = 0,
|
||||
val notes: String = ""
|
||||
) {
|
||||
|
||||
val isExpired: Boolean
|
||||
|
|
|
|||
|
|
@ -214,6 +214,336 @@ class ItemListViewModelTest {
|
|||
val keys = viewModel.uiState.value.groupedItems.keys.toList()
|
||||
assertEquals(listOf("Arzneimittel", "Lebensmittel", "Wasser"), keys)
|
||||
}
|
||||
|
||||
// region Search & Filter Tests
|
||||
|
||||
@Test
|
||||
fun test_searchByName_filtersCorrectly() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis"),
|
||||
buildItemEntity(id = "b", name = "Konserve"),
|
||||
buildItemEntity(id = "c", name = "Reismehl")
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onSearchQueryChanged("Reis")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(2, allItems.size)
|
||||
assertTrue(allItems.all { it.name.contains("Reis") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_searchByNotes_filtersCorrectly() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis", notes = "glutenfrei"),
|
||||
buildItemEntity(id = "b", name = "Nudeln", notes = "enthält Gluten")
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onSearchQueryChanged("glutenfrei")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("Reis", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_searchIsCaseInsensitive() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis"),
|
||||
buildItemEntity(id = "b", name = "Nudeln")
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onSearchQueryChanged("reis")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("Reis", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_filterByCategory_showsOnlyMatchingItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(
|
||||
listOf(
|
||||
CategoryEntity(id = 1, name = "Lebensmittel"),
|
||||
CategoryEntity(id = 2, name = "Hygiene")
|
||||
)
|
||||
)
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis", categoryId = 1),
|
||||
buildItemEntity(id = "b", name = "Seife", categoryId = 2)
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onCategoryFilterChanged(1)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("Reis", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_filterByLocation_showsOnlyMatchingItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(
|
||||
listOf(
|
||||
LocationEntity(id = 1, name = "Keller"),
|
||||
LocationEntity(id = 2, name = "Speisekammer")
|
||||
)
|
||||
)
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis", locationId = 1),
|
||||
buildItemEntity(id = "b", name = "Nudeln", locationId = 2)
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onLocationFilterChanged(2)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("Nudeln", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_filterByExpiryExpired_showsOnlyExpiredItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)),
|
||||
buildItemEntity(id = "b", name = "Frisch", expiryDate = LocalDate.now().plusDays(90))
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onExpiryFilterChanged(ExpiryFilter.EXPIRED)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("Abgelaufen", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_filterByExpirySoon_showsOnlyExpiringSoonItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)),
|
||||
buildItemEntity(id = "b", name = "BaldAblaufend", expiryDate = LocalDate.now().plusDays(10)),
|
||||
buildItemEntity(id = "c", name = "Frisch", expiryDate = LocalDate.now().plusDays(90))
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onExpiryFilterChanged(ExpiryFilter.EXPIRING_SOON)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("BaldAblaufend", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_filterByExpiryOk_showsOnlyOkItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)),
|
||||
buildItemEntity(id = "b", name = "Frisch", expiryDate = LocalDate.now().plusDays(90)),
|
||||
buildItemEntity(id = "c", name = "OhneDatum", expiryDate = null)
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onExpiryFilterChanged(ExpiryFilter.OK)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(2, allItems.size)
|
||||
assertTrue(allItems.map { it.name }.containsAll(listOf("Frisch", "OhneDatum")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_combinedSearchAndFilter_appliesBoth() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(
|
||||
listOf(
|
||||
CategoryEntity(id = 1, name = "Lebensmittel"),
|
||||
CategoryEntity(id = 2, name = "Hygiene")
|
||||
)
|
||||
)
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis", categoryId = 1),
|
||||
buildItemEntity(id = "b", name = "Reisseife", categoryId = 2),
|
||||
buildItemEntity(id = "c", name = "Nudeln", categoryId = 1)
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onSearchQueryChanged("Reis")
|
||||
viewModel.onCategoryFilterChanged(1)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(1, allItems.size)
|
||||
assertEquals("Reis", allItems.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_clearSearch_showsAllItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis"),
|
||||
buildItemEntity(id = "b", name = "Nudeln")
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
viewModel.onSearchQueryChanged("Reis")
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, viewModel.uiState.value.groupedItems.values.flatten().size)
|
||||
|
||||
// When
|
||||
viewModel.clearSearch()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val allItems = viewModel.uiState.value.groupedItems.values.flatten()
|
||||
assertEquals(2, allItems.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_noFilterResults_hasNoFilterResultsIsTrue() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(listOf(buildItemEntity(id = "a", name = "Reis")))
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onSearchQueryChanged("XYZ-nicht-vorhanden")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.hasNoFilterResults)
|
||||
assertFalse(state.isEmpty)
|
||||
assertTrue(state.groupedItems.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_availableCategoriesAndLocations_arePopulated() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(
|
||||
listOf(
|
||||
CategoryEntity(id = 2, name = "Hygiene"),
|
||||
CategoryEntity(id = 1, name = "Lebensmittel")
|
||||
)
|
||||
)
|
||||
fakeLocationRepository.emit(
|
||||
listOf(
|
||||
LocationEntity(id = 2, name = "Speisekammer"),
|
||||
LocationEntity(id = 1, name = "Keller")
|
||||
)
|
||||
)
|
||||
fakeItemRepository.emit(emptyList())
|
||||
|
||||
// When
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertEquals(listOf(2 to "Hygiene", 1 to "Lebensmittel"), state.availableCategories)
|
||||
assertEquals(listOf(1 to "Keller", 2 to "Speisekammer"), state.availableLocations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_filterByCategory_thenClear_showsAllItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeCategoryRepository.emit(
|
||||
listOf(
|
||||
CategoryEntity(id = 1, name = "Lebensmittel"),
|
||||
CategoryEntity(id = 2, name = "Hygiene")
|
||||
)
|
||||
)
|
||||
fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
fakeItemRepository.emit(
|
||||
listOf(
|
||||
buildItemEntity(id = "a", name = "Reis", categoryId = 1),
|
||||
buildItemEntity(id = "b", name = "Seife", categoryId = 2)
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
viewModel.onCategoryFilterChanged(1)
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, viewModel.uiState.value.groupedItems.values.flatten().size)
|
||||
|
||||
// When
|
||||
viewModel.onCategoryFilterChanged(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(2, viewModel.uiState.value.groupedItems.values.flatten().size)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
// region Test Helpers
|
||||
|
|
@ -222,7 +552,9 @@ private fun buildItemEntity(
|
|||
id: String = "id1",
|
||||
name: String = "Konserve",
|
||||
categoryId: Int = 1,
|
||||
locationId: Int = 1
|
||||
locationId: Int = 1,
|
||||
notes: String = "",
|
||||
expiryDate: LocalDate? = null
|
||||
) = ItemEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
|
|
@ -231,9 +563,9 @@ private fun buildItemEntity(
|
|||
unit = "Stk",
|
||||
unitPrice = 1.5,
|
||||
kcalPerKg = null,
|
||||
expiryDate = null,
|
||||
expiryDate = expiryDate,
|
||||
locationId = locationId,
|
||||
notes = "",
|
||||
notes = notes,
|
||||
lastUpdated = 0L
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue