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
@@ -23,10 +23,12 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi
vm.ui.value
}
/** Sync status with [scenario]. */
fun syncState(scenario: FakeScenario): SyncStatusUi = withMain {
/** Sync status with [scenario] after [act] on its ViewModel. */
fun syncState(scenario: FakeScenario, act: (SyncStatusViewModel) -> Unit = {}): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
val vm = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session)
act(vm)
vm.ui.value
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
@@ -56,6 +58,9 @@ val homeStates: List<ScreenState> = listOf(
sync("S-16-sync-error", HomeScenarios.pending.copy(lastAttemptFailed = true)),
sync("S-16-sync-offline", HomeScenarios.syncOffline),
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
sync("S-16-sync-lost", HomeScenarios.syncLoaded, largeFont = true) { it.openRejection("m-1") },
sync("S-16-sync-lost-payment", HomeScenarios.allRejections) { it.openRejection("m-3") },
sync("S-16-sync-lost-deleted", HomeScenarios.allRejections) { it.openRejection("m-5") },
)
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
@@ -63,8 +68,8 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState {
val ui = syncState(scenario)
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (SyncStatusViewModel) -> Unit = {}): ScreenState {
val ui = syncState(scenario, act)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
@@ -93,7 +98,7 @@ class HomeScreenshotTest(private val state: String) {
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
"S-16-sync-offline", "S-16-sync-long",
"S-16-sync-offline", "S-16-sync-long", "S-16-sync-lost", "S-16-sync-lost-payment", "S-16-sync-lost-deleted",
).map { arrayOf<Any>(it) }
}
}
@@ -163,4 +163,19 @@ class SyncStatusViewModelTest {
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
}
@Test
fun openingARejectionShowsItsLostVersion_andCloseOrDismissHidesIt() {
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
val opened = (vm.ui.value as SyncStatusUi.Ready).opened
assertEquals(HomeScenarios.lostBabette, opened?.lost)
withMain { vm.closeRejection() }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
}
}
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.feature.home
import android.content.res.Configuration
import android.os.LocaleList
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -22,6 +23,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import org.robolectric.Shadows.shadowOf
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
@@ -75,6 +77,11 @@ object ScreenSnapshots {
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
// The screen (and a dialog window: S-16's lost version, S-17's install dialog) draw in a later frame; one
// more frame and an idle main looper, so the capture shows this variant and not the previous one.
shadowOf(Looper.getMainLooper()).idle()
compose.mainClock.advanceTimeByFrame()
shadowOf(Looper.getMainLooper()).idle()
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB