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:
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
+295
@@ -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)
|
||||
}
|
||||
}
|
||||
+170
@@ -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),
|
||||
)
|
||||
+55
@@ -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) }
|
||||
}
|
||||
}
|
||||
+44
@@ -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
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user