W6 Android Insights on S-11: server trends and forecast (E-34, E-35), online only (D-141 to D-143)
InsightsRepository in :core:model (SpendTrends, SpendForecast and parts); :data DTOs for E-34/E-35 (round-trip the contract examples, fixing ExampleCoverageTest), InsightsRepositoryImpl through KvittApi and OnlineGate with category/account names from Room (deleted rows included); FakeInsightsRepository with scenarios (loaded, empty household, account without history, offline, unreachable, long); the Insights section below S-11's charts: household trend and per-category trends on SpendLineChart with average and moving average, a forecast card per account, "Needs connection" / "Could not reach the server" over the last data of the session. Strings sv/en; 41 goldens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package se.hagstedt.kvitt.fakes
|
||||
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.model.TrendPoint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* The server's E-34 and E-35 (D-133, D-141) over the scenario, so Insights screens render without a server. The rules
|
||||
* are the endpoints' as `contracts/openapi.yaml` describes them (whole months before the current one, empty months
|
||||
* count, half-up to öre, trailing 3-month moving average; forecast = templates due + the 3-month average of spend not
|
||||
* generated from a template). Offline is [OnlineResult.Offline]; [FakeScenario.insightsFailure] fails both reads.
|
||||
*/
|
||||
class FakeInsightsRepository(private val store: FakeStore) : InsightsRepository {
|
||||
override suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends> = store.online { s ->
|
||||
when {
|
||||
months !in 1..MAX_MONTHS -> OnlineResult.Failed("invalid_request", "months")
|
||||
else -> s.insightsFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.trends(months, measure))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun forecast(measure: Measure): OnlineResult<SpendForecast> = store.online { s ->
|
||||
s.insightsFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.forecast(measure))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_MONTHS = 24
|
||||
}
|
||||
}
|
||||
|
||||
/** D-128's window, counted in whole months here (§15.8). */
|
||||
private const val WINDOW = 3
|
||||
private const val PERCENT = 100L
|
||||
private const val MONTHS_PER_YEAR = 12
|
||||
|
||||
/** A month as a count of months since year 0, so neighbours are ±1. */
|
||||
private fun monthIndex(period: String): Int = period.substring(0, 4).toInt() * MONTHS_PER_YEAR + period.substring(5, 7).toInt() - 1
|
||||
|
||||
private fun period(index: Int): String = "${index / MONTHS_PER_YEAR}-${(index % MONTHS_PER_YEAR + 1).toString().padStart(2, '0')}"
|
||||
|
||||
private fun divHalfUp(a: Long, b: Long): Long {
|
||||
val q = a / b
|
||||
val r = a % b
|
||||
return if (2 * abs(r) >= abs(b)) q + a.sign * b.sign else q
|
||||
}
|
||||
|
||||
private fun FakeScenario.measured(e: FakeExpense, measure: Measure): Long =
|
||||
if (measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
|
||||
|
||||
private fun FakeScenario.trends(months: Int, measure: Measure): SpendTrends {
|
||||
val current = monthIndex(today)
|
||||
val whole = (current - months until current).map(::period)
|
||||
val live = expenses.filter { !it.deleted && it.date <= today }
|
||||
fun trend(of: List<FakeExpense>): SpendTrend {
|
||||
val byMonth = of.groupBy { it.date.substring(0, 7) }.mapValues { (_, list) -> list.sumOf { measured(it, measure) } }
|
||||
val amounts = whole.map { byMonth[it] ?: 0L }
|
||||
val total = amounts.sum()
|
||||
val average = divHalfUp(total, months.toLong())
|
||||
val last = amounts.last()
|
||||
val points = whole.mapIndexed { i, month ->
|
||||
val slice = amounts.subList((i - WINDOW + 1).coerceAtLeast(0), i + 1)
|
||||
TrendPoint(month, amounts[i], divHalfUp(slice.sum(), slice.size.toLong()))
|
||||
}
|
||||
return SpendTrend(
|
||||
totalOre = total,
|
||||
averageOre = average,
|
||||
lastMonthOre = last,
|
||||
lastMonthVsAveragePct = if (average == 0L) null else divHalfUp((last - average) * PERCENT, average).toInt(),
|
||||
currentMonthOre = byMonth[period(current)] ?: 0L,
|
||||
points = points,
|
||||
)
|
||||
}
|
||||
val inScope = live.filter { monthIndex(it.date) in current - months..current }
|
||||
val categoryTrends = inScope.groupBy { it.categoryId }
|
||||
.map { (id, list) -> CategoryTrend(id, category(id), trend(list)) }
|
||||
.filter { it.trend.totalOre != 0L || it.trend.currentMonthOre != 0L }
|
||||
.sortedWith(compareByDescending<CategoryTrend> { it.trend.totalOre }.thenBy { it.categoryId.value })
|
||||
return SpendTrends(measure, today, period(current), months, trend(live), categoryTrends)
|
||||
}
|
||||
|
||||
private fun RecurringTemplate.dueIn(month: String): Boolean = !paused && startPeriod <= month && endPeriod.let { it == null || month <= it }
|
||||
|
||||
private fun FakeScenario.recurringAmount(t: RecurringTemplate, measure: Measure): Long {
|
||||
if (measure == Measure.HOUSEHOLD) return t.draft.amountOre ?: 0L
|
||||
val shares = (split(t.draft) as? SplitOutcome.Ok)?.shares.orEmpty()
|
||||
return shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
|
||||
}
|
||||
|
||||
private fun FakeScenario.forecast(measure: Measure): SpendForecast {
|
||||
val current = monthIndex(today)
|
||||
val live = expenses.filter { !it.deleted && it.date <= today }
|
||||
val forecasts = accountsInOrder().map { account ->
|
||||
val own = live.filter { it.accountId == account.id }
|
||||
val window = own.filter { it.recurringTemplateId == null && monthIndex(it.date) in current - WINDOW until current }
|
||||
val baseline = divHalfUp(window.sumOf { measured(it, measure) }, WINDOW.toLong())
|
||||
val templates = templates.filter { it.draft.accountId == account.id }
|
||||
AccountForecast(
|
||||
accountId = account.id,
|
||||
account = account,
|
||||
currentMonthOre = own.filter { monthIndex(it.date) == current }.sumOf { measured(it, measure) },
|
||||
months = (1..2).map { ahead ->
|
||||
val month = period(current + ahead)
|
||||
val recurring = templates.filter { it.dueIn(month) }.sumOf { recurringAmount(it, measure) }
|
||||
ForecastMonth(month, recurring, baseline, recurring + baseline)
|
||||
},
|
||||
)
|
||||
}
|
||||
return SpendForecast(measure, today, period(current), forecasts)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
|
||||
val expenses = FakeExpenseRepository(store)
|
||||
val settlements = FakeSettlementRepository(store)
|
||||
val analytics = FakeAnalyticsRepository(store)
|
||||
val insights = FakeInsightsRepository(store)
|
||||
val profile = FakeProfileRepository(store)
|
||||
val categories = FakeCategoryRepository(store)
|
||||
val recurring = FakeRecurringRepository(store)
|
||||
|
||||
@@ -66,6 +66,8 @@ data class FakeSettlement(
|
||||
* per account on this phone.
|
||||
* @property lastAttemptFailed the last sync cycle failed (`SyncSummary.lastAttemptFailed`, false while one runs).
|
||||
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
|
||||
* @property insightsFailure the code the Insights reads fail with while online (`network`: the server could not be
|
||||
* reached); null: they succeed (D-141).
|
||||
*/
|
||||
data class FakeScenario(
|
||||
val me: User,
|
||||
@@ -92,6 +94,7 @@ data class FakeScenario(
|
||||
val lastCategoryFor: Map<AccountId, CategoryId>,
|
||||
val lastAttemptFailed: Boolean = false,
|
||||
val pairingInviteKind: InviteKind? = null,
|
||||
val insightsFailure: String? = null,
|
||||
) {
|
||||
val users: List<User> get() = listOfNotNull(me, partner)
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package se.hagstedt.kvitt.fakes.analytics
|
||||
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.Category
|
||||
import se.hagstedt.kvitt.model.ExpenseDraft
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.TemplateId
|
||||
|
||||
/**
|
||||
* Scenarios of S-11's Insights section (D-133, D-141), on [AnalyticsScenarios]' household (today 2026-10-17, so the
|
||||
* whole months are April to September 2026 and the forecast is November and December).
|
||||
*
|
||||
* - [loaded]: [AnalyticsScenarios.loaded] with three templates (rent and home insurance on Huset, the insurance ending
|
||||
* in November so December differs; a paused SL card on Vardag, never forecast), the rent and insurance expenses
|
||||
* generated from them (so the baseline leaves them out), and a new account "Bilen" without history.
|
||||
* - [emptyHousehold]: two accounts and no expenses or templates: zero trends, no categories, a zero forecast.
|
||||
* - [offline]: [loaded] while offline: the reads report Offline (§9.9).
|
||||
* - [unreachable]: [loaded] online, but the server cannot be reached (`Failed("network")`).
|
||||
* - [long]: [AnalyticsScenarios.long] with a template on its long-named account.
|
||||
*/
|
||||
object InsightsScenarios {
|
||||
private val me = FakeScenarios.owner.id
|
||||
|
||||
private val rent = template(0x01, "Hyra", AnalyticsScenarios.huset, AnalyticsScenarios.housing, 1_033_300, 3, endPeriod = null)
|
||||
private val insurance = template(0x02, "Hemförsäkring", AnalyticsScenarios.huset, AnalyticsScenarios.insurance, 32_900, 5, endPeriod = "2026-11")
|
||||
private val travelCard = template(0x03, "SL-kort", AnalyticsScenarios.vardag, AnalyticsScenarios.transport, 98_000, 14, endPeriod = null, paused = true)
|
||||
|
||||
/** A new account with no expenses: its forecast is zero (no recurring, no history). */
|
||||
val bilen = Account(AccountId("01920000-0000-7000-8000-000000000a07"), "Bilen", mapOf(me to 5000, FakeScenarios.partner.id to 5000), "2026-10-01T09:00:00.000Z", archived = false)
|
||||
|
||||
val loaded: FakeScenario = AnalyticsScenarios.loaded.let { base ->
|
||||
base.copy(
|
||||
accounts = base.accounts + bilen,
|
||||
templates = listOf(rent, insurance, travelCard),
|
||||
expenses = base.expenses.map { e ->
|
||||
when (e.description) {
|
||||
"Hyra" -> e.copy(recurringTemplateId = rent.id)
|
||||
"Hemförsäkring" -> e.copy(recurringTemplateId = insurance.id)
|
||||
else -> e
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val emptyHousehold: FakeScenario = FakeScenarios.empty.copy(accounts = listOf(AnalyticsScenarios.vardag, bilen))
|
||||
|
||||
val offline: FakeScenario = loaded.copy(online = false)
|
||||
|
||||
val unreachable: FakeScenario = loaded.copy(insightsFailure = "network")
|
||||
|
||||
val long: FakeScenario = AnalyticsScenarios.long.let { base ->
|
||||
val account = base.accounts[1]
|
||||
base.copy(templates = listOf(template(0x04, "Arrende för sommarstugan", account, base.categories.first(), 1_250_000, 28, endPeriod = null)))
|
||||
}
|
||||
|
||||
private fun template(n: Int, description: String, account: Account, category: Category, ore: Long, day: Int, endPeriod: String?, paused: Boolean = false) =
|
||||
RecurringTemplate(
|
||||
id = TemplateId("01920000-0000-7000-8000-0000000007" + n.toString(16).padStart(2, '0')),
|
||||
draft = ExpenseDraft(
|
||||
id = null,
|
||||
accountId = account.id,
|
||||
categoryId = category.id,
|
||||
amountOre = ore,
|
||||
description = description,
|
||||
notes = "",
|
||||
date = "2026-10-17",
|
||||
payer = me,
|
||||
mode = SplitMode.DEFAULT,
|
||||
ratioBp = null,
|
||||
exactOre = null,
|
||||
fullOn = null,
|
||||
),
|
||||
dayOfMonth = day,
|
||||
startPeriod = "2025-01",
|
||||
endPeriod = endPeriod,
|
||||
paused = paused,
|
||||
nextOccurrence = null,
|
||||
)
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package se.hagstedt.kvitt.fakes
|
||||
|
||||
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.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
|
||||
/** The Insights fake (D-141) follows E-34's and E-35's rules over the scenarios (today 2026-10-17). */
|
||||
class FakeInsightsRepositoryTest {
|
||||
private suspend fun trends(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): SpendTrends =
|
||||
(FakeRepositories(scenario).insights.trends(6, measure) as OnlineResult.Ok).value
|
||||
|
||||
private suspend fun forecast(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): SpendForecast =
|
||||
(FakeRepositories(scenario).insights.forecast(measure) as OnlineResult.Ok).value
|
||||
|
||||
@Test
|
||||
fun trendsCoverTheWholeMonthsBeforeTheCurrentOneWithEmptyMonthsAsZero() = runTest {
|
||||
val t = trends(InsightsScenarios.loaded)
|
||||
assertEquals("2026-10", t.currentMonth)
|
||||
assertEquals(listOf("2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"), t.household.points.map { it.month })
|
||||
assertEquals(0L, t.household.points.first().amountOre)
|
||||
assertEquals(t.household.points.sumOf { it.amountOre }, t.household.totalOre)
|
||||
assertEquals((t.household.totalOre * 2 + 6) / 12, t.household.averageOre)
|
||||
assertEquals(t.household.points.last().amountOre, t.household.lastMonthOre)
|
||||
assertTrue(t.household.currentMonthOre > 0)
|
||||
// Sorted by total, rent first; categories spent only this month are listed too.
|
||||
assertEquals(AnalyticsScenarios.housing.id, t.categories.first().categoryId)
|
||||
assertEquals(t.categories.sortedByDescending { it.trend.totalOre }.map { it.trend.totalOre }, t.categories.map { it.trend.totalOre })
|
||||
assertTrue(t.categories.all { it.category != null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAverageIsTrailingOverThreeMonths() = runTest {
|
||||
val p = trends(InsightsScenarios.loaded).household.points
|
||||
assertEquals(p[0].amountOre, p[0].movingAverageOre)
|
||||
val last = p.takeLast(3).sumOf { it.amountOre }
|
||||
assertEquals((last * 2 + 3) / 6, p.last().movingAverageOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forecastIsTemplatesDuePlusTheThreeMonthBaselineOfOtherSpend() = runTest {
|
||||
val f = forecast(InsightsScenarios.loaded)
|
||||
assertEquals(listOf("2026-11", "2026-12"), f.accounts.first().months.map { it.month })
|
||||
val huset = f.accounts.single { it.accountId == AnalyticsScenarios.huset.id }
|
||||
// Rent and insurance in November, rent only in December (the insurance ends in November).
|
||||
assertEquals(listOf(1_066_200L, 1_033_300L), huset.months.map { it.recurringOre })
|
||||
// Electricity July to September (127 050 + 131 200 + 135 350) ÷ 3; the generated rent and insurance are left out.
|
||||
assertEquals(listOf(131_200L, 131_200L), huset.months.map { it.baselineOre })
|
||||
assertTrue(huset.months.all { it.forecastOre == it.recurringOre + it.baselineOre })
|
||||
// The paused SL card is never forecast; the new account has nothing at all.
|
||||
assertTrue(f.accounts.single { it.accountId == AnalyticsScenarios.vardag.id }.months.all { it.recurringOre == 0L })
|
||||
val bilen = f.accounts.single { it.accountId == InsightsScenarios.bilen.id }
|
||||
assertEquals(0L, bilen.currentMonthOre)
|
||||
assertTrue(bilen.months.all { it.forecastOre == 0L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun myShareUsesTheSharesAGenerationWouldWrite() = runTest {
|
||||
val huset = forecast(InsightsScenarios.loaded, Measure.MY_SHARE).accounts.single { it.accountId == AnalyticsScenarios.huset.id }
|
||||
assertEquals(516_650L + 16_450L, huset.months.first().recurringOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyHouseholdHasZeroTrendsAndNoCategories() = runTest {
|
||||
val t = trends(InsightsScenarios.emptyHousehold)
|
||||
assertEquals(0L, t.household.totalOre)
|
||||
assertNull(t.household.lastMonthVsAveragePct)
|
||||
assertTrue(t.categories.isEmpty())
|
||||
assertEquals(2, forecast(InsightsScenarios.emptyHousehold).accounts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineAndUnreachableFailLikeTheServerWould() = runTest {
|
||||
assertEquals(OnlineResult.Offline, FakeRepositories(InsightsScenarios.offline).insights.trends(6, Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Failed("network", null), FakeRepositories(InsightsScenarios.unreachable).insights.forecast(Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Failed("invalid_request", "months"), FakeRepositories(InsightsScenarios.loaded).insights.trends(25, Measure.HOUSEHOLD))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user