M2b: Android skeleton, :core:model contract, :domain passing all vectors

Gradle project in android/ with every module of SPEC §6.2 (build-logic convention plugins, version catalog,
architecture check in check), :core:model types and repository interfaces of §14.2.4 and §14.5, :domain
implementing §14.5.2 and §14.5.4 (125 vector cases), scenario-driven fakes with the empty scenario, Room 3
entities and KvittDatabase v1 with exported schema, :app shell with Hilt, en/sv strings with MissingTranslation
as a lint error, Roborazzi goldens of the empty screen in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
// :core:fakes — scenario-driven fakes of every repository interface of SPEC §14.2.4. Only for tests, screenshot tests
// and debug previews (the architecture check rejects it in release configurations). Computed values come from :domain.
// M7x add their scenarios under src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/ and nowhere else (SPEC §11.3).
plugins {
id("kvitt.android.library")
}
dependencies {
api(project(":core:model"))
implementation(project(":domain"))
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,107 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.domain.resolveRange
import se.hagstedt.kvitt.domain.spendSeries
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.AnalyticsState
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DrillDownState
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.GroupKey
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SpendBucket
import se.hagstedt.kvitt.model.analytics.AnalyticsExpense
import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity
import se.hagstedt.kvitt.model.analytics.AnalyticsGroupBy
import se.hagstedt.kvitt.model.analytics.AnalyticsMeasure
import se.hagstedt.kvitt.model.analytics.DateRange
/** FR-14 from the scenario's non-deleted expenses through `resolveRange` and `spendSeries` (§14.5.4). */
class FakeAnalyticsRepository(private val store: FakeStore) : AnalyticsRepository {
override fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState> = store.derive { s ->
val range = s.range(filter)
val expenses = s.analyticsExpenses()
val measure = s.measure(filter.measure)
val granularity = if (filter.granularity == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK
val chosen = if (filter.groupBy == GroupBy.CATEGORY) AnalyticsGroupBy.CATEGORY else AnalyticsGroupBy.ACCOUNT
val series = spendSeries(expenses, range, granularity, measure, chosen)
val byCategory = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.CATEGORY).groupTotals
val byAccount = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.ACCOUNT).groupTotals
fun ranked(totals: Map<String, Long>, groupBy: GroupBy) =
totals.entries.sortedByDescending { it.value }.map { s.groupKey(it.key, groupBy) to it.value }
AnalyticsState(
filter = filter,
start = range.start.toString(),
end = range.endInclusive.toString(),
groups = ranked(series.groupTotals, filter.groupBy).map { it.first },
buckets = series.buckets.map { SpendBucket(it.key, it.start.toString(), it.endInclusive.toString(), it.totals, it.total) },
byCategory = ranked(byCategory, GroupBy.CATEGORY),
byAccount = ranked(byAccount, GroupBy.ACCOUNT),
totalOre = series.grandTotal,
)
}
override fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState> =
store.derive { s ->
val range = s.range(filter)
val granularity = if (filter.granularity == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK
val bucket = bucketKey?.let { key ->
spendSeries(emptyList(), range, granularity, AnalyticsMeasure.Household, AnalyticsGroupBy.CATEGORY).buckets.firstOrNull { it.key == key }
}
val start = bucket?.start ?: range.start
val end = bucket?.endInclusive ?: range.endInclusive
val matching = s.expenses.filter { e ->
val date = LocalDate.parse(e.date)
!e.deleted && date >= start && date <= end &&
(categoryId == null || e.categoryId == categoryId) && (accountId == null || e.accountId == accountId)
}
val measured = matching.sumOf { e ->
if (filter.measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == s.me.id }?.shareOre ?: 0L
}
// TODO(spec): DrillDownState.title is a String but S-12's title ("Mat & hushåll, oktober 2026") needs the
// UI's localised names and month format; the fakes join the raw parts (group label or key, bucket key).
val title = listOfNotNull(
categoryId?.let { id -> s.category(id)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } },
accountId?.let { id -> s.account(id)?.name },
bucketKey,
).joinToString(", ")
DrillDownState(
title = title,
totalOre = measured,
rows = matching.sortedWith { a, b -> ledgerOrder.compare(a.date to a.createdAt, b.date to b.createdAt) }
.mapNotNull { s.expenseRow(it) },
)
}
}
private fun FakeScenario.analyticsExpenses(): List<AnalyticsExpense> = expenses.filter { !it.deleted }.map { e ->
AnalyticsExpense(LocalDate.parse(e.date), e.accountId.value, e.categoryId.value, e.amountOre, e.shares.associate { it.userId.value to it.shareOre })
}
private fun FakeScenario.measure(measure: Measure): AnalyticsMeasure =
if (measure == Measure.HOUSEHOLD) AnalyticsMeasure.Household else AnalyticsMeasure.Share(me.id.value)
/**
* AC-14.2 via `resolveRange`. The UI only sends CUSTOM with both dates; the real repository maps
* IncompleteCustomRangeException to `validation_failed`. The fakes fill a missing custom date with today instead, since
* AnalyticsState has no error field (TODO(spec): how an analytics flow reports `validation_failed`, §14.2.4).
*/
private fun FakeScenario.range(filter: AnalyticsFilter): DateRange {
val today = LocalDate.parse(today)
val earliest = expenses.filter { !it.deleted }.minOfOrNull { it.date }?.let { LocalDate.parse(it) }
val custom = filter.preset == RangePreset.CUSTOM
val start = filter.customStart?.let { LocalDate.parse(it) } ?: today.takeIf { custom }
val end = filter.customEnd?.let { LocalDate.parse(it) } ?: today.takeIf { custom }
return resolveRange(filter.preset, today, earliest, start, end)
}
private fun FakeScenario.groupKey(id: String, groupBy: GroupBy): GroupKey = when (groupBy) {
GroupBy.CATEGORY -> categories.firstOrNull { it.id.value == id }.let { GroupKey(id, it?.customName, it?.color) }
GroupBy.ACCOUNT -> GroupKey(id, accounts.firstOrNull { it.id.value == id }?.name, null)
}
@@ -0,0 +1,176 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import se.hagstedt.kvitt.domain.BP_TOTAL
import se.hagstedt.kvitt.domain.computeSplit
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseEditorOptions
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.model.money.SplitError
import se.hagstedt.kvitt.model.money.SplitResult
import se.hagstedt.kvitt.model.money.SplitSpec
class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
override fun editorOptions(): Flow<ExpenseEditorOptions> = store.derive { s ->
val partner = s.partner ?: return@derive null
val active = s.accountsInOrder().filter { !it.archived }
val categories = s.categories.filter { !it.archived }.sortedBy { it.sortOrder }
val other = categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }
ExpenseEditorOptions(
me = s.me,
partner = partner,
accounts = active,
categories = categories,
// AC-8.6: the account last used on this phone if still active, else the oldest active account.
defaultAccountId = s.defaultAccountId?.takeIf { id -> active.any { it.id == id } } ?: active.firstOrNull()?.id,
// AC-8.6: the category last used in that account if still active, else `other`.
defaultCategoryFor = active.mapNotNull { account ->
val last = s.lastCategoryFor[account.id]?.takeIf { id -> categories.any { it.id == id } }
(last ?: other?.id)?.let { account.id to it }
}.toMap(),
today = s.today,
)
}.filterNotNull()
override suspend fun load(id: ExpenseId): ExpenseDraft? {
val e = store.current.expenses.firstOrNull { it.id == id && !it.deleted } ?: return null
return ExpenseDraft(
id = e.id,
accountId = e.accountId,
categoryId = e.categoryId,
amountOre = e.amountOre,
description = e.description,
notes = e.notes,
date = e.date,
payer = e.payer,
mode = e.mode,
ratioBp = if (e.mode == SplitMode.RATIO) e.shares.associate { it.userId to (it.shareBp ?: 0) } else null,
exactOre = if (e.mode == SplitMode.EXACT) e.shares.associate { it.userId to it.shareOre } else null,
fullOn = if (e.mode == SplitMode.FULL) e.shares.firstOrNull { it.shareBp == BP_TOTAL }?.userId else null,
)
}
override fun preview(draft: ExpenseDraft): List<Share>? = (store.current.split(draft) as? SplitOutcome.Ok)?.shares
override suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId> {
val s = store.current
val amountOre = draft.amountOre
val accountId = draft.accountId?.takeIf { s.account(it) != null }
val categoryId = draft.categoryId?.takeIf { s.category(it) != null }
val errors = mutableListOf<FieldError>()
if (amountOre == null) errors += FieldError("amountOre", "required")
if (accountId == null) errors += FieldError("accountId", "required")
if (categoryId == null) errors += FieldError("categoryId", "required")
// TODO(spec): §14.2.4 names only domain tokens and "required" for FieldError.error; the §9.6 length and date
// limits have no domain token, so the fakes use the REST code `validation_failed` for them.
when (draft.description.codePointLength()) {
0 -> errors += FieldError("description", "required")
!in 1..100 -> errors += FieldError("description", "validation_failed")
}
if (draft.notes.codePointLength() > 1000) errors += FieldError("notes", "validation_failed")
if (!s.validDate(draft.date)) errors += FieldError("date", "validation_failed")
val outcome = s.split(draft)
if (outcome is SplitOutcome.Err) {
val field = if (outcome.error == SplitError.AMOUNT_OUT_OF_RANGE) "amountOre" else "split"
errors += FieldError(field, outcome.error.name.lowercase())
}
if (errors.isNotEmpty() || outcome !is SplitOutcome.Ok || amountOre == null || accountId == null || categoryId == null) {
return SaveResult.Invalid(errors)
}
val existing = draft.id?.let { id -> s.expenses.firstOrNull { it.id == id } }
val id = draft.id ?: ExpenseId(store.newId())
val saved = FakeExpense(
id = id,
accountId = accountId,
categoryId = categoryId,
amountOre = amountOre,
description = draft.description.trim(),
notes = draft.notes,
date = draft.date,
payer = draft.payer,
mode = draft.mode,
shares = outcome.shares,
createdAt = existing?.createdAt ?: s.now,
recurringTemplateId = existing?.recurringTemplateId,
pending = true,
timeline = existing?.timeline.orEmpty(),
)
store.edit { scenario ->
scenario.copy(
expenses = scenario.expenses.filter { it.id != id } + saved,
defaultAccountId = accountId,
lastCategoryFor = scenario.lastCategoryFor + (accountId to categoryId),
)
}
return SaveResult.Saved(id)
}
override suspend fun delete(id: ExpenseId) = store.edit { s ->
s.copy(expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
}
override fun detail(id: ExpenseId): Flow<ExpenseDetailState?> = store.derive { s ->
val e = s.expenses.firstOrNull { it.id == id } ?: return@derive null
val account = s.account(e.accountId) ?: return@derive null
val category = s.category(e.categoryId) ?: return@derive null
val payer = s.user(e.payer) ?: return@derive null
ExpenseDetailState(
id = e.id,
account = account,
category = category,
amountOre = e.amountOre,
description = e.description,
notes = e.notes,
date = e.date,
payer = payer,
mode = e.mode,
shares = e.shares,
recurringTemplateName = e.recurringTemplateId?.let { t -> s.templates.firstOrNull { it.id == t }?.draft?.description },
deleted = e.deleted,
pending = e.pending,
timeline = s.timelineWithPending(e.timeline, e.pending, e.deleted),
)
}
}
internal sealed interface SplitOutcome {
data class Ok(val shares: List<Share>) : SplitOutcome
data class Err(val error: SplitError) : SplitOutcome
data object Incomplete : SplitOutcome
}
/** §9.1 through `computeSplit`; mode `default` copies the account's default ratio (AC-8.5). */
internal fun FakeScenario.split(draft: ExpenseDraft): SplitOutcome {
val amount = draft.amountOre ?: return SplitOutcome.Incomplete
val userIds = users.map { it.id }
val bp: Map<UserId, Int>? = when (draft.mode) {
SplitMode.DEFAULT -> draft.accountId?.let { account(it) }?.defaultShares ?: return SplitOutcome.Incomplete
SplitMode.RATIO -> draft.ratioBp ?: return SplitOutcome.Incomplete
SplitMode.FULL -> {
val bearer = draft.fullOn ?: return SplitOutcome.Incomplete
userIds.associateWith { if (it == bearer) BP_TOTAL else 0 }
}
SplitMode.EXACT -> null
}
val spec = when (draft.mode) {
SplitMode.EXACT -> SplitSpec.Exact((draft.exactOre ?: return SplitOutcome.Incomplete).mapKeys { it.key.value })
SplitMode.FULL -> SplitSpec.Full((draft.fullOn ?: return SplitOutcome.Incomplete).value)
else -> SplitSpec.Ratio(bp.orEmpty().mapKeys { it.key.value })
}
return when (val result = computeSplit(amount, draft.payer.value, userIds.map { it.value }, spec)) {
is SplitResult.Err -> SplitOutcome.Err(result.error)
is SplitResult.Ok -> SplitOutcome.Ok(
userIds.map { u -> Share(u, result.shares[u.value] ?: 0L, bp?.get(u)) },
)
}
}
@@ -0,0 +1,158 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.BalanceView
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.HomeState
import se.hagstedt.kvitt.model.LedgerMonth
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncSummary
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.domain.BP_TOTAL
class FakeSessionRepository(private val store: FakeStore) : SessionRepository {
override val session: Flow<SessionState> = store.derive { it.session }
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> =
store.online { s ->
store.edit { it.copy(session = SessionState.Paired(s.me, s.partner, s.deviceId)) }
OnlineResult.Ok(s.me)
}
override suspend fun acknowledgeRevoked() = store.edit { it.copy(session = SessionState.Unpaired) }
}
internal fun FakeScenario.syncSummary(): SyncSummary = SyncSummary(
lastSuccessAt = lastSuccessAt,
pendingCount = expenses.count { it.pending } + settlements.count { it.pending },
rejectedCount = rejections.size,
clockSkew = clockSkew,
running = syncRunning,
)
internal fun UpdateState.info(): UpdateInfo? = when (this) {
UpdateState.None -> null
is UpdateState.Available -> info
is UpdateState.Downloading -> info
is UpdateState.Failed -> info
is UpdateState.NeedsInstallPermission -> info
}
class FakeHomeRepository(private val store: FakeStore) : HomeRepository {
override fun home(): Flow<HomeState> = store.derive { s ->
val balances = s.balances()
val month = s.today.take(7)
val summaries = s.accountsInOrder().map { account ->
AccountSummary(
account = account,
netOre = balances.net(account.id, s.me.id),
expensesThisMonth = s.expenses.count { !it.deleted && it.accountId == account.id && it.date.startsWith(month) },
)
}
HomeState(
me = s.me,
partner = s.partner,
global = s.partner?.let { BalanceView(balances.globalNet(s.me.id), it) },
active = summaries.filter { !it.account.archived },
archived = summaries.filter { it.account.archived },
sync = s.syncSummary(),
update = s.update.info(),
)
}
}
class FakeAccountRepository(private val store: FakeStore) : AccountRepository {
override fun detail(id: AccountId): Flow<AccountDetailState?> = store.derive { s ->
val account = s.account(id) ?: return@derive null
val partner = s.partner ?: return@derive null
AccountDetailState(
account = account,
balance = BalanceView(s.balances().net(id, s.me.id), partner),
months = s.ledgerMonths(id),
deletable = s.accountDeletable(id),
users = s.users,
)
}
override fun all(): Flow<List<Account>> = store.derive { it.accountsInOrder() }
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = store.online { s ->
s.validateAccount(null, name, defaultShares) ?: run {
val id = AccountId(store.newId())
store.edit { it.copy(accounts = it.accounts + Account(id, name.trim(), defaultShares, it.now, archived = false)) }
OnlineResult.Ok(id)
}
}
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = store.online { s ->
if (s.account(id) == null) return@online OnlineResult.Failed("not_found", null)
s.validateAccount(id, name, defaultShares) ?: run {
store.edit { it.copy(accounts = it.accounts.map { a -> if (a.id == id) a.copy(name = name.trim(), defaultShares = defaultShares) else a }) }
OnlineResult.Ok(Unit)
}
}
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = store.online { s ->
if (s.account(id) == null) return@online OnlineResult.Failed("not_found", null)
store.edit { it.copy(accounts = it.accounts.map { a -> if (a.id == id) a.copy(archived = archived) else a }) }
OnlineResult.Ok(Unit)
}
override suspend fun delete(id: AccountId): OnlineResult<Unit> = store.online { s ->
when {
s.account(id) == null -> OnlineResult.Failed("not_found", null)
!s.accountDeletable(id) -> OnlineResult.Failed("account_not_empty", null)
else -> {
store.edit { it.copy(accounts = it.accounts.filter { a -> a.id != id }) }
OnlineResult.Ok(Unit)
}
}
}
}
/** AC-6.1, AC-6.2, AC-5.2, §9.6: the server's answers to an account write, or null when it is accepted. */
private fun FakeScenario.validateAccount(id: AccountId?, name: String, shares: Map<UserId, Int>): OnlineResult.Failed? {
val key = name.trim().lowercase()
return when {
partner == null -> OnlineResult.Failed("household_incomplete", null)
name.codePointLength() !in 1..60 -> OnlineResult.Failed("validation_failed", "name")
shares.keys != users.map { it.id }.toSet() || shares.values.any { it !in 0..BP_TOTAL } || shares.values.sum() != BP_TOTAL ->
OnlineResult.Failed("validation_failed", "default_shares")
accounts.any { it.id != id && it.name.trim().lowercase() == key } -> OnlineResult.Failed("name_taken", "name")
else -> null
}
}
/** AC-6.5 */
internal fun FakeScenario.accountDeletable(id: AccountId): Boolean =
expenses.none { !it.deleted && it.accountId == id } &&
settlements.none { !it.deleted && id in it.allocations } &&
templates.none { it.draft.accountId == id }
/**
* S-5 / AC-10.3: expenses and settlements of one account interleaved by date, grouped by month, newest first; the
* month total is the household spend of the account's expenses in that month.
*/
private fun FakeScenario.ledgerMonths(id: AccountId): List<LedgerMonth> {
val expenseRows = expenses.filter { !it.deleted && it.accountId == id }
.mapNotNull { e -> expenseRow(e)?.let { Triple<Pair<String, String>, LedgerRow, Long>(e.date to e.createdAt, it, e.amountOre) } }
// TODO(spec): §14.2.4 does not say whether a settlement row in an account list carries the settlement's total
// or its allocation to that account; the fakes show the total, with the account count, as AC-11.7 describes.
val settlementRows = settlements.filter { !it.deleted && id in it.allocations }
.mapNotNull { s -> settlementRow(s)?.let { Triple<Pair<String, String>, LedgerRow, Long>(s.date to s.createdAt, it, 0L) } }
return (expenseRows + settlementRows)
.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }
.groupBy { it.first.first.take(7) }
.map { (period, rows) -> LedgerMonth(period, rows.sumOf { it.third }, rows.map { it.second }) }
}
@@ -0,0 +1,21 @@
package se.hagstedt.kvitt.fakes
/** One fake of every repository interface of SPEC §14.2.4, all backed by the same [store] and [scenario]. */
class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
val store = FakeStore(scenario)
val session = FakeSessionRepository(store)
val home = FakeHomeRepository(store)
val accounts = FakeAccountRepository(store)
val expenses = FakeExpenseRepository(store)
val settlements = FakeSettlementRepository(store)
val analytics = FakeAnalyticsRepository(store)
val profile = FakeProfileRepository(store)
val categories = FakeCategoryRepository(store)
val recurring = FakeRecurringRepository(store)
val devices = FakeDeviceRepository(store)
val invites = FakeInviteRepository(store)
val sync = FakeSyncRepository(store)
val update = FakeUpdateRepository(store)
val about = FakeAboutRepository(store)
val connectivity = FakeConnectivityRepository(store)
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/** An expense as the fakes hold it: the stored row of SPEC §14.2.1 in model types. */
data class FakeExpense(
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 shares: List<Share>,
val createdAt: String,
val recurringTemplateId: TemplateId? = null,
val deleted: Boolean = false,
val pending: Boolean = false,
val timeline: List<TimelineLine> = emptyList(),
)
/** A settlement as the fakes hold it; allocations in their stored order. */
data class FakeSettlement(
val id: SettlementId,
val from: UserId,
val to: UserId,
val amountOre: Long,
val date: String,
val note: String,
val allocations: Map<AccountId, Long>,
val createdAt: String,
val deleted: Boolean = false,
val pending: Boolean = false,
val timeline: List<TimelineLine> = emptyList(),
)
/**
* Everything the fakes know. A scenario is plain data; the fake repositories derive every `*State` from it with the
* rules in `:domain`, so fakes never disagree with the real rules (SPEC §6.2).
*
* @property me the viewer; [partner] is null until the partner has joined (AC-5.1).
* @property today the phone's local date (`YYYY-MM-DD`); [now] the phone's clock in the fixed RFC 3339 form.
* @property defaultAccountId the account last used on this phone (AC-8.6); [lastCategoryFor] the category last used
* per account on this phone.
*/
data class FakeScenario(
val me: User,
val partner: User?,
val deviceId: DeviceId,
val session: SessionState,
val accounts: List<Account>,
val categories: List<Category>,
val expenses: List<FakeExpense>,
val settlements: List<FakeSettlement>,
val templates: List<RecurringTemplate>,
val devices: List<Device>,
val invites: List<Invite>,
val rejections: List<Rejection>,
val lastSuccessAt: String?,
val clockSkew: Boolean,
val syncRunning: Boolean,
val update: UpdateState,
val about: AboutState,
val online: Boolean,
val today: String,
val now: String,
val defaultAccountId: AccountId?,
val lastCategoryFor: Map<AccountId, CategoryId>,
) {
val users: List<User> get() = listOfNotNull(me, partner)
}
@@ -0,0 +1,77 @@
package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/** Shared scenarios. Feature scenarios live in `fakes/<feature>/` (SPEC §11.3). */
object FakeScenarios {
val owner = User(UserId("01920000-0000-7000-8000-0000000000a1"), Role.ADMIN, "Jacob", null)
val partner = User(UserId("01920000-0000-7000-8000-0000000000a2"), Role.MEMBER, "Anna", null)
private val ownerDevice = DeviceId("01920000-0000-7000-8000-0000000000d1")
/** The ten seeded default categories (SPEC AC-7.2, ids of §14.1). */
val defaultCategories: List<Category> = listOf(
Triple(DefaultCategoryKey.GROCERIES, "shopping_cart", ColorToken.GREEN),
Triple(DefaultCategoryKey.RESTAURANTS, "restaurant", ColorToken.ORANGE),
Triple(DefaultCategoryKey.HOME, "home", ColorToken.BROWN),
Triple(DefaultCategoryKey.UTILITIES, "bolt", ColorToken.YELLOW),
Triple(DefaultCategoryKey.TRANSPORT, "directions_car", ColorToken.BLUE),
Triple(DefaultCategoryKey.TRAVEL, "flight", ColorToken.TEAL),
Triple(DefaultCategoryKey.ENTERTAINMENT, "movie", ColorToken.PURPLE),
Triple(DefaultCategoryKey.HEALTH, "medical_services", ColorToken.RED),
Triple(DefaultCategoryKey.SHOPPING, "shopping_bag", ColorToken.PINK),
Triple(DefaultCategoryKey.OTHER, "category", ColorToken.GREY),
).mapIndexed { index, (key, icon, color) ->
Category(
id = CategoryId("01920000-0000-7000-8000-" + (index + 1).toString(16).padStart(12, '0')),
defaultKey = key,
customName = null,
icon = icon,
color = color,
sortOrder = (index + 1) * 10,
archived = false,
)
}
/**
* The empty scenario: the household is complete (owner and partner, no pictures), online, synced, no accounts, the
* ten default categories only, no expenses, settlements, templates, invites or rejections. Every required field
* of every `*State` is satisfied.
*/
val empty = FakeScenario(
me = owner,
partner = partner,
deviceId = ownerDevice,
session = SessionState.Paired(owner, partner, ownerDevice),
accounts = emptyList(),
categories = defaultCategories,
expenses = emptyList(),
settlements = emptyList(),
templates = emptyList(),
devices = listOf(
Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, current = true, revoked = false),
),
invites = emptyList(),
rejections = emptyList(),
lastSuccessAt = "2026-10-17T06:00:00.000Z",
clockSkew = false,
syncRunning = false,
update = UpdateState.None,
about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null),
online = true,
today = "2026-10-17",
now = "2026-10-17T06:00:00.000Z",
defaultAccountId = null,
lastCategoryFor = emptyMap(),
)
}
@@ -0,0 +1,292 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryIcons
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.SyncStatusState
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
class FakeProfileRepository(private val store: FakeStore) : ProfileRepository {
override fun household(): Flow<List<User>> = store.derive { it.users }
override suspend fun setDisplayName(name: String): OnlineResult<Unit> = store.online {
if (name.codePointLength() !in 1..40) return@online OnlineResult.Failed("validation_failed", "display_name")
store.editMe { it.copy(displayName = name.trim()) }
OnlineResult.Ok(Unit)
}
override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit> = store.online {
if (mimeType !in setOf("image/jpeg", "image/png", "image/webp")) return@online OnlineResult.Failed("unsupported_media_type", null)
if (bytes.size > 5 * 1024 * 1024) return@online OnlineResult.Failed("payload_too_large", null)
store.editMe { it.copy(pictureVersion = (it.pictureVersion ?: 0) + 1) }
OnlineResult.Ok(Unit)
}
override suspend fun removePicture(): OnlineResult<Unit> = store.online {
store.editMe { it.copy(pictureVersion = null) }
OnlineResult.Ok(Unit)
}
}
private fun FakeStore.editMe(change: (User) -> User) = edit { s ->
val me = change(s.me)
s.copy(me = me, devices = s.devices.map { d -> if (d.user.id == me.id) d.copy(user = me) else d })
}
class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository {
override fun all(): Flow<List<Category>> = store.derive { s -> s.categories.sortedBy { it.sortOrder } }
override suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult<CategoryId> = store.online { s ->
validateCategory(name, icon) ?: run {
val id = CategoryId(store.newId())
val sortOrder = (s.categories.maxOfOrNull { it.sortOrder } ?: 0) + 10 // AC-7.3
store.edit { it.copy(categories = it.categories + Category(id, null, name.trim(), icon, color, sortOrder, archived = false)) }
OnlineResult.Ok(id)
}
}
override suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult<Unit> = store.online { s ->
val category = s.category(id) ?: return@online OnlineResult.Failed("not_found", null)
if (name == null && category.defaultKey == null) return@online OnlineResult.Failed("category_is_default", "name")
validateCategory(name, icon) ?: run {
store.editCategory(id) { it.copy(customName = name?.trim(), icon = icon, color = color) }
OnlineResult.Ok(Unit)
}
}
override suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit> = store.online {
store.edit { s ->
s.copy(categories = s.categories.map { c -> idsInOrder.indexOf(c.id).takeIf { it >= 0 }?.let { c.copy(sortOrder = (it + 1) * 10) } ?: c })
}
OnlineResult.Ok(Unit)
}
override suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit> = store.online { s ->
if (s.category(id) == null) return@online OnlineResult.Failed("not_found", null)
store.editCategory(id) { it.copy(archived = archived) }
OnlineResult.Ok(Unit)
}
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 -> {
store.edit { it.copy(categories = it.categories.filter { c -> c.id != id }) }
OnlineResult.Ok(Unit)
}
}
}
/** §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")
icon !in CategoryIcons.ALL -> OnlineResult.Failed("validation_failed", "icon")
else -> null
}
private fun FakeStore.editCategory(id: CategoryId, change: (Category) -> Category) =
edit { s -> s.copy(categories = s.categories.map { if (it.id == id) change(it) else it }) }
}
class FakeRecurringRepository(private val store: FakeStore) : RecurringRepository {
override fun all(): Flow<List<RecurringTemplate>> = store.derive { it.templates }
override suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId> = store.online { s ->
s.validateTemplate(t) ?: run {
val id = TemplateId(store.newId())
val created = t.copy(id = id, nextOccurrence = s.nextOccurrence(t))
store.edit { it.copy(templates = it.templates + created) }
OnlineResult.Ok(id)
}
}
override suspend fun update(t: RecurringTemplate): OnlineResult<Unit> = store.online { s ->
if (s.templates.none { it.id == t.id }) return@online OnlineResult.Failed("not_found", null)
s.validateTemplate(t) ?: run {
store.edit { it.copy(templates = it.templates.map { x -> if (x.id == t.id) t.copy(nextOccurrence = s.nextOccurrence(t)) else x }) }
OnlineResult.Ok(Unit)
}
}
override suspend fun delete(id: TemplateId): OnlineResult<Unit> = store.online { s ->
if (s.templates.none { it.id == id }) return@online OnlineResult.Failed("not_found", null)
store.edit { it.copy(templates = it.templates.filter { x -> x.id != id }) }
OnlineResult.Ok(Unit)
}
}
/** §9.6 limits of a template (the split itself is checked like an expense's). */
private fun FakeScenario.validateTemplate(t: RecurringTemplate): OnlineResult.Failed? {
val period = Regex("""\d{4}-(0[1-9]|1[0-2])""")
val outcome = split(t.draft)
return when {
partner == null -> OnlineResult.Failed("household_incomplete", null)
t.dayOfMonth !in 1..31 -> OnlineResult.Failed("validation_failed", "day_of_month")
!period.matches(t.startPeriod) -> OnlineResult.Failed("validation_failed", "start_period")
t.endPeriod?.let { !period.matches(it) || it < t.startPeriod } == true -> OnlineResult.Failed("validation_failed", "end_period")
t.draft.description.codePointLength() !in 1..100 -> OnlineResult.Failed("validation_failed", "description")
t.draft.accountId?.let { account(it) } == null -> OnlineResult.Failed("validation_failed", "account_id")
t.draft.categoryId?.let { category(it) } == null -> OnlineResult.Failed("validation_failed", "category_id")
outcome !is SplitOutcome.Ok -> OnlineResult.Failed("validation_failed", "splits")
else -> null
}
}
/**
* The server's `next_occurrence_date` (§9.5) as the fakes see it: the clamped date of the first period from
* `start_period` whose date is today or later, within `end_period`; null when paused.
*/
private fun FakeScenario.nextOccurrence(t: RecurringTemplate): String? {
if (t.paused) return null
val today = LocalDate.parse(today)
var month = YearMonth.parse(t.startPeriod)
val end = t.endPeriod?.let { YearMonth.parse(it) }
while (end == null || month <= end) {
val first = LocalDate(month.year, month.month, 1)
val last = first.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.DAY)
val date = LocalDate(month.year, month.month.number, minOf(t.dayOfMonth, last.day))
if (date >= today) return date.toString()
month = month.plus(1, DateTimeUnit.MONTH)
}
return null
}
class FakeDeviceRepository(private val store: FakeStore) : DeviceRepository {
/** AC-3.1: the admin sees all devices, a member their own. */
override suspend fun list(): OnlineResult<List<Device>> = store.online { s ->
OnlineResult.Ok(if (s.me.role == Role.ADMIN) s.devices else s.devices.filter { it.user.id == s.me.id })
}
override suspend fun revoke(id: DeviceId): OnlineResult<Unit> = store.online { s ->
val device = s.devices.firstOrNull { it.id == id } ?: return@online OnlineResult.Failed("not_found", null)
when {
device.current -> OnlineResult.Failed("cannot_revoke_current_device", null) // AC-3.2
s.me.role != Role.ADMIN && device.user.id != s.me.id -> OnlineResult.Failed("forbidden", null)
else -> {
store.edit { it.copy(devices = it.devices.map { d -> if (d.id == id) d.copy(revoked = true) else d }) }
OnlineResult.Ok(Unit)
}
}
}
}
class FakeInviteRepository(private val store: FakeStore) : InviteRepository {
override suspend fun list(): OnlineResult<List<Invite>> = store.online { s ->
OnlineResult.Ok(s.invites.map { it.copy(code = null, link = null) })
}
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = store.online { s ->
when {
s.me.role != Role.ADMIN -> OnlineResult.Failed("forbidden", null)
kind == InviteKind.ADMIN_SETUP -> OnlineResult.Failed("invalid_request", "kind")
kind == InviteKind.PARTNER && s.partner != null -> OnlineResult.Failed("household_full", null) // AC-2.1
kind == InviteKind.DEVICE && (targetUser == null || s.user(targetUser) == null) -> OnlineResult.Failed("not_found", "target_user_id")
else -> {
val id = store.newId()
val code = inviteCode(id)
// 72-hour expiry (AC-2.3): three calendar days later at the same UTC time.
val expires = LocalDate.parse(s.now.take(10)).plus(3, DateTimeUnit.DAY).toString() + s.now.drop(10)
val invite = Invite(InviteId(id), kind, targetUser.takeIf { kind == InviteKind.DEVICE }, code, "${s.about.serverUrl}/i/$code", s.now, expires)
store.edit { st ->
// AC-2.1: a new partner invite cancels any pending one.
val kept = if (kind == InviteKind.PARTNER) st.invites.filter { it.kind != InviteKind.PARTNER } else st.invites
st.copy(invites = kept + invite)
}
OnlineResult.Ok(invite)
}
}
}
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = store.online { s ->
if (s.invites.none { it.id == id }) return@online OnlineResult.Failed("not_found", null)
store.edit { it.copy(invites = it.invites.filter { x -> x.id != id }) }
OnlineResult.Ok(Unit)
}
/** 12 characters of the Crockford base32 alphabet (§8.3), derived from the id so screenshots are stable. */
private fun inviteCode(id: String): String {
val alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
var value = id.filter { it.isLetterOrDigit() }.takeLast(12).toLong(16)
return buildString { repeat(12) { append(alphabet[(value % 32).toInt()]); value /= 32 } }.reversed()
}
}
class FakeSyncRepository(private val store: FakeStore) : SyncRepository {
override fun status(): Flow<SyncStatusState> = store.derive { s ->
val rows: List<Pair<Pair<String, String>, LedgerRow?>> =
s.expenses.filter { it.pending }.map { (it.date to it.createdAt) to s.expenseRow(it) } +
s.settlements.filter { it.pending }.map { (it.date to it.createdAt) to s.settlementRow(it) }
SyncStatusState(
summary = s.syncSummary(),
pending = rows.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }.mapNotNull { it.second },
rejections = s.rejections,
)
}
/** A sync in the fakes: online, every pending change is accepted. */
override suspend fun syncNow() = store.edit { s ->
if (!s.online || s.clockSkew) {
s
} else {
s.copy(
expenses = s.expenses.map { it.copy(pending = false) },
settlements = s.settlements.map { it.copy(pending = false) },
lastSuccessAt = s.now,
)
}
}
override suspend fun dismiss(mutationId: String) = store.edit { s -> s.copy(rejections = s.rejections.filter { it.mutationId != mutationId }) }
}
/** The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, AC-16.5). */
class FakeUpdateRepository(store: FakeStore) : UpdateRepository {
override val state: Flow<UpdateState> = store.derive { it.update }
override suspend fun check() = Unit
override suspend fun install() = Unit
}
class FakeAboutRepository(store: FakeStore) : AboutRepository {
private val flow = store.derive { it.about }
override fun about(): Flow<AboutState> = flow
}
class FakeConnectivityRepository(store: FakeStore) : ConnectivityRepository {
override val online: Flow<Boolean> = store.derive { it.online }
}
@@ -0,0 +1,134 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import se.hagstedt.kvitt.domain.overSettledAccounts
import se.hagstedt.kvitt.domain.proposeAllocation
import se.hagstedt.kvitt.domain.validateSettlement
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AllocationLine
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SettleRequest
import se.hagstedt.kvitt.model.SettleState
import se.hagstedt.kvitt.model.SettlementDetailState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SettlementRepository
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.money.AccountPosition
import se.hagstedt.kvitt.model.money.Allocation
import se.hagstedt.kvitt.model.money.AllocationResult
import se.hagstedt.kvitt.model.money.SettlementError
import kotlin.math.absoluteValue
class FakeSettlementRepository(private val store: FakeStore) : SettlementRepository {
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
val s = store.current
val partner = requireNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
val existing = editing?.let { id -> s.settlements.firstOrNull { it.id == id } }
if (existing != null) {
return s.settleState(
SettleRequest(existing.id, existing.from, existing.amountOre, fixedAccount, existing.allocations, existing.date, existing.note),
)
}
// AC-11.3 / AC-11.4: the user who owes pays; the amount is the absolute balance, empty when it is zero.
val balances = s.balances()
val myNet = if (fixedAccount != null) balances.net(fixedAccount, s.me.id) else balances.globalNet(s.me.id)
val payer = if (myNet > 0) partner.id else s.me.id
val amount = myNet.absoluteValue.takeIf { it > 0 }
return s.settleState(SettleRequest(null, payer, amount, fixedAccount, null, s.today, ""))
}
override suspend fun recompute(request: SettleRequest): SettleState = store.current.settleState(request)
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
val s = store.current
val state = s.settleState(request)
val errors = mutableListOf<FieldError>()
val amount = request.amountOre
if (amount == null) {
errors += FieldError("amountOre", "required")
} else {
validateSettlement(amount, state.payer.id.value, state.recipient.id.value, s.users.map { it.id.value }, state.allocations())?.let {
errors += FieldError(if (it == SettlementError.AMOUNT_OUT_OF_RANGE) "amountOre" else "allocations", it.name.lowercase())
}
}
// TODO(spec): no domain token for the §9.6 note and date limits; the fakes use `validation_failed` (as expenses).
if (request.note.codePointLength() > 200) errors += FieldError("note", "validation_failed")
if (!s.validDate(request.date)) errors += FieldError("date", "validation_failed")
if (errors.isNotEmpty() || amount == null) return SaveResult.Invalid(errors)
val existing = request.editing?.let { id -> s.settlements.firstOrNull { it.id == id } }
val id = request.editing ?: SettlementId(store.newId())
val saved = FakeSettlement(
id = id,
from = state.payer.id,
to = state.recipient.id,
amountOre = amount,
date = request.date,
note = request.note.trim(),
allocations = state.lines.associate { it.account.id to it.amountOre },
createdAt = existing?.createdAt ?: s.now,
pending = true,
timeline = existing?.timeline.orEmpty(),
)
store.edit { it.copy(settlements = it.settlements.filter { x -> x.id != id } + saved) }
return SaveResult.Saved(id)
}
override suspend fun delete(id: SettlementId) = store.edit { s ->
s.copy(settlements = s.settlements.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
}
override fun detail(id: SettlementId): Flow<SettlementDetailState?> = store.derive { s ->
val st = s.settlements.firstOrNull { it.id == id } ?: return@derive null
val from = s.user(st.from) ?: return@derive null
val to = s.user(st.to) ?: return@derive null
SettlementDetailState(
id = st.id,
from = from,
to = to,
amountOre = st.amountOre,
date = st.date,
note = st.note,
allocations = st.allocations.mapNotNull { (account, amount) -> s.account(account)?.let { it to amount } },
deleted = st.deleted,
pending = st.pending,
timeline = s.timelineWithPending(st.timeline, st.pending, st.deleted),
)
}
}
private fun SettleState.allocations(): List<Allocation> = lines.map { Allocation(it.account.id.value, it.amountOre) }
/**
* §9.4 for a request: with `allocations == null` the proposal of `proposeAllocation`, otherwise the given rows;
* a per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6).
*/
private fun FakeScenario.settleState(request: SettleRequest): SettleState {
val payer: User = requireNotNull(user(request.payer)) { "unknown payer" }
val recipient: User = requireNotNull(users.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" }
val balances = balances(excludingSettlement = request.editing?.value)
val positions = accountsInOrder().map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id, request.payer)) }
val amount = request.amountOre
val fixed = request.fixedAccount
val given = request.allocations
val rows: List<Pair<AccountId, Long>> = when {
fixed != null -> listOf(fixed to (given?.get(fixed) ?: amount ?: 0L))
given != null -> given.toList()
amount == null -> emptyList()
else -> when (val proposal = proposeAllocation(amount, positions)) {
is AllocationResult.Ok -> proposal.allocations.map { AccountId(it.accountId) to it.amountOre }
is AllocationResult.Err -> emptyList()
}
}.mapNotNull { (id, value) -> id.takeIf { account(it) != null }?.let { it to value } }
val over = overSettledAccounts(rows.map { Allocation(it.first.value, it.second) }, positions).toSet()
val lines = rows.mapNotNull { (id, value) ->
account(id)?.let { AllocationLine(it, balances.net(id, request.payer), value, id.value in over) }
}
val allocated = lines.sumOf { it.amountOre }
val valid = amount != null && validateSettlement(
amount, payer.id.value, recipient.id.value, users.map { it.id.value }, lines.map { Allocation(it.account.id.value, it.amountOre) },
) == null
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note)
}
@@ -0,0 +1,123 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import se.hagstedt.kvitt.domain.computeBalances
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.AuditSource
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseEffect
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.model.money.BalanceExpense
import se.hagstedt.kvitt.model.money.BalanceSettlement
import se.hagstedt.kvitt.model.money.Balances
/**
* The single mutable state behind all fake repositories of one [FakeRepositories]. Writes go through [edit], so every
* flow re-emits, like Room after a local write (AC-10.4).
*/
class FakeStore(initial: FakeScenario) {
private val mutable = MutableStateFlow(initial)
val state: StateFlow<FakeScenario> = mutable
val current: FakeScenario get() = mutable.value
private var nextId = 0x100
fun edit(transform: (FakeScenario) -> FakeScenario) = mutable.update(transform)
fun <T> derive(transform: (FakeScenario) -> T): Flow<T> = mutable.map(transform)
/** A new UUIDv7-shaped id, deterministic so screenshots are stable. */
fun newId(): String {
nextId += 1
return "01920000-0000-7000-8000-" + nextId.toString(16).padStart(12, '0')
}
/** §9.9: online-only actions report [OnlineResult.Offline] when the scenario is offline. */
inline fun <T> online(action: (FakeScenario) -> OnlineResult<T>): OnlineResult<T> =
if (!current.online) OnlineResult.Offline else action(current)
}
// Derived values shared by the fakes. Each one is the rule of the spec section named, computed with :domain.
internal fun FakeScenario.user(id: UserId): User? = users.firstOrNull { it.id == id }
internal fun FakeScenario.account(id: AccountId): Account? = accounts.firstOrNull { it.id == id }
internal fun FakeScenario.category(id: CategoryId): Category? = categories.firstOrNull { it.id == id }
/** AC-6.6: accounts by `created_at`, then id. */
internal fun FakeScenario.accountsInOrder(): List<Account> = accounts.sortedWith(compareBy({ it.createdAt }, { it.id.value }))
/** §9.3 over non-deleted rows, optionally excluding the settlement being edited (AC-11.6). */
internal fun FakeScenario.balances(excludingSettlement: String? = null): Balances = computeBalances(
userIds = users.map { it.id.value },
accountIds = accounts.map { it.id.value },
expenses = expenses.filter { !it.deleted }.map { e ->
BalanceExpense(e.accountId.value, e.amountOre, e.payer.value, e.shares.associate { it.userId.value to it.shareOre })
},
settlements = settlements.filter { !it.deleted && it.id.value != excludingSettlement }.map { s ->
BalanceSettlement(s.from.value, s.to.value, s.allocations.mapKeys { it.key.value })
},
)
internal fun Balances.net(account: AccountId, user: UserId): Long = perAccount[account.value]?.get(user.value) ?: 0L
internal fun Balances.globalNet(user: UserId): Long = global[user.value] ?: 0L
/** §9.2: the effect line of an expense from the viewer's side. */
internal fun FakeExpense.effectFor(viewer: UserId): ExpenseEffect {
val nonPayerShare = shares.firstOrNull { it.userId != payer }?.shareOre ?: 0L
return when {
nonPayerShare == 0L -> if (viewer == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
viewer == payer -> ExpenseEffect.Lent(nonPayerShare)
else -> ExpenseEffect.Borrowed(shares.firstOrNull { it.userId == viewer }?.shareOre ?: 0L)
}
}
internal fun FakeScenario.expenseRow(e: FakeExpense): LedgerRow.ExpenseRow? {
val category = category(e.categoryId) ?: return null
return LedgerRow.ExpenseRow(e.id, e.date, e.description, category, e.amountOre, e.effectFor(me.id), e.pending)
}
internal fun FakeScenario.settlementRow(s: FakeSettlement): LedgerRow.SettlementRow? {
val from = user(s.from) ?: return null
val to = user(s.to) ?: return null
return LedgerRow.SettlementRow(s.id, s.date, from, to, s.amountOre, s.allocations.size, s.pending)
}
/** AC-8.10: date descending, then `created_at` descending. */
internal val ledgerOrder: Comparator<Pair<String, String>> =
compareByDescending<Pair<String, String>> { it.first }.thenByDescending { it.second }
/** AC-12.5: a pending local change ends the timeline with a line marked [TimelineLine.pendingLocal]. */
internal fun FakeScenario.timelineWithPending(timeline: List<TimelineLine>, pending: Boolean, deleted: Boolean): List<TimelineLine> {
if (!pending) return timeline
val action = when {
deleted -> AuditAction.DELETE
timeline.isEmpty() -> AuditAction.CREATE
else -> AuditAction.UPDATE
}
return timeline + TimelineLine(me, action, AuditSource.APP, now, emptyList(), pendingLocal = true)
}
/** Length in Unicode code points after trimming (§9.6). */
internal fun String.codePointLength(): Int = trim().let { it.codePointCount(0, it.length) }
/** §9.6: a valid date from 2000-01-01 to today + 365 days. */
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)
}
@@ -0,0 +1,84 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
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.ExpenseId
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.UpdateState
/** Every fake repository answers from the empty scenario with a complete, empty state. */
class EmptyScenarioTest {
private val fakes = FakeRepositories()
@Test
fun sessionIsPairedWithTheWholeHousehold() = runTest {
val session = fakes.session.session.first()
assertTrue(session is SessionState.Paired)
assertEquals(listOf(FakeScenarios.owner, FakeScenarios.partner), fakes.profile.household().first())
}
@Test
fun homeIsSettledWithNoAccounts() = runTest {
val home = fakes.home.home().first()
assertEquals(0L, home.global?.netOre)
assertTrue(home.active.isEmpty() && home.archived.isEmpty())
assertEquals(0, home.sync.pendingCount)
assertNull(home.update)
}
@Test
fun accountsExpensesAndSettlementsAreEmpty() = runTest {
assertTrue(fakes.accounts.all().first().isEmpty())
assertNull(fakes.accounts.detail(AccountId("none")).first())
assertNull(fakes.expenses.detail(ExpenseId("none")).first())
assertNull(fakes.settlements.detail(SettlementId("none")).first())
val options = fakes.expenses.editorOptions().first()
assertNull(options.defaultAccountId)
assertEquals(10, options.categories.size)
val settle = fakes.settlements.start(null, null)
assertNull(settle.amountOre)
assertTrue(settle.lines.isEmpty())
}
@Test
fun analyticsHasEmptyBucketsForTheDefaultRange() = runTest {
val filter = AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
val state = fakes.analytics.analytics(filter).first()
assertEquals("2026-05-01", state.start)
assertEquals(6, state.buckets.size)
assertEquals(0L, state.totalOre)
}
@Test
fun settingsRepositoriesAnswer() = runTest {
assertEquals(10, fakes.categories.all().first().size)
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())
assertTrue(fakes.sync.status().first().rejections.isEmpty())
assertEquals(UpdateState.None, fakes.update.state.first())
assertEquals("dev", fakes.about.about().first().versionName)
assertTrue(fakes.connectivity.online.first())
}
@Test
fun createdAccountFlowsIntoHomeWithZeroBalance() = runTest {
val shares = mapOf(FakeScenarios.owner.id to 5000, FakeScenarios.partner.id to 5000)
val created = fakes.accounts.create("Hemma", shares)
assertTrue(created is OnlineResult.Ok)
assertEquals(OnlineResult.Failed("name_taken", "name"), fakes.accounts.create(" hemma ", shares))
assertEquals(listOf("Hemma"), fakes.home.home().first().active.map { it.account.name })
}
}