M7b: feature home and account (S-4, S-5, S-6, S-16, S-17)

:feature:home: Home with balance hero, sync pill, accounts, archived section,
empty and first-sync states, update banner and "Tillåt installation" dialog;
sync status with pending rows, rejections of every §14.4.6 reason and the
clock-skew banner. :feature:account: account detail with header, ledger by
month, overflow menu, archive/unarchive/delete and their errors; account
editor with name, percent fields (two decimals), slider, presets, preview,
name_taken/validation errors and offline state. Fake scenarios in
fakes/home and fakes/account; full-screen Roborazzi goldens for every state
in sv/en × light/dark (200 % for long states); ViewModel unit tests.

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 10:49:31 +02:00
parent 7380e50ae3
commit 0e1d99df93
26 changed files with 3027 additions and 28 deletions
@@ -0,0 +1,100 @@
package se.hagstedt.kvitt.feature.home
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.home.HomeScenarios
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
/** Home with [scenario] after [act] on its ViewModel. */
fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi = withMain {
val fakes = FakeRepositories(scenario)
val vm = HomeViewModel(fakes.home, fakes.connectivity, fakes.update, fakes.categories)
act(vm)
vm.ui.value
}
/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */
fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain {
val fakes = FakeRepositories(scenario)
val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui
}
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
val homeStates: List<ScreenState> = listOf(
home("S-4-home", HomeScenarios.household),
home("S-4-home-owes-you", HomeScenarios.owesYou),
home("S-4-home-settled", HomeScenarios.settled) { it.toggleArchived() },
home("S-4-home-pending", HomeScenarios.pending),
home("S-4-home-rejected", HomeScenarios.rejected),
home("S-4-home-clock", HomeScenarios.clockSkew),
home("S-4-home-syncing", HomeScenarios.syncing),
home("S-4-home-empty", HomeScenarios.empty),
home("S-4-home-loading", HomeScenarios.loading),
home("S-4-home-offline", HomeScenarios.offline),
home("S-4-home-only-archived", HomeScenarios.onlyArchived),
home("S-4-home-long", HomeScenarios.long, largeFont = true),
home("S-17-update-banner", HomeScenarios.updateAvailable),
home("S-17-update-permission", HomeScenarios.updateNeedsPermission),
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
home("S-17-update-progress", HomeScenarios.updateDownloading),
home("S-17-update-failed", HomeScenarios.updateFailed),
sync("S-16-sync", HomeScenarios.syncLoaded),
sync("S-16-sync-empty", HomeScenarios.household),
sync("S-16-sync-rejections", HomeScenarios.allRejections),
sync("S-16-sync-clock", HomeScenarios.clockSkew),
sync("S-16-sync-running", HomeScenarios.syncing),
sync("S-16-sync-error", HomeScenarios.pending, failed = true),
sync("S-16-sync-offline", HomeScenarios.syncOffline),
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
)
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
val ui = homeState(scenario, act)
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
}
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, failed: Boolean = false): ScreenState {
val ui = syncState(scenario, failed)
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
}
/**
* Goldens of every Home, sync status and update state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:home:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class HomeScreenshotTest(private val state: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val screen = homeStates.first { it.name == state }
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = listOf(
"S-4-home", "S-4-home-owes-you", "S-4-home-settled", "S-4-home-pending", "S-4-home-rejected", "S-4-home-clock",
"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",
).map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,165 @@
package se.hagstedt.kvitt.feature.home
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
private class RecordingUpdates(initial: UpdateState) : UpdateRepository {
val flow = MutableStateFlow(initial)
var installs = 0
override val state: Flow<UpdateState> = flow
override suspend fun check() = Unit
override suspend fun install() {
installs += 1
}
}
class HomeViewModelTest {
private fun ready(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}) = homeState(scenario, act) as HomeUi.Ready
private fun vm(scenario: FakeScenario, updates: UpdateRepository): HomeViewModel = withMain {
val fakes = FakeRepositories(scenario)
HomeViewModel(fakes.home, fakes.connectivity, updates, fakes.categories)
}
@Test
fun householdShowsBalanceAccountsAndFab() {
val ui = ready(HomeScenarios.household)
assertEquals(-325_000L, ui.home.global?.netOre)
assertEquals(listOf("Vardag", "Huset", "Japan 2027"), ui.home.active.map { it.account.name })
assertEquals(listOf("Gotland 2026"), ui.home.archived.map { it.account.name })
assertTrue(ui.showFab)
assertTrue(ui.canSettle)
assertFalse(ui.empty)
assertFalse(ui.loading)
assertFalse(ui.archivedExpanded)
}
@Test
fun colourSlotsFollowCreatedOrderWithArchivedIncluded() {
val ui = ready(HomeScenarios.household)
val index = ui.colorIndex
assertEquals(listOf(0, 1, 2, 3), listOf(HomeScenarios.vardag, HomeScenarios.huset, HomeScenarios.japan, HomeScenarios.gotland).map { index[it.id.value] })
}
@Test
fun settledHasNoSettleButton() {
val ui = ready(HomeScenarios.settled)
assertEquals(0L, ui.home.global?.netOre)
assertFalse(ui.canSettle)
}
@Test
fun archivedSectionToggles() {
assertTrue(ready(HomeScenarios.household) { it.toggleArchived() }.archivedExpanded)
assertFalse(ready(HomeScenarios.household) { it.toggleArchived(); it.toggleArchived() }.archivedExpanded)
}
@Test
fun emptyAndOnlyArchivedHideTheFab() {
val empty = ready(HomeScenarios.empty)
assertTrue(empty.empty)
assertFalse(empty.showFab)
assertEquals(listOf(DefaultCategoryKey.GROCERIES, DefaultCategoryKey.TRAVEL, DefaultCategoryKey.RESTAURANTS), empty.illustration.map { it.defaultKey })
val archivedOnly = ready(HomeScenarios.onlyArchived)
assertFalse(archivedOnly.empty)
assertFalse(archivedOnly.showFab)
}
@Test
fun firstSyncIsLoading() {
val ui = ready(HomeScenarios.loading)
assertTrue(ui.loading)
assertFalse(ui.empty)
assertFalse(ui.showFab)
}
@Test
fun offlineAndPendingAreReported() {
val ui = ready(HomeScenarios.offline)
assertFalse(ui.online)
assertEquals(2, ui.home.sync.pendingCount)
assertTrue(ui.update is UpdateState.Available)
}
@Test
fun laterHidesTheBanner() {
assertEquals(UpdateState.None, ready(HomeScenarios.updateAvailable) { it.onLater() }.update)
}
@Test
fun firstUpdateOpensTheDialogAndContinueInstalls() {
val updates = RecordingUpdates(UpdateState.NeedsInstallPermission(HomeScenarios.update))
val vm = vm(HomeScenarios.household, updates)
withMain { vm.onUpdate() }
assertTrue((vm.ui.value as HomeUi.Ready).installDialog)
assertEquals(0, updates.installs)
withMain { vm.onInstallDialogContinue() }
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
assertEquals(1, updates.installs)
}
@Test
fun updateAndRetryInstallDirectly() {
val updates = RecordingUpdates(UpdateState.Available(HomeScenarios.update))
val vm = vm(HomeScenarios.household, updates)
withMain { vm.onUpdate() }
assertFalse((vm.ui.value as HomeUi.Ready).installDialog)
withMain { vm.onRetry() }
assertEquals(2, updates.installs)
}
@Test
fun dialogDismissKeepsTheBanner() {
val ui = ready(HomeScenarios.updateNeedsPermission) { it.onUpdate(); it.onInstallDialogDismiss() }
assertFalse(ui.installDialog)
assertTrue(ui.update is UpdateState.NeedsInstallPermission)
}
}
class SyncStatusViewModelTest {
@Test
fun syncedHouseholdIsAllSynced() {
val ui = syncState(HomeScenarios.household) as SyncStatusUi.Ready
assertTrue(ui.allSynced)
assertTrue(ui.canSyncNow)
}
@Test
fun pendingAndRejectionsAreListed() {
val ui = syncState(HomeScenarios.allRejections) as SyncStatusUi.Ready
assertFalse(ui.allSynced)
assertEquals(2, ui.status.pending.size)
assertEquals(SyncRejectionReason.entries.toSet(), ui.status.rejections.map { it.reason }.toSet())
assertEquals("Anna", ui.partner?.displayName)
}
@Test
fun syncNowIsDisabledOfflineAndWhileRunning() {
assertFalse((syncState(HomeScenarios.syncOffline) as SyncStatusUi.Ready).canSyncNow)
assertFalse((syncState(HomeScenarios.syncing) as SyncStatusUi.Ready).canSyncNow)
}
@Test
fun syncNowClearsPendingAndDismissRemovesARejection() {
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
withMain { vm.syncNow() }
val synced = vm.ui.value as SyncStatusUi.Ready
assertTrue(synced.status.pending.isEmpty())
assertEquals(1, synced.status.rejections.size)
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
}
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.feature.home
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.captureScreenRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
* snackbars render the same every run.
*/
object ScreenSnapshots {
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private const val SETTLE_MILLIS = 1_000L
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
@OptIn(ExperimentalRoborazziApi::class)
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.mainClock.autoAdvance = false
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark, content = content)
}
}
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}