forked from ankidroid/Anki-Android
-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRobolectricTest.kt
More file actions
544 lines (479 loc) · 21.5 KB
/
RobolectricTest.kt
File metadata and controls
544 lines (479 loc) · 21.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
/****************************************************************************************
* Copyright (c) 2018 Mike Hardy <mike@mikehardy.net> *
* *
* This program is free software; you can redistribute it and/or modify it under *
* the terms of the GNU General Public License as published by the Free Software *
* Foundation; either version 3 of the License, or (at your option) any later *
* version. *
* *
* This program is distributed in the hope that it will be useful, but WITHOUT ANY *
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
* PARTICULAR PURPOSE. See the GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License along with *
* this program. If not, see <http://www.gnu.org/licenses/>. *
****************************************************************************************/
package com.ichi2.anki
import android.Manifest
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Looper
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.work.Configuration
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import anki.collection.OpChanges
import com.ichi2.anki.CollectionManager.CollectionOpenFailure
import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper
import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperWithSleep
import com.ichi2.anki.common.annotations.UseContextParameter
import com.ichi2.anki.common.time.MockTime
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.dialogs.DialogHandler
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.Collection
import com.ichi2.anki.libanki.Note
import com.ichi2.anki.libanki.NotetypeJson
import com.ichi2.anki.libanki.Storage
import com.ichi2.anki.libanki.testutils.AnkiTest
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.compat.customtabs.CustomTabActivityHelper
import com.ichi2.testutils.AndroidTest
import com.ichi2.testutils.CollectionManagerTestAdapter
import com.ichi2.testutils.TaskSchedulerRule
import com.ichi2.testutils.common.FailOnUnhandledExceptionRule
import com.ichi2.testutils.common.IgnoreFlakyTestsInCIRule
import com.ichi2.testutils.filter
import com.ichi2.utils.InMemorySQLiteOpenHelperFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import net.ankiweb.rsdroid.BackendException
import net.ankiweb.rsdroid.testing.RustBackendLoader
import org.hamcrest.Matcher
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.json.JSONException
import org.junit.After
import org.junit.Assert
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TestName
import org.robolectric.Robolectric
import org.robolectric.Shadows
import org.robolectric.android.controller.ActivityController
import org.robolectric.junit.rules.TimeoutRule
import org.robolectric.shadows.ShadowDialog
import org.robolectric.shadows.ShadowLog
import org.robolectric.shadows.ShadowLooper
import org.robolectric.shadows.ShadowMediaPlayer
import timber.log.Timber
import kotlin.test.assertNotNull
open class RobolectricTest :
AnkiTest,
AndroidTest {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private fun Any.wait(timeMs: Long) = (this as Object).wait(timeMs)
private val controllersForCleanup = ArrayList<ActivityController<*>>()
protected fun saveControllerForCleanup(controller: ActivityController<*>) {
controllersForCleanup.add(controller)
}
protected open fun useInMemoryDatabase(): Boolean = true
@get:Rule
val taskScheduler = TaskSchedulerRule()
/** Allows [com.ichi2.testutils.Flaky] to annotate tests in subclasses */
@get:Rule
val ignoreFlakyTests = IgnoreFlakyTestsInCIRule()
@get:Rule
val testName = TestName()
@get:Rule
val failOnUnhandledExceptions = FailOnUnhandledExceptionRule()
@get:Rule
val timeoutRule: TimeoutRule = TimeoutRule.seconds(60)
override val collectionManager: CollectionManagerTestAdapter
get() = CollectionManagerTestAdapter
@Before
@CallSuper
open fun setUp() {
println("""-- executing test "${testName.methodName}"""")
TimeManager.resetWith(MockTime(2020, 7, 7, 7, 0, 0, 0, 10))
throwOnShowError = true
// See the Android logging (from Timber)
ShadowLog.stream =
System.out
// Filters for non-Timber sources. Prefer filtering in RobolectricDebugTree if possible
// LifecycleMonitor: not needed as we already use registerActivityLifecycleCallbacks for logs
// W/ShadowLegacyPath: android.graphics.Path#op() not supported yet.
.filter("^(?!(W/ShadowLegacyPath|D/LifecycleMonitor)).*$")
ChangeManager.clearSubscribers()
validateRunWithAnnotationPresent()
val config =
Configuration
.Builder()
.setExecutor(SynchronousExecutor())
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, config)
// resolved issues with the collection being reused if useInMemoryDatabase is false
CollectionManager.setColForTests(null)
maybeSetupBackend()
Storage.setUseInMemory(useInMemoryDatabase())
// Reset static variable for custom tabs failure.
CustomTabActivityHelper.resetFailed()
// See: #6140 - This global ideally shouldn't exist, but it will cause crashes if set.
DialogHandler.discardMessage()
// BUG: We do not reset the MetaDB
MetaDB.closeDB()
}
protected open fun useLegacyHelper(): Boolean = false
protected fun getHelperFactory(): SupportSQLiteOpenHelper.Factory =
if (useInMemoryDatabase()) {
Timber.w("Using in-memory database for test. Collection should not be re-opened")
InMemorySQLiteOpenHelperFactory()
} else {
FrameworkSQLiteOpenHelperFactory()
}
@After
@CallSuper
open fun tearDown() {
throwOnShowError = false
// If you don't clean up your ActivityControllers you will get OOM errors
for (controller in controllersForCleanup) {
Timber.d("Calling destroy on controller %s", controller.get().toString())
try {
controller.destroy()
} catch (e: Exception) {
// Any exception here is likely because the test code already destroyed it, which is fine
// No exception here should halt test execution since tests are over anyway.
}
}
controllersForCleanup.clear()
try {
if (CollectionManager.isOpenUnsafe()) {
CollectionManager.getColUnsafe().debugEnsureNoOpenPointers()
}
// If you don't tear down the database you'll get unexpected IllegalStateExceptions related to connections
Timber.i("closeCollection: %s", "RobolectricTest: End")
CollectionManager.closeCollectionBlocking()
} catch (ex: BackendException) {
if ("CollectionNotOpen" == ex.message) {
Timber.w(ex, "Collection was already disposed - may have been a problem")
} else {
throw ex
}
} finally {
// After every test make sure the CollectionHelper is no longer overridden (done for null testing)
disableNullCollection()
// called on each AnkiDroidApp.onCreate(), and spams the build
// there is no onDestroy(), so call it here.
Timber.uprootAll()
TimeManager.reset()
}
WorkManagerTestInitHelper.closeWorkDatabase()
Dispatchers.resetMain()
runBlocking { CollectionManager.discardBackend() }
println("""-- completed test "${testName.methodName}"""")
}
/**
* Click on a dialog button for an AlertDialog dialog box. Replaces the above helper.
*/
protected fun clickAlertDialogButton(
button: Int,
@Suppress("SameParameterValue") checkDismissed: Boolean,
) {
val dialog = getLatestAlertDialog()
dialog.getButton(button).performClick()
// Need to run UI thread tasks to actually run the onClickHandler
ShadowLooper.runUiThreadTasks()
if (checkDismissed) {
Assert.assertTrue("Dialog not dismissed?", Shadows.shadowOf(dialog).hasBeenDismissed())
}
}
/**
* Get the current dialog text for AlertDialogs (which are replacing MaterialDialogs). Will return null if no dialog visible
* *or* if you check for dismissed and it has been dismissed
*
* @param checkDismissed true if you want to check for dismissed, will return null even if dialog exists but has been dismissed
* TODO: Rename to getDialogText when all MaterialDialogs are changed to AlertDialogs
*/
protected fun getAlertDialogText(
@Suppress("SameParameterValue") checkDismissed: Boolean,
): String? {
val dialog = getLatestAlertDialog()
if (checkDismissed && Shadows.shadowOf(dialog).hasBeenDismissed()) {
Timber.e("The latest dialog has already been dismissed.")
return null
}
val messageViewWithinDialog = dialog.findViewById<TextView>(android.R.id.message)
Assert.assertFalse(messageViewWithinDialog == null)
return messageViewWithinDialog?.text?.toString()
}
// Robolectric needs a manual advance with the new PAUSED looper mode
companion object {
private var mBackground = true
// Robolectric needs a manual advance with the new PAUSED looper mode
fun advanceRobolectricLooper() {
if (!mBackground) {
return
}
Shadows.shadowOf(Looper.getMainLooper()).runToEndOfTasks()
Shadows.shadowOf(Looper.getMainLooper()).idle()
Shadows.shadowOf(Looper.getMainLooper()).runToEndOfTasks()
}
/**
* * Causes all of the [Runnable]s that have been scheduled to run while advancing the clock to the start time of the last scheduled Runnable.
* * Executes all posted tasks scheduled before or at the current time
*
* Supersedes and will eventually replace [advanceRobolectricLooper] and [advanceRobolectricLooperWithSleep]
*/
fun advanceRobolectricUiLooper() {
Shadows.shadowOf(Looper.getMainLooper()).apply {
runToEndOfTasks()
idle()
// CardBrowserTest:browserIsInMultiSelectModeWhenSelectingAll failed on Windows CI
// This line was added and may or may not make a difference
runToEndOfTasks()
}
}
// Robolectric needs some help sometimes in form of a manual kick, then a wait, to stabilize UI activity
fun advanceRobolectricLooperWithSleep() {
if (!mBackground) {
return
}
advanceRobolectricLooper()
try {
Thread.sleep(500)
} catch (e: Exception) {
Timber.e(e)
}
advanceRobolectricLooper()
}
/** This can probably be implemented in a better manner */
internal fun waitForAsyncTasksToComplete() {
advanceRobolectricLooperWithSleep()
}
@JvmStatic // Using protected members which are not @JvmStatic in the superclass companion is unsupported yet
protected fun <T : AnkiActivity?> startActivityNormallyOpenCollectionWithIntent(
testClass: RobolectricTest,
clazz: Class<T>?,
i: Intent?,
): T {
if (AbstractFlashcardViewer::class.java.isAssignableFrom(clazz!!)) {
// fixes 'Don't know what to do with dataSource...' inside Sounds.kt
// solution from https://github.com/robolectric/robolectric/issues/4673
ShadowMediaPlayer.setMediaInfoProvider {
ShadowMediaPlayer.MediaInfo(1, 0)
}
}
val controller =
Robolectric
.buildActivity(clazz, i)
.create()
.start()
.resume()
.visible()
advanceRobolectricLooperWithSleep()
testClass.saveControllerForCleanup(controller)
return controller.get()
}
}
val targetContext: Context
get() = ApplicationProvider.getApplicationContext()
/**
* Returns an instance of [SharedPreferences] using the test context
* @see [editPreferences] for editing
*/
fun getPreferences(): SharedPreferences = targetContext.sharedPrefs()
protected fun getResourceString(res: Int): String = targetContext.getString(res)
protected fun getQuantityString(
res: Int,
quantity: Int,
vararg formatArgs: Any,
): String = targetContext.resources.getQuantityString(res, quantity, *formatArgs)
/** A collection. Created one second ago, not near cutoff time.
* Each time time is checked, it advance by 10 ms. Not enough to create any change visible to user, but ensure
* we don't get two equal time. */
override val col: Collection
get() =
try {
CollectionManager.getColUnsafe()
} catch (e: UnsatisfiedLinkError) {
throw RuntimeException("Failed to load collection. Did you call super.setUp()?", e)
}
protected val collectionTime: MockTime
get() = TimeManager.time as MockTime
/** Call this method in your test if you to test behavior with a null collection */
protected fun enableNullCollection() {
CollectionManager.closeCollectionBlocking()
CollectionManager.setColForTests(null)
CollectionManager.emulatedOpenFailure = CollectionOpenFailure.LOCKED
}
/** Restore regular collection behavior */
protected fun disableNullCollection() {
CollectionManager.emulatedOpenFailure = null
}
@Throws(JSONException::class)
protected fun getCurrentDatabaseNoteTypeCopy(noteTypeName: String): NotetypeJson {
val collectionModels = col.notetypes
return collectionModels.byName(noteTypeName)!!.deepClone()
}
internal fun <T : AnkiActivity?> startActivityNormallyOpenCollectionWithIntent(
clazz: Class<T>?,
i: Intent?,
): T = startActivityNormallyOpenCollectionWithIntent(this, clazz, i)
internal inline fun <reified T : AnkiActivity?> startRegularActivity(): T = startRegularActivity(null)
internal inline fun <reified T : AnkiActivity?> startRegularActivity(i: Intent? = null): T =
startActivityNormallyOpenCollectionWithIntent(T::class.java, i)
/**
* Call to assume that <code>actual</code> satisfies the condition specified by <code>matcher</code>.
* If not, the test halts and is ignored.
* Example:
* ```kotlin
* assumeThat(1, is(1)); // passes
* foo(); // will execute
* assumeThat(0, is(1)); // assumption failure! test halts
* int x = 1 / 0; // will never execute
* ```
*
* @param <T> the static type accepted by the matcher (this can flag obvious compile-time problems such as `assumeThat(1, equalTo("a"))`)
* @param actual the computed value being compared
* @param matcher an expression, built from [Matchers][Matcher], specifying allowed values
* @see org.hamcrest.CoreMatchers
* @see org.junit.matchers.JUnitMatchers
*/
fun <T> assumeThat(
actual: T,
matcher: Matcher<T>?,
) {
Assume.assumeThat(actual, matcher)
}
/**
* Call to assume that `actual` satisfies the condition specified by <code>matcher</code>.
* If not, the test halts and is ignored.
* Example:
* ```kotlin
* assumeThat("alwaysPasses", 1, equalTo(1)); // passes
* foo(); // will execute
* assumeThat("alwaysFails", 0, equalTo(1)); // assumption failure! test halts
* int x = 1 / 0; // will never execute
* ```
*
* @param <T> the static type accepted by the matcher (this can flag obvious compile-time problems such as `assumeThat(1, equalTo("a"))`
* @param actual the computed value being compared
* @param matcher an expression, built from [Matchers][Matcher], specifying allowed values
* @see org.hamcrest.CoreMatchers
* @see org.junit.matchers.JUnitMatchers
*/
fun <T> assumeThat(
message: String?,
actual: T,
matcher: Matcher<T>?,
) {
Assume.assumeThat(message, actual, matcher)
}
/**
* If called with an expression evaluating to `false`, the test will halt and be ignored.
*
* @param b If `false`, the method will attempt to stop the test and ignore it by
* throwing [AssumptionViolatedException]
* @param message A message to pass to [AssumptionViolatedException]
*/
fun assumeTrue(
message: String?,
b: Boolean,
) {
Assume.assumeTrue(message, b)
}
fun equalFirstField(
expected: Card,
obtained: Card,
) {
MatcherAssert.assertThat(obtained.note().fields[0], Matchers.equalTo(expected.note().fields[0]))
}
/**
* Allows editing of preferences, followed by a call to [apply][SharedPreferences.Editor.apply]:
*
* ```
* editPreferences { putString("key", value) }
* ```
*/
@Suppress("MemberVisibilityCanBePrivate")
fun editPreferences(action: SharedPreferences.Editor.() -> Unit) = getPreferences().edit(action = action)
protected fun grantRecordAudioPermission() {
val application = ApplicationProvider.getApplicationContext<Application>()
val app = Shadows.shadowOf(application)
app.grantPermissions(Manifest.permission.RECORD_AUDIO)
}
private fun validateRunWithAnnotationPresent() {
try {
ApplicationProvider.getApplicationContext<Application>()
} catch (e: IllegalStateException) {
if (e.message != null && e.message!!.startsWith("No instrumentation registered!")) {
// Explicitly ignore the inner exception - generates line noise
throw IllegalStateException("Annotate class: '${javaClass.simpleName}' with '@RunWith(AndroidJUnit4::class)'")
}
throw e
}
}
/** Helper method to update a note */
@SuppressLint("CheckResult")
@UseContextParameter("TestClass")
suspend fun Note.updateOp(block: Note.() -> Unit): Note =
this.also { note ->
block(note)
undoableOp<OpChanges> { col.updateNote(note) }
}
private fun maybeSetupBackend() {
try {
targetContext
} catch (exc: IllegalStateException) {
// We must make sure not to load the backend library into a test running outside
// the Robolectric classloader, or subsequent Robolectric tests that run in this
// process will be unable to make calls into the backend.
println("not annotated with junit, not setting up backend")
return
}
try {
RustBackendLoader.ensureSetup()
} catch (e: UnsatisfiedLinkError) {
if (e.message.toString().contains("library load disallowed by system policy")) {
throw IllegalStateException(
"""library load disallowed by system policy.
"To fix:
* Run the test such that the "developer cannot be verified" message appears
* Press "OK" on the "Apple cannot check it for malicious software" prompt
* Run the Test Again
* Apple Menu - System Preferences - Security & Privacy - General (tab) - Unlock Settings - Select Allow Anyway".
Button is underneath the text: "librsdroid.dylib was blocked from use because it is not from an identified developer"
* Press "OK" on the "Apple cannot check it for malicious software" prompt
* Test should execute correctly""",
)
}
throw e
}
}
override fun setupTestDispatcher(dispatcher: TestDispatcher) {
super.setupTestDispatcher(dispatcher)
ioDispatcher = dispatcher
}
override suspend fun TestScope.runTestInner(testBody: suspend TestScope.() -> Unit) {
collectionManager.setTestDispatcher(UnconfinedTestDispatcher(testScheduler))
testBody()
}
}
private fun getLatestAlertDialog(): AlertDialog =
assertNotNull(ShadowDialog.getLatestDialog() as? AlertDialog, "A dialog should be displayed")