M7c: feature expense, S-7 editor and S-8 detail with timeline

Expense editor (S-7) and expense detail (S-8) in :feature:expense against the
:core:model interfaces and :core:design components: defaults (AC-8.6), keypad
input (§9.6), the four split modes with live preview (AC-8.5, AC-8.7, AC-8.8),
category suggestion with the picked-by-hand flag (CS-2, CS-3, CS-7), quick row
from frequentCategoryIds (D-059, D-060), inline errors, delete and discard
confirmations; detail with hero, facts, timeline, recurring, deleted and missing
states. Fake scenarios in fakes/expense, view-model tests and 127 goldens.

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:54:16 +02:00
parent 0e1d99df93
commit 0b140aa3ad
15 changed files with 2520 additions and 14 deletions
@@ -0,0 +1,87 @@
package se.hagstedt.kvitt.feature.expense
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.model.ExpenseId
/** S-8 state holder: loaded, pending timeline line, deleted, missing, delete flow. */
@OptIn(ExperimentalCoroutinesApi::class)
class ExpenseDetailViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() = Dispatchers.setMain(dispatcher)
@After
fun tearDown() = Dispatchers.resetMain()
private fun TestScope.detail(id: ExpenseId, scenario: FakeScenario = ExpenseScenarios.household): Pair<ExpenseDetailViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync)
advanceUntilIdle()
return vm to fakes
}
@Test
fun loaded_withThePendingLineLast() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.icaId)
val d = vm.state.value.detail
assertFalse(vm.state.value.loading)
assertEquals("ICA Maxi Lindhagen", d?.description)
assertTrue(d?.pending == true)
assertTrue(d?.timeline?.last()?.pendingLocal == true)
assertEquals(4, d?.timeline?.size)
}
@Test
fun recurring_namesTheTemplate() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.hyraId)
assertEquals("Hyra", vm.state.value.detail?.recurringTemplateName)
}
@Test
fun missing_isNullAfterLoading() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.missingId)
assertFalse(vm.state.value.loading)
assertNull(vm.state.value.detail)
}
@Test
fun deleted_staysVisibleAsDeleted() = runTest(dispatcher) {
val (vm, _) = detail(ExpenseScenarios.icaId, ExpenseScenarios.deleted)
assertTrue(vm.state.value.detail?.deleted == true)
}
@Test
fun delete_asksThenDeletes() = runTest(dispatcher) {
val (vm, fakes) = detail(ExpenseScenarios.trananId)
vm.askDelete()
assertTrue(vm.state.value.confirmDelete)
vm.dismissDelete()
assertFalse(vm.state.value.confirmDelete)
vm.askDelete()
vm.confirmDelete()
advanceUntilIdle()
assertEquals(DetailEvent.Deleted, vm.eventFlow.first())
assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.trananId }.deleted)
assertTrue(vm.state.value.detail?.deleted == true)
}
}
@@ -0,0 +1,295 @@
package se.hagstedt.kvitt.feature.expense
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
/** S-7 state holder: defaults, keypad input, split modes, the category suggestion, enablement and error mapping. */
@OptIn(ExperimentalCoroutinesApi::class)
class ExpenseEditorViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
private val jacob = FakeScenarios.owner.id
private val anna = FakeScenarios.partner.id
@Before
fun setUp() = Dispatchers.setMain(dispatcher)
@After
fun tearDown() = Dispatchers.resetMain()
private fun TestScope.editor(scenario: FakeScenario = ExpenseScenarios.household, args: Map<String, Any?> = emptyMap()): Pair<ExpenseEditorViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
advanceUntilIdle()
return vm to fakes
}
private fun ExpenseEditorViewModel.type(vararg keys: String) = keys.forEach { key ->
onKey(if (key == ".") AmountKey.Decimal else AmountKey.Digit(key.toInt()))
}
@Test
fun newExpense_appliesTheDefaults() = runTest(dispatcher) {
val (vm, _) = editor()
val s = vm.state.value
assertTrue(s.ready)
assertFalse(s.isEdit)
assertEquals(ExpenseScenarios.vardag.id, s.form?.accountId)
assertEquals(ExpenseScenarios.groceries.id, s.form?.categoryId)
assertEquals(jacob, s.form?.payer)
assertEquals(SplitMode.DEFAULT, s.form?.mode)
assertEquals("2026-10-17", s.form?.date)
assertEquals(KeypadTarget.Amount, s.keypad)
assertNull(s.preview)
assertFalse(s.canSave)
}
@Test
fun openedFromAnAccount_startsInThatAccount() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_ACCOUNT_ID to ExpenseScenarios.huset.id.value))
assertEquals(ExpenseScenarios.huset.id, vm.state.value.form?.accountId)
assertEquals(ExpenseScenarios.housing.id, vm.state.value.form?.categoryId)
}
@Test
fun tenSecondPath_amountDescriptionSuggestionSave() = runTest(dispatcher) {
val (vm, fakes) = editor()
vm.type("4", "1", "2")
assertEquals(41_200L, vm.state.value.form?.amount?.ore)
assertEquals(listOf(Share(jacob, 20_600, 5000), Share(anna, 20_600, 5000)), vm.state.value.preview)
assertFalse(vm.state.value.canSave)
vm.onDescription("Systembolaget")
advanceUntilIdle()
assertEquals(ExpenseScenarios.entertainment.id, vm.state.value.form?.categoryId)
assertTrue(vm.state.value.canSave)
vm.save()
advanceUntilIdle()
val event = vm.eventFlow.first()
assertTrue(event is EditorEvent.Saved && event.isNew && !event.offline)
assertEquals(1, fakes.store.current.expenses.count { it.description == "Systembolaget" && it.pending })
}
@Test
fun suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault() = runTest(dispatcher) {
val (vm, _) = editor()
vm.onDescription("Systembolaget")
advanceUntilIdle()
vm.onDescription("")
advanceUntilIdle()
assertEquals(ExpenseScenarios.groceries.id, vm.state.value.form?.categoryId)
vm.onCategory(ExpenseScenarios.shopping.id)
vm.onDescription("Systembolaget")
advanceUntilIdle()
assertEquals(ExpenseScenarios.shopping.id, vm.state.value.form?.categoryId)
}
@Test
fun keypad_rejectsAThirdDecimalAndAmountsAboveTheLimit() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "2", ".", "5", "0")
assertFalse(vm.onKey(AmountKey.Digit(1)))
assertFalse(vm.onKey(AmountKey.Decimal))
assertEquals(1_250L, vm.state.value.form?.amount?.ore)
vm.onKey(AmountKey.Clear)
vm.type("1", "0", "0", "0", "0", "0", "0", "0")
assertFalse(vm.onKey(AmountKey.Digit(1)))
assertEquals(1_000_000_000L, vm.state.value.form?.amount?.ore)
}
@Test
fun exactShareLargerThanTheAmount_blocksSaving() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "0", "6", "0")
vm.onDescription("Ullared")
vm.toggleSplit()
vm.onMode(SplitMode.EXACT)
assertEquals(KeypadTarget.Exact, vm.state.value.keypad)
vm.type("1", "2", "0", "0")
assertTrue(vm.state.value.exactTooLarge)
assertNull(vm.state.value.preview)
assertFalse(vm.state.value.canSave)
vm.onKey(AmountKey.Delete)
vm.onKey(AmountKey.Clear)
vm.type("4", "0", "0")
advanceUntilIdle()
assertEquals(listOf(Share(jacob, 40_000, null), Share(anna, 66_000, null)), vm.state.value.preview)
assertTrue(vm.state.value.canSave)
}
@Test
fun ratio_typingOneSideSetsTheOther_andStartsAtTheAccountRatio() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_ACCOUNT_ID to ExpenseScenarios.huset.id.value))
vm.type("1", "0", "0")
vm.onMode(SplitMode.RATIO)
assertEquals(6_000L, vm.state.value.form?.ratioViewer?.ore)
vm.focusKeypad(KeypadTarget.RatioPartner)
vm.onKey(AmountKey.Clear)
vm.type("2", "5")
assertEquals(7_500L, vm.state.value.form?.ratioViewer?.ore)
assertEquals(listOf(Share(jacob, 7_500, 7500), Share(anna, 2_500, 2500)), vm.state.value.preview)
vm.type("1")
assertFalse(vm.onKey(AmountKey.Digit(5)))
}
@Test
fun full_defaultsToThePersonWhoDidNotPay() = runTest(dispatcher) {
val (vm, _) = editor()
vm.onMode(SplitMode.FULL)
assertEquals(anna, vm.state.value.form?.fullOn)
vm.togglePayer()
vm.onMode(SplitMode.FULL)
assertEquals(jacob, vm.state.value.form?.fullOn)
}
@Test
fun accountChange_inDefaultMode_recopiesTheRatio() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "0", "0")
vm.onAccount(ExpenseScenarios.huset.id)
advanceUntilIdle()
assertEquals(listOf(Share(jacob, 6_000, 6000), Share(anna, 4_000, 4000)), vm.state.value.preview)
// CS-3: the suggestion re-runs on an account change (empty description: the AC-8.6 default of Huset).
assertEquals(ExpenseScenarios.housing.id, vm.state.value.form?.categoryId)
}
@Test
fun quickRow_usesTheAccountsFrequentCategories_selectedTakesTheFifthPlace() = runTest(dispatcher) {
val (vm, _) = editor()
val frequent = listOf(ExpenseScenarios.groceries, ExpenseScenarios.transport, ExpenseScenarios.entertainment, ExpenseScenarios.shopping)
assertEquals(frequent.map { it.id }, vm.state.value.quickRow.take(4).map { it.id })
assertEquals(5, vm.state.value.quickRow.size)
vm.onCategory(ExpenseScenarios.other.id)
assertEquals(frequent.map { it.id } + ExpenseScenarios.other.id, vm.state.value.quickRow.map { it.id })
}
@Test
fun offlineSave_isAllowed_andReportsOffline() = runTest(dispatcher) {
val (vm, _) = editor(ExpenseScenarios.offline)
vm.type("5", "0")
vm.onDescription("Pressbyrån")
advanceUntilIdle()
vm.save()
advanceUntilIdle()
val event = vm.eventFlow.first()
assertTrue(event is EditorEvent.Saved && event.offline)
}
@Test
fun noDefaultCategory_showsNoCategory_andBlocksSaving() = runTest(dispatcher) {
val (vm, _) = editor(ExpenseScenarios.noDefaultCategory)
vm.type("5", "0")
vm.onDescription("Okänt")
advanceUntilIdle()
assertNull(vm.state.value.form?.categoryId)
assertFalse(vm.state.value.canSave)
vm.save()
assertEquals(FieldError(ExpenseEditorViewModel.FIELD_CATEGORY, "required"), vm.state.value.errors.single())
}
@Test
fun missingAmountAndDescription_mapToRequired_andFocusTheAmount() = runTest(dispatcher) {
val (vm, _) = editor()
vm.focusKeypad(null)
vm.save()
val fields = vm.state.value.errors.associate { it.field to it.error }
assertEquals("required", fields[ExpenseEditorViewModel.FIELD_AMOUNT])
assertEquals("required", fields[ExpenseEditorViewModel.FIELD_DESCRIPTION])
assertEquals(KeypadTarget.Amount, vm.state.value.keypad)
vm.type("1")
assertNull(vm.state.value.errors.firstOrNull { it.field == ExpenseEditorViewModel.FIELD_AMOUNT })
}
@Test
fun dateTooFarAhead_comesBackAsValidationFailed() = runTest(dispatcher) {
val (vm, _) = editor()
vm.type("1", "0")
vm.onDescription("Biljetter")
advanceUntilIdle()
vm.onDate("2028-01-01")
vm.save()
advanceUntilIdle()
assertEquals(FieldError(ExpenseEditorViewModel.FIELD_DATE, "validation_failed"), vm.state.value.errors.single())
}
@Test
fun editMode_loadsTheExpense_keepsTheCategory_andDeletes() = runTest(dispatcher) {
val (vm, fakes) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.slKortId.value))
val s = vm.state.value
assertTrue(s.isEdit)
assertEquals(SplitMode.FULL, s.form?.mode)
assertEquals(anna, s.form?.fullOn)
assertTrue(s.splitOpen)
assertTrue(s.notesExpanded)
assertNull(s.keypad)
vm.onDescription("Systembolaget")
advanceUntilIdle()
assertEquals(ExpenseScenarios.transport.id, vm.state.value.form?.categoryId)
vm.showOverlay(EditorOverlay.Delete)
vm.confirmDelete()
advanceUntilIdle()
assertEquals(EditorEvent.Deleted, vm.eventFlow.first())
assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.slKortId }.deleted)
}
@Test
fun editSave_reportsAnEdit() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.ullaredId.value))
assertEquals(40_000L, vm.state.value.form?.exactInput?.ore)
assertEquals(listOf(Share(jacob, 40_000, null), Share(anna, 66_000, null)), vm.state.value.preview)
vm.togglePayer()
vm.save()
advanceUntilIdle()
val event = vm.eventFlow.first()
assertTrue(event is EditorEvent.Saved && !event.isNew)
}
@Test
fun editedAmounts_keepBothDecimals() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertEquals("1247.60", vm.state.value.form?.amount?.raw)
assertEquals("412", ExpenseEditorViewModel.moneyInput(41_200).raw)
assertEquals("0.05", ExpenseEditorViewModel.moneyInput(5).raw)
}
@Test
fun close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst() = runTest(dispatcher) {
val (vm, _) = editor()
vm.back()
assertNull(vm.state.value.keypad)
vm.close()
assertEquals(EditorEvent.Closed, vm.eventFlow.first())
vm.onDescription("Taxi")
vm.close()
assertEquals(EditorOverlay.Discard, vm.state.value.overlay)
vm.discard()
assertEquals(EditorEvent.Closed, vm.eventFlow.first())
}
@Test
fun editingADeletedExpense_isMissing() = runTest(dispatcher) {
val (vm, _) = editor(ExpenseScenarios.deleted, mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertTrue(vm.state.value.missing)
}
}
@@ -0,0 +1,170 @@
package se.hagstedt.kvitt.feature.expense.screenshot
import androidx.compose.runtime.Composable
import androidx.lifecycle.SavedStateHandle
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.feature.expense.DeleteExpenseDialog
import se.hagstedt.kvitt.feature.expense.EditorDialog
import se.hagstedt.kvitt.feature.expense.EditorOverlay
import se.hagstedt.kvitt.feature.expense.EditorSheetContent
import se.hagstedt.kvitt.feature.expense.ExpenseDetailActions
import se.hagstedt.kvitt.feature.expense.ExpenseDetailContent
import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel
import se.hagstedt.kvitt.feature.expense.ExpenseEditorActions
import se.hagstedt.kvitt.feature.expense.ExpenseEditorContent
import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel
import se.hagstedt.kvitt.feature.expense.KeypadTarget
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
/** A screenshot case: its golden name, whether it has a 200 % golden, and its content. */
class Case(val name: String, val largeFont: Boolean = false, val content: () -> @Composable () -> Unit)
/** Actions that do nothing: goldens render a fixed state. */
object NoActions : ExpenseEditorActions, ExpenseDetailActions {
override fun onKey(key: AmountKey) = true
override fun focusKeypad(target: KeypadTarget?) = Unit
override fun onDescription(text: String) = Unit
override fun onNotes(text: String) = Unit
override fun expandNotes() = Unit
override fun onCategory(id: CategoryId) = Unit
override fun onAccount(id: AccountId) = Unit
override fun onDate(iso: String) = Unit
override fun togglePayer() = Unit
override fun toggleSplit() = Unit
override fun onMode(mode: SplitMode) = Unit
override fun onViewerPercent(percent: Int) = Unit
override fun onExactTyped(user: UserId) = Unit
override fun onFullOn(user: UserId) = Unit
override fun showOverlay(overlay: EditorOverlay) = Unit
override fun dismissOverlay() = Unit
override fun close() = Unit
override fun back() = Unit
override fun discard() = Unit
override fun confirmDelete() = Unit
override fun save() = Unit
override fun askDelete() = Unit
override fun dismissDelete() = Unit
override fun syncNow() = Unit
}
private fun ExpenseEditorViewModel.type(text: String) = text.forEach { c ->
onKey(if (c == '.') AmountKey.Decimal else AmountKey.Digit(c.digitToInt()))
}
/** S-7 in [scenario] after [steps] (driven through the real view model and the fakes). */
private fun editor(
name: String,
scenario: FakeScenario = ExpenseScenarios.household,
edit: ExpenseId? = null,
largeFont: Boolean = false,
steps: ExpenseEditorViewModel.(idle: () -> Unit) -> Unit = {},
) = Case(name, largeFont) {
val state = ScreenSnapshots.build { idle ->
val fakes = FakeRepositories(scenario)
val args = if (edit == null) emptyMap() else mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to edit.value)
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
idle()
vm.steps(idle)
idle()
vm.state.value
}
when (state.overlay) {
EditorOverlay.None -> ({ ExpenseEditorContent(state, NoActions) })
EditorOverlay.AccountPicker, EditorOverlay.CategoryGrid -> ({
OverlayFrame(sheet = true, { ExpenseEditorContent(state.copy(overlay = EditorOverlay.None), NoActions) }) {
ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) { EditorSheetContent(state, NoActions) }
}
})
else -> ({
OverlayFrame(sheet = false, { ExpenseEditorContent(state.copy(overlay = EditorOverlay.None), NoActions) }) { EditorDialog(state, NoActions, inline = true) }
})
}
}
/** S-8 of [id] in [scenario]. */
private fun detail(
name: String,
id: ExpenseId,
scenario: FakeScenario = ExpenseScenarios.household,
largeFont: Boolean = false,
steps: ExpenseDetailViewModel.() -> Unit = {},
) = Case(name, largeFont) {
val state = ScreenSnapshots.build { idle ->
val fakes = FakeRepositories(scenario)
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync)
idle()
vm.steps()
vm.state.value
}
if (!state.confirmDelete) {
({ ExpenseDetailContent(state, NoActions, {}, {}) })
} else {
({ OverlayFrame(sheet = false, { ExpenseDetailContent(state.copy(confirmDelete = false), NoActions, {}, {}) }) { DeleteExpenseDialog({}, {}, inline = true) } })
}
}
private val tenSecond: ExpenseEditorViewModel.(() -> Unit) -> Unit = { idle ->
type("412")
onDescription("Systembolaget")
idle()
}
/** Every S-7 state of design-spec §4 S-7 and PLAN-M7c §5 (phone height). */
val editorCases = listOf(
editor("s7_new_defaults"),
editor("s7_new", largeFont = true, steps = tenSecond),
editor("s7_edit", edit = ExpenseScenarios.icaId),
editor("s7_split_default") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit() },
editor("s7_split_ratio") { idle ->
onAccount(ExpenseScenarios.huset.id); type("1560"); onDescription("Middag på Tranan"); idle()
onCategory(ExpenseScenarios.restaurants.id); toggleSplit(); onMode(SplitMode.RATIO); onViewerPercent(70); focusKeypad(KeypadTarget.RatioViewer)
},
editor("s7_split_exact_error") { idle ->
type("1060"); onDescription("Ullared"); idle(); onCategory(ExpenseScenarios.shopping.id); toggleSplit(); onMode(SplitMode.EXACT); type("1200")
},
editor("s7_split_full_other") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit(); onMode(SplitMode.FULL) },
editor("s7_split_full_payer") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit(); onMode(SplitMode.FULL); onFullOn(FakeScenarios.owner.id) },
editor("s7_full_notes_edit", edit = ExpenseScenarios.slKortId),
editor("s7_errors_missing") { _ -> focusKeypad(null); save(); focusKeypad(null) },
editor("s7_notes_expanded") { idle -> tenSecond(idle); focusKeypad(null); expandNotes(); onNotes("Till middagen på lördag") },
editor("s7_account_picker") { idle -> tenSecond(idle); showOverlay(EditorOverlay.AccountPicker) },
editor("s7_category_grid") { idle -> tenSecond(idle); showOverlay(EditorOverlay.CategoryGrid) },
// September: the picker's today marker (the real clock, not the fixed one) stays out of the golden.
editor("s7_date_picker", edit = ExpenseScenarios.hyraId) { _ -> showOverlay(EditorOverlay.DatePicker) },
editor("s7_delete_confirm", edit = ExpenseScenarios.icaId) { _ -> showOverlay(EditorOverlay.Delete) },
editor("s7_discard_confirm") { idle -> tenSecond(idle); close() },
editor("s7_offline", scenario = ExpenseScenarios.offline, steps = tenSecond),
editor("s7_no_category", scenario = ExpenseScenarios.noDefaultCategory) { idle -> type("89"); onDescription("Loppis"); idle() },
editor("s7_long", scenario = ExpenseScenarios.long, largeFont = true) { idle ->
type("1234567.89"); onDescription("Snickare Bergström, nytt golv i hallen, köket och vardagsrummet"); idle()
},
editor("s7_long_edit", scenario = ExpenseScenarios.long, edit = ExpenseScenarios.snickareId),
)
/** Every S-8 state of design-spec §4 S-8 and PLAN-M7c §5 (phone height). */
val detailCases = listOf(
detail("s8_loaded_pending", ExpenseScenarios.icaId, largeFont = true),
detail("s8_ratio_timeline", ExpenseScenarios.trananId),
detail("s8_exact", ExpenseScenarios.ullaredId),
detail("s8_full", ExpenseScenarios.slKortId),
detail("s8_recurring", ExpenseScenarios.hyraId),
detail("s8_deleted", ExpenseScenarios.icaId, ExpenseScenarios.deleted),
detail("s8_missing", ExpenseScenarios.missingId),
detail("s8_missing_offline", ExpenseScenarios.missingId, ExpenseScenarios.offline),
detail("s8_offline", ExpenseScenarios.icaId, ExpenseScenarios.offline),
detail("s8_delete_confirm", ExpenseScenarios.trananId) { askDelete() },
)
/** Long content on a tall frame so the whole screen shows. */
val tallCases = listOf(
detail("s8_long", ExpenseScenarios.snickareId, ExpenseScenarios.long),
)
@@ -0,0 +1,55 @@
package se.hagstedt.kvitt.feature.expense.screenshot
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
/**
* S-7 and S-8 goldens: every state × light/dark × Swedish/English, plus 200 % for the states the design spec draws at
* 200 % (PLAN-M7c §5, §7). Frame: Galaxy S26, 411 × 891 dp. Re-record with
* `tools/gradle-serial.sh :feature:expense:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
class ExpenseScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val case = (editorCases + detailCases).first { it.name == name }
ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content())
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun cases(): List<Array<Any>> = (editorCases + detailCases).map { arrayOf<Any>(it.name) }
}
}
/** Long content on a tall frame (411 × 1600 dp) so the whole screen, including a long timeline, is in the golden. */
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h1600dp-xxhdpi")
class ExpenseTallScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val case = tallCases.first { it.name == name }
ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content())
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun cases(): List<Array<Any>> = tallCases.map { arrayOf<Any>(it.name) }
}
}
@@ -0,0 +1,44 @@
package se.hagstedt.kvitt.feature.expense.screenshot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A sheet or dialog drawn over the screen in the same window, the way `:core:design` screenshots `ConfirmDialogContent`
* and the sheet contents: Robolectric captures of separate windows lose the configuration's locale (the window's own
* `LocalConfiguration`), so the goldens draw the real content in the Material sheet or dialog frame instead.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverlayFrame(sheet: Boolean, base: @Composable () -> Unit, overlay: @Composable () -> Unit) {
Box(Modifier.fillMaxSize()) {
base()
Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = if (sheet) SHEET_SCRIM else DIALOG_SCRIM)))
if (sheet) {
Surface(Modifier.align(Alignment.BottomCenter).fillMaxWidth(), shape = BottomSheetDefaults.ExpandedShape, color = BottomSheetDefaults.ContainerColor) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BottomSheetDefaults.DragHandle()
Box(Modifier.fillMaxWidth()) { overlay() }
}
}
} else {
Box(Modifier.align(Alignment.Center).padding(horizontal = DIALOG_SIDE)) { overlay() }
}
}
}
private const val SHEET_SCRIM = 0.32f
private const val DIALOG_SCRIM = 0.38f
private val DIALOG_SIDE = 24.dp
@@ -0,0 +1,103 @@
package se.hagstedt.kvitt.feature.expense.screenshot
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.test.onRoot
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
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
/**
* Captures one golden per language and theme of a whole screen: `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`,
* plus `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, as the spec's 200 %
* frames). The pattern of `:core:design` (M6): content set once, language, theme and font scale switched through a
* configuration context between captures.
* The clock is fixed at 17 October 2026 14:02 in Stockholm, the fake scenarios' "now".
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
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 ""
}
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean = false, 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.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() }
}
}
// Material's date picker formats months and weekdays with the JVM default locale (the app's locale on a phone).
val hostLocale = Locale.getDefault()
try {
variants.forEach { variant ->
Locale.setDefault(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB"))
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
} finally {
Locale.setDefault(hostLocale)
}
}
/**
* Runs [setup] with the view models' main dispatcher set to a test dispatcher and returns its result; [setup] calls
* the given `idle` after actions that wait (the 150 ms suggestion debounce).
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> build(setup: (idle: () -> Unit) -> T): T {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val out = mutableListOf<T>()
try {
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
} finally {
Dispatchers.resetMain()
}
return out.single()
}
}