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:
@@ -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()
|
||||
|
||||
|
||||
+6
-6
@@ -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())
|
||||
|
||||
+88
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user