Android overhaul: one screenshot harness in :core:design's test fixtures; kvitt.android.screenshots wires Roborazzi

Eight modules each had a copy of the same configuration-context harness (ScreenSnapshots/Snapshots); M12f had to fix
the same capture bug in two of them. :core:design's test fixtures (src/testFixtures, package
se.hagstedt.kvitt.design.testing) now hold Variant, TestClock, TestEnvironment, ScreenSnapshots with its three
capture modes (Root; Screen, with the hand-advanced frame clock; ScreenWhenIdle), MainDispatcherRule and withMain.
Test fixtures rather than a :core:testing module: :core:design's own tests can use them without a dependency on
itself, no module is added to the §6.2 graph, and lint treats them as test sources. The architecture check rejects
test fixtures outside test configurations. Modules keep only their own frame (analytics household, settle size and
inline overlays, settings avatar pictures). Every golden is unchanged.

kvitt.android.screenshots applies Roborazzi with outputDir src/test/screenshots and adds the screenshot test
libraries; the feature plugin applies it and adds the test fixtures, so the seven feature build scripts lose their
copied blocks. Removed: roborazzi-junit-rule and roborazzi-compose (never imported), the explicit
lifecycle-viewmodel-compose (feature plugin, :app) and hilt-lifecycle-viewmodel-compose in :app (both still arrive
through the features; release and debug runtime classpaths are identical), and the unused catalog entries
androidx-hilt-navigation-compose and androidx-lifecycle-viewmodel-compose. @Before/@After setMain pairs in nine test
classes use MainDispatcherRule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:54:03 +02:00
parent e4e6e5d792
commit 8229ed60ea
55 changed files with 472 additions and 664 deletions
+1 -1
View File
@@ -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")
+3 -16
View File
@@ -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)
}
@@ -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)
@@ -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()
+5
View File
@@ -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"
@@ -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<Project> {
override fun apply(target: Project) {
@@ -14,13 +15,14 @@ class AndroidFeaturePlugin : Plugin<Project> {
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"))
}
@@ -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<Project> {
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"))
}
}
}
@@ -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<String>
/** Declared project dependencies as `configuration|:path`. */
/** Declared project dependencies as `configuration|:path`, `configuration|:path+testFixtures` for test fixtures. */
@get:Input
abstract val projectDependencies: ListProperty<String>
@@ -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("""(?<![\w.])(java|javax|android|androidx|dalvik|sun)\.[a-z]\w*""")
companion object {
/** Marks a project dependency on the target's test fixtures in [projectDependencies]. */
const val TEST_FIXTURES = "+testFixtures"
private val IMPORT = Regex("""^import\s+([\w.]+)""")
private val QUALIFIED = Regex("""(?<![\w.])(java|javax|android|androidx|dalvik|sun)\.[a-z]\w*""")
}
}
@@ -20,7 +20,10 @@ class ArchitecturePlugin : Plugin<Project> {
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 {
@@ -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)
}
+12 -10
View File
@@ -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)
}
@@ -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/<name>_<sv_light|sv_dark|en_light>.png`, plus
* `<name>_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")
}
}
}
@@ -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 <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}
@@ -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/<name>_<variant>.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)
}
}
}
@@ -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<Variant> =
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,
)
}
+2 -14
View File
@@ -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)
}
@@ -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 {
@@ -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 {
@@ -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
+2 -17
View File
@@ -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)
}
@@ -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)
@@ -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)
@@ -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 {
@@ -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/<name>_<sv_light|sv_dark|en_light>.png`, plus `<name>_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")
}
}
}
@@ -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 {
+2 -13
View File
@@ -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)
}
@@ -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) }
}
@@ -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<ExpenseDetailViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
@@ -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<String, Any?> = emptyMap()): Pair<ExpenseEditorViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
@@ -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/<name>_<sv_light|sv_dark|en_light>.png`,
* plus `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, as the spec's 200 %
* frames). The pattern of `:core:design` (M6): content set once, language, theme and font scale switched through a
* configuration context between captures.
* The clock is fixed at 17 October 2026 14:02 in Stockholm, the fake scenarios' "now".
* 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 <T> build(setup: (idle: () -> Unit) -> T): T {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val out = mutableListOf<T>()
try {
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
} finally {
Dispatchers.resetMain()
}
return out.single()
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> buildState(setup: (idle: () -> Unit) -> T): T {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val out = mutableListOf<T>()
try {
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
} finally {
Dispatchers.resetMain()
}
return out.single()
}
@@ -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()
@@ -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 {
+3 -17
View File
@@ -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)
}
@@ -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 {
@@ -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 {
+3 -16
View File
@@ -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)
}
@@ -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 ->
@@ -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) }
@@ -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 {
+3 -14
View File
@@ -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)
}
@@ -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). */
@@ -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()) }
}
@@ -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),
@@ -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)
}
@@ -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())
}
@@ -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())
}
@@ -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),
@@ -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/<name>_<sv_light|sv_dark|en_light>.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() }
}
}
@@ -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. */
+2 -15
View File
@@ -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)
}
@@ -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<String, String> = emptyMap()): Pair<SettleViewModel, FakeRepositories> {
val repos = FakeRepositories(scenario)
@@ -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)
@@ -31,7 +31,7 @@ private sealed interface Case {
private data class Settle(
val scenario: FakeScenario,
val args: Map<String, String> = 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)
}
}
}
@@ -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/<name>_<sv_light|sv_dark|en_light>.png`, plus
* `<name>_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)
}
}
}
+1 -4
View File
@@ -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" }