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,69 @@
|
||||
package se.hagstedt.kvitt.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Response bodies of the advanced analytics, E-34 and E-35 (D-133, `contracts/openapi.yaml` TrendsResponse, ForecastResponse).
|
||||
|
||||
/** E-34 `TrendPoint`: `month` is `YYYY-MM`. */
|
||||
@Serializable
|
||||
data class TrendPointDto(
|
||||
val month: String,
|
||||
@SerialName("amount_ore") val amountOre: Long,
|
||||
@SerialName("moving_average_ore") val movingAverageOre: Long,
|
||||
)
|
||||
|
||||
/** E-34 `Trend`; `last_month_vs_average_pct` is always present, null when the average is 0. */
|
||||
@Serializable
|
||||
data class TrendDto(
|
||||
@SerialName("total_ore") val totalOre: Long,
|
||||
@SerialName("average_ore") val averageOre: Long,
|
||||
@SerialName("last_month_ore") val lastMonthOre: Long,
|
||||
@SerialName("last_month_vs_average_pct") val lastMonthVsAveragePct: Int?,
|
||||
@SerialName("current_month_ore") val currentMonthOre: Long,
|
||||
val points: List<TrendPointDto>,
|
||||
)
|
||||
|
||||
/** E-34 `CategoryTrend`. */
|
||||
@Serializable
|
||||
data class CategoryTrendDto(
|
||||
@SerialName("category_id") val categoryId: String,
|
||||
val trend: TrendDto,
|
||||
)
|
||||
|
||||
/** E-34 response. */
|
||||
@Serializable
|
||||
data class TrendsResponse(
|
||||
val measure: String,
|
||||
val today: String,
|
||||
@SerialName("current_month") val currentMonth: String,
|
||||
val months: Int,
|
||||
val household: TrendDto,
|
||||
val categories: List<CategoryTrendDto>,
|
||||
)
|
||||
|
||||
/** E-35 `ForecastMonth`. */
|
||||
@Serializable
|
||||
data class ForecastMonthDto(
|
||||
val month: String,
|
||||
@SerialName("recurring_ore") val recurringOre: Long,
|
||||
@SerialName("baseline_ore") val baselineOre: Long,
|
||||
@SerialName("forecast_ore") val forecastOre: Long,
|
||||
)
|
||||
|
||||
/** E-35 `AccountForecast`. */
|
||||
@Serializable
|
||||
data class AccountForecastDto(
|
||||
@SerialName("account_id") val accountId: String,
|
||||
@SerialName("current_month_ore") val currentMonthOre: Long,
|
||||
val months: List<ForecastMonthDto>,
|
||||
)
|
||||
|
||||
/** E-35 response. */
|
||||
@Serializable
|
||||
data class ForecastResponse(
|
||||
val measure: String,
|
||||
val today: String,
|
||||
@SerialName("current_month") val currentMonth: String,
|
||||
val accounts: List<AccountForecastDto>,
|
||||
)
|
||||
@@ -135,6 +135,15 @@ class KvittApi(
|
||||
suspend fun proposal(request: ProposalRequest): ApiResult<ProposalResponse> =
|
||||
authed(HttpMethod.Post, "/settlements/proposal") { jsonBody(request) }
|
||||
|
||||
// E-34, E-35 (D-133): `measure` is the wire value `household` or `my_share`.
|
||||
suspend fun analyticsTrends(months: Int, measure: String): ApiResult<TrendsResponse> = authed(HttpMethod.Get, "/analytics/trends") {
|
||||
parameter("months", months)
|
||||
parameter("measure", measure)
|
||||
}
|
||||
suspend fun analyticsForecast(measure: String): ApiResult<ForecastResponse> = authed(HttpMethod.Get, "/analytics/forecast") {
|
||||
parameter("measure", measure)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> authed(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
|
||||
@@ -46,6 +46,7 @@ import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.HomeRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.InsightsRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.InviteRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.LocalData
|
||||
import se.hagstedt.kvitt.data.repo.PhonePrefs
|
||||
@@ -75,6 +76,7 @@ import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.DeviceRepository
|
||||
import se.hagstedt.kvitt.model.ExpenseRepository
|
||||
import se.hagstedt.kvitt.model.HomeRepository
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.InviteRepository
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.RecurringRepository
|
||||
@@ -266,6 +268,9 @@ object DataModule {
|
||||
@Provides @Singleton
|
||||
fun analytics(local: LocalData, clock: Clock): AnalyticsRepositoryImpl = AnalyticsRepositoryImpl(local, clock)
|
||||
|
||||
@Provides @Singleton
|
||||
fun insights(local: LocalData, api: KvittApi, gate: OnlineGate): InsightsRepositoryImpl = InsightsRepositoryImpl(local, api, gate)
|
||||
|
||||
@Provides @Singleton
|
||||
fun profile(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): ProfileRepositoryImpl =
|
||||
ProfileRepositoryImpl(local, api, gate, db, clock)
|
||||
@@ -303,6 +308,7 @@ abstract class DataBindingsModule {
|
||||
@Binds abstract fun expenses(impl: ExpenseRepositoryImpl): ExpenseRepository
|
||||
@Binds abstract fun settlements(impl: SettlementRepositoryImpl): SettlementRepository
|
||||
@Binds abstract fun analytics(impl: AnalyticsRepositoryImpl): AnalyticsRepository
|
||||
@Binds abstract fun insights(impl: InsightsRepositoryImpl): InsightsRepository
|
||||
@Binds abstract fun profile(impl: ProfileRepositoryImpl): ProfileRepository
|
||||
@Binds abstract fun categories(impl: CategoryRepositoryImpl): CategoryRepository
|
||||
@Binds abstract fun recurring(impl: RecurringRepositoryImpl): RecurringRepository
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package se.hagstedt.kvitt.data.repo
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import se.hagstedt.kvitt.data.api.AccountForecastDto
|
||||
import se.hagstedt.kvitt.data.api.ErrorCodes
|
||||
import se.hagstedt.kvitt.data.api.ForecastResponse
|
||||
import se.hagstedt.kvitt.data.api.KvittApi
|
||||
import se.hagstedt.kvitt.data.api.TrendDto
|
||||
import se.hagstedt.kvitt.data.api.TrendsResponse
|
||||
import se.hagstedt.kvitt.data.connectivity.OnlineGate
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
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.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.model.TrendPoint
|
||||
|
||||
/**
|
||||
* S-11's Insights (D-133, D-141): E-34 and E-35 through [OnlineGate] (§9.9: offline without a request; a network
|
||||
* failure is `Failed("network")`). Nothing is stored. Category and account ids are resolved from the phone's copy,
|
||||
* deleted rows included, so history still has names; an id the phone does not hold maps to null.
|
||||
*/
|
||||
class InsightsRepositoryImpl(private val local: LocalData, private val api: KvittApi, private val gate: OnlineGate) : InsightsRepository {
|
||||
override suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends> {
|
||||
if (months !in MONTHS) return OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months")
|
||||
return gate.run({ api.analyticsTrends(months, measure.wire()) }) { it.toModel(local.snapshot.first(), measure) }
|
||||
}
|
||||
|
||||
override suspend fun forecast(measure: Measure): OnlineResult<SpendForecast> =
|
||||
gate.run({ api.analyticsForecast(measure.wire()) }) { it.toModel(local.snapshot.first(), measure) }
|
||||
|
||||
companion object {
|
||||
/** E-34 `months`: 1 to 24 (`invalid_request` otherwise). */
|
||||
val MONTHS = 1..24
|
||||
}
|
||||
}
|
||||
|
||||
private fun Measure.wire(): String = name.lowercase()
|
||||
|
||||
/** The response's measure; an unknown wire value from a newer server reads as the one requested. */
|
||||
private fun measureOf(wire: String, requested: Measure): Measure = Measure.entries.firstOrNull { it.name.lowercase() == wire } ?: requested
|
||||
|
||||
internal fun TrendsResponse.toModel(s: Snapshot, requested: Measure): SpendTrends = SpendTrends(
|
||||
measure = measureOf(measure, requested),
|
||||
today = today,
|
||||
currentMonth = currentMonth,
|
||||
months = months,
|
||||
household = household.toModel(),
|
||||
categories = categories.map { CategoryTrend(CategoryId(it.categoryId), s.category(it.categoryId), it.trend.toModel()) },
|
||||
)
|
||||
|
||||
private fun TrendDto.toModel(): SpendTrend = SpendTrend(
|
||||
totalOre = totalOre,
|
||||
averageOre = averageOre,
|
||||
lastMonthOre = lastMonthOre,
|
||||
lastMonthVsAveragePct = lastMonthVsAveragePct,
|
||||
currentMonthOre = currentMonthOre,
|
||||
points = points.map { TrendPoint(it.month, it.amountOre, it.movingAverageOre) },
|
||||
)
|
||||
|
||||
internal fun ForecastResponse.toModel(s: Snapshot, requested: Measure): SpendForecast = SpendForecast(
|
||||
measure = measureOf(measure, requested),
|
||||
today = today,
|
||||
currentMonth = currentMonth,
|
||||
accounts = accounts.map { it.toModel(s) },
|
||||
)
|
||||
|
||||
private fun AccountForecastDto.toModel(s: Snapshot): AccountForecast = AccountForecast(
|
||||
accountId = AccountId(accountId),
|
||||
account = s.account(accountId),
|
||||
currentMonthOre = currentMonthOre,
|
||||
months = months.map { ForecastMonth(it.month, it.recurringOre, it.baselineOre, it.forecastOre) },
|
||||
)
|
||||
@@ -88,6 +88,8 @@ internal fun serializerFor(path: String): KSerializer<*>? {
|
||||
31 -> PullPage.serializer()
|
||||
32 -> if (request) PushRequest.serializer() else PushResponse.serializer()
|
||||
33 -> if (request) ProposalRequest.serializer() else ProposalResponse.serializer()
|
||||
34 -> TrendsResponse.serializer()
|
||||
35 -> ForecastResponse.serializer()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package se.hagstedt.kvitt.data.repo
|
||||
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import se.hagstedt.kvitt.data.Harness
|
||||
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
|
||||
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
|
||||
import se.hagstedt.kvitt.data.api.ErrorCodes
|
||||
import se.hagstedt.kvitt.data.api.ErrorDto
|
||||
import se.hagstedt.kvitt.data.api.KvittApi
|
||||
import se.hagstedt.kvitt.data.api.KvittJson
|
||||
import se.hagstedt.kvitt.data.api.examplesDir
|
||||
import se.hagstedt.kvitt.data.runSync
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
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 java.io.File
|
||||
|
||||
/** D-141: E-34 and E-35 through `InsightsRepositoryImpl`, mapped from the committed examples (§14.3.5), online only (§9.9). */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class InsightsRepositoryTest {
|
||||
private lateinit var h: Harness
|
||||
private lateinit var repo: InsightsRepositoryImpl
|
||||
|
||||
private fun example(dir: String) = File(examplesDir(), "$dir/response.json").readText()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
h = Harness().ready()
|
||||
repo = InsightsRepositoryImpl(h.local, h.api, h.gate)
|
||||
h.server.restHandler = { _, path ->
|
||||
when (path) {
|
||||
"/analytics/trends" -> HttpStatusCode.OK to example("E-34-analytics-trends")
|
||||
"/analytics/forecast" -> HttpStatusCode.OK to example("E-35-analytics-forecast")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = h.close()
|
||||
|
||||
@Test
|
||||
fun trendsMapTheE34ExampleAndResolveCategoriesFromThePhone() = runSync {
|
||||
val result = repo.trends(3, Measure.HOUSEHOLD)
|
||||
val trends = (result as OnlineResult.Ok).value
|
||||
val request = h.requests.single()
|
||||
assertEquals(HttpMethod.Get, request.method)
|
||||
assertEquals("/analytics/trends", request.url.encodedPath)
|
||||
assertEquals("3", request.url.parameters["months"])
|
||||
assertEquals("household", request.url.parameters["measure"])
|
||||
assertEquals("Bearer ${Harness.TOKEN}", request.headers[HttpHeaders.Authorization])
|
||||
assertEquals("1", request.headers[KvittApi.PROTOCOL_HEADER])
|
||||
assertEquals(Harness.APP_KEY, request.headers[KvittApi.APP_KEY_HEADER])
|
||||
|
||||
val household = SpendTrend(
|
||||
totalOre = 153_000, averageOre = 51_000, lastMonthOre = 78_000, lastMonthVsAveragePct = 53, currentMonthOre = 12_000,
|
||||
points = listOf(TrendPoint("2026-07", 40_000, 40_000), TrendPoint("2026-08", 35_000, 37_500), TrendPoint("2026-09", 78_000, 51_000)),
|
||||
)
|
||||
val expected = SpendTrends(Measure.HOUSEHOLD, "2026-10-17", "2026-10", 3, household, trends.categories)
|
||||
assertEquals(expected, trends)
|
||||
assertEquals(listOf(CategoryId(GROCERIES), CategoryId("01920000-0000-7000-8000-000000000003")), trends.categories.map { it.categoryId })
|
||||
assertEquals(DefaultCategoryKey.GROCERIES, trends.categories[0].category?.defaultKey)
|
||||
assertNull("a category the phone does not hold has no row", trends.categories[1].category)
|
||||
assertEquals(103, trends.categories[0].trend.lastMonthVsAveragePct)
|
||||
assertEquals(-25, trends.categories[1].trend.lastMonthVsAveragePct)
|
||||
assertEquals(listOf(30_000L, 0L, 63_000L), trends.categories[0].trend.points.map { it.amountOre })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forecastMapsTheE35ExampleWithTheRequestedMeasure() = runSync {
|
||||
val forecast = (repo.forecast(Measure.MY_SHARE) as OnlineResult.Ok).value
|
||||
val request = h.requests.single()
|
||||
assertEquals("/analytics/forecast", request.url.encodedPath)
|
||||
assertEquals("my_share", request.url.parameters["measure"])
|
||||
assertNull("E-35 takes no months", request.url.parameters["months"])
|
||||
// The example says household; the response's measure wins over the requested one.
|
||||
assertEquals(Measure.HOUSEHOLD, forecast.measure)
|
||||
val first = AccountForecast(
|
||||
AccountId(ACCOUNT),
|
||||
forecast.accounts[0].account,
|
||||
12_000,
|
||||
listOf(ForecastMonth("2026-11", 4_900, 40_000, 44_900), ForecastMonth("2026-12", 0, 40_000, 40_000)),
|
||||
)
|
||||
assertEquals(SpendForecast(Measure.HOUSEHOLD, "2026-10-17", "2026-10", forecast.accounts), forecast)
|
||||
assertEquals(first, forecast.accounts[0])
|
||||
assertEquals("Vardag", forecast.accounts[0].account?.name)
|
||||
assertEquals(listOf(null, null), forecast.accounts.drop(1).map { it.account })
|
||||
assertEquals(listOf(16_000L, 16_000L), forecast.accounts[1].months.map { it.forecastOre })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineIsOfflineWithoutARequest() = runSync {
|
||||
h.monitor.state.value = false
|
||||
assertEquals(OnlineResult.Offline, repo.trends(6, Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Offline, repo.forecast(Measure.HOUSEHOLD))
|
||||
assertTrue(h.requests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNetworkFailureIsFailedNetwork() = runSync {
|
||||
h.server.failNetwork = true
|
||||
assertEquals(OnlineResult.Failed(ErrorCodes.NETWORK, null), repo.trends(6, Measure.HOUSEHOLD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverErrorsKeepTheirCodeAndField() = runSync {
|
||||
h.server.restHandler = { _, _ -> HttpStatusCode.BadRequest to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("invalid_request", "bad", "measure")) }
|
||||
assertEquals(OnlineResult.Failed("invalid_request", "measure"), repo.forecast(Measure.HOUSEHOLD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun monthsOutsideOneToTwentyFourAreRefusedWithoutARequest() = runSync {
|
||||
assertEquals(OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months"), repo.trends(0, Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months"), repo.trends(25, Measure.HOUSEHOLD))
|
||||
assertTrue(h.requests.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user