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