Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.github.landwarderer.futon.reader

import android.content.Context
import androidx.work.Configuration
import androidx.work.WorkManager

/** HiltTestApplication does not implement the production application's WorkManager configuration. */
internal fun initializeReaderTestWorkManager(context: Context) {
try {
WorkManager.getInstance(context)
} catch (_: IllegalStateException) {
WorkManager.initialize(context, Configuration.Builder().build())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package io.github.landwarderer.futon.reader

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.landwarderer.futon.SampleData
import io.github.landwarderer.futon.core.cache.MemoryContentCache
import io.github.landwarderer.futon.core.cache.SafeDeferred
import io.github.landwarderer.futon.core.model.LocalMangaSource
import io.github.landwarderer.futon.core.model.TestMangaSource
import io.github.landwarderer.futon.core.nav.ReaderIntent
import io.github.landwarderer.futon.core.parser.MangaDataRepository
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.prefs.ReaderMode
import io.github.landwarderer.futon.history.data.HistoryRepository
import io.github.landwarderer.futon.reader.ui.ReaderActivity
import io.github.landwarderer.futon.reader.ui.ReaderState
import io.github.landwarderer.futon.reader.ui.ReaderViewModel
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.io.File
import javax.inject.Inject

/** Exercise the actual Activity/ViewModel/history path with cached metadata and local test images. */
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class SmartResumeReaderTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@Inject lateinit var cache: MemoryContentCache
@Inject lateinit var data: MangaDataRepository
@Inject lateinit var history: HistoryRepository
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context get() = instrumentation.targetContext

@Before
fun setUp() {
initializeReaderTestWorkManager(context)
hiltRule.inject()
cache.clear(TestMangaSource)
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putBoolean(AppSettings.KEY_SMART_RESUME, true)
.putString(AppSettings.KEY_SMART_RESUME_MODE, "PERCENTAGE")
.putString(AppSettings.KEY_SMART_RESUME_PERCENTAGE, "90")
.putBoolean(AppSettings.KEY_SMART_RESUME_WAIT, false)
.commit()
}

@Test
fun cachedResumeInStandardAndWebtoonReaders() = runBlocking {
for (mode in listOf(ReaderMode.STANDARD, ReaderMode.WEBTOON)) {
val fixture = fixture(mode, cachedSuccessor = true)
launch(fixture).use { scenario ->
val model = model(scenario)
awaitChapter(model, 30)
if (mode == ReaderMode.STANDARD) assertEquals(0, model.readingState.value?.page)
assertEquals(mode, model.readerMode.value)
assertEquals(30L, history.getOne(fixture.updated)?.chapterId)
delay(1000)
screenshot("smart-resume-${mode.name.lowercase()}")
}
}
}

@Test
fun cachedModeDoesNotJumpWhenDetailsArriveLater() = runBlocking {
val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = false, deferUpdate = true)
launch(fixture).use { scenario ->
val model = model(scenario)
awaitChapter(model, 29)
fixture.update.complete(Result.success(fixture.updated))
withTimeout(30_000) { model.mangaDetails.first { it?.isLoaded == true } }
assertEquals(29L, model.readingState.value?.chapterId)
}
}

@Test
fun waitingDefersContentAndHistoryUntilUpdate() = runBlocking {
setWaiting()
val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = false, deferUpdate = true)
launch(fixture).use { scenario ->
val model = model(scenario)
withTimeout(30_000) { model.readerMode.first { it != null } }
assertNull(model.readingState.value)
assertTrue(model.content.value.pages.isEmpty())
assertEquals(29, history.getOne(fixture.saved)?.chaptersCount)
fixture.update.complete(Result.success(fixture.updated))
awaitChapter(model, 30)
assertEquals(30L, history.getOne(fixture.updated)?.chapterId)
}
}

@Test
fun failedUpdateReleasesSavedChapter() = runBlocking {
setWaiting()
val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = false, deferUpdate = true)
launch(fixture).use { scenario ->
val model = model(scenario)
withTimeout(30_000) { model.readerMode.first { it != null } }
fixture.update.complete(Result.failure(IllegalStateException("Test update failed")))
awaitChapter(model, 29)
assertEquals(13, model.readingState.value?.page)
}
}

@Test
fun explicitPositionAndActivityRecreationStayExact() = runBlocking {
val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = true)
launch(fixture, ReaderState(29, 13, 0)).use { scenario ->
awaitChapter(model(scenario), 29)
scenario.recreate()
val model = model(scenario)
awaitChapter(model, 29)
assertEquals(13, model.readingState.value?.page)
}
}

@Test
fun incognitoResumeDoesNotWriteHistory() = runBlocking {
val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = true)
launch(fixture, incognito = true).use { scenario ->
awaitChapter(model(scenario), 30)
assertEquals(29L, history.getOne(fixture.updated)?.chapterId)
}
}

private fun setWaiting() {
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putBoolean(AppSettings.KEY_SMART_RESUME_WAIT, true).commit()
}

private suspend fun fixture(mode: ReaderMode, cachedSuccessor: Boolean, deferUpdate: Boolean = false): Fixture {
val id = -System.nanoTime()
val chapters = (1L..30L).map { chapterId ->
MangaChapter(
id = chapterId, title = "Chapter $chapterId", number = chapterId.toFloat(), volume = 0,
url = "https://example.invalid/$id/$chapterId", uploadDate = 0,
scanlator = null, branch = null, source = TestMangaSource,
)
}
// Include the previous chapter for normal backward metadata loading near the boundary.
for (chapter in chapters.takeLast(3)) {
val pages = (1..15).map { pageNumber ->
val image = File(context.cacheDir, "reader-test-${chapter.id}-$pageNumber.png")
val bitmap = Bitmap.createBitmap(600, 900, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(if (chapter.id == 30L) Color.rgb(205, 235, 220) else Color.rgb(220, 225, 245))
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; textSize = 38f }
canvas.drawText("Chapter ${chapter.id} / Page $pageNumber", 30f, 100f, paint)
image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
MangaPage(chapter.id * 100 + pageNumber, image.toURI().toString(), null, LocalMangaSource)
}
cache.putPages(TestMangaSource, chapter.url, SafeDeferred(CompletableDeferred(Result.success(pages))))
}
val updated = SampleData.mangaDetails.copy(
id = id, title = "Reader resume test", source = TestMangaSource,
url = "https://example.invalid/$id", publicUrl = "https://example.invalid/$id",
chapters = chapters, tags = emptySet(), contentRating = ContentRating.SAFE,
description = null, coverUrl = "", largeCoverUrl = null,
)
val saved = updated.copy(chapters = chapters.take(29))
history.addOrUpdate(saved, 29, 13, 0, 1f, force = true)
data.saveReaderMode(saved, mode)
if (cachedSuccessor) data.storeManga(updated, replaceExisting = true)
val update = CompletableDeferred<Result<Manga>>()
if (!deferUpdate) update.complete(Result.success(updated))
cache.putDetails(TestMangaSource, updated.url, SafeDeferred(update))
return Fixture(saved, updated, update)
}

private fun launch(fixture: Fixture, state: ReaderState? = null, incognito: Boolean = false): ActivityScenario<ReaderActivity> {
val builder = ReaderIntent.Builder(context).mangaId(fixture.saved.id)
if (state != null) builder.state(state)
if (incognito) builder.incognito()
return ActivityScenario.launch(builder.build().intent)
}

private fun model(scenario: ActivityScenario<ReaderActivity>): ReaderViewModel {
lateinit var model: ReaderViewModel
scenario.onActivity { model = ViewModelProvider(it)[ReaderViewModel::class.java] }
return model
}

private suspend fun awaitChapter(model: ReaderViewModel, id: Long) {
withTimeout(30_000) {
combine(model.content, model.readingState) { content, state ->
state?.chapterId == id && content.pages.any { it.chapterId == id }
}.first { it }
}
}

private fun screenshot(name: String) {
val image = instrumentation.uiAutomation.takeScreenshot() ?: return
File(context.getExternalFilesDir(null), "$name.png").outputStream().use {
image.compress(Bitmap.CompressFormat.PNG, 100, it)
}
image.recycle()
}

private data class Fixture(val saved: Manga, val updated: Manga, val update: CompletableDeferred<Result<Manga>>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package io.github.landwarderer.futon.settings

import android.content.Intent
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.landwarderer.futon.R
import io.github.landwarderer.futon.core.nav.AppRouter
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.prefs.ChapterCompletionMode
import io.github.landwarderer.futon.reader.initializeReaderTestWorkManager
import io.github.landwarderer.futon.settings.reader.SmartResumeThresholdDialog
import io.github.landwarderer.futon.settings.search.SettingsSearchHelper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class SmartResumeSettingsTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@Inject lateinit var settings: AppSettings
@Inject lateinit var searchHelper: SettingsSearchHelper

private val instrumentation = InstrumentationRegistry.getInstrumentation()

@Before
fun setUp() {
initializeReaderTestWorkManager(instrumentation.targetContext)
hiltRule.inject()
PreferenceManager.getDefaultSharedPreferences(instrumentation.targetContext).edit()
.remove(AppSettings.KEY_SMART_RESUME)
.remove(AppSettings.KEY_SMART_RESUME_MODE)
.remove(AppSettings.KEY_SMART_RESUME_PERCENTAGE)
.remove(AppSettings.KEY_SMART_RESUME_PAGES)
.remove(AppSettings.KEY_SMART_RESUME_WAIT)
.commit()
}

@Test
fun defaultsDependenciesAndIndependentValuesSurviveRecreation() {
launch().use { scenario ->
scenario.onActivity { activity ->
val fragment = readerSettings(activity)
assertFalse(settings.isSmartResumeEnabled)
assertFalse(settings.isSmartResumeWaitForUpdates)
assertEquals(90, settings.smartResumePercentage)
assertEquals(1, settings.smartResumePagesRemaining)
val mode = fragment.findPreference<ListPreference>(AppSettings.KEY_SMART_RESUME_MODE)!!
val percentage = fragment.findPreference<EditTextPreference>(AppSettings.KEY_SMART_RESUME_PERCENTAGE)!!
val pages = fragment.findPreference<EditTextPreference>(AppSettings.KEY_SMART_RESUME_PAGES)!!
assertFalse(mode.isEnabled)
assertTrue(percentage.isVisible)
assertFalse(pages.isVisible)
fragment.findPreference<SwitchPreferenceCompat>(AppSettings.KEY_SMART_RESUME)!!.isChecked = true
assertTrue(mode.isEnabled)
percentage.text = "95"
mode.value = ChapterCompletionMode.PAGES_REMAINING.name
assertFalse(percentage.isVisible)
assertTrue(pages.isVisible)
pages.text = "2"
mode.value = ChapterCompletionMode.PERCENTAGE.name
assertEquals("95", percentage.text)
assertEquals("2", pages.text)
}
scenario.recreate()
scenario.onActivity {
assertTrue(settings.isSmartResumeEnabled)
assertEquals(95, settings.smartResumePercentage)
assertEquals(2, settings.smartResumePagesRemaining)
}
}
}

@Test
fun numericDialogRejectsInvalidValuesWithoutClosing() {
launch().use { scenario ->
scenario.onActivity { activity ->
val fragment = readerSettings(activity)
fragment.findPreference<SwitchPreferenceCompat>(AppSettings.KEY_SMART_RESUME)!!.isChecked = true
fragment.onDisplayPreferenceDialog(
fragment.findPreference<Preference>(AppSettings.KEY_SMART_RESUME_PERCENTAGE)!!,
)
}
instrumentation.waitForIdleSync()
scenario.onActivity { activity ->
val dialog = activity.supportFragmentManager
.findFragmentByTag("androidx.preference.PreferenceFragment.DIALOG") as SmartResumeThresholdDialog
val alert = dialog.requireDialog() as AlertDialog
val editor = alert.findViewById<EditText>(android.R.id.edit)!!
for (invalid in listOf("", "0", "101", "-1", "1.5", "2147483648")) {
editor.setText(invalid)
alert.getButton(AlertDialog.BUTTON_POSITIVE).performClick()
assertTrue(alert.isShowing)
assertNotNull(editor.error)
assertEquals(90, settings.smartResumePercentage)
}
editor.setText("91")
alert.getButton(AlertDialog.BUTTON_POSITIVE).performClick()
assertEquals(91, settings.smartResumePercentage)
}
}
}

@Test
fun helpButtonIsAccessibleAndDoesNotToggleWaiting() {
launch().use { scenario ->
scenario.onActivity { readerSettings(it).scrollToPreference(AppSettings.KEY_SMART_RESUME_WAIT) }
instrumentation.waitForIdleSync()
scenario.onActivity { activity ->
val help = activity.findViewById<View>(R.id.preference_help)
assertNotNull(help)
assertTrue(help.isEnabled)
assertFalse(help.contentDescription.isNullOrBlank())
help.performClick()
assertFalse(settings.isSmartResumeWaitForUpdates)
}
}
}

@Test
fun thresholdSearchTargetsVisibleParentInBothModes() {
for (mode in ChapterCompletionMode.entries) {
PreferenceManager.getDefaultSharedPreferences(instrumentation.targetContext).edit()
.putString(AppSettings.KEY_SMART_RESUME_MODE, mode.name).commit()
val results = searchHelper.inflatePreferences().filter {
it.title == instrumentation.targetContext.getString(R.string.smart_resume_threshold)
}
assertEquals(1, results.size)
assertEquals(AppSettings.KEY_SMART_RESUME, results.single().key)
}
}

private fun launch(): ActivityScenario<SettingsActivity> = ActivityScenario.launch(
Intent(instrumentation.targetContext, SettingsActivity::class.java).setAction(AppRouter.ACTION_READER),
)

private fun readerSettings(activity: SettingsActivity) =
activity.supportFragmentManager.findFragmentById(R.id.container) as ReaderSettingsFragment
}
Loading
Loading