M12d: frequentCategoryIds and CategoryRepository.deletable (UX-9, D-059)

ExpenseEditorOptions.frequentCategoryIds per active account (up to five active
categories by non-deleted expense count in the account, ties by sort_order) and
CategoryRepository.deletable(id) (AC-7.6), from Room queries in :data and the
same rule in :core:fakes. Room schema unchanged (version 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:34:22 +02:00
parent e6768121d6
commit 557af7e00a
11 changed files with 383 additions and 8 deletions
@@ -40,6 +40,7 @@ class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
(last ?: other?.id)?.let { account.id to it }
}.toMap(),
today = s.today,
frequentCategoryIds = s.frequentCategoryIds(active),
)
}.filterNotNull()
@@ -1,6 +1,7 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
@@ -98,18 +99,17 @@ class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository
}
override suspend fun delete(id: CategoryId): OnlineResult<Unit> = store.online { s ->
val category = s.category(id) ?: return@online OnlineResult.Failed("not_found", null)
when {
category.defaultKey != null -> OnlineResult.Failed("category_is_default", null) // AC-7.6
s.expenses.any { !it.deleted && it.categoryId == id } || s.templates.any { it.draft.categoryId == id } ->
OnlineResult.Failed("category_in_use", null)
else -> {
when (val block = s.categoryDeleteBlock(id)) { // AC-7.6
null -> {
store.edit { it.copy(categories = it.categories.filter { c -> c.id != id }) }
OnlineResult.Ok(Unit)
}
else -> OnlineResult.Failed(block, null)
}
}
override fun deletable(id: CategoryId): Flow<Boolean> = store.derive { s -> s.categoryDeleteBlock(id) == null }.distinctUntilChanged()
/** §9.6 and §9.7. A null name (reset of a default name) is valid. */
private fun validateCategory(name: String?, icon: String): OnlineResult.Failed? = when {
name != null && name.codePointLength() !in 1..40 -> OnlineResult.Failed("validation_failed", "name")
@@ -123,3 +123,32 @@ internal fun FakeScenario.validDate(date: String): Boolean {
val parsed = runCatching { LocalDate.parse(date) }.getOrNull() ?: return false
return parsed >= LocalDate(2000, 1, 1) && parsed <= LocalDate.parse(today).plus(365, DateTimeUnit.DAY)
}
/** UX-9: at most this many entries per account in `ExpenseEditorOptions.frequentCategoryIds`. */
private const val FREQUENT_CATEGORIES = 5
/**
* UX-9: per active account, the active categories by the number of non-deleted expenses in that account that use them
* (pending included), most used first, ties by `sort_order`, then id; at most five; unused categories left out.
*/
internal fun FakeScenario.frequentCategoryIds(activeAccounts: List<Account>): Map<AccountId, List<CategoryId>> {
val active = categories.filter { !it.archived }.associateBy { it.id }
val live = expenses.filter { !it.deleted }
return activeAccounts.associate { account ->
val counts = live.filter { it.accountId == account.id }.groupingBy { it.categoryId }.eachCount()
account.id to counts.keys.mapNotNull { active[it] }
.sortedWith(compareByDescending<Category> { counts[it.id] ?: 0 }.thenBy { it.sortOrder }.thenBy { it.id.value })
.take(FREQUENT_CATEGORIES)
.map { it.id }
}
}
/** Why AC-7.6 refuses deleting [id], as the E-27 error token, or null when it may be deleted. */
internal fun FakeScenario.categoryDeleteBlock(id: CategoryId): String? {
val category = category(id) ?: return "not_found"
return when {
category.defaultKey != null -> "category_is_default"
expenses.any { !it.deleted && it.categoryId == id } || templates.any { it.draft.categoryId == id } -> "category_in_use"
else -> null
}
}
@@ -3,11 +3,13 @@ package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
@@ -47,6 +49,7 @@ class EmptyScenarioTest {
val options = fakes.expenses.editorOptions().first()
assertNull(options.defaultAccountId)
assertEquals(14, options.categories.size)
assertTrue(options.frequentCategoryIds.isEmpty()) // UX-9: no active account, no entry
val settle = fakes.settlements.start(null, null)
assertNull(settle.amountOre)
assertTrue(settle.lines.isEmpty())
@@ -64,6 +67,9 @@ class EmptyScenarioTest {
@Test
fun settingsRepositoriesAnswer() = runTest {
assertEquals(14, fakes.categories.all().first().size)
// UX-9 (AC-7.6): the fourteen defaults are never deletable; an unknown id is not either.
assertTrue(FakeScenarios.defaultCategories.none { fakes.categories.deletable(it.id).first() })
assertFalse(fakes.categories.deletable(CategoryId("none")).first())
assertTrue(fakes.recurring.all().first().isEmpty())
assertEquals(1, (fakes.devices.list() as OnlineResult.Ok).value.size)
assertTrue((fakes.invites.list() as OnlineResult.Ok).value.isEmpty())
@@ -0,0 +1,88 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
/** UX-9 in the fakes: `frequentCategoryIds` and `deletable` follow the same rule as `:data`. */
class FakeEditorCategoriesTest {
private val fakes = FakeRepositories()
private val shares = mapOf(FakeScenarios.owner.id to 5000, FakeScenarios.partner.id to 5000)
private fun id(key: DefaultCategoryKey): CategoryId = FakeScenarios.defaultCategories.single { it.defaultKey == key }.id
private suspend fun account(name: String): AccountId = (fakes.accounts.create(name, shares) as OnlineResult.Ok).value
private suspend fun expense(account: AccountId, category: CategoryId): ExpenseId {
val draft = ExpenseDraft(null, account, category, 10_000, "Köp", "", FakeScenarios.empty.today, FakeScenarios.owner.id, SplitMode.DEFAULT, null, null, null)
return (fakes.expenses.save(draft) as SaveResult.Saved).id
}
private suspend fun frequent(account: AccountId): List<CategoryId> = fakes.expenses.editorOptions().first().frequentCategoryIds.getValue(account)
@Test
fun mostUsedFirst_tiesBySortOrder_atMostFive_perAccount() = runTest {
val vardag = account("Vardag")
val hemma = account("Hemma")
assertEquals(emptyList<CategoryId>(), frequent(vardag))
repeat(3) { expense(vardag, id(DefaultCategoryKey.OTHER)) }
repeat(2) { expense(vardag, id(DefaultCategoryKey.TRAVEL)) }
// Ties of one use each: `sort_order` decides (groceries 10 < restaurants 20 < housing 25 < home 30), not the id.
listOf(DefaultCategoryKey.HOME, DefaultCategoryKey.HOUSING, DefaultCategoryKey.RESTAURANTS, DefaultCategoryKey.GROCERIES)
.forEach { expense(vardag, id(it)) }
expense(hemma, id(DefaultCategoryKey.FEES))
assertEquals(
listOf(DefaultCategoryKey.OTHER, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.GROCERIES, DefaultCategoryKey.RESTAURANTS, DefaultCategoryKey.HOUSING).map(::id),
frequent(vardag),
)
assertEquals(listOf(id(DefaultCategoryKey.FEES)), frequent(hemma))
}
@Test
fun archivedCategoriesAndDeletedExpensesAreLeftOut() = runTest {
val vardag = account("Vardag")
expense(vardag, id(DefaultCategoryKey.TRAVEL))
val deleted = expense(vardag, id(DefaultCategoryKey.HEALTH))
expense(vardag, id(DefaultCategoryKey.FEES))
fakes.expenses.delete(deleted)
fakes.categories.setArchived(id(DefaultCategoryKey.FEES), true)
assertEquals(listOf(id(DefaultCategoryKey.TRAVEL)), frequent(vardag))
}
@Test
fun deletable_customUnusedOnly_andFollowsExpensesAndTemplates() = runTest {
val vardag = account("Vardag")
val pant = (fakes.categories.create("Pant", "category", ColorToken.TEAL) as OnlineResult.Ok).value
assertTrue(fakes.categories.deletable(pant).first())
val e = expense(vardag, pant)
assertFalse(fakes.categories.deletable(pant).first())
fakes.expenses.delete(e)
assertTrue(fakes.categories.deletable(pant).first())
val template = RecurringTemplate(
TemplateId("t"),
ExpenseDraft(null, vardag, pant, 10_000, "Pant", "", FakeScenarios.empty.today, FakeScenarios.owner.id, SplitMode.DEFAULT, null, null, null),
1, "2026-10", null, paused = false, nextOccurrence = null,
)
val created = (fakes.recurring.create(template) as OnlineResult.Ok).value
assertFalse(fakes.categories.deletable(pant).first())
fakes.recurring.delete(created)
assertTrue(fakes.categories.deletable(pant).first())
assertEquals(OnlineResult.Ok(Unit), fakes.categories.delete(pant))
assertFalse(fakes.categories.deletable(pant).first())
assertFalse(fakes.categories.deletable(id(DefaultCategoryKey.OTHER)).first())
}
}