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,86 @@
package se.hagstedt.kvitt.feature.account
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.FakeScenario
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
/** 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)
private const val LONG_NAME = "Gemensamma utgifter för sommarstugan i Österåker och båten i Vaxholm"
/** Every state of S-5 and S-6 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
val accountStates: List<ScreenState> = listOf(
detail("S-5-account", AccountScenarios.loaded, AccountScenarios.vardag),
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() },
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
editor("S-6-account-editor-create", AccountScenarios.editor),
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
editor("S-6-account-editor-edit", AccountScenarios.editor, id = AccountScenarios.huset),
editor("S-6-account-editor-decimals", AccountScenarios.editor) { it.onName("Sommarstugan"); it.onPercent(PercentField.Viewer, "62,75") },
editor("S-6-account-editor-error", AccountScenarios.editor) { it.onName("Vardag"); it.save() },
editor("S-6-account-editor-validation", AccountScenarios.editor) { it.onName(LONG_NAME) },
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = NETWORK) { it.onName("Sommarstugan"); it.save() },
editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
saving(),
)
private fun saving(): ScreenState {
val ui = editorState(AccountScenarios.editor) { it.onName("Sommarstugan"); it.onViewerPercent(60) }.copy(saving = true)
return ScreenState("S-6-account-editor-saving") { AccountEditorContent(ui, AccountEditorActions()) }
}
private fun detail(name: String, scenario: FakeScenario, id: AccountId, failWith: String? = null, largeFont: Boolean = false, act: (AccountDetailViewModel) -> Unit = {}): ScreenState {
val ui = detailState(scenario, id, failWith, act)
return ScreenState(name, largeFont) { AccountDetailContent(ui, AccountDetailActions()) }
}
private fun editor(name: String, scenario: FakeScenario, id: AccountId? = null, failWith: String? = null, largeFont: Boolean = false, act: (AccountEditorViewModel) -> Unit = {}): ScreenState {
val ui = editorState(scenario, id, failWith, act = act)
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
}
/**
* Goldens of every account detail and account editor state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:account:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class AccountScreenshotTest(private val state: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val screen = accountStates.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-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
"S-6-account-editor-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
"S-6-account-editor-saving",
).map { arrayOf<Any>(it) }
}
}
@@ -0,0 +1,43 @@
package se.hagstedt.kvitt.feature.account
import androidx.lifecycle.SavedStateHandle
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.UserId
/** The fake accounts with every online call failing with [code] (a REST error, §9.9); reads are the fakes'. */
class FailingAccounts(private val delegate: AccountRepository, private val code: String, private val field: String? = null) : AccountRepository by delegate {
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = OnlineResult.Failed(code, field)
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = OnlineResult.Failed(code, field)
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = OnlineResult.Failed(code, field)
override suspend fun delete(id: AccountId): OnlineResult<Unit> = OnlineResult.Failed(code, field)
}
fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) emptyMap() else mapOf(AccountDetailViewModel.ARG_ACCOUNT_ID to id.value))
/** S-5 for [id] in [scenario] after [act]; [failWith] makes the online calls fail with that code. */
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity)
act(vm)
vm.ui.value
}
/** S-6 in [scenario], editing [id] or new, after [act]. */
fun editorState(
scenario: FakeScenario,
id: AccountId? = null,
failWith: String? = null,
field: String? = null,
act: (AccountEditorViewModel) -> Unit = {},
): AccountEditorUi = withMain {
val fakes = FakeRepositories(scenario)
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith, field)
val vm = AccountEditorViewModel(args(id), accounts, fakes.session, fakes.connectivity)
act(vm)
vm.ui.value
}
@@ -0,0 +1,215 @@
package se.hagstedt.kvitt.feature.account
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.account.AccountScenarios
import se.hagstedt.kvitt.model.AccountId
class AccountDetailViewModelTest {
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
@Test
fun loadedAccountShowsBalanceMonthsAndFab() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag))
assertEquals(118_450L, ui.detail.balance.netOre)
assertEquals(listOf("2026-10", "2026-09"), ui.detail.months.map { it.period })
assertEquals(0, ui.colorIndex)
assertEquals(50f, ui.viewerPercent)
assertTrue(ui.showFab)
assertFalse(ui.canDelete)
assertTrue(ui.canEdit)
}
@Test
fun archivedAccountHidesTheFab() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland))
assertFalse(ui.showFab)
assertEquals(3, ui.colorIndex)
}
@Test
fun viewerPercentComesFromBasisPoints() {
assertEquals(60f, ready(detailState(AccountScenarios.loaded, AccountScenarios.huset)).viewerPercent)
}
@Test
fun emptyAccountMayBeDeletedOnlyOnline() {
assertTrue(ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id)).canDelete)
val offline = AccountScenarios.empty.copy(online = false)
val ui = ready(detailState(offline, AccountScenarios.sommarstugan.id))
assertFalse(ui.canDelete)
assertFalse(ui.canEdit)
}
@Test
fun archiveNavigatesBackWithTheName() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true); it.archive() })
assertEquals(AccountEvent.Archived("Vardag"), ui.event)
assertFalse(ui.menuOpen)
assertTrue(ui.detail.account.archived)
}
@Test
fun unarchiveStaysWithASnackbar() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland) { it.unarchive() })
assertEquals(AccountMessage.Restored("Gotland 2026"), ui.message)
assertFalse(ui.detail.account.archived)
assertNull(ui.event)
}
@Test
fun networkFailureOffersRetryAndChangesNothing() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() })
assertEquals(AccountMessage.CouldNotReach(AccountAction.Archive), ui.message)
assertFalse(ui.detail.account.archived)
assertFalse(ui.busy)
val shown = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive(); it.messageShown() })
assertNull(shown.message)
}
@Test
fun offlineActionSaysNeedsConnection() {
val ui = ready(detailState(AccountScenarios.offline, AccountScenarios.vardag) { it.archive() })
assertEquals(AccountMessage.NeedsConnection, ui.message)
}
@Test
fun deleteAsksThenDeletes() {
val asked = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() })
assertTrue(asked.deleteDialog)
val dismissed = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.dismissDelete() })
assertFalse(dismissed.deleteDialog)
val deleted = detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.confirmDelete() }
assertEquals(AccountDetailUi.Gone(AccountEvent.Deleted), deleted)
}
@Test
fun deleteOfANonEmptyAccountIsRefused() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.confirmDelete() })
assertEquals(AccountMessage.DeleteBlocked, ui.message)
}
@Test
fun otherErrorsKeepTheirCode() {
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = "forbidden") { it.archive() })
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
}
@Test
fun unknownAccountIsGone() {
assertEquals(AccountDetailUi.Gone(AccountEvent.Gone), detailState(AccountScenarios.loaded, AccountId("missing")))
}
}
class AccountEditorViewModelTest {
@Test
fun newAccountStartsAtFiftyFiftyAndNeedsAName() {
val ui = editorState(AccountScenarios.editor)
assertTrue(ui.loaded)
assertNull(ui.editing)
assertEquals(5_000, ui.viewerBp)
assertEquals(FakeScenarios.owner, ui.me)
assertFalse(ui.canSave)
assertTrue(editorState(AccountScenarios.editor) { it.onName("Sommarstugan") }.canSave)
assertFalse(editorState(AccountScenarios.editor) { it.onName(" ") }.canSave)
}
@Test
fun editStartsFromTheAccount() {
val ui = editorState(AccountScenarios.editor, id = AccountScenarios.huset)
assertEquals("Huset", ui.name)
assertEquals(6_000, ui.viewerBp)
assertFalse(ui.dirty)
}
@Test
fun percentsAcceptTwoDecimalsAndTheOtherFollows() {
assertEquals(6_275, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75") }.viewerBp)
assertEquals(10_000 - 3_350, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Partner, "33.5") }.viewerBp)
// A third decimal is not accepted: the text stays as it was.
val third = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75"); it.onPercent(PercentField.Viewer, "62,755") }
assertEquals("62,75", third.typedText)
// Over 100 is not a valid ratio: Spara is off.
val over = editorState(AccountScenarios.editor) { it.onName("X"); it.onPercent(PercentField.Viewer, "101") }
assertNull(over.viewerBp)
assertFalse(over.canSave)
assertNull(editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "") }.viewerBp)
}
@Test
fun parsePercent() {
assertEquals(6_000, parsePercentBp("60"))
assertEquals(6_050, parsePercentBp("60,5"))
assertEquals(5, parsePercentBp("0.05"))
assertEquals(10_000, parsePercentBp("100"))
assertNull(parsePercentBp("100,01"))
assertNull(parsePercentBp(","))
}
@Test
fun sliderAndPresetsSetWholePercents() {
val ui = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "12,5"); it.onViewerPercent(40) }
assertEquals(4_000, ui.viewerBp)
assertNull(ui.typed)
}
@Test
fun createSavesBasisPointsAndReportsTheNewId() {
val fakes = FakeRepositories(AccountScenarios.editor)
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
withMain { vm.onName("Sommarstugan"); vm.onPercent(PercentField.Viewer, "62,5"); vm.save() }
val saved = vm.ui.value.saved
val created = fakes.store.current.accounts.single { it.name == "Sommarstugan" }
assertEquals(created.id, saved)
assertEquals(mapOf(FakeScenarios.owner.id to 6_250, FakeScenarios.partner.id to 3_750), created.defaultShares)
}
@Test
fun nameTakenIsShownUnderTheName() {
val ui = editorState(AccountScenarios.editor) { it.onName(" vardag "); it.save() }
assertEquals(NameError.Taken("vardag"), ui.nameError)
assertFalse(ui.saving)
assertTrue(ui.canSave)
assertNull(editorState(AccountScenarios.editor) { it.onName("Vardag"); it.save(); it.onName("Vardag 2") }.nameError)
}
@Test
fun tooLongNameIsCaughtBeforeSaving() {
val ui = editorState(AccountScenarios.editor) { it.onName("a".repeat(MAX_NAME + 1)) }
assertEquals(NameError.TooLong, ui.nameError)
assertFalse(ui.canSave)
}
@Test
fun serverValidationMapsToTheField() {
assertEquals(NameError.Invalid, editorState(AccountScenarios.editor, failWith = "validation_failed", field = "name") { it.onName("X"); it.save() }.nameError)
assertTrue(editorState(AccountScenarios.editor, failWith = "validation_failed", field = "default_shares") { it.onName("X"); it.save() }.splitError)
assertEquals(EditorMessage.CouldNotReach, editorState(AccountScenarios.editor, failWith = NETWORK) { it.onName("X"); it.save() }.message)
assertEquals(EditorMessage.Failed("household_incomplete"), editorState(AccountScenarios.editor, failWith = "household_incomplete") { it.onName("X"); it.save() }.message)
}
@Test
fun offlineCannotSave() {
val ui = editorState(AccountScenarios.editorOffline) { it.onName("Sommarstugan") }
assertFalse(ui.online)
assertFalse(ui.enabled)
assertFalse(ui.canSave)
}
@Test
fun closingWithChangesAsksFirst() {
val fakes = FakeRepositories(AccountScenarios.editor)
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
assertTrue(withMain { vm.requestClose() })
withMain { vm.onName("Sommarstugan") }
assertFalse(withMain { vm.requestClose() })
assertTrue(vm.ui.value.discardDialog)
withMain { vm.keepEditing() }
assertFalse(vm.ui.value.discardDialog)
}
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.feature.account
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()
}
}