feat(settings): Kinder-Altersgruppen für Kalorienverbrauch (#47)

domain/model/AgeGroup.kt:
- Neues Enum AgeGroup (6 Gruppen: Kleinkind bis Erwachsener) mit Richtwert-kcal
- AgeGroupEntry-Datenklasse (count + kcalPerDay, anpassbar)
- JSON-Serialisierung via kotlinx.serialization
- Migrationspfad: householdSize + kcalPerPerson → ERWACHSENER-Gruppe

domain/model/SettingsKeys.kt:
- Neue Konstanten-Klasse für Settings-Keys (löst SettingsViewModel-Kopplung im DashboardViewModel auf)

CalculateSupplyRangeUseCase: Signatur auf totalDailyKcal vereinfacht
SettingsViewModel/UiState: ageGroups statt householdSize + dailyKcalPerPerson
DashboardViewModel: combine(3 flows), AGE_GROUPS statt 4 separate Flows
SettingsScreen: Altersgruppen-Tabelle statt zwei TextFields
ImportExportRepositoryImpl: Markdown-Export zeigt Altersgruppen-Gesamtkcal

Tests: +8 neue Tests (Migration, Validierung, Fallbacks), alle bestehenden Tests aktualisiert

Closes #47
This commit is contained in:
Jens Reinemann 2026-05-16 13:35:27 +02:00
parent 1236d61543
commit 9cc15ffaad
13 changed files with 391 additions and 153 deletions

View file

@ -8,6 +8,8 @@ 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.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson
import de.krisenvorrat.app.domain.model.totalDailyKcal
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
@ -143,14 +145,15 @@ internal class ImportExportRepositoryImpl @Inject constructor(
}
val settingsMap = settings.associate { it.key to it.value }
val householdSize = settingsMap["household_size"]
val kcalPerDay = settingsMap["kcal_per_day"]
if (householdSize != null || kcalPerDay != null) {
sb.appendLine("## Einstellungen")
sb.appendLine()
if (householdSize != null) sb.appendLine("- **Haushaltsgröße:** $householdSize Personen")
if (kcalPerDay != null) sb.appendLine("- **kcal/Tag:** $kcalPerDay")
sb.appendLine()
val ageGroupsJson = settingsMap["age_groups"]
if (ageGroupsJson != null) {
val totalDailyKcal = parseAgeGroupsFromJson(ageGroupsJson).totalDailyKcal()
if (totalDailyKcal > 0) {
sb.appendLine("## Einstellungen")
sb.appendLine()
sb.appendLine("- **Haushalt kcal/Tag:** $totalDailyKcal")
sb.appendLine()
}
}
sb.toString().trimEnd() + "\n"

View file

@ -0,0 +1,50 @@
package de.krisenvorrat.app.domain.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
enum class AgeGroup(val label: String, val defaultKcal: Int) {
KLEINKIND("Kleinkind (13 J.)", 1000),
KIND_4_6("Kind (46 J.)", 1400),
KIND_7_9("Kind (79 J.)", 1700),
KIND_10_12("Kind (1012 J.)", 2000),
JUGENDLICH("Jugendlich (1317 J.)", 2400),
ERWACHSENER("Erwachsener", 2000)
}
@Serializable
data class AgeGroupEntry(
val ageGroup: AgeGroup,
val count: Int,
val kcalPerDay: Int
)
internal fun List<AgeGroupEntry>.totalDailyKcal(): Int = sumOf { it.count * it.kcalPerDay }
internal fun defaultAgeGroups(): List<AgeGroupEntry> = AgeGroup.entries.map { group ->
AgeGroupEntry(
ageGroup = group,
count = if (group == AgeGroup.ERWACHSENER) 2 else 0,
kcalPerDay = group.defaultKcal
)
}
private val ageGroupsJson = Json { ignoreUnknownKeys = true }
internal fun List<AgeGroupEntry>.toJson(): String = ageGroupsJson.encodeToString(
kotlinx.serialization.builtins.ListSerializer(AgeGroupEntry.serializer()),
this
)
internal fun parseAgeGroupsFromJson(json: String?): List<AgeGroupEntry> {
if (json.isNullOrBlank()) return defaultAgeGroups()
return try {
ageGroupsJson.decodeFromString(
kotlinx.serialization.builtins.ListSerializer(AgeGroupEntry.serializer()),
json
)
} catch (e: Exception) {
defaultAgeGroups()
}
}

View file

@ -0,0 +1,10 @@
package de.krisenvorrat.app.domain.model
internal object SettingsKeys {
const val HOUSEHOLD_SIZE = "household_size"
const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
const val AGE_GROUPS = "age_groups"
const val SERVER_URL = "server_url"
const val API_KEY = "api_key"
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
}

View file

@ -8,14 +8,14 @@ internal class CalculateSupplyRangeUseCase @Inject constructor() {
companion object {
const val DEFAULT_HOUSEHOLD_SIZE = 2
const val DEFAULT_DAILY_KCAL_PER_PERSON = 2000
const val DEFAULT_TOTAL_DAILY_KCAL = DEFAULT_HOUSEHOLD_SIZE * DEFAULT_DAILY_KCAL_PER_PERSON
}
operator fun invoke(
items: List<ItemEntity>,
householdSize: Int = DEFAULT_HOUSEHOLD_SIZE,
dailyKcalPerPerson: Int = DEFAULT_DAILY_KCAL_PER_PERSON
totalDailyKcal: Int = DEFAULT_TOTAL_DAILY_KCAL
): Double {
val dailyNeed = householdSize * dailyKcalPerPerson
val dailyNeed = totalDailyKcal
if (dailyNeed <= 0) return 0.0
val totalKcal = items.sumOf { item ->

View file

@ -3,7 +3,9 @@ package de.krisenvorrat.app.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.totalDailyKcal
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
@ -11,10 +13,10 @@ import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
import de.krisenvorrat.app.domain.usecase.GetExpiryWarningsUseCase
import de.krisenvorrat.app.domain.usecase.GetMinStockWarningsUseCase
import de.krisenvorrat.app.ui.settings.SettingsViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import de.krisenvorrat.app.domain.repository.CategoryRepository
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -40,18 +42,17 @@ internal class DashboardViewModel @Inject constructor(
combine(
itemRepository.getAll(),
categoryRepository.getAll(),
settingsRepository.observeValue(SettingsViewModel.KEY_HOUSEHOLD_SIZE),
settingsRepository.observeValue(SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON)
) { items, categories, householdSizeStr, dailyKcalStr ->
val householdSize = householdSizeStr?.toIntOrNull()
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE
val dailyKcal = dailyKcalStr?.toIntOrNull()
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON
settingsRepository.observeValue(SettingsKeys.AGE_GROUPS)
) { items, categories, ageGroupsJson ->
val ageGroups = parseAgeGroupsFromJson(ageGroupsJson)
val totalDailyKcal = ageGroups.totalDailyKcal().let {
if (it <= 0) CalculateSupplyRangeUseCase.DEFAULT_TOTAL_DAILY_KCAL else it
}
DashboardUiState(
categorySummaries = calculateCategorySummary(items, categories),
totalValue = calculateTotalValue(items),
supplyRangeDays = calculateSupplyRange(items, householdSize, dailyKcal),
supplyRangeDays = calculateSupplyRange(items, totalDailyKcal),
expiryWarnings = getExpiryWarnings(items),
minStockWarnings = getMinStockWarnings(items),
isLoading = false

View file

@ -39,6 +39,8 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.totalDailyKcal
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -105,25 +107,45 @@ internal fun SettingsScreen(
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = uiState.householdSize,
onValueChange = viewModel::onHouseholdSizeChanged,
label = { Text("Personenzahl") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
uiState.ageGroups.forEach { entry ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = entry.ageGroup.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = entry.count.toString(),
onValueChange = { viewModel.onAgeGroupCountChanged(entry.ageGroup, it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.width(56.dp)
)
Text("×", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = entry.kcalPerDay.toString(),
onValueChange = { viewModel.onAgeGroupKcalChanged(entry.ageGroup, it) },
label = { Text("kcal/Tag") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.width(104.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = uiState.dailyKcalPerPerson,
onValueChange = viewModel::onDailyKcalChanged,
label = { Text("kcal pro Tag pro Person") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
val totalKcal = uiState.ageGroups.totalDailyKcal()
if (totalKcal > 0) {
Text(
text = "Gesamt: $totalKcal kcal/Tag",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
}
Spacer(modifier = Modifier.height(16.dp))

View file

@ -1,10 +1,11 @@
package de.krisenvorrat.app.ui.settings
import android.net.Uri
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.defaultAgeGroups
internal data class SettingsUiState(
val householdSize: String = "",
val dailyKcalPerPerson: String = "",
val ageGroups: List<AgeGroupEntry> = defaultAgeGroups(),
val isLoading: Boolean = true,
val isSaved: Boolean = false,
val isExporting: Boolean = false,

View file

@ -7,10 +7,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.defaultAgeGroups
import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson
import de.krisenvorrat.app.domain.model.toJson
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -41,18 +46,14 @@ internal class SettingsViewModel @Inject constructor(
private fun loadSettings() {
viewModelScope.launch {
try {
val householdSize = settingsRepository.getValue(KEY_HOUSEHOLD_SIZE)
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString()
val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString()
val ageGroups = loadAgeGroupsWithMigration()
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
_uiState.update {
it.copy(
householdSize = householdSize,
dailyKcalPerPerson = dailyKcal,
ageGroups = ageGroups,
serverUrl = serverUrl,
apiKey = apiKey,
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
@ -62,8 +63,7 @@ internal class SettingsViewModel @Inject constructor(
} catch (e: Exception) {
_uiState.update {
it.copy(
householdSize = CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(),
dailyKcalPerPerson = CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(),
ageGroups = defaultAgeGroups(),
isLoading = false
)
}
@ -71,12 +71,48 @@ internal class SettingsViewModel @Inject constructor(
}
}
fun onHouseholdSizeChanged(value: String) {
_uiState.update { it.copy(householdSize = value, isSaved = false) }
private suspend fun loadAgeGroupsWithMigration(): List<AgeGroupEntry> {
val ageGroupsJsonValue = settingsRepository.getValue(KEY_AGE_GROUPS)
if (ageGroupsJsonValue != null) {
return parseAgeGroupsFromJson(ageGroupsJsonValue)
}
val legacyHouseholdSize = settingsRepository.getValue(KEY_HOUSEHOLD_SIZE)?.toIntOrNull()
if (legacyHouseholdSize != null) {
val legacyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)?.toIntOrNull()
?: AgeGroup.ERWACHSENER.defaultKcal
return defaultAgeGroups().map { entry ->
if (entry.ageGroup == AgeGroup.ERWACHSENER) {
entry.copy(count = legacyHouseholdSize, kcalPerDay = legacyKcal)
} else {
entry
}
}
}
return defaultAgeGroups()
}
fun onDailyKcalChanged(value: String) {
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) }
fun onAgeGroupCountChanged(ageGroup: AgeGroup, count: String) {
val parsed = count.toIntOrNull()?.takeIf { it >= 0 } ?: return
_uiState.update { state ->
state.copy(
ageGroups = state.ageGroups.map { entry ->
if (entry.ageGroup == ageGroup) entry.copy(count = parsed) else entry
},
isSaved = false
)
}
}
fun onAgeGroupKcalChanged(ageGroup: AgeGroup, kcal: String) {
val parsed = kcal.toIntOrNull()?.takeIf { it > 0 } ?: return
_uiState.update { state ->
state.copy(
ageGroups = state.ageGroups.map { entry ->
if (entry.ageGroup == ageGroup) entry.copy(kcalPerDay = parsed) else entry
},
isSaved = false
)
}
}
fun onServerUrlChanged(value: String) {
@ -90,16 +126,7 @@ internal class SettingsViewModel @Inject constructor(
fun saveSettings() {
viewModelScope.launch {
try {
val householdSize = _uiState.value.householdSize
val dailyKcal = _uiState.value.dailyKcalPerPerson
if (householdSize.toIntOrNull() != null && householdSize.toInt() > 0) {
settingsRepository.setValue(KEY_HOUSEHOLD_SIZE, householdSize)
}
if (dailyKcal.toIntOrNull() != null && dailyKcal.toInt() > 0) {
settingsRepository.setValue(KEY_DAILY_KCAL_PER_PERSON, dailyKcal)
}
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
@ -272,10 +299,11 @@ internal class SettingsViewModel @Inject constructor(
}
companion object {
const val KEY_HOUSEHOLD_SIZE = "household_size"
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
const val KEY_SERVER_URL = "server_url"
const val KEY_API_KEY = "api_key"
const val KEY_SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
val KEY_HOUSEHOLD_SIZE = SettingsKeys.HOUSEHOLD_SIZE
val KEY_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_API_KEY = SettingsKeys.API_KEY
val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
}
}

View file

@ -3,6 +3,9 @@ package de.krisenvorrat.app.data.export
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.toJson
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@ -188,9 +191,11 @@ class ImportExportRepositoryImplTest {
val categoryDao = FakeCategoryDao()
val settingsDao = FakeSettingsDao()
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test")))
val ageGroups = listOf(
AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 4, kcalPerDay = 2000)
)
settingsDao.upsertAll(listOf(
SettingsEntity(key = "household_size", value = "4"),
SettingsEntity(key = "kcal_per_day", value = "2000")
SettingsEntity(key = "age_groups", value = ageGroups.toJson())
))
val repository = buildRepository(categoryDao, settingsDao = settingsDao)
@ -199,8 +204,7 @@ class ImportExportRepositoryImplTest {
// Then
assertTrue(markdown.contains("## Einstellungen"))
assertTrue(markdown.contains("**Haushaltsgröße:** 4 Personen"))
assertTrue(markdown.contains("**kcal/Tag:** 2000"))
assertTrue(markdown.contains("**Haushalt kcal/Tag:** 8000"))
}
@Test

View file

@ -0,0 +1,62 @@
package de.krisenvorrat.app.domain.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AgeGroupTest {
@Test
fun test_totalDailyKcal_withMixedGroups_returnsSumOfCountTimesKcal() {
// Given
val entries = listOf(
AgeGroupEntry(ageGroup = AgeGroup.KLEINKIND, count = 1, kcalPerDay = 1000),
AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 2, kcalPerDay = 2000),
AgeGroupEntry(ageGroup = AgeGroup.JUGENDLICH, count = 0, kcalPerDay = 2400)
)
// When
val result = entries.totalDailyKcal()
// Then 1×1000 + 2×2000 + 0×2400 = 5000
assertEquals(5000, result)
}
@Test
fun test_parseAgeGroupsFromJson_withValidJson_returnsCorrectGroups() {
// Given
val entries = listOf(
AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 3, kcalPerDay = 2500)
)
val json = entries.toJson()
// When
val parsed = parseAgeGroupsFromJson(json)
// Then
assertEquals(1, parsed.size)
assertEquals(AgeGroup.ERWACHSENER, parsed.first().ageGroup)
assertEquals(3, parsed.first().count)
assertEquals(2500, parsed.first().kcalPerDay)
}
@Test
fun test_parseAgeGroupsFromJson_withNull_returnsDefaults() {
// Given / When
val result = parseAgeGroupsFromJson(null)
// Then
assertEquals(defaultAgeGroups(), result)
}
@Test
fun test_defaultAgeGroups_hasErwachsenerWithCount2() {
// Given / When
val defaults = defaultAgeGroups()
// Then
val erwachsener = defaults.first { it.ageGroup == AgeGroup.ERWACHSENER }
assertEquals(2, erwachsener.count)
assertTrue(defaults.filter { it.ageGroup != AgeGroup.ERWACHSENER }.all { it.count == 0 })
}
}

View file

@ -10,13 +10,13 @@ class CalculateSupplyRangeUseCaseTest {
@Test
fun test_invoke_withKgItems_returnsCorrectDays() {
// Given 2 kg Reis à 350 kcal/100g = 7000 kcal
// 2 Personen × 2000 kcal/Tag = 4000 kcal/Tag → 1.75 Tage
// 4000 kcal/Tag (2 × 2000) → 1.75 Tage
val items = listOf(
buildTestItem(id = "1", quantity = 2.0, unit = "kg", kcalPer100g = 350)
)
// When
val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000)
val result = useCase(items, totalDailyKcal = 4000)
// Then
assertEquals(1.75, result, 0.001)
@ -25,13 +25,13 @@ class CalculateSupplyRangeUseCaseTest {
@Test
fun test_invoke_withGramItems_returnsCorrectDays() {
// Given 500 g Nudeln à 360 kcal/100g = 1800 kcal
// 1 Person × 2000 kcal/Tag → 0.9 Tage
// 2000 kcal/Tag → 0.9 Tage
val items = listOf(
buildTestItem(id = "1", quantity = 500.0, unit = "g", kcalPer100g = 360)
)
// When
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000)
val result = useCase(items, totalDailyKcal = 2000)
// Then
assertEquals(0.9, result, 0.001)
@ -40,14 +40,14 @@ class CalculateSupplyRangeUseCaseTest {
@Test
fun test_invoke_withMultipleItems_sumsTotalKcal() {
// Given 1 kg Reis (350 kcal/100g = 3500 kcal) + 500 g Nudeln (360 kcal/100g = 1800 kcal) = 5300 kcal
// 2 Personen × 2000 kcal/Tag = 4000 kcal/Tag → 1.325 Tage
// 4000 kcal/Tag → 1.325 Tage
val items = listOf(
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350),
buildTestItem(id = "2", quantity = 500.0, unit = "g", kcalPer100g = 360)
)
// When
val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000)
val result = useCase(items, totalDailyKcal = 4000)
// Then
assertEquals(1.325, result, 0.001)
@ -56,7 +56,7 @@ class CalculateSupplyRangeUseCaseTest {
@Test
fun test_invoke_withEmptyList_returnsZero() {
// Given / When
val result = useCase(emptyList(), householdSize = 2, dailyKcalPerPerson = 2000)
val result = useCase(emptyList(), totalDailyKcal = 4000)
// Then
assertEquals(0.0, result, 0.001)
@ -70,8 +70,8 @@ class CalculateSupplyRangeUseCaseTest {
buildTestItem(id = "2", quantity = 1.0, unit = "kg", kcalPer100g = 200)
)
// When Nur 1 kg à 200 kcal/100g = 2000 kcal, 1 Person × 2000 = 1.0 Tage
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000)
// When Nur 1 kg à 200 kcal/100g = 2000 kcal, 2000 kcal/Tag = 1.0 Tage
val result = useCase(items, totalDailyKcal = 2000)
// Then
assertEquals(1.0, result, 0.001)
@ -85,30 +85,30 @@ class CalculateSupplyRangeUseCaseTest {
)
// When
val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000)
val result = useCase(items, totalDailyKcal = 4000)
// Then
assertEquals(0.0, result, 0.001)
}
@Test
fun test_invoke_withZeroHouseholdSize_returnsZero() {
fun test_invoke_withZeroTotalDailyKcal_returnsZero() {
// Given
val items = listOf(
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350)
)
// When
val result = useCase(items, householdSize = 0, dailyKcalPerPerson = 2000)
val result = useCase(items, totalDailyKcal = 0)
// Then
assertEquals(0.0, result, 0.001)
}
@Test
fun test_invoke_withDefaultParameters_uses2PersonsAnd2000Kcal() {
fun test_invoke_withDefaultParameters_uses4000KcalPerDay() {
// Given 4 kg Reis à 350 kcal/100g = 14000 kcal
// Default: 2 Personen × 2000 kcal = 4000 kcal/Tag → 3.5 Tage
// Default: 4000 kcal/Tag → 3.5 Tage
val items = listOf(
buildTestItem(id = "1", quantity = 4.0, unit = "kg", kcalPer100g = 350)
)
@ -123,13 +123,13 @@ class CalculateSupplyRangeUseCaseTest {
@Test
fun test_invoke_withMgUnit_convertsCorrectly() {
// Given 500000 mg = 500 g à 200 kcal/100g = 1000 kcal
// 1 Person × 1000 kcal/Tag → 1.0 Tag
// 1000 kcal/Tag → 1.0 Tag
val items = listOf(
buildTestItem(id = "1", quantity = 500000.0, unit = "mg", kcalPer100g = 200)
)
// When
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 1000)
val result = useCase(items, totalDailyKcal = 1000)
// Then
assertEquals(1.0, result, 0.001)
@ -138,7 +138,7 @@ class CalculateSupplyRangeUseCaseTest {
@Test
fun test_invoke_withMixedUnits_onlyCountsWeightBased() {
// Given 1 kg (350 kcal/100g) + 5 Stk (ignored) + 2 L (ignored)
// Total: 3500 kcal, 1 Person × 2000 → 1.75 Tage
// Total: 3500 kcal, 2000 kcal/Tag → 1.75 Tage
val items = listOf(
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350),
buildTestItem(id = "2", quantity = 5.0, unit = "Stk", kcalPer100g = 100),
@ -146,7 +146,7 @@ class CalculateSupplyRangeUseCaseTest {
)
// When
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000)
val result = useCase(items, totalDailyKcal = 2000)
// Then
assertEquals(1.75, result, 0.001)

View file

@ -3,9 +3,13 @@ package de.krisenvorrat.app.ui.dashboard
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.defaultAgeGroups
import de.krisenvorrat.app.domain.model.toJson
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.ui.settings.SettingsViewModel
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
@ -136,7 +140,15 @@ class DashboardViewModelTest {
@Test
fun test_init_withKcalItems_supplyRangeIsCalculated() = runTest(testDispatcher) {
// Given 1kg item with 200 kcal/100g = 2000 kcal total
// Default: 2 persons * 2000 kcal/day = 4000 kcal/day → 0.5 days
// AgeGroups: ERWACHSENER count=2, kcal=2000 → 4000 kcal/day → 0.5 days
val ageGroups = defaultAgeGroups().map { entry ->
if (entry.ageGroup == AgeGroup.ERWACHSENER) entry.copy(count = 2, kcalPerDay = 2000)
else entry
}
fakeSettingsRepository.setValue(
de.krisenvorrat.app.ui.settings.SettingsViewModel.KEY_AGE_GROUPS,
ageGroups.toJson()
)
fakeItemRepository.emit(
listOf(
buildTestItem(id = "a", quantity = 1000.0, unit = "g", kcalPer100g = 200)
@ -151,6 +163,28 @@ class DashboardViewModelTest {
assertEquals(0.5, viewModel.uiState.value.supplyRangeDays, 0.001)
}
@Test
fun test_init_withEmptyAgeGroups_usesDefaultTotalKcalForRange() = runTest(testDispatcher) {
// Given 1000g × 400 kcal/100g = 4000 kcal; alle AgeGroup-counts = 0
// → totalDailyKcal = 0 → Fallback auf DEFAULT_TOTAL_DAILY_KCAL = 4000
// → supplyRangeDays = 4000 / 4000 = 1.0
val allZeroAgeGroups = defaultAgeGroups().map { it.copy(count = 0) }
fakeSettingsRepository.setValue(
de.krisenvorrat.app.ui.settings.SettingsViewModel.KEY_AGE_GROUPS,
allZeroAgeGroups.toJson()
)
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", quantity = 1000.0, unit = "g", kcalPer100g = 400))
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
assertEquals(1.0, viewModel.uiState.value.supplyRangeDays, 0.001)
}
@Test
fun test_init_withExpiringItems_expiryWarningsArePresent() = runTest(testDispatcher) {
// Given item expiring in 3 months (within URGENT_MONTHS=6)

View file

@ -9,7 +9,10 @@ import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.defaultAgeGroups
import de.krisenvorrat.app.domain.model.toJson
import de.krisenvorrat.shared.model.InventoryDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -74,7 +77,7 @@ class SettingsViewModelTest {
)
@Test
fun test_init_withNoStoredValues_showsDefaults() = runTest(testDispatcher) {
fun test_init_withNoStoredValues_showsDefaultAgeGroups() = runTest(testDispatcher) {
// Given no stored settings
viewModel = createViewModel()
@ -84,68 +87,122 @@ class SettingsViewModelTest {
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertEquals(
CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(),
state.householdSize
)
assertEquals(
CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(),
state.dailyKcalPerPerson
)
assertEquals(defaultAgeGroups(), state.ageGroups)
}
@Test
fun test_init_withStoredValues_showsStoredValues() = runTest(testDispatcher) {
fun test_init_withStoredAgeGroups_showsStoredGroups() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "4"
val stored = defaultAgeGroups().map { entry ->
if (entry.ageGroup == AgeGroup.ERWACHSENER) entry.copy(count = 3, kcalPerDay = 2500)
else entry
}
fakeSettingsRepository.store[SettingsViewModel.KEY_AGE_GROUPS] = stored.toJson()
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER }
assertEquals(3, erwachsener.count)
assertEquals(2500, erwachsener.kcalPerDay)
}
@Test
fun test_init_withLegacyHouseholdSize_migratesErwachsener() = runTest(testDispatcher) {
// Given legacy keys present, no age_groups key
fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "3"
fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON] = "2500"
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals("4", state.householdSize)
assertEquals("2500", state.dailyKcalPerPerson)
val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER }
assertEquals(3, erwachsener.count)
assertEquals(2500, erwachsener.kcalPerDay)
}
@Test
fun test_onHouseholdSizeChanged_updatesState() = runTest(testDispatcher) {
fun test_init_withLegacyHouseholdSizeOnly_migratesWithDefaultKcal() = runTest(testDispatcher) {
// Given nur KEY_HOUSEHOLD_SIZE gesetzt, kein KEY_DAILY_KCAL_PER_PERSON
fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "3"
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER }
assertEquals(3, erwachsener.count)
assertEquals(AgeGroup.ERWACHSENER.defaultKcal, erwachsener.kcalPerDay)
}
@Test
fun test_onAgeGroupCountChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onHouseholdSizeChanged("5")
viewModel.onAgeGroupCountChanged(AgeGroup.KLEINKIND, "2")
// Then
assertEquals("5", viewModel.uiState.value.householdSize)
val kleinkind = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.KLEINKIND }
assertEquals(2, kleinkind.count)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_onDailyKcalChanged_updatesState() = runTest(testDispatcher) {
fun test_onAgeGroupKcalChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onDailyKcalChanged("1800")
viewModel.onAgeGroupKcalChanged(AgeGroup.ERWACHSENER, "2200")
// Then
assertEquals("1800", viewModel.uiState.value.dailyKcalPerPerson)
val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER }
assertEquals(2200, erwachsener.kcalPerDay)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_saveSettings_withValidValues_persistsAndShowsSaved() = runTest(testDispatcher) {
fun test_onAgeGroupCountChanged_withNegativeValue_doesNotUpdateState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
val initialGroups = viewModel.uiState.value.ageGroups
// When
viewModel.onAgeGroupCountChanged(AgeGroup.KLEINKIND, "-1")
// Then
assertEquals(initialGroups, viewModel.uiState.value.ageGroups)
}
@Test
fun test_onAgeGroupKcalChanged_withZeroValue_doesNotUpdateState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
val initialGroups = viewModel.uiState.value.ageGroups
// When
viewModel.onAgeGroupKcalChanged(AgeGroup.ERWACHSENER, "0")
// Then
assertEquals(initialGroups, viewModel.uiState.value.ageGroups)
}
@Test
fun test_saveSettings_persistsAgeGroupsJson() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onHouseholdSizeChanged("3")
viewModel.onDailyKcalChanged("2200")
// When
viewModel.saveSettings()
@ -153,40 +210,7 @@ class SettingsViewModelTest {
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertEquals("3", fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE])
assertEquals("2200", fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON])
}
@Test
fun test_saveSettings_withInvalidHouseholdSize_doesNotPersistInvalid() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onHouseholdSizeChanged("abc")
viewModel.onDailyKcalChanged("2200")
// When
viewModel.saveSettings()
advanceUntilIdle()
// Then household_size not stored, but dailyKcal is
assertFalse(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_HOUSEHOLD_SIZE))
assertEquals("2200", fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON])
}
@Test
fun test_saveSettings_withZeroHouseholdSize_doesNotPersist() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onHouseholdSizeChanged("0")
// When
viewModel.saveSettings()
advanceUntilIdle()
// Then zero is invalid, not persisted
assertFalse(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_HOUSEHOLD_SIZE))
assertTrue(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_AGE_GROUPS))
}
@Test
@ -507,7 +531,6 @@ class SettingsViewModelTest {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onHouseholdSizeChanged("2")
viewModel.onServerUrlChanged("https://myserver.com")
viewModel.onApiKeyChanged("secret-key-123")