From 557af7e00a468a44f2b85964db580a98f2779f97 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 08:34:22 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../kvitt/fakes/FakeExpenseRepository.kt | 1 + .../kvitt/fakes/FakeSettingsRepositories.kt | 12 +- .../se/hagstedt/kvitt/fakes/FakeStore.kt | 29 +++ .../hagstedt/kvitt/fakes/EmptyScenarioTest.kt | 6 + .../kvitt/fakes/FakeEditorCategoriesTest.kt | 88 +++++++++ .../se/hagstedt/kvitt/model/Expenses.kt | 17 +- .../se/hagstedt/kvitt/model/Settings.kt | 7 + .../kotlin/se/hagstedt/kvitt/data/db/Daos.kt | 32 ++++ .../kvitt/data/repo/ExpenseRepositoryImpl.kt | 19 +- .../kvitt/data/repo/SettingsRepositories.kt | 3 + .../kvitt/data/repo/EditorCategoriesTest.kt | 177 ++++++++++++++++++ 11 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/EditorCategoriesTest.kt diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt index 5207636..b7c7b9a 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt @@ -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() diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt index 47a2adf..2a45184 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt @@ -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 = 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 = 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") diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt index 548094d..79490ff 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt @@ -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): Map> { + 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 { 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 + } +} diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt index 8d6a01c..8fce0c4 100644 --- a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt @@ -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()) diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt new file mode 100644 index 0000000..3a9bb45 --- /dev/null +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt @@ -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 = fakes.expenses.editorOptions().first().frequentCategoryIds.getValue(account) + + @Test + fun mostUsedFirst_tiesBySortOrder_atMostFive_perAccount() = runTest { + val vardag = account("Vardag") + val hemma = account("Hemma") + assertEquals(emptyList(), 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()) + } +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt index 908af79..294c802 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt @@ -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?, val exactOre: Map?, val fullOn: UserId?) -data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List, val categories: List, val defaultAccountId: AccountId?, val defaultCategoryFor: Map, 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, + val categories: List, + val defaultAccountId: AccountId?, + val defaultCategoryFor: Map, + val today: String, + val frequentCategoryIds: Map>, +) data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List, val pendingLocal: Boolean) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt index 00dc6d3..6fe1360 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt @@ -18,6 +18,13 @@ interface CategoryRepository { suspend fun reorder(idsInOrder: List): OnlineResult suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult suspend fun delete(id: CategoryId): OnlineResult + + /** + * 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 } data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt index 4fed584..1fa4fb6 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt @@ -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 + @Upsert suspend fun upsert(category: CategoryEntity) } @@ -103,6 +117,21 @@ interface ExpenseDao { @Query("SELECT COUNT(*) FROM expenses WHERE pending = 1") fun observePendingCount(): Flow + /** + * 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> + @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) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt index 0c3ba7f..3cd45b7 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt @@ -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 = combine(local.snapshot, prefs.editorDefaults) { s, defaults -> + override fun editorOptions(): Flow = 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, activeCategories: Set, uses: List): Map> { + 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) + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt index a12811f..d52a860 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt @@ -91,6 +91,7 @@ class CategoryRepositoryImpl( clock: Clock, ) : CategoryRepository { private val rows = LocalRows(db, clock) + private val categories = db.categories() override fun all(): Flow> = local.snapshot.map { it.categories }.distinctUntilChanged() @@ -121,6 +122,8 @@ class CategoryRepositoryImpl( override suspend fun delete(id: CategoryId): OnlineResult = gate.run({ api.deleteCategory(id.value) }) { rows.writeCategoryIfNewer(it) } + override fun deletable(id: CategoryId): Flow = 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 diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/EditorCategoriesTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/EditorCategoriesTest.kt new file mode 100644 index 0000000..ac968d8 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/EditorCategoriesTest.kt @@ -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) { + 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(), 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) } + } +}