test(android): screenshot tests for every screen
Roborazzi draws Library (phone, tablet, font scale 1.5, empty), a title, Shares, Settings and Downloads from fixed records under Robolectric, and build-android.sh check compares them with the committed images in app/src/test/screenshots. The redesign changes every screen; a layout regression now fails the gate and shows up in review as a changed image.
No files matched your search
@@ -6,6 +6,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.ktlint)
|
alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.roborazzi)
|
||||||
}
|
}
|
||||||
|
|
||||||
val repositoryRoot = rootProject.projectDir.parentFile
|
val repositoryRoot = rootProject.projectDir.parentFile
|
||||||
@@ -272,6 +273,11 @@ dependencies {
|
|||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.androidx.test.core)
|
testImplementation(libs.androidx.test.core)
|
||||||
testImplementation(libs.androidx.work.testing)
|
testImplementation(libs.androidx.work.testing)
|
||||||
|
testImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.roborazzi)
|
||||||
|
testImplementation(libs.roborazzi.compose)
|
||||||
|
testImplementation(libs.roborazzi.junit.rule)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.junit)
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
@@ -295,6 +301,13 @@ ktlint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot baselines live beside the tests that draw them, so a change to how
|
||||||
|
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
|
||||||
|
// rewrites them; `verifyRoborazziDebug` fails on any difference.
|
||||||
|
roborazzi {
|
||||||
|
outputDir.set(file("src/test/screenshots"))
|
||||||
|
}
|
||||||
|
|
||||||
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||||
// though the filter above drops every file in it. Without this, Gradle refuses
|
// though the filter above drops every file in it. Without this, Gradle refuses
|
||||||
// the build for an undeclared dependency between the two tasks.
|
// the build for an undeclared dependency between the two tasks.
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.work.testing.WorkManagerTestInitHelper
|
||||||
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every screen, drawn from fixed records, against a committed image.
|
||||||
|
*
|
||||||
|
* The screens are about to be redesigned one by one, and a layout regression —
|
||||||
|
* a row that wraps, a button pushed off the edge — compiles and passes every
|
||||||
|
* other test. Here it is a changed PNG in review. No artwork is loaded: the
|
||||||
|
* bridge is not available on the host, so every tile shows its placeholder,
|
||||||
|
* which is also what a title without metadata looks like on a phone.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
|
||||||
|
class ScreenshotTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
|
compose.setContent {
|
||||||
|
ProtonStreamTheme {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library on a phone`() = capture("library-phone") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||||
|
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||||
|
// reads it from, so the scale is set there.
|
||||||
|
val density = LocalDensity.current
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty library`() = capture("library-empty") {
|
||||||
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
|
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null) = EpisodeRecord(
|
||||||
|
shareId = "share",
|
||||||
|
volumeId = "volume",
|
||||||
|
linkId = "s${season}e$number",
|
||||||
|
name = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt()),
|
||||||
|
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
|
||||||
|
detail = "1.2 GiB",
|
||||||
|
season = season,
|
||||||
|
number = number,
|
||||||
|
size = 1_300_000_000uL,
|
||||||
|
progress = progress,
|
||||||
|
resumeAt = progress?.let { it * 1440 },
|
||||||
|
watched = watched,
|
||||||
|
offline = false,
|
||||||
|
providerName = if (number == 1u) "Mother and Children" else null,
|
||||||
|
providerOverview = null,
|
||||||
|
stillUrl = null,
|
||||||
|
airDate = null,
|
||||||
|
lastPlayed = if (progress != null) 1_790_000_000L else 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||||
|
key = key,
|
||||||
|
name = name,
|
||||||
|
year = 2023u,
|
||||||
|
kind = TitleType.SERIES,
|
||||||
|
watchedCount = seasons.sumOf { s -> s.episodes.count { it.watched } }.toULong(),
|
||||||
|
episodeCount = seasons.sumOf { it.episodes.size }.toULong(),
|
||||||
|
canonicalName = null,
|
||||||
|
originalName = null,
|
||||||
|
overview = overview,
|
||||||
|
metadataProvider = null,
|
||||||
|
metadataId = null,
|
||||||
|
metadataYear = null,
|
||||||
|
metadataKind = null,
|
||||||
|
posterUrl = null,
|
||||||
|
backdropUrl = null,
|
||||||
|
rating = 8.4,
|
||||||
|
genres = listOf("Drama", "Mystery"),
|
||||||
|
providerEpisodeCount = null,
|
||||||
|
externalUrl = null,
|
||||||
|
manualMatch = false,
|
||||||
|
seasons = seasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SERIES = title(
|
||||||
|
"oshi-no-ko",
|
||||||
|
"Oshi no Ko",
|
||||||
|
listOf(
|
||||||
|
SeasonRecord(1u, "Season 1", (1u..4u).map { episode(1u, it, watched = it < 3u) }),
|
||||||
|
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
|
||||||
|
),
|
||||||
|
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
|
.copy(kind = TitleType.FILM, year = 1988u)
|
||||||
|
|
||||||
|
val LIBRARY = AppUiState(
|
||||||
|
loading = false,
|
||||||
|
titles = listOf(
|
||||||
|
FILM,
|
||||||
|
SERIES,
|
||||||
|
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
|
||||||
|
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
|
||||||
|
),
|
||||||
|
shares = listOf(
|
||||||
|
ShareRecord("anime", "anime", true),
|
||||||
|
ShareRecord("films", "films", false),
|
||||||
|
),
|
||||||
|
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -14,6 +14,7 @@ androidx-test-core = "1.7.0"
|
|||||||
androidx-test-junit = "1.3.0"
|
androidx-test-junit = "1.3.0"
|
||||||
androidx-test-runner = "1.7.0"
|
androidx-test-runner = "1.7.0"
|
||||||
ktlint-plugin = "13.1.0"
|
ktlint-plugin = "13.1.0"
|
||||||
|
roborazzi = "1.76.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||||
@@ -41,9 +42,13 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
|
|||||||
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
||||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||||
|
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" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||||
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
@@ -46,7 +46,7 @@ Before submitting Android work, run the Rust workspace gate from the repository
|
|||||||
root, then:
|
root, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/build-android.sh check # ktlint, Android lint, host tests
|
bash scripts/build-android.sh check # ktlint, Android lint, host and screenshot tests
|
||||||
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
|
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ that needs a viewer's eyes is a scripted case rather than a paragraph in a
|
|||||||
checklist.
|
checklist.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/build-android.sh check # ktlint, Android lint, host tests
|
bash scripts/build-android.sh check # ktlint, Android lint, host and screenshot tests
|
||||||
bash scripts/android-acceptance.sh --list # what the device suite covers
|
bash scripts/android-acceptance.sh --list # what the device suite covers
|
||||||
bash scripts/android-acceptance.sh # debug APK, one attached device
|
bash scripts/android-acceptance.sh # debug APK, one attached device
|
||||||
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
|
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
|
||||||
@@ -211,6 +211,15 @@ and checks the corner radius the desktop draws at. Both are lists of the *other*
|
|||||||
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
||||||
across fails here rather than being noticed in a screenshot.
|
across fails here rather than being noticed in a screenshot.
|
||||||
|
|
||||||
|
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
|
||||||
|
records — Library on a phone, a tablet and at font scale 1.5, an empty library,
|
||||||
|
a title, Shares, Settings and Downloads — and `check` compares them with the
|
||||||
|
images in `app/src/test/screenshots`. A layout regression compiles and passes
|
||||||
|
everything else; here it fails, and the review shows the changed image. After a
|
||||||
|
deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they
|
||||||
|
are committed with it. No artwork loads on the host, so tiles show their
|
||||||
|
placeholder, and the palette is the shipped default.
|
||||||
|
|
||||||
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
||||||
equivalent:
|
equivalent:
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ android_dir="$repo_root/android"
|
|||||||
usage() {
|
usage() {
|
||||||
echo "usage: $0 {debug|check|release|signed|all}"
|
echo "usage: $0 {debug|check|release|signed|all}"
|
||||||
echo
|
echo
|
||||||
echo " check ktlint, Android lint and host unit tests — no device needed."
|
echo " check ktlint, Android lint, host unit tests and screenshot comparison —"
|
||||||
|
echo " no device needed. Re-record changed screens with"
|
||||||
|
echo " 'gradlew recordRoborazziDebug' and commit the images."
|
||||||
echo " On-device coverage is scripts/android-acceptance.sh."
|
echo " On-device coverage is scripts/android-acceptance.sh."
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
debug) tasks=(assembleDebug) ;;
|
debug) tasks=(assembleDebug) ;;
|
||||||
check) tasks=(ktlintCheck lintDebug testDebugUnitTest) ;;
|
# verifyRoborazziDebug *is* testDebugUnitTest, with the screenshots compared
|
||||||
|
# against src/test/screenshots instead of skipped.
|
||||||
|
check) tasks=(ktlintCheck lintDebug verifyRoborazziDebug) ;;
|
||||||
release) tasks=(assembleRelease bundleRelease) ;;
|
release) tasks=(assembleRelease bundleRelease) ;;
|
||||||
signed)
|
signed)
|
||||||
required=(
|
required=(
|
||||||
@@ -31,7 +35,7 @@ case "${1:-}" in
|
|||||||
done
|
done
|
||||||
tasks=(assembleRelease bundleRelease)
|
tasks=(assembleRelease bundleRelease)
|
||||||
;;
|
;;
|
||||||
all) tasks=(ktlintCheck lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
|
all) tasks=(ktlintCheck lintDebug verifyRoborazziDebug assembleDebug assembleRelease bundleRelease) ;;
|
||||||
*) usage >&2; exit 2 ;;
|
*) usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||