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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |