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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -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<String>
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// :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.
|
// 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/<feature>/ and nowhere else (SPEC §11.3).
|
// Each feature's scenarios live under src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/ (SPEC §11.3).
|
||||||
plugins {
|
plugins {
|
||||||
id("kvitt.android.library")
|
id("kvitt.android.library")
|
||||||
}
|
}
|
||||||
@@ -11,39 +13,6 @@ dependencies {
|
|||||||
testImplementation(libs.kotlinx.coroutines.test)
|
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
|
// 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, copied from contracts/category-keywords.csv at build time (never
|
// se/hagstedt/kvitt/fakes/category-keywords.csv.
|
||||||
// hand-copied). A missing table fails the build: the task's input file must exist.
|
bundleCategoryKeywords("se/hagstedt/kvitt/fakes/category-keywords.csv")
|
||||||
abstract class CopyCategoryKeywords : DefaultTask() {
|
|
||||||
@get:InputFile
|
|
||||||
@get:PathSensitive(PathSensitivity.NONE)
|
|
||||||
abstract val table: RegularFileProperty
|
|
||||||
|
|
||||||
@get:Input
|
|
||||||
abstract val resourcePath: Property<String>
|
|
||||||
|
|
||||||
@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>("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) }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
// :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
|
// updater (SPEC §6.2, §14.2, §14.4). Hilt modules here provide the implementations; :app binds the :core:model
|
||||||
// :core:model interfaces to them.
|
// interfaces to them.
|
||||||
plugins {
|
plugins {
|
||||||
id("kvitt.android.library")
|
id("kvitt.android.library")
|
||||||
id("kvitt.android.hilt")
|
id("kvitt.android.hilt")
|
||||||
@@ -41,48 +44,16 @@ dependencies {
|
|||||||
// so a changed contract file re-runs the tests.
|
// so a changed contract file re-runs the tests.
|
||||||
val contractsExamples = rootProject.layout.projectDirectory.dir("../contracts/examples")
|
val contractsExamples = rootProject.layout.projectDirectory.dir("../contracts/examples")
|
||||||
val roomSchemas = layout.projectDirectory.dir("schemas")
|
val roomSchemas = layout.projectDirectory.dir("schemas")
|
||||||
val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv")
|
val keywordsTable = categoryKeywordsTable()
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
inputs.dir(contractsExamples).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("contractsExamples")
|
inputs.dir(contractsExamples).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("contractsExamples")
|
||||||
inputs.dir(roomSchemas).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("roomSchemas")
|
inputs.dir(roomSchemas).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("roomSchemas")
|
||||||
systemProperty("kvitt.contracts.examples", contractsExamples.asFile.absolutePath)
|
systemProperty("kvitt.contracts.examples", contractsExamples.asFile.absolutePath)
|
||||||
systemProperty("kvitt.room.schemas", roomSchemas.asFile.absolutePath)
|
systemProperty("kvitt.room.schemas", roomSchemas.asFile.absolutePath)
|
||||||
inputs.file(categoryKeywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords")
|
inputs.file(keywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords")
|
||||||
systemProperty("kvitt.contracts.keywords", categoryKeywordsTable.asFile.absolutePath)
|
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
|
// 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, copied from contracts/category-keywords.csv at build time (never
|
// se/hagstedt/kvitt/data/category-keywords.csv.
|
||||||
// hand-copied). A missing table fails the build: the task's input file must exist.
|
bundleCategoryKeywords("se/hagstedt/kvitt/data/category-keywords.csv")
|
||||||
abstract class CopyCategoryKeywords : DefaultTask() {
|
|
||||||
@get:InputFile
|
|
||||||
@get:PathSensitive(PathSensitivity.NONE)
|
|
||||||
abstract val table: RegularFileProperty
|
|
||||||
|
|
||||||
@get:Input
|
|
||||||
abstract val resourcePath: Property<String>
|
|
||||||
|
|
||||||
@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>("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) }
|
|
||||||
}
|
|
||||||
|
|||||||
-99
@@ -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/<state>_<sv_light|sv_dark|en_light>.png`, plus `<state>_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 <T> withMain(block: () -> T): T {
|
|
||||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
|
||||||
try {
|
|
||||||
return block()
|
|
||||||
} finally {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-99
@@ -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/<state>_<sv_light|sv_dark|en_light>.png`, plus `<state>_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 <T> withMain(block: () -> T): T {
|
|
||||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
|
||||||
try {
|
|
||||||
return block()
|
|
||||||
} finally {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-76
@@ -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/<name>_<sv_light|sv_dark|en_light>.png`, plus
|
|
||||||
* `<name>_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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -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()
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user