Compare commits

...

4 Commits

Author SHA1 Message Date
schroda
c0618fcc5c Try to keep cached images usable on manga rename (#2052) 2026-05-18 14:17:52 -04:00
schroda
9686f75a2d Fix/losing downloads on manga rename during update (#2051)
* Fix renaming manga download dir

* Simplify manga download dir rename function

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
2026-05-18 14:05:21 -04:00
schroda
4d5307f15b Fix/chapter list update preserving download state (#2050)
* Fix preserving chapter download state of deleted chapters

* Fix preserving chapter download state of updated chapters
2026-05-18 14:04:49 -04:00
Constantin Piber
779229a48a Fix tests (#2049)
* Fix test setup

* Fix tests

* Disable broken CloudflareTest

* Add a basic test for Android's Looper
2026-05-18 14:04:39 -04:00
15 changed files with 238 additions and 77 deletions

View File

@@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**WebUI**) Handle serving non-default webui with "bundled" - (**WebUI**) Handle serving non-default webui with "bundled"
- (**WebUI**) Wait until WebUI is ready to open in browser - (**WebUI**) Wait until WebUI is ready to open in browser
- (**Downloads**) Truncate filenames by byte length to prevent "File name too long" IO errors - (**Downloads**) Truncate filenames by byte length to prevent "File name too long" IO errors
- (**Downloads**) Fix being unable to find downloads after manga was renamed during an update
- (**Downloads**) Fix preserving chapter download states during an update
- (**Extension**) Do not indicate an update is available when the extension is not installed - (**Extension**) Do not indicate an update is available when the extension is not installed
- (**Chapter**) Fix losing chapter data on failed chapter list update - (**Chapter**) Fix losing chapter data on failed chapter list update
- (**Chapter**) Fix database error when fetching chapter updates - (**Chapter**) Fix database error when fetching chapter updates

View File

@@ -88,4 +88,6 @@ object SettingsRegistry {
fun get(name: String): SettingMetadata? = settings[name] fun get(name: String): SettingMetadata? = settings[name]
fun getAll(): Map<String, SettingMetadata> = settings.toMap() fun getAll(): Map<String, SettingMetadata> = settings.toMap()
fun clear() = settings.clear()
} }

View File

@@ -236,7 +236,7 @@ object Chapter {
val deletedChapterNumbers = TreeSet<Float>() val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>() val deletedReadChapterNumbers = TreeSet<Float>()
val deletedBookmarkedChapterNumbers = TreeSet<Float>() val deletedBookmarkedChapterNumbers = TreeSet<Float>()
val deletedDownloadedChapterNumberInfoMap = mutableMapOf<Float, MutableMap<String?, Int>>() val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>() val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList` // clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
@@ -247,13 +247,7 @@ object Chapter {
if (!chapterUrls.contains(dbChapter.url)) { if (!chapterUrls.contains(dbChapter.url)) {
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber) if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber) if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
if (dbChapter.downloaded) { if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
val pageCountByScanlator =
deletedDownloadedChapterNumberInfoMap.getOrPut(
dbChapter.chapterNumber,
) { mutableMapOf() }
pageCountByScanlator[dbChapter.scanlator] = dbChapter.pageCount
}
deletedChapterNumbers.add(dbChapter.chapterNumber) deletedChapterNumbers.add(dbChapter.chapterNumber)
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
dbChapter.id dbChapter.id
@@ -292,18 +286,24 @@ object Chapter {
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
// only preserve download status for chapters of the same scanlator, otherwise,
// the downloaded files won't be found anyway
val downloadedChapterInfo = deletedDownloadedChapterNumberInfoMap[chapter.chapterNumber]
val pageCount = downloadedChapterInfo?.get(chapter.scanlator)
if (pageCount != null) {
this[ChapterTable.isDownloaded] = true
this[ChapterTable.pageCount] = pageCount
}
// Try to use the fetch date of the original entry to not pollute 'Updates' tab // Try to use the fetch date of the original entry to not pollute 'Updates' tab
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let { deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
this[ChapterTable.fetchedAt] = it this[ChapterTable.fetchedAt] = it
} }
val deletedChapter = deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]!!
val hasDownloadedPages = deletedChapter.pageCount > 0
val isSameName = deletedChapter.name == chapter.name
val isSameScanlator = deletedChapter.scanlator == chapter.scanlator
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
// the downloaded files won't be found anyway
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
if (isDownloadPreservable) {
this[ChapterTable.isDownloaded] = true
this[ChapterTable.pageCount] = deletedChapter.pageCount
}
} }
}.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) } }.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) }
} }
@@ -313,12 +313,30 @@ object Chapter {
.apply { .apply {
chaptersToUpdate.forEach { chaptersToUpdate.forEach {
addBatch(EntityID(it.id, ChapterTable)) addBatch(EntityID(it.id, ChapterTable))
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
this[ChapterTable.name] = it.name this[ChapterTable.name] = it.name
this[ChapterTable.date_upload] = it.uploadDate this[ChapterTable.date_upload] = it.uploadDate
this[ChapterTable.chapter_number] = it.chapterNumber this[ChapterTable.chapter_number] = it.chapterNumber
this[ChapterTable.scanlator] = it.scanlator this[ChapterTable.scanlator] = it.scanlator
this[ChapterTable.sourceOrder] = it.index this[ChapterTable.sourceOrder] = it.index
this[ChapterTable.realUrl] = it.realUrl this[ChapterTable.realUrl] = it.realUrl
this[ChapterTable.isDownloaded] = currentChapter.downloaded
this[ChapterTable.pageCount] = currentChapter.pageCount
if (!currentChapter.downloaded) {
return@forEach
}
val isSameScanlator = currentChapter.scanlator == it.scanlator
val isSameName = currentChapter.name == it.name
val isDownloadPreservable = isSameName && isSameScanlator
if (!isDownloadPreservable) {
this[ChapterTable.isDownloaded] = false
this[ChapterTable.pageCount] = -1
}
} }
}.toExecutable() }.toExecutable()
.execute(this@transaction) .execute(this@transaction)

View File

@@ -133,7 +133,7 @@ object Manga {
"" ""
} }
if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) { if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, remoteTitle) val canUpdateTitle = updateMangaDownloadDir(mangaEntry[MangaTable.title], source.toString(), remoteTitle)
if (canUpdateTitle) { if (canUpdateTitle) {
it[MangaTable.title] = remoteTitle it[MangaTable.title] = remoteTitle

View File

@@ -24,14 +24,22 @@ private val applicationDirs: ApplicationDirs by injectLazy()
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
private fun getMangaDir(
title: String,
sourceName: String,
): String {
val sourceDir = SafePath.buildValidFilename(sourceName)
val mangaDir = SafePath.buildValidFilename(title)
return "$sourceDir/$mangaDir"
}
private fun getMangaDir(mangaId: Int): String = private fun getMangaDir(mangaId: Int): String =
transaction { transaction {
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sourceDir = SafePath.buildValidFilename(source.toString()) getMangaDir(mangaEntry[MangaTable.title], source.toString())
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
"$sourceDir/$mangaDir"
} }
private fun getChapterDir( private fun getChapterDir(
@@ -62,8 +70,18 @@ private fun getChapterDir(
fun getThumbnailDownloadPath(mangaId: Int): String = applicationDirs.thumbnailDownloadsRoot + "/$mangaId" fun getThumbnailDownloadPath(mangaId: Int): String = applicationDirs.thumbnailDownloadsRoot + "/$mangaId"
fun getMangaDownloadDir(
title: String,
sourceName: String,
): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(title, sourceName)
fun getMangaDownloadDir(mangaId: Int): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(mangaId) fun getMangaDownloadDir(mangaId: Int): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(mangaId)
fun getMangaCacheDir(
title: String,
sourceName: String,
): String = applicationDirs.tempMangaCacheRoot + "/" + getMangaDir(title, sourceName)
fun getChapterDownloadPath( fun getChapterDownloadPath(
mangaId: Int, mangaId: Int,
chapterId: Int, chapterId: Int,
@@ -79,38 +97,21 @@ fun getChapterCachePath(
chapterId: Int, chapterId: Int,
): String = applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId) ): String = applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId)
/** return value says if rename/move was successful */ private fun updateDownloadDir(
fun updateMangaDownloadDir( currentDir: String,
mangaId: Int, newDir: String,
newTitle: String,
): Boolean { ): Boolean {
// Get current manga directory (uses its own transaction) val currentDirFile = File(currentDir)
val currentMangaDir = getMangaDir(mangaId)
// Build new directory path
val newMangaDir =
transaction {
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sourceDir = SafePath.buildValidFilename(source.toString())
val newMangaDirName = SafePath.buildValidFilename(newTitle)
"$sourceDir/$newMangaDirName"
}
val oldDir = "${applicationDirs.downloadsRoot}/$currentMangaDir"
val newDir = "${applicationDirs.downloadsRoot}/$newMangaDir"
val oldDirFile = File(oldDir)
val newDirFile = File(newDir) val newDirFile = File(newDir)
if (!oldDirFile.exists()) { if (!currentDirFile.exists()) {
return true return true
} }
return try { return try {
Files.move(oldDirFile.toPath(), newDirFile.toPath()) Files.move(currentDirFile.toPath(), newDirFile.toPath())
if (oldDirFile.exists()) { if (currentDirFile.exists()) {
return false return false
} }
@@ -118,9 +119,31 @@ fun updateMangaDownloadDir(
return false return false
} }
true return true
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "updateMangaDownloadDir: failed to rename manga download folder from \"$oldDir\" to \"$newDir\"" } logger.error(e) { "updateDownloadDir: failed to rename download folder from \"$currentDir\" to \"$newDir\"" }
false false
} }
} }
/** return value says if rename/move was successful */
fun updateMangaDownloadDir(
title: String,
sourceName: String,
newTitle: String,
): Boolean {
val currentDownloadDir = getMangaDownloadDir(title, sourceName)
val newDownloadDir = getMangaDownloadDir(newTitle, sourceName)
val renamed = updateDownloadDir(currentDownloadDir, newDownloadDir)
val tryToKeepCachedFilesUsable = renamed
if (tryToKeepCachedFilesUsable) {
val currentCacheDir = getMangaCacheDir(title, sourceName)
val newCacheDir = getMangaCacheDir(newTitle, sourceName)
updateDownloadDir(currentCacheDir, newCacheDir)
}
return renamed
}

View File

@@ -7,12 +7,21 @@ package suwayomi.tachidesk.manga.model.dataclass
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import java.util.Objects
import kotlin.math.min import kotlin.math.min
open class PaginatedList<T>( open class PaginatedList<T>(
val page: List<T>, val page: List<T>,
val hasNextPage: Boolean, val hasNextPage: Boolean,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PaginatedList<T>) return false
return page == other.page && hasNextPage == other.hasNextPage
}
override fun hashCode(): Int = Objects.hash(page, hasNextPage)
}
const val PAGINATION_FACTOR = 50 const val PAGINATION_FACTOR = 50

View File

@@ -22,7 +22,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.config4k.toConfig import io.github.config4k.toConfig
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.json.JavalinJackson import io.javalin.json.JavalinJackson3
import io.javalin.json.JsonMapper import io.javalin.json.JsonMapper
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -222,7 +222,7 @@ fun serverModule(applicationDirs: ApplicationDirs): Module =
module { module {
single { applicationDirs } single { applicationDirs }
single<IUpdater> { Updater() } single<IUpdater> { Updater() }
single<JsonMapper> { JavalinJackson() } single<JsonMapper> { JavalinJackson3() }
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)

View File

@@ -144,9 +144,10 @@ object DBManager {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
fun databaseUp() { fun databaseUp(givenDb: Database? = null) {
val db = val db =
try { givenDb
?: try {
DBManager.setupDatabase() DBManager.setupDatabase()
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Failed to setup Database" } logger.error(e) { "Failed to setup Database" }

View File

@@ -1,17 +1,22 @@
package masstest package masstest
import android.os.Looper
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.koin.core.context.stopKoin
import suwayomi.tachidesk.manga.impl.Source import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.server.applicationSetup import suwayomi.tachidesk.server.applicationSetup
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.test.BASE_PATH import suwayomi.tachidesk.test.BASE_PATH
import suwayomi.tachidesk.test.setLoggingEnabled import suwayomi.tachidesk.test.setLoggingEnabled
import xyz.nulldev.ts.config.CONFIG_PREFIX import xyz.nulldev.ts.config.CONFIG_PREFIX
@@ -25,8 +30,11 @@ class CloudFlareTest {
fun setup() { fun setup() {
val dataRoot = File(BASE_PATH).absolutePath val dataRoot = File(BASE_PATH).absolutePath
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot) System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
Looper.clearMainLooperForTest()
SettingsRegistry.clear()
applicationSetup() applicationSetup()
setLoggingEnabled(false) setLoggingEnabled(false)
return
runBlocking { runBlocking {
val extensions = ExtensionsList.getExtensionList() val extensions = ExtensionsList.getExtensionList()
@@ -48,9 +56,15 @@ class CloudFlareTest {
setLoggingEnabled(true) setLoggingEnabled(true)
} }
@AfterAll
fun teardown() {
stopKoin()
}
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@Test @Test
@Disabled
fun `test nhentai browse`() = fun `test nhentai browse`() =
runTest { runTest {
assert(nhentai.getPopularManga(1).mangas.isNotEmpty()) { assert(nhentai.getPopularManga(1).mangas.isNotEmpty()) {

View File

@@ -7,6 +7,7 @@ package masstest
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import android.os.Looper
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
@@ -17,9 +18,11 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.koin.core.context.stopKoin
import suwayomi.tachidesk.manga.impl.Source.getSourceList import suwayomi.tachidesk.manga.impl.Source.getSourceList
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
@@ -28,6 +31,7 @@ import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.server.applicationSetup import suwayomi.tachidesk.server.applicationSetup
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.test.BASE_PATH import suwayomi.tachidesk.test.BASE_PATH
import suwayomi.tachidesk.test.setLoggingEnabled import suwayomi.tachidesk.test.setLoggingEnabled
import xyz.nulldev.ts.config.CONFIG_PREFIX import xyz.nulldev.ts.config.CONFIG_PREFIX
@@ -51,6 +55,8 @@ class TestExtensionCompatibility {
fun setup() { fun setup() {
val dataRoot = File(BASE_PATH).absolutePath val dataRoot = File(BASE_PATH).absolutePath
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot) System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
Looper.clearMainLooperForTest()
SettingsRegistry.clear()
applicationSetup() applicationSetup()
setLoggingEnabled(false) setLoggingEnabled(false)
@@ -72,12 +78,22 @@ class TestExtensionCompatibility {
} }
} }
} }
sources = getSourceList().map { getCatalogueSourceOrNull(it.id.toLong())!! as HttpSource } sources =
getSourceList()
.filter {
// filter local source
it.id.toLong() != 0L
}.map { getCatalogueSourceOrNull(it.id.toLong())!! as HttpSource }
} }
setLoggingEnabled(true) setLoggingEnabled(true)
File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" }) File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
} }
@AfterAll
fun teardown() {
stopKoin()
}
@Test @Test
fun runTest() { fun runTest() {
runBlocking(Dispatchers.Default) { runBlocking(Dispatchers.Default) {

View File

@@ -0,0 +1,56 @@
package suwayomi.tachidesk
import android.os.Handler
import android.os.Looper
import android.os.Message
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import kotlin.text.StringBuilder
class LooperThread : Thread() {
var mHandler: Handler? = null
val latch = CountDownLatch(1)
override fun run() {
Looper.prepare()
mHandler = Handler(Looper.myLooper())
latch.countDown()
Looper.loop()
}
}
class LooperTest {
@Test
fun multiplePostWork() {
val thread = LooperThread()
thread.start()
val sb = StringBuilder()
val latch = CountDownLatch(1)
assertTrue(thread.latch.await(5, TimeUnit.SECONDS))
thread.mHandler!!.post {
Thread.sleep(100)
sb.append("a_b_c")
}
thread.mHandler!!.post {
Thread.sleep(100)
sb.append("_d_e_f")
}
thread.mHandler!!.post {
Thread.sleep(100)
sb.append("_g_h_i")
latch.countDown()
}
assertNotEquals("a_b_c_d_e_f_g_h_i", sb.toString())
assertTrue(latch.await(5, TimeUnit.SECONDS))
assertEquals("a_b_c_d_e_f_g_h_i", sb.toString())
thread.mHandler!!.looper.quit()
// thread.join()
}
}

View File

@@ -18,19 +18,22 @@ import suwayomi.tachidesk.test.clearTables
class CategoryControllerTest : ApplicationTest() { class CategoryControllerTest : ApplicationTest() {
@Test @Test
fun categoryReorder() { fun categoryReorder() {
clearTables(
CategoryTable,
)
Category.createCategory("foo") Category.createCategory("foo")
Category.createCategory("bar") Category.createCategory("bar")
val cats = Category.getCategoryList() val cats = Category.getCategoryList()
val foo = cats.asSequence().filter { it.name == "foo" }.first() val foo = cats.asSequence().filter { it.name == "foo" }.first()
val bar = cats.asSequence().filter { it.name == "bar" }.first() val bar = cats.asSequence().filter { it.name == "bar" }.first()
assertEquals(1, foo.order) assertEquals(0, foo.order)
assertEquals(2, bar.order) assertEquals(1, bar.order)
Category.reorderCategory(1, 2) Category.reorderCategory(1, 2)
val catsReordered = Category.getCategoryList() val catsReordered = Category.getCategoryList()
val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first() val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first()
val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first() val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first()
assertEquals(2, fooReordered.order) assertEquals(1, fooReordered.order)
assertEquals(1, barReordered.order) assertEquals(0, barReordered.order)
} }
@AfterEach @AfterEach

View File

@@ -35,7 +35,7 @@ class CategoryMangaTest : ApplicationTest() {
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
"Manga should not have any unread chapters", "Manga should not have any unread chapters",
) )
createChapters(mangaId, 10, false) createChapters(mangaId, 10, false, start = 11)
assertEquals( assertEquals(
10, 10,
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,

View File

@@ -12,9 +12,12 @@ import eu.kanade.tachiyomi.createAppModule
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.core.ExperimentalKeywordApi
import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.Database
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import suwayomi.tachidesk.server.JavalinSetup import suwayomi.tachidesk.server.JavalinSetup
import suwayomi.tachidesk.server.ServerConfig import suwayomi.tachidesk.server.ServerConfig
@@ -22,7 +25,9 @@ import suwayomi.tachidesk.server.androidCompat
import suwayomi.tachidesk.server.database.databaseUp import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.serverModule import suwayomi.tachidesk.server.serverModule
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
import suwayomi.tachidesk.server.util.SystemTray import suwayomi.tachidesk.server.util.SystemTray
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@@ -55,6 +60,13 @@ open class ApplicationTest {
private var initializedTheApp = false private var initializedTheApp = false
fun testingSetup() { fun testingSetup() {
// register Tachidesk's config which is dubbed "ServerConfig"
SettingsRegistry.clear()
ConfigTypeRegistration.registerCustomTypes()
GlobalConfigManager.registerModule(
ServerConfig.register { GlobalConfigManager.config },
)
// Application dirs // Application dirs
val applicationDirs = ApplicationDirs() val applicationDirs = ApplicationDirs()
@@ -72,13 +84,9 @@ open class ApplicationTest {
File(it).mkdirs() File(it).mkdirs()
} }
// register Tachidesk's config which is dubbed "ServerConfig"
GlobalConfigManager.registerModule(
ServerConfig.register { GlobalConfigManager.config },
)
// initialize Koin modules // initialize Koin modules
val app = App() val app = App()
stopKoin()
startKoin { startKoin {
modules( modules(
createAppModule(app), createAppModule(app),
@@ -128,14 +136,14 @@ open class ApplicationTest {
} }
// create system tray // create system tray
if (serverConfig.systemTrayEnabled.value) { // if (serverConfig.systemTrayEnabled.value) {
try { // try {
SystemTray.create() // SystemTray.create()
} catch (e: Throwable) { // } catch (e: Throwable) {
// cover both java.lang.Exception and java.lang.Error // // cover both java.lang.Exception and java.lang.Error
e.printStackTrace() // e.printStackTrace()
} // }
} // }
// Disable jetty's logging // Disable jetty's logging
System.setProperty("org.eclipse.jetty.util.log.announce", "false") System.setProperty("org.eclipse.jetty.util.log.announce", "false")
@@ -154,8 +162,16 @@ open class ApplicationTest {
// fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase() // fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
Locale.setDefault(Locale.ENGLISH) Locale.setDefault(Locale.ENGLISH)
val dbConfig =
DatabaseConfig {
useNestedTransactions = true
@OptIn(ExperimentalKeywordApi::class)
preserveKeywordCasing = false
defaultSchema = null
}
// in-memory database, don't discard database between connections/transactions // in-memory database, don't discard database between connections/transactions
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver") val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver", databaseConfig = dbConfig)
databaseUp(db) databaseUp(db)

View File

@@ -55,8 +55,9 @@ fun createChapters(
mangaId: Int, mangaId: Int,
amount: Int, amount: Int,
read: Boolean, read: Boolean,
start: Int = 1,
) { ) {
val list = listOf((0 until amount)).flatten().map { 1 } val list = listOf((0 until amount)).flatten().map { it + start }
transaction { transaction {
ChapterTable ChapterTable
.batchInsert(list) { .batchInsert(list) {