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())
}
}
@@ -6,7 +6,22 @@ import kotlinx.coroutines.flow.Flow
data class ExpenseDraft(val id: ExpenseId?, val accountId: AccountId?, val categoryId: CategoryId?, val amountOre: Long?, val description: String, val notes: String, val date: String, val payer: UserId, val mode: SplitMode, val ratioBp: Map<UserId, Int>?, val exactOre: Map<UserId, Long>?, val fullOn: UserId?)
data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List<Account>, val categories: List<Category>, val defaultAccountId: AccountId?, val defaultCategoryFor: Map<AccountId, CategoryId>, val today: String)
/**
* [frequentCategoryIds] (UX-9, D-059): for each active account, up to five active categories ordered by the number of
* non-deleted expenses in that account that use them (every expense on the phone: both users, pending, imported and
* recurring included), descending; ties by `sort_order` ascending. Categories with no use in the account are left out,
* so a list may be shorter or empty; the editor fills its row up from `sort_order` (design rule, not contract).
*/
data class ExpenseEditorOptions(
val me: User,
val partner: User,
val accounts: List<Account>,
val categories: List<Category>,
val defaultAccountId: AccountId?,
val defaultCategoryFor: Map<AccountId, CategoryId>,
val today: String,
val frequentCategoryIds: Map<AccountId, List<CategoryId>>,
)
data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List<FieldChange>, val pendingLocal: Boolean)
@@ -18,6 +18,13 @@ interface CategoryRepository {
suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit>
suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit>
suspend fun delete(id: CategoryId): OnlineResult<Unit>
/**
* UX-9 (AC-7.6): false for a default category (`defaultKey != null`), one used by a non-deleted expense or a
* non-deleted recurring template, and an unknown or deleted id. Computed from the phone's copy; the server stays the
* authority, so [delete] can still fail with `category_is_default` or `category_in_use`.
*/
fun deletable(id: CategoryId): Flow<Boolean>
}
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
@@ -54,6 +54,20 @@ interface CategoryDao {
@Query("SELECT * FROM categories WHERE id = :id")
suspend fun get(id: String): CategoryEntity?
/**
* UX-9 (AC-7.6): true when the category exists, is not deleted, is not a default and no non-deleted expense or
* non-deleted recurring template uses it; null for an unknown id. Re-emits on every write to the three tables.
*/
@Query(
"""
SELECT c.default_key IS NULL AND c.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM expenses e WHERE e.category_id = c.id AND e.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM recurring_templates t WHERE t.category_id = c.id AND t.deleted_at IS NULL)
FROM categories c WHERE c.id = :id
""",
)
fun observeDeletable(id: String): Flow<Boolean?>
@Upsert
suspend fun upsert(category: CategoryEntity)
}
@@ -103,6 +117,21 @@ interface ExpenseDao {
@Query("SELECT COUNT(*) FROM expenses WHERE pending = 1")
fun observePendingCount(): Flow<Int>
/**
* UX-9: per account, the active categories (not archived, not deleted) by the number of non-deleted expenses in that
* account that use them (pending rows included), most used first, ties by `sort_order`, then id for a stable order.
*/
@Query(
"""
SELECT e.account_id AS accountId, e.category_id AS categoryId, COUNT(*) AS uses
FROM expenses e JOIN categories c ON c.id = e.category_id
WHERE e.deleted_at IS NULL AND c.deleted_at IS NULL AND c.archived_at IS NULL
GROUP BY e.account_id, e.category_id
ORDER BY e.account_id, uses DESC, c.sort_order, c.id
""",
)
fun observeCategoryUse(): Flow<List<CategoryUse>>
@Upsert
suspend fun upsert(expense: ExpenseEntity)
@@ -206,3 +235,6 @@ interface RejectionDao {
@Query("UPDATE sync_rejections SET dismissed = 1 WHERE mutation_id = :mutationId")
suspend fun dismiss(mutationId: String)
}
/** One row of [ExpenseDao.observeCategoryUse]: how many non-deleted expenses of [accountId] use [categoryId]. */
data class CategoryUse(val accountId: String, val categoryId: String, val uses: Int)
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import se.hagstedt.kvitt.data.db.CategoryUse
import se.hagstedt.kvitt.data.db.ExpenseEntity
import se.hagstedt.kvitt.data.db.ExpenseSplitEntity
import se.hagstedt.kvitt.data.db.KvittDatabase
@@ -50,7 +51,7 @@ class ExpenseRepositoryImpl(
) : ExpenseRepository {
private val timelines = Timelines(db, local)
override fun editorOptions(): Flow<ExpenseEditorOptions> = combine(local.snapshot, prefs.editorDefaults) { s, defaults ->
override fun editorOptions(): Flow<ExpenseEditorOptions> = combine(local.snapshot, prefs.editorDefaults, db.expenses().observeCategoryUse()) { s, defaults, uses ->
val me = s.me ?: return@combine null
val partner = s.partner ?: return@combine null
val active = s.accounts.filter { !it.archived }
@@ -69,6 +70,7 @@ class ExpenseRepositoryImpl(
(last ?: other?.id)?.let { account.id to it }
}.toMap(),
today = clock.today(),
frequentCategoryIds = frequentCategoryIds(active.map { it.id }, categories.map { it.id }.toSet(), uses),
)
}.filterNotNull().distinctUntilChanged()
@@ -240,3 +242,18 @@ internal fun validDate(date: String, clock: Clock): Boolean {
}
private const val MAX_DAYS_AHEAD = 365
/** UX-9: at most this many entries per account in [ExpenseEditorOptions.frequentCategoryIds]. */
private const val FREQUENT_CATEGORIES = 5
/**
* UX-9: [uses] comes ordered from Room (per account, most used first, ties by `sort_order`); every active account gets
* an entry, possibly empty. Only ids in [activeCategories] are kept, so the list never names a category the same
* options do not offer, even while the two Room flows are one write apart.
*/
internal fun frequentCategoryIds(activeAccounts: List<AccountId>, activeCategories: Set<CategoryId>, uses: List<CategoryUse>): Map<AccountId, List<CategoryId>> {
val byAccount = uses.groupBy { AccountId(it.accountId) }
return activeAccounts.associateWith { account ->
byAccount[account].orEmpty().map { CategoryId(it.categoryId) }.filter { it in activeCategories }.take(FREQUENT_CATEGORIES)
}
}
@@ -91,6 +91,7 @@ class CategoryRepositoryImpl(
clock: Clock,
) : CategoryRepository {
private val rows = LocalRows(db, clock)
private val categories = db.categories()
override fun all(): Flow<List<Category>> = local.snapshot.map { it.categories }.distinctUntilChanged()
@@ -121,6 +122,8 @@ class CategoryRepositoryImpl(
override suspend fun delete(id: CategoryId): OnlineResult<Unit> = gate.run({ api.deleteCategory(id.value) }) { rows.writeCategoryIfNewer(it) }
override fun deletable(id: CategoryId): Flow<Boolean> = categories.observeDeletable(id.value).map { it == true }.distinctUntilChanged()
private companion object {
/** The step between orders, as AC-7.2 and AC-7.3 use. */
const val SORT_STEP = 10
@@ -0,0 +1,177 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.FakeServer
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
import se.hagstedt.kvitt.data.Harness.Companion.ME
import se.hagstedt.kvitt.data.Harness.Companion.OTHER
import se.hagstedt.kvitt.data.Harness.Companion.PARTNER
import se.hagstedt.kvitt.data.db.CategoryEntity
import se.hagstedt.kvitt.data.db.ExpenseEntity
import se.hagstedt.kvitt.data.db.RecurringTemplateEntity
import se.hagstedt.kvitt.data.draft
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.data.savedId
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseEditorOptions
/** UX-9 in `:data`: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable` from Room, reactive. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class EditorCategoriesTest {
private lateinit var h: Harness
private var serial = 0
@Before
fun setUp() {
h = Harness().ready()
h.server.addAccount(HEMMA, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-01-02T00:00:00.000Z")
h.server.addAccount(EMPTY, "Resor", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-01-03T00:00:00.000Z")
h.server.addAccount(ARCHIVED, "Gammalt", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-01-04T00:00:00.000Z")
h.server.accounts[ARCHIVED] = h.server.accounts.getValue(ARCHIVED).copy(archivedAt = FakeServer.T0)
runSync {
h.engine.sync()
h.local.snapshot.first { it.accounts.size == 4 }
// Custom categories whose ids sort opposite to their `sort_order`, so a tie shows which one decides.
CUSTOM.forEachIndexed { i, id -> category(id, sortOrder = 30 + 10 * i) }
}
}
@After
fun tearDown() = h.close()
private fun categories() = CategoryRepositoryImpl(h.local, h.api, h.gate, h.db, h.clock)
private suspend fun category(id: String, sortOrder: Int, key: String? = null, archived: Boolean = false, deleted: Boolean = false) {
h.db.categories().upsert(
CategoryEntity(id, key, if (key == null) "Egen $sortOrder" else null, "category", "grey", sortOrder, T, T, if (archived) T else null, if (deleted) T else null, 1, 0),
)
}
/** A synced (or, with [pending], a not yet pushed) expense row in Room. */
private suspend fun expense(category: String, account: String = ACCOUNT, deleted: Boolean = false, pending: Boolean = false): String {
val id = "0192c000-0000-7000-8000-" + (++serial).toString(16).padStart(12, '0')
h.db.expenses().upsert(
ExpenseEntity(
id, account, category, 10_000, "Köp", "", "2026-10-10", PARTNER, "default", null, null, PARTNER, PARTNER, T, T,
if (deleted) T else null, if (pending) null else 1, pending, if (pending) id else null, 0,
),
)
return id
}
private suspend fun deleteExpense(id: String) {
val row = h.db.expenses().get(id) ?: throw AssertionError("no expense $id")
h.db.expenses().upsert(row.copy(deletedAt = T))
}
private suspend fun template(category: String, deleted: Boolean = false) {
h.db.templates().upsert(
RecurringTemplateEntity(
"0192d000-0000-7000-8000-000000000001", ACCOUNT, category, 10_000, "Hyra", "", ME, "default", 25, "2026-01", null, false, null,
ME, ME, T, T, if (deleted) T else null, 1, null, 0,
),
)
}
/** The options once [predicate] holds: the flow is reactive, so a write shows without a new subscription. */
private suspend fun options(predicate: (ExpenseEditorOptions) -> Boolean): ExpenseEditorOptions = h.expenseRepo().editorOptions().first(predicate)
private suspend fun frequentOnce(account: String, expected: List<String>) {
val got = options { it.frequentCategoryIds[AccountId(account)] == expected.map(::CategoryId) }
assertEquals(expected.map(::CategoryId), got.frequentCategoryIds.getValue(AccountId(account)))
}
@Test
fun mostUsedFirst_tiesBySortOrder_atMostFive_perActiveAccount() = runSync {
repeat(3) { expense(CUSTOM[5]) }
repeat(2) { expense(GROCERIES) }
// One use each: sort_order decides (30, 40, 60, 70), not the id (CUSTOM ids descend).
listOf(CUSTOM[3], CUSTOM[0], CUSTOM[4], CUSTOM[1]).forEach { expense(it) }
expense(OTHER, account = HEMMA)
expense(OTHER, account = ARCHIVED)
val options = options { it.frequentCategoryIds[AccountId(ACCOUNT)]?.size == 5 && it.frequentCategoryIds[AccountId(HEMMA)]?.isNotEmpty() == true }
assertEquals(listOf(CUSTOM[5], GROCERIES, CUSTOM[0], CUSTOM[1], CUSTOM[3]).map(::CategoryId), options.frequentCategoryIds.getValue(AccountId(ACCOUNT)))
// Fewer than five used: only those (the editor fills its row from sort_order); none used: an empty list.
assertEquals(listOf(CategoryId(OTHER)), options.frequentCategoryIds.getValue(AccountId(HEMMA)))
assertEquals(emptyList<CategoryId>(), options.frequentCategoryIds.getValue(AccountId(EMPTY)))
// An archived account is not offered, so it has no entry.
assertEquals(setOf(ACCOUNT, HEMMA, EMPTY).map(::AccountId).toSet(), options.frequentCategoryIds.keys)
}
@Test
fun pendingCounts_deletedAndArchivedAreLeftOut_andTheListFollowsWrites() = runSync {
expense(CUSTOM[0])
expense(CUSTOM[1], pending = true)
expense(CUSTOM[1], pending = true)
val gone = expense(CUSTOM[2])
expense(CUSTOM[2])
expense(CUSTOM[2])
expense(CUSTOM[3], deleted = true)
frequentOnce(ACCOUNT, listOf(CUSTOM[2], CUSTOM[1], CUSTOM[0]))
// Deleting one CUSTOM[2] expense makes it a tie with CUSTOM[1] (2 each); sort_order puts CUSTOM[1] first.
deleteExpense(gone)
frequentOnce(ACCOUNT, listOf(CUSTOM[1], CUSTOM[2], CUSTOM[0]))
category(CUSTOM[1], sortOrder = 40, archived = true)
frequentOnce(ACCOUNT, listOf(CUSTOM[2], CUSTOM[0]))
category(CUSTOM[1], sortOrder = 40)
frequentOnce(ACCOUNT, listOf(CUSTOM[1], CUSTOM[2], CUSTOM[0]))
category(CUSTOM[0], sortOrder = 30, deleted = true)
frequentOnce(ACCOUNT, listOf(CUSTOM[1], CUSTOM[2]))
// A local save through the repository is pending and counts at once.
repeat(3) { h.expenseRepo().save(draft("Willys").copy(categoryId = CategoryId(GROCERIES))).savedId() }
frequentOnce(ACCOUNT, listOf(GROCERIES, CUSTOM[1], CUSTOM[2]))
}
@Test
fun deletable_falseForDefaults_unknownAndDeleted() = runSync {
val repo = categories()
assertFalse(repo.deletable(CategoryId(GROCERIES)).first())
assertFalse(repo.deletable(CategoryId(OTHER)).first())
assertFalse(repo.deletable(CategoryId("0192ffff-0000-7000-8000-999999999999")).first())
assertTrue(repo.deletable(CategoryId(CUSTOM[0])).first())
category(CUSTOM[0], sortOrder = 30, deleted = true)
assertFalse(repo.deletable(CategoryId(CUSTOM[0])).first { !it })
}
@Test
fun deletable_followsExpensesAndTemplates_archivedStillCounts() = runSync {
val flow = categories().deletable(CategoryId(CUSTOM[1]))
assertTrue(flow.first())
val e = expense(CUSTOM[1], pending = true)
assertFalse(flow.first { !it })
deleteExpense(e)
assertTrue(flow.first { it })
expense(CUSTOM[1], deleted = true)
assertTrue(flow.first())
template(CUSTOM[1])
assertFalse(flow.first { !it })
template(CUSTOM[1], deleted = true)
assertTrue(flow.first { it })
// Archived and in use: still not deletable (AC-7.6 counts every non-deleted expense, archived or not).
category(CUSTOM[1], sortOrder = 40, archived = true)
expense(CUSTOM[1], account = HEMMA)
assertFalse(flow.first { !it })
}
private companion object {
const val T = "2026-10-01T00:00:00.000Z"
const val HEMMA = "0192a003-0000-7000-8000-000000000002"
const val EMPTY = "0192a003-0000-7000-8000-000000000003"
const val ARCHIVED = "0192a003-0000-7000-8000-000000000004"
val CUSTOM = (0..5).map { "0192e000-0000-7000-8000-0000000000" + (0x9f - it).toString(16) }
}
}