diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6c12005..1e10979 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ktlint) + alias(libs.plugins.roborazzi) } val repositoryRoot = rootProject.projectDir.parentFile @@ -272,6 +273,11 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) 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.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 // though the filter above drops every file in it. Without this, Gradle refuses // the build for an undeclared dependency between the two tasks. diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt new file mode 100644 index 0000000..9cec594 --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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, 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), + ) + } +} diff --git a/android/app/src/test/screenshots/downloads-empty.png b/android/app/src/test/screenshots/downloads-empty.png new file mode 100644 index 0000000..6a808f6 Binary files /dev/null and b/android/app/src/test/screenshots/downloads-empty.png differ diff --git a/android/app/src/test/screenshots/library-empty.png b/android/app/src/test/screenshots/library-empty.png new file mode 100644 index 0000000..94f3e39 Binary files /dev/null and b/android/app/src/test/screenshots/library-empty.png differ diff --git a/android/app/src/test/screenshots/library-phone-large-font.png b/android/app/src/test/screenshots/library-phone-large-font.png new file mode 100644 index 0000000..9d6dab1 Binary files /dev/null and b/android/app/src/test/screenshots/library-phone-large-font.png differ diff --git a/android/app/src/test/screenshots/library-phone.png b/android/app/src/test/screenshots/library-phone.png new file mode 100644 index 0000000..2ad841f Binary files /dev/null and b/android/app/src/test/screenshots/library-phone.png differ diff --git a/android/app/src/test/screenshots/library-tablet.png b/android/app/src/test/screenshots/library-tablet.png new file mode 100644 index 0000000..17f04b7 Binary files /dev/null and b/android/app/src/test/screenshots/library-tablet.png differ diff --git a/android/app/src/test/screenshots/settings-phone.png b/android/app/src/test/screenshots/settings-phone.png new file mode 100644 index 0000000..f50e9a3 Binary files /dev/null and b/android/app/src/test/screenshots/settings-phone.png differ diff --git a/android/app/src/test/screenshots/shares-phone.png b/android/app/src/test/screenshots/shares-phone.png new file mode 100644 index 0000000..32aec70 Binary files /dev/null and b/android/app/src/test/screenshots/shares-phone.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png new file mode 100644 index 0000000..3d84b3b Binary files /dev/null and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a8e9d82..215ced7 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -14,6 +14,7 @@ androidx-test-core = "1.7.0" androidx-test-junit = "1.3.0" androidx-test-runner = "1.7.0" ktlint-plugin = "13.1.0" +roborazzi = "1.76.0" [libraries] 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-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 2abbf9d..16dd169 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -46,7 +46,7 @@ Before submitting Android work, run the Rust workspace gate from the repository root, then: ```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 ``` diff --git a/docs/TESTING.md b/docs/TESTING.md index 7d9c195..fbf563c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -178,7 +178,7 @@ that needs a viewer's eyes is a scripted case rather than a paragraph in a checklist. ```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 # debug APK, one attached device 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 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 equivalent: diff --git a/scripts/build-android.sh b/scripts/build-android.sh index bd2d921..485e085 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -8,13 +8,17 @@ android_dir="$repo_root/android" usage() { echo "usage: $0 {debug|check|release|signed|all}" 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." } case "${1:-}" in 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) ;; signed) required=( @@ -31,7 +35,7 @@ case "${1:-}" in done tasks=(assembleRelease bundleRelease) ;; - all) tasks=(ktlintCheck lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;; + all) tasks=(ktlintCheck lintDebug verifyRoborazziDebug assembleDebug assembleRelease bundleRelease) ;; *) usage >&2; exit 2 ;; esac