From 8229ed60eafd6249c666c3a96227f01e723fe96a Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 22:54:03 +0200 Subject: [PATCH] Android overhaul: one screenshot harness in :core:design's test fixtures; kvitt.android.screenshots wires Roborazzi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight modules each had a copy of the same configuration-context harness (ScreenSnapshots/Snapshots); M12f had to fix the same capture bug in two of them. :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing) now hold Variant, TestClock, TestEnvironment, ScreenSnapshots with its three capture modes (Root; Screen, with the hand-advanced frame clock; ScreenWhenIdle), MainDispatcherRule and withMain. Test fixtures rather than a :core:testing module: :core:design's own tests can use them without a dependency on itself, no module is added to the §6.2 graph, and lint treats them as test sources. The architecture check rejects test fixtures outside test configurations. Modules keep only their own frame (analytics household, settle size and inline overlays, settings avatar pictures). Every golden is unchanged. kvitt.android.screenshots applies Roborazzi with outputDir src/test/screenshots and adds the screenshot test libraries; the feature plugin applies it and adds the test fixtures, so the seven feature build scripts lose their copied blocks. Removed: roborazzi-junit-rule and roborazzi-compose (never imported), the explicit lifecycle-viewmodel-compose (feature plugin, :app) and hilt-lifecycle-viewmodel-compose in :app (both still arrive through the features; release and debug runtime classpaths are identical), and the unused catalog entries androidx-hilt-navigation-compose and androidx-lifecycle-viewmodel-compose. @Before/@After setMain pairs in nine test classes use MainDispatcherRule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/adapter/fcm/build.gradle.kts | 2 +- android/app/build.gradle.kts | 19 +-- .../se/hagstedt/kvitt/AppViewModelTest.kt | 15 +-- .../kvitt/KvittEmptyScreenScreenshotTest.kt | 3 +- android/build-logic/build.gradle.kts | 5 + .../kvitt/buildlogic/AndroidFeaturePlugin.kt | 6 +- .../buildlogic/AndroidScreenshotsPlugin.kt | 25 ++++ .../kvitt/buildlogic/ArchitectureCheckTask.kt | 21 +++- .../kvitt/buildlogic/ArchitecturePlugin.kt | 5 +- .../kvitt/buildlogic/ArchitectureRules.kt | 5 +- android/core/design/build.gradle.kts | 22 ++-- .../kvitt/design/screenshot/Snapshots.kt | 78 ++---------- .../kvitt/design/testing/MainDispatcher.kt | 29 +++++ .../kvitt/design/testing/ScreenSnapshots.kt | 116 ++++++++++++++++++ .../kvitt/design/testing/TestEnvironment.kt | 66 ++++++++++ android/feature/account/build.gradle.kts | 16 +-- .../feature/account/AccountScreenshotTest.kt | 3 +- .../kvitt/feature/account/AccountStates.kt | 1 + .../feature/account/AccountViewModelTest.kt | 1 + android/feature/analytics/build.gradle.kts | 19 +-- .../analytics/AnalyticsViewModelTest.kt | 14 +-- .../analytics/DrillDownViewModelTest.kt | 15 +-- .../screenshot/AnalyticsScreenshotTest.kt | 5 +- .../screenshot/AnalyticsSnapshots.kt | 74 ++--------- .../screenshot/DrillDownScreenshotTest.kt | 5 +- android/feature/expense/build.gradle.kts | 15 +-- .../expense/EditorOptionRowsLayoutTest.kt | 10 +- .../expense/ExpenseDetailViewModelTest.kt | 18 +-- .../expense/ExpenseEditorViewModelTest.kt | 18 +-- .../feature/expense/screenshot/BuildState.kt | 101 ++------------- .../kvitt/feature/expense/screenshot/Cases.kt | 4 +- .../screenshot/ExpenseScreenshotTest.kt | 7 +- android/feature/home/build.gradle.kts | 20 +-- .../kvitt/feature/home/HomeScreenshotTest.kt | 4 +- .../kvitt/feature/home/HomeViewModelTest.kt | 3 +- android/feature/onboarding/build.gradle.kts | 19 +-- .../feature/onboarding/OtherViewModelsTest.kt | 15 +-- .../onboarding/PairingViewModelTest.kt | 15 +-- .../screenshot/OnboardingScreenshotTest.kt | 22 ++-- android/feature/settings/build.gradle.kts | 17 +-- .../feature/settings/logic/StateHolderTest.kt | 3 +- .../RecurringOptionRowsLayoutTest.kt | 7 +- .../screenshot/CategoriesScreenshotTest.kt | 10 +- .../DevicesInvitesAboutScreenshotTest.kt | 12 +- .../settings/screenshot/HubScreenshotTest.kt | 2 +- .../screenshot/ProfileScreenshotTest.kt | 4 +- .../screenshot/RecurringScreenshotTest.kt | 16 +-- .../settings/screenshot/SettingsSnapshots.kt | 92 ++++---------- .../feature/settings/screenshot/Shots.kt | 2 +- android/feature/settle/build.gradle.kts | 17 +-- .../feature/settle/SettleViewModelTest.kt | 15 +-- .../settle/SettlementDetailViewModelTest.kt | 14 +-- .../settle/screenshot/SettleScreenshotTest.kt | 12 +- .../settle/screenshot/SettleSnapshots.kt | 67 +--------- android/gradle/libs.versions.toml | 5 +- 55 files changed, 472 insertions(+), 664 deletions(-) create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidScreenshotsPlugin.kt create mode 100644 android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/MainDispatcher.kt create mode 100644 android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/ScreenSnapshots.kt create mode 100644 android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/TestEnvironment.kt diff --git a/android/adapter/fcm/build.gradle.kts b/android/adapter/fcm/build.gradle.kts index ef36c41..9ce6b88 100644 --- a/android/adapter/fcm/build.gradle.kts +++ b/android/adapter/fcm/build.gradle.kts @@ -1,4 +1,4 @@ -// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (SPEC §6.2, owned by M8): the +// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (SPEC §6.2): the // FirebaseMessagingService, the FCM token calls, and the mapping of data messages to the project's own PushEvent. plugins { id("kvitt.android.library") diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7f77b93..8f015e1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,13 +1,13 @@ // :app — MainActivity, Application, Hilt wiring (every :core:model interface bound to :data, D-041 and D-085), -// navigation and deep links, push handling and notifications, network security and release signing (SPEC §6.2, M8). +// navigation and deep links, push handling and notifications, network security and release signing (SPEC §6.2). import java.util.Properties plugins { id("kvitt.android.application") id("kvitt.android.compose") id("kvitt.android.hilt") + id("kvitt.android.screenshots") alias(libs.plugins.google.services) - alias(libs.plugins.roborazzi) } // SPEC §10.6: release signing from android/local.properties (git-ignored); without the keys the release build is unsigned. @@ -67,10 +67,6 @@ android { } } -roborazzi { - outputDir.set(file("src/test/screenshots")) -} - dependencies { implementation(project(":core:model")) implementation(project(":core:design")) @@ -85,25 +81,16 @@ dependencies { implementation(project(":feature:analytics")) implementation(project(":feature:settings")) testImplementation(project(":core:fakes")) + testImplementation(testFixtures(project(":core:design"))) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.work.runtime.ktx) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.coil) implementation(libs.coil.compose) - - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt index 6d4e2a6..a257378 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt @@ -1,31 +1,24 @@ package se.hagstedt.kvitt -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -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.Rule import org.junit.Test import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.FakeScenarios import se.hagstedt.kvitt.link.AppLink import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.design.testing.MainDispatcherRule /** The shell's link and permission rules: AC-2.6 (D-084) and AC-2.9. A plain JVM test over the fakes. */ @OptIn(ExperimentalCoroutinesApi::class) class AppViewModelTest { - @Before - fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun tearDown() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts) diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt index 5ff389a..95c673b 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt @@ -11,6 +11,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** * Golden screenshots of the empty screen in light and dark (Roborazzi on Robolectric). `check` verifies them; @@ -19,7 +20,7 @@ import se.hagstedt.kvitt.design.KvittTheme */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi", application = Application::class) +@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS, application = Application::class) class KvittEmptyScreenScreenshotTest { @get:Rule val compose = createComposeRule() diff --git a/android/build-logic/build.gradle.kts b/android/build-logic/build.gradle.kts index a83b3d6..e2b6a44 100644 --- a/android/build-logic/build.gradle.kts +++ b/android/build-logic/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { compileOnly(libs.android.gradle.plugin) compileOnly(libs.kotlin.gradle.plugin) compileOnly(libs.compose.compiler.gradle.plugin) + compileOnly(libs.roborazzi.gradle.plugin) } gradlePlugin { @@ -30,6 +31,10 @@ gradlePlugin { id = "kvitt.android.application" implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidApplicationPlugin" } + register("kvittAndroidScreenshots") { + id = "kvitt.android.screenshots" + implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidScreenshotsPlugin" + } register("kvittAndroidHilt") { id = "kvitt.android.hilt" implementationClass = "se.hagstedt.kvitt.buildlogic.HiltPlugin" diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt index 34a3caa..e15093a 100644 --- a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt @@ -6,7 +6,8 @@ import org.gradle.api.Project /** * Feature module (SPEC §6.2, convention in android/README.md): Compose screens against the UI contract only. * Features see `:core:model` and `:core:design`; `:core:fakes` only for debug previews and tests, so fakes never reach - * a release build. ViewModels are Hilt ViewModels obtained with `hiltViewModel()`. + * a release build; `:core:design`'s test fixtures and the screenshot tests (`kvitt.android.screenshots`) for tests. ViewModels are + * Hilt ViewModels obtained with `hiltViewModel()`. */ class AndroidFeaturePlugin : Plugin { override fun apply(target: Project) { @@ -14,13 +15,14 @@ class AndroidFeaturePlugin : Plugin { pluginManager.apply("kvitt.android.library") pluginManager.apply("kvitt.android.compose") pluginManager.apply("kvitt.android.hilt") + pluginManager.apply("kvitt.android.screenshots") val catalog = libs dependencies.add("implementation", dependencies.project(mapOf("path" to ":core:model"))) dependencies.add("implementation", dependencies.project(mapOf("path" to ":core:design"))) dependencies.add("debugImplementation", dependencies.project(mapOf("path" to ":core:fakes"))) dependencies.add("testImplementation", dependencies.project(mapOf("path" to ":core:fakes"))) + dependencies.add("testImplementation", dependencies.testFixtures(dependencies.project(mapOf("path" to ":core:design")))) dependencies.add("implementation", catalog.library("androidx-hilt-lifecycle-viewmodel-compose")) - dependencies.add("implementation", catalog.library("androidx-lifecycle-viewmodel-compose")) dependencies.add("implementation", catalog.library("androidx-lifecycle-runtime-compose")) dependencies.add("implementation", catalog.library("kotlinx-coroutines-core")) } diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidScreenshotsPlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidScreenshotsPlugin.kt new file mode 100644 index 0000000..5534d18 --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidScreenshotsPlugin.kt @@ -0,0 +1,25 @@ +package se.hagstedt.kvitt.buildlogic + +import io.github.takahirom.roborazzi.RoborazziExtension +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Roborazzi screenshot tests on Robolectric for an Android module with Compose (SPEC §6.2 "Tests", D-010): goldens in + * `src/test/screenshots/`, verified by every unit-test run (`roborazzi.test.verify=true` in gradle.properties), and the + * test libraries every screenshot test uses. Shared harness: `:core:design`'s test fixtures + * (`se.hagstedt.kvitt.design.testing`). + */ +class AndroidScreenshotsPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("io.github.takahirom.roborazzi") + extensions.configure(RoborazziExtension::class.java) { outputDir.set(file("src/test/screenshots")) } + val catalog = libs + dependencies.add("debugImplementation", catalog.library("androidx-compose-ui-test-manifest")) + dependencies.add("testImplementation", catalog.library("androidx-compose-ui-test-junit4")) + dependencies.add("testImplementation", catalog.library("robolectric")) + dependencies.add("testImplementation", catalog.library("roborazzi")) + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt index 2c4e551..9fc4c1d 100644 --- a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt @@ -15,7 +15,8 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction /** - * Fails the build on a dependency edge outside SPEC §6.2, on `:core:fakes` in a release configuration, and on + * Fails the build on a dependency edge outside SPEC §6.2, on `:core:fakes` in a release configuration, on + * test fixtures outside a test configuration, and on * imports or fully qualified references outside kotlin.*, kotlinx.* and the project's packages in a pure module. */ @CacheableTask @@ -23,7 +24,7 @@ abstract class ArchitectureCheckTask : DefaultTask() { @get:Input abstract val modulePath: Property - /** Declared project dependencies as `configuration|:path`. */ + /** Declared project dependencies as `configuration|:path`, `configuration|:path+testFixtures` for test fixtures. */ @get:Input abstract val projectDependencies: ListProperty @@ -51,7 +52,9 @@ abstract class ArchitectureCheckTask : DefaultTask() { } val allowed = ArchitectureRules.allowed[path] for (entry in projectDependencies.get()) { - val (configuration, target) = entry.split('|', limit = 2) + val (configuration, dependency) = entry.split('|', limit = 2) + val fixtures = dependency.endsWith(TEST_FIXTURES) + val target = dependency.removeSuffix(TEST_FIXTURES) if (target == path) continue if (allowed != null && target !in allowed) { violations += "$path -> $target ($configuration) is not an allowed edge (SPEC §6.2)" @@ -59,6 +62,9 @@ abstract class ArchitectureCheckTask : DefaultTask() { if (target == ArchitectureRules.FAKES && !ArchitectureRules.isTestOrDebugConfiguration(configuration)) { violations += "$path -> $target in '$configuration': fakes only in test or debug configurations" } + if (fixtures && !ArchitectureRules.isTestConfiguration(configuration)) { + violations += "$path -> $target's test fixtures in '$configuration': test fixtures only in test configurations" + } } if (path in ArchitectureRules.pureModules) { if (androidPluginApplied.get()) violations += "$path is pure Kotlin/JVM and must not apply an Android plugin" @@ -90,8 +96,11 @@ abstract class ArchitectureCheckTask : DefaultTask() { } } - private companion object { - val IMPORT = Regex("""^import\s+([\w.]+)""") - val QUALIFIED = Regex("""(? { modulePath.set(projectPath) projectDependencies.set(provider { configurations.flatMap { c -> - c.dependencies.withType(ProjectDependency::class.java).map { "${c.name}|${it.path}" } + c.dependencies.withType(ProjectDependency::class.java).map { dependency -> + val fixtures = dependency.capabilitySelectors.any { it.displayName.contains("test-fixtures") } + "${c.name}|${dependency.path}" + if (fixtures) ArchitectureCheckTask.TEST_FIXTURES else "" + } }.distinct().sorted() }) externalDependencies.set(provider { diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt index f65c3d3..226e180 100644 --- a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt @@ -36,5 +36,8 @@ object ArchitectureRules { /** `:core:fakes` may appear only in test and debug configurations (fakes never reach a release build). */ fun isTestOrDebugConfiguration(name: String): Boolean = - name.contains("test", ignoreCase = true) || name.contains("debug", ignoreCase = true) + isTestConfiguration(name) || name.contains("debug", ignoreCase = true) + + /** A module's test fixtures (`testFixtures(project(…))`) may appear only in test configurations. */ + fun isTestConfiguration(name: String): Boolean = name.contains("test", ignoreCase = true) } diff --git a/android/core/design/build.gradle.kts b/android/core/design/build.gradle.kts index 73fcbaf..28ffb30 100644 --- a/android/core/design/build.gradle.kts +++ b/android/core/design/build.gradle.kts @@ -1,13 +1,15 @@ // :core:design — theme, tokens, shared components, charts, formatters and shared strings (SPEC §6.2, docs/ux/design-spec.md -// §2 and §3). Owned by M6; feature modules (M7x) consume it and may not edit it. +// §2 and §3). Feature modules consume it; screens' shared UI belongs here, not in a feature. Its test fixtures +// (src/testFixtures, package se.hagstedt.kvitt.design.testing) are the test support every module's tests share: the +// golden variants, the fixed clock, the screenshot harness and the main-dispatcher helpers (test configurations only). plugins { id("kvitt.android.library") id("kvitt.android.compose") - alias(libs.plugins.roborazzi) + id("kvitt.android.screenshots") } -roborazzi { - outputDir.set(file("src/test/screenshots")) +android { + testFixtures { enable = true } } dependencies { @@ -15,10 +17,10 @@ dependencies { // Avatars: pictures are loaded by Coil; :app supplies the request model through LocalAvatarPictures (design-spec §3.3). implementation(libs.coil.compose) - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) + testFixturesApi(platform(libs.androidx.compose.bom)) + testFixturesApi(libs.androidx.compose.ui.test.junit4) + testFixturesApi(libs.robolectric) + testFixturesApi(libs.roborazzi) + testFixturesApi(libs.kotlinx.coroutines.test) + testFixturesApi(libs.junit) } diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt index db7b749..3e3b626 100644 --- a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt @@ -1,7 +1,5 @@ package se.hagstedt.kvitt.design.screenshot -import android.content.res.Configuration -import android.os.LocaleList import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -11,84 +9,30 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.Modifier -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 androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import kotlinx.datetime.TimeZone import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.catalogue.Samples -import se.hagstedt.kvitt.design.component.KvittClock -import se.hagstedt.kvitt.design.component.LocalKvittClock import se.hagstedt.kvitt.design.component.ProvideHousehold -import java.util.Locale -import kotlin.time.Instant +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** - * Captures three goldens (Swedish light and dark, English light; D-098): `src/test/screenshots/_.png`, plus - * `_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, like the spec's 200 % frames). - * The content is set once; language (configuration, resources), theme and font scale (the platform's non-linear - * scaling, from a configuration context) switch between captures. Width: the Galaxy S26, 411 dp (SPEC §13.1). The - * clock is fixed at 17 October 2026 14:02 in Stockholm and the viewer is Jacob, so every render is reproducible. + * A component sheet's goldens through the test fixtures' [ScreenSnapshots] (`:core:design`) (D-098): the sheet is a column 411 dp wide (the + * Galaxy S26, SPEC §13.1) with the gutter and section gaps, on `surface`, and the viewer is Jacob. */ object Snapshots { - 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, content: @Composable ColumnScope.() -> Unit) { - val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 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) { - ProvideHousehold(Samples.jacob, Samples.anna) { - Surface(color = MaterialTheme.colorScheme.surface) { - Column( - modifier = Modifier.width(411.dp).wrapContentHeight().padding(KvittTheme.spacing.gutter), - verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap), - content = content, - ) - } - } + ScreenSnapshots.snap(compose, name, largeFont) { + ProvideHousehold(Samples.jacob, Samples.anna) { + Surface(color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.width(411.dp).wrapContentHeight().padding(KvittTheme.spacing.gutter), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap), + content = content, + ) } } } - variants.forEach { variant -> - current = variant - compose.waitForIdle() - compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") - } } } diff --git a/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/MainDispatcher.kt b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/MainDispatcher.kt new file mode 100644 index 0000000..6dd0e84 --- /dev/null +++ b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/MainDispatcher.kt @@ -0,0 +1,29 @@ +package se.hagstedt.kvitt.design.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** Sets `Dispatchers.Main` to [dispatcher] (unconfined by default) for each test, so ViewModels run on the test thread. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule(val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() { + override fun starting(description: Description) = Dispatchers.setMain(dispatcher) + + override fun finished(description: Description) = Dispatchers.resetMain() +} + +/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */ +@OptIn(ExperimentalCoroutinesApi::class) +fun withMain(block: () -> T): T { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + return block() + } finally { + Dispatchers.resetMain() + } +} diff --git a/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/ScreenSnapshots.kt b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/ScreenSnapshots.kt new file mode 100644 index 0000000..78642bb --- /dev/null +++ b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/ScreenSnapshots.kt @@ -0,0 +1,116 @@ +package se.hagstedt.kvitt.design.testing + +import android.os.Looper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.captureScreenRoboImage +import org.robolectric.Shadows.shadowOf +import se.hagstedt.kvitt.design.KvittTheme +import java.util.Locale + +/** + * The goldens of a screen state (SPEC §6.2 "Tests", D-098): `src/test/screenshots/_.png` for every + * [Variant.goldens]. The content is composed once; language, theme and font scale switch through [TestEnvironment] + * between captures. Full screens render at 1x through [QUALIFIERS] (the Galaxy S26, 411 × 891 dp, SPEC §13.1). + */ +object ScreenSnapshots { + /** Robolectric qualifiers of a full screen at 1x. */ + const val QUALIFIERS = "w411dp-h891dp-mdpi" + + /** How a variant is settled and captured. */ + enum class Capture { + /** The compose root after `waitForIdle`: the screen without dialog windows. */ + Root, + + /** + * The whole screen with its dialog windows, frame clock advanced by hand so animations and snackbars render + * the same every run: 1 s, then one more frame and an idle main looper, so the capture shows this variant and + * not the previous one (a dialog window draws in a later frame). + */ + Screen, + + /** + * The whole screen with its dialog windows after the main looper (ViewModel coroutines) and Compose are idle; + * the `prepare` step (a scroll) runs once before the first capture. + */ + ScreenWhenIdle, + } + + private const val SETTLE_MILLIS = 1_000L + + /** Runs pending main-thread work (ViewModel coroutines). */ + fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + /** + * Captures [content] in [KvittTheme] for every variant. [recreate] composes the content anew per variant, so + * remembered state (scroll, picker) starts fresh; [jvmLocale] also sets the JVM default locale per variant + * (Material's date picker formats months and weekdays with it, as the app's locale on a phone); [providers] wraps + * the theme, for composition locals a module's screens need (avatar pictures). + */ + @OptIn(ExperimentalRoborazziApi::class) + fun snap( + compose: ComposeContentTestRule, + name: String, + largeFont: Boolean = false, + capture: Capture = Capture.Root, + recreate: Boolean = false, + jvmLocale: Boolean = false, + prepare: (ComposeContentTestRule) -> Unit = {}, + providers: @Composable (content: @Composable () -> Unit) -> Unit = { it() }, + content: @Composable () -> Unit, + ) { + val variants = Variant.goldens(largeFont) + var current by mutableStateOf(variants.first()) + if (capture == Capture.Screen) compose.mainClock.autoAdvance = false + compose.setContent { + val variant = current + TestEnvironment(variant) { + providers { + KvittTheme(darkTheme = variant.dark) { + if (recreate) key(variant) { content() } else content() + } + } + } + } + if (capture == Capture.ScreenWhenIdle) { + idleMainLooper() + compose.waitForIdle() + prepare(compose) + } + val hostLocale = Locale.getDefault() + try { + variants.forEach { variant -> + if (jvmLocale) Locale.setDefault(variant.locale) + current = variant + val path = "src/test/screenshots/${name}_${variant.suffix}.png" + when (capture) { + Capture.Root -> { + compose.waitForIdle() + compose.onRoot().captureRoboImage(path) + } + Capture.Screen -> { + compose.mainClock.advanceTimeBy(SETTLE_MILLIS) + idleMainLooper() + compose.mainClock.advanceTimeByFrame() + idleMainLooper() + captureScreenRoboImage(path) + } + Capture.ScreenWhenIdle -> { + idleMainLooper() + compose.waitForIdle() + captureScreenRoboImage(path) + } + } + } + } finally { + if (jvmLocale) Locale.setDefault(hostLocale) + } + } +} diff --git a/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/TestEnvironment.kt b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/TestEnvironment.kt new file mode 100644 index 0000000..e81f89c --- /dev/null +++ b/android/core/design/src/testFixtures/kotlin/se/hagstedt/kvitt/design/testing/TestEnvironment.kt @@ -0,0 +1,66 @@ +package se.hagstedt.kvitt.design.testing + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +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.unit.Density +import kotlinx.datetime.TimeZone +import se.hagstedt.kvitt.design.component.KvittClock +import se.hagstedt.kvitt.design.component.LocalKvittClock +import java.util.Locale +import kotlin.time.Instant + +/** The tests' "now": 17 October 2026 14:02 in Stockholm, the fake scenarios' clock, so every render is reproducible. */ +val TestClock: KvittClock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) + +/** + * One golden of a component sheet or screen state (D-098): Swedish light and dark and English light, plus Swedish + * light at 200 % font scale for the long states. [suffix] is the file-name part `sv_light`, `sv_dark`, `en_light` or + * `sv_light_200`. + */ +data class Variant(val language: String, val dark: Boolean, val fontScale: Float = 1f) { + val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else "" + + /** The app's locale for [language]: `sv-SE` or `en-GB`. */ + val locale: Locale get() = Locale.forLanguageTag(if (language == "sv") "sv-SE" else "en-GB") + + companion object { + /** The three goldens of every state, then the 200 % one when [largeFont]. */ + fun goldens(largeFont: Boolean): List = + listOf(Variant("sv", dark = false), Variant("sv", dark = true), Variant("en", dark = false)) + + if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList() + } +} + +/** + * Renders [content] as the phone would in [variant]: language (configuration and resources), night mode and font + * scale (the platform's non-linear scaling, from a configuration context) and [clock]. The theme itself is the + * caller's, so a test can pick `KvittTheme(darkTheme = variant.dark)`. + */ +@Composable +fun TestEnvironment(variant: Variant, clock: KvittClock = TestClock, content: @Composable () -> Unit) { + val base = LocalContext.current + val baseConfiguration = LocalConfiguration.current + val context = remember(variant) { + val config = Configuration(baseConfiguration).apply { + setLocales(LocaleList(variant.locale)) + 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, + content = content, + ) +} diff --git a/android/feature/account/build.gradle.kts b/android/feature/account/build.gradle.kts index 6ad6695..304339a 100644 --- a/android/feature/account/build.gradle.kts +++ b/android/feature/account/build.gradle.kts @@ -1,22 +1,10 @@ -// :feature:account — Account detail and account editor (S-5, S-6; M7b). Convention: android/README.md. -// Dependencies: :core:model and :core:design; :core:fakes for tests and debug only (all three from kvitt.android.feature). +// :feature:account — Account detail and account editor (S-5, S-6). Convention: android/README.md; :core:model, +// :core:design, :core:fakes (debug and tests), :core:design's test fixtures and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) -} - -roborazzi { - outputDir.set(file("src/test/screenshots")) } dependencies { // BackHandler: system back on S-6 with unsaved changes asks to discard (design-spec S-6). implementation(libs.androidx.activity.compose) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) - debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt index dd353f3..6ef8e2f 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountScreenshotTest.kt @@ -11,6 +11,7 @@ import org.robolectric.annotation.GraphicsMode import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.account.AccountScenarios import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** 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) @@ -69,7 +70,7 @@ class AccountScreenshotTest(private val state: String) { @Test fun screen() { val screen = accountStates.first { it.name == state } - ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content) + ScreenSnapshots.snap(compose, screen.name, screen.largeFont, ScreenSnapshots.Capture.Screen, content = screen.content) } companion object { diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt index 340439f..670f4f4 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountStates.kt @@ -7,6 +7,7 @@ import se.hagstedt.kvitt.model.AccountId import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.OnlineResult import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.design.testing.withMain /** 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 { diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt index db67f6f..eb004a7 100644 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt +++ b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/AccountViewModelTest.kt @@ -9,6 +9,7 @@ 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 +import se.hagstedt.kvitt.design.testing.withMain class AccountDetailViewModelTest { private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready diff --git a/android/feature/analytics/build.gradle.kts b/android/feature/analytics/build.gradle.kts index dc70739..778073e 100644 --- a/android/feature/analytics/build.gradle.kts +++ b/android/feature/analytics/build.gradle.kts @@ -1,20 +1,5 @@ -// :feature:analytics — Analytics (S-11) and drill-down (S-12), M7e. Convention: android/README.md. :core:model, -// :core:design and (tests and debug only) :core:fakes come from the kvitt.android.feature plugin. +// :feature:analytics — Analytics (S-11) and drill-down (S-12). Convention: android/README.md; :core:model, +// :core:design, :core:fakes (debug and tests), :core:testing and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) -} - -roborazzi { - outputDir.set(file("src/test/screenshots")) -} - -dependencies { - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt index 809ea2b..97aa5af 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsViewModelTest.kt @@ -1,17 +1,13 @@ package se.hagstedt.kvitt.feature.analytics -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher -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.assertTrue -import org.junit.Before +import org.junit.Rule import org.junit.Test import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeScenario @@ -20,14 +16,12 @@ import se.hagstedt.kvitt.model.Granularity import se.hagstedt.kvitt.model.GroupBy import se.hagstedt.kvitt.model.Measure import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.design.testing.MainDispatcherRule @OptIn(ExperimentalCoroutinesApi::class) class AnalyticsViewModelTest { - @Before - fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun tearDown() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private fun viewModel(scenario: FakeScenario): AnalyticsViewModel { val fakes = FakeRepositories(scenario) diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt index 6e9fe2d..73bcca3 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownViewModelTest.kt @@ -1,26 +1,19 @@ package se.hagstedt.kvitt.feature.analytics -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher -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.Before +import org.junit.Rule import org.junit.Test import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios +import se.hagstedt.kvitt.design.testing.MainDispatcherRule @OptIn(ExperimentalCoroutinesApi::class) class DrillDownViewModelTest { - @Before - fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun tearDown() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private suspend fun load(args: DrillDownArgs): DrillDownUiState { val fakes = FakeRepositories(AnalyticsScenarios.loaded) diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt index 5e60fca..5f4d9db 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsScreenshotTest.kt @@ -32,6 +32,7 @@ import se.hagstedt.kvitt.model.Granularity import se.hagstedt.kvitt.model.GroupBy import se.hagstedt.kvitt.model.Measure import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** * S-11 in every state of design-spec S-11 and PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content). @@ -39,7 +40,7 @@ import se.hagstedt.kvitt.model.RangePreset */ @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi") +@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS) class AnalyticsScreenshotTest(private val name: String) { @get:Rule val compose = createComposeRule() @@ -47,7 +48,7 @@ class AnalyticsScreenshotTest(private val name: String) { @Test fun screen() { val state = states.getValue(name) - ScreenSnapshots.snap(compose, name, largeFont = name == "s11_long", content = state) + AnalyticsSnapshots.snap(compose, name, largeFont = name == "s11_long", content = state) } companion object { diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsSnapshots.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsSnapshots.kt index 75919cc..e5edde4 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsSnapshots.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsSnapshots.kt @@ -1,86 +1,28 @@ package se.hagstedt.kvitt.feature.analytics.screenshot -import android.content.res.Configuration -import android.os.LocaleList import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -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.datetime.TimeZone -import se.hagstedt.kvitt.design.KvittTheme -import se.hagstedt.kvitt.design.component.KvittClock -import se.hagstedt.kvitt.design.component.LocalKvittClock import se.hagstedt.kvitt.design.component.ProvideHousehold import se.hagstedt.kvitt.fakes.FakeScenarios -import java.util.Locale -import kotlin.time.Instant +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** - * Captures a whole screen (411 × 891 dp, the Galaxy S26 of SPEC §13.1) in three variants (Swedish light and dark, English light; D-098): - * `src/test/screenshots/_.png`, plus `_sv_light_200.png` with [largeFont] (200 %, - * like the design spec's 200 % frames). The clock is the fakes' today (17 October 2026, 14:02 in Stockholm) and the - * viewer is Jacob. Language, theme and font scale switch through a configuration context, as in :core:design's tests; - * the content is recreated per variant so remembered state (scroll, picker) starts fresh. + * S-11's and S-12's goldens through the test fixtures' [ScreenSnapshots] (`:core:design`): the viewer is Jacob, the screen fills the + * surface, and the content is recreated per variant so remembered state (scroll, picker) starts fresh. */ -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 "" - } - +object AnalyticsSnapshots { fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean = false, content: @Composable () -> Unit) { - val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 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) { - ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { - key(variant) { Box(Modifier.fillMaxSize()) { content() } } - } - } + ScreenSnapshots.snap(compose, name, largeFont, recreate = true) { + ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Box(Modifier.fillMaxSize()) { content() } } } } - variants.forEach { variant -> - current = variant - compose.waitForIdle() - compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") - } } } diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt index 0216b2e..70374eb 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/DrillDownScreenshotTest.kt @@ -18,18 +18,19 @@ import se.hagstedt.kvitt.feature.analytics.DrillDownArgs import se.hagstedt.kvitt.feature.analytics.DrillDownScreen import se.hagstedt.kvitt.feature.analytics.DrillDownUiState import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** S-12 in every state of PLAN-M7e §5, × light/dark × sv/en (plus 200 % for long content). */ @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi") +@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS) class DrillDownScreenshotTest(private val name: String) { @get:Rule val compose = createComposeRule() @Test fun screen() { - ScreenSnapshots.snap(compose, name, largeFont = name == "s12_long", content = states.getValue(name)) + AnalyticsSnapshots.snap(compose, name, largeFont = name == "s12_long", content = states.getValue(name)) } companion object { diff --git a/android/feature/expense/build.gradle.kts b/android/feature/expense/build.gradle.kts index 1876e2d..73a7e1e 100644 --- a/android/feature/expense/build.gradle.kts +++ b/android/feature/expense/build.gradle.kts @@ -1,21 +1,10 @@ -// :feature:expense — Expense editor, expense detail and timeline (S-7, S-8; M7c). Convention: android/README.md. +// :feature:expense — Expense editor, expense detail and timeline (S-7, S-8). Convention: android/README.md; :core:model, +// :core:design, :core:fakes (debug and tests), :core:design's test fixtures and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) -} - -roborazzi { - outputDir.set(file("src/test/screenshots")) } dependencies { // BackHandler: back with the keypad up hides it instead of closing the editor (design-spec §3.7). implementation(libs.androidx.activity.compose) - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt index aa5daff..e461e85 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/EditorOptionRowsLayoutTest.kt @@ -31,8 +31,10 @@ import se.hagstedt.kvitt.design.format.AmountKey import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios import se.hagstedt.kvitt.feature.expense.screenshot.NoActions -import se.hagstedt.kvitt.feature.expense.screenshot.ScreenSnapshots +import se.hagstedt.kvitt.feature.expense.screenshot.buildState import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.design.testing.ScreenSnapshots +import se.hagstedt.kvitt.design.testing.TestClock /** * The four option rows of S-7 (Konto, Datum, Betalt av, Delning; D-102) keep their places and sizes when their values @@ -42,12 +44,12 @@ import se.hagstedt.kvitt.model.SplitMode */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi") +@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS) class EditorOptionRowsLayoutTest { @get:Rule val compose = createComposeRule() - private val short: ExpenseEditorState = ScreenSnapshots.build { idle -> + private val short: ExpenseEditorState = buildState { idle -> val fakes = FakeRepositories(ExpenseScenarios.household) val vm = ExpenseEditorViewModel(SavedStateHandle(), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity) idle() @@ -79,7 +81,7 @@ class EditorOptionRowsLayoutTest { LocalConfiguration provides context.resources.configuration, LocalResources provides context.resources, LocalDensity provides Density(context), - LocalKvittClock provides ScreenSnapshots.clock, + LocalKvittClock provides TestClock, ) { KvittTheme { ExpenseEditorContent(state, NoActions) } } diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt index c846ac8..9c46436 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt @@ -1,37 +1,29 @@ 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.Rule 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 +import se.hagstedt.kvitt.design.testing.MainDispatcherRule /** 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() + @get:Rule + val main = MainDispatcherRule() + private val dispatcher = main.dispatcher private fun TestScope.detail(id: ExpenseId, scenario: FakeScenario = ExpenseScenarios.household): Pair { val fakes = FakeRepositories(scenario) diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt index 43654de..7eb15b0 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt @@ -1,21 +1,16 @@ 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.Rule import org.junit.Test import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountKey @@ -26,20 +21,17 @@ 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 +import se.hagstedt.kvitt.design.testing.MainDispatcherRule /** 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() + @get:Rule + val main = MainDispatcherRule() + private val dispatcher = main.dispatcher 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 = emptyMap()): Pair { val fakes = FakeRepositories(scenario) val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity) diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/BuildState.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/BuildState.kt index 69efab8..ea658cb 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/BuildState.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/BuildState.kt @@ -1,21 +1,5 @@ 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 @@ -23,81 +7,20 @@ 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 three goldens of a whole screen (Swedish light and dark, English light; D-098): `src/test/screenshots/_.png`, - * plus `_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". + * 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). */ -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(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 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 build(setup: (idle: () -> Unit) -> T): T { - val dispatcher = UnconfinedTestDispatcher() - Dispatchers.setMain(dispatcher) - val out = mutableListOf() - try { - runTest(dispatcher) { out += setup { advanceUntilIdle() } } - } finally { - Dispatchers.resetMain() - } - return out.single() +@OptIn(ExperimentalCoroutinesApi::class) +fun buildState(setup: (idle: () -> Unit) -> T): T { + val dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + val out = mutableListOf() + try { + runTest(dispatcher) { out += setup { advanceUntilIdle() } } + } finally { + Dispatchers.resetMain() } + return out.single() } diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt index fcd9e00..7fc7f3c 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt @@ -68,7 +68,7 @@ private fun editor( largeFont: Boolean = false, steps: ExpenseEditorViewModel.(idle: () -> Unit) -> Unit = {}, ) = Case(name, largeFont) { - val state = ScreenSnapshots.build { idle -> + val state = buildState { 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) @@ -98,7 +98,7 @@ private fun detail( largeFont: Boolean = false, steps: ExpenseDetailViewModel.() -> Unit = {}, ) = Case(name, largeFont) { - val state = ScreenSnapshots.build { idle -> + val state = buildState { 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() diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt index c1c57cc..08d9675 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt @@ -7,6 +7,7 @@ import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** * S-7 and S-8 goldens: every state × light/dark × Swedish/English, plus 200 % for the states the design spec draws at @@ -15,7 +16,7 @@ import org.robolectric.annotation.GraphicsMode */ @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi") +@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS) class ExpenseScreenshotTest(private val name: String) { @get:Rule val compose = createComposeRule() @@ -23,7 +24,7 @@ class ExpenseScreenshotTest(private val name: String) { @Test fun screen() { val case = (editorCases + detailCases).first { it.name == name } - ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content()) + ScreenSnapshots.snap(compose, case.name, case.largeFont, jvmLocale = true, content = case.content()) } companion object { @@ -44,7 +45,7 @@ class ExpenseTallScreenshotTest(private val name: String) { @Test fun screen() { val case = tallCases.first { it.name == name } - ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content()) + ScreenSnapshots.snap(compose, case.name, case.largeFont, jvmLocale = true, content = case.content()) } companion object { diff --git a/android/feature/home/build.gradle.kts b/android/feature/home/build.gradle.kts index 3646d90..31638a0 100644 --- a/android/feature/home/build.gradle.kts +++ b/android/feature/home/build.gradle.kts @@ -1,20 +1,6 @@ -// :feature:home — Home with global balance, accounts, sync line and update banner (S-4, S-16, S-17; M7b). Convention: android/README.md. -// Dependencies: :core:model and :core:design; :core:fakes for tests and debug only (all three from kvitt.android.feature). +// :feature:home — Home with global balance, accounts, sync line and update banner, and sync status (S-4, S-16, S-17). +// Convention: android/README.md; :core:model, :core:design, :core:fakes (debug and tests), :core:design's test fixtures +// and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) -} - -roborazzi { - outputDir.set(file("src/test/screenshots")) -} - -dependencies { - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) - debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt index ca7f595..53f37b7 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt @@ -11,6 +11,8 @@ import org.robolectric.annotation.GraphicsMode import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.home.HomeScenarios +import se.hagstedt.kvitt.design.testing.ScreenSnapshots +import se.hagstedt.kvitt.design.testing.withMain /** 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) @@ -87,7 +89,7 @@ class HomeScreenshotTest(private val state: String) { @Test fun screen() { val screen = homeStates.first { it.name == state } - ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content) + ScreenSnapshots.snap(compose, screen.name, screen.largeFont, ScreenSnapshots.Capture.Screen, content = screen.content) } companion object { diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt index 2658f45..0a7ab88 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt @@ -11,9 +11,10 @@ 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.UpdateCheck +import se.hagstedt.kvitt.model.UpdateRepository import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.design.testing.withMain /** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */ private class RecordingUpdates(initial: UpdateState) : UpdateRepository { diff --git a/android/feature/onboarding/build.gradle.kts b/android/feature/onboarding/build.gradle.kts index 34b05f5..dd757d2 100644 --- a/android/feature/onboarding/build.gradle.kts +++ b/android/feature/onboarding/build.gradle.kts @@ -1,9 +1,8 @@ -// :feature:onboarding — Pairing, partner profile step, waiting for partner, device removed (S-1, S-2, S-3, S-18; M7a). -// Convention: android/README.md. :core:model and :core:design come from the feature plugin; :core:fakes only for debug -// and tests. +// :feature:onboarding — Pairing, partner profile step, waiting for partner, device removed (S-1, S-2, S-3, S-18). +// Convention: android/README.md; :core:model, :core:design, :core:fakes (debug and tests), :core:design's test fixtures +// and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) } android { @@ -11,19 +10,7 @@ android { buildFeatures { buildConfig = true } } -roborazzi { - outputDir.set(file("src/test/screenshots")) -} - dependencies { // The Android photo picker (AC-4.3) through ActivityResultContracts.PickVisualMedia. implementation(libs.androidx.activity.compose) - - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt index 1c9de6f..7da569b 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt @@ -1,16 +1,11 @@ package se.hagstedt.kvitt.feature.onboarding -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -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.Rule import org.junit.Test import se.hagstedt.kvitt.design.format.KvittLanguage import se.hagstedt.kvitt.fakes.FakeRepositories @@ -22,15 +17,13 @@ import se.hagstedt.kvitt.fakes.onboarding.ScriptedProfileRepository import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository import se.hagstedt.kvitt.model.OnlineResult import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.design.testing.MainDispatcherRule /** S-2, S-3 and S-18 state holders over the onboarding fake scenarios. */ @OptIn(ExperimentalCoroutinesApi::class) class OtherViewModelsTest { - @Before - fun main() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun reset() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private fun profile(scenario: FakeScenario, picture: CallOutcome = CallOutcome.Succeed, name: CallOutcome = CallOutcome.Succeed) = FakeRepositories(scenario).let { f -> diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt index b84eb82..22818c7 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt @@ -1,16 +1,11 @@ package se.hagstedt.kvitt.feature.onboarding -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -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.Rule import org.junit.Test import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeScenario @@ -19,14 +14,12 @@ import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios import se.hagstedt.kvitt.fakes.onboarding.PairCall import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository import se.hagstedt.kvitt.model.InviteKind +import se.hagstedt.kvitt.design.testing.MainDispatcherRule @OptIn(ExperimentalCoroutinesApi::class) class PairingViewModelTest { - @Before - fun main() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun reset() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private fun vm(scenario: FakeScenario = OnboardingScenarios.unpaired, outcome: CallOutcome = CallOutcome.Succeed, allowLanHttp: Boolean = false) = ScriptedSessionRepository(FakeRepositories(scenario).store, outcome).let { it to PairingViewModel(it, allowLanHttp) } diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt index b807347..0e51bb3 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt @@ -1,19 +1,18 @@ package se.hagstedt.kvitt.feature.onboarding.screenshot +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.v2.createComposeRule -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before 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.design.testing.MainDispatcherRule +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** * One golden per screen state × light/dark × Swedish/English (PLAN-M7a §5, §7), plus 200 % for the states marked so. @@ -23,22 +22,19 @@ import org.robolectric.annotation.GraphicsMode @OptIn(ExperimentalCoroutinesApi::class) @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi") +@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS) class OnboardingScreenshotTest(private val name: String) { @get:Rule val compose = createComposeRule() - @Before - fun mainDispatcher() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun resetMainDispatcher() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() @Test fun screen() { val state = OnboardingStates.all.first { it.name == name } val content = state.render() - ScreenSnapshots.snap(compose, state.name, state.largeFont, content) + ScreenSnapshots.snap(compose, state.name, state.largeFont) { Box(Modifier.fillMaxSize()) { content() } } } companion object { diff --git a/android/feature/settings/build.gradle.kts b/android/feature/settings/build.gradle.kts index 7bf864e..0f2151a 100644 --- a/android/feature/settings/build.gradle.kts +++ b/android/feature/settings/build.gradle.kts @@ -1,22 +1,11 @@ -// :feature:settings — Settings hub, profile, categories, recurring, devices, invites, about (S-13 to S-15, S-19 to S-22; M7f). Convention: android/README.md. +// :feature:settings — Settings hub, profile, categories, recurring, devices, invites, about (S-13 to S-15, S-19 to +// S-22). Convention: android/README.md; :core:model, :core:design, :core:fakes (debug and tests), :core:design's test +// fixtures and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) -} - -roborazzi { - outputDir.set(file("src/test/screenshots")) } dependencies { // The Android photo picker (AC-4.3): ActivityResultContracts.PickVisualMedia through rememberLauncherForActivityResult. implementation(libs.androidx.activity.compose) - - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt index eb1faa7..03d2257 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt @@ -7,6 +7,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import se.hagstedt.kvitt.design.format.AmountKey import se.hagstedt.kvitt.fakes.settings.SAMPLE_CODE import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories import se.hagstedt.kvitt.fakes.settings.SettingsScenarios @@ -25,9 +26,9 @@ import se.hagstedt.kvitt.feature.settings.profile.ProfileViewModel import se.hagstedt.kvitt.feature.settings.recurring.EditorFocus import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel import se.hagstedt.kvitt.feature.settings.recurring.StartError -import se.hagstedt.kvitt.design.format.AmountKey import se.hagstedt.kvitt.model.OnlineResult import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.design.testing.MainDispatcherRule import se.hagstedt.kvitt.core.design.R as DesignR /** The settings ViewModels against the fakes: input handling, enablement rules and error mapping (PLAN-M7f §5). */ diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt index dd9a335..91ae7f3 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringOptionRowsLayoutTest.kt @@ -29,8 +29,9 @@ import se.hagstedt.kvitt.design.component.LocalKvittClock import se.hagstedt.kvitt.design.component.editorOptionTag import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories import se.hagstedt.kvitt.fakes.settings.SettingsScenarios -import se.hagstedt.kvitt.feature.settings.screenshot.ScreenSnapshots +import se.hagstedt.kvitt.feature.settings.screenshot.SettingsSnapshots import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.design.testing.TestClock /** * The three option rows of S-19 (Konto, Betalt av, Delning; D-107, extending D-102) keep their places and sizes when @@ -50,7 +51,7 @@ class RecurringOptionRowsLayoutTest { val f = r.fakes val handle = SavedStateHandle(mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to SettingsScenarios.hyra.id.value)) val vm = RecurringEditorViewModel(handle, f.expenses, f.home, r.recurring, f.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() requireNotNull(vm.state.value) } @@ -74,7 +75,7 @@ class RecurringOptionRowsLayoutTest { LocalConfiguration provides context.resources.configuration, LocalResources provides context.resources, LocalDensity provides Density(context), - LocalKvittClock provides ScreenSnapshots.clock, + LocalKvittClock provides TestClock, ) { KvittTheme { RecurringEditorContent(state, RecurringEditorActions()) } } diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt index 6dc0272..70d7fad 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt @@ -39,10 +39,10 @@ class CategoriesScreenshotTest(private val state: String) { private fun list(name: String, scenario: FakeScenario, scrollToEnd: Boolean = false, drive: (ScriptedSettingsRepositories, CategoryListViewModel) -> Unit = { _, _ -> }) = Shot("list_$name") { compose -> val r = ScriptedSettingsRepositories(scenario) val vm = CategoryListViewModel(r.categories, r.fakes.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) val prepare: (androidx.compose.ui.test.junit4.ComposeContentTestRule) -> Unit = { c -> if (scrollToEnd) c.onNodeWithText("Pant").performScrollTo() } - ScreenSnapshots.snap(compose, "s15_categories_list_$name", prepare = prepare) { + SettingsSnapshots.snap(compose, "s15_categories_list_$name", prepare = prepare) { val s by vm.state.collectAsStateWithLifecycle() CategoryListContent(s, CategoryListActions()) } @@ -52,9 +52,9 @@ class CategoriesScreenshotTest(private val state: String) { val r = ScriptedSettingsRepositories(scenario) val handle = SavedStateHandle(category?.let { mapOf(CategoryEditorViewModel.ARG_CATEGORY_ID to it.id.value) } ?: emptyMap()) val vm = CategoryEditorViewModel(handle, r.categories, r.fakes.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) - ScreenSnapshots.snap(compose, "s15_categories_editor_$name") { + SettingsSnapshots.snap(compose, "s15_categories_editor_$name") { val s by vm.state.collectAsStateWithLifecycle() CategoryEditorContent(s, CategoryEditorActions()) } @@ -96,7 +96,7 @@ class CategoriesScreenshotTest(private val state: String) { editor("error_network", SettingsScenarios.loaded, SettingsScenarios.hund) { r, vm -> r.script.fail = OnlineResult.Failed("network", null) vm.onColor(se.hagstedt.kvitt.model.ColorToken.ORANGE) - ScreenSnapshots.idle() + SettingsSnapshots.idle() vm.save() }, editor("needs_connection", SettingsScenarios.offline, SettingsScenarios.hund), diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt index 356f33a..b2d60e5 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt @@ -41,9 +41,9 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) { val r = ScriptedSettingsRepositories(scenario) before(r) val vm = DevicesViewModel(r.devices, r.fakes.session, r.profile, r.fakes.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) - ScreenSnapshots.snap(compose, "s20_devices_$name") { + SettingsSnapshots.snap(compose, "s20_devices_$name") { val s by vm.state.collectAsStateWithLifecycle() DevicesContent(s, DevicesActions()) } @@ -54,9 +54,9 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) { private fun invites(name: String, scenario: FakeScenario, drive: (ScriptedSettingsRepositories, InvitesViewModel) -> Unit = { _, _ -> }) = Shot("s21_invites_$name") { compose -> val r = ScriptedSettingsRepositories(scenario) val vm = InvitesViewModel(r.invites, r.fakes.session, r.profile, r.fakes.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) - ScreenSnapshots.snap(compose, "s21_invites_$name") { + SettingsSnapshots.snap(compose, "s21_invites_$name") { val s by vm.state.collectAsStateWithLifecycle() InvitesContent(s, InvitesActions()) } @@ -65,9 +65,9 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) { private fun about(name: String, scenario: FakeScenario, drive: (ScriptedSettingsRepositories, AboutViewModel) -> Unit = { _, _ -> }) = Shot("s22_about_$name") { compose -> val r = ScriptedSettingsRepositories(scenario) val vm = AboutViewModel(r.fakes.about, r.update, r.fakes.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) - ScreenSnapshots.snap(compose, "s22_about_$name") { + SettingsSnapshots.snap(compose, "s22_about_$name") { val s by vm.state.collectAsStateWithLifecycle() AboutContent(s, AboutActions(), updateCheckAvailable = true) } diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt index cd7aad4..d3bb0e2 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt @@ -33,7 +33,7 @@ class HubScreenshotTest(private val state: String) { private fun shot(name: String, scenario: FakeScenario) = Shot(name) { compose -> val vm = viewModel(ScriptedSettingsRepositories(scenario)) - ScreenSnapshots.snap(compose, "s13_hub_$name") { + SettingsSnapshots.snap(compose, "s13_hub_$name") { val s by vm.state.collectAsStateWithLifecycle() SettingsHubContent(s, SettingsHubActions()) } diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt index 89db2cb..f262c3e 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt @@ -34,9 +34,9 @@ class ProfileScreenshotTest(private val state: String) { private fun shot(name: String, scenario: FakeScenario, picture: Boolean = false, drive: (ScriptedSettingsRepositories, ProfileViewModel) -> Unit = { _, _ -> }) = Shot(name) { compose -> val r = ScriptedSettingsRepositories(scenario) val vm = ProfileViewModel(r.profile, r.fakes.session, r.fakes.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) - ScreenSnapshots.snap(compose, "s14_profile_$name", picture) { + SettingsSnapshots.snap(compose, "s14_profile_$name", picture) { val s by vm.state.collectAsStateWithLifecycle() ProfileContent(s, ProfileActions()) } diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt index a4627c0..74218af 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt @@ -43,7 +43,7 @@ class RecurringScreenshotTest(private val state: String) { val r = ScriptedSettingsRepositories(scenario) val f = r.fakes val vm = RecurringListViewModel(r.recurring, f.accounts, r.categories, f.session, r.profile, f.connectivity) - ScreenSnapshots.snap(compose, "s19_recurring_list_$name") { + SettingsSnapshots.snap(compose, "s19_recurring_list_$name") { val s by vm.state.collectAsStateWithLifecycle() RecurringListContent(s, RecurringListActions()) } @@ -60,10 +60,10 @@ class RecurringScreenshotTest(private val state: String) { val f = r.fakes val handle = SavedStateHandle(template?.let { mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to it.id.value) } ?: emptyMap()) val vm = RecurringEditorViewModel(handle, f.expenses, f.home, r.recurring, f.connectivity) - ScreenSnapshots.idle() + SettingsSnapshots.idle() drive(r, vm) - ScreenSnapshots.idle() - ScreenSnapshots.snap(compose, "s19_recurring_editor_$name", prepare = { c -> if (scrollTo != null) c.onNodeWithText(scrollTo, substring = true).performScrollTo() }) { + SettingsSnapshots.idle() + SettingsSnapshots.snap(compose, "s19_recurring_editor_$name", prepare = { c -> if (scrollTo != null) c.onNodeWithText(scrollTo, substring = true).performScrollTo() }) { val s by vm.state.collectAsStateWithLifecycle() RecurringEditorContent(s, RecurringEditorActions()) } @@ -113,24 +113,24 @@ class RecurringScreenshotTest(private val state: String) { editor("error_validation", SettingsScenarios.loaded, null, scrollTo = "06:00") { r, vm -> newHyra(r, vm) r.script.fail = OnlineResult.Failed("validation_failed", "start_period") - ScreenSnapshots.idle() + SettingsSnapshots.idle() vm.save() }, editor("error_network", SettingsScenarios.loaded, SettingsScenarios.hyra) { r, vm -> r.script.fail = OnlineResult.Failed("network", null) vm.onPaused(true) - ScreenSnapshots.idle() + SettingsSnapshots.idle() vm.save() }, editor("required", SettingsScenarios.loaded, null) { _, vm -> vm.focus(EditorFocus.None) - ScreenSnapshots.idle() + SettingsSnapshots.idle() vm.save() }, editor("delete_confirm", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> vm.askDelete() }, editor("discard", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> vm.onDescription("Hyra och el") - ScreenSnapshots.idle() + SettingsSnapshots.idle() vm.close() }, editor("needs_connection", SettingsScenarios.offline, SettingsScenarios.hyra), diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/SettingsSnapshots.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/SettingsSnapshots.kt index d679d16..8b0d695 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/SettingsSnapshots.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/SettingsSnapshots.kt @@ -1,98 +1,50 @@ package se.hagstedt.kvitt.feature.settings.screenshot -import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Shader -import android.os.Looper -import android.os.LocaleList import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.Modifier -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode -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.datetime.TimeZone -import org.robolectric.Shadows.shadowOf -import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.component.AvatarPictures -import se.hagstedt.kvitt.design.component.KvittClock import se.hagstedt.kvitt.design.component.LocalAvatarPictures -import se.hagstedt.kvitt.design.component.LocalKvittClock -import java.util.Locale -import kotlin.time.Instant +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** - * Whole-screen goldens of `:feature:settings` (M7f), following `:core:design`'s pattern: three captures (Swedish light and - * dark, English light; D-098), `src/test/screenshots/_.png`. The screen (Robolectric `w411dp-h914dp`, the Galaxy - * S26) is captured with its dialogs. Content is set once; language and theme switch through a configuration context. - * The clock is 17 October 2026 14:02 Stockholm, as in the fakes' scenarios. + * `:feature:settings`'s goldens through the test fixtures' [ScreenSnapshots] (`:core:design`) (Robolectric `w411dp-h914dp`, the Galaxy + * S26): the whole screen with its dialogs once the ViewModel's coroutines are idle, on `surface`. [snap]'s `picture` + * draws a stand-in profile picture for users with a `pictureVersion`. */ -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 suffix: String get() = "${language}_${if (dark) "dark" else "light"}" - } - +object SettingsSnapshots { /** Runs pending main-thread work (ViewModel coroutines) before a capture. */ - fun idle() = shadowOf(Looper.getMainLooper()).idle() + fun idle() = ScreenSnapshots.idleMainLooper() - @OptIn(ExperimentalRoborazziApi::class) fun snap(compose: ComposeContentTestRule, name: String, picture: Boolean = false, prepare: (ComposeContentTestRule) -> Unit = {}, content: @Composable () -> Unit) { - val variants = listOf(Variant("sv", dark = false), Variant("sv", dark = true), Variant("en", dark = false)) - 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"))) - 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) - } - val pictures = remember { if (picture) AvatarPictures { user -> if (user.pictureVersion != null) portrait else null } else AvatarPictures { null } } - CompositionLocalProvider( - LocalContext provides context, - LocalConfiguration provides context.resources.configuration, - LocalResources provides context.resources, - LocalDensity provides Density(context), - LocalKvittClock provides clock, - LocalAvatarPictures provides pictures, - // Coil loads synchronously in inspection mode, so a picture is drawn before the capture. - LocalInspectionMode provides picture, - ) { - KvittTheme(darkTheme = variant.dark) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { content() } - } - } - } - idle() - compose.waitForIdle() - prepare(compose) - variants.forEach { variant -> - current = variant - idle() - compose.waitForIdle() - captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") + ScreenSnapshots.snap( + compose, + name, + capture = ScreenSnapshots.Capture.ScreenWhenIdle, + prepare = prepare, + providers = { inner -> + val pictures = remember { if (picture) AvatarPictures { user -> if (user.pictureVersion != null) portrait else null } else AvatarPictures { null } } + CompositionLocalProvider( + LocalAvatarPictures provides pictures, + // Coil loads synchronously in inspection mode, so a picture is drawn before the capture. + LocalInspectionMode provides picture, + content = inner, + ) + }, + ) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { content() } } } diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt index e12a8bf..b6ff5fa 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt @@ -2,7 +2,7 @@ package se.hagstedt.kvitt.feature.settings.screenshot import androidx.compose.ui.test.junit4.ComposeContentTestRule -/** One screenshot state: [run] builds the ViewModel from the fakes, drives it and calls [ScreenSnapshots.snap]. */ +/** One screenshot state: [run] builds the ViewModel from the fakes, drives it and calls [SettingsSnapshots.snap]. */ class Shot(val name: String, val run: (ComposeContentTestRule) -> Unit) /** The parameter list of a screen's test: one entry per state. */ diff --git a/android/feature/settle/build.gradle.kts b/android/feature/settle/build.gradle.kts index 381601c..e4a3e7f 100644 --- a/android/feature/settle/build.gradle.kts +++ b/android/feature/settle/build.gradle.kts @@ -1,23 +1,10 @@ -// :feature:settle — Settle up and settlement detail (S-9, S-10; M7d). Convention: android/README.md. -// Depends on :core:model and :core:design (added by the feature plugin); :core:fakes only for debug and tests. +// :feature:settle — Settle up and settlement detail (S-9, S-10). Convention: android/README.md; :core:model, +// :core:design, :core:fakes (debug and tests), :core:design's test fixtures and the screenshot tests come from kvitt.android.feature. plugins { id("kvitt.android.feature") - alias(libs.plugins.roborazzi) -} - -roborazzi { - outputDir.set(file("src/test/screenshots")) } dependencies { // BackHandler: system back with unsaved changes asks "Slänga ändringarna?" (design-spec S-9). implementation(libs.androidx.activity.compose) - - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.roborazzi) - testImplementation(libs.roborazzi.compose) - testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt index 5f05e4a..d5cf255 100644 --- a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModelTest.kt @@ -1,17 +1,12 @@ package se.hagstedt.kvitt.feature.settle import androidx.lifecycle.SavedStateHandle -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -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.Rule import org.junit.Test import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.fakes.FakeRepositories @@ -19,15 +14,13 @@ import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.FakeScenarios import se.hagstedt.kvitt.fakes.settle.SettleScenarios import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.design.testing.MainDispatcherRule /** S-9's state holder: defaults, re-proposals, enablement, warnings, error mapping and saving (AC-11.3 to AC-11.6). */ @OptIn(ExperimentalCoroutinesApi::class) class SettleViewModelTest { - @Before - fun main() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun reset() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private fun vm(scenario: FakeScenario, args: Map = emptyMap()): Pair { val repos = FakeRepositories(scenario) diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt index 857ad77..9e1931d 100644 --- a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailViewModelTest.kt @@ -1,33 +1,27 @@ package se.hagstedt.kvitt.feature.settle import androidx.lifecycle.SavedStateHandle -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher -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.Rule import org.junit.Test import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.settle.SettleScenarios import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.design.testing.MainDispatcherRule /** S-10's state holder: the detail, the missing and deleted states and deleting (AC-11.6, AC-12.4, AC-12.5). */ @OptIn(ExperimentalCoroutinesApi::class) class SettlementDetailViewModelTest { - @Before - fun main() = Dispatchers.setMain(UnconfinedTestDispatcher()) - - @After - fun reset() = Dispatchers.resetMain() + @get:Rule + val main = MainDispatcherRule() private fun vm(id: String, repos: FakeRepositories = FakeRepositories(SettleScenarios.detail)) = SettlementDetailViewModel(SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to id)), repos.settlements, repos.accounts, repos.home, repos.connectivity, repos.sync) diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt index e1899b1..adfdaee 100644 --- a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleScreenshotTest.kt @@ -31,7 +31,7 @@ private sealed interface Case { private data class Settle( val scenario: FakeScenario, val args: Map = emptyMap(), - override val height: Dp = ScreenSnapshots.PHONE_HEIGHT, + override val height: Dp = SettleSnapshots.PHONE_HEIGHT, override val largeFont: Boolean = false, val steps: SettleViewModel.() -> Unit = {}, ) : Case @@ -39,7 +39,7 @@ private data class Settle( private data class Detail( val scenario: FakeScenario, val id: String, - override val height: Dp = ScreenSnapshots.PHONE_HEIGHT, + override val height: Dp = SettleSnapshots.PHONE_HEIGHT, override val largeFont: Boolean = false, val steps: SettlementDetailViewModel.() -> Unit = {}, ) : Case @@ -98,9 +98,9 @@ class SettleScreenshotTest(private val name: String) { compose.waitForIdle() vm.apply(case.steps) compose.waitForIdle() - ScreenSnapshots.snap(compose, name, case.height, case.largeFont) { + SettleSnapshots.snap(compose, name, case.height, case.largeFont) { val state by vm.state.collectAsState() - SettleContent(state, vm, overlayHost = ScreenSnapshots.inlineOverlays) + SettleContent(state, vm, overlayHost = SettleSnapshots.inlineOverlays) } } is Detail -> { @@ -109,9 +109,9 @@ class SettleScreenshotTest(private val name: String) { SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to case.id)), repos.settlements, repos.accounts, repos.home, repos.connectivity, repos.sync, ) vm.apply(case.steps) - ScreenSnapshots.snap(compose, name, case.height, case.largeFont) { + SettleSnapshots.snap(compose, name, case.height, case.largeFont) { val state by vm.state.collectAsState() - SettlementDetailContent(state, vm, onBack = {}, onEdit = {}, overlayHost = ScreenSnapshots.inlineOverlays) + SettlementDetailContent(state, vm, onBack = {}, onEdit = {}, overlayHost = SettleSnapshots.inlineOverlays) } } } diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleSnapshots.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleSnapshots.kt index b46842b..5b6caa6 100644 --- a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleSnapshots.kt +++ b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleSnapshots.kt @@ -1,7 +1,5 @@ package se.hagstedt.kvitt.feature.settle.screenshot -import android.content.res.Configuration -import android.os.LocaleList import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -12,50 +10,26 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.Alignment import androidx.compose.ui.Modifier -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 androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -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 se.hagstedt.kvitt.feature.settle.OverlayHost import se.hagstedt.kvitt.feature.settle.OverlayKind -import java.util.Locale -import kotlin.time.Instant +import se.hagstedt.kvitt.design.testing.ScreenSnapshots /** - * Captures a whole screen in three variants (Swedish light and dark, English light; D-098): `src/test/screenshots/_.png`, plus - * `_sv_light_200.png` with [largeFont] (200 % font scale, Swedish, light, like the spec's 200 % frames). Pattern - * of `:core:design`'s `Snapshots` (M6): content set once, configuration switched between captures. The screen is the - * Galaxy S26's 411 dp wide and [height] high; the clock is fixed at 17 October 2026 14:02 in Stockholm. + * S-9's and S-10's goldens through the test fixtures' [ScreenSnapshots] (`:core:design`): the screen is the Galaxy S26's 411 dp wide and + * `height` high on `surface`, captured from the compose root, so dialogs and sheets draw inside it ([inlineOverlays]). */ -object ScreenSnapshots { - val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) - +object SettleSnapshots { /** Normal phone height in dp; long-content states pass a taller one so every row is in the capture. */ val PHONE_HEIGHT: Dp = 891.dp private const val SCRIM_ALPHA = 0.38f - 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 "" - } - /** Draws dialogs and sheets inside the screen over a 38 % scrim (design-spec §3.11), so the root capture shows them. */ val inlineOverlays: OverlayHost = { kind, _, content -> Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = SCRIM_ALPHA))) { @@ -71,37 +45,8 @@ object ScreenSnapshots { } fun snap(compose: ComposeContentTestRule, name: String, height: Dp = PHONE_HEIGHT, largeFont: Boolean = false, content: @Composable BoxScope.() -> Unit) { - val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 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) { - Box(Modifier.size(411.dp, height).background(MaterialTheme.colorScheme.surface), content = content) - } - } - } - variants.forEach { variant -> - current = variant - compose.waitForIdle() - compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") + ScreenSnapshots.snap(compose, name, largeFont) { + Box(Modifier.size(411.dp, height).background(MaterialTheme.colorScheme.surface), content = content) } } } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index f6ba390..7c5b579 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -37,7 +37,6 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } @@ -56,7 +55,6 @@ androidx-work-testing = { module = "androidx.work:work-testing", version.ref = " hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHilt" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -74,12 +72,11 @@ firebase-messaging = { module = "com.google.firebase:firebase-messaging" } junit = { module = "junit:junit", version.ref = "junit" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } -roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } -roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } # Gradle plugins as libraries, for the convention plugins in build-logic android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +roborazzi-gradle-plugin = { module = "io.github.takahirom.roborazzi:roborazzi-gradle-plugin", version.ref = "roborazzi" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }