M12f: S-16 lost version read-only, S-22 versions from /health, settle start without a partner (D-109 to D-111)

- Rejection.lost: RejectedVersion (the pushed entity from sync_rejections.rejected_payload as a ledger row, account,
  payer, split, notes, deleted); S-16 "Öppna" shows it read-only in a dialog with "Visa nuvarande version" (S-8/S-10);
  without one it opens the current version as before. Ledger rows take a null onClick (read-only).
- AboutState.schemaVersion; AboutRepositoryImpl calls E-1 /health once per collection when online; header fallback.
  S-22 shows "1.1.0 (schema 3)".
- SettlementRepository.start/recompute return null without a session or partner; save is Invalid(payer); no
  checkNotNull/requireNotNull in :data.
- :feature:home screenshot harness: one more frame and an idle looper before each capture; the committed sv_dark,
  200 % and dialog goldens were one variant stale (light instead of dark, en instead of sv_200).
- Tests: lost version (stale, refused deletion, unreadable payload), /health online and offline, settle without a
  partner, category reorder PATCHes, recurring toWire splits, §9.2 effect cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:32:24 +02:00
parent 7fb86f5524
commit dc7a296d70
90 changed files with 550 additions and 70 deletions
@@ -71,7 +71,7 @@ object FakeScenarios {
clockSkew = false,
syncRunning = false,
update = UpdateState.None,
about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null),
about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null, null),
online = true,
today = "2026-10-17",
now = "2026-10-17T06:00:00.000Z",
@@ -22,9 +22,9 @@ import se.hagstedt.kvitt.model.money.SettlementError
import kotlin.math.absoluteValue
class FakeSettlementRepository(private val store: FakeStore) : SettlementRepository {
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? {
val s = store.current
val partner = requireNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
val partner = s.partner ?: return null
val existing = editing?.let { id -> s.settlements.firstOrNull { it.id == id } }
if (existing != null) {
return s.settleState(
@@ -39,7 +39,7 @@ class FakeSettlementRepository(private val store: FakeStore) : SettlementReposit
return s.settleState(SettleRequest(null, payer, amount, fixedAccount, null, s.today, ""))
}
override suspend fun recompute(request: SettleRequest): SettleState = store.current.settleState(request)
override suspend fun recompute(request: SettleRequest): SettleState? = store.current.settleState(request)
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
val s = store.current
@@ -9,7 +9,10 @@ import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseEffect
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.RejectedVersion
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
@@ -119,8 +122,47 @@ object HomeScenarios {
settlement(4, jacob, mapOf(huset.id to 308_450, japan.id to 135_000), "2026-10-17"),
)
private fun categoryOf(key: DefaultCategoryKey) = FakeScenarios.defaultCategories.first { it.defaultKey == key }
/** A lost expense version (D-109): Jacob paid, split in halves by the account default unless [jacobBp] says otherwise. */
private fun lostExpense(
n: Int,
key: DefaultCategoryKey,
amountOre: Long,
description: String,
date: String,
mode: SplitMode = SplitMode.DEFAULT,
jacobBp: Int = 5_000,
notes: String = "",
deleted: Boolean = false,
): RejectedVersion {
val jacobShare = amountOre * jacobBp / 10_000
val row = LedgerRow.ExpenseRow(ExpenseId(id("e", n)), date, description, categoryOf(key), amountOre, ExpenseEffect.Lent(amountOre - jacobShare), pending = false)
val bp = if (mode == SplitMode.EXACT) null else jacobBp
val shares = listOf(Share(jacob, jacobShare, bp), Share(anna, amountOre - jacobShare, bp?.let { 10_000 - it }))
return RejectedVersion(row, vardag.name, FakeScenarios.owner, mode, shares, notes, deleted)
}
/** Jacob's lost version of the Babette dinner: 1 240 kr, 60/40, with a note. */
val lostBabette = lostExpense(12, DefaultCategoryKey.RESTAURANTS, 124_000, "Babette", "2026-10-16", mode = SplitMode.RATIO, jacobBp = 6_000, notes = "Pizza och vin med Lisa och Erik")
/** A `stale` rejection of Anna's newer Babette expense (S-16 loaded). */
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z", FakeScenarios.partner)
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z", FakeScenarios.partner, lostBabette)
/** A refused deletion (clock skew): the cinema tickets Jacob deleted on the phone. */
val clockSkewRejection = Rejection(
"m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z", null,
lostExpense(15, DefaultCategoryKey.ENTERTAINMENT, 34_000, "Biobiljetter Rigoletto", "2026-10-15", deleted = true),
)
/** A refused payment from Jacob to Anna, 500 kr in Vardag. */
val paymentRejection = Rejection(
"m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z", null,
RejectedVersion(
LedgerRow.SettlementRow(SettlementId(id("5", 9)), "2026-10-17", FakeScenarios.owner, FakeScenarios.partner, 50_000, 1, pending = false),
null, null, null, emptyList(), "Betalning till Anna", deleted = false,
),
)
/** S-16 loaded: two changes waiting and one `stale` rejection. */
val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection))
@@ -180,10 +222,14 @@ object HomeScenarios {
val allRejections: FakeScenario = pending.copy(
rejections = listOf(
staleRejection,
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z", null),
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z", null),
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z", null),
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z", null),
Rejection(
"m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z", null,
lostExpense(13, DefaultCategoryKey.GROCERIES, 8_900, "Pressbyrån Odenplan", "2026-10-16"),
),
paymentRejection,
// Its category is gone from the phone too, so there is no lost version to show; "Öppna" opens S-8.
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z", null, null),
clockSkewRejection,
),
)
@@ -116,7 +116,7 @@ object SettingsScenarios {
devices = devices,
invites = listOf(pendingDeviceInvite),
lastSuccessAt = "2026-10-17T11:59:00.000Z",
about = AboutState("1.1", 11, "https://icehack.asuscomm.com:8443", "1.1.0"),
about = AboutState("1.1", 11, "https://icehack.asuscomm.com:8443", "1.1.0", 3),
today = "2026-10-17",
now = "2026-10-17T12:02:00.000Z",
defaultAccountId = huset.id,
@@ -188,7 +188,8 @@ object SettingsScenarios {
},
)
private fun FakeScenario.withServerVersion(version: String?): FakeScenario = copy(about = about.copy(serverVersion = version))
/** Offline the server version is the last response's header (here none) and the schema version unknown (D-110). */
private fun FakeScenario.withServerVersion(version: String?): FakeScenario = copy(about = about.copy(serverVersion = version, schemaVersion = null))
/** Every scenario of this file by name, for previews and tests. */
val all: Map<String, FakeScenario> = mapOf(
@@ -50,7 +50,7 @@ class EmptyScenarioTest {
assertNull(options.defaultAccountId)
assertEquals(14, options.categories.size)
assertTrue(options.frequentCategoryIds.isEmpty()) // UX-9: no active account, no entry
val settle = fakes.settlements.start(null, null)
val settle = requireNotNull(fakes.settlements.start(null, null))
assertNull(settle.amountOre)
assertTrue(settle.lines.isEmpty())
}