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,20 @@
|
||||
// :domain — the pure rules of SPEC §14.5 (split, balances, allocation, settlement check, analytics).
|
||||
plugins {
|
||||
id("kvitt.jvm.library")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":core:model"))
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
// The shared test vectors (SPEC §14.5.5) are copied from the repository's contracts/ folder into the test resources.
|
||||
// A missing file is not tolerated: VectorFilesTest fails unless exactly the seven files of §14.5.5 are present.
|
||||
val testVectorsRoot = layout.buildDirectory.dir("generated/test-vectors")
|
||||
val copyTestVectors = tasks.register<Sync>("copyTestVectors") {
|
||||
description = "Copies contracts/test-vectors/*.json into the :domain test resources."
|
||||
from(layout.projectDirectory.dir("../../contracts/test-vectors")) { include("*.json") }
|
||||
into(testVectorsRoot.map { it.dir("test-vectors") })
|
||||
}
|
||||
sourceSets.named("test") { resources.srcDir(testVectorsRoot) }
|
||||
tasks.named("processTestResources") { dependsOn(copyTestVectors) }
|
||||
@@ -0,0 +1,64 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import se.hagstedt.kvitt.model.money.AccountPosition
|
||||
import se.hagstedt.kvitt.model.money.Allocation
|
||||
import se.hagstedt.kvitt.model.money.AllocationError
|
||||
import se.hagstedt.kvitt.model.money.AllocationResult
|
||||
import se.hagstedt.kvitt.model.money.SettlementError
|
||||
|
||||
private val accountOrder = compareBy<AccountPosition>({ it.createdAt }, { it.accountId })
|
||||
|
||||
/** Settlement allocation proposal, the normative algorithm of SPEC §9.4 (§14.5.2). */
|
||||
fun proposeAllocation(amountOre: Long, accounts: List<AccountPosition>): AllocationResult {
|
||||
if (!amountInRange(amountOre)) return AllocationResult.Err(AllocationError.AMOUNT_OUT_OF_RANGE)
|
||||
if (accounts.isEmpty()) return AllocationResult.Err(AllocationError.NO_ACCOUNTS)
|
||||
|
||||
val owing = accounts.filter { it.payerNetOre < 0 }.sortedWith(accountOrder)
|
||||
if (owing.isEmpty()) {
|
||||
val target = accounts.filter { !it.archived }.sortedWith(accountOrder).firstOrNull()
|
||||
?: accounts.sortedWith(accountOrder).first()
|
||||
return AllocationResult.Ok(listOf(Allocation(target.accountId, amountOre)), amountOre, listOf(target.accountId))
|
||||
}
|
||||
val allocations = mutableListOf<Allocation>()
|
||||
var remaining = amountOre
|
||||
for (position in owing) {
|
||||
if (remaining <= 0) break
|
||||
val amount = minOf(remaining, -position.payerNetOre)
|
||||
allocations += Allocation(position.accountId, amount)
|
||||
remaining -= amount
|
||||
}
|
||||
if (remaining <= 0) return AllocationResult.Ok(allocations, 0L, emptyList())
|
||||
val last = allocations.removeAt(allocations.lastIndex)
|
||||
allocations += last.copy(amountOre = last.amountOre + remaining)
|
||||
return AllocationResult.Ok(allocations, remaining, listOf(last.accountId))
|
||||
}
|
||||
|
||||
/** Settlement validity of SPEC §9.4; errors in [SettlementError] order, `null` when valid. References are not checked. */
|
||||
fun validateSettlement(
|
||||
amountOre: Long,
|
||||
fromUserId: String,
|
||||
toUserId: String,
|
||||
userIds: List<String>,
|
||||
allocations: List<Allocation>,
|
||||
): SettlementError? = when {
|
||||
!amountInRange(amountOre) -> SettlementError.AMOUNT_OUT_OF_RANGE
|
||||
fromUserId == toUserId -> SettlementError.SAME_USER
|
||||
fromUserId !in userIds || toUserId !in userIds -> SettlementError.USER_UNKNOWN
|
||||
allocations.isEmpty() -> SettlementError.NO_ALLOCATIONS
|
||||
allocations.size > MAX_ALLOCATIONS -> SettlementError.TOO_MANY_ALLOCATIONS
|
||||
allocations.any { it.amountOre < 1 } -> SettlementError.ALLOCATION_NOT_POSITIVE
|
||||
allocations.map { it.accountId }.toSet().size != allocations.size -> SettlementError.DUPLICATE_ACCOUNT
|
||||
// Every allocation is >= 1 here, so one above the amount already proves a mismatch (and avoids overflow).
|
||||
allocations.any { it.amountOre > amountOre } || allocations.sumOf { it.amountOre } != amountOre ->
|
||||
SettlementError.ALLOCATION_SUM_MISMATCH
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Over-settle warnings (SPEC §9.4): ids, in allocation order, whose allocation exceeds max(0, -payerNetOre).
|
||||
* An allocation for an account not in [accounts] counts as net 0.
|
||||
*/
|
||||
fun overSettledAccounts(allocations: List<Allocation>, accounts: List<AccountPosition>): List<String> {
|
||||
val net = accounts.associate { it.accountId to it.payerNetOre }
|
||||
return allocations.filter { it.amountOre > maxOf(0L, -(net[it.accountId] ?: 0L)) }.map { it.accountId }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
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.Bucket
|
||||
import se.hagstedt.kvitt.model.analytics.DateRange
|
||||
import se.hagstedt.kvitt.model.analytics.SpendSeries
|
||||
|
||||
/**
|
||||
* Spend per bucket and group (SPEC §14.5.4). Buckets cover the range completely, in order, empty ones included, with
|
||||
* their start and end clipped to the range. Zero groups are omitted. Expenses outside the range are ignored.
|
||||
*/
|
||||
fun spendSeries(
|
||||
expenses: List<AnalyticsExpense>,
|
||||
range: DateRange,
|
||||
granularity: AnalyticsGranularity,
|
||||
measure: AnalyticsMeasure,
|
||||
groupBy: AnalyticsGroupBy,
|
||||
): SpendSeries {
|
||||
val start = range.start
|
||||
val end = range.endInclusive
|
||||
val keyOf: (LocalDate) -> String = when (granularity) {
|
||||
AnalyticsGranularity.MONTH -> ::monthKey
|
||||
AnalyticsGranularity.WEEK -> ::isoWeekKey
|
||||
}
|
||||
val slots = mutableListOf<Triple<String, LocalDate, LocalDate>>()
|
||||
var cursor = when (granularity) {
|
||||
AnalyticsGranularity.MONTH -> LocalDate(start.year, start.month, 1)
|
||||
AnalyticsGranularity.WEEK -> start.minus(start.dayOfWeek.isoDayNumber - 1, DateTimeUnit.DAY)
|
||||
}
|
||||
while (cursor <= end) {
|
||||
val next = when (granularity) {
|
||||
AnalyticsGranularity.MONTH -> cursor.plus(1, DateTimeUnit.MONTH)
|
||||
AnalyticsGranularity.WEEK -> cursor.plus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
val last = next.minus(1, DateTimeUnit.DAY)
|
||||
slots += Triple(keyOf(cursor), maxOf(cursor, start), minOf(last, end))
|
||||
cursor = next
|
||||
}
|
||||
|
||||
val totals = slots.associateTo(LinkedHashMap()) { it.first to LinkedHashMap<String, Long>() }
|
||||
for (expense in expenses) {
|
||||
if (expense.date < start || expense.date > end) continue
|
||||
val value = when (measure) {
|
||||
AnalyticsMeasure.Household -> expense.amountOre
|
||||
is AnalyticsMeasure.Share -> expense.shares[measure.userId] ?: 0L
|
||||
}
|
||||
val group = when (groupBy) {
|
||||
AnalyticsGroupBy.CATEGORY -> expense.categoryId
|
||||
AnalyticsGroupBy.ACCOUNT -> expense.accountId
|
||||
}
|
||||
totals[keyOf(expense.date)]?.let { it[group] = (it[group] ?: 0L) + value }
|
||||
}
|
||||
|
||||
val groupTotals = LinkedHashMap<String, Long>()
|
||||
val buckets = slots.map { (key, bucketStart, bucketEnd) ->
|
||||
val nonZero = totals[key].orEmpty().filterValues { it != 0L }
|
||||
nonZero.forEach { (group, value) -> groupTotals[group] = (groupTotals[group] ?: 0L) + value }
|
||||
Bucket(key, bucketStart, bucketEnd, nonZero, nonZero.values.sum())
|
||||
}
|
||||
val nonZeroGroups = groupTotals.filterValues { it != 0L }
|
||||
return SpendSeries(buckets, nonZeroGroups, nonZeroGroups.values.sum())
|
||||
}
|
||||
|
||||
/** `CUSTOM` without a start or an end date: the caller maps this to `validation_failed` (SPEC §14.5.4). */
|
||||
class IncompleteCustomRangeException(missing: String) :
|
||||
IllegalArgumentException("resolveRange: CUSTOM needs both dates; $missing is null")
|
||||
|
||||
/**
|
||||
* The date range of an analytics preset (SPEC §14.5.4, AC-14.2).
|
||||
* @throws IncompleteCustomRangeException for [RangePreset.CUSTOM] with a null [customStart] or [customEnd].
|
||||
*/
|
||||
fun resolveRange(
|
||||
preset: RangePreset,
|
||||
today: LocalDate,
|
||||
earliestExpense: LocalDate?,
|
||||
customStart: LocalDate?,
|
||||
customEnd: LocalDate?,
|
||||
): DateRange {
|
||||
val firstOfMonth = LocalDate(today.year, today.month, 1)
|
||||
return when (preset) {
|
||||
RangePreset.THIS_MONTH -> DateRange(firstOfMonth, today)
|
||||
RangePreset.LAST_3_MONTHS -> DateRange(firstOfMonth.minus(2, DateTimeUnit.MONTH), today)
|
||||
RangePreset.LAST_6_MONTHS -> DateRange(firstOfMonth.minus(5, DateTimeUnit.MONTH), today)
|
||||
RangePreset.LAST_12_MONTHS -> DateRange(firstOfMonth.minus(11, DateTimeUnit.MONTH), today)
|
||||
RangePreset.THIS_YEAR -> DateRange(LocalDate(today.year, 1, 1), today)
|
||||
// An earliest expense after today gives today..today (SPEC §14.5.4).
|
||||
RangePreset.ALL_TIME -> DateRange(earliestExpense?.takeIf { it <= today } ?: today, today)
|
||||
RangePreset.CUSTOM -> {
|
||||
val s = customStart ?: throw IncompleteCustomRangeException("customStart")
|
||||
val e = customEnd ?: throw IncompleteCustomRangeException("customEnd")
|
||||
if (s > e) DateRange(e, s) else DateRange(s, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthKey(date: LocalDate): String = "${pad(date.year, 4)}-${pad(date.month.number, 2)}"
|
||||
|
||||
/** ISO week key `YYYY-Www`: the week-numbering year is the year of the week's Thursday. */
|
||||
private fun isoWeekKey(date: LocalDate): String {
|
||||
val thursday = date.plus(4 - date.dayOfWeek.isoDayNumber, DateTimeUnit.DAY)
|
||||
val week = (thursday.dayOfYear - 1) / 7 + 1
|
||||
return "${pad(thursday.year, 4)}-W${pad(week, 2)}"
|
||||
}
|
||||
|
||||
private fun pad(value: Int, width: Int): String = value.toString().padStart(width, '0')
|
||||
@@ -0,0 +1,41 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import se.hagstedt.kvitt.model.money.BalanceExpense
|
||||
import se.hagstedt.kvitt.model.money.BalanceSettlement
|
||||
import se.hagstedt.kvitt.model.money.Balances
|
||||
|
||||
/**
|
||||
* Net per account and user (SPEC §9.3, §14.5.2). Inputs hold only non-deleted rows. [Balances.perAccount] has an entry
|
||||
* for every id of [accountIds] and for any other account referenced; each inner map has every id of [userIds].
|
||||
*/
|
||||
fun computeBalances(
|
||||
userIds: List<String>,
|
||||
accountIds: List<String>,
|
||||
expenses: List<BalanceExpense>,
|
||||
settlements: List<BalanceSettlement>,
|
||||
): Balances {
|
||||
val perAccount = LinkedHashMap<String, MutableMap<String, Long>>()
|
||||
fun account(id: String): MutableMap<String, Long> =
|
||||
perAccount.getOrPut(id) { userIds.associateWithTo(LinkedHashMap()) { 0L } }
|
||||
|
||||
accountIds.forEach { account(it) }
|
||||
for (expense in expenses) {
|
||||
val net = account(expense.accountId)
|
||||
net.add(expense.payerUserId, expense.amountOre)
|
||||
expense.shares.forEach { (user, share) -> net.add(user, -share) }
|
||||
}
|
||||
for (settlement in settlements) {
|
||||
settlement.allocations.forEach { (accountId, amount) ->
|
||||
val net = account(accountId)
|
||||
net.add(settlement.fromUserId, amount)
|
||||
net.add(settlement.toUserId, -amount)
|
||||
}
|
||||
}
|
||||
val global = userIds.associateWithTo(LinkedHashMap()) { 0L }
|
||||
perAccount.values.forEach { net -> net.forEach { (user, value) -> global.add(user, value) } }
|
||||
return Balances(perAccount, global)
|
||||
}
|
||||
|
||||
private fun MutableMap<String, Long>.add(key: String, value: Long) {
|
||||
this[key] = (this[key] ?: 0L) + value
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
/** Amount limits of SPEC §9.6 (expense, settlement, template). */
|
||||
const val AMOUNT_MIN_ORE: Long = 1L
|
||||
const val AMOUNT_MAX_ORE: Long = 1_000_000_000L
|
||||
|
||||
/** 10000 bp = 100 % (SPEC §9.1). */
|
||||
const val BP_TOTAL: Int = 10_000
|
||||
|
||||
/** Allocations per settlement (SPEC §9.6). */
|
||||
const val MAX_ALLOCATIONS: Int = 50
|
||||
|
||||
internal fun amountInRange(amountOre: Long): Boolean = amountOre in AMOUNT_MIN_ORE..AMOUNT_MAX_ORE
|
||||
@@ -0,0 +1,45 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import se.hagstedt.kvitt.model.money.SplitError
|
||||
import se.hagstedt.kvitt.model.money.SplitResult
|
||||
import se.hagstedt.kvitt.model.money.SplitSpec
|
||||
|
||||
/**
|
||||
* Shares of an expense (SPEC §9.1, §14.5.2). Errors are checked in [SplitError] order and the first failure wins.
|
||||
* Ratio rule: the non-payer's share is rounded down, the payer absorbs the remainder.
|
||||
*/
|
||||
fun computeSplit(amountOre: Long, payerUserId: String, userIds: List<String>, spec: SplitSpec): SplitResult {
|
||||
if (!amountInRange(amountOre)) return SplitResult.Err(SplitError.AMOUNT_OUT_OF_RANGE)
|
||||
val users = userIds.toSet()
|
||||
if (userIds.size != 2 || users.size != 2) return SplitResult.Err(SplitError.USERS_INVALID)
|
||||
if (payerUserId !in users) return SplitResult.Err(SplitError.PAYER_UNKNOWN)
|
||||
return when (spec) {
|
||||
is SplitSpec.Ratio -> when {
|
||||
spec.bp.values.any { it !in 0..BP_TOTAL } -> SplitResult.Err(SplitError.BP_OUT_OF_RANGE)
|
||||
spec.bp.values.sumOf { it.toLong() } != BP_TOTAL.toLong() -> SplitResult.Err(SplitError.BP_SUM_NOT_10000)
|
||||
spec.bp.keys != users -> SplitResult.Err(SplitError.SHARE_USERS_MISMATCH)
|
||||
else -> SplitResult.Ok(ratioRule(amountOre, payerUserId, userIds, spec.bp))
|
||||
}
|
||||
is SplitSpec.Exact -> when {
|
||||
spec.ore.values.any { it < 0 } -> SplitResult.Err(SplitError.EXACT_NEGATIVE)
|
||||
// Every value is >= 0 here, so one value above the amount already proves a mismatch (and avoids overflow).
|
||||
spec.ore.values.any { it > amountOre } || spec.ore.values.sum() != amountOre ->
|
||||
SplitResult.Err(SplitError.EXACT_SUM_MISMATCH)
|
||||
spec.ore.keys != users -> SplitResult.Err(SplitError.SHARE_USERS_MISMATCH)
|
||||
else -> SplitResult.Ok(userIds.associateWith { spec.ore[it] ?: 0L })
|
||||
}
|
||||
is SplitSpec.Full -> if (spec.bearerUserId !in users) {
|
||||
SplitResult.Err(SplitError.BEARER_UNKNOWN)
|
||||
} else {
|
||||
val bp = userIds.associateWith { if (it == spec.bearerUserId) BP_TOTAL else 0 }
|
||||
SplitResult.Ok(ratioRule(amountOre, payerUserId, userIds, bp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ratioRule(amountOre: Long, payerUserId: String, userIds: List<String>, bp: Map<String, Int>): Map<String, Long> {
|
||||
val nonPayer = userIds.first { it != payerUserId }
|
||||
// 64-bit: the largest product, 1 000 000 000 x 10000, fits. Operands are non-negative, so division floors.
|
||||
val nonPayerShare = amountOre * (bp[nonPayer] ?: 0) / BP_TOTAL
|
||||
return mapOf(payerUserId to amountOre - nonPayerShare, nonPayer to nonPayerShare)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import se.hagstedt.kvitt.model.analytics.DateRange
|
||||
|
||||
/** Cases of SPEC §14.5.4 that no vector covers. */
|
||||
class ResolveRangeTest {
|
||||
private val today = LocalDate(2026, 10, 17)
|
||||
|
||||
@Test
|
||||
fun customWithoutStartOrEndIsAnError() {
|
||||
assertThrows(IncompleteCustomRangeException::class.java) { resolveRange(RangePreset.CUSTOM, today, null, null, today) }
|
||||
assertThrows(IncompleteCustomRangeException::class.java) { resolveRange(RangePreset.CUSTOM, today, null, today, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allTimeWithEarliestAfterTodayIsTodayOnly() {
|
||||
assertEquals(DateRange(today, today), resolveRange(RangePreset.ALL_TIME, today, LocalDate(2026, 12, 1), null, null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The vector directory holds exactly the seven files of SPEC §14.5.5; a missing or untested file fails. */
|
||||
class VectorFilesTest {
|
||||
@Test
|
||||
fun everyVectorFileIsPresentAndTested() {
|
||||
val url = VectorFilesTest::class.java.getResource("/${Vectors.DIRECTORY}")
|
||||
?: error("no test-vectors resource directory: contracts/test-vectors was not copied")
|
||||
val present = File(url.toURI()).listFiles().orEmpty().map { it.name }.filter { it.endsWith(".json") }.toSet()
|
||||
assertEquals(VECTOR_FUNCTIONS.map { "$it.json" }.toSet(), present)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyFileHasCases() {
|
||||
VECTOR_FUNCTIONS.forEach { assertTrue("$it.json has no cases", Vectors.cases(it).isNotEmpty()) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
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
|
||||
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.BalanceExpense
|
||||
import se.hagstedt.kvitt.model.money.BalanceSettlement
|
||||
import se.hagstedt.kvitt.model.money.SplitResult
|
||||
import se.hagstedt.kvitt.model.money.SplitSpec
|
||||
|
||||
/** Every case of every file in contracts/test-vectors/ (SPEC §14.5.5), one JUnit test per case. */
|
||||
@RunWith(Parameterized::class)
|
||||
class VectorTest(private val case: VectorCase, private val label: String) {
|
||||
|
||||
@Test
|
||||
fun matchesExpected() {
|
||||
assertEquals(label, case.expected, run(case.function, case.input))
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{1}")
|
||||
fun cases(): List<Array<Any>> = VECTOR_FUNCTIONS.flatMap { Vectors.cases(it) }.map { arrayOf<Any>(it, "${it.function}: ${it.name}") }
|
||||
|
||||
fun run(function: String, input: JsonObject): JsonElement = when (function) {
|
||||
"compute_split" -> split(input)
|
||||
"compute_balances" -> balances(input)
|
||||
"propose_allocation" -> proposal(input)
|
||||
"validate_settlement" -> settlement(input)
|
||||
"over_settled_accounts" -> ok(strings(overSettledAccounts(allocations(input), positions(input))))
|
||||
"spend_series" -> series(input)
|
||||
"resolve_range" -> range(input)
|
||||
else -> error("no runner for $function")
|
||||
}
|
||||
|
||||
private fun split(input: JsonObject): JsonElement {
|
||||
val spec = input.getValue("spec").jsonObject
|
||||
val splitSpec = when (val mode = spec.str("mode")) {
|
||||
"ratio" -> SplitSpec.Ratio(spec.intMap("bp"))
|
||||
"exact" -> SplitSpec.Exact(spec.longMap("ore"))
|
||||
"full" -> SplitSpec.Full(spec.str("bearer_user_id"))
|
||||
else -> error("unknown mode $mode")
|
||||
}
|
||||
return when (val r = computeSplit(input.long("amount_ore"), input.str("payer_user_id"), input.strings("user_ids"), splitSpec)) {
|
||||
is SplitResult.Ok -> ok(longs(r.shares))
|
||||
is SplitResult.Err -> err(r.error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun balances(input: JsonObject): JsonElement {
|
||||
val expenses = input.objects("expenses").map {
|
||||
BalanceExpense(it.str("account_id"), it.long("amount_ore"), it.str("payer_user_id"), it.longMap("shares"))
|
||||
}
|
||||
val settlements = input.objects("settlements").map {
|
||||
BalanceSettlement(it.str("from_user_id"), it.str("to_user_id"), it.longMap("allocations"))
|
||||
}
|
||||
val b = computeBalances(input.strings("user_ids"), input.strings("account_ids"), expenses, settlements)
|
||||
return ok(JsonObject(mapOf("per_account" to JsonObject(b.perAccount.mapValues { longs(it.value) }), "global" to longs(b.global))))
|
||||
}
|
||||
|
||||
private fun positions(input: JsonObject): List<AccountPosition> = input.objects("accounts").map {
|
||||
AccountPosition(it.str("account_id"), it.str("created_at"), it.bool("archived"), it.long("payer_net_ore"))
|
||||
}
|
||||
|
||||
private fun allocations(input: JsonObject): List<Allocation> =
|
||||
input.objects("allocations").map { Allocation(it.str("account_id"), it.long("amount_ore")) }
|
||||
|
||||
private fun proposal(input: JsonObject): JsonElement = when (val r = proposeAllocation(input.long("amount_ore"), positions(input))) {
|
||||
is AllocationResult.Ok -> ok(
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"allocations" to JsonArray(r.allocations.map {
|
||||
JsonObject(mapOf("account_id" to JsonPrimitive(it.accountId), "amount_ore" to JsonPrimitive(it.amountOre)))
|
||||
}),
|
||||
"excess_ore" to JsonPrimitive(r.excessOre),
|
||||
"over_settled_account_ids" to strings(r.overSettledAccountIds),
|
||||
),
|
||||
),
|
||||
)
|
||||
is AllocationResult.Err -> err(r.error)
|
||||
}
|
||||
|
||||
private fun settlement(input: JsonObject): JsonElement {
|
||||
val error = validateSettlement(
|
||||
input.long("amount_ore"), input.str("from_user_id"), input.str("to_user_id"), input.strings("user_ids"), allocations(input),
|
||||
)
|
||||
return if (error == null) ok(JsonNull) else err(error)
|
||||
}
|
||||
|
||||
private fun series(input: JsonObject): JsonElement {
|
||||
val expenses = input.objects("expenses").map {
|
||||
AnalyticsExpense(LocalDate.parse(it.str("date")), it.str("account_id"), it.str("category_id"), it.long("amount_ore"), it.longMap("shares"))
|
||||
}
|
||||
val r = input.getValue("range").jsonObject
|
||||
val range = DateRange(LocalDate.parse(r.str("start")), LocalDate.parse(r.str("end_inclusive")))
|
||||
val granularity = when (val g = input.str("granularity")) {
|
||||
"month" -> AnalyticsGranularity.MONTH
|
||||
"week" -> AnalyticsGranularity.WEEK
|
||||
else -> error("granularity $g")
|
||||
}
|
||||
val m = input.getValue("measure")
|
||||
val measure = if (m is JsonPrimitive && m.content == "household") {
|
||||
AnalyticsMeasure.Household
|
||||
} else {
|
||||
AnalyticsMeasure.Share(m.jsonObject.getValue("share").jsonPrimitive.content)
|
||||
}
|
||||
val groupBy = when (val g = input.str("group_by")) {
|
||||
"category" -> AnalyticsGroupBy.CATEGORY
|
||||
"account" -> AnalyticsGroupBy.ACCOUNT
|
||||
else -> error("group_by $g")
|
||||
}
|
||||
val s = spendSeries(expenses, range, granularity, measure, groupBy)
|
||||
return ok(
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"buckets" to JsonArray(s.buckets.map {
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"key" to JsonPrimitive(it.key),
|
||||
"start" to JsonPrimitive(it.start.toString()),
|
||||
"end_inclusive" to JsonPrimitive(it.endInclusive.toString()),
|
||||
"totals" to longs(it.totals),
|
||||
"total" to JsonPrimitive(it.total),
|
||||
),
|
||||
)
|
||||
}),
|
||||
"group_totals" to longs(s.groupTotals),
|
||||
"grand_total" to JsonPrimitive(s.grandTotal),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun range(input: JsonObject): JsonElement {
|
||||
val preset = RangePreset.valueOf(input.str("preset").uppercase())
|
||||
fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) }
|
||||
val r = resolveRange(preset, LocalDate.parse(input.str("today")), date("earliest_expense"), date("custom_start"), date("custom_end"))
|
||||
return ok(JsonObject(mapOf("start" to JsonPrimitive(r.start.toString()), "end_inclusive" to JsonPrimitive(r.endInclusive.toString()))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
|
||||
/** The seven vector files of SPEC §14.5.5, keyed by function name. */
|
||||
val VECTOR_FUNCTIONS: List<String> = listOf(
|
||||
"compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts",
|
||||
"spend_series", "resolve_range",
|
||||
)
|
||||
|
||||
data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement)
|
||||
|
||||
object Vectors {
|
||||
const val DIRECTORY = "test-vectors"
|
||||
|
||||
fun text(function: String): String {
|
||||
val path = "/$DIRECTORY/$function.json"
|
||||
val stream = Vectors::class.java.getResourceAsStream(path)
|
||||
?: error("missing vector file $path (copied from contracts/test-vectors by :domain:copyTestVectors)")
|
||||
return stream.use { it.readBytes().decodeToString() }
|
||||
}
|
||||
|
||||
fun cases(function: String): List<VectorCase> {
|
||||
val root = Json.parseToJsonElement(text(function)).jsonObject
|
||||
check(root.getValue("format").jsonPrimitive.int == 1) { "$function.json: format must be 1" }
|
||||
check(root.getValue("function").jsonPrimitive.content == function) { "$function.json: function key mismatch" }
|
||||
return root.getValue("cases").jsonArray.map { case ->
|
||||
val o = case.jsonObject
|
||||
VectorCase(function, o.getValue("name").jsonPrimitive.content, o.getValue("input").jsonObject, o.getValue("expected"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON helpers for the runner.
|
||||
internal fun JsonObject.str(key: String): String = getValue(key).jsonPrimitive.content
|
||||
internal fun JsonObject.strOrNull(key: String): String? = (get(key) as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content
|
||||
internal fun JsonObject.long(key: String): Long = getValue(key).jsonPrimitive.long
|
||||
internal fun JsonObject.bool(key: String): Boolean = getValue(key).jsonPrimitive.boolean
|
||||
internal fun JsonObject.strings(key: String): List<String> = getValue(key).jsonArray.map { it.jsonPrimitive.content }
|
||||
internal fun JsonObject.longMap(key: String): Map<String, Long> = getValue(key).jsonObject.mapValues { it.value.jsonPrimitive.long }
|
||||
internal fun JsonObject.intMap(key: String): Map<String, Int> = getValue(key).jsonObject.mapValues { it.value.jsonPrimitive.int }
|
||||
internal fun JsonObject.objects(key: String): List<JsonObject> = getValue(key).jsonArray.map { it.jsonObject }
|
||||
|
||||
internal fun ok(value: JsonElement): JsonElement = JsonObject(mapOf("ok" to value))
|
||||
internal fun err(token: Enum<*>): JsonElement = JsonObject(mapOf("error" to JsonPrimitive(token.name.lowercase())))
|
||||
internal fun longs(map: Map<String, Long>): JsonObject = JsonObject(map.mapValues { JsonPrimitive(it.value) })
|
||||
internal fun strings(list: List<String>): JsonArray = JsonArray(list.map { JsonPrimitive(it) })
|
||||
Reference in New Issue
Block a user