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:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
+4
-2
@@ -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"))
|
||||
}
|
||||
|
||||
+25
@@ -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
-6
@@ -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*""")
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-60
@@ -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,68 +9,21 @@ 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) {
|
||||
ScreenSnapshots.snap(compose, name, largeFont) {
|
||||
ProvideHousehold(Samples.jacob, Samples.anna) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column(
|
||||
@@ -85,10 +36,3 @@ object Snapshots {
|
||||
}
|
||||
}
|
||||
}
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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()
|
||||
}
|
||||
}
|
||||
+116
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-10
@@ -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)
|
||||
|
||||
+4
-11
@@ -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)
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+6
-64
@@ -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) {
|
||||
ScreenSnapshots.snap(compose, name, largeFont, recreate = true) {
|
||||
ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
key(variant) { Box(Modifier.fillMaxSize()) { content() } }
|
||||
Box(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+6
-4
@@ -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) }
|
||||
}
|
||||
|
||||
+5
-13
@@ -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)
|
||||
|
||||
+5
-13
@@ -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
-78
@@ -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,73 +7,13 @@ 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".
|
||||
*/
|
||||
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 {
|
||||
fun <T> buildState(setup: (idle: () -> Unit) -> T): T {
|
||||
val dispatcher = UnconfinedTestDispatcher()
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val out = mutableListOf<T>()
|
||||
@@ -100,4 +24,3 @@ object ScreenSnapshots {
|
||||
}
|
||||
return out.single()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-11
@@ -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 ->
|
||||
|
||||
+4
-11
@@ -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) }
|
||||
|
||||
+9
-13
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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). */
|
||||
|
||||
+4
-3
@@ -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()) }
|
||||
}
|
||||
|
||||
+5
-5
@@ -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),
|
||||
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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())
|
||||
}
|
||||
|
||||
+2
-2
@@ -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())
|
||||
}
|
||||
|
||||
+8
-8
@@ -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),
|
||||
|
||||
+15
-63
@@ -1,100 +1,52 @@
|
||||
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)
|
||||
}
|
||||
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(
|
||||
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,
|
||||
content = inner,
|
||||
)
|
||||
},
|
||||
) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in profile picture: a warm gradient with a lighter disc (no photo of a person is committed). */
|
||||
private val portrait: Bitmap by lazy {
|
||||
|
||||
+1
-1
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-11
@@ -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)
|
||||
|
||||
+4
-10
@@ -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)
|
||||
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-60
@@ -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) {
|
||||
ScreenSnapshots.snap(compose, name, largeFont) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user