From e4e6e5d792c96327d4320e1ad45bfb2e7a9799b5 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 22:53:44 +0200 Subject: [PATCH] Android overhaul: one CopyCategoryKeywords task in build-logic instead of a copy in :data and :core:fakes The task class and its wiring were duplicated verbatim in both build scripts; bundleCategoryKeywords(resourcePath) registers it once per module. Resource paths and inputs are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../kvitt/buildlogic/CategoryKeywords.kt | 59 +++++++++++ android/core/fakes/build.gradle.kts | 43 ++------ android/data/build.gradle.kts | 51 +++------- .../kvitt/feature/account/ScreenSnapshots.kt | 99 ------------------- ...reenSnapshots.kt => AnalyticsSnapshots.kt} | 0 .../{ScreenSnapshots.kt => BuildState.kt} | 0 .../kvitt/feature/home/ScreenSnapshots.kt | 99 ------------------- .../onboarding/screenshot/ScreenSnapshots.kt | 76 -------------- .../settings/logic/MainDispatcherRule.kt | 17 ---- ...creenSnapshots.kt => SettingsSnapshots.kt} | 0 ...{ScreenSnapshots.kt => SettleSnapshots.kt} | 0 11 files changed, 76 insertions(+), 368 deletions(-) create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/CategoryKeywords.kt delete mode 100644 android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt rename android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/{ScreenSnapshots.kt => AnalyticsSnapshots.kt} (100%) rename android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/{ScreenSnapshots.kt => BuildState.kt} (100%) delete mode 100644 android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt delete mode 100644 android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt delete mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt rename android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/{ScreenSnapshots.kt => SettingsSnapshots.kt} (100%) rename android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/{ScreenSnapshots.kt => SettleSnapshots.kt} (100%) diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/CategoryKeywords.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/CategoryKeywords.kt new file mode 100644 index 0000000..1b20c5a --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/CategoryKeywords.kt @@ -0,0 +1,59 @@ +package se.hagstedt.kvitt.buildlogic + +import com.android.build.api.variant.AndroidComponentsExtension +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** Copies the keyword table to [resourcePath] under [outputDir]. A missing table fails the build. */ +abstract class CopyCategoryKeywords : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val table: RegularFileProperty + + @get:Input + abstract val resourcePath: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun copy() { + val source = table.get().asFile + if (!source.isFile) throw GradleException("missing ${source.path}") + val root = outputDir.get().asFile + root.deleteRecursively() + val target = root.resolve(resourcePath.get()) + target.parentFile.mkdirs() + source.copyTo(target) + } +} + +/** `contracts/category-keywords.csv`, the keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6). */ +fun Project.categoryKeywordsTable(): RegularFile = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv") + +/** + * Bundles the keyword table as the Java resource [resourcePath] of every variant, copied from contracts/ at build time + * (never hand-copied). Used by `:data` and `:core:fakes`. + */ +fun Project.bundleCategoryKeywords(resourcePath: String) { + val copy = tasks.register("copyCategoryKeywords", CopyCategoryKeywords::class.java) { + description = "Copies contracts/category-keywords.csv into the main Java resources." + table.set(categoryKeywordsTable()) + this.resourcePath.set(resourcePath) + outputDir.set(layout.buildDirectory.dir("generated/category-keywords")) + } + extensions.getByType(AndroidComponentsExtension::class.java).onVariants { variant -> + variant.sources.resources?.addGeneratedSourceDirectory(copy, CopyCategoryKeywords::outputDir) + } +} diff --git a/android/core/fakes/build.gradle.kts b/android/core/fakes/build.gradle.kts index b960518..ded0c38 100644 --- a/android/core/fakes/build.gradle.kts +++ b/android/core/fakes/build.gradle.kts @@ -1,6 +1,8 @@ +import se.hagstedt.kvitt.buildlogic.bundleCategoryKeywords + // :core:fakes — scenario-driven fakes of every repository interface of SPEC §14.2.4. Only for tests, screenshot tests // and debug previews (the architecture check rejects it in release configurations). Computed values come from :domain. -// M7x add their scenarios under src/main/kotlin/se/hagstedt/kvitt/fakes// and nowhere else (SPEC §11.3). +// Each feature's scenarios live under src/main/kotlin/se/hagstedt/kvitt/fakes// (SPEC §11.3). plugins { id("kvitt.android.library") } @@ -11,39 +13,6 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) } -// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6) is bundled as the Java resource -// se/hagstedt/kvitt/fakes/category-keywords.csv, copied from contracts/category-keywords.csv at build time (never -// hand-copied). A missing table fails the build: the task's input file must exist. -abstract class CopyCategoryKeywords : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val table: RegularFileProperty - - @get:Input - abstract val resourcePath: Property - - @get:OutputDirectory - abstract val outputDir: DirectoryProperty - - @TaskAction - fun copy() { - val source = table.get().asFile - if (!source.isFile) throw GradleException("missing ${source.path}") - val root = outputDir.get().asFile - root.deleteRecursively() - val target = root.resolve(resourcePath.get()) - target.parentFile.mkdirs() - source.copyTo(target) - } -} - -val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv") -val copyCategoryKeywords = tasks.register("copyCategoryKeywords") { - description = "Copies contracts/category-keywords.csv into the main Java resources." - table.set(categoryKeywordsTable) - resourcePath.set("se/hagstedt/kvitt/fakes/category-keywords.csv") - outputDir.set(layout.buildDirectory.dir("generated/category-keywords")) -} -androidComponents { - onVariants { variant -> variant.sources.resources?.addGeneratedSourceDirectory(copyCategoryKeywords, CopyCategoryKeywords::outputDir) } -} +// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6), bundled as the Java resource +// se/hagstedt/kvitt/fakes/category-keywords.csv. +bundleCategoryKeywords("se/hagstedt/kvitt/fakes/category-keywords.csv") diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts index 13e0a9c..fc3def8 100644 --- a/android/data/build.gradle.kts +++ b/android/data/build.gradle.kts @@ -1,6 +1,9 @@ +import se.hagstedt.kvitt.buildlogic.bundleCategoryKeywords +import se.hagstedt.kvitt.buildlogic.categoryKeywordsTable + // :data — Room database, Ktor client, token store, sync engine, WorkManager workers, the real repositories and the APK -// updater (SPEC §6.2, §14.2, §14.4; owned by M5). Hilt modules here provide the implementations; :app (M8) binds the -// :core:model interfaces to them. +// updater (SPEC §6.2, §14.2, §14.4). Hilt modules here provide the implementations; :app binds the :core:model +// interfaces to them. plugins { id("kvitt.android.library") id("kvitt.android.hilt") @@ -41,48 +44,16 @@ dependencies { // so a changed contract file re-runs the tests. val contractsExamples = rootProject.layout.projectDirectory.dir("../contracts/examples") val roomSchemas = layout.projectDirectory.dir("schemas") -val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv") +val keywordsTable = categoryKeywordsTable() tasks.withType().configureEach { inputs.dir(contractsExamples).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("contractsExamples") inputs.dir(roomSchemas).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("roomSchemas") systemProperty("kvitt.contracts.examples", contractsExamples.asFile.absolutePath) systemProperty("kvitt.room.schemas", roomSchemas.asFile.absolutePath) - inputs.file(categoryKeywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords") - systemProperty("kvitt.contracts.keywords", categoryKeywordsTable.asFile.absolutePath) + inputs.file(keywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords") + systemProperty("kvitt.contracts.keywords", keywordsTable.asFile.absolutePath) } -// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6) is bundled as the Java resource -// se/hagstedt/kvitt/data/category-keywords.csv, copied from contracts/category-keywords.csv at build time (never -// hand-copied). A missing table fails the build: the task's input file must exist. -abstract class CopyCategoryKeywords : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val table: RegularFileProperty - - @get:Input - abstract val resourcePath: Property - - @get:OutputDirectory - abstract val outputDir: DirectoryProperty - - @TaskAction - fun copy() { - val source = table.get().asFile - if (!source.isFile) throw GradleException("missing ${source.path}") - val root = outputDir.get().asFile - root.deleteRecursively() - val target = root.resolve(resourcePath.get()) - target.parentFile.mkdirs() - source.copyTo(target) - } -} - -val copyCategoryKeywords = tasks.register("copyCategoryKeywords") { - description = "Copies contracts/category-keywords.csv into the main Java resources." - table.set(categoryKeywordsTable) - resourcePath.set("se/hagstedt/kvitt/data/category-keywords.csv") - outputDir.set(layout.buildDirectory.dir("generated/category-keywords")) -} -androidComponents { - onVariants { variant -> variant.sources.resources?.addGeneratedSourceDirectory(copyCategoryKeywords, CopyCategoryKeywords::outputDir) } -} +// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6), bundled as the Java resource +// se/hagstedt/kvitt/data/category-keywords.csv. +bundleCategoryKeywords("se/hagstedt/kvitt/data/category-keywords.csv") diff --git a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt b/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt deleted file mode 100644 index 5c02c7f..0000000 --- a/android/feature/account/src/test/kotlin/se/hagstedt/kvitt/feature/account/ScreenSnapshots.kt +++ /dev/null @@ -1,99 +0,0 @@ -package se.hagstedt.kvitt.feature.account - -import android.content.res.Configuration -import android.os.LocaleList -import android.os.Looper -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.unit.Density -import com.github.takahirom.roborazzi.ExperimentalRoborazziApi -import com.github.takahirom.roborazzi.captureScreenRoboImage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.datetime.TimeZone -import org.robolectric.Shadows.shadowOf -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 - -/** - * Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus - * included): `src/test/screenshots/_.png`, plus `_sv_light_200.png` at 200 % font - * scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at mdpi (1x) to keep the goldens small (D-098). The - * clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and - * snackbars render the same every run. - */ -object ScreenSnapshots { - const val QUALIFIERS = "w411dp-h891dp-mdpi" - val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) - private const val SETTLE_MILLIS = 1_000L - - 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 "" - } - - @OptIn(ExperimentalRoborazziApi::class) - fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, 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.mainClock.autoAdvance = false - 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 = content) - } - } - variants.forEach { variant -> - current = variant - compose.mainClock.advanceTimeBy(SETTLE_MILLIS) - // The screen (and a dialog window) draw in a later frame; one more frame and an idle main looper, so the - // capture shows this variant and not the previous one. - shadowOf(Looper.getMainLooper()).idle() - compose.mainClock.advanceTimeByFrame() - shadowOf(Looper.getMainLooper()).idle() - captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") - } - } -} - -/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */ -@OptIn(ExperimentalCoroutinesApi::class) -fun withMain(block: () -> T): T { - Dispatchers.setMain(UnconfinedTestDispatcher()) - try { - return block() - } finally { - Dispatchers.resetMain() - } -} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/ScreenSnapshots.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsSnapshots.kt similarity index 100% rename from android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/ScreenSnapshots.kt rename to android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/AnalyticsSnapshots.kt diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ScreenSnapshots.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/BuildState.kt similarity index 100% rename from android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ScreenSnapshots.kt rename to android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/BuildState.kt diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt deleted file mode 100644 index 9c2efe2..0000000 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/ScreenSnapshots.kt +++ /dev/null @@ -1,99 +0,0 @@ -package se.hagstedt.kvitt.feature.home - -import android.content.res.Configuration -import android.os.LocaleList -import android.os.Looper -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.unit.Density -import com.github.takahirom.roborazzi.ExperimentalRoborazziApi -import com.github.takahirom.roborazzi.captureScreenRoboImage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.datetime.TimeZone -import org.robolectric.Shadows.shadowOf -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 - -/** - * Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus - * included): `src/test/screenshots/_.png`, plus `_sv_light_200.png` at 200 % font - * scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at mdpi (1x) to keep the goldens small (D-098). The - * clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and - * snackbars render the same every run. - */ -object ScreenSnapshots { - const val QUALIFIERS = "w411dp-h891dp-mdpi" - val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) - private const val SETTLE_MILLIS = 1_000L - - 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 "" - } - - @OptIn(ExperimentalRoborazziApi::class) - fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, 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.mainClock.autoAdvance = false - 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 = content) - } - } - variants.forEach { variant -> - current = variant - compose.mainClock.advanceTimeBy(SETTLE_MILLIS) - // The screen (and a dialog window: S-16's lost version, S-17's install dialog) draw in a later frame; one - // more frame and an idle main looper, so the capture shows this variant and not the previous one. - shadowOf(Looper.getMainLooper()).idle() - compose.mainClock.advanceTimeByFrame() - shadowOf(Looper.getMainLooper()).idle() - captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") - } - } -} - -/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */ -@OptIn(ExperimentalCoroutinesApi::class) -fun withMain(block: () -> T): T { - Dispatchers.setMain(UnconfinedTestDispatcher()) - try { - return block() - } finally { - Dispatchers.resetMain() - } -} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt deleted file mode 100644 index af1ca79..0000000 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt +++ /dev/null @@ -1,76 +0,0 @@ -package se.hagstedt.kvitt.feature.onboarding.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.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 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 java.util.Locale -import kotlin.time.Instant - -/** - * Captures a whole screen in three variants (Swedish light and dark, English light; D-098): `src/test/screenshots/_.png`, plus - * `_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish, light), as `:core:design`'s Snapshots - * do for components. The phone is the Galaxy S26 (411 dp wide, SPEC §13.1); the clock is fixed at 17 October 2026 - * 14:02 in Stockholm so every render is reproducible. - */ -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, 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) { - Box(Modifier.fillMaxSize()) { content() } - } - } - } - variants.forEach { variant -> - current = variant - compose.waitForIdle() - compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") - } - } -} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt deleted file mode 100644 index 06b8832..0000000 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package se.hagstedt.kvitt.feature.settings.logic - -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.rules.TestWatcher -import org.junit.runner.Description - -/** `viewModelScope` runs on an unconfined test dispatcher, so every ViewModel call completes before it returns. */ -@OptIn(ExperimentalCoroutinesApi::class) -class MainDispatcherRule : TestWatcher() { - override fun starting(description: Description) = Dispatchers.setMain(UnconfinedTestDispatcher()) - - override fun finished(description: Description) = Dispatchers.resetMain() -} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ScreenSnapshots.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/SettingsSnapshots.kt similarity index 100% rename from android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ScreenSnapshots.kt rename to android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/SettingsSnapshots.kt diff --git a/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/ScreenSnapshots.kt b/android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleSnapshots.kt similarity index 100% rename from android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/ScreenSnapshots.kt rename to android/feature/settle/src/test/kotlin/se/hagstedt/kvitt/feature/settle/screenshot/SettleSnapshots.kt