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:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
+20
View File
@@ -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) })