M5: Android data layer: DAOs, Ktor client and DTOs, token store, sync engine and workers, repositories, APK updater
DAOs on M2b's Room 3 entities and KvittDatabase v1 (schema 1.json unchanged), DTOs for every wire object and endpoint body with a round-trip test over all 80 contract examples, the Ktor client with the section 14.3.1 headers and 14.3.2 error mapping, the Keystore-encrypted token store in auth.preferences_pb, the sync engine of section 14.4 (push batches, pull pages, epoch recovery, rejections, clock skew, 401 and 426 handling) tested with MockEngine including AC-15.6, WorkManager triggers, every :core:model repository, the APK updater behind an installer interface, Coil pictures, Hilt modules, Room migration test infrastructure. Fakes: the five settled TODO(spec) comments resolved per SPEC v0.4 (settlement row in an account's ledger carries that account's allocation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -64,8 +64,8 @@ class FakeAnalyticsRepository(private val store: FakeStore) : AnalyticsRepositor
|
||||
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).
|
||||
// §14.2.4 (v0.4): the title is the unlocalised parts joined by ", " (category custom name or lowercase
|
||||
// default key, account name, bucket key); S-12 formats its visible title itself.
|
||||
val title = listOfNotNull(
|
||||
categoryId?.let { id -> s.category(id)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } },
|
||||
accountId?.let { id -> s.account(id)?.name },
|
||||
@@ -90,7 +90,7 @@ private fun FakeScenario.measure(measure: Measure): AnalyticsMeasure =
|
||||
/**
|
||||
* 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).
|
||||
* AnalyticsState has no error field, so a missing custom date is resolved as today (§14.2.4, v0.4).
|
||||
*/
|
||||
private fun FakeScenario.range(filter: AnalyticsFilter): DateRange {
|
||||
val today = LocalDate.parse(today)
|
||||
|
||||
@@ -70,8 +70,7 @@ class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
|
||||
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.
|
||||
// §14.2.4 (v0.4): a §9.6 rule without a domain token (lengths, dates) is `validation_failed`.
|
||||
when (draft.description.codePointLength()) {
|
||||
0 -> errors += FieldError("description", "required")
|
||||
!in 1..100 -> errors += FieldError("description", "validation_failed")
|
||||
|
||||
+3
-3
@@ -147,10 +147,10 @@ internal fun FakeScenario.accountDeletable(id: AccountId): Boolean =
|
||||
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.
|
||||
// AC-11.7 (v0.4): in an account's list a settlement row carries the allocation to that account; accountCount
|
||||
// stays the allocation count of the whole settlement.
|
||||
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) } }
|
||||
.mapNotNull { s -> settlementRow(s, account = id)?.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) }
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ class FakeSettlementRepository(private val store: FakeStore) : SettlementReposit
|
||||
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).
|
||||
// §14.2.4 (v0.4): the §9.6 note and date limits have no domain token and are `validation_failed`.
|
||||
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)
|
||||
|
||||
@@ -92,10 +92,12 @@ internal fun FakeScenario.expenseRow(e: FakeExpense): LedgerRow.ExpenseRow? {
|
||||
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? {
|
||||
/** AC-11.7: the settlement total, or with [account] the allocation to that account (an account's list, S-5). */
|
||||
internal fun FakeScenario.settlementRow(s: FakeSettlement, account: AccountId? = null): 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)
|
||||
val amount = if (account != null) s.allocations[account] ?: 0L else s.amountOre
|
||||
return LedgerRow.SettlementRow(s.id, s.date, from, to, amount, s.allocations.size, s.pending)
|
||||
}
|
||||
|
||||
/** AC-8.10: date descending, then `created_at` descending. */
|
||||
|
||||
Reference in New Issue
Block a user