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:
@@ -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)
|
||||
}
|
||||
+107
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+158
@@ -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(),
|
||||
)
|
||||
}
|
||||
+292
@@ -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 }
|
||||
}
|
||||
+134
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user