Skip to content

Commit 11838b5

Browse files
david-allisonBrayanDSO
authored andcommitted
test: screenshot test for all activities
Prep for edge to edge: 17334 Assisted-by: Claude Opus 4.7 - all
1 parent a541725 commit 11838b5

6 files changed

Lines changed: 187 additions & 7 deletions

File tree

AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUnderBackupTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity
2121
import com.ichi2.anki.preferences.PreferencesActivity
2222
import com.ichi2.testutils.ActivityList
2323
import com.ichi2.testutils.ActivityList.ActivityLaunchParam
24+
import com.ichi2.testutils.skipTest
2425
import com.ichi2.utils.ExceptionUtil.getFullStackTrace
2526
import org.hamcrest.MatcherAssert.assertThat
2627
import org.hamcrest.Matchers.equalTo
2728
import org.junit.Assert
28-
import org.junit.Assume.assumeThat
2929
import org.junit.Before
3030
import org.junit.Test
3131
import org.junit.runner.RunWith
@@ -110,7 +110,7 @@ $stackTrace""",
110110
reason: String,
111111
) {
112112
if (launcher!!.simpleName == activityName) {
113-
assumeThat("$activityName $reason", true, equalTo(false))
113+
skipTest("$activityName $reason")
114114
}
115115
}
116116

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// SPDX-FileCopyrightText: 2026 David Allison <davidallisongithub@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
package com.ichi2.anki
4+
5+
import android.app.Activity
6+
import android.view.Gravity
7+
import android.view.View
8+
import android.view.ViewGroup
9+
import android.widget.FrameLayout
10+
import androidx.core.content.edit
11+
import androidx.core.graphics.Insets
12+
import androidx.core.view.ViewCompat
13+
import androidx.core.view.WindowInsetsCompat
14+
import com.ichi2.anki.account.AccountActivity
15+
import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity
16+
import com.ichi2.anki.multimedia.MultimediaActivity
17+
import com.ichi2.anki.preferences.PreferencesActivity
18+
import com.ichi2.anki.preferences.sharedPrefs
19+
import com.ichi2.anki.previewer.CardViewerActivity
20+
import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity
21+
import com.ichi2.testutils.ActivityList
22+
import com.ichi2.testutils.ActivityList.ActivityLaunchParam
23+
import com.ichi2.testutils.BackupManagerTestUtilities
24+
import com.ichi2.testutils.skipTest
25+
import com.ichi2.utils.dp
26+
import org.junit.After
27+
import org.junit.Before
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
import org.robolectric.ParameterizedRobolectricTestRunner
31+
32+
/**
33+
* Captures a baseline screenshot for every activity declared in the manifest.
34+
*
35+
* @see ActivityList.allActivitiesAndIntents
36+
*
37+
* `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.AllActivitiesScreenshotTest"`
38+
*
39+
* TODO: Split each activity into its own per-class screenshot test
40+
*/
41+
@RunWith(ParameterizedRobolectricTestRunner::class)
42+
class AllActivitiesScreenshotTest : ScreenshotTest() {
43+
init {
44+
setPhoneQualifiers()
45+
}
46+
47+
@ParameterizedRobolectricTestRunner.Parameter
48+
@JvmField
49+
var launcher: ActivityLaunchParam? = null
50+
51+
// Used for display and the screenshot filename (e.g. "DeckPicker" or "DeckPicker_edgeToEdge")
52+
@ParameterizedRobolectricTestRunner.Parameter(1)
53+
@JvmField
54+
var displayName: String? = null
55+
56+
@ParameterizedRobolectricTestRunner.Parameter(2)
57+
@JvmField
58+
var configure: (Activity.() -> Unit)? = null
59+
60+
@Before
61+
override fun setUp() {
62+
// Same exclusions as ActivityStartupUnderBackupTest — onCreate fails standalone for these.
63+
notYetHandled(IntentHandler::class.java.simpleName, "Not working (or implemented) - inherits from Activity")
64+
notYetHandled(IntentHandler2::class.java.simpleName, "Not working (or implemented) - inherits from Activity")
65+
notYetHandled(
66+
PreferencesActivity::class.java.simpleName,
67+
"Not working (or implemented) - inherits from AppCompatPreferenceActivity",
68+
)
69+
notYetHandled(
70+
SingleFragmentActivity::class.java.simpleName,
71+
"Implemented, but the test fails because the activity throws if a specific intent extra isn't set",
72+
)
73+
notYetHandled(InstantNoteEditorActivity::class.java.simpleName, "Single instance activity so should be used")
74+
75+
// Fragment-host activities: need a 'fragmentName' intent extra to render anything.
76+
// TODO: split these into per-class screenshot tests that pass a real fragment.
77+
notYetHandled(ConfigAwareSingleFragmentActivity::class.java.simpleName, "Needs 'fragmentName' intent extra")
78+
notYetHandled(CardViewerActivity::class.java.simpleName, "Needs 'fragmentName' intent extra")
79+
notYetHandled(MultimediaActivity::class.java.simpleName, "Needs 'fragmentName' intent extra")
80+
notYetHandled(AccountActivity::class.java.simpleName, "Needs 'fragmentName' intent extra")
81+
82+
// TODO: split into a per-class test that creates a real note type before launching.
83+
notYetHandled(CardTemplateEditor::class.java.simpleName, "Needs a real note type in the collection")
84+
85+
super.setUp()
86+
87+
// Setup for DeckPicker
88+
ensureCollectionLoadIsSynchronous()
89+
setIntroductionSlidesShown(true)
90+
BackupManagerTestUtilities.setupSpaceForBackup(targetContext)
91+
// suppress the periodic 'backup your collection' prompt so the screenshot is just the activity
92+
targetContext.sharedPrefs().edit { putBoolean("backupPromptDisabled", true) }
93+
}
94+
95+
@After
96+
fun tearDownBackup() {
97+
BackupManagerTestUtilities.reset()
98+
}
99+
100+
@Test
101+
fun screenshot() {
102+
val activity =
103+
startActivityNormallyOpenCollectionWithIntent(
104+
launcher!!.activity,
105+
launcher!!.buildIntent(targetContext),
106+
)
107+
configure!!(activity)
108+
captureScreen(displayName!!)
109+
}
110+
111+
private fun notYetHandled(
112+
activityName: String,
113+
reason: String,
114+
) {
115+
if (launcher!!.simpleName == activityName) {
116+
skipTest("$activityName $reason")
117+
}
118+
}
119+
120+
companion object {
121+
private val regular: Activity.() -> Unit = {}
122+
private val edgeToEdge: Activity.() -> Unit = { simulateEdgeToEdge() }
123+
124+
@ParameterizedRobolectricTestRunner.Parameters(name = "{1}")
125+
@JvmStatic
126+
fun initParameters(): Collection<Array<Any>> =
127+
ActivityList.allActivitiesAndIntents().flatMap { launcher ->
128+
listOf(
129+
arrayOf<Any>(launcher, launcher.simpleName, regular),
130+
arrayOf<Any>(launcher, "${launcher.simpleName}_edgeToEdge", edgeToEdge),
131+
)
132+
}
133+
}
134+
}
135+
136+
/**
137+
* Inject realistic system bars for edge to edge.
138+
*
139+
* Mirrors the helper from `DeckPickerScreenshotTest` on the `edge-2-edge` branch.
140+
*
141+
* WARN: Does not match reality. There are issues with element placement and scrolling lists. [FAB in Deck Picker]
142+
*/
143+
private fun Activity.simulateEdgeToEdge() {
144+
val insets =
145+
WindowInsetsCompat
146+
.Builder()
147+
.setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, 24.dp.toPx(this), 0, 0))
148+
.setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, 48.dp.toPx(this)))
149+
.build()
150+
ViewCompat.dispatchApplyWindowInsets(window.decorView, insets)
151+
152+
val decor = window.decorView as ViewGroup
153+
val navBarOverlay =
154+
View(this).apply {
155+
setBackgroundColor(0x80000000.toInt())
156+
}
157+
decor.addView(
158+
navBarOverlay,
159+
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 48.dp.toPx(this), Gravity.BOTTOM),
160+
)
161+
}

AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.ichi2.anki
1818

1919
import android.Manifest
2020
import android.annotation.SuppressLint
21+
import android.app.Activity
2122
import android.app.Application
2223
import android.content.Context
2324
import android.content.Intent
@@ -273,7 +274,7 @@ open class RobolectricTest :
273274
}
274275

275276
@JvmStatic // Using protected members which are not @JvmStatic in the superclass companion is unsupported yet
276-
protected fun <T : AnkiActivity?> startActivityNormallyOpenCollectionWithIntent(
277+
protected fun <T : Activity?> startActivityNormallyOpenCollectionWithIntent(
277278
testClass: RobolectricTest,
278279
clazz: Class<T>?,
279280
i: Intent?,
@@ -350,14 +351,14 @@ open class RobolectricTest :
350351
return collectionModels.byName(noteTypeName)!!.deepClone()
351352
}
352353

353-
internal fun <T : AnkiActivity?> startActivityNormallyOpenCollectionWithIntent(
354+
internal fun <T : Activity?> startActivityNormallyOpenCollectionWithIntent(
354355
clazz: Class<T>?,
355356
i: Intent?,
356357
): T = startActivityNormallyOpenCollectionWithIntent(this, clazz, i)
357358

358-
internal inline fun <reified T : AnkiActivity?> startRegularActivity(): T = startRegularActivity(null)
359+
internal inline fun <reified T : Activity?> startRegularActivity(): T = startRegularActivity(null)
359360

360-
internal inline fun <reified T : AnkiActivity?> startRegularActivity(i: Intent? = null): T =
361+
internal inline fun <reified T : Activity?> startRegularActivity(i: Intent? = null): T =
361362
startActivityNormallyOpenCollectionWithIntent(T::class.java, i)
362363

363364
fun equalFirstField(

AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
package com.ichi2.anki
1717

1818
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
19+
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
1920
import com.github.takahirom.roborazzi.RoborazziOptions
2021
import com.github.takahirom.roborazzi.captureScreenRoboImage
2122
import com.github.takahirom.roborazzi.provideRoborazziContext
2223
import org.junit.experimental.categories.Category
24+
import org.robolectric.RuntimeEnvironment
2325
import org.robolectric.annotation.GraphicsMode
2426
import java.io.File
2527

@@ -31,6 +33,16 @@ interface ScreenshotTestCategory
3133
@Category(ScreenshotTestCategory::class)
3234
@GraphicsMode(GraphicsMode.Mode.NATIVE)
3335
abstract class ScreenshotTest : RobolectricTest() {
36+
/** Pixel-class phone in portrait, light theme. */
37+
protected fun setPhoneQualifiers() {
38+
RuntimeEnvironment.setQualifiers(RobolectricDeviceQualifiers.MediumPhone)
39+
}
40+
41+
/** Required for [DeckPicker.fragmented] to be true. */
42+
protected fun setTabletQualifiers() {
43+
RuntimeEnvironment.setQualifiers(RobolectricDeviceQualifiers.MediumTablet)
44+
}
45+
3446
/**
3547
* Captures a screenshot to `build/outputs/roborazzi/<TestClass>/<name>.png`.
3648
*

AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ object ActivityList {
113113

114114
fun build(context: Context): ActivityController<out Activity> =
115115
Robolectric
116-
.buildActivity(activity, intentBuilder.apply(context))
116+
.buildActivity(activity, buildIntent(context))
117+
118+
fun buildIntent(context: Context): Intent = intentBuilder.apply(context)
117119

118120
val className: String = activity.name
119121

AnkiDroid/src/test/java/com/ichi2/testutils/AnkiAssert.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package com.ichi2.testutils
1717

18+
import org.junit.AssumptionViolatedException
1819
import kotlin.test.junit5.JUnit5Asserter
1920

2021
/** Asserts that the expression is `false` with an optional [message]. */
@@ -27,3 +28,6 @@ fun assertFalse(
2728
// JUnitAsserter doesn't contain it, so we add it in
2829
JUnit5Asserter.assertTrue(message, !actual)
2930
}
31+
32+
/** Unconditionally skips the current test with [reason] reported as the skip message. */
33+
fun skipTest(reason: String): Nothing = throw AssumptionViolatedException(reason)

0 commit comments

Comments
 (0)