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" }